Skip to content

Commit cd6a643

Browse files
Refactor Python core with explicit contracts and stronger test coverage
1 parent 870cc08 commit cd6a643

4 files changed

Lines changed: 163 additions & 36 deletions

File tree

ml_vix_model.py

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
from __future__ import annotations
2+
13
import os
24
import re
5+
from dataclasses import dataclass
36
from io import StringIO
7+
from typing import Mapping, Sequence
48

59
import pandas as pd
610
import requests
@@ -16,6 +20,22 @@
1620
TABLE_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*){2}$")
1721

1822

23+
@dataclass(frozen=True)
24+
class TrainingResult:
25+
mse: float
26+
train_rows: int
27+
test_rows: int
28+
predicted_close_for_input: float
29+
30+
def to_dict(self) -> dict[str, float | int]:
31+
return {
32+
"mse": self.mse,
33+
"train_rows": self.train_rows,
34+
"test_rows": self.test_rows,
35+
"predicted_close_for_input": self.predicted_close_for_input,
36+
}
37+
38+
1939
class DataProcessor:
2040
def __init__(self, url: str):
2141
self.url = url
@@ -47,7 +67,7 @@ def _validate_table_name(table_name: str) -> str:
4767
return table_name
4868

4969

50-
def create_temp_table(conn, table_name: str) -> None:
70+
def create_temp_table(conn: object, table_name: str) -> None:
5171
table = _validate_table_name(table_name)
5272
with conn.cursor() as cur:
5373
cur.execute(f"DROP TABLE IF EXISTS {table}")
@@ -57,7 +77,7 @@ def create_temp_table(conn, table_name: str) -> None:
5777
)
5878

5979

60-
def insert_data_to_temp_table(conn, df: pd.DataFrame, table_name: str) -> None:
80+
def insert_data_to_temp_table(conn: object, df: pd.DataFrame, table_name: str) -> None:
6181
table = _validate_table_name(table_name)
6282
with conn.cursor() as cur:
6383
payload = df.copy()
@@ -66,16 +86,53 @@ def insert_data_to_temp_table(conn, df: pd.DataFrame, table_name: str) -> None:
6686
cur.executemany(f"INSERT INTO {table} VALUES (%s, %s, %s)", rows)
6787

6888

69-
def fetch_data_from_temp_table(conn, table_name: str) -> pd.DataFrame:
89+
def fetch_data_from_temp_table(conn: object, table_name: str) -> pd.DataFrame:
7090
table = _validate_table_name(table_name)
7191
with conn.cursor() as cur:
7292
cur.execute(f"SELECT * FROM {table}")
7393
rows = cur.fetchall()
7494
return pd.DataFrame(rows, columns=["DATE", "CLOSE_PRICE", "VOLATILITY_INDEX"])
7595

7696

77-
def run_training_and_generate_results() -> dict:
97+
def _fit_and_evaluate(
98+
features: pd.DataFrame, targets: pd.Series, predict_for: float
99+
) -> TrainingResult:
100+
if features.empty or targets.empty:
101+
raise ValueError("Training data is empty. Cannot fit model.")
102+
103+
X_train, X_test, y_train, y_test = train_test_split(
104+
features, targets, test_size=0.2, random_state=42
105+
)
106+
if X_train.empty or X_test.empty:
107+
raise ValueError("Insufficient rows after train-test split.")
108+
109+
model = LinearRegression()
110+
model.fit(X_train, y_train)
111+
112+
predictions = model.predict(X_test)
113+
mse = mean_squared_error(y_test, predictions)
114+
single_prediction = float(model.predict(pd.DataFrame({"VOLATILITY_INDEX": [predict_for]}))[0])
115+
116+
return TrainingResult(
117+
mse=float(mse),
118+
train_rows=int(len(X_train)),
119+
test_rows=int(len(X_test)),
120+
predicted_close_for_input=single_prediction,
121+
)
122+
123+
124+
def _build_report(result: TrainingResult) -> str:
125+
return (
126+
"Our model predicts VIX CLOSE_PRICE from VOLATILITY_INDEX. "
127+
f"MSE on unseen data: {result.mse:.4f}. "
128+
f"Train rows: {result.train_rows}, Test rows: {result.test_rows}. "
129+
f"Sample prediction @ VOLATILITY_INDEX=0.40: {result.predicted_close_for_input:.2f}."
130+
)
131+
132+
133+
def run_training_and_generate_results() -> Mapping[str, float | int]:
78134
table_name = os.getenv("SNOWFLAKE_TEMP_TABLE", DEFAULT_TABLE)
135+
prediction_input = float(os.getenv("VIX_PREDICTION_INPUT", "0.40"))
79136
conn = get_snowflake_connection()
80137
try:
81138
data_processor = DataProcessor(VIX_DATA_URL)
@@ -86,23 +143,13 @@ def run_training_and_generate_results() -> dict:
86143
insert_data_to_temp_table(conn, df, table_name=table_name)
87144
fetched_df = fetch_data_from_temp_table(conn, table_name=table_name)
88145

