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.

243 lines
8.4 KiB
Python

"""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