"""Tests for case_studies/utils/analytics.py — registry query contracts. Per memory rule ``feedback_results_json``: "Registry only, never JSONs." This module is the canonical path Ch6–Ch20 insights notebooks take to pull IC / AUC / Sharpe numbers into the book. A silent regression in any of these queries would corrupt every cross-chapter summary table. The tests pin three layers: 1. **Metadata invariants** — the handful of dicts that declare the 9 case-study IDs must agree on keys, so a new case study can't be added to one dict and forgotten in another. 2. **Path resolution** — ``_cs_dir`` / ``_registry_path`` honor ``ML4T_OUTPUT_DIR`` for test isolation. 3. **Query contracts** — against a seeded SQLite registry: - ``load_model_ic`` filters by family, split, case_studies list and returns the expected rows with a ``case_study`` label column. - ``load_classification_metrics`` requires ``task_type = 'classification'``. - ``load_best_ic_per_family`` picks max IC per (case_study, family) pair and optionally restricts to the primary label. - ``load_chapter_backtests("ch16")`` maps to ``stage="signal"`` and joins backtest_runs × backtest_metrics × prediction_sets × training_runs. - Spec helpers: ``extract_cost_bps`` sums commission + slippage from either v1 or v2 backtest specs; ``extract_allocator`` reads strategy.allocation.method. """ from __future__ import annotations import json import sqlite3 from pathlib import Path import polars as pl import pytest from case_studies.utils import analytics # ----------------------------------------------------------------------------- # Metadata invariants # ----------------------------------------------------------------------------- def test_case_study_ids_match_metadata_keys() -> None: assert list(analytics.CASE_STUDY_META.keys()) == analytics.CASE_STUDY_IDS @pytest.mark.parametrize( "dict_name", ["PRIMARY_LABELS", "SHORT_NAMES", "DATASET_META", "CADENCE_MAP", "DISPLAY_NAMES"], ) def test_metadata_dict_keys_match_case_study_ids(dict_name) -> None: """Every metadata dict must enumerate the same 9 case studies — otherwise cross-dict joins in load_best_ic_per_family / load_chapter_backtests drop rows silently. """ d = getattr(analytics, dict_name) assert set(d.keys()) == set(analytics.CASE_STUDY_IDS), ( f"{dict_name} keys differ: missing {set(analytics.CASE_STUDY_IDS) - set(d.keys())}, " f"extra {set(d.keys()) - set(analytics.CASE_STUDY_IDS)}" ) def test_primary_labels_are_non_empty_strings() -> None: for cs, lbl in analytics.PRIMARY_LABELS.items(): assert isinstance(lbl, str) and lbl, f"{cs}: empty/non-string primary label" # ----------------------------------------------------------------------------- # Path resolution # ----------------------------------------------------------------------------- def test_cs_dir_production_path(monkeypatch) -> None: """With no ML4T_OUTPUT_DIR, _cs_dir falls back to REPO_ROOT/case_studies.""" monkeypatch.delenv("ML4T_OUTPUT_DIR", raising=False) from utils.paths import REPO_ROOT assert analytics._cs_dir() == REPO_ROOT / "case_studies" def test_cs_dir_redirects_to_output_dir_when_registry_present(tmp_path, monkeypatch) -> None: """With ML4T_OUTPUT_DIR set AND a registry.db present under tmp, _cs_dir returns the tmp root instead of the production case_studies path. """ monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path)) (tmp_path / "etfs" / "run_log").mkdir(parents=True) (tmp_path / "etfs" / "run_log" / "registry.db").touch() assert analytics._cs_dir("etfs") == tmp_path def test_cs_dir_falls_back_when_registry_missing_under_output_dir(tmp_path, monkeypatch) -> None: """ML4T_OUTPUT_DIR set but no registry.db under it → fall back to production.""" monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path)) from utils.paths import REPO_ROOT assert analytics._cs_dir("etfs") == REPO_ROOT / "case_studies" def test_registry_path_is_three_levels_deep(tmp_path, monkeypatch) -> None: monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path)) (tmp_path / "etfs" / "run_log").mkdir(parents=True) (tmp_path / "etfs" / "run_log" / "registry.db").touch() p = analytics._registry_path("etfs") assert p == tmp_path / "etfs" / "run_log" / "registry.db" # ----------------------------------------------------------------------------- # _query behavior on empty / missing databases # ----------------------------------------------------------------------------- def test_query_returns_empty_df_when_path_missing(tmp_path) -> None: missing = tmp_path / "nope.db" out = analytics._query(missing, "SELECT 1") assert isinstance(out, pl.DataFrame) assert out.is_empty() def test_query_returns_empty_df_when_no_rows(tmp_path) -> None: db = tmp_path / "empty.db" conn = sqlite3.connect(str(db)) conn.execute("CREATE TABLE t (x INTEGER)") conn.commit() conn.close() out = analytics._query(db, "SELECT * FROM t") assert out.is_empty() def test_query_returns_populated_df(tmp_path) -> None: db = tmp_path / "rows.db" conn = sqlite3.connect(str(db)) conn.execute("CREATE TABLE t (x INTEGER, y TEXT)") conn.executemany("INSERT INTO t VALUES (?, ?)", [(1, "a"), (2, "b")]) conn.commit() conn.close() out = analytics._query(db, "SELECT * FROM t ORDER BY x") assert out.to_dicts() == [{"x": 1, "y": "a"}, {"x": 2, "y": "b"}] # ----------------------------------------------------------------------------- # Seeded-registry fixture: builds the schema + minimal rows # ----------------------------------------------------------------------------- def _create_registry_schema(conn: sqlite3.Connection) -> None: """Create the subset of the registry schema the analytics queries touch.""" conn.executescript( """ CREATE TABLE 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 ); CREATE TABLE prediction_sets ( prediction_hash TEXT PRIMARY KEY, training_hash TEXT NOT NULL, checkpoint_value INTEGER, checkpoint_kind TEXT, split TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE prediction_metrics ( prediction_hash TEXT PRIMARY KEY, computed_at TEXT NOT NULL, ic_mean REAL, ic_std REAL, ic_t REAL, n_folds REAL, pct_positive REAL, task_type TEXT, accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL, log_loss REAL, brier_score REAL ); CREATE TABLE backtest_runs ( backtest_hash TEXT PRIMARY KEY, prediction_hash TEXT NOT NULL, spec_json TEXT, stage TEXT, created_at TEXT NOT NULL ); CREATE TABLE backtest_metrics ( backtest_hash TEXT PRIMARY KEY, computed_at TEXT NOT NULL, sharpe REAL, sortino REAL, total_return REAL, max_drawdown REAL ); CREATE TABLE fold_metrics ( prediction_hash TEXT NOT NULL, fold_id INTEGER NOT NULL, computed_at TEXT NOT NULL, ic REAL, PRIMARY KEY (prediction_hash, fold_id) ); """ ) def _seed_registry(db_path: Path) -> None: """Populate a registry with 4 training runs + predictions + 2 backtests. Layout (etfs): linear_a / fwd_ret_21d / validation / ic=0.05 / regression linear_b / fwd_ret_21d / validation / ic=0.08 / regression <- best linear gbm_a / fwd_ret_21d / validation / ic=0.10 / regression <- best gbm (primary) gbm_a / fwd_ret_21d / holdout / ic=0.03 / regression linear_c / fwd_dir_5d / validation / ic=0.04 / classification (task_type='classification') Plus 1 ch16 (signal) backtest and 1 ch17 (allocation) backtest on the same gbm prediction_hash. """ db_path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(str(db_path)) _create_registry_schema(conn) # training_runs conn.executemany( "INSERT INTO training_runs VALUES (?, ?, ?, ?, ?, ?)", [ ("th_lin_a", "linear", "fwd_ret_21d", "ridge_a100", None, "2024-01-01T00:00:00"), ("th_lin_b", "linear", "fwd_ret_21d", "ridge_b100", None, "2024-01-01T00:00:00"), ("th_gbm_a", "gbm", "fwd_ret_21d", "default", None, "2024-01-01T00:00:00"), ("th_lin_c", "linear", "fwd_dir_5d", "logistic", None, "2024-01-01T00:00:00"), ], ) # prediction_sets conn.executemany( "INSERT INTO prediction_sets VALUES (?, ?, ?, ?, ?, ?)", [ ("ph_lin_a_val", "th_lin_a", 0, "final", "validation", "2024-01-02T00:00:00"), ("ph_lin_b_val", "th_lin_b", 0, "final", "validation", "2024-01-02T00:00:00"), ("ph_gbm_a_val", "th_gbm_a", 100, "final", "validation", "2024-01-02T00:00:00"), ("ph_gbm_a_hol", "th_gbm_a", 100, "final", "holdout", "2024-01-02T00:00:00"), ("ph_lin_c_val", "th_lin_c", 0, "final", "validation", "2024-01-02T00:00:00"), ], ) # prediction_metrics — regression and classification conn.executemany( "INSERT INTO prediction_metrics (prediction_hash, computed_at, ic_mean, task_type, " "auc_roc, accuracy) VALUES (?, ?, ?, ?, ?, ?)", [ ("ph_lin_a_val", "2024-01-03", 0.05, "regression", None, None), ("ph_lin_b_val", "2024-01-03", 0.08, "regression", None, None), ("ph_gbm_a_val", "2024-01-03", 0.10, "regression", None, None), ("ph_gbm_a_hol", "2024-01-03", 0.03, "regression", None, None), ("ph_lin_c_val", "2024-01-03", 0.04, "classification", 0.62, 0.55), ], ) # backtest_runs — one signal (ch16) + one allocation (ch17) on the gbm prediction spec_v2 = { "version": 2, "strategy": {"allocation": {"method": "inverse_vol"}}, "backtest_config": { "commission": {"rate": 0.0005}, # 5 bps "slippage": {"rate": 0.0003}, # 3 bps }, } conn.executemany( "INSERT INTO backtest_runs VALUES (?, ?, ?, ?, ?)", [ ("bh_sig", "ph_gbm_a_val", json.dumps(spec_v2), "signal", "2024-01-04"), ("bh_alloc", "ph_gbm_a_val", json.dumps(spec_v2), "allocation", "2024-01-04"), ], ) conn.executemany( "INSERT INTO backtest_metrics (backtest_hash, computed_at, sharpe, sortino, " "total_return, max_drawdown) VALUES (?, ?, ?, ?, ?, ?)", [ ("bh_sig", "2024-01-05", 1.2, 1.8, 0.35, -0.10), ("bh_alloc", "2024-01-05", 1.5, 2.2, 0.45, -0.08), ], ) conn.commit() conn.close() @pytest.fixture def seeded_registries(tmp_path, monkeypatch) -> Path: """Build registries for etfs + crypto_perps_funding under a temp output dir. The second case study (crypto) is intentionally empty (only schema) so multi-case-study queries have a no-op partition to merge against. """ monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path)) _seed_registry(tmp_path / "etfs" / "run_log" / "registry.db") # crypto: schema but no rows crypto_db = tmp_path / "crypto_perps_funding" / "run_log" / "registry.db" crypto_db.parent.mkdir(parents=True) conn = sqlite3.connect(str(crypto_db)) _create_registry_schema(conn) conn.commit() conn.close() return tmp_path # ----------------------------------------------------------------------------- # load_model_ic # ----------------------------------------------------------------------------- def test_load_model_ic_returns_all_families_by_default(seeded_registries) -> None: df = analytics.load_model_ic(case_studies=["etfs", "crypto_perps_funding"], split="validation") # etfs: 3 validation rows (lin_a, lin_b, gbm_a) with regression task_type; # lin_c is also validation but the query doesn't filter task_type here. assert df.height == 4 assert set(df["case_study"].unique().to_list()) == {"etfs"} assert set(df["family"].unique().to_list()) == {"linear", "gbm"} def test_load_model_ic_filters_by_family(seeded_registries) -> None: df = analytics.load_model_ic(families="gbm", case_studies=["etfs"], split="validation") assert df["family"].unique().to_list() == ["gbm"] assert df.height == 1 def test_load_model_ic_filters_by_split_holdout(seeded_registries) -> None: df = analytics.load_model_ic(case_studies=["etfs"], split="holdout") # only gbm_a has a holdout prediction assert df.height == 1 assert df["split"].to_list() == ["holdout"] assert df["ic_mean"].to_list() == [0.03] def test_load_model_ic_returns_empty_when_no_case_study_has_data(seeded_registries) -> None: df = analytics.load_model_ic(case_studies=["crypto_perps_funding"], split="validation") assert df.is_empty() def test_load_model_ic_has_case_study_label_column(seeded_registries) -> None: df = analytics.load_model_ic(case_studies=["etfs"], split="validation") assert "case_study" in df.columns assert df["case_study"].unique().to_list() == ["etfs"] # ----------------------------------------------------------------------------- # load_classification_metrics # ----------------------------------------------------------------------------- def test_load_classification_metrics_filters_task_type_eq_1(seeded_registries) -> None: """Only the linear_c row (task_type='classification') should come back.""" df = analytics.load_classification_metrics(case_studies=["etfs"], split="validation") assert df.height == 1 assert df["family"].to_list() == ["linear"] assert df["auc_roc"].to_list() == [0.62] assert df["task_type"].to_list() == ["classification"] def test_load_classification_metrics_excludes_regression_rows(seeded_registries) -> None: """Regression rows (task_type='regression') must not leak into the classification view.""" df = analytics.load_classification_metrics(case_studies=["etfs"], split="validation") # Spec: no rows with null AUC should appear assert df.filter(pl.col("auc_roc").is_null()).is_empty() # ----------------------------------------------------------------------------- # load_best_ic_per_family # ----------------------------------------------------------------------------- def test_load_best_ic_per_family_picks_max_per_pair(seeded_registries) -> None: """Primary label for etfs is fwd_ret_21d. Among linear runs on that label, lin_b (IC=0.08) beats lin_a (IC=0.05). gbm has only one run (IC=0.10). """ best = analytics.load_best_ic_per_family(case_studies=["etfs"], split="validation") rows = {(r["case_study"], r["family"]): r for r in best.to_dicts()} assert rows[("etfs", "linear")]["ic_mean"] == 0.08 assert rows[("etfs", "linear")]["config_name"] == "ridge_b100" assert rows[("etfs", "gbm")]["ic_mean"] == 0.10 def test_load_best_ic_per_family_primary_label_excludes_other_labels( seeded_registries, ) -> None: """With use_primary_label=True (default), the linear_c run on fwd_dir_5d must not appear — only rows with label == primary are kept. """ best = analytics.load_best_ic_per_family(case_studies=["etfs"], split="validation") assert all(row["label"] == "fwd_ret_21d" for row in best.to_dicts()) def test_load_best_ic_per_family_use_primary_label_false_includes_all( seeded_registries, ) -> None: """With use_primary_label=False, the fwd_dir_5d row competes for best linear.""" best = analytics.load_best_ic_per_family( case_studies=["etfs"], split="validation", use_primary_label=False ) # linear: best of {lin_a 0.05, lin_b 0.08, lin_c 0.04} is lin_b linear_row = next(r for r in best.to_dicts() if r["family"] == "linear") assert linear_row["ic_mean"] == 0.08 def test_load_best_ic_per_family_adds_display_name(seeded_registries) -> None: best = analytics.load_best_ic_per_family(case_studies=["etfs"], split="validation") assert "display_name" in best.columns assert best["display_name"].unique().to_list() == ["ETFs"] # ----------------------------------------------------------------------------- # load_chapter_backtests # ----------------------------------------------------------------------------- def test_load_chapter_backtests_ch16_maps_to_signal_stage(seeded_registries) -> None: """chapter='ch16' → stage='signal' → returns the bh_sig backtest row.""" df = analytics.load_chapter_backtests("ch16", case_studies=["etfs"]) assert df.height == 1 assert df["backtest_hash"].to_list() == ["bh_sig"] def test_load_chapter_backtests_explicit_stage_overrides_chapter(seeded_registries) -> None: df = analytics.load_chapter_backtests("ch16", stage="allocation", case_studies=["etfs"]) assert df["backtest_hash"].to_list() == ["bh_alloc"] def test_load_chapter_backtests_joins_sharpe_and_training_columns(seeded_registries) -> None: df = analytics.load_chapter_backtests("ch17", case_studies=["etfs"]) assert df.height == 1 row = df.to_dicts()[0] assert row["sharpe"] == 1.5 assert row["family"] == "gbm" assert row["config_name"] == "default" def test_load_chapter_backtests_metrics_filter_selects_columns(seeded_registries) -> None: df = analytics.load_chapter_backtests("ch17", case_studies=["etfs"], metrics=["sharpe"]) # Only meta columns + sharpe; sortino/total_return/max_drawdown excluded assert "sharpe" in df.columns assert "sortino" not in df.columns assert "total_return" not in df.columns def test_load_chapter_backtests_returns_empty_for_unused_stage(seeded_registries) -> None: df = analytics.load_chapter_backtests("ch18", case_studies=["etfs"]) assert df.is_empty() # ----------------------------------------------------------------------------- # Spec helpers # ----------------------------------------------------------------------------- def test_parse_backtest_spec_round_trips_json() -> None: spec = {"version": 2, "strategy": {"foo": "bar"}, "backtest_config": {}} assert analytics.parse_backtest_spec(json.dumps(spec)) == spec def test_extract_cost_bps_sums_commission_and_slippage_from_v2_spec() -> None: spec_json = json.dumps( { "version": 2, "strategy": {}, "backtest_config": { "commission": {"rate": 0.0005}, # 5 bps "slippage": {"rate": 0.0003}, # 3 bps }, } ) assert analytics.extract_cost_bps(spec_json) == pytest.approx(8.0) def test_extract_cost_bps_handles_v1_spec() -> None: """v1 specs store costs in a flat ``costs`` dict; cost_view falls back to it.""" spec_json = json.dumps({"costs": {"commission_bps": 2.0, "slippage_bps": 1.5}}) assert analytics.extract_cost_bps(spec_json) == pytest.approx(3.5) def test_extract_allocator_reads_strategy_allocation_method() -> None: spec_json = json.dumps( { "version": 2, "strategy": {"allocation": {"method": "risk_parity"}}, "backtest_config": {}, } ) assert analytics.extract_allocator(spec_json) == "risk_parity" def test_extract_allocator_defaults_to_unknown_when_missing() -> None: spec_json = json.dumps({"version": 2, "strategy": {}, "backtest_config": {}}) assert analytics.extract_allocator(spec_json) == "unknown"