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

1101 lines
36 KiB
Python

"""Query and read APIs for experiment registry data."""
from __future__ import annotations
import json
import logging
import sqlite3
from pathlib import Path
import polars as pl
from ..notebook_contracts import (
degenerate_prediction_sql,
excluded_family_sql,
filter_active_model_rows,
)
from .specs import canonical_json
from .store import (
_backtest_dir,
_case_dir,
_infer_stage,
_open_registry,
_prediction_dir,
_registry_db_path,
_run_log_dir,
_stage_filter_clause,
_training_dir,
)
logger = logging.getLogger(__name__)
# SQLite's SQLITE_MAX_VARIABLE_NUMBER defaults to 999 on builds prior to 3.32
# (still common on system pythons and some CI images). Chunk IN-clause
# parameters at half that to leave headroom for other bound parameters in
# the same statement.
_IN_CLAUSE_CHUNK = 500
def _table_exists(case_dir: Path, table: str) -> bool:
"""Probe ``sqlite_master`` for a table. Returns False when the registry
is missing on disk."""
db_path = _registry_db_path(case_dir)
if not db_path.exists():
return False
db = sqlite3.connect(str(db_path))
try:
row = db.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
(table,),
).fetchone()
return row is not None
finally:
db.close()
def _existing_prediction_hashes(case_dir: Path) -> set[str] | None:
"""Return prediction hashes that have physical parquet files, or None to skip filtering.
Returns None only when the predictions directory doesn't exist (no filtering).
Returns empty set when the directory exists but has no valid files (filters everything out).
"""
pred_base = _run_log_dir(case_dir) / "predictions"
if not pred_base.exists():
return None
return {d.name for d in pred_base.iterdir() if (d / "predictions.parquet").exists()}
def _query_table(
case_dir: Path,
query: str,
params: tuple = (),
):
"""Execute a query and return a Polars DataFrame."""
import polars as pl
db_path = _registry_db_path(case_dir)
if not db_path.exists():
return pl.DataFrame()
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
try:
rows = db.execute(query, params).fetchall()
if not rows:
return pl.DataFrame()
return pl.DataFrame([dict(r) for r in rows], infer_schema_length=None)
finally:
db.close()
def load_training_runs(
case_study: str,
*,
family: str | None = None,
label: str | None = None,
case_dir: Path | None = None,
):
"""Load training runs, optionally filtered by family and/or label."""
if case_dir is None:
case_dir = _case_dir(case_study)
query = "SELECT * FROM training_runs"
conditions = []
params: list[str] = []
if family:
conditions.append("family = ?")
params.append(family)
if label:
conditions.append("label = ?")
params.append(label)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY created_at DESC"
return _query_table(case_dir, query, tuple(params))
def load_prediction_sets(
case_study: str,
*,
training_hash: str | None = None,
split: str | None = None,
case_dir: Path | None = None,
):
"""Load prediction sets, optionally filtered."""
if case_dir is None:
case_dir = _case_dir(case_study)
query = "SELECT * FROM prediction_sets"
conditions = []
params: list[str] = []
if training_hash:
conditions.append("training_hash = ?")
params.append(training_hash)
if split:
conditions.append("split = ?")
params.append(split)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY created_at DESC"
return _query_table(case_dir, query, tuple(params))
def load_prediction_metrics(
case_study: str,
*,
prediction_hash: str | None = None,
case_dir: Path | None = None,
):
"""Load prediction metrics (wide format), optionally filtered."""
if case_dir is None:
case_dir = _case_dir(case_study)
query = "SELECT * FROM prediction_metrics"
conditions = []
params: list[str] = []
if prediction_hash:
conditions.append("prediction_hash = ?")
params.append(prediction_hash)
if conditions:
query += " WHERE " + " AND ".join(conditions)
return _query_table(case_dir, query, tuple(params))
def load_backtest_runs(
case_study: str,
*,
prediction_hash: str | None = None,
case_dir: Path | None = None,
):
"""Load backtest runs, optionally filtered by prediction_hash."""
if case_dir is None:
case_dir = _case_dir(case_study)
query = "SELECT * FROM backtest_runs"
params: list[str] = []
if prediction_hash:
query += " WHERE prediction_hash = ?"
params.append(prediction_hash)
query += " ORDER BY created_at DESC"
return _query_table(case_dir, query, tuple(params))
def load_backtest_metrics(
case_study: str,
*,
backtest_hash: str | None = None,
case_dir: Path | None = None,
):
"""Load backtest metrics (wide format), optionally filtered.
Selection-bias columns (``dsr``, ``dsr_pvalue``, ``expected_max_sharpe``,
``min_trl_periods``, ``pbo``, ``reality_check_*``, ``k_variants``,
``leader_min_trl``) are overridden with the effective-rank (ER) values
from the persisted ``cohort_metrics`` table (cohort_type='family',
leader_hash=backtest_hash) when a matching cohort row exists. Rows that
are not the leader of any (stage, label, family) cohort retain the
underlying ``backtest_metrics`` values (typically NULL).
"""
if case_dir is None:
case_dir = _case_dir(case_study)
query = "SELECT * FROM backtest_metrics"
conditions = []
params: list[str] = []
if backtest_hash:
conditions.append("backtest_hash = ?")
params.append(backtest_hash)
if conditions:
query += " WHERE " + " AND ".join(conditions)
df = _query_table(case_dir, query, tuple(params))
if df.is_empty():
return df
# LEFT JOIN cohort_metrics (cohort_type='family') and override the
# selection-bias columns with ER values where available.
hashes = df["backtest_hash"].to_list()
overrides = {
"dsr": "_cm_dsr",
"dsr_pvalue": "_cm_dsr_pvalue",
"expected_max_sharpe": "_cm_expected_max_sharpe",
"min_trl_periods": "_cm_min_trl_periods",
"k_variants": "_cm_k_variants",
"leader_min_trl": "_cm_leader_min_trl",
"pbo": "_cm_pbo",
"pbo_n_combinations": "_cm_pbo_n_combinations",
"pbo_median_oos_rank": "_cm_pbo_median_oos_rank",
"pbo_mean_degradation": "_cm_pbo_mean_degradation",
"pbo_n_folds": "_cm_pbo_n_folds",
"reality_check_pvalue": "_cm_reality_check_pvalue",
"reality_check_statistic": "_cm_reality_check_statistic",
"reality_check_k": "_cm_reality_check_k",
}
def _ensure_legacy_columns(out: pl.DataFrame) -> pl.DataFrame:
"""Post-Phase-H: backtest_metrics dropped the legacy selection-
bias columns. If a consumer indexes the row by these names
(e.g., ``full["dsr"]``), missing columns raise KeyError. Add
them as null Float64 so consumers see ``None`` instead.
"""
missing = [c for c in overrides if c not in out.columns]
if missing:
out = out.with_columns([pl.lit(None, dtype=pl.Float64).alias(c) for c in missing])
return out
# Pre-migration registries (some CSes never got the cohort_metrics
# backfill) lack the table entirely. Probe sqlite_master so a missing
# table is distinguishable from a SQL syntax error / disk I/O error
# downstream — narrower than the previous bare ``OperationalError``
# catch which swallowed every SQLite failure mode (typos, locks, the
# IN-limit overflow this function now chunks around).
if not _table_exists(case_dir, "cohort_metrics"):
return _ensure_legacy_columns(df)
# ORDER BY makes the dedupe deterministic when two family cohorts share
# a leader_hash (warning path below). SQLite row order is otherwise
# unspecified, so the ER override values that survive `unique(keep=
# 'first')` would silently differ run-to-run. Carrying stage/label/
# family through the SELECT (under _cm_ aliases dropped before the
# join) lets the warning report which cohort row was kept.
cm_query_template = """
SELECT leader_hash,
stage AS _cm_stage,
label AS _cm_label,
family AS _cm_family,
dsr_er AS _cm_dsr,
dsr_er_pvalue AS _cm_dsr_pvalue,
expected_max_sharpe_er AS _cm_expected_max_sharpe,
min_trl_periods_er AS _cm_min_trl_periods,
k_variants AS _cm_k_variants,
leader_min_trl AS _cm_leader_min_trl,
pbo AS _cm_pbo,
pbo_n_combinations AS _cm_pbo_n_combinations,
pbo_median_oos_rank AS _cm_pbo_median_oos_rank,
pbo_mean_degradation AS _cm_pbo_mean_degradation,
pbo_n_folds AS _cm_pbo_n_folds,
reality_check_pvalue AS _cm_reality_check_pvalue,
reality_check_statistic AS _cm_reality_check_statistic,
reality_check_k AS _cm_reality_check_k
FROM cohort_metrics
WHERE cohort_type = 'family'
AND leader_hash IN ({placeholders})
ORDER BY stage, label, family
"""
cm_frames: list[pl.DataFrame] = []
for start in range(0, len(hashes), _IN_CLAUSE_CHUNK):
chunk = hashes[start : start + _IN_CLAUSE_CHUNK]
placeholders = ",".join("?" for _ in chunk)
chunk_df = _query_table(
case_dir, cm_query_template.format(placeholders=placeholders), tuple(chunk)
)
if not chunk_df.is_empty():
cm_frames.append(chunk_df)
if not cm_frames:
return _ensure_legacy_columns(df)
cm_df = pl.concat(cm_frames, how="vertical_relaxed")
# Guard against duplicate (leader_hash) rows in cohort_metrics. The
# unique index is on (cohort_type, stage, label, family), so the same
# backtest_hash can be the leader of two different family cohorts.
# When that happens, a naive LEFT JOIN fans out — one row of
# backtest_metrics becomes multiple rows. Detect and warn, keeping
# the first occurrence so the join still has exactly the original
# row cardinality.
if cm_df["leader_hash"].n_unique() < cm_df.height:
dupe_hashes = (
cm_df.group_by("leader_hash")
.agg(pl.len().alias("n"))
.filter(pl.col("n") > 1)["leader_hash"]
.to_list()
)
# ORDER BY stage, label, family in the chunk SQL plus this
# final sort make `unique(keep='first')` deterministic across
# runs. Without it, SQLite row order is unspecified and the
# surviving ER values for any duplicate-leader hashes would
# silently churn.
cm_df = cm_df.sort(["leader_hash", "_cm_stage", "_cm_label", "_cm_family"])
cm_df = cm_df.unique(subset=["leader_hash"], keep="first")
kept = (
cm_df.filter(pl.col("leader_hash").is_in(dupe_hashes))
.select(
pl.col("leader_hash"),
pl.col("_cm_stage").alias("stage"),
pl.col("_cm_label").alias("label"),
pl.col("_cm_family").alias("family"),
)
.head(5)
.to_dicts()
)
logger.warning(
"cohort_metrics has duplicate family-cohort leader_hash rows for %s "
"(n=%d); keeping first by ORDER BY stage, label, family. "
"Kept (first 5): %s",
case_study,
len(dupe_hashes),
kept,
)
# Drop the stage/label/family helper columns — they exist only to
# make the dedupe deterministic and surface the kept row in the
# warning above.
cm_df = cm_df.drop(["_cm_stage", "_cm_label", "_cm_family"])
df = df.join(cm_df, left_on="backtest_hash", right_on="leader_hash", how="left")
exprs = []
for base, cm_col in overrides.items():
if cm_col not in df.columns:
continue
if base in df.columns:
# Both present (pre-Phase-H registries): coalesce — cohort
# wins where the row is a family leader, backtest_metrics
# passes through otherwise.
exprs.append(pl.coalesce(pl.col(cm_col), pl.col(base)).alias(base))
else:
# Post-Phase-H: backtest_metrics no longer carries the
# legacy column; alias the cohort_metrics value under the
# base name so consumers keep working.
exprs.append(pl.col(cm_col).alias(base))
if exprs:
df = df.with_columns(exprs)
drop_cols = [c for c in overrides.values() if c in df.columns]
if drop_cols:
df = df.drop(drop_cols)
return _ensure_legacy_columns(df)
def load_backtest_fold_metrics(
case_study: str,
*,
backtest_hash: str | None = None,
case_dir: Path | None = None,
):
"""Load per-fold backtest metrics from `backtest_fold_metrics`.
Wide-format fold-level breakdown (Sharpe, Sortino, max DD, ann return,
Calmar with 95% CIs and bootstrap metadata). Filter by backtest_hash to
pull a single rank-1 lineage.
"""
if case_dir is None:
case_dir = _case_dir(case_study)
query = "SELECT * FROM backtest_fold_metrics"
conditions = []
params: list[str] = []
if backtest_hash:
conditions.append("backtest_hash = ?")
params.append(backtest_hash)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY fold_id"
return _query_table(case_dir, query, tuple(params))
# ---------------------------------------------------------------------------
# Cross-case-study queries
# ---------------------------------------------------------------------------
def load_all_training_runs():
"""Load training runs from all case studies."""
import polars as pl
from utils.paths import REPO_ROOT
cs_dir = REPO_ROOT / "case_studies"
frames = []
for d in sorted(cs_dir.iterdir()):
if not d.is_dir() or d.name.startswith("_"):
continue
db_path = _registry_db_path(d)
if not db_path.exists():
continue
df = _query_table(d, "SELECT * FROM training_runs")
if len(df) > 0:
frames.append(df.with_columns(pl.lit(d.name).alias("case_study")))
return pl.concat(frames, how="diagonal") if frames else pl.DataFrame()
def load_all_prediction_metrics():
"""Load prediction metrics from all case studies."""
import polars as pl
from utils.paths import REPO_ROOT
cs_dir = REPO_ROOT / "case_studies"
frames = []
for d in sorted(cs_dir.iterdir()):
if not d.is_dir() or d.name.startswith("_"):
continue
db_path = _registry_db_path(d)
if not db_path.exists():
continue
df = _query_table(d, "SELECT * FROM prediction_metrics")
if len(df) > 0:
frames.append(df.with_columns(pl.lit(d.name).alias("case_study")))
return pl.concat(frames, how="diagonal") if frames else pl.DataFrame()
# ---------------------------------------------------------------------------
# Backtest-oriented queries
# ---------------------------------------------------------------------------
def load_prediction_index(
case_study: str,
*,
label: str | None = None,
split: str | None = None,
family: str | None = None,
case_dir: Path | None = None,
):
"""Load a prediction index joining training_runs, prediction_sets, and IC metrics.
Returns a DataFrame with columns:
prediction_hash, training_hash, family, config_name, label,
split, checkpoint_value, source, ic_mean, predictions_path
The ``source`` column follows the convention ``{family}/{split}/{config_name}``.
"""
import polars as pl
if case_dir is None:
case_dir = _case_dir(case_study)
db_path = _registry_db_path(case_dir)
if not db_path.exists():
print(f"\n No model registry found for '{case_study}'.")
print(" Run a model notebook first to generate predictions:")
print(f" uv run python case_studies/{case_study}/06_linear.py\n")
return pl.DataFrame()
query = """
SELECT
p.prediction_hash,
p.training_hash,
t.family,
t.config_name,
t.label,
p.split,
p.checkpoint_value,
m.ic_mean
FROM prediction_sets p
JOIN training_runs t ON p.training_hash = t.training_hash
LEFT JOIN prediction_metrics m
ON p.prediction_hash = m.prediction_hash
"""
conditions = []
params: list[str] = []
exclude_clause, exclude_params = excluded_family_sql(case_study, "t.family", for_backtest=True)
if label:
conditions.append("t.label = ?")
params.append(label)
if split and split != "all":
conditions.append("p.split = ?")
params.append(split)
if family:
conditions.append("t.family = ?")
params.append(family)
if exclude_clause:
conditions.append(exclude_clause.removeprefix(" AND "))
params.extend(exclude_params)
# Exclude prediction sets with any constant-prediction (NULL-IC) fold so they
# never enter a backtest sweep — see degenerate_prediction_sql().
conditions.append(degenerate_prediction_sql("p.prediction_hash").removeprefix(" AND "))
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY m.ic_mean DESC NULLS LAST"
df = _query_table(case_dir, query, tuple(params))
if df.is_empty():
return df
df = filter_active_model_rows(df, case_study)
if df.is_empty():
return df
# Build source string: {family}/{split}/{config_name}
df = df.with_columns(
(
pl.col("family") + pl.lit("/") + pl.col("split") + pl.lit("/") + pl.col("config_name")
).alias("source"),
)
# Add predictions_path and filter to entries with physical files.
# Seeded registries may contain production hashes without parquet files;
# CI model runs produce fresh entries with real files.
pred_base = _run_log_dir(case_dir) / "predictions"
df = df.with_columns(
(
pl.lit(str(pred_base))
+ pl.lit("/")
+ pl.col("prediction_hash")
+ pl.lit("/predictions.parquet")
).alias("predictions_path"),
)
existing = _existing_prediction_hashes(case_dir)
if existing is not None:
df = df.filter(pl.col("prediction_hash").is_in(list(existing)))
return df
# ---------------------------------------------------------------------------
# Spec file I/O
# ---------------------------------------------------------------------------
def read_training_spec(
case_study: str,
training_hash: str,
*,
case_dir: Path | None = None,
) -> dict:
"""Read the spec.json for a training run."""
if case_dir is None:
case_dir = _case_dir(case_study)
path = _training_dir(case_dir, training_hash) / "spec.json"
if not path.exists():
# Fall back to config.json for migrated entries
path = _training_dir(case_dir, training_hash) / "config.json"
config = json.loads(path.read_text())
return config.get("spec", config)
return json.loads(path.read_text())
def read_backtest_spec(
case_study: str,
backtest_hash: str,
*,
case_dir: Path | None = None,
) -> dict:
"""Read the spec.json for a backtest run."""
if case_dir is None:
case_dir = _case_dir(case_study)
path = _backtest_dir(case_dir, backtest_hash) / "spec.json"
if not path.exists():
path = _backtest_dir(case_dir, backtest_hash) / "config.json"
config = json.loads(path.read_text())
return config.get("spec", config)
return json.loads(path.read_text())
def read_predictions(
case_study: str,
prediction_hash: str,
*,
case_dir: Path | None = None,
):
"""Read predictions.parquet for a prediction set."""
import polars as pl
if case_dir is None:
case_dir = _case_dir(case_study)
path = _prediction_dir(case_dir, prediction_hash) / "predictions.parquet"
df = pl.read_parquet(path)
# Normalize legacy column names to canonical schema
renames = {}
if "actual" in df.columns and "y_true" not in df.columns:
renames["actual"] = "y_true"
if "prediction" in df.columns and "y_score" not in df.columns:
renames["prediction"] = "y_score"
if "fold" in df.columns and "fold_id" not in df.columns:
renames["fold"] = "fold_id"
if renames:
df = df.rename(renames)
return df
# ---------------------------------------------------------------------------
# Filesystem helpers (public)
# ---------------------------------------------------------------------------
def training_dir(case_study: str, training_hash: str, *, case_dir: Path | None = None) -> Path:
"""Get the filesystem directory for a training run."""
if case_dir is None:
case_dir = _case_dir(case_study)
return _training_dir(case_dir, training_hash)
def prediction_dir(case_study: str, prediction_hash: str, *, case_dir: Path | None = None) -> Path:
"""Get the filesystem directory for a prediction set."""
if case_dir is None:
case_dir = _case_dir(case_study)
return _prediction_dir(case_dir, prediction_hash)
def backtest_dir(case_study: str, backtest_hash: str, *, case_dir: Path | None = None) -> Path:
"""Get the filesystem directory for a backtest run."""
if case_dir is None:
case_dir = _case_dir(case_study)
return _backtest_dir(case_dir, backtest_hash)
def load_existing_backtest_hashes(
case_study: str,
*,
stage: str | None = None,
case_dir: Path | None = None,
) -> set[str]:
"""Return registered backtest hashes, optionally filtered by stage."""
if case_dir is None:
case_dir = _case_dir(case_study)
db_path = _registry_db_path(case_dir)
if not db_path.exists():
return set()
query = "SELECT backtest_hash FROM backtest_runs"
params: tuple[str, ...] = ()
if stage is not None:
query += " WHERE stage = ?"
params = (stage,)
db = sqlite3.connect(str(db_path))
try:
rows = db.execute(query, params).fetchall()
finally:
db.close()
return {row[0] for row in rows}
# ---------------------------------------------------------------------------
# Source string helper
# ---------------------------------------------------------------------------
def model_source(family: str, config_name: str | None) -> str:
"""Canonical source identifier: ``{family}/{config_name}``.
This is the ONE function that produces source strings.
Used by registration, backtest loading, and results JSON.
"""
if config_name:
return f"{family}/{config_name}"
return family
# ---------------------------------------------------------------------------
# Cross-level queries
# ---------------------------------------------------------------------------
# Canonical schema for resolve_best_predictions() output. Returning a
# schema-stable empty DataFrame on no-match lets downstream
# `.select("source", ...)` surface "(no matching rows)" rather than a
# cryptic ColumnNotFoundError with "valid columns: []".
_BEST_PREDICTIONS_SCHEMA: dict[str, pl.DataType] = {
"prediction_hash": pl.Utf8,
"training_hash": pl.Utf8,
"family": pl.Utf8,
"config_name": pl.Utf8,
"label": pl.Utf8,
"split": pl.Utf8,
"checkpoint_value": pl.Float64,
"sharpe": pl.Float64,
"source": pl.Utf8,
"predictions_path": pl.Utf8,
}
def resolve_best_predictions(
case_study: str,
label: str,
*,
split: str | None,
top_n: int = 10,
stage: str = "signal",
chapter_filter: str | None = None,
case_dir: Path | None = None,
):
"""Return top-N prediction hashes ranked by backtest Sharpe at a given stage.
Joins backtest_metrics (Sharpe) -> backtest_runs -> prediction_sets ->
training_runs. Filters by the ``stage`` column.
Parameters
----------
case_study : str
Case study identifier.
label : str
Label to filter training runs by.
split : str or None
Required. Pass "validation" for chapter pipeline (allocation /
cost / risk) lineage; pass "holdout" for honest out-of-sample
evaluation; pass ``None`` only for cross-split audit queries
where mixing is intentional. Mixing splits silently selects
holdout predictions over validation, which produces NULL
allocation/cost/risk rows in chapter prose.
top_n : int
Number of top predictions to return.
stage : str
Pipeline stage to filter by: "signal", "allocation", etc.
chapter_filter : str, optional
Chapter-based filter (e.g. "ch16"). Converted to stage internally.
case_dir : Path, optional
Override case study directory.
Returns
-------
pl.DataFrame
Columns: prediction_hash, training_hash, family, config_name,
source, label, split, sharpe, predictions_path
"""
import polars as pl
if case_dir is None:
case_dir = _case_dir(case_study)
db_path = _registry_db_path(case_dir)
if not db_path.exists():
logger.warning("No registry.db found for '%s'", case_study)
return pl.DataFrame(schema=_BEST_PREDICTIONS_SCHEMA)
# Find top-N distinct model configs, each represented by its best checkpoint.
#
# A single model config (e.g., latent_factors/sae) may have many prediction
# hashes — one per checkpoint epoch. Without dedup, the top-N list can be
# dominated by checkpoints of a single model, hiding model diversity.
#
# Two-stage approach:
# 1. Inner: best Sharpe per (family, config_name) — one row per distinct config
# 2. Outer: rank configs, take top_n, resolve back to the actual prediction_hash
#
# Prefer stage column; fall back to spec_json LIKE for un-migrated DBs.
stage_clause, stage_params = _stage_filter_clause(stage, chapter_filter)
exclude_clause, exclude_params = excluded_family_sql(case_study, "t.family")
degenerate_clause = degenerate_prediction_sql("p.prediction_hash")
split_clause = ""
params: list[str] = [label] + stage_params + exclude_params
if split:
split_clause = "AND p.split = ?"
params.append(split)
params.append(str(top_n))
query = f"""
WITH per_prediction AS (
-- Best backtest Sharpe per prediction_hash (a prediction may have
-- been backtested with multiple signal methods)
SELECT
p.prediction_hash,
p.training_hash,
p.split,
p.checkpoint_value,
t.family,
t.config_name,
t.label,
MAX(bm.sharpe) AS sharpe
FROM backtest_metrics bm
JOIN backtest_runs b ON bm.backtest_hash = b.backtest_hash
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
JOIN training_runs t ON p.training_hash = t.training_hash
WHERE t.label = ?
{stage_clause}
{exclude_clause}
{degenerate_clause}
{split_clause}
GROUP BY p.prediction_hash
),
best_per_config AS (
-- Best checkpoint per (family, config_name): one row per distinct model
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY family, config_name
ORDER BY sharpe DESC, checkpoint_value DESC
) AS rn
FROM per_prediction
)
SELECT
prediction_hash,
training_hash,
family,
config_name,
label,
split,
checkpoint_value,
sharpe
FROM best_per_config
WHERE rn = 1
ORDER BY sharpe DESC
LIMIT ?
"""
df = _query_table(case_dir, query, tuple(params))
if df.is_empty():
return pl.DataFrame(schema=_BEST_PREDICTIONS_SCHEMA)
df = filter_active_model_rows(df, case_study)
if df.is_empty():
return pl.DataFrame(schema=_BEST_PREDICTIONS_SCHEMA)
# Build source string
df = df.with_columns(
(pl.col("family") + pl.lit("/") + pl.col("config_name").fill_null(pl.lit("default"))).alias(
"source"
),
)
# Add predictions_path and filter to entries with physical files
pred_base = _run_log_dir(case_dir) / "predictions"
df = df.with_columns(
(
pl.lit(str(pred_base))
+ pl.lit("/")
+ pl.col("prediction_hash")
+ pl.lit("/predictions.parquet")
).alias("predictions_path"),
)
existing = _existing_prediction_hashes(case_dir)
if existing is not None:
df = df.filter(pl.col("prediction_hash").is_in(list(existing)))
return df
def resolve_best_backtest_runs(
case_study: str,
label: str,
*,
split: str | None,
stage: str | None = None,
chapter: str | None = None,
top_n: int = 3,
case_dir: Path | None = None,
):
"""Return top-N backtest runs at a given stage, ranked by Sharpe.
Used by Ch18 and Ch19 to find the best prediction+allocator combos
from the allocation stage.
Parameters
----------
case_study : str
Case study identifier.
label : str
Label to filter by.
split : str or None
Required. Pass "validation" for chapter pipeline (cost / risk)
lineage; pass "holdout" for honest out-of-sample evaluation;
pass ``None`` only for cross-split audit queries where mixing
is intentional. See ``resolve_best_predictions`` for the bug
history that motivated making this required.
stage : str, optional
Pipeline stage: "signal", "allocation", "cost_sensitivity",
"risk_overlay".
chapter : str, optional
**Deprecated.** Legacy chapter tag (e.g., "ch17").
Ignored when ``stage`` is provided.
top_n : int
Number of top runs to return.
case_dir : Path, optional
Override case study directory.
Returns
-------
pl.DataFrame
Columns: backtest_hash, prediction_hash, spec_json, sharpe
"""
import polars as pl
if case_dir is None:
case_dir = _case_dir(case_study)
db_path = _registry_db_path(case_dir)
if not db_path.exists():
logger.warning("No registry.db found for '%s'", case_study)
return pl.DataFrame()
# Map chapter to stage
if stage is None and chapter:
_chapter_to_stage = {
"ch16": "signal",
"ch17": "allocation",
"ch18": "cost_sensitivity",
"ch19": "risk_overlay",
}
stage = _chapter_to_stage.get(chapter, chapter)
stage_clause, stage_params = _stage_filter_clause(stage)
exclude_clause, exclude_params = excluded_family_sql(case_study, "t.family")
degenerate_clause = degenerate_prediction_sql("p.prediction_hash")
split_clause = ""
split_params: list[str] = []
if split:
split_clause = "AND p.split = ?"
split_params = [split]
query = f"""
SELECT
b.backtest_hash,
b.prediction_hash,
b.spec_json,
bm.sharpe,
t.family,
t.config_name
FROM backtest_metrics bm
JOIN backtest_runs b ON bm.backtest_hash = b.backtest_hash
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
JOIN training_runs t ON p.training_hash = t.training_hash
WHERE t.label = ?
{exclude_clause}
{stage_clause}
{degenerate_clause}
{split_clause}
ORDER BY bm.sharpe DESC
LIMIT ?
"""
df = _query_table(
case_dir,
query,
tuple([label] + exclude_params + stage_params + split_params + [str(top_n)]),
)
if df.is_empty():
return df
df = filter_active_model_rows(df, case_study)
if df.is_empty():
return df
# Filter to entries with physical prediction files
existing = _existing_prediction_hashes(case_dir)
if existing is not None:
df = df.filter(pl.col("prediction_hash").is_in(list(existing)))
return df.select("backtest_hash", "prediction_hash", "spec_json", "sharpe")
def load_paired_metrics(
case_study: str,
*,
challenger_hash: str | None = None,
benchmark_kind: str | None = None,
case_dir: Path | None = None,
):
"""Load ``backtest_paired_metrics`` rows for the given case study.
Filter by ``challenger_hash`` and/or ``benchmark_kind``; both optional.
Returns an empty Polars DataFrame if no rows match.
"""
import polars as pl
if case_dir is None:
case_dir = _case_dir(case_study)
where: list[str] = []
params: list[str] = []
if challenger_hash is not None:
where.append("challenger_hash = ?")
params.append(challenger_hash)
if benchmark_kind is not None:
where.append("benchmark_kind = ?")
params.append(benchmark_kind)
sql = "SELECT * FROM backtest_paired_metrics"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY computed_at DESC"
db_path = _registry_db_path(case_dir)
if not db_path.exists():
return pl.DataFrame()
db = sqlite3.connect(str(db_path))
db.row_factory = sqlite3.Row
try:
rows = db.execute(sql, tuple(params)).fetchall()
if not rows:
return pl.DataFrame()
return pl.DataFrame([dict(r) for r in rows], infer_schema_length=None)
finally:
db.close()
# ---------------------------------------------------------------------------
# Migration: backfill stage column
# ---------------------------------------------------------------------------
def backfill_stages(case_study: str | None = None) -> dict[str, int]:
"""Backfill the ``stage`` column for existing backtest_runs rows.
For each row where ``stage IS NULL``, infer the stage from spec_json:
- Has "chapter":"ch19" or risk rules -> risk_overlay
- Has "chapter":"ch18" -> cost_sensitivity
- Has "chapter":"ch17" or allocation key -> allocation
- Everything else -> signal
Parameters
----------
case_study : str, optional
Single case study to backfill. If None, backfill ALL case studies.
Returns
-------
dict[str, int]
Mapping of case_study_id -> number of rows updated.
"""
from utils.paths import get_case_study_dir
if case_study:
case_studies = [case_study]
else:
case_studies = [
"etfs",
"crypto_perps_funding",
"nasdaq100_microstructure",
"sp500_equity_option_analytics",
"us_firm_characteristics",
"fx_pairs",
"cme_futures",
"sp500_options",
"us_equities_panel",
]
result = {}
for cs_id in case_studies:
try:
case_dir = get_case_study_dir(cs_id)
except Exception as exc:
logger.warning("Could not resolve case study dir for %s: %s", cs_id, exc)
continue
db_path = _registry_db_path(case_dir)
if not db_path.exists():
continue
db = _open_registry(case_dir)
try:
# Check if stage column exists (the CREATE TABLE IF NOT EXISTS
# in _open_registry should have added it already)
cursor = db.execute(
"SELECT backtest_hash, spec_json, prediction_hash "
"FROM backtest_runs WHERE stage IS NULL"
)
rows = cursor.fetchall()
updated = 0
for b_hash, spec_json_str, pred_hash in rows:
if not spec_json_str:
continue
try:
spec = json.loads(spec_json_str)
except (json.JSONDecodeError, TypeError):
continue
stage = _infer_stage(spec, case_dir=case_dir, prediction_hash=pred_hash)
db.execute(
"UPDATE backtest_runs SET stage = ? WHERE backtest_hash = ?",
(stage, b_hash),
)
updated += 1
db.commit()
result[cs_id] = updated
if updated:
logger.info("Backfilled %d stage values for '%s'", updated, cs_id)
finally:
db.close()
return result