1. Promise
One-sentence promise
After this lecture, you can structure a quantitative modelling project with clean interfaces, separation of concerns, and configuration-driven runs that produce reproducible artefacts.
End tag line
Engineering is what makes results trustworthy.
2. Learning objectives
After reading this lecture, you will be able to:
- Explain why
fit() / predict() / score()is a minimum interface for models. - Separate code into data, features, model, evaluation, and reporting layers.
- Use configuration-driven runs so results are reproducible and comparable.
- Produce an “evidence pack” that a validator can re-run without you.
- Avoid typical failure modes: hidden parameters, hard-coded paths, ad-hoc notebooks.
3. Conceptual core
3.1 Clear interfaces: fit() / predict() / score()
A model in a quantitative system is not “an object with weights”. It is a component with a contract.
Minimum contract:
fit(train_data, config) -> selfpredict(data, config) -> predictionsscore(data, config) -> metrics
A good interface ensures:
- testability: you can unit-test each method.
- swappability: you can replace models without rewriting evaluation.
- auditability: you can explain inputs and outputs cleanly.
Validator sentence
If a model cannot be re-fit and re-scored from an explicit interface, it is not a model artefact. It is a notebook result.
3.2 Separation of concerns: data, features, model, evaluation, reporting
A reliable project has layers.
Data layer - reads raw sources, - aligns time index, - applies filters, - outputs clean tables.
Features layer - transforms data into feature matrix , - creates targets , - enforces time-consistency (no look-ahead), - stores feature definitions as code.
Model layer - implements the interface, - contains training logic, - contains prediction logic, - no file I/O and no plotting.
Evaluation layer - defines splits (rolling/expanding), - calls fit/predict/score, - aggregates metrics, - performs stability checks.
Reporting layer - generates plots, - writes metric tables, - writes a model card summary, - produces a run directory.
Design rule
Data and evaluation move fast. The model must be stable and boring.
3.3 Configuration-driven runs (YAML/JSON/args)
If parameters live in code, your results are not comparable.
A configuration file should define:
- target name and horizon,
- universe and filters,
- feature set version,
- model type and hyperparameters,
- split protocol,
- metrics,
- random seed,
- output paths and run IDs.
Minimum reproducibility contract
A run is defined by:
- config snapshot,
- code version (commit hash),
- data ID (or snapshot path),
- seed,
- environment (optional, but ideal).
Validator sentence
A configuration is part of the model. It must be versioned and shipped.
4. Suggested folder structure (simple and defensible)
project/
src/
data.py
features.py
model.py
evaluation.py
reporting.py
runner.py
configs/
baseline.json
ridge_v0.json
runs/
2026-02-08_1530_run001/
config.json
metrics.json
fold_metrics.csv
plots/
rmse_by_fold.png
notes.md
5. Common failure modes (and corrections)
- Notebook-only workflow
- Correction: keep notebooks as consumers of
src/functions.
- Hidden parameters
- Correction: all parameters in config; code reads config.
- Mixed responsibilities
- Correction: model code never loads CSVs and never plots.
- One-off evaluation
- Correction: evaluation is a function you can run for any model.
- No run artefacts
- Correction: every run writes config + metrics + plots to a run folder.
6. Control lens
In control systems, you separate:
- plant model,
- estimator,
- controller,
- simulator,
- logging.
Finance modelling is the same discipline.
If you mix them, you cannot debug failures.
7. Python lab (minimal end-to-end structured run)
This lab shows:
- a model class with
fit/predict/score, - separated data/features/evaluation/reporting functions,
- config-driven run using JSON,
- run artefacts written to a folder.
It uses synthetic time series data so it runs instantly.
import json
import os
from dataclasses import dataclass
from datetime import datetime
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# -----------------------------
# 1) Data layer
# -----------------------------
def make_synthetic_series(T: int, seed: int) -> pd.DataFrame:
rng = np.random.default_rng(seed)
t = np.arange(T)
# weak persistence + seasonality + regime change noise
season = 0.4 * np.sin(2 * np.pi * t / 5)
sigma = np.ones(T) * 0.7
sigma[T // 2:] = 1.4
y = np.zeros(T)
for k in range(1, T):
y[k] = 0.25 * y[k - 1] + season[k] + sigma[k] * rng.normal()
idx = pd.date_range("2024-01-01", periods=T, freq="B")
return pd.DataFrame({"y": y}, index=idx)
# -----------------------------
# 2) Feature layer
# -----------------------------
def build_features(df: pd.DataFrame, cfg: dict) -> pd.DataFrame:
d = df.copy()
lags = cfg["features"]["lags"]
for L in lags:
d[f"lag{L}"] = d["y"].shift(L)
# target horizon
h = cfg["target"]["horizon"]
d["y_target"] = d["y"].shift(-h)
# drop NA created by lags/target shift
d = d.dropna().copy()
return d
# -----------------------------
# 3) Model layer (fit/predict/score)
# -----------------------------
@dataclass
class OLSModel:
feature_cols: list
beta: np.ndarray | None = None
def fit(self, train: pd.DataFrame) -> "OLSModel":
X = np.column_stack([np.ones(len(train))] + [train[c].values for c in self.feature_cols])
y = train["y_target"].values
self.beta = np.linalg.lstsq(X, y, rcond=None)[0]
return self
def predict(self, data: pd.DataFrame) -> np.ndarray:
if self.beta is None:
raise RuntimeError("Model is not fitted.")
X = np.column_stack([np.ones(len(data))] + [data[c].values for c in self.feature_cols])
return X @ self.beta
def score(self, data: pd.DataFrame) -> dict:
y_true = data["y_target"].values
y_hat = self.predict(data)
rmse = float(np.sqrt(np.mean((y_true - y_hat) ** 2)))
mae = float(np.mean(np.abs(y_true - y_hat)))
return {"rmse": rmse, "mae": mae}
# -----------------------------
# 4) Evaluation layer (rolling CV)
# -----------------------------
def rolling_folds(n: int, L_train: int, B_test: int):
starts = list(range(0, n - (L_train + B_test) + 1, B_test))
return [(s, s + L_train, s + L_train + B_test) for s in starts]
def evaluate(model_cls, df_feat: pd.DataFrame, cfg: dict) -> pd.DataFrame:
L_train = cfg["split"]["train_len"]
B_test = cfg["split"]["test_len"]
folds = rolling_folds(len(df_feat), L_train, B_test)
rows = []
for i, (a, b, c) in enumerate(folds, start=1):
tr = df_feat.iloc[a:b]
te = df_feat.iloc[b:c]
model = model_cls(feature_cols=cfg["features"]["cols"]).fit(tr)
metrics = model.score(te)
rows.append({
"fold": i,
"test_start": str(te.index.min().date()),
"test_end": str(te.index.max().date()),
**metrics
})
return pd.DataFrame(rows)
# -----------------------------
# 5) Reporting layer (plots + artefacts)
# -----------------------------
def make_run_dir(base_dir="runs") -> str:
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
run_dir = os.path.join(base_dir, f"{ts}_run")
os.makedirs(os.path.join(run_dir, "plots"), exist_ok=True)
return run_dir
def save_json(path: str, obj: dict):
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, indent=2)
def report(run_dir: str, cfg: dict, fold_df: pd.DataFrame):
# Save config snapshot
save_json(os.path.join(run_dir, "config.json"), cfg)
# Save fold metrics
fold_path = os.path.join(run_dir, "fold_metrics.csv")
fold_df.to_csv(fold_path, index=False)
# Aggregate metrics
summary = {
"rmse_mean": float(fold_df["rmse"].mean()),
"rmse_std": float(fold_df["rmse"].std(ddof=0)),
"mae_mean": float(fold_df["mae"].mean()),
"n_folds": int(len(fold_df)),
}
save_json(os.path.join(run_dir, "metrics.json"), summary)
# Plot RMSE by fold
plt.figure(figsize=(7.5, 3.5))
plt.plot(fold_df["fold"], fold_df["rmse"], marker="o")
plt.title("Rolling CV: RMSE by fold")
plt.xlabel("Fold")
plt.ylabel("RMSE")
plt.tight_layout()
plt.savefig(os.path.join(run_dir, "plots", "rmse_by_fold.png"))
plt.show()
# Minimal notes for handover
notes = [
"# Run notes",
f"- Target horizon: {cfg['target']['horizon']}",
f"- Features: {cfg['features']['cols']}",
f"- Split: rolling train_len={cfg['split']['train_len']}, test_len={cfg['split']['test_len']}",
f"- RMSE mean: {summary['rmse_mean']:.4f}",
"",
"Safe usage statement:",
"- This run is a baseline evidence pack for structure and reproducibility.",
"- Do not interpret as production performance.",
]
with open(os.path.join(run_dir, "notes.md"), "w", encoding="utf-8") as f:
f.write("\n".join(notes))
return summary
# -----------------------------
# 6) Runner (configuration-driven)
# -----------------------------
cfg = {
"seed": 123,
"data": {"T": 700},
"target": {"horizon": 1},
"features": {
"lags": [1, 2, 3, 5],
"cols": ["lag1", "lag2", "lag3", "lag5"]
},
"split": {"train_len": 240, "test_len": 60},
}
df_raw = make_synthetic_series(T=cfg["data"]["T"], seed=cfg["seed"])
df_feat = build_features(df_raw, cfg)
run_dir = make_run_dir()
fold_df = evaluate(OLSModel, df_feat, cfg)
summary = report(run_dir, cfg, fold_df)
print("Run directory:", run_dir)
print("Summary:", summary)What this lab proves
- You can run the same pipeline with a new config without changing code.
- The model obeys a clean interface, so evaluation is generic.
- Output artefacts exist: config snapshot, metrics, fold table, plot, notes.
Explicit note
This is intentionally not a “best model”. This is a structural baseline that demonstrates quant engineering discipline.
8. Mini-exercises
- Add a second config with
horizon=5and compare fold RMSE. - Add a
baseline_last_valuemodel that also implementsfit/predict/score. - Add a
reportingplot for MAE by fold. - Make the run directory include a short hash of the config content.
9. Summary
- A quant engineer ships interfaces, separation, and reproducibility.
fit/predict/scoreenables evaluation and validation at scale.- Config-driven runs turn experiments into comparable evidence.
- Reporting is an artefact, not a screenshot.