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

1329 lines
49 KiB
Python

"""Reader-facing API for exploring backtest results from the registry.
Usage::
from case_studies.utils.backtest_explorer import BacktestExplorer
explorer = BacktestExplorer("etfs")
explorer.summary()
explorer.best(stage="signal", top_n=5)
explorer.compare_allocators()
explorer.inspect("backtest_hash_abc")
explorer.progression("prediction_hash_xyz")
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import polars as pl
from case_studies.utils.backtest_presets import cost_view, strategy_view
from case_studies.utils.notebook_contracts import excluded_family_sql, filter_active_model_rows
# Sentinel distinguishing "no filter" from "match exit_at_max_days IS NULL".
_UNSET = object()
# Canonical schema for BacktestExplorer.best() output. Used to construct
# schema-stable empty DataFrames so downstream `.select("source", ...)`
# surfaces "(no matching rows)" instead of a cryptic ColumnNotFoundError.
_BEST_SCHEMA: dict[str, pl.DataType] = {
"backtest_hash": pl.Utf8,
"prediction_hash": pl.Utf8,
"source": pl.Utf8,
"family": pl.Utf8,
"config_name": pl.Utf8,
"label": pl.Utf8,
"signal_method": pl.Utf8,
"universe_filter": pl.Utf8,
"exit_at_max_days": pl.Int64,
"sharpe": pl.Float64,
"cagr": pl.Float64,
"max_drawdown": pl.Float64,
"total_return": pl.Float64,
"volatility": pl.Float64,
"ic_mean": pl.Float64,
}
# ---------------------------------------------------------------------------
# Result containers
# ---------------------------------------------------------------------------
@dataclass
class BacktestDetail:
"""Full detail for a single backtest run."""
backtest_hash: str
prediction_hash: str
stage: str | None
spec: dict
metrics: dict[str, float]
daily_returns_path: Path | None
trades_path: Path | None
weights_path: Path | None
source: str | None = None
# ---------------------------------------------------------------------------
# Spec-string parsing helpers
# ---------------------------------------------------------------------------
def _parse_spec(spec_str: str | None) -> dict | None:
"""Return the parsed JSON spec as a dict, or None if not parseable.
Failure modes that return None:
1. ``spec_str is None`` (NULL in the registry)
2. ``spec_str == ""`` (empty string)
3. ``spec_str`` is malformed JSON (truncated writes)
4. The parsed value is not a dict (e.g. ``"42"`` → int, ``"null"``
→ None, ``"[1, 2]"`` → list) — callers that feed this into
``strategy_view`` or ``cost_view`` rely on dict-shaped input
Callers that don't care about distinguishing "not parseable" from
"empty dict" can write ``_parse_spec(s) or {}`` to get a
guaranteed-dict. Callers that DO need to distinguish (e.g.
``compare_allocators`` which returns "unknown" for unparseable
rows and "equal_weight" for a successfully-parsed spec missing
the allocation method) check ``is None`` explicitly.
Replaces five copies of the same parse-and-default pattern.
"""
if not spec_str:
return None
# Note: only catching JSONDecodeError here, not TypeError. The
# original sites caught both defensively, but json.loads only raises
# TypeError on non-string input, which the ``if not spec_str`` guard
# plus the str|None type annotation make unreachable from well-typed
# callers. The ``(A, B)`` tuple form is also what ruff 0.15 on a
# py314 target rewrites to the Python-2-style comma form, which then
# fails on Python 3.12 CI.
try:
value = json.loads(spec_str)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
# ---------------------------------------------------------------------------
# Explorer
# ---------------------------------------------------------------------------
class BacktestExplorer:
"""High-level reader API for querying backtest results.
All data is read from ``registry.db`` — no JSON files needed.
"""
def __init__(self, case_study: str, *, case_dir: Path | None = None):
from utils.paths import get_case_study_dir
self.case_study = case_study
self.case_dir = case_dir or get_case_study_dir(case_study)
self._db_path = self.case_dir / "run_log" / "registry.db"
if not self._db_path.exists():
raise FileNotFoundError(f"No registry.db found for '{case_study}' at {self._db_path}")
# -- helpers --
def _query(self, sql: str, params: tuple = ()) -> pl.DataFrame:
db = sqlite3.connect(str(self._db_path))
db.row_factory = sqlite3.Row
try:
rows = db.execute(sql, params).fetchall()
if not rows:
return pl.DataFrame()
return pl.DataFrame([dict(r) for r in rows])
finally:
db.close()
def _backtest_dir(self, b_hash: str) -> Path:
return self.case_dir / "run_log" / "backtest" / b_hash
def _filter_active_models(self, df: pl.DataFrame) -> pl.DataFrame:
return filter_active_model_rows(df, self.case_study)
# -----------------------------------------------------------------
# summary: what has been run?
# -----------------------------------------------------------------
def summary(self) -> dict[str, int]:
"""Count of backtest runs by stage.
Returns
-------
dict[str, int]
e.g. {"signal": 3336, "allocation": 329, ...}
"""
df = self._query(
"SELECT stage, COUNT(*) AS n FROM backtest_runs GROUP BY stage ORDER BY n DESC"
)
if df.is_empty():
return {}
return dict(zip(df["stage"].to_list(), df["n"].to_list(), strict=False))
# -----------------------------------------------------------------
# best: top backtests at a stage
# -----------------------------------------------------------------
def best(
self,
stage: str = "signal",
*,
top_n: int = 10,
metric: str = "sharpe",
) -> pl.DataFrame:
"""Top-N backtests at a given stage, ranked by ``metric``.
Returns
-------
pl.DataFrame
Columns: backtest_hash, prediction_hash, source, family,
config_name, label, signal_method, sharpe, cagr, max_drawdown,
total_return, volatility, ic_mean
"""
df = self._query(
f"""
SELECT
b.backtest_hash,
b.prediction_hash,
b.spec_json,
b.stage,
t.family,
t.config_name,
t.label,
bm.sharpe,
bm.cagr,
bm.max_drawdown,
bm.total_return,
bm.volatility,
pm.ic_mean
FROM backtest_runs b
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
JOIN training_runs t ON p.training_hash = t.training_hash
LEFT JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
LEFT JOIN prediction_metrics pm ON p.prediction_hash = pm.prediction_hash
WHERE b.stage = ?
AND p.split != 'holdout'
{excluded_family_sql(self.case_study, "t.family")[0]}
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
ORDER BY bm.sharpe DESC
LIMIT ?
""",
(stage, *excluded_family_sql(self.case_study, "t.family")[1], top_n),
)
if df.is_empty():
return pl.DataFrame(schema=_BEST_SCHEMA)
df = self._filter_active_models(df)
if df.is_empty():
return pl.DataFrame(schema=_BEST_SCHEMA)
# Build source and extract signal_method from spec
df = df.with_columns(
(
pl.col("family") + pl.lit("/") + pl.col("config_name").fill_null(pl.lit("default"))
).alias("source"),
)
# Extract signal_method, universe_filter, exit_at_max_days from spec_json.
# The (universe_filter, exit_at_max_days) pair identifies the execution
# regime in the O'Donovan-Yu (2025) cost-mitigation cascade for
# sp500_options:
# - Rung-1 (naive round-trip): universe_filter="full", exit_at_max_days=10
# - Rung-2 (HTM, full): universe_filter="full", exit_at_max_days=None
# - Rung-3 (HTM, liquid q20): universe_filter="liquid", exit_at_max_days=None
# Both Rung-1 and Rung-2 carry universe_filter="full"; pinning the
# chapter-wide rank-1 to the HTM baseline therefore needs both fields.
# Other case studies default to ("full", None) and are unaffected.
parsed = [_parse_spec(s) or {} for s in df["spec_json"].to_list()]
methods = [strategy_view(sp).get("signal", {}).get("method", "") for sp in parsed]
universe_filters = [
strategy_view(sp).get("signal", {}).get("universe_filter", "full") or "full"
for sp in parsed
]
exit_at_max_days = [
strategy_view(sp).get("signal", {}).get("exit_at_max_days") for sp in parsed
]
df = df.with_columns(
pl.Series("signal_method", methods),
pl.Series("universe_filter", universe_filters),
pl.Series("exit_at_max_days", exit_at_max_days, dtype=pl.Int64),
)
return df.select(
"backtest_hash",
"prediction_hash",
"source",
"family",
"config_name",
"label",
"signal_method",
"universe_filter",
"exit_at_max_days",
"sharpe",
"cagr",
"max_drawdown",
"total_return",
"volatility",
"ic_mean",
)
# -----------------------------------------------------------------
# compare_families: model family comparison at a stage
# -----------------------------------------------------------------
def compare_families(self, stage: str = "signal") -> pl.DataFrame:
"""Compare model families by backtest Sharpe at a given stage.
Returns
-------
pl.DataFrame
Columns: family, n, sharpe_median, sharpe_max, sharpe_q75,
pct_positive
"""
df = self._query(
f"""
SELECT
t.family,
bm.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 b.stage = ?
AND p.split != 'holdout'
{excluded_family_sql(self.case_study, "t.family")[0]}
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
""",
(stage, *excluded_family_sql(self.case_study, "t.family")[1]),
)
if df.is_empty():
return df
df = self._filter_active_models(df)
if df.is_empty():
return df
return (
df.group_by("family")
.agg(
n=pl.len(),
sharpe_median=pl.col("sharpe").median(),
sharpe_max=pl.col("sharpe").max(),
sharpe_q75=pl.col("sharpe").quantile(0.75),
pct_positive=((pl.col("sharpe") > 0).sum() / pl.len() * 100),
)
.sort("sharpe_median", descending=True)
)
# -----------------------------------------------------------------
# compare_allocators: allocation method comparison
# -----------------------------------------------------------------
def compare_allocators(
self,
*,
prediction_hash: str | None = None,
stages: tuple[str, ...] = ("allocation",),
) -> pl.DataFrame:
"""Compare allocation methods from the allocation stage.
Parameters
----------
prediction_hash : str, optional
If provided, restrict the comparison to backtests carrying this
prediction_hash (full or prefix match). Used by Ch20 to align the
allocator-heatmap pool to the spine rank-1 carrier so Figure 20.7
and Table 20.6 read off the same prediction.
stages : tuple of str, default ``("allocation",)``
Which backtest stages to include. Ch20 Figure 20.14 / Table 20.6
isolate the allocator layer and read off ``"allocation"`` only; the
risk overlay (ch19) is a downstream layer covered in §20.7, so
folding it in here would credit the allocator with the overlay's
work. Pass ``"risk_overlay"`` explicitly only for cross-stage views.
Returns
-------
pl.DataFrame
Columns: allocator, n, avg_sharpe, best_sharpe, avg_max_dd
"""
if not stages:
return pl.DataFrame()
placeholders = ", ".join("?" for _ in stages)
sql = f"""
SELECT
b.spec_json,
t.family,
t.config_name,
bm.sharpe,
bm.max_drawdown
FROM backtest_runs b
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
JOIN training_runs t ON p.training_hash = t.training_hash
JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
WHERE b.stage IN ({placeholders})
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
"""
params: tuple = tuple(stages)
if prediction_hash:
sql += " AND b.prediction_hash LIKE ?"
params = (*params, prediction_hash + "%")
df = self._query(sql, params)
if df.is_empty():
return df
# Extract allocator from spec_json. Unparseable spec → "unknown";
# missing allocation key → "unknown" so risk_overlay rows whose
# spec carries only the risk overlay (no explicit allocator) are
# not silently bucketed under equal_weight (Ch20 Figure 20.7 / Table
# 20.6 pin allocator-method semantics to the spec, not to an engine
# default).
def _allocator_from(s: str | None) -> str:
spec = _parse_spec(s)
if spec is None:
return "unknown"
method = strategy_view(spec).get("allocation", {}).get("method")
return method if method else "unknown"
allocators = [_allocator_from(s) for s in df["spec_json"].to_list()]
df = df.with_columns(pl.Series("allocator", allocators))
df = df.filter(pl.col("allocator") != "unknown")
if df.is_empty():
return df
return (
df.group_by("allocator")
.agg(
n=pl.len(),
avg_sharpe=pl.col("sharpe").mean(),
best_sharpe=pl.col("sharpe").max(),
avg_max_dd=pl.col("max_drawdown").mean(),
)
.sort("avg_sharpe", descending=True)
)
# -----------------------------------------------------------------
# inspect: full detail for one backtest
# -----------------------------------------------------------------
def inspect(self, backtest_hash: str) -> BacktestDetail:
"""Load full details for a single backtest run.
Parameters
----------
backtest_hash : str
Full or prefix of the backtest hash.
Returns
-------
BacktestDetail
"""
# Support prefix matching
df = self._query(
"""
SELECT
b.backtest_hash,
b.prediction_hash,
b.stage,
b.spec_json,
t.family,
t.config_name
FROM backtest_runs b
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
JOIN training_runs t ON p.training_hash = t.training_hash
WHERE b.backtest_hash LIKE ?
LIMIT 1
""",
(backtest_hash + "%",),
)
if df.is_empty():
raise KeyError(f"No backtest found matching '{backtest_hash}'")
row = df.row(0, named=True)
b_hash = row["backtest_hash"]
# Load metrics (wide format — each column is a metric)
metrics_df = self._query(
"SELECT * FROM backtest_metrics WHERE backtest_hash = ?",
(b_hash,),
)
metrics = {}
if not metrics_df.is_empty():
row_dict = metrics_df.row(0, named=True)
metrics = {
k: v
for k, v in row_dict.items()
if k not in ("backtest_hash", "computed_at") and v is not None
}
# Parse spec
spec = {}
if row["spec_json"]:
import contextlib
with contextlib.suppress(json.JSONDecodeError, TypeError):
spec = json.loads(row["spec_json"])
# File paths
bt_dir = self._backtest_dir(b_hash)
returns_path = bt_dir / "daily_returns.parquet"
trades_path = bt_dir / "trades.parquet"
weights_path = bt_dir / "weights.parquet"
source = None
if row.get("family"):
config = row.get("config_name") or "default"
source = f"{row['family']}/{config}"
return BacktestDetail(
backtest_hash=b_hash,
prediction_hash=row["prediction_hash"],
stage=row["stage"],
spec=spec,
metrics=metrics,
daily_returns_path=returns_path if returns_path.exists() else None,
trades_path=trades_path if trades_path.exists() else None,
weights_path=weights_path if weights_path.exists() else None,
source=source,
)
# -----------------------------------------------------------------
# progression: Sharpe across stages for a prediction
# -----------------------------------------------------------------
def progression(
self,
prediction_hash: str,
*,
universe_filter: str | None | object = _UNSET,
exit_at_max_days: int | None | object = _UNSET,
) -> pl.DataFrame:
"""Show Sharpe progression across stages for a given prediction.
Finds the best backtest at each stage for this prediction hash
and shows how performance changes as allocation, costs, and risk
overlays are added.
Parameters
----------
prediction_hash : str
Prediction set to trace through the pipeline.
universe_filter : str, None, or _UNSET, optional
If set to a string, restrict to backtests whose
``strategy.signal.universe_filter`` matches (defaulting null
spec entries to ``"full"`` so case studies without an explicit
universe_filter still match). If left at ``_UNSET`` (default),
no filter is applied. Used to scope sp500_options to its full
vs. liquid execution regime.
exit_at_max_days : int, None, or _UNSET, optional
If set to ``None`` explicitly, restrict to backtests whose
spec has no ``exit_at_max_days`` set (HTM regime). If set to
an integer, match exactly. If left at ``_UNSET`` (default),
no filter is applied. Together with ``universe_filter`` this
pins sp500_options to a specific cascade rung across all
stages, not just the signal stage.
Returns
-------
pl.DataFrame
Columns: stage, sharpe, cagr, max_drawdown, backtest_hash
"""
clauses = [
"b.prediction_hash = ?",
"b.stage IS NOT NULL",
"bm.sharpe IS NOT NULL",
"(bm.num_trades IS NULL OR bm.num_trades > 0)",
]
params: list[object] = [prediction_hash]
if universe_filter is not _UNSET:
clauses.append(
"COALESCE(json_extract(b.spec_json, '$.strategy.signal.universe_filter'), 'full') = ?"
)
params.append(universe_filter)
if exit_at_max_days is not _UNSET:
if exit_at_max_days is None:
clauses.append(
"json_extract(b.spec_json, '$.strategy.signal.exit_at_max_days') IS NULL"
)
else:
clauses.append(
"json_extract(b.spec_json, '$.strategy.signal.exit_at_max_days') = ?"
)
params.append(exit_at_max_days)
where_sql = " AND ".join(clauses)
df = self._query(
f"""
SELECT
b.stage,
b.backtest_hash,
bm.sharpe,
bm.cagr,
bm.max_drawdown
FROM backtest_runs b
JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
WHERE {where_sql}
ORDER BY bm.sharpe DESC
""",
tuple(params),
)
if df.is_empty():
return df
# Take best Sharpe per stage
stage_order = {"signal": 0, "allocation": 1, "cost_sensitivity": 2, "risk_overlay": 3}
best_per_stage = df.sort("sharpe", descending=True).group_by("stage").first()
# Sort by pipeline order
return (
best_per_stage.with_columns(
pl.col("stage").replace_strict(stage_order, default=99).alias("_order")
)
.sort("_order")
.drop("_order")
)
# -----------------------------------------------------------------
# deflated_sharpe: DSR from registry data
# -----------------------------------------------------------------
def deflated_sharpe(
self,
stage: str = "signal",
*,
top_n: int = 20,
periods_per_year: int = 252,
) -> pl.DataFrame:
"""Per-variant Sharpe with selection-bias DSR for family leaders.
Per-variant PSR (single-strategy probability of skill, no
multiple-testing correction) is computed on the fly from
``daily_returns.parquet``.
Selection-bias DSR / RAS / Reality Check / PBO come from the
persisted ``cohort_metrics`` table (cohort_type='family',
leader_hash=backtest_hash). Backward-compatible columns
``deflated_sharpe``, ``expected_max_sharpe``, ``dsr_pvalue``,
``significant`` carry the **effective-rank (ER) DSR** — the
library maintainer's recommended default. ``dsr_mp`` and
``dsr_raw`` are surfaced alongside for sensitivity. Rows that
are not the family leader for their ``(stage, label, family)``
have NULL selection-bias columns.
Returns
-------
pl.DataFrame
Columns: source, sharpe, psr_pvalue, deflated_sharpe,
expected_max_sharpe, dsr_pvalue, significant, is_best,
dsr_mp, dsr_mp_pvalue, dsr_raw, dsr_raw_pvalue, k_variants,
n_trials_effective_er, n_trials_effective_mp, ras_leader,
ras_pvalue, reality_check_pvalue, pbo, family, label.
"""
from ml4t.diagnostic.evaluation.stats import deflated_sharpe_ratio
top = self.best(stage=stage, top_n=top_n)
if top.is_empty():
return pl.DataFrame()
per_variant_psr: dict[str, float | None] = {}
for row in top.iter_rows(named=True):
b_hash = row["backtest_hash"]
returns_path = self._backtest_dir(b_hash) / "daily_returns.parquet"
if not returns_path.exists():
per_variant_psr[b_hash] = None
continue
ret_df = pl.read_parquet(returns_path)
if "daily_return" not in ret_df.columns:
per_variant_psr[b_hash] = None
continue
arr = ret_df["daily_return"].to_numpy()
if np.std(arr, ddof=1) <= 1e-10:
per_variant_psr[b_hash] = None
continue
try:
psr = deflated_sharpe_ratio([arr], periods_per_year=periods_per_year)
per_variant_psr[b_hash] = float(psr.p_value)
except Exception: # pragma: no cover
per_variant_psr[b_hash] = None
hashes = top["backtest_hash"].to_list()
placeholders = ",".join("?" for _ in hashes)
cm = self._query(
f"""
SELECT leader_hash, k_variants,
n_trials_effective_mp, n_trials_effective_er,
dsr_raw, dsr_raw_pvalue,
dsr_mp, dsr_mp_pvalue,
dsr_er, dsr_er_pvalue, expected_max_sharpe_er,
ras_leader, ras_pvalue,
reality_check_pvalue, pbo
FROM cohort_metrics
WHERE cohort_type = 'family' AND stage = ?
AND leader_hash IN ({placeholders})
""",
(stage, *hashes),
)
cm_by_hash: dict[str, dict] = {}
if not cm.is_empty():
for r in cm.iter_rows(named=True):
cm_by_hash[r["leader_hash"]] = r
def _round(x, n=4):
return round(x, n) if x is not None else None
rows = []
for r in top.iter_rows(named=True):
b_hash = r["backtest_hash"]
cmr = cm_by_hash.get(b_hash)
is_leader = cmr is not None
dsr_er_p = cmr["dsr_er_pvalue"] if is_leader else None
rows.append(
{
"source": r["source"],
"family": r["family"],
"label": r["label"],
"sharpe": _round(r["sharpe"]),
"psr_pvalue": _round(per_variant_psr.get(b_hash)),
"deflated_sharpe": _round(cmr["dsr_er"]) if is_leader else None,
"expected_max_sharpe": _round(cmr["expected_max_sharpe_er"])
if is_leader
else None,
"dsr_pvalue": _round(dsr_er_p),
"significant": (dsr_er_p is not None and dsr_er_p < 0.05)
if is_leader
else None,
"is_best": is_leader,
"dsr_mp": _round(cmr["dsr_mp"]) if is_leader else None,
"dsr_mp_pvalue": _round(cmr["dsr_mp_pvalue"]) if is_leader else None,
"dsr_raw": _round(cmr["dsr_raw"]) if is_leader else None,
"dsr_raw_pvalue": _round(cmr["dsr_raw_pvalue"]) if is_leader else None,
"k_variants": cmr["k_variants"] if is_leader else None,
"n_trials_effective_er": _round(cmr["n_trials_effective_er"], 1)
if is_leader
else None,
"n_trials_effective_mp": _round(cmr["n_trials_effective_mp"], 1)
if is_leader
else None,
"ras_leader": _round(cmr["ras_leader"]) if is_leader else None,
"ras_pvalue": _round(cmr["ras_pvalue"]) if is_leader else None,
"reality_check_pvalue": _round(cmr["reality_check_pvalue"])
if is_leader
else None,
"pbo": _round(cmr["pbo"]) if is_leader else None,
}
)
return pl.DataFrame(rows).sort("sharpe", descending=True, nulls_last=True)
# -----------------------------------------------------------------
# cost_sensitivity: breakeven analysis from registry
# -----------------------------------------------------------------
def cost_sensitivity(self, *, prediction_hash: str | None = None) -> pl.DataFrame:
"""Load cost sensitivity results from the cost_sensitivity stage.
Only the bps (``commission.model='percentage'``) regime is returned;
per-share rows have ``commission.rate=0`` and ``slippage.rate=0`` so
their derived ``cost_bps`` is mechanically 0 and would otherwise pile
up on the bps-axis origin. Notebooks rendering both regimes must
query the registry directly (see ``etfs/16_costs.py::load_cost_rows``
for the pattern).
Parameters
----------
prediction_hash : str, optional
When provided, restrict to cost rows on this prediction. Case
studies with a pinned carrier (e.g. nasdaq's cost-feasible
ensemble) must scope to the carrier so the full-universe
cost-defeat demonstration rows do not pool into the headline.
Returns
-------
pl.DataFrame
Columns: cost_bps, sharpe, max_drawdown, allocator
"""
pred_clause = "" if prediction_hash is None else " AND b.prediction_hash = ?"
params = () if prediction_hash is None else (prediction_hash,)
df = self._query(
f"""
SELECT
b.spec_json,
bm.sharpe,
bm.max_drawdown
FROM backtest_runs b
JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
WHERE b.stage = 'cost_sensitivity'
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
AND json_extract(b.spec_json, '$.backtest_config.commission.model') = 'percentage'
{pred_clause}
""",
params,
)
if df.is_empty():
return df
df = self._filter_active_models(df)
if df.is_empty():
return df
# Extract cost_bps and allocator from spec
rows = []
for spec_str, sharpe, max_dd in zip(
df["spec_json"].to_list(),
df["sharpe"].to_list(),
df["max_drawdown"].to_list(),
strict=False,
):
spec = _parse_spec(spec_str) or {}
costs = cost_view(spec)
cost_bps = costs.get("commission_bps", 0) + costs.get("slippage_bps", 0)
allocator = strategy_view(spec).get("allocation", {}).get("method", "equal_weight")
rows.append(
{
"cost_bps": cost_bps,
"sharpe": sharpe,
"max_drawdown": max_dd,
"allocator": allocator,
}
)
return pl.DataFrame(rows).sort("cost_bps")
# -----------------------------------------------------------------
# risk_impact: risk overlay comparison from registry
# -----------------------------------------------------------------
def risk_impact(self, *, prediction_hash: str | None = None) -> pl.DataFrame:
"""Load risk overlay results and compute impact vs baseline.
Parameters
----------
prediction_hash : str, optional
When provided, restrict to risk-overlay rows on this prediction.
Case studies with a pinned carrier (e.g. nasdaq's cost-feasible
ensemble) must scope to the carrier so the full-universe overlay
demonstration rows do not pool into the headline.
Returns
-------
pl.DataFrame
Columns: risk_name, risk_type, sharpe, max_drawdown,
num_trades, baseline_sharpe, sharpe_delta
"""
pred_clause = "" if prediction_hash is None else " AND b.prediction_hash = ?"
pred_params = () if prediction_hash is None else (prediction_hash,)
df = self._query(
f"""
SELECT
b.spec_json,
t.family,
t.config_name,
bm.sharpe,
bm.max_drawdown,
bm.num_trades
FROM backtest_runs b
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
JOIN training_runs t ON p.training_hash = t.training_hash
JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
WHERE b.stage = 'risk_overlay'
{excluded_family_sql(self.case_study, "t.family")[0]}
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
{pred_clause}
""",
tuple(excluded_family_sql(self.case_study, "t.family")[1]) + pred_params,
)
if df.is_empty():
return df
df = self._filter_active_models(df)
if df.is_empty():
return df
rows = []
for spec_str, sharpe, max_dd, trades in zip(
df["spec_json"].to_list(),
df["sharpe"].to_list(),
df["max_drawdown"].to_list(),
df["num_trades"].to_list(),
strict=False,
):
spec = _parse_spec(spec_str) or {}
risk = strategy_view(spec).get("risk", {})
risk_name = risk.get("name", "unknown")
# Determine risk type
pos_rules = risk.get("position_rules", [])
port_limits = risk.get("portfolio_limits", [])
if risk_name == "baseline":
risk_type = "baseline"
elif pos_rules:
risk_type = pos_rules[0].get("type", "unknown")
elif port_limits:
risk_type = port_limits[0].get("type", "unknown")
else:
risk_type = "unknown"
rows.append(
{
"risk_name": risk_name,
"risk_type": risk_type,
"sharpe": sharpe,
"max_drawdown": max_dd,
"num_trades": trades,
}
)
result = pl.DataFrame(rows)
# Compute baseline: the no-overlay Sharpe the overlays are measured
# against. Registry-wide (unpinned) this is the best allocation-stage
# Sharpe — the normal pipeline where overlays sit on an allocator. When
# the comparison is pinned to a carrier prediction (e.g. nasdaq's
# signal-stage slot ensemble, which has no allocation stage), the
# baseline is the carrier's own no-overlay Sharpe over its signal and
# allocation rows; the registry-wide allocation max would otherwise
# return an unrelated full-universe strategy.
baseline_stage_clause = (
"b.stage = 'allocation'"
if prediction_hash is None
else "b.stage IN ('signal', 'allocation')"
)
baseline_df = self._query(
f"""
SELECT
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 {baseline_stage_clause}
{excluded_family_sql(self.case_study, "t.family")[0]}
{pred_clause}
""",
tuple(excluded_family_sql(self.case_study, "t.family")[1]) + pred_params,
)
baseline_df = self._filter_active_models(baseline_df)
baseline_sharpe = baseline_df["sharpe"].max() if not baseline_df.is_empty() else None
if baseline_sharpe is not None:
result = result.with_columns(
pl.lit(baseline_sharpe).alias("baseline_sharpe"),
(pl.col("sharpe") - baseline_sharpe).alias("sharpe_delta"),
)
else:
result = result.with_columns(
pl.lit(None).cast(pl.Float64).alias("baseline_sharpe"),
pl.lit(None).cast(pl.Float64).alias("sharpe_delta"),
)
return result.sort("sharpe", descending=True)
# -----------------------------------------------------------------
# fold_performance: per-fold backtest metrics
# -----------------------------------------------------------------
def fold_performance(self, backtest_hash: str) -> pl.DataFrame:
"""Per-fold backtest metrics (Sharpe, max_dd, etc.) for one backtest.
Parameters
----------
backtest_hash : str
Full or prefix of the backtest hash.
Returns
-------
pl.DataFrame
Columns: fold_id, sharpe, cagr, max_drawdown, volatility,
total_return, n_days, ...
"""
df = self._query(
"""
SELECT *
FROM backtest_fold_metrics
WHERE backtest_hash LIKE ?
ORDER BY fold_id
""",
(backtest_hash + "%",),
)
if df.is_empty():
return df
# Drop internal columns, already in wide format
drop_cols = [c for c in ["backtest_hash", "computed_at"] if c in df.columns]
if drop_cols:
df = df.drop(drop_cols)
return df.sort("fold_id")
# -----------------------------------------------------------------
# ic_sharpe_scatter: IC vs backtest Sharpe per fold
# -----------------------------------------------------------------
def ic_sharpe_scatter(
self,
stage: str = "signal",
*,
top_n: int = 10,
) -> pl.DataFrame:
"""Join prediction IC per fold with backtest Sharpe per fold.
This enables the empirical fundamental law test: plotting IC
against realized Sharpe at the fold level to see whether
better predictions produce better portfolio returns.
Parameters
----------
stage : str
Pipeline stage to filter backtests.
top_n : int
Number of top backtests (by headline Sharpe) to include.
Returns
-------
pl.DataFrame
Columns: source, fold_id, ic, sharpe, cagr, max_drawdown
"""
# Get top backtests at this stage
top = self.best(stage=stage, top_n=top_n)
if top.is_empty():
return pl.DataFrame()
rows = []
for row in top.iter_rows(named=True):
b_hash = row["backtest_hash"]
p_hash = row["prediction_hash"]
source = row.get("source", b_hash[:8])
# Get backtest fold metrics (wide format)
bt_wide = self._query(
"""
SELECT *
FROM backtest_fold_metrics
WHERE backtest_hash = ?
""",
(b_hash,),
)
if bt_wide.is_empty():
continue
# Drop internal columns
drop_cols = [c for c in ["backtest_hash", "computed_at"] if c in bt_wide.columns]
if drop_cols:
bt_wide = bt_wide.drop(drop_cols)
# Get prediction fold metrics (IC) — wide format
pred_folds = self._query(
"""
SELECT fold_id, ic
FROM fold_metrics
WHERE prediction_hash = ?
""",
(p_hash,),
)
if pred_folds.is_empty():
continue
ic_df = pred_folds.select("fold_id", "ic")
joined = bt_wide.join(ic_df, on="fold_id", how="left")
joined = joined.with_columns(pl.lit(source).alias("source"))
rows.append(joined)
if not rows:
return pl.DataFrame()
result = pl.concat(rows, how="diagonal")
# Select core columns (others available but less critical)
cols = ["source", "fold_id"]
for c in ["ic", "sharpe", "cagr", "max_drawdown", "volatility", "n_days"]:
if c in result.columns:
cols.append(c)
return result.select(cols).sort("source", "fold_id")
# -----------------------------------------------------------------
# backfill_fold_metrics: compute fold metrics for existing backtests
# -----------------------------------------------------------------
def backfill_fold_metrics(
self,
stage: str = "signal",
*,
label: str = "",
limit: int = 0,
) -> int:
"""Compute and store fold metrics for existing backtests.
Finds backtests at the given stage that have daily_returns.parquet
but no entries in backtest_fold_metrics, then computes and registers
fold-level performance metrics.
Parameters
----------
stage : str
Pipeline stage to backfill.
label : str
Label name for fold boundary computation.
limit : int
Max backtests to process (0 = all).
Returns
-------
int
Number of backtests backfilled.
"""
from case_studies.utils.registry import (
compute_backtest_fold_metrics,
register_backtest_fold_metrics,
)
# Find backtests without fold metrics
df = self._query(
"""
SELECT b.backtest_hash
FROM backtest_runs b
WHERE b.stage = ?
AND b.backtest_hash NOT IN (
SELECT DISTINCT backtest_hash FROM backtest_fold_metrics
)
""",
(stage,),
)
if df.is_empty():
return 0
hashes = df["backtest_hash"].to_list()
count = 0
for b_hash in hashes:
if limit > 0 and count >= limit:
break
returns_path = self._backtest_dir(b_hash) / "daily_returns.parquet"
if not returns_path.exists():
continue
daily_ret = pl.read_parquet(returns_path)
fold_m = compute_backtest_fold_metrics(daily_ret, self.case_study, label=label)
if fold_m:
register_backtest_fold_metrics(self.case_study, b_hash, fold_m)
count += 1
return count
# -----------------------------------------------------------------
# search_context: distribution stats for a stage
# -----------------------------------------------------------------
def search_context(self, stage: str = "signal") -> dict[str, Any]:
"""Distribution statistics for all backtests at a stage.
Quantifies search risk: how exceptional is the champion relative
to the full sweep?
Returns
-------
dict
Keys: total, median_sharpe, mean_sharpe, p90_sharpe,
champion_sharpe, champion_source, champion_percentile,
pct_positive
"""
df = self._query(
"""
SELECT
bm.sharpe,
t.family || '/' || COALESCE(t.config_name, 'default') AS source
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 b.stage = ?
AND p.split != 'holdout'
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
""",
(stage,),
)
if df.is_empty():
return {}
sharpes = df["sharpe"].to_numpy()
best_idx = int(np.argmax(sharpes))
return {
"total": len(sharpes),
"median_sharpe": float(np.median(sharpes)),
"mean_sharpe": float(np.mean(sharpes)),
"p90_sharpe": float(np.percentile(sharpes, 90)),
"champion_sharpe": float(sharpes[best_idx]),
"champion_source": df["source"][best_idx],
"champion_percentile": float((sharpes <= sharpes[best_idx]).sum() / len(sharpes) * 100),
"pct_positive": float((sharpes > 0).sum() / len(sharpes) * 100),
}
# -----------------------------------------------------------------
# champion_lineage: locked path through all stages
# -----------------------------------------------------------------
def champion_lineage(self, prediction_hash: str) -> dict[str, dict]:
"""Locked path through signal -> allocation -> cost -> risk.
For each stage, returns the BEST backtest for this specific
``prediction_hash`` with spec annotations (allocator, top_k,
cost_bps, risk_type).
Returns
-------
dict[str, dict]
Keyed by stage. Each value has: sharpe, cagr, max_drawdown,
backtest_hash, plus stage-specific fields.
"""
df = self._query(
"""
SELECT
b.stage,
b.backtest_hash,
b.spec_json,
bm.sharpe,
bm.cagr,
bm.max_drawdown,
bm.volatility,
bm.total_return
FROM backtest_runs b
JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
WHERE b.prediction_hash = ?
AND b.stage IS NOT NULL
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
ORDER BY bm.sharpe DESC
""",
(prediction_hash,),
)
if df.is_empty():
return {}
result: dict[str, dict] = {}
stage_order = ["signal", "allocation", "cost_sensitivity", "risk_overlay"]
for stage_name in stage_order:
stage_df = df.filter(pl.col("stage") == stage_name)
if stage_df.is_empty():
continue
row = stage_df.row(0, named=True)
entry: dict[str, Any] = {
"sharpe": row["sharpe"],
"cagr": row["cagr"],
"max_drawdown": row["max_drawdown"],
"volatility": row["volatility"],
"total_return": row["total_return"],
"backtest_hash": row["backtest_hash"],
}
# Extract stage-specific annotations from spec_json
spec = {}
if row["spec_json"]:
import contextlib
with contextlib.suppress(json.JSONDecodeError, TypeError):
spec = json.loads(row["spec_json"])
strategy = strategy_view(spec)
if stage_name == "signal":
entry["signal_method"] = strategy.get("signal", {}).get("method", "")
entry["top_k"] = strategy.get("signal", {}).get("top_k", None)
elif stage_name == "allocation":
entry["allocator"] = strategy.get("allocation", {}).get("method", "")
entry["top_k"] = strategy.get("signal", {}).get("top_k", None)
elif stage_name == "cost_sensitivity":
costs = cost_view(spec)
entry["cost_bps"] = costs.get("commission_bps", 0) + costs.get("slippage_bps", 0)
entry["allocator"] = strategy.get("allocation", {}).get("method", "")
elif stage_name == "risk_overlay":
risk = strategy.get("risk", {})
entry["risk_name"] = risk.get("name", "")
pos_rules = risk.get("position_rules", [])
entry["risk_type"] = pos_rules[0].get("type", "") if pos_rules else ""
result[stage_name] = entry
return result
# -----------------------------------------------------------------
# concentration_curve: Sharpe vs top_k at allocation stage
# -----------------------------------------------------------------
def concentration_curve(self, prediction_hash: str) -> pl.DataFrame:
"""Sharpe vs top_k for a given prediction at allocation stage.
Shows how portfolio concentration affects performance — typically
more actionable than allocator comparison alone.
Returns
-------
pl.DataFrame
Columns: top_k, allocator, sharpe, max_drawdown, cagr
"""
df = self._query(
"""
SELECT
b.spec_json,
bm.sharpe,
bm.max_drawdown,
bm.cagr
FROM backtest_runs b
JOIN backtest_metrics bm ON bm.backtest_hash = b.backtest_hash
WHERE b.prediction_hash = ?
AND b.stage = 'allocation'
AND bm.sharpe IS NOT NULL
AND (bm.num_trades IS NULL OR bm.num_trades > 0)
""",
(prediction_hash,),
)
if df.is_empty():
return df
rows = []
for spec_str, sharpe, max_dd, cagr in zip(
df["spec_json"].to_list(),
df["sharpe"].to_list(),
df["max_drawdown"].to_list(),
df["cagr"].to_list(),
strict=False,
):
spec = _parse_spec(spec_str) or {}
strategy = strategy_view(spec)
top_k = strategy.get("signal", {}).get("top_k", None)
allocator = strategy.get("allocation", {}).get("method", "equal_weight")
rows.append(
{
"top_k": top_k,
"allocator": allocator,
"sharpe": sharpe,
"max_drawdown": max_dd,
"cagr": cagr,
}
)
return pl.DataFrame(rows).sort("top_k")
# -----------------------------------------------------------------
# repr
# -----------------------------------------------------------------
def __repr__(self) -> str:
counts = self.summary()
total = sum(counts.values())
parts = ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
return f"BacktestExplorer('{self.case_study}', {total} runs: {parts})"