项目文件夹

文件
2026-07-13 13:26:28 +08:00

2133 行
86 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""Core backtest execution — engine-first, used by BOTH demo and sweep notebooks.
This module provides a single ``run_backtest()`` function that:
1. Converts predictions to target weights via strategy_spec["signal"]
2. Dispatches to engine or vectorized path
3. Optionally registers the result in registry.db
4. Returns a unified result object
The key invariant is that **sweep notebooks call the same function as demo
notebooks**. There is no separate vectorized reimplementation for sweeps.
Usage::
from case_studies.utils.backtest_runner import run_backtest
result = run_backtest(
case_study="etfs",
prediction_hash="abc123",
strategy_spec=spec,
prices=prices,
predictions=predictions,
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import numpy as np
import polars as pl
from case_studies.utils.backtest_loaders import BacktestConfig, get_backtest_config
from case_studies.utils.backtest_presets import (
apply_calendar_session_enforcement,
ensure_backtest_spec,
runtime_backtest_config,
strategy_view,
)
from case_studies.utils.signals import build_target_weights_from_config
# ---------------------------------------------------------------------------
# Periods per year for Sharpe annualization
# ---------------------------------------------------------------------------
# Periods per year for Sharpe annualization. Used by the vectorized path
# where each return observation corresponds to one rebalance period.
_PERIODS_PER_YEAR: dict[str, float] = {
"monthly_month_end": 12,
"weekly": 52,
"weekly_friday": 52,
"weekly_friday_close": 52,
"daily": 252,
"daily_close": 252,
"daily_ny_close": 252,
"8_hour_funding_aligned": 365 * 3, # 3 observations per calendar day
"15_min": 252 * 26, # ~26 fifteen-min bars per NYSE session
"15_minute": 252 * 26, # alias
"30_min": 252 * 13, # ~13 thirty-min bars per NYSE session
"30_minute": 252 * 13,
"1_hour": 252 * 6.5, # 6.5 hours per NYSE session
"1_hourly": 252 * 6.5,
"4_hour": 252 * 1.625, # ~1.625 four-hour bars per NYSE session
"4_hourly": 252 * 1.625,
}
# Calendar name → exchange_calendars MIC code
_CALENDAR_TO_XCAL: dict[str, str] = {
"NYSE": "XNYS",
"CME": "us_futures",
"FX": "24/5",
"crypto": "24/7",
}
# Cache for calendar session counts
_calendar_ppy_cache: dict[str, int] = {}
def calendar_periods_per_year(calendar: str) -> int:
"""Get trading days per year for a calendar using exchange_calendars.
Computes the average number of sessions over a 10-year window
(2015-2024) and caches the result.
"""
if calendar in _calendar_ppy_cache:
return _calendar_ppy_cache[calendar]
xcal_name = _CALENDAR_TO_XCAL.get(calendar, calendar)
try:
import exchange_calendars as xcals
cal = xcals.get_calendar(xcal_name)
total = sum(
len(cal.sessions_in_range(f"{y}-01-01", f"{y}-12-31")) for y in range(2015, 2025)
)
ppy = round(total / 10)
except Exception:
# Fallback if exchange_calendars unavailable or calendar unknown
ppy = 252
_calendar_ppy_cache[calendar] = ppy
return ppy
# ---------------------------------------------------------------------------
# Portfolio metrics via ml4t-diagnostic
# ---------------------------------------------------------------------------
def compute_portfolio_metrics(
returns: np.ndarray,
*,
periods_per_year: int = 252,
case_study: str | None = None,
label: str | None = None,
uncertainty: bool = True,
uncertainty_n_boot: int = 1000,
uncertainty_seed: int = 0,
trim_leading_zeros: bool = False,
) -> dict[str, float]:
"""Compute portfolio metrics using ml4t-diagnostic.
Replaces hand-rolled Sharpe/drawdown/etc. with the library's
validated implementation. When ``uncertainty=True`` (default) the returned
dict is extended with block-bootstrap CIs, Lo/LdP-2025 Sharpe SE,
Newey-West HAC SE for annualized return, and PSR p-value — driven by
:func:`case_studies.utils.uncertainty.compute_backtest_uncertainty`.
Parameters
----------
returns : np.ndarray
Array of period returns (daily or per-rebalance).
periods_per_year : int
Annualization factor (252 for daily, 52 for weekly, etc.).
case_study, label : optional
Used by the block-length resolver to pick rebalance_step from setup.yaml.
uncertainty : bool, default True
If False, skip the bootstrap (fast path for sweep inner loops).
uncertainty_n_boot, uncertainty_seed : int
Bootstrap configuration.
trim_leading_zeros : bool, default False
Legacy first-non-zero strip. Kept for callers that pass pre-canonical
return series (e.g., raw engine output without canonical-window slice).
Production callers (``_run_engine`` and the retrofit pipeline) pass
``False`` because they slice to the canonical (cs, label, split) window
first, which preserves real "no-trade" days at the start of the window
as legitimate zero-return periods rather than stripping them.
Returns
-------
dict[str, float]
Metric name → value. Keys match the existing backtest_metrics schema,
plus uncertainty columns when ``uncertainty=True``.
"""
from ml4t.diagnostic.evaluation import PortfolioAnalysis
if trim_leading_zeros and len(returns) > 0:
nonzero = np.flatnonzero(np.asarray(returns) != 0.0)
if len(nonzero) > 0:
returns = returns[nonzero[0] :]
if len(returns) < 2:
return {
"sharpe": 0.0,
"sortino": 0.0,
"total_return": 0.0,
"max_drawdown": 0.0,
"cagr": 0.0,
"calmar": 0.0,
"volatility": 0.0,
"win_rate": 0.0,
"omega": 0.0,
"var_95": 0.0,
"cvar_95": 0.0,
"stability": 0.0,
"skewness": 0.0,
"kurtosis": 0.0,
"tail_ratio": 0.0,
"n_periods": int(len(returns)),
}
analysis = PortfolioAnalysis(returns=returns, periods_per_year=periods_per_year)
pm = analysis.compute_summary_stats()
def _safe(v: float) -> float:
"""Sanitize metric value: handle complex, inf, nan."""
if isinstance(v, complex):
v = v.real
if not np.isfinite(v):
return 0.0
return float(v)
out = {
"sharpe": _safe(pm.sharpe_ratio),
"sortino": _safe(pm.sortino_ratio),
"total_return": _safe(pm.total_return),
"max_drawdown": _safe(pm.max_drawdown),
"cagr": _safe(pm.annual_return),
"calmar": _safe(pm.calmar_ratio),
"volatility": _safe(pm.annual_volatility),
"win_rate": _safe(pm.win_rate),
"omega": _safe(pm.omega_ratio),
"var_95": _safe(pm.var_95),
"cvar_95": _safe(pm.cvar_95),
"stability": _safe(pm.stability),
"skewness": _safe(pm.skewness),
"kurtosis": _safe(pm.kurtosis),
"tail_ratio": _safe(pm.tail_ratio),
"n_periods": int(len(returns)),
}
if uncertainty and len(returns) >= 4:
try:
from case_studies.utils.uncertainty import compute_backtest_uncertainty
unc = compute_backtest_uncertainty(
returns,
periods_per_year=periods_per_year,
case_study=case_study,
label=label,
n_boot=uncertainty_n_boot,
seed=uncertainty_seed,
)
out.update(unc)
except Exception as exc: # pragma: no cover - never block point estimates
import warnings
warnings.warn(
f"compute_backtest_uncertainty failed: {exc}; point metrics returned without CIs",
stacklevel=2,
)
return out
# ---------------------------------------------------------------------------
# Result container
# ---------------------------------------------------------------------------
@dataclass
class BacktestRunResult:
"""Unified result from both engine and vectorized paths."""
daily_returns: pl.DataFrame # [timestamp, daily_return]
metrics: dict[str, float]
strategy_spec: dict
prediction_hash: str
backtest_hash: str | None = None
# Engine-only fields
engine_result: Any = None # BacktestResult from ml4t-backtest
weights: pl.DataFrame | None = None
execution_mode: str = "engine"
# ---------------------------------------------------------------------------
# Weight precomputation (for risk sweep reuse)
# ---------------------------------------------------------------------------
def precompute_weights(
predictions: pl.DataFrame,
strategy_spec: dict,
prices: pl.DataFrame,
*,
label: str = "",
case_study: str = "",
) -> pl.DataFrame:
"""Compute allocation weights from a strategy spec, without running the engine.
Use this to avoid redundant MVO/HRP computation in Ch19 risk sweeps
where the same allocation weights are tested with different risk overlays.
Returns
-------
pl.DataFrame
Weights [timestamp, symbol, weight] ready for ``run_backtest(precomputed_weights=...)``.
"""
predictions = normalize_prediction_columns(predictions)
strategy = strategy_view(strategy_spec)
signal_config = strategy["signal"]
rebal_spec = strategy.get("rebalance", {})
weights = build_target_weights_from_config(predictions, signal_config)
alloc_spec = strategy.get("allocation")
if alloc_spec:
cadence = strategy.get("rebalance", {}).get("cadence", "")
weights = _apply_allocation(
weights,
predictions,
prices,
alloc_spec,
cadence=cadence,
label=label,
case_study=case_study,
)
return weights
# ---------------------------------------------------------------------------
# Strategy spec construction
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Prediction normalization
# ---------------------------------------------------------------------------
def normalize_prediction_columns(df: pl.DataFrame) -> pl.DataFrame:
"""Normalize prediction columns to canonical [timestamp, symbol, y_score, ...]."""
renames = {}
# Time column: date → timestamp
if "timestamp" not in df.columns and "date" in df.columns:
renames["date"] = "timestamp"
# Entity column: asset/product/stock_id/entity → symbol
if "symbol" not in df.columns:
for col in ("asset", "product", "stock_id", "entity"):
if col in df.columns:
renames[col] = "symbol"
break
# Score column
if "y_score" not in df.columns:
if "prediction" in df.columns:
renames["prediction"] = "y_score"
if "y_true" not in df.columns and "actual" in df.columns:
renames["actual"] = "y_true"
if "fold_id" not in df.columns and "fold" in df.columns:
renames["fold"] = "fold_id"
if renames:
df = df.rename(renames)
# Cast types
if "timestamp" in df.columns:
ts_dtype = df.schema["timestamp"]
if ts_dtype == pl.Date:
df = df.with_columns(pl.col("timestamp").cast(pl.Datetime("us")))
elif ts_dtype in (pl.String, pl.Utf8):
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:
df = df.with_columns(pl.col("timestamp").dt.replace_time_zone(None))
if "symbol" in df.columns and df.schema["symbol"] != pl.String:
df = df.with_columns(pl.col("symbol").cast(pl.String))
return df
# Tolerant-by-design cap on (timestamp, symbol) join misses between a
# classification label and its continuous-return counterpart. >10% null
# rate indicates a regeneration mismatch between the two label parquets
# (not source-data sparsity), and is escalated to a hard error rather
# than silently dropping rows from the backtest. Callers operating in
# a legitimately high-null regime can override via the ``max_null_rate``
# parameter on ``substitute_continuous_return_for_classification``.
_MAX_NULL_RATE = 0.10
# Polars integer dtypes for symbol id columns (e.g., us_firm ``stock_id``).
# Used by ``_align_symbol_dtype`` to detect numeric-vs-string mismatches.
_INT_SYMBOL_DTYPES = (
pl.UInt8,
pl.UInt16,
pl.UInt32,
pl.UInt64,
pl.Int8,
pl.Int16,
pl.Int32,
pl.Int64,
)
def _align_symbol_dtype(
target: pl.DataFrame,
other: pl.DataFrame,
*,
case_study: str,
target_side: str,
other_side: str,
) -> pl.DataFrame:
"""Cast ``other['symbol']`` to ``target['symbol'].dtype``, failing loudly.
Polars will silently raise ``InvalidOperationError`` when a string
column with real tickers (e.g. ``"AAPL"``) is cast to integer — the
error message names neither the case study nor the column origin,
making diagnostics painful. This helper detects the pl.Utf8 ↔
integer mismatch and surfaces a context-rich error before the cast,
keeping the same behavior for compatible cases (same dtype, or
same-kind cast).
"""
target_dtype = target["symbol"].dtype
other_dtype = other["symbol"].dtype
if other_dtype == target_dtype:
return other
target_is_int = target_dtype in _INT_SYMBOL_DTYPES
other_is_str = other_dtype in (pl.Utf8, pl.String)
other_is_int = other_dtype in _INT_SYMBOL_DTYPES
target_is_str = target_dtype in (pl.Utf8, pl.String)
if target_is_int and other_is_str:
# Probe: every value must parse as the target integer dtype.
try:
return other.with_columns(pl.col("symbol").cast(target_dtype))
except Exception as exc: # noqa: BLE001 — surface Polars's opaque error
raise TypeError(
f"_align_symbol_dtype: incompatible symbol representations for "
f"case_study={case_study!r}: {target_side}.symbol is "
f"{target_dtype} (numeric ids) but {other_side}.symbol is "
f"{other_dtype} (likely tickers, not parseable as integer). "
f"Underlying Polars error: {exc}"
) from exc
if other_is_int and target_is_str:
return other.with_columns(pl.col("symbol").cast(target_dtype))
# Same-kind cast (e.g., Int32 → Int64, Utf8 → String alias).
return other.with_columns(pl.col("symbol").cast(target_dtype))
def substitute_continuous_return_for_classification(
predictions: pl.DataFrame,
case_study: str,
label: str,
*,
max_null_rate: float = _MAX_NULL_RATE,
) -> pl.DataFrame:
"""Replace binary y_true with the underlying continuous return for classification labels.
The vectorized backtest computes ``gross_ret = weight * y_true``. For
regression labels y_true is the forward return; for classification
labels (fwd_class_*, fwd_dir_*) it is the binary class indicator, so
the product collapses into a position-weighted accuracy proxy rather
than economic P&L. We substitute y_true with the continuous return
declared in setup.yaml::labels.classification_eval_label.
Returns predictions unchanged when ``label`` is not registered as a
classification target (i.e., regression labels pass through).
"""
if not label:
return predictions
from pathlib import Path as _Path
import yaml as _yaml
from utils import CASE_STUDIES_DIR
setup_path = _Path(CASE_STUDIES_DIR) / case_study / "config" / "setup.yaml"
if not setup_path.exists():
return predictions
setup = _yaml.safe_load(setup_path.read_text())
mapping = (setup.get("labels") or {}).get("classification_eval_label") or {}
if label not in mapping:
return predictions
eval_label = str(mapping[label])
eval_path = _Path(CASE_STUDIES_DIR) / case_study / "labels" / f"{eval_label}.parquet"
if not eval_path.exists():
raise FileNotFoundError(
f"Continuous-return label {eval_label!r} expected at {eval_path} "
f"for classification label {label!r} but not found. Required so the "
f"vectorized backtest can compute economic P&L instead of weight × binary."
)
eval_df = pl.read_parquet(eval_path).select(["timestamp", "symbol", eval_label])
# Dedupe-assert eval_df on the join key before the left join. A duplicate
# (timestamp, symbol) row in the continuous-return parquet would fan out
# ``predictions`` silently, inflating downstream weight × y_true into a
# wrong-but-plausible P&L (the very failure mode this function is meant
# to prevent on the classification path).
eval_h0 = eval_df.height
eval_h_uniq = eval_df.unique(subset=["timestamp", "symbol"]).height
if eval_h_uniq != eval_h0:
raise ValueError(
f"substitute_continuous_return_for_classification: continuous-return "
f"label parquet at {eval_path} has {eval_h0 - eval_h_uniq} duplicate "
f"(timestamp, symbol) rows ({eval_h_uniq} unique). Re-run the upstream "
f"label step for case_study={case_study!r} to produce a unique-keyed "
f"parquet."
)
eval_df = eval_df.unique(subset=["timestamp", "symbol"], keep="first")
# Harmonize join-key dtypes to match the (already-normalized) predictions frame.
if eval_df["timestamp"].dtype != predictions["timestamp"].dtype:
if eval_df["timestamp"].dtype == pl.Date:
eval_df = eval_df.with_columns(pl.col("timestamp").cast(pl.Datetime("us")))
eval_df = eval_df.cast({"timestamp": predictions["timestamp"].dtype})
eval_df = _align_symbol_dtype(
predictions,
eval_df,
case_study=case_study,
target_side="predictions",
other_side=f"labels/{eval_label}.parquet",
)
pred_h0 = predictions.height
joined = (
predictions.drop("y_true")
.join(eval_df, on=["timestamp", "symbol"], how="left")
.rename({eval_label: "y_true"})
)
# Height-assert: ``left`` should never produce more rows than the left frame
# carried in. Belt-and-suspenders for the dedupe assertion above.
if joined.height != pred_h0:
raise RuntimeError(
f"substitute_continuous_return_for_classification: left join "
f"changed row count {pred_h0} -> {joined.height} for case_study="
f"{case_study!r} label={label!r}. eval_df keys are not unique "
f"on (timestamp, symbol) after dedupe — internal invariant broken."
)
n_null = int(joined["y_true"].null_count())
if n_null > 0:
n_total = joined.height
null_rate = n_null / n_total
# Tolerant-by-design caps at ``max_null_rate`` (default
# ``_MAX_NULL_RATE`` = 10%); above that, raise. >10% null rate
# indicates a regeneration mismatch between the classification and
# continuous-return parquets, not source-data sparsity.
if null_rate > max_null_rate:
raise ValueError(
f"substitute_continuous_return_for_classification: "
f"{n_null}/{n_total} ({null_rate:.2%}) predictions for "
f"classification label {label!r} have no matching {eval_label!r} "
f"value after join on (timestamp, symbol); exceeds "
f"max_null_rate={max_null_rate:.2%}. Null rate above "
f"{max_null_rate:.0%} indicates a regeneration mismatch "
f"between the classification and continuous-return label "
f"parquets; re-run the upstream label step for {case_study!r}."
)
print(
f" WARN substitute_continuous_return_for_classification: "
f"{n_null}/{n_total} ({null_rate:.4%}) predictions for "
f"classification label {label!r} have no matching {eval_label!r} "
f"value after join on (timestamp, symbol); dropping those rows."
)
joined = joined.filter(pl.col("y_true").is_not_null())
return joined
def _apply_cost_feasible_filter(
predictions: pl.DataFrame,
case_study: str,
prediction_hash: str | None,
) -> pl.DataFrame:
"""Restrict predictions to the frozen, per-split cost-feasible universe.
The split is resolved from the prediction set's registry entry; the
symbol list is read from ``setup.yaml::universe.cost_feasible.{split}``.
Raises if the split cannot be resolved or the list is absent — a silent
full-universe fallback would change the registered result.
"""
from pathlib import Path as _Path
import yaml as _yaml
from case_studies.utils.cv_window import lookup_split
from utils import CASE_STUDIES_DIR
if not prediction_hash:
raise ValueError(
"universe_filter='cost_feasible' requires a prediction_hash to "
f"resolve the split for case_study={case_study!r}; got none."
)
split = lookup_split(case_study, prediction_hash)
if split not in ("validation", "holdout"):
raise ValueError(
f"universe_filter='cost_feasible' could not resolve split for "
f"prediction_hash={prediction_hash!r} (case_study={case_study!r}); "
f"lookup_split returned {split!r}. The prediction set must be "
f"registered with a 'validation' or 'holdout' split first."
)
setup = _yaml.safe_load(
(_Path(CASE_STUDIES_DIR) / case_study / "config" / "setup.yaml").read_text()
)
symbols = (((setup.get("universe") or {}).get("cost_feasible")) or {}).get(split)
if not symbols:
raise KeyError(
f"setup.yaml::universe.cost_feasible.{split} missing/empty for "
f"case_study={case_study!r}; required when "
f"signal.universe_filter='cost_feasible'."
)
filtered = predictions.filter(pl.col("symbol").is_in(list(symbols)))
if filtered.is_empty() and not predictions.is_empty():
raise ValueError(
f"universe_filter='cost_feasible' produced an empty frame for "
f"case_study={case_study!r} split={split!r}: the prediction set's "
f"symbols do not intersect the frozen cost-feasible list (e.g. a "
f"point-in-time ticker mismatch like FB/META). Refusing to run a "
f"zero-row backtest — same 'no silent fallback' intent as above."
)
return filtered
def apply_universe_filter(
predictions: pl.DataFrame,
prices: pl.DataFrame,
case_study: str,
signal_config: dict | None,
prediction_hash: str | None = None,
) -> pl.DataFrame:
"""Apply spec-declared universe restriction to predictions before backtest.
When ``signal_config["universe_filter"] == "liquid"`` (sp500_options
rung-3 in the O'Donovan-Yu / Muravyev-Pearson HTM cost cascade), the
backtest must restrict each rebalance date to the tightest-quoted
subset of the universe. The quantile lives in
``setup.yaml::backtest.sweep.htm_cost_cascade.liquid_quantile``; the
spread column is ``instr_rel_spread`` on the prices frame.
When ``signal_config["universe_filter"] == "cost_feasible"``
(nasdaq100_microstructure), the backtest restricts predictions to a
FROZEN, per-split symbol list committed under
``setup.yaml::universe.cost_feasible.{validation,holdout}``. The list is
the cost-feasible universe — the cheapest-to-trade names by round-trip
cost, profiled strictly before each window (no look-ahead — see
``build_cost_feasible_universe.py``); the split is resolved from the
prediction set's registry entry via ``lookup_split``. Like ``liquid``,
only the filter *name* enters the backtest hash, not the resolved symbols.
Returns predictions unchanged when no filter applies. Built into
``run_backtest`` so any caller — sweep notebooks, ``generate_holdout``,
ad-hoc scripts — gets the same filter as the bespoke sp500_options
pipeline, driven purely by the strategy spec.
"""
if not signal_config:
return predictions
uf = str(signal_config.get("universe_filter", "")).strip().lower()
if uf in ("", "full", "none"):
return predictions
if uf == "cost_feasible":
return _apply_cost_feasible_filter(predictions, case_study, prediction_hash)
if uf != "liquid":
raise ValueError(
f"universe_filter={uf!r} not supported. Allowed: 'liquid', 'cost_feasible', or 'full'."
)
if "instr_rel_spread" not in prices.columns:
raise ValueError(
f"universe_filter='liquid' requires 'instr_rel_spread' on the prices "
f"frame for case_study={case_study!r}; got columns={list(prices.columns)}."
)
from pathlib import Path as _Path
import yaml as _yaml
from utils import CASE_STUDIES_DIR
setup = _yaml.safe_load(
(_Path(CASE_STUDIES_DIR) / case_study / "config" / "setup.yaml").read_text()
)
cascade = (((setup.get("backtest") or {}).get("sweep") or {}).get("htm_cost_cascade")) or {}
if "liquid_quantile" not in cascade:
raise KeyError(
f"setup.yaml::backtest.sweep.htm_cost_cascade.liquid_quantile missing for "
f"case_study={case_study!r}; required when signal.universe_filter='liquid'."
)
liquid_quantile = float(cascade["liquid_quantile"])
# Daily quantile of relative half-spread; ties broken with rank('min').
# Collapse timestamp to the date grain before grouping so any caller
# supplying sub-daily or unnormalized intraday bars still produces a
# within-date rank rather than a within-bar rank (mirrors the bespoke
# sp500_options sweep). Dedupe ``(date, symbol)`` to one row per
# (date, symbol) — taking the min half-spread when multiple bars share
# a date — so the rank denominator is symbol-count, not bar-count.
half = (
prices.select(
pl.col("timestamp").cast(pl.Date).alias("_date"),
pl.col("symbol"),
(pl.col("instr_rel_spread") / 2).alias("_hs"),
)
.group_by(["_date", "symbol"])
.agg(pl.col("_hs").min())
)
liquid_keys = (
half.with_columns(
(pl.col("_hs").rank("min").over("_date") / pl.col("_hs").count().over("_date")).alias(
"_q"
)
)
.filter(pl.col("_q") <= liquid_quantile)
.select([pl.col("_date").alias("timestamp"), pl.col("symbol")])
)
if liquid_keys["timestamp"].dtype != predictions["timestamp"].dtype:
# Predictions stamps are typically Datetime("us") at midnight; cast
# back from Date so the semi-join key types match exactly.
if predictions["timestamp"].dtype == pl.Datetime("us"):
liquid_keys = liquid_keys.with_columns(pl.col("timestamp").cast(pl.Datetime("us")))
else:
liquid_keys = liquid_keys.cast({"timestamp": predictions["timestamp"].dtype})
liquid_keys = _align_symbol_dtype(
predictions,
liquid_keys,
case_study=case_study,
target_side="predictions",
other_side="prices",
)
return predictions.join(liquid_keys, on=["timestamp", "symbol"], how="semi")
# ---------------------------------------------------------------------------
# Core backtest function
# ---------------------------------------------------------------------------
def run_backtest(
case_study: str,
prediction_hash: str,
strategy_spec: dict,
*,
prices: pl.DataFrame,
predictions: pl.DataFrame,
label: str = "",
register: bool = True,
initial_cash: float = 1_000_000.0,
calendar: str = "NYSE",
precomputed_weights: pl.DataFrame | None = None,
force_rebacktest: bool = False,
contract_specs: dict | None = None,
) -> BacktestRunResult:
"""Core backtest: predictions -> weights -> engine/vectorized -> result.
This is the SINGLE entry point for ALL backtests — demo, sweep, and
downstream chapters. Sweep notebooks call this in a loop with different
strategy_specs; they never contain backtest math themselves.
Parameters
----------
case_study : str
Case study identifier (e.g., "etfs").
prediction_hash : str
Hash of the prediction set being backtested.
strategy_spec : dict
Identity-defining configuration with signal, execution, costs sections.
prices : pl.DataFrame
Price data [timestamp, symbol, open, high, low, close, volume].
predictions : pl.DataFrame
Predictions [timestamp, symbol, y_score, y_true, ...].
label : str
Label name (used for thinning in vectorized mode).
register : bool
Whether to register the result in registry.db.
initial_cash : float
Starting portfolio value.
calendar : str
Trading calendar for daily return aggregation.
precomputed_weights : pl.DataFrame, optional
Pre-computed allocation weights [timestamp, symbol, weight].
When provided, skips signal computation and allocation — goes
straight to engine/vectorized with these weights. Use this in
Ch19 risk sweeps where allocation is identical across risk
variants (avoids re-running expensive MVO/HRP per variant).
contract_specs : dict, optional
Per-asset contract specifications (futures multipliers, tick sizes).
Pass for futures case studies to get correct P&L scaling.
Returns
-------
BacktestRunResult
Unified result with daily_returns, metrics, and optional engine_result.
"""
import time
from datetime import UTC
_bt_started_at = datetime.now(UTC).isoformat()
_bt_t0 = time.perf_counter()
# 0. Normalize prediction columns to canonical schema, then for
# classification labels replace the binary y_true with the underlying
# continuous return so weight × y_true produces economic P&L rather
# than a position-weighted accuracy proxy (see
# ``substitute_continuous_return_for_classification`` docstring).
predictions = normalize_prediction_columns(predictions)
predictions = substitute_continuous_return_for_classification(predictions, case_study, label)
strategy_spec = ensure_backtest_spec(
case_study,
get_backtest_config(case_study),
strategy_spec,
prices=prices,
prediction_hash=prediction_hash,
initial_cash=initial_cash,
)
# Re-source initial_cash from the canonical spec. ensure_backtest_spec's
# idempotent-canonical branch preserves an existing backtest_config.cash.initial
# (typically $100K from setup.yaml) without overwriting it from the function
# arg. The broker starts at that spec value; the RiskManager must initialize
# its high-water-mark from the same number or it sees a fictitious 90%
# drawdown on bar 1 when the function-arg default ($1M) diverges from the
# spec ($100K) — halting the strategy before any trade is placed.
initial_cash = float(strategy_spec["backtest_config"]["cash"]["initial"])
strategy = strategy_view(strategy_spec)
# Apply spec-declared universe restriction (e.g., sp500_options rung-3
# 'liquid' subset). Driven purely by strategy.signal.universe_filter so
# the bespoke sweep notebooks and generic generate_holdout share the
# same code path.
predictions = apply_universe_filter(
predictions,
prices,
case_study,
strategy.get("signal") or {},
prediction_hash=prediction_hash,
)
# Skip-if-complete: if the backtest_hash already has complete artifacts,
# return the cached result instead of re-running (unless force_rebacktest).
if register and not force_rebacktest:
from case_studies.utils.registry import backtest_dir as _bt_dir_fn
from case_studies.utils.registry import backtest_run_status
from case_studies.utils.registry.store import _case_dir, _open_registry
_bt_status = backtest_run_status(case_study, prediction_hash, strategy_spec)
if _bt_status.complete:
_cached_dir = _bt_dir_fn(case_study, _bt_status.backtest_hash)
_cached_returns = _cached_dir / "daily_returns.parquet"
if _cached_returns.exists():
print(f" SKIP backtest ({_bt_status.summary()}) — reusing cached result")
cached_df = pl.read_parquet(_cached_returns)
# Load cached metrics from registry
_db = _open_registry(_case_dir(case_study))
try:
_metric_cols = [
r[1] for r in _db.execute("PRAGMA table_info(backtest_metrics)").fetchall()
]
_metric_cols = [
c for c in _metric_cols if c not in ("backtest_hash", "computed_at")
]
if _metric_cols:
_q = f"SELECT {', '.join(_metric_cols)} FROM backtest_metrics WHERE backtest_hash = ?"
_row = _db.execute(_q, (_bt_status.backtest_hash,)).fetchone()
cached_metrics = dict(zip(_metric_cols, _row, strict=True)) if _row else {}
else:
cached_metrics = {}
finally:
_db.close()
return BacktestRunResult(
daily_returns=cached_df,
metrics=cached_metrics,
strategy_spec=strategy_spec,
prediction_hash=prediction_hash,
backtest_hash=_bt_status.backtest_hash,
engine_result=None,
weights=precomputed_weights,
execution_mode=strategy.get("rebalance", {}).get("mode", "unknown"),
)
signal_config = strategy["signal"]
rebal_spec = strategy.get("rebalance", {})
if precomputed_weights is not None:
# Skip signal + allocation — use provided weights directly
weights = precomputed_weights
elif signal_config.get("method") == "slot_persistent_signal_exit":
# Slot selection IS the allocation — Ch17 allocator stage is skipped
# because the slot mechanism's `weight_per_slot` plays the role of
# the cheap allocator. See case_studies/utils/slot_strategy.py for
# the mechanism and rules/standards docs for the design call.
from case_studies.utils.slot_strategy import build_persistent_slot_weights_hybrid
slot_kwargs: dict = {}
for required in ("long_q", "lookback_days", "bars_per_day", "max_slots", "hold_bars"):
# Reject both absent and explicit-None values so a hand-wired
# signal_config surfaces here rather than as a far-off TypeError
# inside build_persistent_slot_weights_hybrid.
if signal_config.get(required) is None:
msg = (
f"signal method 'slot_persistent_signal_exit' requires a "
f"non-null {required!r} in signal_config; got {sorted(signal_config)}"
)
raise KeyError(msg)
slot_kwargs[required] = signal_config[required]
for opt in (
"weight_per_slot",
"exit_signal_q",
"take_profit",
"stop_loss",
"pred_freshness_max_min",
"direction",
):
if signal_config.get(opt) is not None:
slot_kwargs[opt] = signal_config[opt]
weights, _slot_stats = build_persistent_slot_weights_hybrid(
predictions,
prices,
**slot_kwargs,
)
else:
# 1. Convert predictions to target weights
weights = build_target_weights_from_config(predictions, signal_config)
# 1b. Apply allocation method if specified (Ch17+)
alloc_spec = strategy.get("allocation")
if alloc_spec:
weights = _apply_allocation(
weights,
predictions,
prices,
alloc_spec,
cadence=rebal_spec.get("cadence", ""),
label=label,
case_study=case_study,
prediction_hash=prediction_hash,
)
# 2. Dispatch to engine or vectorized
bt_cfg = strategy_spec["backtest_config"]
commission_block = bt_cfg["commission"]
slippage_block = bt_cfg["slippage"]
if commission_block.get("model") == "per_share":
cost_spec = {
"model": "per_share_plus_spread",
"per_share": float(commission_block["per_share"]),
"default_half_spread_usd": float(slippage_block.get("spread", 0.0)),
"asset_spreads": dict(slippage_block.get("spread_by_asset", {}) or {}),
"spread_convention": slippage_block.get("spread_convention", "half_spread"),
}
else:
cost_spec = {
"model": "percentage",
"commission_bps": float(commission_block["rate"]) * 10_000.0,
"slippage_bps": float(slippage_block["rate"]) * 10_000.0,
}
if rebal_spec["mode"] == "vectorized":
# sp500_options HTM short-straddle uses a dedicated multi-cohort daily-MTM
# backtest path: overlapping 5-cohort book, per-cohort daily premium + hedge
# P&L, entry-spread + hedge-rebalance transaction costs. The simple
# weights × y_true vectorized path cannot express this strategy because
# y_true is a single 30-day return, not a daily P&L series.
if case_study == "sp500_options" and label == "ret_to_expiry":
result = _run_htm_daily_mtm(
case_study=case_study,
predictions=predictions,
signal_config=signal_config,
initial_cash=initial_cash,
risk_spec=strategy.get("risk", {}),
allocation_spec=strategy.get("allocation", {}),
label=label,
prediction_hash=prediction_hash,
)
else:
result = _run_vectorized(
weights=weights,
predictions=predictions,
prices=prices,
cost_spec=cost_spec,
cadence=rebal_spec.get("cadence", ""),
label=label,
case_study=case_study,
initial_cash=initial_cash,
risk_spec=strategy.get("risk", {}),
prediction_hash=prediction_hash,
)
else:
allow_short = signal_config.get("long_short", False) or (
str(signal_config.get("direction", "long_only")).strip().lower() == "short_only"
)
result = _run_engine(
weights=weights,
prices=prices,
predictions=predictions,
strategy_spec=strategy_spec,
rebalance_spec=rebal_spec,
risk_spec=strategy.get("risk", {}),
allow_short=allow_short,
initial_cash=initial_cash,
calendar=calendar,
contract_specs=contract_specs,
case_study=case_study,
label=label,
)
# Build metrics dict
metrics = result["metrics"]
# Build daily returns DataFrame
daily_returns = result["daily_returns"]
# Extract trade log, fills, equity, portfolio state from engine result
# (all None for vectorized path)
trades_df = result.get("trades_df")
fills_df = result.get("fills_df")
equity_df = result.get("equity_df")
portfolio_state_df = result.get("portfolio_state_df")
# 3. Register
backtest_hash = None
if register:
from case_studies.utils.registry import (
compute_backtest_fold_metrics,
register_backtest_fold_metrics,
register_backtest_run,
)
_bt_elapsed_s = time.perf_counter() - _bt_t0
backtest_hash = register_backtest_run(
case_study,
prediction_hash,
strategy_spec,
returns=daily_returns,
trades=trades_df,
fills=fills_df,
equity=equity_df,
portfolio_state=portfolio_state_df,
weights=weights,
metrics=metrics,
started_at=_bt_started_at,
elapsed_s=_bt_elapsed_s,
)
# Compute and register per-fold backtest metrics
cadence = rebal_spec.get("cadence", "daily")
ppy = int(_PERIODS_PER_YEAR.get(cadence, 252))
fold_metrics = compute_backtest_fold_metrics(
daily_returns,
case_study,
label=label,
periods_per_year=ppy,
)
if fold_metrics:
register_backtest_fold_metrics(case_study, backtest_hash, fold_metrics)
return BacktestRunResult(
daily_returns=daily_returns,
metrics=metrics,
strategy_spec=strategy_spec,
prediction_hash=prediction_hash,
backtest_hash=backtest_hash,
engine_result=result.get("engine_result"),
weights=weights,
execution_mode=rebal_spec["mode"],
)
# ---------------------------------------------------------------------------
# Engine path
# ---------------------------------------------------------------------------
def _run_engine(
weights: pl.DataFrame,
prices: pl.DataFrame,
predictions: pl.DataFrame,
strategy_spec: dict,
rebalance_spec: dict,
risk_spec: dict,
allow_short: bool,
initial_cash: float,
calendar: str,
contract_specs: dict | None = None,
*,
case_study: str | None = None,
label: str | None = None,
) -> dict:
"""Run backtest via ml4t-backtest Engine."""
from ml4t.backtest import DataFeed, Engine, RebalanceConfig, Strategy, TargetWeightExecutor
from case_studies.utils.backtest_loaders import (
extract_daily_returns_frame,
infer_session_alignment,
)
config = runtime_backtest_config(strategy_spec)
profile_rebalance_mode = config.rebalance_mode
# Session enforcement — drop bars outside trading sessions (e.g., CME
# Saturdays). Idempotent with the same mutation applied in
# ``ensure_backtest_spec``; kept here as belt-and-suspenders so the
# engine always sees the right value even if a spec is passed in raw.
apply_calendar_session_enforcement(config, calendar)
# Pre-compute weight dict from DataFrame
weight_dict: dict[datetime, dict[str, float]] = {}
for row in weights.iter_rows(named=True):
ts = row["timestamp"]
if ts not in weight_dict:
weight_dict[ts] = {}
if row["weight"] != 0:
weight_dict[ts][row["symbol"]] = row["weight"]
# Resolve calendar-aware rebalance schedule, then thin by the label's
# non-overlapping step from setup.yaml::labels.rebalance_step. Mirrors
# the same two-step thinning that thin_to_rebalance_dates() applies on
# the vectorized path (see backtest_loaders.thin_to_rebalance_dates).
# Without this, multi-step labels (e.g. fwd_ret_60m on a 15m cadence
# with step=4) over-rebalance by step×.
#
# The schedule is derived from the canonical *prediction* timeline, not
# from weight_dict.keys(). Allocation-class methods (score_weighted,
# HRP, MVO, inverse_vol, risk_parity) pre-thin to non-overlapping
# rebalance dates inside _apply_allocation via thin_to_rebalance_dates;
# if we resolved the schedule from those already-sparse weight keys and
# applied gather_every(step) again, we'd thin by step² and trade
# ~step× too rarely. The on_data callback already gates on
# ``timestamp in weight_dict``, so dates without weights are skipped.
from case_studies.utils.backtest_loaders import (
get_rebalance_step,
resolve_rebalance_timestamps,
)
cadence = rebalance_spec.get("cadence", "monthly_month_end")
all_pred_ts = pl.Series("ts", predictions["timestamp"].unique().sort().to_list())
schedule_dates = resolve_rebalance_timestamps(all_pred_ts, cadence, calendar)
if case_study and label:
step = get_rebalance_step(case_study, label)
if step > 1:
schedule_dates = schedule_dates.gather_every(step)
rebalance_schedule = set(schedule_dates.to_list())
# Build risk components from spec (Ch19)
position_rules = _build_position_rules(risk_spec)
risk_manager = _build_risk_manager(risk_spec, initial_cash)
# Rebalance thresholds are sourced from setup.yaml::backtest.rebalance and
# always present in the canonical strategy.rebalance block (populated by
# ensure_backtest_spec()).
min_weight_change = float(rebalance_spec["min_weight_change"])
min_trade_value = float(rebalance_spec["min_trade_value"])
# Build strategy
class _PrecomputedStrategy(Strategy):
def __init__(self):
self._rules_set = False
self.executor = TargetWeightExecutor(
config=RebalanceConfig(
min_trade_value=min_trade_value,
min_weight_change=min_weight_change,
allow_fractional=None, # Defer to broker.share_type (profile)
allow_short=allow_short,
rebalance_mode=profile_rebalance_mode,
)
)
def on_data(self, timestamp, data, context, broker):
# Set position rules on broker (once, first bar)
if not self._rules_set:
if position_rules:
broker.set_position_rules(position_rules)
self._rules_set = True
# Check portfolio-level limits (each bar)
if risk_manager:
positions = {a: p.market_value for a, p in broker.positions.items()}
risk_results = risk_manager.update(
equity=broker.get_account_value(),
positions=positions,
timestamp=timestamp,
broker=broker,
)
# Two guards on purpose: the liquidate check catches a bar
# where the manager flattened but left is_halted=False, while
# is_halted catches a prior-bar halt; neither subsumes the other.
if any(result.action == "liquidate" for result in risk_results):
return
if risk_manager.is_halted:
return
# Calendar-aware schedule: only rebalance on resolved dates
if timestamp not in rebalance_schedule:
return
if timestamp in weight_dict:
targets = {a: w for a, w in weight_dict[timestamp].items() if a in data}
if targets:
self.executor.execute(targets, data, broker)
# Resolve the canonical (cs, label, split) window — same window for every
# strategy on the same (cs, label, split). Callers pre-window `prices` via
# load_backtest_prices_for(cs, label, split=...) so the parquet read is
# row-group-pruned; the engine asserts the price range stays within the
# canonical window. Falls back to predictions.min/max only when no
# canonical window can be derived (label without CV folds, sentinel
# prediction_hash, etc.).
from case_studies.utils.cv_window import canonical_window, lookup_split
prices_ts_dtype = prices.schema["timestamp"]
window = None
if case_study and label:
prediction_hash = (
strategy_spec.get("backtest_config", {}).get("metadata", {}).get("prediction_hash")
)
split = lookup_split(case_study, prediction_hash) if prediction_hash else None
if split is not None:
window = canonical_window(case_study, label, split=split)
# If split is unknown (no prediction_hash in metadata or unrecognized
# split label) we deliberately fall through to the predictions.min/max
# branch below rather than silently mis-windowing a holdout backtest
# against the validation window.
if window is not None:
win_start, win_end = window
# Compare on the date component so calendar-edge drift (parquet starts
# 2024-01-02 when win_start=2024-01-01 because Jan 1 is a holiday) is
# tolerated. The upper-bound assertion fires when prices EXTEND past
# the canonical window — i.e. the caller forgot to pre-window the
# right edge. The lower bound is intentionally NOT asserted: callers
# may load earlier-than-canonical prefix history when a rolling-vol
# allocator (inverse_vol / risk_parity / hrp / mvo_ledoit_wolf) needs
# warmup so the first rebalance has data-driven (not median-imputed)
# weights. The daily_returns frame is sliced to [win_start, win_end]
# below regardless of how wide the load was.
prices_dates = prices["timestamp"].dt.date()
prices_min_date = prices_dates.min()
prices_max_date = prices_dates.max()
if prices_min_date is None or prices_max_date is None:
raise RuntimeError(
f"Empty prices frame for cs={case_study} label={label} "
f"split={split} — canonical window [{win_start}, {win_end}]."
)
if prices_max_date > win_end:
raise AssertionError(
f"Prices not pre-windowed for cs={case_study} label={label} "
f"split={split}: canonical window [{win_start}, {win_end}], "
f"prices range [{prices_min_date}, {prices_max_date}] — "
f"upper bound exceeded. Pass end_date to load_backtest_prices() "
f"or call load_backtest_prices_for(cs, label, split=split)."
)
elif predictions.height > 0:
# Fallback when canonical window unavailable: still slice to the
# predictions' span so demo notebooks with sentinel prediction_hash
# don't process pre-history.
pred_ts = predictions["timestamp"]
if pred_ts.dtype != prices_ts_dtype:
pred_ts = pred_ts.cast(prices_ts_dtype)
prices = prices.filter(
(pl.col("timestamp") >= pred_ts.min()) & (pl.col("timestamp") <= pred_ts.max())
)
# signals_df is intentionally omitted: _PrecomputedStrategy reads
# weight_dict directly, so routing predictions through the bar iterator
# would waste hot-path memory.
feed = DataFeed(prices_df=prices, feed_spec=config.feed_spec)
strategy = _PrecomputedStrategy()
engine = Engine.from_config(feed, strategy, config, contract_specs=contract_specs)
engine_result = engine.run()
# Extract daily returns
session_aligned = infer_session_alignment(calendar)
daily_df = extract_daily_returns_frame(
engine_result,
calendar=calendar,
session_aligned=session_aligned,
)
# Slice the persisted daily-returns frame to the canonical (cs, label,
# split) window — same window as the price-trim above, so every
# (cs, label, split) produces a daily_returns parquet covering the same
# dates regardless of which strategy was run. Date-component compare so
# intraday bars on win_end aren't dropped by midnight promotion.
if window is not None:
daily_df = daily_df.filter(
(pl.col("timestamp").dt.date() >= window[0])
& (pl.col("timestamp").dt.date() <= window[1])
)
returns_arr = daily_df["daily_return"].to_numpy()
ppy = calendar_periods_per_year(calendar)
metrics = compute_portfolio_metrics(returns_arr, periods_per_year=ppy, trim_leading_zeros=False)
# Engine-specific metrics (execution details not derivable from returns)
m = engine_result.metrics
metrics["num_trades"] = m.get("num_trades", 0)
metrics["total_commission"] = m.get("total_commission", 0.0)
metrics["total_slippage"] = m.get("total_slippage", 0.0)
# avg_turnover: target-weight semantics (sum_i |Δw_i| averaged over the daily
# timeline, 0 on non-rebalance days). Same formula as the vectorized path so
# the registry column has consistent meaning across both engines. Skipping the
# engine's own `m["avg_turnover"]` (notional/equity) — that value is unbounded
# for leveraged products (cme_futures multipliers inflate it 10⁴–10⁵×) and
# mixes incompatibly with vectorized-path rows on the same column.
if weights.height > 0:
weights_sorted = weights.sort("symbol", "timestamp").with_columns(
abs_change=(
pl.col("weight") - pl.col("weight").shift(1).over("symbol").fill_null(0.0)
).abs(),
)
turnover_by_ts = weights_sorted.group_by("timestamp").agg(
turnover=pl.col("abs_change").sum()
)
# Align to daily timeline so non-rebalance days contribute 0 to the mean
# (matches port_ret.join(turnover) in the vectorized path).
turnover_aligned = daily_df.join(
turnover_by_ts.with_columns(pl.col("timestamp").cast(daily_df.schema["timestamp"])),
on="timestamp",
how="left",
).with_columns(pl.col("turnover").fill_null(0.0))
mean_turnover = turnover_aligned["turnover"].mean()
metrics["avg_turnover"] = float(mean_turnover) if mean_turnover is not None else 0.0
else:
metrics["avg_turnover"] = 0.0
# Extract trade log
trades_df = None
if engine_result.trades:
try:
trades_df = engine_result.to_trades_dataframe()
except Exception as e:
import logging
logging.getLogger(__name__).warning("Trade extraction failed: %s", e)
# Extract fill-level execution records (quote-aware since backtest b11)
fills_df = None
if engine_result.fills:
try:
fills_df = engine_result.to_fills_dataframe()
except Exception as e:
import logging
logging.getLogger(__name__).warning("Fills extraction failed: %s", e)
# Extract equity curve and portfolio state (bar-level resolution)
equity_df = None
portfolio_state_df = None
try:
equity_df = engine_result.to_equity_dataframe()
portfolio_state_df = engine_result.to_portfolio_state_dataframe()
except Exception as e:
import logging
logging.getLogger(__name__).warning("Equity/portfolio state extraction failed: %s", e)
return {
"daily_returns": daily_df,
"metrics": metrics,
"engine_result": engine_result,
"trades_df": trades_df,
"fills_df": fills_df,
"equity_df": equity_df,
"portfolio_state_df": portfolio_state_df,
}
# ---------------------------------------------------------------------------
# Hold-to-expiry daily-MTM path (sp500_options / ret_to_expiry)
# ---------------------------------------------------------------------------
def _run_htm_daily_mtm(
case_study: str,
predictions: pl.DataFrame,
signal_config: dict,
initial_cash: float,
risk_spec: dict | None = None,
allocation_spec: dict | None = None,
label: str | None = None,
prediction_hash: str | None = None,
) -> dict:
"""Dispatch wrapper for the hold-to-expiry daily-MTM short-straddle backtest.
Delegates to ``case_studies.sp500_options._htm_backtest.run_htm_daily_mtm``,
which implements the multi-cohort daily-MTM accounting:
- Friday entry of top-K short straddles, ~30-day DTE.
- Daily delta hedge via the underlying stock (threshold rehedging).
- Cash-settle at expiry (no market exit, no exit bid-ask).
- Full transaction costs: entry option spread (bid-ask on both legs) on
cohort entry day; hedge-trade spread + equity commission on every
hedge rebalance day.
- Book size = 5 concurrent cohorts × 1/5 capital each (fully invested).
The entry-and-weighting scheme is read from ``signal_config`` (same shape
as the vectorized signal dispatcher): ``method`` + ``top_k`` / ``percentile``.
Returns the same shape as ``_run_vectorized``: ``{daily_returns, metrics}``
where ``daily_returns`` has columns ``[timestamp, daily_return]`` so the
registry write path treats it identically to any other backtest.
"""
from pathlib import Path
import yaml
from case_studies.sp500_options._htm_backtest import run_htm_daily_mtm
from utils import CASE_STUDIES_DIR
from utils.paths import REPO_ROOT
cs_dir = CASE_STUDIES_DIR / case_study
labels_dir = cs_dir / "labels"
# Anchor on REPO_ROOT — same convention as every other case-study data
# path. Resolving relative to cwd masked real "data missing" errors as
# cwd-mismatch fallbacks pointing at a different (also-missing) path.
raw_options_dir = REPO_ROOT / "data" / "equities" / "market" / "sp500" / "options_straddles_raw"
method = str(signal_config.get("method", "equal_weight_top_k"))
top_k = int(signal_config.get("top_k", 20))
percentile = float(signal_config.get("percentile", 90.0))
exit_at_max_days = signal_config.get("exit_at_max_days")
if exit_at_max_days is not None:
exit_at_max_days = int(exit_at_max_days)
# For round-trip mode (exit_at_max_days set), weekly entry with a 10-day
# hold yields ~2 concurrent cohorts, not 5. Caller can override via
# signal_config.n_roll; default is the HTM-expiry value (5).
from case_studies.sp500_options._htm_backtest import N_ROLL_DEFAULT
n_roll = int(signal_config.get("n_roll", N_ROLL_DEFAULT))
# Read cost/risk parameters from setup.yaml so the wrapper does not
# silently drop them. Required keys raise KeyError; missing optional keys
# fall through to run_htm_daily_mtm's defaults.
setup = yaml.safe_load((cs_dir / "config" / "setup.yaml").read_text())
cost_components = setup["costs"]["components"]
delta_threshold = float(setup["hedging_protocol"]["delta_threshold"])
hedge_spread_bps = float(cost_components["hedge_spread"]["estimate_bps_of_notional"])
equity_commission_per_share = float(cost_components["commission"]["equity_per_share"])
option_commission_per_contract = float(cost_components["commission"]["option_per_contract"])
result = run_htm_daily_mtm(
case_study=case_study,
predictions=predictions,
labels_dir=labels_dir,
raw_options_dir=raw_options_dir,
method=method,
top_k=top_k,
percentile=percentile,
exit_at_max_days=exit_at_max_days,
n_roll=n_roll,
delta_threshold=delta_threshold,
hedge_spread_bps=hedge_spread_bps,
equity_commission_per_share=equity_commission_per_share,
option_commission_per_contract=option_commission_per_contract,
allocation_spec=allocation_spec,
)
port = result["daily_returns"]
metrics = result["metrics"]
# Slice port to canonical (cs, label, split) window so daily_returns and
# the aux cost-accounting metrics (cumulative_entry_cost, n_rebalance_dates,
# etc.) all reflect the same canonical window. Mirrors _run_engine and
# _run_vectorized — same drifting-parquet bug otherwise. The cohort fields
# (entry_cost_day, n_open, etc.) are filtered with the same `date` slice
# because the multi-cohort daily-MTM book emits one row per holding date.
sliced = False
if prediction_hash and case_study and label:
from case_studies.utils.cv_window import canonical_window, lookup_split
split = lookup_split(case_study, prediction_hash)
if split is not None:
window = canonical_window(case_study, label, split=split)
if window is not None:
win_start, win_end = window
port_filtered = port.filter(
(pl.col("date").cast(pl.Date) >= win_start)
& (pl.col("date").cast(pl.Date) <= win_end)
)
if port_filtered.is_empty():
raise RuntimeError(
f"Canonical window [{win_start}, {win_end}] for "
f"cs={case_study} label={label} split={split} produced "
f"empty port (HTM daily-MTM; port span "
f"{port['date'].min()}{port['date'].max()})."
)
if port_filtered.height != port.height:
sliced = True
port = port_filtered
# Shape the return like _run_vectorized so the registry writer is agnostic.
daily_returns = port.select(
pl.col("date").cast(pl.Datetime("us")).alias("timestamp"),
pl.col("portfolio_ret").alias("daily_return"),
)
# When the canonical-window slice actually trimmed rows, recompute the
# returns-based metric set so the registry Sharpe/CAGR/volatility/etc.
# reflect the sliced daily_returns rather than the inner function's
# pre-slice values. Aux cohort metrics (cumulative_entry_cost, etc.) are
# recomputed from sliced port below regardless of slice.
if sliced:
from case_studies.sp500_options._htm_backtest import _compute_metrics
metrics.update(_compute_metrics(port))
# Optional portfolio-level risk overlay (Ch19). Same mechanism as vectorized
# path: operates on the daily return series post-hoc.
if risk_spec:
from case_studies.sp500_options._htm_backtest import _compute_metrics
port_for_risk = daily_returns.rename({"daily_return": "net_ret"})
port_for_risk = _apply_vectorized_risk(port_for_risk, risk_spec)
daily_returns = port_for_risk.select(
pl.col("timestamp"), pl.col("net_ret").alias("daily_return")
)
# Recompute the full metric set from the post-overlay return series so
# cagr/max_drawdown/volatility/etc. reflect the same series as Sharpe.
post = daily_returns.rename({"daily_return": "portfolio_ret"})
metrics.update(_compute_metrics(post))
# Final unified metric pass: replace HTM-internal Sharpe/Sortino/etc. with
# the canonical ml4t.diagnostic.PortfolioAnalysis values so HTM metrics are
# comparable to engine/vectorized paths AND include the uncertainty bands
# (sharpe_se_lo, sharpe_ci95_lo/hi, sortino_ci95_*, ann_return_hac_se +
# ci95, max_dd_ci95_*, calmar_ci95_*, psr_pvalue, bootstrap_block_length/n).
# HTM uses daily MTM on NYSE sessions → periods_per_year = 252. Operates on
# the FINAL daily_returns (post-slice, post-risk-overlay) so the persisted
# parquet and the registered metrics are derived from the same series.
returns_arr = daily_returns["daily_return"].to_numpy()
metrics.update(
compute_portfolio_metrics(
returns_arr,
periods_per_year=252,
case_study=case_study,
label=label,
uncertainty=True,
)
)
# Number of distinct rebalance events (= entry days with any new cohort).
# `n_open.sum()` is the count of cohort-days, kept under a distinct key.
metrics["n_rebalance_dates"] = int((port["entry_cost_day"] > 0).sum())
metrics["cohort_days_open"] = int(port["n_open"].sum())
metrics["avg_cohorts_open"] = float(port["n_open"].mean())
metrics["cumulative_entry_cost"] = float(port["entry_cost_day"].sum())
metrics["cumulative_hedge_cost"] = float(port["hedge_cost_day"].sum())
if "exit_cost_day" in port.columns:
metrics["cumulative_exit_cost"] = float(port["exit_cost_day"].sum())
return {
"daily_returns": daily_returns,
"metrics": metrics,
}
# ---------------------------------------------------------------------------
# Vectorized path (for 3 special case studies)
# ---------------------------------------------------------------------------
def _run_vectorized(
weights: pl.DataFrame,
predictions: pl.DataFrame,
prices: pl.DataFrame,
cost_spec: dict,
cadence: str,
label: str,
case_study: str,
initial_cash: float,
risk_spec: dict | None = None,
prediction_hash: str | None = None,
) -> dict:
"""Run vectorized backtest (weight × forward return - costs).
Used for us_firm_characteristics, sp500_options, nasdaq100_microstructure.
Cost dispatch supports two models:
* percentage — fractional drag = turnover × (commission_bps + slippage_bps) / 1e4
* per_share_plus_spread — fractional drag = sum_i(|Δw_i| × (per_share + half_spread_i) / price_i),
which models per-share commission and per-asset half-spread slippage. The
|Δshares_i| × cost_per_share_i / NAV identity reduces to the form above
because |Δshares_i| = |Δw_i| × NAV / price_i and NAV cancels.
Portfolio-level risk overlays are applied post-hoc via
``_apply_vectorized_risk``. Only ``max_drawdown`` is supported — it models
an intraday exit at the threshold with explicit slippage. ``daily_loss``
is refused on this path: an honest per-bar halt requires intraday
position tracking that the close-to-close return series cannot express.
Position-level rules (stop-loss, trailing stop) likewise cannot be
applied in vectorized mode.
"""
from case_studies.utils.backtest_loaders import get_rebalance_step, thin_to_rebalance_dates
# Thin predictions to non-overlapping periods. Step is declared per-label
# in the case study's setup.yaml under labels.rebalance_step.
step = get_rebalance_step(case_study, label)
thinned = thin_to_rebalance_dates(predictions, cadence=cadence, step=step)
# Re-compute weights on thinned predictions
# (The weights were computed on full predictions; we need to recompute
# or filter to thinned timestamps)
rebalance_dates = thinned["timestamp"].unique()
# Semi-join to filter — avoids Polars is_in precision mismatch
rebal_df = pl.DataFrame({"timestamp": rebalance_dates})
if rebal_df["timestamp"].dtype != weights["timestamp"].dtype:
rebal_df = rebal_df.cast({"timestamp": weights["timestamp"].dtype})
weights_thinned = weights.join(rebal_df, on="timestamp", how="semi")
# Harmonize timestamp dtypes before join
thinned_sel = thinned.select(["timestamp", "symbol", "y_true"])
if weights_thinned["timestamp"].dtype != thinned_sel["timestamp"].dtype:
thinned_sel = thinned_sel.cast({"timestamp": weights_thinned["timestamp"].dtype})
# Join weights with forward returns
bt = weights_thinned.join(
thinned_sel,
on=["timestamp", "symbol"],
how="inner",
)
# Portfolio returns per period
port_ret = (
bt.group_by("timestamp")
.agg(
gross_ret=(pl.col("weight") * pl.col("y_true")).sum(),
n_positions=pl.len(),
)
.sort("timestamp")
)
# Compute per-symbol weight changes and aggregate turnover for diagnostics
weights_sorted = weights_thinned.sort("timestamp", "symbol").with_columns(
abs_change=(
pl.col("weight") - pl.col("weight").shift(1).over("symbol").fill_null(0.0)
).abs(),
)
turnover = weights_sorted.group_by("timestamp").agg(turnover=pl.col("abs_change").sum())
port_ret = port_ret.join(turnover, on="timestamp", how="left").with_columns(
pl.col("turnover").fill_null(0.0)
)
# Apply costs — dispatch on cost_spec.model
cost_model = cost_spec.get("model", "percentage")
if cost_model == "per_share_plus_spread":
per_share = float(cost_spec["per_share"])
default_hs = float(cost_spec.get("default_half_spread_usd", 0.0))
asset_spreads = cost_spec.get("asset_spreads", {}) or {}
if "close" in prices.columns:
price_col = "close"
elif "mid" in prices.columns:
price_col = "mid"
else:
raise ValueError(
"per_share_plus_spread cost model requires a 'close' or 'mid' "
f"column on the prices frame; got columns={list(prices.columns)}"
)
prices_sel = prices.select(["timestamp", "symbol", pl.col(price_col).alias("_px")])
if prices_sel["timestamp"].dtype != weights_sorted["timestamp"].dtype:
prices_sel = prices_sel.cast({"timestamp": weights_sorted["timestamp"].dtype})
wc_priced = weights_sorted.join(prices_sel, on=["timestamp", "symbol"], how="left")
# Per-asset half-spread map; default fallback for symbols not in map
if asset_spreads:
wc_priced = wc_priced.with_columns(
_hs=pl.col("symbol").replace_strict(
asset_spreads, default=default_hs, return_dtype=pl.Float64
)
)
else:
wc_priced = wc_priced.with_columns(_hs=pl.lit(default_hs, dtype=pl.Float64))
# Fractional cost drag per period: sum_i(|Δw_i| × (per_share + hs_i) / price_i)
# Skip rows where price is null (symbol not in prices frame); they contribute 0.
cost_drag = (
wc_priced.with_columns(
_drag=pl.when(pl.col("_px").is_not_null() & (pl.col("_px") > 0))
.then(pl.col("abs_change") * (per_share + pl.col("_hs")) / pl.col("_px"))
.otherwise(0.0)
)
.group_by("timestamp")
.agg(cost_drag=pl.col("_drag").sum())
)
port_ret = port_ret.join(cost_drag, on="timestamp", how="left").with_columns(
pl.col("cost_drag").fill_null(0.0),
net_ret=pl.col("gross_ret") - pl.col("cost_drag"),
)
else:
cost_rate = (
float(cost_spec.get("commission_bps", 0.0)) + float(cost_spec.get("slippage_bps", 0.0))
) / 10_000
port_ret = port_ret.with_columns(
net_ret=pl.col("gross_ret") - pl.col("turnover") * cost_rate,
)
# Slice port_ret to canonical (cs, label, split) window so every strategy
# on the same (cs, label, split) produces a daily_returns parquet covering
# the same dates regardless of which predictions span which dates. Mirrors
# _run_engine slice at lines 740-786 + 805-816. Without this, vectorized
# daily_returns drift by the prediction window's left/right edges and
# cross-config comparisons aren't apples-to-apples. Slice happens BEFORE
# the risk overlay so the drawdown breaker only fires on canonical-window
# losses (not on stale pre-canonical drawdowns).
if prediction_hash and case_study and label:
from case_studies.utils.cv_window import canonical_window, lookup_split
split = lookup_split(case_study, prediction_hash)
if split is not None:
window = canonical_window(case_study, label, split=split)
if window is not None:
win_start, win_end = window
port_ret_filtered = port_ret.filter(
(pl.col("timestamp").cast(pl.Date) >= win_start)
& (pl.col("timestamp").cast(pl.Date) <= win_end)
)
if port_ret_filtered.is_empty():
raise RuntimeError(
f"Canonical window [{win_start}, {win_end}] for "
f"cs={case_study} label={label} split={split} produced "
f"empty port_ret (vectorized path; port_ret span "
f"{port_ret['timestamp'].min()}{port_ret['timestamp'].max()})."
)
port_ret = port_ret_filtered
# Apply portfolio-level risk overlays (post-hoc on return series)
if risk_spec:
port_ret = _apply_vectorized_risk(port_ret, risk_spec)
# Daily returns DataFrame
daily_returns = port_ret.select(
pl.col("timestamp"),
pl.col("net_ret").alias("daily_return"),
)
# Portfolio metrics via ml4t-diagnostic
returns_arr = daily_returns["daily_return"].to_numpy()
n = len(returns_arr)
# Annualization: use cadence when known, else estimate from data span
periods_per_year = int(_PERIODS_PER_YEAR.get(cadence, 0))
if not periods_per_year and n > 1:
all_ts = daily_returns["timestamp"].unique().sort()
span_secs = float((all_ts[-1] - all_ts[0]).total_seconds())
span_years = span_secs / (365.25 * 86400)
periods_per_year = int(n / span_years) if span_years > 0.01 else 252
metrics = compute_portfolio_metrics(returns_arr, periods_per_year=periods_per_year or 252)
# Vectorized-specific metrics (not derivable from returns alone)
avg_turnover = float(port_ret["turnover"].mean()) if n > 0 else 0.0
metrics["avg_turnover"] = avg_turnover
metrics["n_periods"] = n
return {
"daily_returns": daily_returns,
"metrics": metrics,
}
# ---------------------------------------------------------------------------
# Vectorized risk overlays (Ch19) — portfolio-level limits only
# ---------------------------------------------------------------------------
def _apply_vectorized_risk(port_ret: pl.DataFrame, risk_spec: dict) -> pl.DataFrame:
"""Apply portfolio-level risk limits to a close-to-close return series.
Used by the vectorized + HTM dispatch paths (us_firm_characteristics,
sp500_options/ret_to_expiry). Engine-path case studies use
``ml4t.backtest.risk.RiskManager`` via ``_build_risk_manager`` and never
enter this function.
Supported limits:
``max_drawdown``: model an intraday exit at the drawdown threshold.
Find the first close where cumulative drawdown crosses ``-threshold``;
replace that bar's return with the equity move from the prior close
down to ``peak * (1 - threshold) * (1 - breach_slippage)``; zero every
subsequent bar. Default ``breach_slippage`` = 50 bps; configurable
via ``risk_spec['breach_slippage']``.
Refused (raises ``ValueError``):
``daily_loss``: a vectorized close-to-close series cannot implement
a per-bar daily-loss halt without lookahead — zeroing the breach
bar's loss while keeping every winning bar inflates Sharpe to
infinity in the limit. Use ``max_drawdown`` or move the CS to the
engine path (which has proper ``DailyLossLimit`` halt-on-update
semantics through ``ml4t.backtest.risk``).
"""
limits = risk_spec.get("portfolio_limits", [])
if not limits:
return port_ret
dd_threshold = None
for lc in limits:
ltype = lc["type"]
if ltype == "max_drawdown":
dd_threshold = lc["threshold"]
elif ltype == "daily_loss":
raise ValueError(
"daily_loss portfolio limit is not supported on the "
"vectorized/HTM path: the only honest implementation needs "
"intraday position tracking (engine path's "
"ml4t.backtest.risk.DailyLossLimit). Drop it from the sweep "
"config or move the case study to the engine path."
)
returns = port_ret["net_ret"].to_numpy().copy()
if dd_threshold is not None:
breach_slippage = float(risk_spec.get("breach_slippage", 0.005))
cum = np.cumprod(1 + returns)
peak = np.maximum.accumulate(cum)
drawdowns = cum / peak - 1.0
breach_idx = np.where(drawdowns < -abs(dd_threshold))[0]
if len(breach_idx) > 0:
i = int(breach_idx[0])
prior_eq = float(cum[i - 1]) if i > 0 else 1.0
# Exit at peak * (1 - threshold), then take breach_slippage on the
# exit. Equity at exit = peak[i] * (1 - threshold) * (1 - slip).
exit_eq = float(peak[i]) * (1.0 - abs(dd_threshold)) * (1.0 - breach_slippage)
returns[i] = exit_eq / prior_eq - 1.0
returns[i + 1 :] = 0.0
return port_ret.with_columns(pl.Series("net_ret", returns))
# ---------------------------------------------------------------------------
# Allocation dispatch (Ch17)
# ---------------------------------------------------------------------------
def _apply_allocation(
weights: pl.DataFrame,
predictions: pl.DataFrame,
prices: pl.DataFrame,
alloc_spec: dict,
*,
cadence: str = "",
label: str = "",
case_study: str = "",
prediction_hash: str | None = None,
) -> pl.DataFrame:
"""Post-process signal weights with an allocation method.
Dispatches to utils.allocation functions based on alloc_spec["method"].
The signal weights determine asset SELECTION (which assets are in the
portfolio); the allocation method determines SIZING (how much weight
each gets).
"""
method = alloc_spec.get("method", "equal_weight")
top_k = int(alloc_spec.get("top_k", weights["symbol"].n_unique()))
long_short = bool(alloc_spec.get("long_short", False))
if method == "equal_weight":
return weights
from case_studies.utils.allocation import (
_cap_weights,
compute_conformal_weights,
compute_hrp_weights,
compute_inverse_vol_weights,
compute_mvo_weights,
compute_risk_parity_weights,
)
# Harmonize timestamp + symbol dtypes before joins. us_firm predictions
# carry symbol as UInt32 (stock_id) while prices use String; without this,
# downstream is_in/join on symbol silently produces empty results. The
# symbol cast is routed through ``_align_symbol_dtype`` so a real
# ticker-vs-id mismatch surfaces with case-study context instead of an
# opaque Polars ``InvalidOperationError``.
ts_dtype = weights["timestamp"].dtype
if predictions["timestamp"].dtype != ts_dtype:
predictions = predictions.cast({"timestamp": ts_dtype})
predictions = _align_symbol_dtype(
weights,
predictions,
case_study=case_study,
target_side="weights",
other_side="predictions",
)
if prices["timestamp"].dtype != ts_dtype:
prices = prices.cast({"timestamp": ts_dtype})
prices = _align_symbol_dtype(
weights,
prices,
case_study=case_study,
target_side="weights",
other_side="prices",
)
# Filter predictions to only the assets selected by the signal step
selected_keys = weights.select(["timestamp", "symbol"]).unique()
filtered_preds = predictions.join(selected_keys, on=["timestamp", "symbol"], how="inner")
# Allocation only matters on actual rebalance dates. Without cadence-aware
# thinning, covariance-based allocators solve the same optimization on every
# prediction timestamp even when the engine only rebalances weekly/monthly.
from case_studies.utils.backtest_loaders import get_rebalance_step, thin_to_rebalance_dates
if not case_study or not label:
raise ValueError(
"_apply_allocation requires both case_study and label to look up "
"labels.rebalance_step from setup.yaml. Pass them from the caller."
)
step = get_rebalance_step(case_study, label)
rebal_preds = thin_to_rebalance_dates(filtered_preds, cadence=cadence, step=step)
# Max weight cap — applied after all covariance-based allocators
max_weight = float(alloc_spec.get("max_weight", 0.0))
if method == "score_weighted":
from case_studies.utils.signals import build_target_weights
result = build_target_weights(
rebal_preds,
method="score_weighted_top_k",
top_k=top_k,
long_short=long_short,
)
if max_weight > 0:
result = _cap_weights(result, max_weight)
return result
if method == "conformal_weighted":
if not prediction_hash:
raise ValueError(
"conformal_weighted allocation requires prediction_hash; "
"caller must pass it through _apply_allocation."
)
from case_studies.utils.conformal import load_conformal_widths
alpha = float(alloc_spec.get("alpha", 0.20))
widths = load_conformal_widths(case_study, prediction_hash, alpha=alpha)
floor_q = float(alloc_spec.get("floor_quantile", 0.01))
result = compute_conformal_weights(
rebal_preds,
widths,
top_k,
long_short=long_short,
floor_quantile=floor_q,
)
if max_weight > 0:
result = _cap_weights(result, max_weight)
return result
vol_window = int(alloc_spec.get("vol_window", alloc_spec.get("lookback", 63)))
if method == "inverse_vol":
result = compute_inverse_vol_weights(
rebal_preds, prices, top_k, vol_window=vol_window, long_short=long_short
)
elif method == "risk_parity":
result = compute_risk_parity_weights(
rebal_preds, prices, top_k, vol_window=vol_window, long_short=long_short
)
elif method in ("mvo", "mvo_ledoit_wolf"):
lookback = int(alloc_spec.get("lookback", 126))
mvo_max_weight = max_weight if max_weight > 0 else 1.0
result = compute_mvo_weights(
rebal_preds,
prices,
top_k,
lookback=lookback,
max_weight=mvo_max_weight,
long_short=long_short,
)
elif method == "hrp":
result = compute_hrp_weights(
rebal_preds, prices, top_k, vol_window=vol_window, long_short=long_short
)
else:
import logging
logging.getLogger(__name__).warning(
"Unknown allocation method '%s', returning signal weights", method
)
return weights
if max_weight > 0:
result = _cap_weights(result, max_weight)
return result
# ---------------------------------------------------------------------------
# Risk rules (Ch19) — engine-level integration
# ---------------------------------------------------------------------------
def _build_position_rules(risk_spec: dict):
"""Create ml4t-backtest PositionRule objects from risk spec.
Supports: stop_loss, trailing_stop, time_exit.
Returns a RuleChain (multiple rules) or single rule, or None.
"""
rules_config = risk_spec.get("position_rules", [])
if not rules_config:
return None
from ml4t.backtest.risk import RuleChain, StopLoss, TimeExit, TrailingStop
rules = []
for rc in rules_config:
rtype = rc["type"]
if rtype == "stop_loss":
rules.append(StopLoss(pct=rc["threshold"]))
elif rtype == "trailing_stop":
rules.append(TrailingStop(pct=rc["threshold"]))
elif rtype == "time_exit":
rules.append(TimeExit(max_bars=rc["bars"]))
if not rules:
return None
return RuleChain(rules) if len(rules) > 1 else rules[0]
def _build_risk_manager(risk_spec: dict, initial_cash: float):
"""Create RiskManager with portfolio-level limits from risk spec.
Supports: max_drawdown, daily_loss.
Returns initialized RiskManager, or None.
"""
limits_config = risk_spec.get("portfolio_limits", [])
if not limits_config:
return None
from ml4t.backtest.risk import DailyLossLimit, MaxDrawdownLimit, RiskManager
limits = []
for lc in limits_config:
ltype = lc["type"]
if ltype == "max_drawdown":
limits.append(MaxDrawdownLimit(max_drawdown=lc["threshold"]))
elif ltype == "daily_loss":
limits.append(DailyLossLimit(max_daily_loss_pct=lc["threshold"]))
if not limits:
return None
rm = RiskManager(limits=limits)
rm.initialize(initial_cash)
return rm
# ---------------------------------------------------------------------------
# Convenience: run random-signal plumbing test
# ---------------------------------------------------------------------------
def run_plumbing_test(
case_study: str,
prices: pl.DataFrame,
strategy_spec: dict,
*,
n_assets: int | None = None,
top_k: int = 20,
seed: int = 42,
initial_cash: float = 1_000_000.0,
calendar: str = "NYSE",
contract_specs: dict | None = None,
) -> float:
"""Run a random-signal backtest. Returns Sharpe ratio (should be ~0).
This validates the backtest pipeline produces no spurious alpha
from random inputs.
"""
strategy_spec = ensure_backtest_spec(
case_study,
get_backtest_config(case_study),
strategy_spec,
prices=prices,
prediction_hash="plumbing_test",
initial_cash=initial_cash,
)
strategy = strategy_view(strategy_spec)
rebal_spec = strategy.get("rebalance", {})
if rebal_spec["mode"] == "vectorized":
# Generate random weights
timestamps = prices["timestamp"].unique().sort()
symbols = prices["symbol"].unique().sort().to_list()
rng = np.random.default_rng(seed)
rows = []
k = min(top_k, len(symbols))
for ts in timestamps:
selected = rng.choice(symbols, size=k, replace=False)
w = 1.0 / k
for s in selected:
rows.append({"timestamp": ts, "symbol": s, "weight": w})
random_weights = pl.DataFrame(rows)
# Need y_true for vectorized path — use prices to get returns
# This is a simplified plumbing test for vectorized
return 0.0 # Vectorized plumbing test is in the notebook
# Engine plumbing test
from ml4t.backtest import DataFeed, Engine, RebalanceConfig, Strategy, TargetWeightExecutor
config = runtime_backtest_config(strategy_spec)
signal_config = strategy["signal"]
long_short = bool(signal_config.get("long_short", False))
signal_direction = str(signal_config.get("direction", "long_only")).strip().lower()
allow_short = long_short or signal_direction == "short_only"
# Calendar-aware rebalance schedule for random signal
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
cadence = rebal_spec.get("cadence", "monthly_month_end")
price_ts = prices["timestamp"].unique().sort()
plumbing_schedule = set(resolve_rebalance_timestamps(price_ts, cadence, calendar).to_list())
asset_list = sorted(prices["symbol"].unique().to_list())
k = min(top_k, len(asset_list))
rng = np.random.default_rng(seed)
class _RandomStrategy(Strategy):
def __init__(self):
self.executor = TargetWeightExecutor(
config=RebalanceConfig(
min_trade_value=100.0,
min_weight_change=0.005,
allow_fractional=None, # Defer to broker.share_type (profile)
allow_short=allow_short,
)
)
def on_data(self, timestamp, data, context, broker):
if timestamp not in plumbing_schedule:
return
available = [a for a in asset_list if a in data]
if not available:
return
if signal_direction == "short_only":
selected = rng.choice(available, size=min(k, len(available)), replace=False)
weight = -1.0 / len(selected)
targets = {a: weight for a in selected}
elif long_short:
side_k = min(k, len(available) // 2)
if side_k == 0:
return
selected = rng.choice(available, size=side_k * 2, replace=False).tolist()
longs = selected[:side_k]
shorts = selected[side_k:]
long_weight = 1.0 / len(longs)
short_weight = -1.0 / len(shorts)
targets = {a: long_weight for a in longs}
targets.update({a: short_weight for a in shorts})
else:
selected = rng.choice(available, size=min(k, len(available)), replace=False)
weight = 1.0 / len(selected)
targets = {a: weight for a in selected}
self.executor.execute(targets, data, broker)
feed = DataFeed(prices_df=prices, feed_spec=config.feed_spec)
strategy = _RandomStrategy()
engine = Engine.from_config(feed, strategy, config, contract_specs=contract_specs)
result = engine.run()
return result.metrics.get("sharpe", 0.0)