Files
2026-07-13 13:26:28 +08:00

1184 lines
46 KiB
Python

"""Generate minimal results fixtures for test output directories.
Downstream notebooks need:
1. results/*.json — legacy format for some downstream comparisons
2. run_log/registry.db — SQLite registry queried by case_study_insights,
model_analysis, and backtest notebooks via utils.case_study_analytics
3. results/causal_dml.json — Ch15 causal insights
4. results/ch08_features.json, ch09_temporal.json — Ch08/09 summaries
All fixtures use minimal but schema-correct data. Only written if not
already present (real upstream runs take priority).
"""
import hashlib
import json
import sqlite3
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).parent.parent.parent
CS_ROOT = REPO_ROOT / "case_studies"
# All model families in the pipeline
FAMILIES = ["linear", "gbm", "tabular_dl", "deep_learning", "latent_factors", "causal_dml"]
# Config names per family (representative). Linear has two so Ch26 can find lasso.
FAMILY_CONFIGS = {
"linear": ["ridge_a1.0", "lasso_a0.01"],
"gbm": ["lgb_default_mse"],
"tabular_dl": ["tabm_s"],
"deep_learning": ["lstm_64"],
"latent_factors": ["pca_5"],
"causal_dml": ["dml_linear"],
}
# Backtest stages (Ch16-19)
BACKTEST_STAGES = ["signal", "allocation", "cost_sensitivity", "risk_overlay"]
# Timestamp for all fixture entries
FIXTURE_TS = "2026-01-01T00:00:00"
def _linear_fixture(label: str) -> dict:
"""Minimal linear results JSON — just enough for downstream `best_model` lookups."""
return {
"case_study_id": "fixture",
"chapter": "ch11",
"stage": f"linear_{label}",
"timestamp": "2026-01-01T00:00:00",
"git_commit": "fixture",
"notebook": "fixture",
"summary": {
"n_folds": 2,
"n_features": 10,
"n_rows": 100,
"primary_label": label,
"label_column": "y",
"best_model": "ridge",
"hpo_method": "grid",
"models": {
"ridge": {"ic_mean": 0.01, "ic_std": 0.005, "best_alpha": 1.0},
"ols": {"ic_mean": 0.008, "ic_std": 0.006},
"lasso": {"ic_mean": 0.009, "ic_std": 0.005, "best_alpha": 0.01},
},
},
}
def _gbm_fixture(label: str) -> dict:
"""Minimal GBM results JSON — just enough for downstream `val_ic_mean` lookups."""
return {
"case_study_id": "fixture",
"chapter": "ch12",
"stage": f"gbm_{label}",
"timestamp": "2026-01-01T00:00:00",
"git_commit": "fixture",
"notebook": "fixture",
"summary": {
"n_folds": 2,
"n_features": 10,
"n_rows": 100,
"primary_label": label,
"label_column": "y",
"device": "cpu",
"num_boost_round": 5,
"n_configs": 1,
"best_config": "default_mse",
"best_iteration": 5,
"val_ic_mean": 0.015,
"grid": {"default_mse": {"best_ic": 0.015, "best_iteration": 5}},
},
}
def _tabular_dl_fixture(label: str) -> dict:
"""Minimal TabDL results JSON."""
return {
"case_study_id": "fixture",
"chapter": "ch12",
"stage": f"tabular_dl_{label}",
"timestamp": "2026-01-01T00:00:00",
"git_commit": "fixture",
"notebook": "fixture",
"summary": {
"n_folds": 2,
"n_features": 10,
"n_rows": 100,
"primary_label": label,
"label_column": "y",
"val_ic_mean": 0.012,
"best_config": "tabm_s",
},
}
def _make_hash(content: str) -> str:
"""Deterministic 12-char hash for fixture data."""
return hashlib.sha256(content.encode()).hexdigest()[:12]
def _migrate_long_to_wide(db_path: Path) -> None:
"""Migrate registry.db metric tables from long format (metric/value pairs)
to wide format (one column per metric).
Old intermediates used EAV-style tables:
fold_metrics(prediction_hash, fold_id, metric, value, computed_at)
backtest_metrics(backtest_hash, metric, value, detail_json, computed_at)
Production code expects wide tables:
fold_metrics(prediction_hash, fold_id, computed_at, ic, ic_std, rmse, ...)
backtest_metrics(backtest_hash, computed_at, sharpe, sortino, ...)
"""
db = sqlite3.connect(str(db_path))
# --- Migrate prediction_metrics ---
pm_cols = {r[1] for r in db.execute("PRAGMA table_info(prediction_metrics)").fetchall()}
if "metric" in pm_cols and "ic_mean" not in pm_cols:
rows = db.execute(
"SELECT prediction_hash, metric, value, computed_at FROM prediction_metrics"
).fetchall()
db.execute("DROP TABLE prediction_metrics")
db.execute("""
CREATE TABLE prediction_metrics (
prediction_hash TEXT PRIMARY KEY REFERENCES prediction_sets(prediction_hash),
computed_at TEXT NOT NULL,
ic_mean REAL, ic_std REAL, ic_t REAL, n_folds REAL, n_obs REAL,
n_periods REAL, pct_positive REAL, task_type REAL,
accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL,
log_loss REAL, brier_score REAL
)
""")
wide = {}
for pred_hash, metric, value, computed_at in rows:
if pred_hash not in wide:
wide[pred_hash] = {"computed_at": computed_at}
wide[pred_hash][metric] = value
valid_cols = {
"ic_mean",
"ic_std",
"ic_t",
"n_folds",
"n_obs",
"n_periods",
"pct_positive",
"task_type",
"accuracy",
"balanced_accuracy",
"auc_roc",
"auc_pr",
"log_loss",
"brier_score",
}
for pred_hash, vals in wide.items():
cols_present = [c for c in valid_cols if c in vals]
placeholders = ", ".join(["?"] * (2 + len(cols_present)))
col_names = ", ".join(["prediction_hash", "computed_at"] + cols_present)
values = [pred_hash, vals["computed_at"]] + [vals[c] for c in cols_present]
db.execute(
f"INSERT OR IGNORE INTO prediction_metrics ({col_names}) VALUES ({placeholders})",
values,
)
# --- Migrate fold_metrics ---
fm_cols = {r[1] for r in db.execute("PRAGMA table_info(fold_metrics)").fetchall()}
if "metric" in fm_cols and "ic" not in fm_cols:
rows = db.execute(
"SELECT prediction_hash, fold_id, metric, value, computed_at FROM fold_metrics"
).fetchall()
db.execute("DROP TABLE fold_metrics")
db.execute("""
CREATE TABLE fold_metrics (
prediction_hash TEXT NOT NULL REFERENCES prediction_sets(prediction_hash),
fold_id INTEGER NOT NULL, computed_at TEXT NOT NULL,
ic REAL, ic_std REAL, n_periods REAL, n_obs REAL, n_entities REAL,
rmse REAL, mae REAL,
accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL,
log_loss REAL, brier_score REAL,
PRIMARY KEY (prediction_hash, fold_id)
)
""")
# Pivot long → wide
wide = {}
for pred_hash, fold_id, metric, value, computed_at in rows:
key = (pred_hash, fold_id)
if key not in wide:
wide[key] = {"computed_at": computed_at}
wide[key][metric] = value
valid_cols = {
"ic",
"ic_std",
"n_periods",
"n_obs",
"n_entities",
"rmse",
"mae",
"accuracy",
"balanced_accuracy",
"auc_roc",
"auc_pr",
"log_loss",
"brier_score",
}
for (pred_hash, fold_id), vals in wide.items():
cols_present = [c for c in valid_cols if c in vals]
placeholders = ", ".join(["?"] * (3 + len(cols_present)))
col_names = ", ".join(["prediction_hash", "fold_id", "computed_at"] + cols_present)
values = [pred_hash, fold_id, vals["computed_at"]] + [vals[c] for c in cols_present]
db.execute(
f"INSERT OR IGNORE INTO fold_metrics ({col_names}) VALUES ({placeholders})", values
)
# --- Migrate backtest_metrics ---
bm_cols = {r[1] for r in db.execute("PRAGMA table_info(backtest_metrics)").fetchall()}
if "metric" in bm_cols and "sharpe" not in bm_cols:
rows = db.execute(
"SELECT backtest_hash, metric, value, computed_at FROM backtest_metrics"
).fetchall()
db.execute("DROP TABLE backtest_metrics")
db.execute("""
CREATE TABLE backtest_metrics (
backtest_hash TEXT PRIMARY KEY REFERENCES backtest_runs(backtest_hash),
computed_at TEXT NOT NULL,
sharpe REAL, sortino REAL, total_return REAL, max_drawdown REAL,
cagr REAL, volatility REAL, calmar REAL, omega REAL, stability REAL,
tail_ratio REAL, win_rate REAL, kurtosis REAL, skewness REAL,
var_95 REAL, cvar_95 REAL, n_periods REAL,
num_trades REAL, total_commission REAL, total_slippage REAL, avg_turnover REAL
)
""")
# Pivot long → wide
wide = {}
for b_hash, metric, value, computed_at in rows:
if b_hash not in wide:
wide[b_hash] = {"computed_at": computed_at}
wide[b_hash][metric] = value
valid_cols = {
"sharpe",
"sortino",
"total_return",
"max_drawdown",
"cagr",
"volatility",
"calmar",
"omega",
"stability",
"tail_ratio",
"win_rate",
"kurtosis",
"skewness",
"var_95",
"cvar_95",
"n_periods",
"num_trades",
"total_commission",
"total_slippage",
"avg_turnover",
}
for b_hash, vals in wide.items():
cols_present = [c for c in valid_cols if c in vals]
placeholders = ", ".join(["?"] * (2 + len(cols_present)))
col_names = ", ".join(["backtest_hash", "computed_at"] + cols_present)
values = [b_hash, vals["computed_at"]] + [vals[c] for c in cols_present]
db.execute(
f"INSERT OR IGNORE INTO backtest_metrics ({col_names}) VALUES ({placeholders})",
values,
)
# --- Migrate backtest_fold_metrics (if long format) ---
bfm_cols = {r[1] for r in db.execute("PRAGMA table_info(backtest_fold_metrics)").fetchall()}
if "metric" in bfm_cols and "sharpe" not in bfm_cols:
rows = db.execute(
"SELECT backtest_hash, fold_id, metric, value, computed_at FROM backtest_fold_metrics"
).fetchall()
db.execute("DROP TABLE backtest_fold_metrics")
db.execute("""
CREATE TABLE backtest_fold_metrics (
backtest_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
fold_id INTEGER NOT NULL, metric TEXT NOT NULL,
value REAL, computed_at TEXT NOT NULL,
PRIMARY KEY (backtest_hash, fold_id, metric)
)
""")
for row in rows:
db.execute("INSERT OR IGNORE INTO backtest_fold_metrics VALUES (?,?,?,?,?)", row)
db.commit()
db.close()
def _add_cohort_metrics_table(db_path: Path) -> None:
"""Add cohort_metrics + backtest_paired_metrics to an existing test-data
registry.
Mirrors the schemas in case_studies/utils/registry/store.py. Both tables
start empty; consumers use LEFT JOIN / fetchall→pl.DataFrame, so empty
is fine for CI. Real backfill comes from scripts/backfill_cohort_metrics.py
and the paired-metrics populator in 01_aggregate_synthesis.
"""
db = sqlite3.connect(str(db_path))
db.executescript("""
CREATE TABLE IF NOT EXISTS backtest_paired_metrics (
challenger_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
benchmark_hash TEXT NOT NULL,
benchmark_kind TEXT,
periods_per_year INTEGER,
bootstrap_block_length INTEGER,
bootstrap_n INTEGER,
sharpe_diff REAL,
sharpe_diff_ci95_lo REAL,
sharpe_diff_ci95_hi REAL,
ret_diff REAL,
ret_diff_ci95_lo REAL,
ret_diff_ci95_hi REAL,
max_dd_diff REAL,
max_dd_diff_ci95_lo REAL,
max_dd_diff_ci95_hi REAL,
info_ratio REAL,
info_ratio_ci95_lo REAL,
info_ratio_ci95_hi REAL,
prob_challenger_wins REAL,
p_value REAL,
computed_at TEXT NOT NULL,
PRIMARY KEY (challenger_hash, benchmark_hash)
);
CREATE TABLE IF NOT EXISTS cohort_metrics (
cohort_type TEXT NOT NULL,
stage TEXT,
label TEXT NOT NULL,
family TEXT,
leader_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
k_variants INTEGER NOT NULL,
periods_per_year REAL NOT NULL,
computed_at TEXT NOT NULL,
n_trials_effective_mp REAL,
n_trials_effective_er REAL,
dsr_raw REAL, dsr_raw_pvalue REAL,
expected_max_sharpe_raw REAL, min_trl_periods_raw REAL,
dsr_mp REAL, dsr_mp_pvalue REAL,
expected_max_sharpe_mp REAL, min_trl_periods_mp REAL,
dsr_er REAL, dsr_er_pvalue REAL,
expected_max_sharpe_er REAL, min_trl_periods_er REAL,
ras_leader REAL,
ras_complexity REAL,
ras_n_strategies REAL,
ras_pvalue REAL,
reality_check_pvalue REAL,
reality_check_statistic REAL,
reality_check_k REAL,
pbo REAL,
pbo_n_combinations REAL,
pbo_median_oos_rank REAL,
pbo_mean_degradation REAL,
pbo_n_folds REAL,
leader_sharpe REAL,
leader_sortino REAL,
leader_min_trl REAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cohort_unique
ON cohort_metrics(cohort_type, COALESCE(stage, ''), label, COALESCE(family, ''));
CREATE INDEX IF NOT EXISTS idx_cohort_leader ON cohort_metrics(leader_hash);
""")
db.commit()
db.close()
def _seed_registry_db(cs_dir: Path, cs_id: str, primary_label: str) -> None:
"""Create a minimal registry.db with entries for all families and stages.
Schema matches utils/registry.py REGISTRY_SCHEMA_SQL exactly.
Creates entries that utils.case_study_analytics and utils.model_analysis
can query without crashing.
"""
db_path = cs_dir / "run_log" / "registry.db"
if db_path.exists():
try:
_db = sqlite3.connect(str(db_path))
cols = {r[1] for r in _db.execute("PRAGMA table_info(training_runs)").fetchall()}
tables = {
r[0]
for r in _db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
bm_cols = {r[1] for r in _db.execute("PRAGMA table_info(backtest_metrics)").fetchall()}
fm_cols = {r[1] for r in _db.execute("PRAGMA table_info(fold_metrics)").fetchall()}
pm_cols = {
r[1] for r in _db.execute("PRAGMA table_info(prediction_metrics)").fetchall()
}
_db.close()
# Core schema check: training_runs must have training_hash
has_core = "training_hash" in cols
# Wide-format metric check
bm_wide = "sharpe" in bm_cols
fm_wide = "ic" in fm_cols
pm_wide = "ic_mean" in pm_cols
has_all_tables = (
"fold_metrics" in tables
and "backtest_runs" in tables
and "cohort_metrics" in tables
)
if has_core and has_all_tables and bm_wide and fm_wide and pm_wide:
return # Fully current schema — don't overwrite
# Schema present but missing cohort_metrics — add it without
# rebuilding the entire registry (preserves seeded rows).
if has_core and "cohort_metrics" not in tables:
_add_cohort_metrics_table(db_path)
tables.add("cohort_metrics")
has_all_tables = (
"fold_metrics" in tables
and "backtest_runs" in tables
and "cohort_metrics" in tables
)
if has_core and has_all_tables and bm_wide and fm_wide and pm_wide:
return
if not has_core:
# Legacy schema (run_id instead of training_hash) — must rebuild
db_path.unlink()
else:
# Core schema OK but metrics need migration
if not bm_wide or not fm_wide or not pm_wide:
_migrate_long_to_wide(db_path)
# Fall through to seed missing entries
except Exception:
db_path.unlink(missing_ok=True)
db_path.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(str(db_path))
db.executescript("""
CREATE TABLE IF NOT EXISTS training_runs (
training_hash TEXT PRIMARY KEY, family TEXT NOT NULL,
label TEXT NOT NULL, config_name TEXT,
spec_json TEXT, created_at TEXT NOT NULL,
git_commit TEXT, entry_point TEXT
);
CREATE TABLE IF NOT EXISTS prediction_sets (
prediction_hash TEXT PRIMARY KEY,
training_hash TEXT NOT NULL REFERENCES training_runs(training_hash),
checkpoint_value INTEGER, checkpoint_kind TEXT,
split TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS prediction_metrics (
prediction_hash TEXT PRIMARY KEY REFERENCES prediction_sets(prediction_hash),
computed_at TEXT NOT NULL,
ic_mean REAL, ic_std REAL, ic_t REAL, n_folds REAL, n_obs REAL,
n_periods REAL, pct_positive REAL, task_type REAL,
accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL,
log_loss REAL, brier_score REAL
);
CREATE TABLE IF NOT EXISTS fold_metrics (
prediction_hash TEXT NOT NULL REFERENCES prediction_sets(prediction_hash),
fold_id INTEGER NOT NULL,
computed_at TEXT NOT NULL,
ic REAL, ic_std REAL, n_periods REAL, n_obs REAL, n_entities REAL,
rmse REAL, mae REAL,
accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL,
log_loss REAL, brier_score REAL,
PRIMARY KEY (prediction_hash, fold_id)
);
CREATE TABLE IF NOT EXISTS backtest_runs (
backtest_hash TEXT PRIMARY KEY,
prediction_hash TEXT NOT NULL REFERENCES prediction_sets(prediction_hash),
spec_json TEXT, stage TEXT, created_at TEXT NOT NULL, git_commit TEXT
);
CREATE TABLE IF NOT EXISTS backtest_metrics (
backtest_hash TEXT PRIMARY KEY REFERENCES backtest_runs(backtest_hash),
computed_at TEXT NOT NULL,
sharpe REAL, sortino REAL, total_return REAL, max_drawdown REAL,
cagr REAL, volatility REAL, calmar REAL, omega REAL, stability REAL,
tail_ratio REAL, win_rate REAL, kurtosis REAL, skewness REAL,
var_95 REAL, cvar_95 REAL, n_periods REAL,
num_trades REAL, total_commission REAL, total_slippage REAL, avg_turnover REAL
);
CREATE TABLE IF NOT EXISTS backtest_fold_metrics (
backtest_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
fold_id INTEGER NOT NULL, metric TEXT NOT NULL,
value REAL, computed_at TEXT NOT NULL,
PRIMARY KEY (backtest_hash, fold_id, metric)
);
CREATE TABLE IF NOT EXISTS cohort_metrics (
cohort_type TEXT NOT NULL,
stage TEXT,
label TEXT NOT NULL,
family TEXT,
leader_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
k_variants INTEGER NOT NULL,
periods_per_year REAL NOT NULL,
computed_at TEXT NOT NULL,
n_trials_effective_mp REAL,
n_trials_effective_er REAL,
dsr_raw REAL, dsr_raw_pvalue REAL,
expected_max_sharpe_raw REAL, min_trl_periods_raw REAL,
dsr_mp REAL, dsr_mp_pvalue REAL,
expected_max_sharpe_mp REAL, min_trl_periods_mp REAL,
dsr_er REAL, dsr_er_pvalue REAL,
expected_max_sharpe_er REAL, min_trl_periods_er REAL,
ras_leader REAL,
ras_complexity REAL,
ras_n_strategies REAL,
ras_pvalue REAL,
reality_check_pvalue REAL,
reality_check_statistic REAL,
reality_check_k REAL,
pbo REAL,
pbo_n_combinations REAL,
pbo_median_oos_rank REAL,
pbo_mean_degradation REAL,
pbo_n_folds REAL,
leader_sharpe REAL,
leader_sortino REAL,
leader_min_trl REAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cohort_unique
ON cohort_metrics(cohort_type, COALESCE(stage, ''), label, COALESCE(family, ''));
CREATE INDEX IF NOT EXISTS idx_cohort_leader ON cohort_metrics(leader_hash);
CREATE TABLE IF NOT EXISTS backtest_paired_metrics (
challenger_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
benchmark_hash TEXT NOT NULL,
benchmark_kind TEXT,
periods_per_year INTEGER,
bootstrap_block_length INTEGER,
bootstrap_n INTEGER,
sharpe_diff REAL,
sharpe_diff_ci95_lo REAL,
sharpe_diff_ci95_hi REAL,
ret_diff REAL,
ret_diff_ci95_lo REAL,
ret_diff_ci95_hi REAL,
max_dd_diff REAL,
max_dd_diff_ci95_lo REAL,
max_dd_diff_ci95_hi REAL,
info_ratio REAL,
info_ratio_ci95_lo REAL,
info_ratio_ci95_hi REAL,
prob_challenger_wins REAL,
p_value REAL,
computed_at TEXT NOT NULL,
PRIMARY KEY (challenger_hash, benchmark_hash)
);
""")
# IC values per family (realistic ordering: gbm > linear > dl > others)
ic_values = {
"linear": 0.018,
"gbm": 0.025,
"tabular_dl": 0.022,
"deep_learning": 0.020,
"latent_factors": 0.015,
"causal_dml": 0.012,
}
# Insert training runs + prediction sets + metrics per family/config.
# Also insert for ALL labels (not just primary) so Ch26 notebooks
# can find specific config+label combos like lasso/fwd_ret_1d.
best_pred_hash = None
best_ic = -1.0
all_labels = [primary_label]
# Get variant labels from setup.yaml
setup_path = CS_ROOT / cs_id / "config" / "setup.yaml"
if setup_path.exists():
setup = yaml.safe_load(setup_path.read_text())
variants = setup.get("labels", {}).get("variants", [])
if isinstance(variants, list):
for v in variants:
name = v if isinstance(v, str) else v.get("name", "")
if name and name not in all_labels:
all_labels.append(name)
for family in FAMILIES:
config_names = FAMILY_CONFIGS[family]
for config_name in config_names:
for label in all_labels:
t_hash = _make_hash(f"{cs_id}/{family}/{config_name}/{label}")
p_hash = _make_hash(f"pred/{t_hash}/validation")
ic = ic_values.get(family, 0.01)
spec = {"family": family, "config_name": config_name, "label": label}
db.execute(
"""INSERT OR IGNORE INTO training_runs
(training_hash, family, label, config_name, spec_json, created_at, git_commit, entry_point)
VALUES (?,?,?,?,?,?,?,?)""",
(
t_hash,
family,
label,
config_name,
json.dumps(spec),
FIXTURE_TS,
"fixture",
"fixture",
),
)
db.execute(
"""INSERT OR IGNORE INTO prediction_sets
(prediction_hash, training_hash, checkpoint_value, checkpoint_kind, split, created_at)
VALUES (?,?,?,?,?,?)""",
(p_hash, t_hash, 100, "final", "validation", FIXTURE_TS),
)
db.execute(
"""INSERT OR IGNORE INTO prediction_metrics
(prediction_hash, computed_at, ic_mean, ic_std, n_folds, n_obs)
VALUES (?,?,?,?,?,?)""",
(p_hash, FIXTURE_TS, ic, ic * 0.3, 2, 100),
)
# Fold metrics (2 folds) — wide format matching production schema
for fold_id in range(2):
fold_ic = ic + (0.002 if fold_id == 0 else -0.002)
db.execute(
"""INSERT OR IGNORE INTO fold_metrics
(prediction_hash, fold_id, computed_at, ic, ic_std, n_periods, n_obs, n_entities, rmse, mae)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
p_hash,
fold_id,
FIXTURE_TS,
fold_ic,
fold_ic * 0.3,
50,
100,
5,
0.05,
0.03,
),
)
if label == primary_label and ic > best_ic:
best_ic = ic
best_pred_hash = p_hash
# Insert backtest runs for each stage (using best model's prediction)
if best_pred_hash:
sharpe_by_stage = {
"signal": 0.8,
"allocation": 0.9,
"cost_sensitivity": 0.7,
"risk_overlay": 0.85,
}
for stage in BACKTEST_STAGES:
b_hash = _make_hash(f"bt/{cs_id}/{stage}/{best_pred_hash}")
spec = {
"stage": stage,
"prediction_hash": best_pred_hash,
"chapter": f"ch{16 + BACKTEST_STAGES.index(stage)}",
"signal": {"method": "equal_weight_top_k", "top_k": 5},
"allocation": {"method": "equal_weight"},
"costs": {"commission_bps": 5, "slippage_bps": 5},
"execution": {"rebalance": "monthly"},
}
db.execute(
"""INSERT OR IGNORE INTO backtest_runs
(backtest_hash, prediction_hash, spec_json, stage, created_at, git_commit)
VALUES (?,?,?,?,?,?)""",
(b_hash, best_pred_hash, json.dumps(spec), stage, FIXTURE_TS, "fixture"),
)
sharpe = sharpe_by_stage.get(stage, 0.5)
db.execute(
"""INSERT OR IGNORE INTO backtest_metrics
(backtest_hash, computed_at, sharpe, sortino, total_return,
max_drawdown, cagr, volatility, calmar, n_periods)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
b_hash,
FIXTURE_TS,
sharpe,
sharpe * 1.2,
sharpe * 0.1,
-0.15,
sharpe * 0.05,
0.12,
sharpe * 0.33,
252,
),
)
db.commit()
db.close()
# Create synthetic prediction parquets for ALL prediction_hashes in the
# registry (both fixture-generated and sampled-from-production). Uses real
# symbols from setup.yaml and dates spanning the holdout boundary so backtest
# notebooks can run (results are garbage but the pipeline completes).
_backfill_all_prediction_parquets(cs_dir, cs_id)
def _backfill_all_backtest_artifacts(cs_dir: Path) -> None:
"""Generate synthetic daily_returns.parquet for every backtest_runs entry.
Creates `run_log/backtest/{hash}/daily_returns.parquet` with a small daily
Float64 return series so notebooks that resolve a backtest hash and read
its daily-returns artifact (e.g., 17_portfolio_construction/01_portfolio_metrics)
have a file to load. Values are bounded random noise — CI only needs the
pipeline to complete, not to reproduce production performance.
"""
db_path = cs_dir / "run_log" / "registry.db"
if not db_path.exists():
return
try:
import numpy as np
import polars as _pl
except ImportError:
return
db = sqlite3.connect(str(db_path))
try:
rows = db.execute("SELECT backtest_hash FROM backtest_runs").fetchall()
except sqlite3.OperationalError:
rows = []
db.close()
if not rows:
return
bt_root = cs_dir / "run_log" / "backtest"
bt_root.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(42)
n_days = 1000
import datetime as _dt
base = _dt.date(2020, 1, 1)
day_list = [base + _dt.timedelta(days=i) for i in range(n_days)]
for (b_hash,) in rows:
artifact_dir = bt_root / b_hash
artifact_dir.mkdir(parents=True, exist_ok=True)
path = artifact_dir / "daily_returns.parquet"
if path.exists():
continue
returns = rng.normal(loc=0.0005, scale=0.012, size=n_days).astype("float64")
df = _pl.DataFrame({"timestamp": day_list, "daily_return": returns}).with_columns(
_pl.col("timestamp").cast(_pl.Date)
)
df.write_parquet(path)
def _backfill_all_prediction_parquets(cs_dir: Path, cs_id: str) -> None:
"""Generate synthetic prediction parquets for every hash in the registry.
Uses real symbols from setup.yaml and dates spanning 2 years before the
holdout boundary so backtests have data to work with. Predictions are random
noise — CI only needs the pipeline to complete, not meaningful results.
"""
db_path = cs_dir / "run_log" / "registry.db"
if not db_path.exists():
return
try:
import numpy as np
import polars as _pl
except ImportError:
return
# Get all prediction hashes from registry
db = sqlite3.connect(str(db_path))
hashes = [r[0] for r in db.execute("SELECT prediction_hash FROM prediction_sets").fetchall()]
db.close()
if not hashes:
return
# Read symbols from setup.yaml (fall back to generic)
setup_path = CS_ROOT / cs_id / "config" / "setup.yaml"
symbols = ["SYM0", "SYM1", "SYM2", "SYM3", "SYM4"]
holdout_start = "2024-01-01"
entity_col = "symbol"
if setup_path.exists():
setup = yaml.safe_load(setup_path.read_text())
universe = setup.get("universe", {})
assets = universe.get("assets", [])
if assets:
symbols = assets[:10] # Cap at 10 for test speed
if cs_id == "cme_futures":
entity_col = "product"
eval_cfg = setup.get("evaluation", {})
if eval_cfg.get("holdout_start"):
holdout_start = eval_cfg["holdout_start"]
# Generate daily dates: 2 years before holdout through 6 months after
from datetime import date, timedelta
ho = date.fromisoformat(holdout_start)
start = ho - timedelta(days=730)
end = ho + timedelta(days=180)
dates = []
d = start
while d <= end:
if d.weekday() < 5: # Weekdays only
dates.append(d)
d += timedelta(days=1)
# Subsample to ~60 dates for speed
step = max(1, len(dates) // 60)
dates = dates[::step]
rng = np.random.default_rng(42)
n_symbols = len(symbols)
n_dates = len(dates)
n = n_symbols * n_dates
# Build one template DataFrame, reuse for all hashes
rows_symbol = [s for _ in dates for s in symbols]
rows_date = [d for d in dates for _ in range(n_symbols)]
# Canonical production schema: prediction / actual / fold (NOT y_score / y_true / fold_id).
# Notebooks read these columns by name; using non-canonical names here would silently
# break downstream notebooks that resolve hashes from the registry.
#
# Fold assignment must mirror walk-forward CV: every symbol is present in every
# fold for the dates in that fold's window. Assigning fold by row index (e.g.,
# i % 2) silently partitions symbols across folds and breaks per-symbol
# conformal calibration (each symbol ends up in one fold only). Partition by
# date instead so all symbols share the same fold on each date.
n_folds = 2
rows_fold = [
(_di // max(1, n_dates // n_folds + 1)) % n_folds
for _di in range(n_dates)
for _ in range(n_symbols)
]
template = _pl.DataFrame(
{
entity_col: rows_symbol,
"timestamp": _pl.Series(rows_date).cast(_pl.Date),
"fold": rows_fold,
"prediction": rng.normal(0, 0.01, n).tolist(),
"actual": rng.normal(0, 0.01, n).tolist(),
}
)
for p_hash in hashes:
pred_dir = cs_dir / "run_log" / "predictions" / p_hash
pred_file = pred_dir / "predictions.parquet"
if pred_file.exists():
continue
pred_dir.mkdir(parents=True, exist_ok=True)
template.write_parquet(str(pred_file))
def _seed_causal_json(results_dir: Path, cs_id: str, label: str) -> None:
"""Seed results/causal_dml.json for Ch15 insights."""
path = results_dir / "causal_dml.json"
if path.exists():
return
path.write_text(
json.dumps(
{
"case_study_id": cs_id,
"label": label,
"treatment": "momentum_21d",
"summary": {
"ate": 0.003,
"ate_se": 0.001,
"refutation_placebo": {"new_effect": 0.0001, "p_value": 0.85},
"refutation_subset": {"new_effect": 0.0028, "p_value": 0.02},
},
},
indent=2,
)
)
def _seed_feature_json(results_dir: Path, cs_id: str) -> None:
"""Seed results/ch08_features.json for Ch08 feature summary."""
path = results_dir / "ch08_features.json"
if path.exists():
return
path.write_text(
json.dumps(
{
"case_study_id": cs_id,
"evaluation": {
"n_features": 15,
"n_features_tested": 15,
"n_significant_fdr05": 8,
"inflation_factor": 1.5,
"max_pairwise_corr": 0.72,
"corr_pairs_above_07": 3,
"top_features": ["past_ret_21d", "vol_21d", "rsi_14"],
"metrics": {"ic_mean": 0.02, "ic_std": 0.01},
},
},
indent=2,
)
)
def _seed_temporal_json(results_dir: Path, cs_id: str) -> None:
"""Seed results/ch09_temporal.json for Ch09 temporal summary."""
path = results_dir / "ch09_temporal.json"
if path.exists():
return
path.write_text(
json.dumps(
{
"case_study_id": cs_id,
"incremental_evaluation": {
"temporal_models": ["arima", "garch", "kalman"],
"ic_contribution": {"arima": 0.005, "garch": 0.003, "kalman": 0.002},
},
},
indent=2,
)
)
def _seed_demo_predictions(cs_dir: Path, cs_id: str, primary_label: str) -> None:
"""Seed demo prediction parquets for live-simulation notebooks (Ch25).
Ch25 notebooks check CASE_DIR / "models" / "predictions_reg_{horizon}d.parquet"
first. CASE_DIR = get_case_study_dir() which redirects to ML4T_OUTPUT_DIR in tests.
We seed a single flat predictions file there so the first check succeeds.
"""
try:
import numpy as np
import polars as _pl
except ImportError:
return
CS_CONFIGS = {
"cme_futures": {
"asset_col": "product",
"assets": ["CL", "NG", "GC", "ES", "ZN", "6E"],
"horizons": [5, 21],
},
"fx_pairs": {
"asset_col": "symbol",
"assets": ["EURUSD", "GBPUSD", "USDJPY", "AUDUSD"],
"horizons": [1, 5],
},
"us_equities_panel": {
"asset_col": "symbol",
"assets": ["AAPL", "MSFT", "GOOGL", "AMZN"],
"horizons": [5, 21],
},
"etfs": {
"asset_col": "symbol",
"assets": ["SPY", "QQQ", "IWM", "EFA", "TLT"],
"horizons": [21],
},
}
config = CS_CONFIGS.get(cs_id)
if not config:
return
rng = np.random.default_rng(42)
models_dir = cs_dir / "models"
models_dir.mkdir(parents=True, exist_ok=True)
for horizon in config["horizons"]:
pred_file = models_dir / f"predictions_reg_{horizon}d.parquet"
if pred_file.exists():
continue
n_days = 60
rows = []
for i in range(n_days):
for asset in config["assets"]:
rows.append(
{
"timestamp": f"2024-{(i // 22) + 1:02d}-{(i % 22) + 1:02d}",
config["asset_col"]: asset,
"prediction": float(rng.normal(0, 0.01)),
}
)
df = _pl.DataFrame(rows).with_columns(_pl.col("timestamp").str.to_date().alias("timestamp"))
df.write_parquet(str(pred_file))
def _seed_news_features(output_dir: Path) -> None:
"""Seed a minimal news_features.parquet for Ch10/08_text_feature_evaluation.
The notebook loads from get_output_dir(8, "fnspid") / "news_features.parquet".
In test mode that becomes {ML4T_OUTPUT_DIR}/ch08_fnspid/news_features.parquet.
Required columns: symbol, timestamp, fwd_ret_1d, fwd_ret_5d, fwd_ret_20d,
weighted_surprise, sentiment_mean, sentiment_momentum, coverage_count.
"""
try:
import numpy as np
import polars as _pl
except ImportError:
return
out_dir = output_dir / "ch08_fnspid"
path = out_dir / "news_features.parquet"
if path.exists():
return
out_dir.mkdir(parents=True, exist_ok=True)
rng = np.random.default_rng(42)
symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA", "JPM"]
from datetime import date, timedelta
start = date(2023, 1, 3)
dates = [
start + timedelta(days=i) for i in range(60) if (start + timedelta(days=i)).weekday() < 5
]
n = len(symbols) * len(dates)
df = _pl.DataFrame(
{
"symbol": [s for _ in dates for s in symbols],
"timestamp": _pl.Series([d for d in dates for _ in symbols]).cast(_pl.Date),
"fwd_ret_1d": rng.normal(0, 0.01, n).tolist(),
"fwd_ret_5d": rng.normal(0, 0.02, n).tolist(),
"fwd_ret_20d": rng.normal(0, 0.04, n).tolist(),
"weighted_surprise": rng.normal(0, 0.5, n).tolist(),
"sentiment_mean": rng.normal(0, 0.3, n).tolist(),
"sentiment_momentum": rng.normal(0, 0.2, n).tolist(),
"coverage_count": rng.poisson(3, n).tolist(),
}
)
df.write_parquet(str(path))
def _seed_ch16_parity_json() -> None:
"""Seed cached parity JSON artifacts for Ch16 notebooks 15-18.
These notebooks read from get_chapter_dir(16) / "resources" / "<name>.json".
That path is NOT redirected by ML4T_OUTPUT_DIR — it's a real code-repo path.
"""
resources_dir = REPO_ROOT / "16_strategy_simulation" / "resources"
resources_dir.mkdir(parents=True, exist_ok=True)
# NB15: lean_parity_results.json
_write_if_missing(
resources_dir / "lean_parity_results.json",
{
"artifact_source": "fixture",
"scenario_id": "multi_250_20yr",
"scenario_label": "250 assets, 20 years daily",
"data_source": "fixture",
"cached": True,
"limitations": ["Fixture data for CI testing"],
"results": [
{
"framework_id": "ml4t-lean",
"label": "ml4t-backtest (LEAN profile)",
"num_trades": 428459,
"final_value": 1234567.89,
"runtime_sec": 12.5,
"data_points": 1250000,
},
{
"framework_id": "lean",
"label": "QuantConnect LEAN CLI",
"num_trades": 428459,
"final_value": 1234566.34,
"runtime_sec": 95.3,
"data_points": 1250000,
},
],
"comparison": {
"trade_gap": 0,
"trade_gap_pct": 0.0,
"final_value_gap": 1.55,
"final_value_gap_pct": 1.255e-06,
"runtime_speedup": 7.62,
"remaining_gap_driver": "price_precision",
"notes": [
"next-bar open execution is aligned",
"margin-enabled LEAN account semantics are aligned",
"decoded fill chronology matches exactly at event identity and 4-decimal price",
],
},
},
)
# NB16 (case_study_lean_parity_results.json), NB17
# (backtrader_zipline_parity_results.json), and NB18
# (vectorbt_parity_results.json) are intentionally NOT seeded here.
# Their artifacts hold genuine engine-parity numbers and are committed under
# 16_strategy_simulation/resources/ (version-controlled, always present on
# checkout), so the fabricated CI fallbacks were removed. NB16 is reproducible
# via ml4t.backtest._validation.case_study_lean; NB17 and NB18 via
# validation/benchmark_suite.py.
def _write_if_missing(path: Path, data: dict) -> None:
"""Write JSON file only if it doesn't already exist."""
if path.exists():
return
path.write_text(json.dumps(data, indent=2))
def seed_results(output_dir: Path, case_study_ids: list[str]) -> None:
"""Write minimal fixture results into test output directories.
Creates:
1. results/*.json — legacy format for downstream comparisons
2. run_log/registry.db — SQLite registry for case_study_insights + model_analysis
3. results/causal_dml.json — Ch15 causal insights
4. results/ch08_features.json, ch09_temporal.json — Ch08/09 summaries
Only writes files that don't already exist (upstream notebooks may have
produced real results during the same test session).
"""
for cs_id in case_study_ids:
setup_path = CS_ROOT / cs_id / "config" / "setup.yaml"
if not setup_path.exists():
continue
setup = yaml.safe_load(setup_path.read_text())
primary_label = setup.get("labels", {}).get("primary")
if not primary_label:
continue
# Get all label configs for this case study
labels = [primary_label]
variants = setup.get("labels", {}).get("variants", [])
if isinstance(variants, list):
labels.extend(v if isinstance(v, str) else v.get("name", "") for v in variants)
cs_dir = output_dir / cs_id
results_dir = cs_dir / "results"
results_dir.mkdir(parents=True, exist_ok=True)
for label in labels:
if not label:
continue
# Linear fixture
linear_path = results_dir / f"linear_{label}.json"
if not linear_path.exists():
linear_path.write_text(json.dumps(_linear_fixture(label), indent=2))
# GBM fixture
gbm_path = results_dir / f"gbm_{label}.json"
if not gbm_path.exists():
gbm_path.write_text(json.dumps(_gbm_fixture(label), indent=2))
# TabDL fixture
tabdl_path = results_dir / f"tabular_dl_{label}.json"
if not tabdl_path.exists():
tabdl_path.write_text(json.dumps(_tabular_dl_fixture(label), indent=2))
# Registry DB — the primary data source for insights + analysis notebooks
_seed_registry_db(cs_dir, cs_id, primary_label)
# Backfill prediction parquets for ALL hashes in registry
# (must run AFTER _seed_registry_db, and also when registry was pre-seeded)
_backfill_all_prediction_parquets(cs_dir, cs_id)
# Backfill daily_returns.parquet for ALL backtest hashes in registry
# so downstream notebooks (e.g., 17/01_portfolio_metrics) that resolve a
# backtest hash and read its daily-returns artifact have a file to load.
# Must run from outer loop because _seed_registry_db early-returns when
# the schema is already current, skipping any post-commit work.
_backfill_all_backtest_artifacts(cs_dir)
# Ch15 causal insights
_seed_causal_json(results_dir, cs_id, primary_label)
# Ch08/09 feature + temporal summaries
_seed_feature_json(results_dir, cs_id)
_seed_temporal_json(results_dir, cs_id)
# Ch25 live-simulation demo predictions
_seed_demo_predictions(cs_dir, cs_id, primary_label)
# --- Non-case-study chapter fixtures ---
_seed_news_features(output_dir)
_seed_ch16_parity_json()