89-
X, y = fetched_df[["VOLATILITY_INDEX"]], fetched_df["CLOSE_PRICE"]
90-
X_train, X_test, y_train, y_test = train_test_split(
91-
X, y, test_size=0.2, random_state=42
92-
)
93-
94-
model = LinearRegression()
95-
model.fit(X_train, y_train)
96-
97-
predictions = model.predict(X_test)
98-
mse = mean_squared_error(y_test, predictions)
99-
100-
report = (
101-
"Our model predicts VIX CLOSE_PRICE from VOLATILITY_INDEX. "
102-
f"The Mean Squared Error (MSE) on unseen test data is {mse:.4f}."
146+
result = _fit_and_evaluate(
147+
features=fetched_df[["VOLATILITY_INDEX"]],
148+
targets=fetched_df["CLOSE_PRICE"],
149+
predict_for=prediction_input,
103150
)
104-
print(report)
105-
return {"mse": float(mse), "train_rows": int(len(X_train)), "test_rows": int(len(X_test))}
151+
print(_build_report(result))
152+
return result.to_dict()
106153
finally:
107154
conn.close()
108155

snowflake_connection.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,39 @@
1+
from __future__ import annotations
2+
13
import configparser
24
import os
5+
from dataclasses import dataclass
36
from pathlib import Path
4-
from typing import Dict
7+
from typing import Any, Dict, Mapping
58

69
import snowflake.connector
10+
from snowflake.connector.connection import SnowflakeConnection
711

812

913
REQUIRED_FIELDS = ("user", "password", "account", "warehouse", "database", "schema")
1014

1115

12-
def _load_from_config(config_path: Path) -> Dict[str, str]:
16+
@dataclass(frozen=True)
17+
class SnowflakeSettings:
18+
user: str
19+
password: str
20+
account: str
21+
warehouse: str
22+
database: str
23+
schema: str
24+
25+
def to_connect_kwargs(self) -> Dict[str, str]:
26+
return {
27+
"user": self.user,
28+
"password": self.password,
29+
"account": self.account,
30+
"warehouse": self.warehouse,
31+
"database": self.database,
32+
"schema": self.schema,
33+
}
34+
35+
36+
def _load_from_config(config_path: Path) -> Mapping[str, str]:
1337
parser = configparser.ConfigParser()
1438
if not config_path.exists():
1539
return {}
@@ -19,7 +43,7 @@ def _load_from_config(config_path: Path) -> Dict[str, str]:
1943
return {key: parser["snowflake"].get(key, "") for key in REQUIRED_FIELDS}
2044

2145

22-
def _load_from_env() -> Dict[str, str]:
46+
def _load_from_env() -> Mapping[str, str]:
2347
return {
2448
"user": os.getenv("SNOWFLAKE_USER", ""),
2549
"password": os.getenv("SNOWFLAKE_PASSWORD", ""),
@@ -30,7 +54,7 @@ def _load_from_env() -> Dict[str, str]:
3054
}
3155

3256

33-
def get_connection_params(config_path: str | None = None) -> Dict[str, str]:
57+
def get_connection_params(config_path: str | None = None) -> SnowflakeSettings:
3458
config_file = Path(
3559
config_path or os.getenv("SNOWFLAKE_CONFIG_PATH", "config/snowflake_config.ini")
3660
)
@@ -49,16 +73,11 @@ def get_connection_params(config_path: str | None = None) -> Dict[str, str]:
4973
f"values in {config_file}."
5074
)
5175

52-
return merged
76+
return SnowflakeSettings(**merged)
5377

5478

