"""Synthetic OHLCV price path generation for scenario backtesting. Implements Regime-Switching GBM with optional jump-diffusion (Merton model). Individual stock paths are generated as market factor + beta + idiosyncratic noise to produce realistic cross-sectional correlations. """ from __future__ import annotations import datetime as dt import math from dataclasses import dataclass, field from typing import Any import numpy as np @dataclass class PriceRegime: """Parameters for a single market regime segment.""" annualized_drift: float """Expected annual return, e.g. 0.15 for bull, -0.20 for bear.""" annualized_vol: float """Annual volatility, e.g. 0.15 for calm, 0.35 for stressed.""" duration_days: int """Number of trading days this regime lasts.""" jump_prob: float = 0.0 """Per-day probability of a Merton jump event.""" jump_mean: float = 0.0 """Mean log-jump size. Negative for crash-type regimes.""" jump_std: float = 0.02 """Standard deviation of log-jump size.""" def _generate_regime_log_returns( regimes: list[PriceRegime], n_days: int, rng: np.random.Generator, ) -> list[float]: """Generate n_days market-level daily log returns following the regime sequence.""" log_rets: list[float] = [] dt_step = 1.0 / 252 for regime in regimes: drift = (regime.annualized_drift - 0.5 * regime.annualized_vol ** 2) * dt_step diffusion = regime.annualized_vol * math.sqrt(dt_step) days_this_regime = min(regime.duration_days, n_days - len(log_rets)) for _ in range(days_this_regime): lr = drift + diffusion * rng.standard_normal() if regime.jump_prob > 0 and rng.random() < regime.jump_prob: lr += rng.normal(regime.jump_mean, max(regime.jump_std, 1e-6)) log_rets.append(lr) # Extend with last regime if regimes run short last = regimes[-1] drift = (last.annualized_drift - 0.5 * last.annualized_vol ** 2) * dt_step diffusion = last.annualized_vol * math.sqrt(dt_step) while len(log_rets) < n_days: log_rets.append(drift + diffusion * rng.standard_normal()) return log_rets[:n_days] def _bars_from_log_returns( log_rets: list[float], trading_dates: list[dt.date], initial_price: float, intraday_vol_scale: float, base_volume: int, rng: np.random.Generator, ) -> dict[dt.date, dict[str, Any]]: """Build OHLCV bars from a sequence of daily log-returns. Ensures OHLCV consistency: low <= min(open, close), high >= max(open, close). Volume is log-normally distributed and positively correlated with |return|. """ bars: dict[dt.date, dict[str, Any]] = {} prev_close = initial_price for date, lr in zip(trading_dates, log_rets): close = max(prev_close * math.exp(lr), 0.01) # Open: prev_close * small gap (mean-zero noise) gap = rng.normal(0, intraday_vol_scale * 0.5) open_ = max(prev_close * math.exp(gap), 0.01) # Intraday range around open/close extremes intraday_noise = abs(rng.normal(0, intraday_vol_scale * 0.7)) hi_raw = max(open_, close) * (1.0 + intraday_noise) lo_raw = min(open_, close) * max(1.0 - intraday_noise, 0.001) high = max(hi_raw, open_, close) low = min(lo_raw, open_, close) low = max(low, 0.01) # Volume: log-normal, amplified by absolute return vol_factor = 1.0 + 3.0 * abs(math.exp(lr) - 1) volume = max(1000, int(rng.lognormal(math.log(base_volume), 0.4) * vol_factor)) bars[date] = { "date": date, "open": round(open_, 4), "high": round(high, 4), "low": round(low, 4), "close": round(close, 4), "volume": volume, } prev_close = close return bars def generate_price_paths( n_symbols: int, initial_prices: list[float] | None, regimes: list[PriceRegime], trading_dates: list[dt.date], market_beta_range: tuple[float, float] = (0.6, 1.2), rng: np.random.Generator | None = None, tickers: list[str] | None = None, return_market_log_rets: bool = False, ) -> ( tuple[dict[str, dict[dt.date, dict[str, Any]]], list[float]] | dict[str, dict[dt.date, dict[str, Any]]] ): """Generate correlated OHLCV bars for n_symbols stocks. Each stock has a random beta to a shared market factor plus idiosyncratic noise. Returns bars_by_symbol_date dict compatible with SnapshotStore. Args: n_symbols: Number of synthetic stocks to generate. initial_prices: Optional list of starting prices (defaults to random $20-$200). regimes: Sequence of PriceRegime objects defining the market environment. trading_dates: Ordered list of NYSE trading dates (from calendar.get_trading_days). market_beta_range: (min, max) range for individual stock betas. rng: NumPy random generator (seeded for reproducibility). tickers: Optional list of ticker symbols (auto-generated if None). return_market_log_rets: If True, also return the market log-return series. Returns: bars_by_symbol_date dict, or (dict, market_log_rets) if return_market_log_rets=True. """ if rng is None: rng = np.random.default_rng() n = len(trading_dates) if tickers is None: tickers = [f"SYM{i:03d}" for i in range(n_symbols)] # Typical vol across all regimes (for intraday range scaling) avg_vol = sum(r.annualized_vol for r in regimes) / max(len(regimes), 1) dt_step = 1.0 / 252 # Market-level log returns (shared factor) market_log_rets = _generate_regime_log_returns(regimes, n, rng) bars_by_symbol: dict[str, dict[dt.date, dict[str, Any]]] = {} for i, ticker in enumerate(tickers[:n_symbols]): if initial_prices and i < len(initial_prices): init_price = initial_prices[i] else: init_price = float(rng.uniform(20.0, 200.0)) beta = float(rng.uniform(*market_beta_range)) idio_vol = avg_vol * float(rng.uniform(0.3, 0.8)) # Build stock log returns: beta * market + idiosyncratic stock_log_rets: list[float] = [] for mkt_lr in market_log_rets: idio = rng.normal(0, idio_vol * math.sqrt(dt_step)) stock_log_rets.append(beta * mkt_lr + idio) intraday_scale = (idio_vol + avg_vol * beta) * math.sqrt(dt_step) * 0.5 base_vol = int(rng.uniform(500_000, 10_000_000)) bars = _bars_from_log_returns( stock_log_rets, trading_dates, initial_price=init_price, intraday_vol_scale=intraday_scale, base_volume=base_vol, rng=rng, ) bars_by_symbol[ticker] = bars if return_market_log_rets: return bars_by_symbol, market_log_rets return bars_by_symbol def generate_market_etf_paths( regimes: list[PriceRegime], trading_dates: list[dt.date], rng: np.random.Generator, spy_initial: float = 480.0, qqq_initial: float = 415.0, ) -> tuple[dict[dt.date, dict[str, Any]], dict[dt.date, dict[str, Any]], list[float]]: """Generate SPY and QQQ synthetic price bars plus market log-returns. QQQ has slightly higher vol and beta than SPY to reflect tech concentration. Returns: (spy_bars, qqq_bars, market_log_rets) """ n = len(trading_dates) dt_step = 1.0 / 252 avg_vol = sum(r.annualized_vol for r in regimes) / max(len(regimes), 1) market_log_rets = _generate_regime_log_returns(regimes, n, rng) # SPY ≈ market (beta ~1.0, low idio noise) spy_log_rets: list[float] = [] for lr in market_log_rets: spy_log_rets.append(lr + rng.normal(0, avg_vol * 0.05 * math.sqrt(dt_step))) spy_bars = _bars_from_log_returns( spy_log_rets, trading_dates, initial_price=spy_initial, intraday_vol_scale=avg_vol * math.sqrt(dt_step) * 0.4, base_volume=80_000_000, rng=rng, ) # QQQ ≈ market * 1.1 beta + higher idio noise qqq_log_rets: list[float] = [] for lr in market_log_rets: qqq_log_rets.append( 1.1 * lr + rng.normal(0, avg_vol * 0.08 * math.sqrt(dt_step)) ) qqq_bars = _bars_from_log_returns( qqq_log_rets, trading_dates, initial_price=qqq_initial, intraday_vol_scale=avg_vol * math.sqrt(dt_step) * 0.45, base_volume=60_000_000, rng=rng, ) return spy_bars, qqq_bars, market_log_rets