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