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

1868 lines
69 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Universal loader for Ch16-19 backtesting case study notebooks.
Replaces the outdated 16_strategy_simulation/code/prediction_loader.py.
Handles schema normalization across all 9 case studies and 5 model families.
Functions:
load_backtest_predictions(): Load + normalize prediction artifacts
load_backtest_prices(): Load + normalize price data for DataFeed
build_target_weights(): Convert predictions → portfolio weights (delegated to utils.signals)
get_backtest_config(): Extract costs, calendar, rebalance config from setup.yaml
compute_allocator_metrics(): Compute 11-metric allocator summary in one call
compute_dsr_table(): DSR for all model variants (selection-bias accounting)
extract_daily_returns_frame(): Extract daily returns from BacktestResult
aggregate_timestamped_returns_to_daily(): Aggregate timestamped returns to daily
infer_session_alignment(): Infer whether returns need session alignment
Usage:
from case_studies.utils.backtest_loaders import (
load_backtest_predictions,
load_backtest_prices,
build_target_weights,
get_backtest_config,
compute_allocator_metrics,
compute_dsr_table,
)
"""
from __future__ import annotations
import re
import sqlite3
import warnings
from dataclasses import dataclass, field
from functools import cache
from pathlib import Path
from typing import TYPE_CHECKING, Literal
import numpy as np
import polars as pl
import yaml
from case_studies.utils.notebook_contracts import degenerate_prediction_sql
from case_studies.utils.registry import model_source
from case_studies.utils.signals import build_target_weights
from utils.artifact_specs import resolve_market_runtime, resolve_market_semantics
from utils.paths import get_case_study_dir
if TYPE_CHECKING:
from ml4t.backtest.result import BacktestResult
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ALL_CASE_STUDIES = [
"etfs",
"crypto_perps_funding",
"nasdaq100_microstructure",
"sp500_equity_option_analytics",
"us_firm_characteristics",
"fx_pairs",
"cme_futures",
"sp500_options",
"us_equities_panel",
]
MODEL_FAMILIES = ["linear", "gbm", "tabular_dl", "deep_learning", "latent_factors"]
# Columns that could be entity identifiers in predictions
_ENTITY_COLS = {"symbol", "product", "stock_id", "entity", "instrument_id", "asset"}
# Columns that could be time identifiers
_TIME_COLS = {"date", "timestamp", "session_date"}
# Normalized output schema for predictions
_PRED_SCHEMA = ["timestamp", "symbol", "y_score", "y_true", "fold_id", "model_id", "source"]
# Case studies that use vectorized (non-Engine) backtesting
VECTORIZED_CASE_STUDIES = {"us_firm_characteristics", "sp500_options"}
@cache
def _load_backtest_preset_config(case_study_id: str) -> dict:
"""Load the case-study backtest preset if present."""
case_dir = get_case_study_dir(case_study_id)
path = case_dir / "config" / "backtest" / "base.yaml"
if not path.exists():
return {}
with path.open() as f:
data = yaml.safe_load(f) or {}
return data if isinstance(data, dict) else {}
def _preset_requests_quotes(case_study_id: str) -> bool:
"""Return True when the backtest preset requires bid/ask columns."""
preset = _load_backtest_preset_config(case_study_id)
feed = preset.get("feed", {})
execution = preset.get("execution", {})
return bool(feed.get("bid_col") or feed.get("ask_col")) or (
execution.get("execution_price") in {"bid", "ask", "quote_mid", "quote_side"}
or execution.get("mark_price") in {"bid", "ask", "quote_mid", "quote_side"}
)
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class BacktestPredictions:
"""Container for normalized prediction data."""
predictions: pl.DataFrame # [timestamp, symbol, y_score, y_true, fold_id, model_id, source]
case_study_id: str
label: str
model_families: list[str]
n_assets: int
n_timestamps: int
date_range: tuple[str, str]
sources: dict[str, int] = field(default_factory=dict) # {family: n_rows}
registry_entries: list[dict] = field(default_factory=list)
@dataclass
class BacktestConfig:
"""Configuration extracted from setup.yaml for backtesting."""
case_study_id: str
primary_label: str
label_buffer: str
calendar: str
cadence: str
execution_delay: str
commission_bps: float # Normalized single number (midpoint of range)
slippage_bps: float # Estimated slippage
costs_class: str # "material" or "negligible"
long_short: bool
holdout_start: str
holdout_end: str
n_splits: int
raw_costs: dict # Original costs section from setup.yaml
min_weight_change: float = 0.005 # Engine rebalance threshold (skip < this)
min_trade_value: float = 100.0 # Engine rebalance threshold (skip < this $)
initial_cash: float = 100_000.0 # SSOT — set from setup.yaml::execution.initial_cash
share_type: str = "integer" # SSOT — set from setup.yaml::execution.share_type
def load_contract_specs_from_yaml(yaml_path: Path | None = None):
"""Load futures contract specs from YAML, deriving multipliers from tick values."""
from ml4t.backtest import AssetClass, ContractSpec
if yaml_path is None:
repo_root = Path(__file__).resolve().parents[2] # case_studies/utils/ → repo root
candidates = [
repo_root / "data" / "futures" / "market" / "futures_specs.yaml",
repo_root / "data" / "futures" / "futures_specs.yaml",
repo_root / "data" / "_archive" / "config" / "futures_specs.yaml",
]
yaml_path = next((path for path in candidates if path.exists()), candidates[0])
with yaml_path.open() as f:
raw = yaml.safe_load(f)
specs = {}
for symbol, info in raw["products"].items():
init_pct = info.get("initial_margin_pct")
maint_pct = info.get("maintenance_margin_pct")
if (init_pct is None) != (maint_pct is None):
raise ValueError(
f"{symbol}: must specify both initial_margin_pct and "
f"maintenance_margin_pct or neither "
f"(got init={init_pct}, maint={maint_pct})"
)
margin_pct = (init_pct, maint_pct) if init_pct is not None else None
specs[symbol] = ContractSpec(
symbol=symbol,
asset_class=AssetClass.FUTURE,
multiplier=info["tick_value"] / info["tick_size"],
tick_size=info["tick_size"],
margin_pct=margin_pct,
)
return specs
# ---------------------------------------------------------------------------
# Prediction loading
# ---------------------------------------------------------------------------
def _detect_entity_col(df: pl.DataFrame) -> str | None:
"""Detect entity column from a DataFrame."""
for col in _ENTITY_COLS:
if col in df.columns:
return col
return None
def _detect_time_col(df: pl.DataFrame) -> str | None:
"""Detect time column from a DataFrame."""
for col in _TIME_COLS:
if col in df.columns:
return col
return None
def _normalize_predictions(
df: pl.DataFrame,
source: str,
case_study_id: str,
) -> pl.DataFrame:
"""Normalize a prediction DataFrame to the canonical schema.
Handles two prediction schemas:
- Linear: [date/timestamp, entity, fold, model, prediction, actual]
- GBM/DL/Latent: [date/timestamp, entity, y_true, y_score, fold_id, model_id]
Returns: [timestamp, symbol, y_score, y_true, fold_id, model_id, source]
"""
time_col = _detect_time_col(df)
entity_col = _detect_entity_col(df)
if time_col is None:
msg = f"No time column found in {source} predictions for {case_study_id}. Columns: {df.columns}"
raise ValueError(msg)
# --- Rename columns to canonical names ---
renames = {}
# Time → timestamp
if time_col != "timestamp":
renames[time_col] = "timestamp"
# Entity → symbol
if entity_col and entity_col != "symbol" and "symbol" not in df.columns:
renames[entity_col] = "symbol"
# Linear schema: prediction→y_score, actual→y_true, fold→fold_id, model→model_id
if "prediction" in df.columns:
renames["prediction"] = "y_score"
renames["actual"] = "y_true"
renames["fold"] = "fold_id"
renames["model"] = "model_id"
elif "model_id" not in df.columns and "config" in df.columns:
renames["config"] = "model_id"
if renames:
df = df.rename(renames)
if case_study_id == "cme_futures" and "position" in df.columns:
df = df.filter(pl.col("position") == 0)
# --- Type normalization ---
# Cast date types to Datetime for consistent timestamp column
ts_dtype = df.schema["timestamp"]
if ts_dtype == pl.Date:
df = df.with_columns(pl.col("timestamp").cast(pl.Datetime("us")))
elif ts_dtype == pl.String or ts_dtype == pl.Utf8:
# String timestamps (e.g., latent_factors PCA) — parse to Datetime
df = df.with_columns(pl.col("timestamp").str.to_datetime().cast(pl.Datetime("us")))
elif hasattr(ts_dtype, "time_zone") and ts_dtype.time_zone:
# Strip timezone (crypto has UTC)
df = df.with_columns(pl.col("timestamp").dt.replace_time_zone(None))
# Ensure fold_id is Int64
if "fold_id" in df.columns and df.schema["fold_id"] != pl.Int64:
df = df.with_columns(pl.col("fold_id").cast(pl.Int64))
# Ensure model_id is String
if "model_id" in df.columns and df.schema["model_id"] != pl.String:
df = df.with_columns(pl.col("model_id").cast(pl.String))
# Ensure symbol is String (us_firm has UInt32 stock_id)
if "symbol" in df.columns and df.schema["symbol"] != pl.String:
df = df.with_columns(pl.col("symbol").cast(pl.String))
# Add source column
df = df.with_columns(pl.lit(source).alias("source"))
# Select only canonical columns (drop extras like position, instrument_id duplicates)
keep = [c for c in _PRED_SCHEMA if c in df.columns]
return df.select(keep)
def _load_registry_prediction_frames(
case_study_id: str,
case_dir: Path,
label: str,
model_families: list[str],
split: str,
best_only: bool,
) -> tuple[list[pl.DataFrame], dict[str, int], list[dict]]:
# Use the new registry at run_log/registry.db (SSOT since registry redesign)
db_path = case_dir / "run_log" / "registry.db"
if not db_path.exists():
return [], {}, []
requested = set(model_families)
# Query prediction_sets JOIN training_runs for label + split filtering
conn = sqlite3.connect(str(db_path))
try:
query = """
SELECT
p.prediction_hash,
p.training_hash,
p.split,
t.family,
t.config_name,
t.label,
t.created_at
FROM prediction_sets p
JOIN training_runs t ON p.training_hash = t.training_hash
WHERE t.label = ?
"""
# Drop prediction sets with any constant-prediction (NULL-IC) fold so a
# degenerate L1/EN model is never backtested — see degenerate_prediction_sql().
query += degenerate_prediction_sql("p.prediction_hash")
params: list[str] = [label]
if split == "validation":
query += " AND p.split = 'validation'"
elif split == "holdout":
query += " AND p.split = 'holdout'"
# split == "all" → no additional filter
rows = conn.execute(query, params).fetchall()
col_names = [
"prediction_hash",
"training_hash",
"split",
"family",
"config_name",
"label",
"created_at",
]
finally:
conn.close()
frames: list[pl.DataFrame] = []
sources: dict[str, int] = {}
entries: list[dict] = []
for row_tuple in rows:
row = dict(zip(col_names, row_tuple, strict=False))
family = str(row.get("family", "")).strip()
if family not in requested:
continue
prediction_hash = row["prediction_hash"]
pred_path = case_dir / "run_log" / "predictions" / prediction_hash / "predictions.parquet"
if not pred_path.exists():
continue
try:
raw = pl.read_parquet(pred_path)
except Exception as exc:
warnings.warn(f"Failed to read predictions {pred_path}: {exc}", stacklevel=2)
continue
if raw.is_empty():
continue
source = model_source(family, row.get("config_name"))
run_split = row.get("split", "validation")
if split == "all" and run_split == "holdout":
source = f"{source}/holdout"
normalized = _normalize_predictions(raw, source, case_study_id)
if best_only and family != "latent_factors":
normalized = _select_best_predictions(normalized)
frames.append(normalized)
source_counts = normalized.group_by("source").agg(n=pl.len())
for count_row in source_counts.iter_rows(named=True):
source_name = count_row["source"]
sources[source_name] = sources.get(source_name, 0) + count_row["n"]
entries.append(
{
"hash": prediction_hash,
"family": family,
"label": row.get("label"),
"created_at": row.get("created_at"),
"source": source,
"predictions_path": str(pred_path),
}
)
return frames, sources, entries
def _load_cme_front_month_targets(case_dir: Path, label: str) -> pl.DataFrame | None:
label_path = case_dir / "labels" / f"{label}.parquet"
if not label_path.exists():
return None
try:
labels = pl.read_parquet(label_path)
except Exception as exc:
warnings.warn(f"Failed to read labels {label_path}: {exc}", stacklevel=2)
return None
if label not in labels.columns or "position" not in labels.columns:
return None
time_col = (
"timestamp"
if "timestamp" in labels.columns
else "date"
if "date" in labels.columns
else None
)
asset_col = (
"product"
if "product" in labels.columns
else "symbol"
if "symbol" in labels.columns
else None
)
if time_col is None or asset_col is None:
return None
return (
labels.filter(pl.col("position") == 0)
.select(
[
pl.col(time_col).cast(pl.Datetime("us")).alias("timestamp"),
pl.col(asset_col).cast(pl.String).alias("symbol"),
pl.col(label).cast(pl.Float64).alias("_front_y_true"),
]
)
.unique(subset=["timestamp", "symbol"], keep="first")
)
def _select_best_predictions(df: pl.DataFrame) -> pl.DataFrame:
"""Select best model per fold from multi-model prediction files.
For files with multiple models (linear has 9+, GBM has multiple configs),
select the model with highest mean |y_score| correlation with y_true per fold.
"""
if "model_id" not in df.columns:
return df
n_models = df["model_id"].n_unique()
if n_models <= 1:
return df
# Compute rank IC per model across all folds
model_ics = (
df.group_by("model_id")
.agg(
ic=pl.corr("y_score", "y_true", method="spearman"),
n_obs=pl.len(),
)
.sort("ic", descending=True)
)
best_model = model_ics.row(0, named=True)["model_id"]
return df.filter(pl.col("model_id") == best_model)
def load_backtest_predictions(
case_study_id: str,
label: str | None = None,
model_families: list[str] | None = None,
best_only: bool = True,
split: str = "validation",
use_registry: bool | None = None, # Deprecated — registry is always primary
) -> BacktestPredictions:
"""Load and normalize prediction artifacts for backtesting.
The model registry (``registry.db`` / ``models.db``) is the source of truth.
Predictions are loaded from content-addressed run directories
(``run_log/models/runs/{hash}/`` or ``models/runs/{hash}/``), keyed by
the registry's ``model_runs`` table.
Args:
case_study_id: Case study identifier (e.g., "etfs", "cme_futures")
label: Target label (e.g., "fwd_ret_21d"). None = primary from setup.yaml.
model_families: List of families to load. None = all available.
best_only: If True, select best model per family. If False, return all.
split: Which prediction split to load: "validation", "holdout", or "all".
use_registry: Deprecated — ignored. Registry is always used.
Returns:
BacktestPredictions with normalized [timestamp, symbol, y_score, y_true,
fold_id, model_id, source] DataFrame.
"""
case_dir = get_case_study_dir(case_study_id)
# Resolve label from setup.yaml if not provided
if label is None:
setup = yaml.safe_load((case_dir / "config" / "setup.yaml").read_text())
label = setup["labels"]["primary"]
if model_families is None:
model_families = MODEL_FAMILIES
valid_splits = {"validation", "holdout", "all"}
if split not in valid_splits:
msg = f"Invalid split='{split}'. Must be one of {sorted(valid_splits)}"
raise ValueError(msg)
cme_front_targets = (
_load_cme_front_month_targets(case_dir, label) if case_study_id == "cme_futures" else None
)
# --- Primary source: registry-backed content-addressed runs ---
frames, sources, registry_entries = _load_registry_prediction_frames(
case_study_id=case_study_id,
case_dir=case_dir,
label=label,
model_families=model_families,
split=split,
best_only=best_only,
)
if not frames:
msg = f"No predictions found for {case_study_id}/{label} in families {model_families}"
raise FileNotFoundError(msg)
predictions = pl.concat(frames, how="diagonal_relaxed")
if case_study_id == "cme_futures":
if (
cme_front_targets is not None
and "timestamp" in predictions.columns
and cme_front_targets.schema.get("timestamp") != predictions.schema.get("timestamp")
):
cme_front_targets = cme_front_targets.with_columns(
pl.col("timestamp").cast(predictions.schema["timestamp"])
)
base_sort = ["timestamp", "symbol", "source"]
fold_sort = ["fold_id"] if "fold_id" in predictions.columns else []
fold_desc = [True] if fold_sort else []
if cme_front_targets is not None and {"timestamp", "symbol", "y_true"}.issubset(
predictions.columns
):
predictions = predictions.join(
cme_front_targets, on=["timestamp", "symbol"], how="left"
)
predictions = predictions.with_columns(
(pl.col("y_true") - pl.col("_front_y_true")).abs().alias("_front_err")
)
predictions = predictions.sort(
by=base_sort + ["_front_err"] + fold_sort,
descending=[False, False, False, False] + fold_desc,
nulls_last=True,
)
predictions = predictions.unique(subset=["timestamp", "symbol", "source"], keep="first")
predictions = predictions.drop(["_front_y_true", "_front_err"])
else:
predictions = predictions.sort(
by=base_sort + fold_sort,
descending=[False, False, False] + fold_desc,
nulls_last=True,
).unique(subset=["timestamp", "symbol", "source"], keep="first")
predictions = predictions.sort(["source", "timestamp", "symbol"])
sources = {
row["source"]: row["n"]
for row in predictions.group_by("source").agg(pl.len().alias("n")).iter_rows(named=True)
}
# Compute summary stats
n_assets = predictions["symbol"].n_unique() if "symbol" in predictions.columns else 0
ts_col = "timestamp"
n_timestamps = predictions[ts_col].n_unique()
date_range = (
str(predictions[ts_col].min()),
str(predictions[ts_col].max()),
)
return BacktestPredictions(
predictions=predictions,
case_study_id=case_study_id,
label=label,
model_families=[f for f in model_families if any(s.startswith(f) for s in sources)],
n_assets=n_assets,
n_timestamps=n_timestamps,
date_range=date_range,
sources=sources,
registry_entries=registry_entries,
)
# ---------------------------------------------------------------------------
# Price loading
# ---------------------------------------------------------------------------
# Per-case-study price normalization config
_PRICE_CONFIG = {
"etfs": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "close",
"ohlcv": True,
"loader": "etfs",
"drop_cols": [],
},
"crypto_perps_funding": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "close",
"ohlcv": True,
"loader": "crypto_perps",
"drop_cols": [
"premium_index_open",
"premium_index_high",
"premium_index_low",
"premium_index_close",
],
},
"nasdaq100_microstructure": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "close",
"ohlcv": True,
"loader": "nasdaq100_bars",
"drop_cols": [],
},
"sp500_equity_option_analytics": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "close",
"ohlcv": True,
"loader": "sp500_daily_bars",
"drop_cols": [
"sec_id",
"adj_factor",
"vol_adj_factor",
"adjustment_factor",
"adjustment_reason",
],
},
"us_firm_characteristics": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": None, # No OHLCV — uses ret column for decile portfolio
"ohlcv": False,
# No loader — uses materialized prices.parquet (returns-only, no OHLCV source)
"drop_cols": [],
},
"fx_pairs": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "close",
"ohlcv": True,
"loader": "fx_pairs",
"drop_cols": [],
},
"cme_futures": {
"entity_col": "product",
"time_col": "session_date",
"close_col": "close",
"ohlcv": True,
"loader": "cme_futures",
"drop_cols": ["bar_count", "session_start", "session_end"],
"filter": {"tenor": 0}, # Front-month only
},
"sp500_options": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "underlying_price", # No standard OHLCV
"ohlcv": False,
"loader": "sp500_options_straddles",
"drop_cols": ["qc_any_estimated_iv"],
},
"us_equities_panel": {
"entity_col": "symbol",
"time_col": "timestamp",
"close_col": "adj_close",
"ohlcv": True,
"loader": "us_equities",
"rename_cols": {
"adj_open": "open",
"adj_high": "high",
"adj_low": "low",
"adj_close": "close",
"adj_volume": "volume",
},
# Drop raw OHLCV before adj_ rename to avoid duplicate columns
"drop_cols": [
"open",
"high",
"low",
"close",
"volume",
"ex-dividend",
"split_ratio",
"returns",
"adv_21d",
],
},
}
def _load_via_canonical(
loader_name: str,
max_symbols: int = 0,
frequency: str = "",
include_quotes: bool = False,
start_date: str | None = None,
end_date: str | None = None,
) -> pl.DataFrame:
"""Dispatch to canonical data loaders instead of reading prices.parquet."""
if loader_name == "etfs":
from data import load_etfs
return load_etfs(max_symbols=max_symbols, start_date=start_date, end_date=end_date)
if loader_name == "crypto_perps":
from data import load_crypto_perps
return load_crypto_perps(
frequency="8h",
max_symbols=max_symbols,
start_date=start_date,
end_date=end_date,
)
if loader_name == "fx_pairs":
from data import load_fx_pairs
return load_fx_pairs(
frequency="daily",
max_symbols=max_symbols,
start_date=start_date,
end_date=end_date,
)
if loader_name == "cme_futures":
from data import load_cme_futures
return load_cme_futures(
max_symbols=max_symbols,
start_date=start_date,
end_date=end_date,
)
if loader_name == "sp500_daily_bars":
from data.equities.loader import load_sp500_daily_bars
return load_sp500_daily_bars(
max_symbols=max_symbols,
start_date=start_date,
end_date=end_date,
)
if loader_name == "sp500_options_straddles":
from data import load_sp500_options_straddles
return load_sp500_options_straddles(
max_symbols=max_symbols,
start_date=start_date,
end_date=end_date,
)
if loader_name == "us_equities":
from data import load_us_equities
return load_us_equities(
max_symbols=max_symbols,
start_date=start_date,
end_date=end_date,
)
if loader_name == "nasdaq100_bars":
from data.equities.loader import load_nasdaq100_bars
freq = frequency or "15m"
return load_nasdaq100_bars(
frequency=freq,
max_symbols=max_symbols,
include_quotes=include_quotes,
start_date=start_date,
end_date=end_date,
)
msg = f"Unknown loader: {loader_name}"
raise ValueError(msg)
def load_backtest_prices(
case_study_id: str,
max_symbols: int = 0,
frequency: str = "",
include_quotes: bool = False,
start_date: str | None = None,
end_date: str | None = None,
) -> pl.DataFrame:
"""Load and normalize price data for DataFeed consumption.
Returns a DataFrame with columns [timestamp, symbol, open, high, low, close, volume]
for standard case studies, or case-study-specific columns for special cases
(us_firm: ret, sp500_options: instrument-level).
Args:
case_study_id: Case study identifier
max_symbols: Limit universe size (0 = all)
frequency: Bar frequency override for loader-backed case studies (e.g. "1m",
"15m", "1h"). Empty string uses the default for the case study.
include_quotes: If True, include bid/ask OHLCV columns (loader-backed only).
Use for risk-layer stop monitoring with bid/ask-aware execution.
start_date: Optional lower bound (``YYYY-MM-DD``) pushed into the parquet
read for row-group pruning. Callers SHOULD pass the canonical
``(cs, label, split)`` window so memory scales with the window
rather than the full history — see ``load_backtest_prices_for``
for the convenience that resolves the window from
``canonical_window``.
end_date: Optional upper bound (``YYYY-MM-DD``) pushed into the parquet
read.
Returns:
Normalized price DataFrame ready for DataFeed
"""
config = dict(_PRICE_CONFIG[case_study_id])
runtime = resolve_market_runtime(case_study_id)
if runtime:
for key in ("entity_col", "time_col", "close_col", "ohlcv", "loader", "prices_file"):
if runtime.get(key) is not None:
config[key] = runtime[key]
if "drop_cols" in runtime:
config["drop_cols"] = list(runtime["drop_cols"])
if "rename_cols" in runtime:
config["rename_cols"] = dict(runtime["rename_cols"])
if "filter" in runtime:
config["filter"] = dict(runtime["filter"])
effective_frequency = frequency or str(runtime.get("frequency", ""))
effective_include_quotes = (
include_quotes
or bool(runtime.get("include_quotes", False))
or _preset_requests_quotes(case_study_id)
)
loader_name = config.get("loader")
# Canonical loader dispatch — avoids materialized prices.parquet for most CS
if loader_name:
df = _load_via_canonical(
loader_name,
max_symbols,
effective_frequency,
effective_include_quotes,
start_date=start_date,
end_date=end_date,
)
# Apply post-load filters (e.g., CME front-month: tenor=0)
if "filter" in config:
for col, val in config["filter"].items():
df = df.filter(pl.col(col) == val)
else:
# File-backed fallback: US Firms (returns-only) and SP500 Options (straddles).
# Apply date filters lazily so parquet row-group pruning kicks in.
case_dir = get_case_study_dir(case_study_id)
prices_file = config.get("prices_file", "prices.parquet")
prices_path = case_dir / "labels" / prices_file
lf = pl.scan_parquet(prices_path)
if "filter" in config:
for col, val in config["filter"].items():
lf = lf.filter(pl.col(col) == val)
# Date pushdown — resolve dtype-aware comparison
if start_date or end_date:
ts_col = config.get("time_col", "timestamp")
if ts_col not in lf.collect_schema().names():
ts_col = "timestamp" if "timestamp" in lf.collect_schema().names() else "date"
ts_type = lf.collect_schema()[ts_col]
tz = getattr(ts_type, "time_zone", None)
is_date = ts_type == pl.Date
if start_date:
lit = (
pl.lit(start_date).str.to_date()
if is_date
else pl.lit(start_date).str.to_datetime()
)
if tz and not is_date:
lit = lit.dt.replace_time_zone(tz)
lf = lf.filter(pl.col(ts_col) >= lit)
if end_date:
if is_date:
lf = lf.filter(pl.col(ts_col) <= pl.lit(end_date).str.to_date())
else:
end_lit = pl.lit(end_date).str.to_datetime()
if tz:
end_lit = end_lit.dt.replace_time_zone(tz)
lf = lf.filter(pl.col(ts_col) < end_lit + pl.duration(days=1))
df = lf.collect()
# Drop unwanted columns
drop = [c for c in config.get("drop_cols", []) if c in df.columns]
if drop:
df = df.drop(drop)
# Apply renames (us_equities adj_ columns)
if "rename_cols" in config:
renames = {k: v for k, v in config["rename_cols"].items() if k in df.columns}
if renames:
df = df.rename(renames)
# Rename close column if non-standard
close_col = config.get("close_col")
if close_col and close_col != "close" and close_col in df.columns:
df = df.rename({close_col: "close"})
# Rename entity → symbol
entity_col = config["entity_col"]
if entity_col not in df.columns:
detected_entity = _detect_entity_col(df)
if detected_entity is None:
msg = f"No entity column found for {case_study_id}. Columns: {df.columns}"
raise KeyError(msg)
entity_col = detected_entity
if entity_col != "symbol" and entity_col in df.columns:
df = df.rename({entity_col: "symbol"})
# Ensure symbol is String
if "symbol" in df.columns and df.schema["symbol"] != pl.String:
df = df.with_columns(pl.col("symbol").cast(pl.String))
# Rename time → timestamp and cast to Datetime
time_col = config["time_col"]
if time_col not in df.columns:
detected_time = _detect_time_col(df)
if detected_time is None:
msg = f"No time column found for {case_study_id}. Columns: {df.columns}"
raise KeyError(msg)
time_col = detected_time
if time_col != "timestamp" and time_col in df.columns:
df = df.rename({time_col: "timestamp"})
if df.schema["timestamp"] == pl.Date:
df = df.with_columns(pl.col("timestamp").cast(pl.Datetime("ms")))
elif hasattr(df.schema["timestamp"], "time_zone") and df.schema["timestamp"].time_zone:
df = df.with_columns(pl.col("timestamp").dt.replace_time_zone(None))
# Drop filter columns that are no longer needed (e.g., position for CME)
if "filter" in config:
for col in config["filter"]:
if col in df.columns:
df = df.drop(col)
# Universe reduction
if max_symbols > 0 and "symbol" in df.columns:
top_symbols = (
df.group_by("symbol")
.agg(pl.len().alias("n"))
.sort("n", descending=True)
.head(max_symbols)["symbol"]
)
df = df.filter(pl.col("symbol").is_in(top_symbols))
return df.sort("timestamp", "symbol")
@cache
def _load_case_setup_yaml(case_study_id: str) -> dict:
"""Cached read of the case study's setup.yaml. Returns {} when missing."""
setup_path = get_case_study_dir(case_study_id) / "config" / "setup.yaml"
if not setup_path.exists():
return {}
with setup_path.open() as f:
data = yaml.safe_load(f) or {}
return data if isinstance(data, dict) else {}
def warmup_periods_for(case_study_id: str) -> int:
"""Resolve the per-CS warmup period count from setup.yaml.
Returns ``max(execution.allocator_lookback, max sweep allocator
{vol_window, lookback})``. Replaces the hardcoded ``warmup_periods=126``
constant that previously coupled call sites to the daily-cadence
default by hand; non-daily CSes need a different bar count (crypto
240, nasdaq100 520, us_firm 12).
Returns 0 when no allocator window is declared (the unbounded
fallback in ``load_backtest_prices_for`` will then skip the
prefix-day calculation entirely).
"""
setup = _load_case_setup_yaml(case_study_id)
execution = setup.get("execution") or {}
candidates: list[int] = []
base = execution.get("allocator_lookback")
if base is not None:
candidates.append(int(base))
backtest = setup.get("backtest") or {}
sweep = backtest.get("sweep") or {}
allocators = sweep.get("allocators") or []
for alloc in allocators:
if not isinstance(alloc, dict):
continue
for key in ("vol_window", "lookback"):
value = alloc.get(key)
if value is not None:
candidates.append(int(value))
return max(candidates) if candidates else 0
# Calendar-day spacing per allocator-window bar, indexed by setup.yaml
# cadence / bar_frequency tokens. Daily cadences allow 1.5× to absorb
# weekends + market holidays; intraday cadences are pure trading-time
# (no weekend allowance needed — the price loader's start_date filter
# only sees timestamps that exist). Monthly month-end approximates a
# calendar-month spacing.
_CADENCE_CALENDAR_DAYS_PER_PERIOD: dict[str, float] = {
# Daily cadences
"daily_close": 1.5,
"daily_ny_close": 1.5,
# Weekly cadences
"weekly_friday_close": 7.0,
"weekly_friday": 7.0,
# 8-hour funding
"8_hour_funding_aligned": 1.0 / 3.0,
# Intraday equity microstructure: ~26 fifteen-minute bars per RTH
# trading day; multiply by 1.4 to account for weekends.
"15_minute": (1.0 / 26.0) * 1.4,
# Monthly month-end
"monthly_month_end": 31.0,
}
def _calendar_days_per_period(case_study_id: str) -> float:
"""Calendar-day spacing per allocator-window bar for this case study.
Reads ``decision.entry_cadence`` (or ``decision.cadence`` or
``decision.bar_frequency``) and returns the calendar-day multiplier
used to walk the start_date back during a warmup-prefix load. Falls
back to the daily 1.5× heuristic when the cadence token isn't
recognized — old behavior for unknown CSes.
"""
setup = _load_case_setup_yaml(case_study_id)
decision = setup.get("decision") or {}
cadence = (
decision.get("entry_cadence") or decision.get("cadence") or decision.get("bar_frequency")
)
if cadence and cadence in _CADENCE_CALENDAR_DAYS_PER_PERIOD:
return _CADENCE_CALENDAR_DAYS_PER_PERIOD[cadence]
return 1.5
def load_backtest_prices_for(
case_study_id: str,
label: str,
split: Literal["validation", "holdout"] = "validation",
warmup_periods: int = 0,
**kwargs,
) -> pl.DataFrame:
"""Load prices pre-windowed to ``canonical_window(case_study_id, label, split)``.
When ``warmup_periods > 0``, the start of the load window is left
unconstrained so rolling-vol allocators (``inverse_vol`` /
``risk_parity`` / ``hrp`` / ``mvo_ledoit_wolf``) see pre-window history
and produce data-driven weights at the first rebalance instead of
falling back to the median-imputed warmup. The end of the window is
always capped to the canonical window end; the engine's port_ret only
covers rebalance timestamps from the predictions, so the extra prefix
history is consumed by the allocator's rolling window but does not
enter return aggregation.
Args:
case_study_id: Case study identifier.
label: Target label (e.g. ``"fwd_ret_21d"``).
split: ``"validation"`` (default) for the union-of-folds window, or
``"holdout"`` for ``setup.yaml::evaluation.{holdout_start,
holdout_end}``.
warmup_periods: Number of pre-window periods the caller's allocator
needs (typically ``strategy.allocation.vol_window`` or
``lookback``). When > 0, the start of the load window is
dropped so the parquet read returns the full prefix up to the
canonical window end. When 0 (default), only the canonical
window is loaded.
**kwargs: Forwarded to :func:`load_backtest_prices` (``max_symbols``,
``frequency``, ``include_quotes``). Explicit ``start_date`` or
``end_date`` in ``kwargs`` take precedence over the canonical
window.
"""
import math
from datetime import timedelta
from case_studies.utils.cv_window import canonical_window
win = canonical_window(case_study_id, label, split=split)
if win is not None:
kwargs.setdefault("end_date", win[1].isoformat())
if warmup_periods <= 0:
kwargs.setdefault("start_date", win[0].isoformat())
else:
# Bounded warmup: walk start_date back by ~warmup_periods
# allocator-window bars, expressed as calendar days using the
# per-CS cadence multiplier from
# ``_CADENCE_CALENDAR_DAYS_PER_PERIOD``. Daily cadences use 1.5×
# (weekend + holiday allowance), weekly 7×, monthly 31×, 8h
# ~0.33×, 15-min ~0.054×. Previously this was a flat 1.5×
# which over-allocated by ~50× on the intraday
# nasdaq100_microstructure CS (520 periods × 15-min bars).
# ``math.ceil`` is load-bearing: float-arithmetic truncation
# (e.g. ``520 * (1.0/26.0) * 1.4`` can land at 27.999...)
# would silently under-provision the warmup window by one
# bar on intraday CSes. The floor of 7 days ensures the
# parquet read covers at least a full calendar week even
# when ``warmup_periods`` is tiny (e.g. monthly us_firm
# with 12 periods).
cal_per_period = _calendar_days_per_period(case_study_id)
prefix_days = max(math.ceil(warmup_periods * cal_per_period), 7)
kwargs.setdefault("start_date", (win[0] - timedelta(days=prefix_days)).isoformat())
return load_backtest_prices(case_study_id, **kwargs)
# ---------------------------------------------------------------------------
# Calendar-aware schedule resolution
# ---------------------------------------------------------------------------
def resolve_rebalance_timestamps(
available_timestamps: pl.Series,
cadence: str,
calendar: str = "NYSE",
) -> pl.Series:
"""Resolve exact rebalance timestamps from cadence + calendar + available data.
Instead of counting elapsed seconds or stepping by fixed intervals, this
function selects the actual timestamps that match the declared schedule:
- ``monthly_month_end`` → last available timestamp in each calendar month
- ``weekly_friday_close`` / ``weekly_friday`` → last available timestamp
in each ISO week (typically Friday, or Thursday if Friday is a holiday)
- ``daily_*`` → every available timestamp
- ``8_hour_*`` / ``15_min`` → every available timestamp (fixed-interval
cadences where the data is already at the correct granularity)
Parameters
----------
available_timestamps : pl.Series
Sorted unique timestamps from predictions or prices.
cadence : str
Rebalance cadence from setup.yaml.
calendar : str
Trading calendar name (used for future session filtering).
Returns
-------
pl.Series
Subset of available_timestamps matching the declared schedule.
"""
if available_timestamps.is_empty():
return available_timestamps
ts = available_timestamps.unique().sort()
if cadence == "monthly_month_end":
# Last available session in each calendar month
df = pl.DataFrame({"ts": ts}).with_columns(
year=pl.col("ts").dt.year(),
month=pl.col("ts").dt.month(),
)
month_ends = (
df.group_by("year", "month").agg(pl.col("ts").max().alias("rebal_ts")).sort("rebal_ts")
)
return month_ends["rebal_ts"]
if cadence in {"weekly", "weekly_friday", "weekly_friday_close"}:
# Last available session in each ISO week
df = pl.DataFrame({"ts": ts}).with_columns(
iso_year=pl.col("ts").dt.iso_year(),
iso_week=pl.col("ts").dt.week(),
)
week_ends = (
df.group_by("iso_year", "iso_week")
.agg(pl.col("ts").max().alias("rebal_ts"))
.sort("rebal_ts")
)
return week_ends["rebal_ts"]
# All other cadences: daily, 8_hour, 15_min, etc.
# The data is already at the correct granularity — every timestamp is valid.
return ts
# ---------------------------------------------------------------------------
# Rebalance thinning
# ---------------------------------------------------------------------------
@cache
def get_rebalance_step(case_study: str, label: str) -> int:
"""Return the per-label vectorized-backtest thinning step, from setup.yaml.
The step is the number of schedule slots to advance per trade so that
holding periods don't overlap. It is a design-time property of the
(case study, label) pair — authored in each case study's
``config/setup.yaml`` under ``labels.rebalance_step``. No inference
happens at runtime.
Parameters
----------
case_study : str
Case study identifier (e.g., ``"sp500_options"``).
label : str
Label name (e.g., ``"ret_to_expiry"``).
Returns
-------
int
Rebalance step (>= 1).
Raises
------
KeyError
If ``labels.rebalance_step[label]`` is missing from setup.yaml.
New labels must be registered explicitly.
"""
# Always read the source-of-truth setup.yaml under CASE_STUDIES_DIR, not the
# ML4T_OUTPUT_DIR-redirected get_case_study_dir() path: the rebalance-step
# declaration is configuration, not output, and tests must see the real value.
from utils import CASE_STUDIES_DIR
setup_path = CASE_STUDIES_DIR / case_study / "config" / "setup.yaml"
setup = yaml.safe_load(setup_path.read_text())
steps = (setup.get("labels") or {}).get("rebalance_step") or {}
if label not in steps:
raise KeyError(
f"labels.rebalance_step[{label!r}] not declared in "
f"case_studies/{case_study}/config/setup.yaml. Add it explicitly — "
f"the step is (schedule cadence, label horizon)-dependent and must "
f"not be inferred at runtime."
)
step = int(steps[label])
if step < 1:
raise ValueError(
f"labels.rebalance_step[{label!r}] = {step!r} in "
f"case_studies/{case_study}/config/setup.yaml — must be >= 1."
)
return step
def thin_to_rebalance_dates(
predictions: pl.DataFrame,
cadence: str = "",
step: int = 1,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""Thin predictions to non-overlapping rebalance dates.
Two-step thinning for vectorized backtests:
1. **Calendar alignment** — filter prediction timestamps to those that
match the declared rebalance cadence (e.g., only Fridays for
``weekly_friday``, only month-ends for ``monthly_month_end``).
2. **Non-overlapping thinning** — keep every ``step``-th calendar-aligned
date so forward-return windows don't overlap. The caller supplies
``step`` via :func:`get_rebalance_step`, which looks it up from
``labels.rebalance_step`` in the case study's setup.yaml.
Parameters
----------
predictions : pl.DataFrame
Must contain ``time_col`` (default ``"timestamp"``).
cadence : str
Rebalance cadence from setup.yaml (e.g., ``"monthly_month_end"``).
step : int
Number of schedule slots to advance per trade (1 = keep every
calendar-aligned date). Must be >= 1.
time_col : str
Timestamp column name.
Returns
-------
pl.DataFrame
Filtered DataFrame with rows at non-overlapping rebalance times.
"""
all_dates = predictions[time_col].unique().sort()
n_dates = len(all_dates)
if n_dates <= 1:
return predictions
# Step 1: Calendar-aware schedule resolution
schedule_dates = resolve_rebalance_timestamps(all_dates, cadence)
# Step 2: Apply design-time non-overlapping step
if step > 1:
schedule_dates = schedule_dates.gather_every(step)
# Semi-join to filter — avoids Polars is_in precision mismatch
# (group_by().agg(max) can change Datetime precision)
schedule_df = pl.DataFrame({time_col: schedule_dates})
if schedule_df[time_col].dtype != predictions[time_col].dtype:
schedule_df = schedule_df.cast({time_col: predictions[time_col].dtype})
return predictions.join(schedule_df, on=time_col, how="semi")
# ---------------------------------------------------------------------------
# Shared allocator metrics
# ---------------------------------------------------------------------------
def _periods_per_year_from_ann_factor(ann_factor: float) -> int:
"""Convert annualization factor (sqrt(N)) back to periods per year."""
return max(1, round(ann_factor**2))
def compute_allocator_metrics(
port_returns: pl.Series | list[float],
weights_df: pl.DataFrame | None = None,
ann_factor: float = np.sqrt(252),
time_col: str = "timestamp",
cost_rate: float = 0.0,
) -> dict:
"""Compute allocator summary metrics using ml4t-diagnostic PortfolioAnalysis.
Args:
port_returns: Series or list of per-period gross returns. If cost_rate > 0,
turnover-adjusted net returns are computed internally.
weights_df: Optional DataFrame with [timestamp, symbol, weight] for
computing concentration and turnover metrics.
ann_factor: Annualization factor (sqrt of periods per year).
time_col: Time column name in weights_df.
cost_rate: Per-period cost rate applied to turnover (e.g., 0.001 for 10 bps).
When > 0, net returns = gross - turnover * cost_rate.
Returns:
Dict with return-based metrics from PortfolioAnalysis plus weight-based
metrics (turnover, HHI, effective bets, max weight).
"""
import numpy as _np
from ml4t.diagnostic.evaluation import PortfolioAnalysis
if isinstance(port_returns, pl.Series):
rets = port_returns.to_numpy()
else:
rets = _np.array(port_returns)
rets = rets[~_np.isnan(rets)]
_empty_keys = [
"sharpe",
"sortino",
"calmar",
"omega",
"total_return",
"annual_return",
"max_drawdown",
"max_dd_duration",
"var_95",
"cvar_95",
"win_rate",
"profit_factor",
"stability",
"avg_turnover",
"avg_hhi",
"eff_bets",
"avg_max_weight",
]
if len(rets) == 0:
return {k: 0.0 for k in _empty_keys}
# --- Weight-based metrics (computed first for cost deduction) ---
avg_turnover = 0.0
avg_hhi = 0.0
eff_bets = 0.0
avg_max_weight = 0.0
turnover_per_period = None
if weights_df is not None and len(weights_df) > 0:
hhi_ts = (
weights_df.with_columns(w2=pl.col("weight") ** 2)
.group_by(time_col)
.agg(hhi=pl.col("w2").sum(), max_w=pl.col("weight").abs().max())
)
avg_hhi = float(hhi_ts["hhi"].mean())
avg_max_weight = float(hhi_ts["max_w"].mean())
eff_bets = 1.0 / avg_hhi if avg_hhi > 0 else 0.0
w_lag = weights_df.sort(time_col, "symbol").with_columns(
prev_w=pl.col("weight").shift(1).over("symbol").fill_null(0.0)
)
to_ts = (
w_lag.with_columns(delta=(pl.col("weight") - pl.col("prev_w")).abs())
.group_by(time_col)
.agg(turnover=pl.col("delta").sum())
)
avg_turnover = float(to_ts["turnover"].mean())
if cost_rate > 0:
turnover_per_period = to_ts["turnover"].to_numpy()
# Deduct transaction costs if cost_rate provided
if cost_rate > 0 and turnover_per_period is not None:
n = min(len(rets), len(turnover_per_period))
rets = rets[:n] - turnover_per_period[:n] * cost_rate
elif cost_rate > 0:
rets = rets - avg_turnover * cost_rate
# --- Return-based metrics via PortfolioAnalysis ---
periods_per_year = _periods_per_year_from_ann_factor(ann_factor)
pa = PortfolioAnalysis(pl.Series("returns", rets), periods_per_year=periods_per_year)
stats = pa.compute_summary_stats()
dd = pa.compute_drawdown_analysis()
def _safe_round(value: object, digits: int = 4) -> float:
if isinstance(value, complex):
value = value.real
return round(float(value), digits)
return {
"sharpe": _safe_round(stats.sharpe_ratio, 4),
"sortino": _safe_round(stats.sortino_ratio, 4),
"calmar": _safe_round(stats.calmar_ratio, 4),
"omega": _safe_round(stats.omega_ratio, 4),
"total_return": _safe_round(stats.total_return, 6),
"annual_return": _safe_round(stats.annual_return, 6),
"max_drawdown": _safe_round(stats.max_drawdown, 6),
"max_dd_duration": int(dd.max_duration_days),
"var_95": _safe_round(stats.var_95, 6),
"cvar_95": _safe_round(stats.cvar_95, 6),
"win_rate": _safe_round(stats.win_rate, 4),
"profit_factor": _safe_round(stats.profit_factor, 4),
"stability": _safe_round(stats.stability, 4),
"avg_turnover": round(avg_turnover, 6),
"avg_hhi": round(avg_hhi, 6),
"eff_bets": round(eff_bets, 2),
"avg_max_weight": round(avg_max_weight, 6),
}
def compute_dsr_table(
returns_by_source: dict[str, pl.Series | np.ndarray],
periods_per_year: int = 252,
) -> pl.DataFrame:
"""Rank model variants by Sharpe with raw-K selection-bias adjustment for the best.
Ad-hoc utility for one-off DSR analysis over a custom returns dict. Uses
**raw-K** trial counting (no Marchenko-Pastur or effective-rank
correction), which overcounts trials when variants are correlated.
For headline / persisted DSR numbers, prefer the cohort_metrics table:
BacktestExplorer(cs).deflated_sharpe(stage="signal")
which surfaces the effective-rank (ER) DSR — the library maintainer's
recommended default — alongside MP and raw-K for sensitivity.
Each variant gets its own Sharpe ratio and individual PSR (probability of
skill without multiple-testing correction). The best variant additionally
gets DSR columns showing how selection bias across K tested strategies
deflates the observed Sharpe.
Args:
returns_by_source: Dict mapping model name to return series.
periods_per_year: Annualization periods.
Returns:
DataFrame sorted by Sharpe (descending) with columns: source, sharpe,
psr_pvalue, deflated_sharpe, expected_max_sharpe, dsr_pvalue,
significant, is_best.
"""
from ml4t.diagnostic.evaluation.stats import deflated_sharpe_ratio
freq_map = {252: "daily", 52: "weekly", 12: "monthly", 1: "annual"}
frequency = freq_map.get(periods_per_year, "daily")
all_returns = []
names = []
for name, ret in returns_by_source.items():
arr = ret.to_numpy() if isinstance(ret, pl.Series) else np.asarray(ret)
all_returns.append(arr)
names.append(name)
if not all_returns:
return pl.DataFrame(
schema={
"source": pl.Utf8,
"sharpe": pl.Float64,
"psr_pvalue": pl.Float64,
"deflated_sharpe": pl.Float64,
"expected_max_sharpe": pl.Float64,
"dsr_pvalue": pl.Float64,
"significant": pl.Boolean,
"is_best": pl.Boolean,
}
)
# Per-variant PSR (individual probability of skill, no multiple-testing correction)
per_variant_psr = {}
sharpes = {}
for i, name in enumerate(names):
arr = all_returns[i]
sr = float(np.mean(arr) / max(np.std(arr, ddof=1), 1e-8) * np.sqrt(periods_per_year))
sharpes[name] = sr
try:
psr = deflated_sharpe_ratio(
[arr], frequency=frequency, periods_per_year=periods_per_year
)
per_variant_psr[name] = psr
except Exception as exc:
warnings.warn(f"DSR computation failed for {name}: {exc}", stacklevel=2)
per_variant_psr[name] = None
# Aggregate DSR across all variants (selection-bias adjustment for best-of-K)
# Filter out zero-variance return series (e.g. constant/all-zero returns from test data)
valid_returns = [r for r in all_returns if np.std(r, ddof=1) > 1e-10]
if not valid_returns:
return pl.DataFrame(
schema={
"source": pl.Utf8,
"sharpe": pl.Float64,
"psr_pvalue": pl.Float64,
"deflated_sharpe": pl.Float64,
"expected_max_sharpe": pl.Float64,
"dsr_pvalue": pl.Float64,
"significant": pl.Boolean,
"is_best": pl.Boolean,
}
)
dsr = deflated_sharpe_ratio(
valid_returns, frequency=frequency, periods_per_year=periods_per_year
)
# Identify best variant by Sharpe
best_name = max(sharpes, key=sharpes.get)
rows = []
for name in names:
psr = per_variant_psr.get(name)
is_best = name == best_name
rows.append(
{
"source": name,
"sharpe": round(sharpes[name], 4),
"psr_pvalue": round(psr.p_value, 4) if psr else None,
"deflated_sharpe": round(dsr.deflated_sharpe, 4) if is_best else None,
"expected_max_sharpe": round(dsr.expected_max_sharpe, 4) if is_best else None,
"dsr_pvalue": round(dsr.p_value, 4) if is_best else None,
"significant": bool(dsr.is_significant) if is_best else None,
"is_best": is_best,
}
)
# Sort by Sharpe descending so best is row 0
df = pl.DataFrame(rows).sort("sharpe", descending=True)
return df
def print_stage_dsr_summary(
explorer,
*,
stages: tuple[str, ...] = ("signal", "allocation", "cost_sensitivity", "risk_overlay"),
top_n: int = 20,
head: int = 10,
) -> None:
"""Print per-stage DSR / PSR tables for a case-study explorer.
The selection-bias question — "after K variants were tried, does the leader
have skill?" — is well-defined at every pipeline stage, not just the
signal stage. This helper iterates the four stages, prints the leader
table for each one (with PSR per variant + DSR for the leader), and
silently skips stages that have no data.
"""
for stage in stages:
try:
table = explorer.deflated_sharpe(stage=stage, top_n=top_n)
except ValueError as exc:
if "zero variance" in str(exc).lower():
print(f"\n--- DSR @ {stage}: skipped ({exc}) ---")
continue
raise
except Exception as exc: # pragma: no cover
print(f"\n--- DSR @ {stage}: error ({exc}) ---")
continue
if table is None or table.is_empty():
continue
print(f"\n--- DSR @ {stage} (K={table.height}) ---")
print(table.head(head))
def infer_session_alignment(calendar: str | None) -> bool:
"""Infer whether returns should be aligned to trading sessions."""
return bool(calendar and "CME" in str(calendar).upper())
def _extract_session_aligned_returns(result: BacktestResult) -> pl.DataFrame:
"""Rebuild session-aligned returns when ml4t-backtest's helper hits dtype issues."""
from zoneinfo import ZoneInfo
from ml4t.backtest.sessions import SessionConfig, assign_session_date
equity_df = (
result.to_equity_dataframe()
.select("timestamp", "equity")
.with_columns(pl.col("equity").cast(pl.Float64))
)
if equity_df.is_empty():
return pl.DataFrame({"timestamp": pl.Series([], dtype=pl.Date), "daily_return": []})
session_config = SessionConfig(
calendar=result.config.calendar,
timezone=result.config.timezone,
session_start_time=getattr(result.config, "session_start_time", None),
)
tz = ZoneInfo(session_config.timezone)
session_start_hour = session_config.get_session_start_hour()
session_start_minute = session_config.get_session_start_minute()
timestamps = equity_df["timestamp"].to_list()
session_dates = [
assign_session_date(ts, tz, session_start_hour, session_start_minute) for ts in timestamps
]
daily = (
pl.DataFrame(
{
"timestamp": timestamps,
"equity": equity_df["equity"].to_list(),
"session_date": session_dates,
},
strict=False,
)
.group_by("session_date")
.agg(
pl.col("equity").first().alias("open_equity"),
pl.col("equity").last().alias("close_equity"),
)
.sort("session_date")
)
prev_close = daily.select(pl.col("close_equity").shift(1)).to_series()
return (
daily.with_columns(
((pl.col("close_equity") - prev_close) / prev_close)
.fill_null(0.0)
.alias("daily_return")
)
.select(pl.col("session_date").cast(pl.Date).alias("timestamp"), "daily_return")
.sort("timestamp")
.unique("timestamp", keep="last")
)
def extract_daily_returns_frame(
result: BacktestResult,
calendar: str | None = None,
session_aligned: bool | None = None,
) -> pl.DataFrame:
"""Extract daily returns with dates from BacktestResult.
Prefers `to_daily_pnl()` so output includes date/session_date labels.
"""
if session_aligned is None:
cal = calendar
if cal is None and hasattr(result, "config") and result.config is not None:
cal = getattr(result.config, "calendar", None)
session_aligned = infer_session_alignment(cal)
if hasattr(result, "to_daily_pnl"):
try:
daily = result.to_daily_pnl(session_aligned=session_aligned)
except TypeError:
if session_aligned and getattr(result, "config", None) is not None:
return _extract_session_aligned_returns(result)
daily = None
if daily is not None:
if daily.is_empty():
return pl.DataFrame({"timestamp": pl.Series([], dtype=pl.Date), "daily_return": []})
date_col = "session_date" if "session_date" in daily.columns else "date"
if date_col not in daily.columns:
msg = f"to_daily_pnl() missing date column. Columns: {daily.columns}"
raise ValueError(msg)
return (
daily.select(
pl.col(date_col).cast(pl.Date).alias("timestamp"),
pl.col("return_pct").cast(pl.Float64).alias("daily_return"),
)
.sort("timestamp")
.unique("timestamp", keep="last")
)
if hasattr(result, "to_daily_returns"):
daily_returns = result.to_daily_returns(
calendar=calendar,
session_aligned=session_aligned,
)
if not isinstance(daily_returns, pl.Series):
daily_returns = pl.Series("daily_return", np.asarray(daily_returns, dtype=float))
return pl.DataFrame(
{"date_idx": np.arange(len(daily_returns)), "daily_return": daily_returns}
)
if hasattr(result, "to_returns_series"):
rets = result.to_returns_series()
if not isinstance(rets, pl.Series):
rets = pl.Series("daily_return", np.asarray(rets, dtype=float))
return pl.DataFrame({"date_idx": np.arange(len(rets)), "daily_return": rets})
msg = "BacktestResult has no daily or period returns export method"
raise AttributeError(msg)
def aggregate_timestamped_returns_to_daily(
returns_df: pl.DataFrame,
*,
timestamp_col: str = "timestamp",
return_col: str = "ret",
calendar: str | None = None,
session_aligned: bool | None = None,
) -> pl.DataFrame:
"""Aggregate timestamped period returns to daily returns.
For CME-style sessions, uses session-date assignment when available.
"""
if returns_df.is_empty():
return pl.DataFrame({"timestamp": pl.Series([], dtype=pl.Date), "daily_return": []})
if timestamp_col not in returns_df.columns or return_col not in returns_df.columns:
msg = f"Expected columns '{timestamp_col}' and '{return_col}'. Got: {returns_df.columns}"
raise ValueError(msg)
out = returns_df.select([timestamp_col, return_col]).drop_nulls()
if out.is_empty():
return pl.DataFrame({"timestamp": pl.Series([], dtype=pl.Date), "daily_return": []})
if out[timestamp_col].dtype == pl.Utf8:
out = out.with_columns(pl.col(timestamp_col).str.to_datetime(strict=False))
if session_aligned is None:
session_aligned = infer_session_alignment(calendar)
if session_aligned:
try:
from zoneinfo import ZoneInfo
from ml4t.backtest.sessions import SessionConfig, assign_session_date
cfg = SessionConfig(calendar=str(calendar or "CME_Equity"))
tz = ZoneInfo(cfg.timezone)
sh = cfg.get_session_start_hour()
sm = cfg.get_session_start_minute()
ts = out[timestamp_col].to_list()
session_dates = [assign_session_date(t, tz, sh, sm).date() for t in ts]
out = out.with_columns(pl.Series("timestamp", session_dates, dtype=pl.Date))
except Exception:
out = out.with_columns(pl.col(timestamp_col).dt.date().alias("timestamp"))
else:
out = out.with_columns(pl.col(timestamp_col).dt.date().alias("timestamp"))
return (
out.group_by("timestamp")
.agg(daily_return=((1.0 + pl.col(return_col)).product() - 1.0))
.sort("timestamp")
)
# ---------------------------------------------------------------------------
# Config extraction
# ---------------------------------------------------------------------------
def get_backtest_config(case_study_id: str) -> BacktestConfig:
"""Extract backtesting configuration from setup.yaml.
Normalizes the heterogeneous costs sections into a uniform
(commission_bps, slippage_bps) pair.
Args:
case_study_id: Case study identifier
Returns:
BacktestConfig with normalized cost and execution parameters
"""
case_dir = get_case_study_dir(case_study_id)
setup = yaml.safe_load((case_dir / "config" / "setup.yaml").read_text())
market_semantics = resolve_market_semantics(case_study_id, setup)
labels = setup["labels"]
evaluation = setup["evaluation"]
decision = setup.get("decision", {})
mapping = setup.get("mapping", {})
costs = setup.get("costs", {})
# Normalize costs to (commission_bps, slippage_bps)
commission_bps, slippage_bps = _normalize_costs(costs, case_study_id)
# Determine long/short from mapping state-space tokens.
# One-sided short states (e.g., short_straddle_hedged) should not trigger cross-sectional
# long/short construction.
position_space = str(mapping.get("position_state_space", "long_only")).strip().lower()
tokens = [tok for tok in re.split(r"[^a-z0-9]+", position_space) if tok]
long_short = "long" in tokens and "short" in tokens
# Determine cadence: entry_cadence > cadence > bar_frequency > default
cadence = (
decision.get("entry_cadence")
or decision.get("cadence")
or decision.get("bar_frequency")
or "monthly_month_end"
)
backtest_block = setup.get("backtest", {}) or {}
rebalance_block = backtest_block.get("rebalance", {}) or {}
default_rebal = rebalance_block.get("default", {}) or {}
# Engine-level execution defaults: single source of truth. Notebooks must
# never declare local INITIAL_CASH / SHARE_TYPE constants. Falls back to
# the previous notebook defaults during migration; the CS's setup.yaml
# should declare an ``execution:`` block explicitly.
execution = setup.get("execution", {}) or {}
initial_cash = float(execution.get("initial_cash", 100_000.0))
share_type = str(execution.get("share_type", "integer"))
return BacktestConfig(
case_study_id=case_study_id,
primary_label=labels["primary"],
label_buffer=labels.get("buffer", ""),
calendar=market_semantics.get("calendar") or evaluation.get("calendar", "NYSE"),
cadence=cadence,
execution_delay=decision.get("execution_delay", "next_bar_open"),
commission_bps=commission_bps,
slippage_bps=slippage_bps,
costs_class=costs.get("class", "material"),
long_short=long_short,
holdout_start=evaluation.get("holdout_start", ""),
holdout_end=evaluation.get("holdout_end", ""),
n_splits=evaluation.get("n_splits", 1),
raw_costs=costs,
min_weight_change=float(default_rebal.get("min_weight_change", 0.005)),
min_trade_value=float(default_rebal.get("min_trade_value", 100.0)),
initial_cash=initial_cash,
share_type=share_type,
)
def get_benchmark_rebalance_thresholds(case_study_id: str) -> tuple[float, float]:
"""Return (min_weight_change, min_trade_value) for the benchmark profile.
Read from setup.yaml:backtest.rebalance.benchmark. The benchmark — full-
universe equal-weight — needs thresholds disabled (per-asset weight = 1/N
is below the default 0.5% for any reasonable universe), so this profile
typically returns (0.0, 0.0). Falls back to the default profile if no
benchmark block is declared.
"""
case_dir = get_case_study_dir(case_study_id)
setup = yaml.safe_load((case_dir / "config" / "setup.yaml").read_text())
rebal = (setup.get("backtest", {}) or {}).get("rebalance", {}) or {}
bench = rebal.get("benchmark") or rebal.get("default") or {}
return (
float(bench.get("min_weight_change", 0.0)),
float(bench.get("min_trade_value", 0.0)),
)
def _normalize_costs(costs: dict, case_study_id: str) -> tuple[float, float]:
"""Convert heterogeneous cost structures to (commission_bps, slippage_bps).
Returns:
(commission_bps, slippage_bps) — both in basis points
"""
if not costs or costs.get("class") == "negligible":
return 0.0, 0.0
# Most case studies: per_leg_cost_bps_range → midpoint
if "per_leg_cost_bps_range" in costs:
lo, hi = costs["per_leg_cost_bps_range"]
midpoint = (lo + hi) / 2
# Split roughly 60/40 between commission and slippage
return midpoint * 0.6, midpoint * 0.4
# Crypto: fee_schedule with taker/maker
if "fee_schedule" in costs:
fee = costs["fee_schedule"]
taker = fee.get("taker_bps", 4)
maker = fee.get("maker_bps", 2)
# Use taker as conservative estimate (most retail trades are taker)
return float(taker), 1.0 # Minimal slippage for liquid crypto
# Round trip cost
if "round_trip_cost_bps" in costs:
rt = costs["round_trip_cost_bps"]
per_leg = rt / 2
return per_leg * 0.6, per_leg * 0.4
# Fallback: covers cme_futures (commission_per_contract + spread_ticks)
# and other non-standard cost structures. For CME futures, exact bps
# conversion requires contract-specific notional; 7 bps total is a
# reasonable aggregate across the 30-product universe.
return 5.0, 2.0