55-
def get_snowflake_connection(config_path: str | None = None):
79+
def get_snowflake_connection(
80+
config_path: str | None = None, **connector_kwargs: Any
81+
) -> SnowflakeConnection:
5682
params = get_connection_params(config_path=config_path)
57-
return snowflake.connector.connect(
58-
user=params["user"],
59-
password=params["password"],
60-
account=params["account"],
61-
warehouse=params["warehouse"],
62-
database=params["database"],
63-
schema=params["schema"],
64-
)
83+
return snowflake.connector.connect(**params.to_connect_kwargs(), **connector_kwargs)

test_ml_vix_model.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pandas as pd
44
import pytest
55

6-
from ml_vix_model import DataProcessor, _validate_table_name
6+
from ml_vix_model import DataProcessor, _fit_and_evaluate, _validate_table_name
77

88

99
def test_fetch_data_from_url_parses_required_columns():
@@ -42,3 +42,22 @@ def test_validate_table_name_accepts_fully_qualified_name():
4242
def test_validate_table_name_rejects_invalid_name():
4343
with pytest.raises(ValueError):
4444
_validate_table_name("raw.temp_table")
45+
46+
47+
def test_fit_and_evaluate_returns_expected_shape():
48+
features = pd.DataFrame({"VOLATILITY_INDEX": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
49+
targets = pd.Series([20, 25, 30, 35, 40, 45], name="CLOSE_PRICE")
50+
result = _fit_and_evaluate(features=features, targets=targets, predict_for=0.4)
51+
assert result.train_rows > 0
52+
assert result.test_rows > 0
53+
assert result.mse >= 0
54+
assert isinstance(result.predicted_close_for_input, float)
55+
56+
57+
def test_fit_and_evaluate_raises_for_empty_data():
58+
with pytest.raises(ValueError):
59+
_fit_and_evaluate(
60+
features=pd.DataFrame({"VOLATILITY_INDEX": []}),
61+
targets=pd.Series([], dtype=float, name="CLOSE_PRICE"),
62+
predict_for=0.4,
63+
)

test_snowflake_connection.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,50 @@
11
import os
2+
from unittest.mock import patch
23

34
import pytest
45

5-
from snowflake_connection import get_snowflake_connection
6+
from snowflake_connection import get_connection_params, get_snowflake_connection
7+
8+
9+
def test_get_connection_params_prefers_env(monkeypatch):
10+
monkeypatch.setenv("SNOWFLAKE_USER", "u")
11+
monkeypatch.setenv("SNOWFLAKE_PASSWORD", "p")
12+
monkeypatch.setenv("SNOWFLAKE_ACCOUNT", "a")
13+
monkeypatch.setenv("SNOWFLAKE_WAREHOUSE", "w")
14+
monkeypatch.setenv("SNOWFLAKE_DATABASE", "d")
15+
monkeypatch.setenv("SNOWFLAKE_SCHEMA", "s")
16+
params = get_connection_params(config_path="config/does-not-exist.ini")
17+
assert params.user == "u"
18+
assert params.database == "d"
19+
20+
21+
def test_get_connection_params_missing_values_raises(monkeypatch):
22+
for key in (
23+
"SNOWFLAKE_USER",
24+
"SNOWFLAKE_PASSWORD",
25+
"SNOWFLAKE_ACCOUNT",
26+
"SNOWFLAKE_WAREHOUSE",
27+
"SNOWFLAKE_DATABASE",
28+
"SNOWFLAKE_SCHEMA",
29+
):
30+
monkeypatch.delenv(key, raising=False)
31+
32+
with pytest.raises(ValueError):
33+
get_connection_params(config_path="config/does-not-exist.ini")
34+
35+
36+
def test_get_snowflake_connection_passes_connector_kwargs(monkeypatch):
37+
monkeypatch.setenv("SNOWFLAKE_USER", "u")
38+
monkeypatch.setenv("SNOWFLAKE_PASSWORD", "p")
39+
monkeypatch.setenv("SNOWFLAKE_ACCOUNT", "a")
40+
monkeypatch.setenv("SNOWFLAKE_WAREHOUSE", "w")
41+
monkeypatch.setenv("SNOWFLAKE_DATABASE", "d")
42+
monkeypatch.setenv("SNOWFLAKE_SCHEMA", "s")
43+
44+
with patch("snowflake_connection.snowflake.connector.connect") as connect_mock:
45+
get_snowflake_connection(login_timeout=10)
46+
connect_mock.assert_called_once()
47+
assert connect_mock.call_args.kwargs["login_timeout"] == 10
648

749

850
@pytest.mark.skipif(

0 commit comments

Comments
 (0)