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.

147 lines
5.1 KiB
Python

"""Assembles a synthetic SnapshotStore from a ScenarioConfig.
Orchestrates the full synthetic data generation pipeline:
1. Trading dates (real NYSE calendar)
2. Market ETF price paths (SPY, QQQ)
3. Individual stock price paths (correlated with market)
4. Macro indicators (VIX, HY spread, SPY/QQQ rolling stats)
5. Event candidates (parameterized feature distributions)
6. Event-price coupling (signal-to-noise injection)
7. SnapshotStore assembly
"""
from __future__ import annotations
import datetime as dt
from typing import Any
import numpy as np
from libs.backtest.scenarios.coupling import couple_events_to_prices
from libs.backtest.scenarios.event_gen import generate_events
from libs.backtest.scenarios.macro_gen import generate_macro_data
from libs.backtest.scenarios.price_gen import generate_market_etf_paths, generate_price_paths
from libs.backtest.scenarios.scenarios import ScenarioConfig
from libs.backtest.snapshot_store import SnapshotStore
# Starting dates for synthetic scenarios (real NYSE calendar)
# Using a date range that's safely after 2020 (no pandemic-era data issues)
_SCENARIO_START_DATE = dt.date(2024, 1, 2)
def _get_trading_dates(n_days: int, start: dt.date = _SCENARIO_START_DATE) -> list[dt.date]:
"""Return n_days consecutive NYSE trading dates starting from start."""
from libs.backtest.calendar import get_trading_days
# Request a window of n_days * 1.5 calendar days to account for weekends/holidays
end_estimate = start + dt.timedelta(days=int(n_days * 1.5) + 30)
all_days = get_trading_days(start, end_estimate)
return all_days[:n_days]
def _total_regime_days(scenario: ScenarioConfig) -> int:
"""Sum of all regime durations in the scenario."""
return sum(r.duration_days for r in scenario.price_regimes)
def build_synthetic_store(
scenario: ScenarioConfig,
rng: np.random.Generator | None = None,
) -> SnapshotStore:
"""Assemble a complete synthetic SnapshotStore from a ScenarioConfig.
The returned store has no connection to real market data. It can be
passed directly to BacktestRunner without any Oracle API or database.
Args:
scenario: Fully specified synthetic market scenario.
rng: NumPy random generator. If None, uses scenario.seed or random state.
Returns:
SnapshotStore ready for BacktestRunner.run().
"""
if rng is None:
seed = scenario.seed
rng = np.random.default_rng(seed)
# 1. Trading dates
n_days = _total_regime_days(scenario) + 30 # extra buffer for parking tail
trading_dates = _get_trading_dates(n_days)
# 2. Market ETF paths (SPY, QQQ) + market log-returns for macro correlation
spy_bars, qqq_bars, market_log_rets = generate_market_etf_paths(
regimes=scenario.price_regimes,
trading_dates=trading_dates,
rng=rng,
)
# 3. Individual stock paths
n_sym = scenario.n_symbols
tickers = [f"SYN{i:03d}" for i in range(n_sym)]
bars_by_symbol = generate_price_paths(
n_symbols=n_sym,
initial_prices=None,
regimes=scenario.price_regimes,
trading_dates=trading_dates,
market_beta_range=(0.5, 1.4),
rng=rng,
tickers=tickers,
)
assert isinstance(bars_by_symbol, dict), "generate_price_paths must return dict"
# Merge ETF bars in as well (for parking lookups)
bars_by_symbol["SPY"] = spy_bars
bars_by_symbol["QQQ"] = qqq_bars
# 4. Macro data
macro_by_date = generate_macro_data(
spy_bars=spy_bars,
qqq_bars=qqq_bars,
vix_config=scenario.vix_config,
hy_config=scenario.hy_config,
trading_dates=trading_dates,
rng=rng,
market_log_rets=market_log_rets,
)
# 5. Event candidates
candidates_by_exec_date = generate_events(
trading_dates=trading_dates,
symbols=tickers,
dist=scenario.event_distribution,
rng=rng,
bars_by_symbol=bars_by_symbol,
)
# 5b. Inject macro_vix / macro_hy_spread into each candidate row.
# selector._row_matches_strategy_engine_filters() reads macro_vix and
# macro_hy_spread directly from the row dict (not from macro_by_date),
# so we must populate them here.
for exec_date, rows in candidates_by_exec_date.items():
macro = macro_by_date.get(exec_date, {})
mv = macro.get("macro_vix")
hy = macro.get("macro_hy_spread")
for row in rows:
if mv is not None:
row["macro_vix"] = mv
if hy is not None:
row["macro_hy_spread"] = hy
# 6. Event-price coupling (signal injection)
couple_events_to_prices(
candidates=candidates_by_exec_date,
bars_by_symbol=bars_by_symbol,
trading_dates=trading_dates,
signal_strength=scenario.signal_strength,
signal_decay_days=scenario.signal_decay_days,
false_positive_rate=scenario.false_positive_rate,
rng=rng,
)
# 7. Assemble SnapshotStore
return SnapshotStore(
candidates_by_exec_date=candidates_by_exec_date,
bars_by_symbol_date=bars_by_symbol,
macro_by_date=macro_by_date,
)