446 lines
16 KiB
Python
446 lines
16 KiB
Python
"""Test model notebooks produce correct registry entries in isolation.
|
|
|
|
Runs each case study model notebook (stage >= 06) with minimal parameters
|
|
in an isolated environment. Production data is read via symlinks; all
|
|
writes (registry.db, predictions, results JSON) go to a temp directory.
|
|
|
|
The production registry is NEVER opened or touched.
|
|
|
|
Design:
|
|
1. Session fixture creates temp dir with symlinked read-only data
|
|
2. Each notebook runs via Papermill with aggressive param reduction
|
|
3. ML4T_OUTPUT_DIR redirects all get_case_study_dir() writes to temp
|
|
4. After each run, query the test registry.db for expected entries
|
|
|
|
The goal is code-path coverage, not model quality. Params are set to the
|
|
absolute minimum that still exercises the training→register→predict loop:
|
|
MAX_SYMBOLS=3, MAX_FOLDS=2, N_EPOCHS=2, NUM_BOOST_ROUND=20.
|
|
|
|
Usage:
|
|
# All model notebooks (~15-20 min)
|
|
uv run pytest tests/test_model_registry.py -v
|
|
|
|
# Specific case study
|
|
uv run pytest tests/test_model_registry.py -v -k "crypto_perps_funding"
|
|
|
|
# Specific model family across all case studies
|
|
uv run pytest tests/test_model_registry.py -v -k "06_linear"
|
|
|
|
# Single notebook
|
|
uv run pytest tests/test_model_registry.py -v -k "etfs and 06_linear"
|
|
|
|
# Dry run — see what would be tested
|
|
uv run pytest tests/test_model_registry.py --collect-only
|
|
"""
|
|
|
|
import re
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.pm_helpers import get_overrides, run_notebook
|
|
|
|
REPO_ROOT = Path(__file__).parent.parent
|
|
PROD_CS_DIR = REPO_ROOT / "case_studies"
|
|
|
|
# Ordered smallest-to-largest for faster feedback
|
|
CASE_STUDIES = [
|
|
"crypto_perps_funding",
|
|
"fx_pairs",
|
|
"cme_futures",
|
|
"etfs",
|
|
"sp500_options",
|
|
"nasdaq100_microstructure",
|
|
"sp500_equity_option_analytics",
|
|
"us_firm_characteristics",
|
|
"us_equities_panel",
|
|
]
|
|
|
|
# Directories containing production pipeline artifacts (read-only).
|
|
# Symlinked into the test output directory so model notebooks can read them.
|
|
# Everything else (run_log/, results/, models/) is created fresh for writes.
|
|
_READ_ONLY_DIRS = {"config", "features", "labels"}
|
|
|
|
# Minimum stage number for model notebooks
|
|
_MODEL_STAGE_MIN = 6
|
|
|
|
# Suffixes that are NOT model notebooks (backtest, strategy, diagnostics).
|
|
# These depend on upstream predictions and should be tested separately.
|
|
_EXCLUDED_SUFFIXES = frozenset(
|
|
{
|
|
"backtest",
|
|
"backtest_sweep",
|
|
"backtest_analysis",
|
|
"portfolio_management",
|
|
"costs",
|
|
"risk_management",
|
|
"model_analysis",
|
|
"strategy_analysis",
|
|
"synthesis",
|
|
"ic_diagnostic",
|
|
"prediction_ingestion",
|
|
}
|
|
)
|
|
|
|
# Latent factor models need more symbols than other families because factor
|
|
# extraction requires a cross-section wide enough for the covariance matrix.
|
|
_LATENT_FACTOR_SUFFIXES = frozenset(
|
|
{
|
|
"latent_factors",
|
|
"pca",
|
|
"ipca",
|
|
"sdf",
|
|
"cae",
|
|
"sae",
|
|
"term_structure_pca",
|
|
}
|
|
)
|
|
_LATENT_FACTOR_OVERRIDES = {
|
|
"MAX_SYMBOLS": 10,
|
|
"N_FACTORS": 3,
|
|
}
|
|
|
|
# Case studies with sparse data (monthly frequency) need more symbols
|
|
# to have enough observations for CV splits.
|
|
_SPARSE_DATA_CASE_STUDIES = frozenset({"us_firm_characteristics"})
|
|
_SPARSE_DATA_OVERRIDES = {"MAX_SYMBOLS": 20}
|
|
|
|
# Minimal parameters for code-path coverage. Applied LAST so they
|
|
# override anything from overrides.yaml — we want the absolute minimum
|
|
# that still exercises the full train→register→predict loop.
|
|
_QUICK_PARAMS = {
|
|
"MAX_SYMBOLS": 3,
|
|
"MAX_FOLDS": 2,
|
|
"N_EPOCHS": 2,
|
|
"NUM_BOOST_ROUND": 20,
|
|
"BATCH_SIZE": 64,
|
|
"LOOKBACK": 24, # PatchTST needs lookback + stride >= patch_len (≥8); 24 leaves margin
|
|
"MAX_SAMPLES": 1000,
|
|
"CV_FOLDS": 2,
|
|
"N_PLACEBO": 3,
|
|
"N_FACTORS": 2,
|
|
"FORCE_RETRAIN": True,
|
|
}
|
|
|
|
# Model suffixes known to use register=True (training_runs + prediction_sets).
|
|
# Matched against the suffix after the NN_ prefix, since notebook numbers
|
|
# vary across case studies (e.g. causal_dml is 10, 11, 12, or 13 depending
|
|
# on the case study).
|
|
# Built from: grep -l "register=True" case_studies/*/[0-9][0-9]_*.py
|
|
_REGISTERING_SUFFIXES = frozenset(
|
|
{
|
|
"linear",
|
|
"gbm",
|
|
"tabular_dl",
|
|
"dl_lstm",
|
|
"dl_patchtst",
|
|
"dl_tsmixer",
|
|
"dl_nlinear",
|
|
"dl_tcn",
|
|
# NOTE: causal_dml notebooks register to ``causal_runs`` (DML effect
|
|
# estimates), not ``training_runs`` — so they are intentionally NOT in
|
|
# this set. Likewise ``NN_latent_factors`` is a thin index notebook that
|
|
# only displays the best already-registered factor IC; the factor models
|
|
# themselves register under their own sub-stems (pca/ipca/sdf/cae/sae,
|
|
# listed below). Both still execute; only the training-run-registration
|
|
# assertion is skipped for them.
|
|
"ipca",
|
|
"pca",
|
|
"sdf",
|
|
"cae",
|
|
"sae",
|
|
}
|
|
)
|
|
|
|
# DL notebooks use entry_point = "dl_{model}" (e.g. "dl_lstm") instead of
|
|
# the full filename stem (e.g. "09_dl_lstm"). Map stage stems to actual
|
|
# entry_point values for these notebooks.
|
|
_DL_RE = re.compile(r"^\d{2}_(dl_.+)$")
|
|
|
|
|
|
def _expected_entry_point(stage: str) -> str:
|
|
"""Return the entry_point value the notebook will use in the registry."""
|
|
m = _DL_RE.match(stage)
|
|
if m:
|
|
return m.group(1) # "09_dl_lstm" → "dl_lstm"
|
|
return stage # "06_linear" → "06_linear"
|
|
|
|
|
|
_STAGE_RE = re.compile(r"^(\d{2})_")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test collection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _collect_model_notebooks() -> list[tuple[str, str, Path]]:
|
|
"""Discover all model notebooks (stage >= 06) across case studies.
|
|
|
|
Returns (case_study, stage_stem, notebook_path) tuples sorted by
|
|
case study order then filename within each case study.
|
|
"""
|
|
tests = []
|
|
for cs in CASE_STUDIES:
|
|
cs_dir = PROD_CS_DIR / cs
|
|
if not cs_dir.exists():
|
|
continue
|
|
for notebook in sorted(cs_dir.glob("[0-9][0-9]_*.py")):
|
|
if notebook.name.startswith("_"):
|
|
continue
|
|
match = _STAGE_RE.match(notebook.name)
|
|
if not match:
|
|
continue
|
|
stage_num = int(match.group(1))
|
|
if stage_num < _MODEL_STAGE_MIN:
|
|
continue
|
|
# Skip non-model notebooks (backtest, strategy, diagnostics)
|
|
suffix = notebook.stem[len(match.group(0)) :]
|
|
if suffix in _EXCLUDED_SUFFIXES:
|
|
continue
|
|
tests.append((cs, notebook.stem, notebook))
|
|
return tests
|
|
|
|
|
|
MODEL_TESTS = _collect_model_notebooks()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def isolated_model_output(tmp_path_factory):
|
|
"""Create an isolated output directory with symlinked production data.
|
|
|
|
For each case study, symlinks read-only directories (config/, features/,
|
|
labels/) from the production case study directory so that model notebooks
|
|
can load upstream artifacts. Write-target directories (run_log/, results/,
|
|
models/) are NOT symlinked — they are created fresh by the notebooks.
|
|
|
|
Returns the temp root directory (passed as output_dir to run_notebook,
|
|
which sets ML4T_OUTPUT_DIR).
|
|
"""
|
|
import shutil
|
|
|
|
test_root = tmp_path_factory.mktemp("model_registry_test")
|
|
|
|
for cs in CASE_STUDIES:
|
|
prod_cs = PROD_CS_DIR / cs
|
|
if not prod_cs.exists():
|
|
continue
|
|
|
|
test_cs = test_root / cs
|
|
test_cs.mkdir()
|
|
|
|
for subdir in _READ_ONLY_DIRS:
|
|
src = prod_cs / subdir
|
|
if src.exists():
|
|
(test_cs / subdir).symlink_to(src.resolve())
|
|
|
|
# Seed the global preset library (case_studies/config/{model_type}/*.yaml).
|
|
# load_configs() resolves presets at {case_dir.parent}/config/, which maps
|
|
# to test_root/config/ when ML4T_OUTPUT_DIR is set. Without this, every
|
|
# notebook that loads GBM/DL/TabDL/latent/causal presets fails.
|
|
global_config_src = PROD_CS_DIR / "config"
|
|
global_config_dst = test_root / "config"
|
|
if global_config_src.exists():
|
|
shutil.copytree(global_config_src, global_config_dst)
|
|
# Patch presets for minimal runtime (2 epochs, etc.)
|
|
from tests.conftest import _patch_presets_for_testing
|
|
|
|
_patch_presets_for_testing(global_config_dst)
|
|
|
|
return test_root
|
|
|
|
|
|
LOG_PATH = Path("/tmp/model_registry_test.log")
|
|
|
|
|
|
@pytest.fixture(scope="session", autouse=True)
|
|
def _init_log():
|
|
"""Initialize the progress log and route Papermill cell output to it."""
|
|
import logging
|
|
import time
|
|
|
|
with open(LOG_PATH, "w") as f:
|
|
f.write(f"[{time.strftime('%H:%M:%S')}] === Model Registry Test Suite ===\n")
|
|
f.write(f"[{time.strftime('%H:%M:%S')}] {len(MODEL_TESTS)} tests collected\n")
|
|
f.flush()
|
|
|
|
# Route Papermill's cell-level progress + notebook print() output to log file.
|
|
# Papermill uses "papermill" logger (not "papermill.execute") for cell markers
|
|
# and captured output. We add a file handler so it goes to our log regardless
|
|
# of pytest's log level.
|
|
handler = logging.FileHandler(LOG_PATH)
|
|
handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S"))
|
|
for logger_name in ("papermill", "papermill.execute"):
|
|
logger = logging.getLogger(logger_name)
|
|
logger.addHandler(handler)
|
|
logger.setLevel(logging.INFO)
|
|
logger.propagate = False # Don't pollute pytest captured output
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _query_registry(db_path: Path, table: str, where: str = "") -> list[dict]:
|
|
"""Query a registry table and return rows as dicts."""
|
|
if not db_path.exists():
|
|
return []
|
|
db = sqlite3.connect(str(db_path))
|
|
db.row_factory = sqlite3.Row
|
|
try:
|
|
sql = f"SELECT * FROM {table}"
|
|
if where:
|
|
sql += f" WHERE {where}"
|
|
return [dict(r) for r in db.execute(sql).fetchall()]
|
|
except sqlite3.OperationalError:
|
|
return []
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _registry_summary(db_path: Path) -> dict:
|
|
"""Return a summary of registry contents for reporting."""
|
|
return {
|
|
"training_runs": len(_query_registry(db_path, "training_runs")),
|
|
"prediction_sets": len(_query_registry(db_path, "prediction_sets")),
|
|
"prediction_metrics": len(_query_registry(db_path, "prediction_metrics")),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"case_study,stage,notebook_path",
|
|
MODEL_TESTS,
|
|
ids=[f"{cs}::{stage}" for cs, stage, _ in MODEL_TESTS],
|
|
)
|
|
def test_model_notebook(case_study, stage, notebook_path, isolated_model_output):
|
|
"""Run a model notebook in isolation and verify registry output.
|
|
|
|
Steps:
|
|
1. Load per-notebook overrides (timeout, parameters, skip/gpu flags)
|
|
2. Merge with default reduced parameters (MAX_SYMBOLS=15, MAX_FOLDS=2)
|
|
3. Execute via Papermill with ML4T_OUTPUT_DIR → isolated temp dir
|
|
4. Assert successful completion
|
|
5. For notebooks with register=True, assert registry entries exist
|
|
"""
|
|
# --- Skip / override handling ---
|
|
rel_path = notebook_path.relative_to(REPO_ROOT).with_suffix("")
|
|
overrides = get_overrides(str(rel_path))
|
|
|
|
if overrides.get("skip"):
|
|
pytest.skip(overrides.get("skip_reason", "marked skip in overrides"))
|
|
|
|
if overrides.get("gpu"):
|
|
try:
|
|
import torch
|
|
|
|
if not torch.cuda.is_available():
|
|
pytest.skip("GPU required but not available")
|
|
except ImportError:
|
|
pytest.skip("torch not installed")
|
|
|
|
# --- Parameters ---
|
|
# Start with overrides.yaml, then apply ALL quick-test params on top.
|
|
# Quick params win — we want minimal runtime, not overrides.yaml scale.
|
|
# Papermill warns (but doesn't error) about unknown parameters, so it's
|
|
# safe to inject all of them even if the notebook doesn't use them all.
|
|
override_params = overrides.get("parameters", {})
|
|
parameters = {**override_params, **_QUICK_PARAMS}
|
|
|
|
# Latent factor models need a wider cross-section for factor extraction
|
|
stage_match_p = _STAGE_RE.match(stage)
|
|
suffix_p = stage[len(stage_match_p.group(0)) :] if stage_match_p else stage
|
|
if suffix_p in _LATENT_FACTOR_SUFFIXES:
|
|
parameters.update(_LATENT_FACTOR_OVERRIDES)
|
|
|
|
# Sparse-data case studies (monthly frequency) need more symbols
|
|
if case_study in _SPARSE_DATA_CASE_STUDIES:
|
|
parameters.update(_SPARSE_DATA_OVERRIDES)
|
|
|
|
default_timeout = 600 if suffix_p in _LATENT_FACTOR_SUFFIXES else 300
|
|
timeout = overrides.get("timeout", default_timeout)
|
|
|
|
# --- Snapshot registry state before run ---
|
|
registry_db = isolated_model_output / case_study / "run_log" / "registry.db"
|
|
before = _registry_summary(registry_db)
|
|
|
|
# --- Execute ---
|
|
result = run_notebook(
|
|
py_path=notebook_path,
|
|
parameters=parameters,
|
|
timeout=timeout,
|
|
output_dir=isolated_model_output,
|
|
log_path=LOG_PATH,
|
|
)
|
|
|
|
assert result["status"] == "ok", (
|
|
f"\n{'=' * 70}\nFAILED: {case_study}::{stage}\n{'=' * 70}\n{result['error']}\n{'=' * 70}"
|
|
)
|
|
|
|
# --- Registry assertions (for notebooks that register) ---
|
|
after = _registry_summary(registry_db)
|
|
|
|
# Check if this notebook is expected to register (match on suffix)
|
|
stage_match = _STAGE_RE.match(stage)
|
|
suffix = stage[len(stage_match.group(0)) :] if stage_match else stage
|
|
expects_registration = suffix in _REGISTERING_SUFFIXES
|
|
|
|
if expects_registration:
|
|
new_training = after["training_runs"] - before["training_runs"]
|
|
new_predictions = after["prediction_sets"] - before["prediction_sets"]
|
|
|
|
# Check for new entries OR updated entries (upserts).
|
|
# Some notebooks (e.g. 12_pca) re-register configs that were
|
|
# already created by an earlier notebook (11_latent_factors),
|
|
# resulting in upserts with 0 net new rows but updated entry_points.
|
|
expected_ep = _expected_entry_point(stage)
|
|
runs = _query_registry(
|
|
registry_db,
|
|
"training_runs",
|
|
f"entry_point = '{expected_ep}'",
|
|
)
|
|
|
|
if new_training > 0:
|
|
assert new_predictions > 0, (
|
|
f"{case_study}::{stage} created {new_training} training_runs "
|
|
f"but 0 new prediction_sets"
|
|
)
|
|
print(
|
|
f"\n Registry OK: +{new_training} training_runs, "
|
|
f"+{new_predictions} prediction_sets"
|
|
)
|
|
elif len(runs) > 0:
|
|
print(
|
|
f"\n Registry OK: {len(runs)} training_runs with "
|
|
f"entry_point='{expected_ep}' (upserted, no net new rows)"
|
|
)
|
|
else:
|
|
# Neither new entries nor matching entry_points — real failure
|
|
msg = (
|
|
f"{case_study}::{stage} has register=True but created "
|
|
f"0 new training_runs and found 0 with "
|
|
f"entry_point='{expected_ep}' (total: {after['training_runs']})"
|
|
)
|
|
raise AssertionError(msg)
|
|
else:
|
|
# Non-registering notebook — just report what happened
|
|
new_training = after["training_runs"] - before["training_runs"]
|
|
if new_training > 0:
|
|
print(
|
|
f"\n Note: {stage} created {new_training} training_runs "
|
|
f"(not in _REGISTERING_NOTEBOOKS set — consider adding)"
|
|
)
|
|
else:
|
|
print("\n OK (no registry writes expected)")
|