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

576 lines
22 KiB
Python

from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any
import polars as pl
import yaml
from case_studies.utils.backtest_loaders import BacktestConfig as CaseStudyBacktestConfig
from utils.paths import get_case_study_dir
try:
from ml4t.backtest import BacktestConfig as EngineBacktestConfig
except (ImportError, ModuleNotFoundError): # pragma: no cover - import depends on env
EngineBacktestConfig = None
_EXECUTION_MODE_BY_DELAY = {
"NEXT_BAR_OPEN": "next_bar",
"MONDAY_OPEN": "next_bar",
"1_BAR": "next_bar",
"AT_FUNDING_TIMESTAMP": "same_bar",
}
def resolve_execution_mode(fill_timing: str):
"""Map fill_timing string to ExecutionMode enum.
Raises ValueError for unknown tokens instead of silently degrading.
"""
from ml4t.backtest import ExecutionMode
token = fill_timing.upper().replace(" ", "_")
mode_str = _EXECUTION_MODE_BY_DELAY.get(token)
if mode_str is None:
raise ValueError(
f"Unknown execution delay '{fill_timing}'. "
f"Known values: {sorted(_EXECUTION_MODE_BY_DELAY.keys())}"
)
return ExecutionMode.NEXT_BAR if mode_str == "next_bar" else ExecutionMode.SAME_BAR
def preset_path(case_study: str) -> Path:
"""Path to the source-controlled backtest preset.
Always reads from the source repo (never ML4T_OUTPUT_DIR), since
config/backtest/base.yaml is checked-in source, not runtime data.
"""
from utils.paths import get_case_study_source_dir
return get_case_study_source_dir(case_study) / "config" / "backtest" / "base.yaml"
def load_backtest_preset(case_study: str) -> dict[str, Any]:
path = preset_path(case_study)
with path.open() as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise TypeError(f"Backtest preset at {path} must be a mapping")
return data
def _infer_data_frequency(cadence: str) -> str:
token = cadence.lower()
if "15" in token:
return "15m"
if "30" in token:
return "30m"
if "1_hour" in token or "hourly" in token or token == "1h":
return "1h"
if "8_hour" in token or "funding" in token:
return "irregular"
return "daily"
def _build_feed_spec(
case_study: str,
prices: pl.DataFrame,
case_config: CaseStudyBacktestConfig,
) -> dict[str, Any]:
columns = set(prices.columns)
feed = {
"timestamp_col": "timestamp",
"entity_col": "symbol",
"open_col": "open" if "open" in columns else None,
"high_col": "high" if "high" in columns else None,
"low_col": "low" if "low" in columns else None,
"close_col": "close" if "close" in columns else None,
"price_col": "price" if "price" in columns else ("close" if "close" in columns else None),
"volume_col": "volume" if "volume" in columns else None,
"bid_col": "bid" if "bid" in columns else None,
"ask_col": "ask" if "ask" in columns else None,
"mid_col": "mid" if "mid" in columns else None,
"calendar": case_config.calendar,
"data_frequency": _infer_data_frequency(case_config.cadence),
"timezone": "UTC",
}
if case_study in {"sp500_options", "sp500_equity_option_analytics"}:
feed["bar_type"] = "quote"
return {k: v for k, v in feed.items() if v is not None}
def build_resolved_backtest_config(
case_study: str,
case_config: CaseStudyBacktestConfig,
strategy_spec: dict[str, Any],
*,
prices: pl.DataFrame,
initial_cash: float,
) -> EngineBacktestConfig:
if EngineBacktestConfig is None: # pragma: no cover - import depends on env
raise ImportError("ml4t-backtest is required for backtest preset resolution")
preset = deepcopy(load_backtest_preset(case_study))
preset.setdefault("account", {})
preset.setdefault("execution", {})
preset.setdefault("commission", {})
preset.setdefault("slippage", {})
preset.setdefault("cash", {})
preset.setdefault("calendar", {})
preset.setdefault("position_sizing", {})
fill_timing = strategy_spec.get("execution", {}).get(
"fill_timing"
) or case_config.execution_delay.upper().replace(" ", "_")
execution_mode = _EXECUTION_MODE_BY_DELAY.get(fill_timing, "next_bar")
signal_spec = strategy_spec.get("signal", {})
signal_direction = str(signal_spec.get("direction", "long_only")).strip().lower()
allow_short = bool(signal_spec.get("long_short", case_config.long_short)) or (
signal_direction == "short_only"
)
preset["account"]["allow_short_selling"] = allow_short
preset["execution"]["execution_mode"] = execution_mode
preset["cash"]["initial"] = float(initial_cash)
costs = strategy_spec.get("costs", {})
cost_model = costs.get("model", "percentage")
if cost_model == "percentage":
commission_bps = float(costs.get("commission_bps", case_config.commission_bps))
slippage_bps = float(costs.get("slippage_bps", case_config.slippage_bps))
preset["commission"]["model"] = "percentage"
preset["commission"]["rate"] = commission_bps / 10_000.0
preset["slippage"]["model"] = "percentage"
preset["slippage"]["rate"] = slippage_bps / 10_000.0
elif cost_model == "per_share_plus_spread":
# IB-style realistic equity costs: per-share commission, integer-share
# sizing, half-spread slippage in dollars per share. Per-asset spreads
# can be supplied directly (asset_spreads dict in setup.yaml) or via a
# parquet artifact (asset_spreads_source) measured from quote data.
preset["commission"]["model"] = "per_share"
preset["commission"]["per_share"] = float(costs["per_share"])
preset["commission"]["minimum"] = float(costs.get("minimum", 0.35))
# When execution_price is quote_side, the fill price already includes
# the bid/ask half-spread relative to mid (FillEngine returns
# ask for BUY / bid for SELL via broker.QUOTE_SIDE). Wiring the same
# measured half-spread into the slippage layer on top of that would
# charge the spread twice. Use a zero slippage layer in that case;
# per-share commission still applies independently.
execution_price = preset.get("execution", {}).get("execution_price")
if execution_price == "quote_side":
preset["slippage"]["model"] = "percentage"
preset["slippage"]["rate"] = 0.0
else:
preset["slippage"]["model"] = "spread"
preset["slippage"]["spread_convention"] = costs.get("spread_convention", "half_spread")
spread_by_asset: dict[str, float] = {}
asset_spreads_source = costs.get("asset_spreads_source")
if asset_spreads_source:
spreads_path = get_case_study_dir(case_study, create=False) / asset_spreads_source
spread_col = costs.get("asset_spreads_column", "median_half_spread_usd")
spreads_df = pl.read_parquet(spreads_path)
spread_by_asset = dict(
zip(
spreads_df["symbol"].to_list(),
[float(x) for x in spreads_df[spread_col].to_list()],
)
)
elif "asset_spreads" in costs:
spread_by_asset = {str(k): float(v) for k, v in costs["asset_spreads"].items()}
if spread_by_asset:
preset["slippage"]["spread_by_asset"] = spread_by_asset
if "default_half_spread_usd" in costs:
preset["slippage"]["spread"] = float(costs["default_half_spread_usd"])
else:
raise ValueError(
f"Unknown costs.model {cost_model!r}. Supported: 'percentage', 'per_share_plus_spread'."
)
# Share-type comes from setup.yaml::execution.share_type via case_config —
# never hardcoded per-cost-model branch. Falls back to the preset JSON's
# value (if any) when case_config.share_type is the default placeholder.
share_type = getattr(case_config, "share_type", None)
if share_type:
preset["position_sizing"]["share_type"] = share_type
preset["calendar"]["calendar"] = case_config.calendar
preset["calendar"].setdefault("data_frequency", _infer_data_frequency(case_config.cadence))
derived_feed = _build_feed_spec(case_study, prices, case_config)
explicit_feed = preset.get("feed", {})
preset["feed"] = {
**derived_feed,
**{k: v for k, v in explicit_feed.items() if v is not None},
}
metadata = dict(preset.get("metadata", {}))
metadata.update(
{
"case_study": case_study,
"chapter": strategy_spec.get("chapter"),
"cadence": strategy_spec.get("execution", {}).get("cadence", case_config.cadence),
"fill_timing": fill_timing,
"preset_path": str(preset_path(case_study)),
"signal_direction": signal_direction,
}
)
preset["metadata"] = metadata
return EngineBacktestConfig.from_dict(preset, preset_name=preset_path(case_study).stem)
def _serialize_backtest_config(config: EngineBacktestConfig | dict[str, Any]) -> dict[str, Any]:
if hasattr(config, "to_dict"):
return dict(config.to_dict())
return dict(config)
def runtime_backtest_config(spec: dict[str, Any]) -> EngineBacktestConfig:
if EngineBacktestConfig is None: # pragma: no cover - import depends on env
raise ImportError("ml4t-backtest is required for backtest config resolution")
runtime = EngineBacktestConfig.from_dict(spec["backtest_config"])
spec["_runtime_backtest_config"] = runtime
return runtime
# Calendars that require session enforcement (drop bars outside trading
# sessions, e.g. CME Saturdays). Mirror of the rule in backtest_runner._run_engine;
# applied at spec construction so plan-time hashes match registered hashes.
SESSION_ENFORCED_CALENDARS = frozenset({"CME", "us_futures"})
def apply_calendar_session_enforcement(config: EngineBacktestConfig, calendar: str | None) -> None:
"""Set ``enforce_sessions=True`` on ``config`` when ``calendar`` requires it.
Without this, ``ensure_backtest_spec`` would produce a plan-time spec
with ``enforce_sessions=False`` while ``_run_engine`` later mutates the
same runtime to ``True``, breaking ``_runtime_backtest_config`` hash
stability.
"""
if calendar in SESSION_ENFORCED_CALENDARS:
config.enforce_sessions = True
def serializable_backtest_spec(spec: dict[str, Any]) -> dict[str, Any]:
clean = deepcopy(spec)
clean.pop("_runtime_backtest_config", None)
if "backtest_config" in clean:
clean["backtest_config"] = _serialize_backtest_config(clean["backtest_config"])
return clean
def is_backtest_spec(spec: dict[str, Any]) -> bool:
"""Return True if ``spec`` is in canonical form (has ``strategy`` + ``backtest_config``)."""
return spec.get("version") == 2 and "strategy" in spec and "backtest_config" in spec
def ensure_backtest_spec(
case_study: str,
case_config: CaseStudyBacktestConfig,
strategy_spec: dict[str, Any],
*,
prices: pl.DataFrame,
prediction_hash: str,
initial_cash: float,
) -> dict[str, Any]:
"""Normalize ``strategy_spec`` to the canonical backtest spec form.
Idempotent: if ``strategy_spec`` is already canonical, it is returned with
a refreshed ``_runtime_backtest_config``. Otherwise, a flat strategy_spec
(with ``signal`` / ``execution`` / ``costs`` / etc. blocks) is projected
into the canonical envelope: ``strategy.{signal, rebalance, allocation, risk}``
plus a resolved ``backtest_config`` block.
Rebalance thresholds (``min_weight_change``, ``min_trade_value``) are
always populated in ``strategy.rebalance`` — taken from ``execution.*``
when present, otherwise from ``case_config`` (which sources them from
``setup.yaml::backtest.rebalance.default``).
"""
if is_backtest_spec(strategy_spec):
spec = deepcopy(strategy_spec)
spec.setdefault(
"chapter", spec.get("backtest_config", {}).get("metadata", {}).get("chapter")
)
# Ensure rebalance thresholds are populated; specs that omit them
# would otherwise raise KeyError on `rebalance_spec["min_weight_change"]`.
rb = spec.setdefault("strategy", {}).setdefault("rebalance", {})
rb.setdefault("min_weight_change", float(getattr(case_config, "min_weight_change", 0.005)))
rb.setdefault("min_trade_value", float(getattr(case_config, "min_trade_value", 100.0)))
if "backtest_config" in spec:
# Always overwrite metadata.prediction_hash with the caller's
# argument — the spec may have been cloned from another run
# (e.g. Ch20 holdout reuses the validation rank-1 spec with a
# fresh holdout pred_hash), and downstream split-resolution
# depends on the metadata reflecting the prediction set actually
# being backtested.
metadata = spec["backtest_config"].setdefault("metadata", {})
metadata["prediction_hash"] = prediction_hash
runtime = EngineBacktestConfig.from_dict(spec["backtest_config"])
# Only the session-enforced calendars actually mutate ``runtime``
# here; for all other calendars the from_dict/to_dict round-trip
# would be a silent re-serialize that could perturb hashes for
# any CS whose dict shape differs from the dataclass defaults
# (None→0 normalization, dropped unknown keys, etc.). Confine the
# ``backtest_config`` overwrite to the CSes where it is needed.
if case_config.calendar in SESSION_ENFORCED_CALENDARS:
apply_calendar_session_enforcement(runtime, case_config.calendar)
# Re-serialize so the canonical ``backtest_config`` matches
# the runtime; preserve every caller-supplied metadata key
# by merging the original metadata back over the dataclass's
# serialized view (the dataclass typically pins a schema
# and drops unknown keys).
original_metadata = dict(metadata)
rebuilt = runtime.to_dict()
rebuilt_metadata = dict(rebuilt.get("metadata") or {})
rebuilt_metadata.update(original_metadata)
rebuilt["metadata"] = rebuilt_metadata
spec["backtest_config"] = rebuilt
spec["_runtime_backtest_config"] = runtime
return spec
execution = deepcopy(strategy_spec.get("execution", {}))
rebalance = {
"mode": execution.get("mode", "engine"),
"cadence": execution.get("cadence", case_config.cadence),
"min_weight_change": float(
execution["min_weight_change"]
if "min_weight_change" in execution
else getattr(case_config, "min_weight_change", 0.005)
),
"min_trade_value": float(
execution["min_trade_value"]
if "min_trade_value" in execution
else getattr(case_config, "min_trade_value", 100.0)
),
}
strategy = {
"signal": deepcopy(strategy_spec.get("signal", {})),
"rebalance": rebalance,
}
if "allocation" in strategy_spec:
strategy["allocation"] = deepcopy(strategy_spec["allocation"])
if "risk" in strategy_spec:
strategy["risk"] = deepcopy(strategy_spec["risk"])
resolved_config = build_resolved_backtest_config(
case_study,
case_config,
strategy_spec,
prices=prices,
initial_cash=initial_cash,
)
resolved_config.metadata["prediction_hash"] = prediction_hash
apply_calendar_session_enforcement(resolved_config, case_config.calendar)
resolved_config_dict = resolved_config.to_dict()
return {
"version": 2,
"chapter": strategy_spec.get("chapter"),
"preset_id": f"{case_study}:base",
"strategy": strategy,
"backtest_config": resolved_config_dict,
"_runtime_backtest_config": resolved_config,
}
_COST_PASSTHROUGH_KEYS = (
"model",
"per_share",
"minimum",
"max_pct",
"asset_spreads_source",
"asset_spreads_column",
"asset_spreads",
"default_half_spread_usd",
"spread_convention",
)
def _costs_block_from_case_config(
case_config: CaseStudyBacktestConfig,
) -> dict[str, Any]:
"""Build the strategy_spec.costs block from the case config.
For the percentage model (default), emit the bps form. For the
per_share_plus_spread model, forward the full costs schema from setup.yaml
so build_resolved_backtest_config can dispatch.
"""
raw_costs = getattr(case_config, "raw_costs", None) or {}
cost_model = raw_costs.get("model", "percentage")
if cost_model == "per_share_plus_spread":
return {key: deepcopy(raw_costs[key]) for key in _COST_PASSTHROUGH_KEYS if key in raw_costs}
return {
"commission_bps": case_config.commission_bps,
"slippage_bps": case_config.slippage_bps,
}
def build_backtest_spec(
case_study: str,
case_config: CaseStudyBacktestConfig,
*,
prices: pl.DataFrame,
prediction_hash: str,
initial_cash: float,
signal: dict[str, Any],
allocation: dict[str, Any] | None = None,
risk: dict[str, Any] | None = None,
chapter: str | None = None,
execution_mode: str | None = None,
min_weight_change: float | None = None,
min_trade_value: float | None = None,
) -> dict[str, Any]:
strategy_spec: dict[str, Any] = {
"signal": deepcopy(signal),
"execution": {
"mode": (
execution_mode
if execution_mode is not None
else "vectorized"
if case_study in {"us_firm_characteristics", "sp500_options"}
else "engine"
),
"engine_preset": "realistic",
"cadence": case_config.cadence,
"fill_timing": case_config.execution_delay.upper().replace(" ", "_"),
"min_weight_change": (
min_weight_change
if min_weight_change is not None
else getattr(case_config, "min_weight_change", 0.005)
),
"min_trade_value": (
min_trade_value
if min_trade_value is not None
else getattr(case_config, "min_trade_value", 100.0)
),
},
"costs": _costs_block_from_case_config(case_config),
}
if chapter is not None:
strategy_spec["chapter"] = chapter
if allocation is not None:
strategy_spec["allocation"] = deepcopy(allocation)
if risk is not None:
strategy_spec["risk"] = deepcopy(risk)
return ensure_backtest_spec(
case_study,
case_config,
strategy_spec,
prices=prices,
prediction_hash=prediction_hash,
initial_cash=initial_cash,
)
def clone_backtest_spec(spec: dict[str, Any]) -> dict[str, Any]:
cloned = serializable_backtest_spec(spec)
if is_backtest_spec(cloned):
cloned["_runtime_backtest_config"] = EngineBacktestConfig.from_dict(
cloned["backtest_config"]
)
return cloned
def set_backtest_costs_bps(
spec: dict[str, Any],
*,
commission_bps: float,
slippage_bps: float,
) -> dict[str, Any]:
if not is_backtest_spec(spec):
updated = deepcopy(spec)
updated["costs"] = {
"commission_bps": commission_bps,
"slippage_bps": slippage_bps,
}
return updated
updated = deepcopy(spec)
bt_cfg = updated["backtest_config"]
bt_cfg["commission"] = {
"model": "percentage",
"rate": commission_bps / 10_000.0,
}
bt_cfg["slippage"] = {
"model": "percentage",
"rate": slippage_bps / 10_000.0,
}
updated["_runtime_backtest_config"] = EngineBacktestConfig.from_dict(bt_cfg)
return updated
def set_backtest_costs_per_share(
spec: dict[str, Any],
*,
per_share: float,
default_half_spread_usd: float,
asset_spreads: dict[str, float] | None = None,
spread_convention: str = "half_spread",
minimum: float = 0.0,
) -> dict[str, Any]:
"""Mutate spec to use per-share commission + spread slippage.
Switches the engine commission/slippage models from `percentage` to
`per_share` / `spread`. Safe to call on a spec that originally used the
percentage model — the previous rate fields are replaced. Used by the
cost-sensitivity sweep to walk the per-share+spread regime alongside
the bps regime for case studies whose dataset supports it (those with
prices and integer-share semantics).
`minimum` is the per-order commission floor in dollars and defaults to
`0.0` (no floor). Pass `minimum=0.35` to match the IBKR Pro per-order
floor used by `build_resolved_backtest_config`.
"""
if not is_backtest_spec(spec):
updated = deepcopy(spec)
updated["costs"] = {
"model": "per_share_plus_spread",
"per_share": float(per_share),
"default_half_spread_usd": float(default_half_spread_usd),
"asset_spreads": dict(asset_spreads or {}),
"spread_convention": spread_convention,
"minimum": float(minimum),
}
return updated
updated = deepcopy(spec)
bt_cfg = updated["backtest_config"]
bt_cfg["commission"] = {
"model": "per_share",
"rate": 0.0,
"per_share": float(per_share),
"minimum": float(minimum),
"per_trade": 0.0,
}
bt_cfg["slippage"] = {
"model": "spread",
"rate": 0.0,
"spread": float(default_half_spread_usd),
"spread_convention": spread_convention,
}
if asset_spreads:
bt_cfg["slippage"]["spread_by_asset"] = {str(k): float(v) for k, v in asset_spreads.items()}
bt_cfg.setdefault("position_sizing", {})["share_type"] = "integer"
updated["_runtime_backtest_config"] = EngineBacktestConfig.from_dict(bt_cfg)
return updated
def strategy_view(spec: dict[str, Any]) -> dict[str, Any]:
return spec["strategy"] if is_backtest_spec(spec) else spec
def cost_view(spec: dict[str, Any]) -> dict[str, Any]:
if not is_backtest_spec(spec):
return spec.get("costs", {})
cfg = spec.get("backtest_config", {})
commission = cfg.get("commission", {})
slippage = cfg.get("slippage", {})
return {
"commission_bps": round(float(commission.get("rate", 0.0)) * 10_000.0, 10),
"slippage_bps": round(float(slippage.get("rate", 0.0)) * 10_000.0, 10),
}