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) -> self
  • predict(data, config) -> predictions
  • score(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 XX, - creates targets yy, - 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)

  1. Notebook-only workflow
  • Correction: keep notebooks as consumers of src/ functions.
  1. Hidden parameters
  • Correction: all parameters in config; code reads config.
  1. Mixed responsibilities
  • Correction: model code never loads CSVs and never plots.
  1. One-off evaluation
  • Correction: evaluation is a function you can run for any model.
  1. 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

  1. Add a second config with horizon=5 and compare fold RMSE.
  2. Add a baseline_last_value model that also implements fit/predict/score.
  3. Add a reporting plot for MAE by fold.
  4. Make the run directory include a short hash of the config content.

9. Summary

  • A quant engineer ships interfaces, separation, and reproducibility.
  • fit/predict/score enables evaluation and validation at scale.
  • Config-driven runs turn experiments into comparable evidence.
  • Reporting is an artefact, not a screenshot.