You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

151 lines
5.3 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""Parameter grid search engine for intraday backtesting.
Data is fetched once; simulations run repeatedly with different params.
288 combinations × ~200 days ≈ 5 minutes total simulation time.
"""
from __future__ import annotations
import itertools
from pathlib import Path
from typing import Any
import yaml
from libs.intraday.domain import (
IntradayConfig,
ORBStrategyParams,
StrategyParams,
SweepResult,
)
from libs.intraday.metrics import compute_metrics
from libs.intraday.simulator import run_simulation
class SweepConfig:
"""Parsed sweep configuration."""
def __init__(self, base_config: IntradayConfig, sweep_params: dict[str, list[Any]]) -> None:
self.base_config = base_config
self.sweep_params = sweep_params
@property
def total_combinations(self) -> int:
total = 1
for vals in self.sweep_params.values():
total *= len(vals)
return total
def load_sweep_config(sweep_path: str, base_config: IntradayConfig) -> SweepConfig:
"""Load a sweep YAML and merge with the base config."""
with open(sweep_path) as f:
raw = yaml.safe_load(f)
sweep_params: dict[str, list[Any]] = {}
for key, vals in raw.get("sweep", {}).items():
if not isinstance(vals, list):
vals = [vals]
# Normalize None strings and null values
normalized = [None if v in (None, "null", "none", "None") else v for v in vals]
sweep_params[key] = normalized
return SweepConfig(base_config=base_config, sweep_params=sweep_params)
def generate_combinations(sweep: SweepConfig) -> list[dict[str, Any]]:
"""Generate Cartesian product of all sweep parameters."""
keys = sorted(sweep.sweep_params.keys())
values = [sweep.sweep_params[k] for k in keys]
combos = list(itertools.product(*values))
return [dict(zip(keys, combo)) for combo in combos]
def apply_overrides(base_config: IntradayConfig, overrides: dict[str, Any]) -> IntradayConfig:
"""Apply parameter overrides to base config, returning a new config.
Branches on strategy_mode: momentum overrides go to StrategyParams,
ORB overrides go to ORBStrategyParams.
"""
if base_config.strategy_mode == "orb":
orb = base_config.orb_strategy or ORBStrategyParams()
orb_dict = orb.model_dump()
orb_fields = set(ORBStrategyParams.model_fields.keys())
for key, val in overrides.items():
if key in orb_fields:
orb_dict[key] = val
new_orb = ORBStrategyParams(**orb_dict)
return base_config.model_copy(update={"orb_strategy": new_orb})
# Momentum mode (default)
strategy_dict = base_config.strategy.model_dump()
strategy_fields = set(StrategyParams.model_fields.keys())
for key, val in overrides.items():
if key in strategy_fields:
strategy_dict[key] = val
new_strategy = StrategyParams(**strategy_dict)
return base_config.model_copy(update={"strategy": new_strategy})
def run_sweep(
sweep: SweepConfig,
all_intraday: dict[str, dict[str, list[dict]]],
trading_days: list[str],
progress_callback: Any = None,
enrichment: dict | None = None,
momentum_enrichment: dict | None = None,
vix_by_day: dict[str, float] | None = None,
ticker_sectors: dict[str, str] | None = None,
sector_proxy_intraday_by_day: dict[str, dict[str, list[dict]]] | None = None,
) -> list[SweepResult]:
"""Run simulation for each parameter combination.
Data is pre-fetched and shared across all runs.
Only the simulation (pure CPU computation) varies per combination.
Args:
sweep: SweepConfig with base config and param grid.
all_intraday: Pre-loaded {date: {ticker: [bars]}} data.
trading_days: List of dates.
progress_callback: Optional callable(completed, total) for progress.
enrichment: Pre-computed daily enrichment (required for ORB strategy).
Returns:
List of SweepResult sorted by Sharpe ratio descending.
"""
combos = generate_combinations(sweep)
results: list[SweepResult] = []
is_orb = sweep.base_config.strategy_mode == "orb"
for i, overrides in enumerate(combos):
config = apply_overrides(sweep.base_config, overrides)
if is_orb:
from libs.intraday.orb_simulator import run_orb_simulation
day_results = run_orb_simulation(
all_intraday, trading_days, config.orb_strategy, enrichment or {},
vix_by_day=vix_by_day,
)
else:
day_results = run_simulation(
all_intraday,
trading_days,
config.strategy,
daily_enrichment=momentum_enrichment,
vix_by_day=vix_by_day,
ticker_sectors=ticker_sectors,
sector_proxy_intraday_by_day=sector_proxy_intraday_by_day,
)
metrics = compute_metrics(day_results, config, run_id=f"sw{i:04d}")
results.append(SweepResult(params=overrides, metrics=metrics))
if progress_callback:
progress_callback(i + 1, len(combos))
# Sort by Sharpe descending (None treated as -inf)
results.sort(
key=lambda r: (r.metrics.sharpe_ratio or float("-inf"), r.metrics.total_return_pct or -999),
reverse=True,
)
return results