"""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)")