1438 lines
43 KiB
Python
1438 lines
43 KiB
Python
# ---
|
|
# jupyter:
|
|
# jupytext:
|
|
# cell_metadata_filter: tags,-all
|
|
# formats: ipynb,py:percent
|
|
# text_representation:
|
|
# extension: .py
|
|
# format_name: percent
|
|
# format_version: '1.3'
|
|
# jupytext_version: 1.19.3
|
|
# kernelspec:
|
|
# display_name: Python 3 (ipykernel)
|
|
# language: python
|
|
# name: python3
|
|
# ---
|
|
|
|
# %% [markdown]
|
|
# # Chapter 5: Classical Simulation Methods
|
|
#
|
|
# **Docker image**: `ml4t`
|
|
#
|
|
# **Purpose**: Implement and compare classical Monte Carlo methods for generating
|
|
# synthetic financial data, building the foundation for the learned generative
|
|
# models that follow.
|
|
#
|
|
# This notebook teaches the **mechanics** of established Monte Carlo methods for
|
|
# generating synthetic financial data. We cover both the underlying mathematics
|
|
# and practical implementation, then show library shortcuts for production use.
|
|
#
|
|
# ## Why Simulate?
|
|
#
|
|
# Historical market data provides only **one path** through an infinite space of
|
|
# possibilities. Simulation generates alternative scenarios for:
|
|
#
|
|
# 1. **Risk Management**: VaR, stress testing, tail risk assessment
|
|
# 2. **Backtesting**: Validate strategies beyond historical experience
|
|
# 3. **Data Augmentation**: Larger datasets for ML model training
|
|
# 4. **Privacy**: Share synthetic data without exposing proprietary signals
|
|
#
|
|
# ## Learning Objectives
|
|
#
|
|
# After completing this notebook, you will be able to:
|
|
#
|
|
# 1. **Implement** each classical stochastic model from scratch
|
|
# 2. **Explain** the assumptions and limitations of each model
|
|
# 3. **Choose** the appropriate model for your use case
|
|
# 4. **Use** library implementations for production work
|
|
#
|
|
# **Book Reference**: Chapter 5, Section 5.2 (Classical Simulation Baselines)
|
|
#
|
|
# **Prerequisites**: Basic probability and stochastic processes; familiarity
|
|
# with NumPy array operations. Requires ETF data from Chapter 2 (`load_etfs()`).
|
|
#
|
|
# ## Notebook Structure
|
|
#
|
|
# 1. **Part 1**: Continuous-Time Price Models (GBM, Jump-Diffusion, OU, Heston)
|
|
# 2. **Part 2**: Discrete-Time Volatility Model (GARCH with calibration)
|
|
# 3. **Part 3**: Bootstrap Methods (IID, Block, Stationary)
|
|
# 4. **Part 4**: Model Comparison
|
|
#
|
|
# ## Statistical Note
|
|
#
|
|
# We use **log-returns** throughout for consistency with continuous-time SDEs:
|
|
# - Log-return: $r_t = \ln(S_t / S_{t-1})$
|
|
# - Kurtosis values are **Fisher (excess) kurtosis**: Gaussian = 0
|
|
#
|
|
# ## References
|
|
#
|
|
# - Glasserman, P. (2003). "Monte Carlo Methods in Financial Engineering"
|
|
# - Politis, D. & Romano, J. (1994). "The Stationary Bootstrap"
|
|
# - Cont, R. (2001). "Empirical Properties of Asset Returns: Stylized Facts"
|
|
# - Heston, S. (1993). "A Closed-Form Solution for Options with Stochastic Volatility"
|
|
|
|
# %%
|
|
"""Classical Simulation Methods - Educational implementation of stochastic models."""
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import plotly.graph_objects as go
|
|
import polars as pl
|
|
import seaborn as sns
|
|
from arch import arch_model
|
|
from arch.bootstrap import IIDBootstrap, MovingBlockBootstrap, StationaryBootstrap
|
|
from ml4t.data.providers import SyntheticProvider
|
|
from plotly.subplots import make_subplots
|
|
from scipy.stats import kurtosis, skew
|
|
from statsmodels.tsa.stattools import acf
|
|
|
|
from data import load_etfs
|
|
from utils.reproducibility import set_global_seeds
|
|
from utils.style import COLORS
|
|
|
|
# %% tags=["parameters"]
|
|
# Production defaults (Papermill overrides for testing)
|
|
SEED = 42
|
|
|
|
# %%
|
|
set_global_seeds(SEED)
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
# # Part 1: Continuous-Time Price Models
|
|
#
|
|
# These models specify stochastic differential equations (SDEs) for price dynamics.
|
|
# Each captures different market phenomena:
|
|
#
|
|
# | Model | Key Feature | Best For |
|
|
# |-------|-------------|----------|
|
|
# | **GBM** | Log-normal prices, constant vol | Option pricing, baseline |
|
|
# | **Jump-Diffusion** | Rare extreme moves | Crash scenarios, tail risk |
|
|
# | **Mean-Reversion** | Prices drift to equilibrium | Spreads, commodities, rates |
|
|
# | **Heston** | Stochastic volatility | Vol surfaces, leverage effect |
|
|
#
|
|
# We implement each from scratch using local RNG for reproducibility,
|
|
# then show the equivalent library call.
|
|
|
|
# %% [markdown]
|
|
# ## 1.1 Geometric Brownian Motion (GBM)
|
|
#
|
|
# The foundation of quantitative finance. Price $S$ follows the stochastic
|
|
# differential equation:
|
|
#
|
|
# $$dS = \mu S \, dt + \sigma S \, dW$$
|
|
#
|
|
# where:
|
|
# - $\mu$ = drift (expected annual return)
|
|
# - $\sigma$ = volatility (annualized)
|
|
# - $dW$ = Wiener process increment (Brownian motion)
|
|
#
|
|
# ### Key Properties
|
|
#
|
|
# - **Log-returns are Gaussian**: $\ln(S_{t+1}/S_t) \sim N((\mu - \sigma^2/2)\Delta t, \sigma^2 \Delta t)$
|
|
# - **Prices are log-normal**: Always positive, no crashes below zero
|
|
# - **No memory**: Future returns independent of past (no autocorrelation)
|
|
# - **Constant volatility**: Same vol every day (unrealistic)
|
|
#
|
|
# ### Discretization (Euler-Maruyama)
|
|
#
|
|
# $$S_{t+\Delta t} = S_t \exp\left((\mu - \frac{\sigma^2}{2}) \Delta t + \sigma \sqrt{\Delta t} \, Z\right)$$
|
|
#
|
|
# where $Z \sim N(0,1)$.
|
|
|
|
|
|
# %%
|
|
def simulate_gbm(
|
|
n_steps: int,
|
|
mu: float,
|
|
sigma: float,
|
|
S0: float = 100.0,
|
|
dt: float = 1 / 252,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate Geometric Brownian Motion price path.
|
|
|
|
Parameters
|
|
----------
|
|
n_steps : int
|
|
Number of time steps
|
|
mu : float
|
|
Annual drift (expected return)
|
|
sigma : float
|
|
Annual volatility
|
|
S0 : float
|
|
Initial price
|
|
dt : float
|
|
Time step (1/252 for daily)
|
|
rng : np.random.Generator, optional
|
|
Random number generator for reproducibility
|
|
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Price path of length n_steps + 1
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
# Standard normal random draws
|
|
Z = rng.standard_normal(n_steps)
|
|
|
|
# Log-return for each step
|
|
log_returns = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * Z
|
|
|
|
# Cumulative sum gives log-prices, then exponentiate
|
|
log_prices = np.cumsum(log_returns)
|
|
prices = S0 * np.exp(log_prices)
|
|
|
|
# Prepend initial price
|
|
return np.insert(prices, 0, S0)
|
|
|
|
|
|
# %% [markdown]
|
|
# ### GBM Simulation
|
|
#
|
|
# Generate 2 years of daily data and inspect the return distribution.
|
|
|
|
# %%
|
|
# Example: 2 years of daily data
|
|
gbm_rng = np.random.default_rng(42)
|
|
gbm_prices = simulate_gbm(n_steps=504, mu=0.08, sigma=0.20, rng=gbm_rng)
|
|
gbm_returns = np.diff(np.log(gbm_prices))
|
|
|
|
print(f"GBM simulation: {len(gbm_prices)} prices")
|
|
print(f"Annualized return: {gbm_returns.mean() * 252:.2%}")
|
|
print(f"Annualized volatility: {gbm_returns.std() * np.sqrt(252):.2%}")
|
|
print(f"Skewness: {skew(gbm_returns):.4f} (Gaussian: 0)")
|
|
print(f"Excess kurtosis: {kurtosis(gbm_returns, fisher=True, bias=False):.4f} (Gaussian: 0)")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: GBM
|
|
#
|
|
# The `ml4t-data` package provides production implementations via `SyntheticProvider`:
|
|
|
|
# %%
|
|
provider = SyntheticProvider(model="gbm", annual_return=0.08, annual_volatility=0.20, seed=42)
|
|
df = provider.fetch_ohlcv("SYNTH", "2022-01-01", "2023-12-31", "daily")
|
|
print(f"SyntheticProvider GBM: {len(df)} bars, final close={float(df['close'][-1]):.2f}")
|
|
|
|
# %% [markdown]
|
|
# ### GBM Limitations
|
|
#
|
|
# GBM assumes returns are **i.i.d. Gaussian**, which contradicts observed
|
|
# "stylized facts" of financial returns:
|
|
#
|
|
# 1. **Fat tails**: Real returns have excess kurtosis (more extremes than Gaussian)
|
|
# 2. **Volatility clustering**: High-vol days tend to follow high-vol days
|
|
# 3. **Leverage effect**: Negative returns often increase future volatility
|
|
#
|
|
# Despite these limitations, GBM remains the workhorse for option pricing
|
|
# (Black-Scholes) due to its analytical tractability.
|
|
|
|
# %% [markdown]
|
|
# ## 1.2 Jump-Diffusion (Merton Model)
|
|
#
|
|
# Adds occasional extreme moves to GBM via a compound Poisson process:
|
|
#
|
|
# $$dS = \mu S \, dt + \sigma S \, dW + S(e^Y - 1) \, dN$$
|
|
#
|
|
# where:
|
|
# - $dN$ = Poisson process with intensity $\lambda$ (jumps per year)
|
|
# - $Y \sim N(\mu_J, \sigma_J^2)$ = log jump size
|
|
#
|
|
# ### Drift Compensator
|
|
#
|
|
# To ensure $\mu$ represents the *total* expected return (including jumps),
|
|
# we subtract the expected jump contribution:
|
|
#
|
|
# $$k = \mathbb{E}[e^Y - 1] = \exp(\mu_J + \tfrac{1}{2}\sigma_J^2) - 1$$
|
|
#
|
|
# ### Discretization
|
|
#
|
|
# $$S_{t+\Delta t} = S_t \exp\left((\mu - \lambda k - \frac{\sigma^2}{2}) \Delta t
|
|
# + \sigma \sqrt{\Delta t} Z + \sum_{i=1}^{N_t} Y_i\right)$$
|
|
#
|
|
# where $N_t \sim \text{Poisson}(\lambda \Delta t)$.
|
|
|
|
|
|
# %%
|
|
def simulate_jump_diffusion(
|
|
n_steps: int,
|
|
mu: float,
|
|
sigma: float,
|
|
lambda_: float,
|
|
mu_jump: float,
|
|
sigma_jump: float,
|
|
S0: float = 100.0,
|
|
dt: float = 1 / 252,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate Merton jump-diffusion price path with compensated drift.
|
|
|
|
Parameters
|
|
----------
|
|
n_steps : int
|
|
Number of time steps
|
|
mu : float
|
|
Total annual drift (including jump contribution)
|
|
sigma : float
|
|
Annual volatility (diffusion part)
|
|
lambda_ : float
|
|
Jump intensity (expected jumps per year)
|
|
mu_jump : float
|
|
Mean of log jump size Y ~ N(mu_jump, sigma_jump^2)
|
|
sigma_jump : float
|
|
Std of log jump size
|
|
S0 : float
|
|
Initial price
|
|
dt : float
|
|
Time step
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Price path of length n_steps + 1
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
# Jump compensator: E[e^Y - 1] so mu remains the total expected return
|
|
k = np.exp(mu_jump + 0.5 * sigma_jump**2) - 1
|
|
|
|
# Diffusion component with compensated drift
|
|
Z = rng.standard_normal(n_steps)
|
|
drift_compensated = mu - lambda_ * k - 0.5 * sigma**2
|
|
diffusion = drift_compensated * dt + sigma * np.sqrt(dt) * Z
|
|
|
|
# Jump component: compound Poisson
|
|
N_jumps = rng.poisson(lambda_ * dt, n_steps)
|
|
jump_component = np.zeros(n_steps)
|
|
|
|
# Vectorized: for steps with jumps, sample and sum log jump sizes
|
|
steps_with_jumps = np.where(N_jumps > 0)[0]
|
|
for t in steps_with_jumps:
|
|
jump_sizes = rng.normal(mu_jump, sigma_jump, N_jumps[t])
|
|
jump_component[t] = np.sum(jump_sizes)
|
|
|
|
# Combine and build price path
|
|
log_returns = diffusion + jump_component
|
|
log_prices = np.cumsum(log_returns)
|
|
prices = S0 * np.exp(log_prices)
|
|
|
|
return np.insert(prices, 0, S0)
|
|
|
|
|
|
# %% [markdown]
|
|
# ### Jump-Diffusion Simulation
|
|
#
|
|
# Simulate with negative jumps (crash scenarios). With `lambda_=5` we expect
|
|
# roughly 5 jumps per year; `mu_jump=-0.03` means each jump averages 3% down.
|
|
|
|
# %%
|
|
# Example: negative jumps (crash scenarios)
|
|
# lambda=5 means ~5 jumps/year; mu_jump=-0.03 means avg 3% down-jump
|
|
jd_rng = np.random.default_rng(43)
|
|
jd_prices = simulate_jump_diffusion(
|
|
n_steps=504,
|
|
mu=0.08, # Total expected return
|
|
sigma=0.15, # Diffusion volatility
|
|
lambda_=5.0,
|
|
mu_jump=-0.03, # Mean log jump (negative = down)
|
|
sigma_jump=0.04,
|
|
rng=jd_rng,
|
|
)
|
|
jd_returns = np.diff(np.log(jd_prices))
|
|
|
|
# Show jump compensator
|
|
k = np.exp(-0.03 + 0.5 * 0.04**2) - 1
|
|
print(f"Jump-Diffusion simulation: {len(jd_prices)} prices")
|
|
print(f"Jump compensator k: {k:.4f} (subtracted from drift)")
|
|
print(f"Annualized return: {jd_returns.mean() * 252:.2%}")
|
|
print(f"Annualized volatility: {jd_returns.std() * np.sqrt(252):.2%}")
|
|
print(f"Skewness: {skew(jd_returns):.4f}")
|
|
print(f"Excess kurtosis: {kurtosis(jd_returns, fisher=True, bias=False):.4f} (> 0 from jumps)")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: Jump-Diffusion
|
|
|
|
# %%
|
|
provider = SyntheticProvider(model="gbm_jump", annual_return=0.08, annual_volatility=0.15, seed=43)
|
|
df = provider.fetch_ohlcv("SYNTH", "2022-01-01", "2023-12-31", "daily")
|
|
print(f"SyntheticProvider Jump-Diffusion: {len(df)} bars, final close={float(df['close'][-1]):.2f}")
|
|
|
|
# %% [markdown]
|
|
# ## 1.3 Mean-Reversion (Ornstein-Uhlenbeck)
|
|
#
|
|
# Prices gravitate toward a long-term equilibrium $\theta$:
|
|
#
|
|
# $$d(\log S) = \kappa(\theta - \log S) \, dt + \sigma \, dW$$
|
|
#
|
|
# where:
|
|
# - $\kappa$ = speed of mean reversion
|
|
# - $\theta$ = long-term mean (log-price level)
|
|
# - Half-life: $t_{1/2} = \ln(2) / \kappa$
|
|
#
|
|
# ### Key Properties
|
|
#
|
|
# - **Stationary**: Prices fluctuate around equilibrium
|
|
# - **No trends**: Can't capture bull/bear markets
|
|
# - **Negative autocorrelation**: Today's move partially reversed tomorrow
|
|
#
|
|
# ### Discretization Options
|
|
#
|
|
# **Euler-Maruyama** (approximate):
|
|
# $$X_{t+\Delta t} = X_t + \kappa(\theta - X_t)\Delta t + \sigma\sqrt{\Delta t} Z$$
|
|
#
|
|
# **Exact transition** (closed-form for OU):
|
|
# $$X_{t+\Delta t} = \theta + (X_t - \theta)e^{-\kappa\Delta t}
|
|
# + \sigma\sqrt{\frac{1 - e^{-2\kappa\Delta t}}{2\kappa}} Z$$
|
|
#
|
|
# We implement both to compare discretization error.
|
|
|
|
|
|
# %%
|
|
def simulate_mean_reversion_euler(
|
|
n_steps: int,
|
|
kappa: float,
|
|
theta: float,
|
|
sigma: float,
|
|
S0: float = 100.0,
|
|
dt: float = 1 / 252,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate OU process using Euler-Maruyama discretization.
|
|
|
|
Parameters
|
|
----------
|
|
n_steps : int
|
|
Number of time steps
|
|
kappa : float
|
|
Mean reversion speed (annualized)
|
|
theta : float
|
|
Long-term mean (log-price level)
|
|
sigma : float
|
|
Volatility (annualized)
|
|
S0 : float
|
|
Initial price
|
|
dt : float
|
|
Time step
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Price path of length n_steps + 1
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
log_prices = np.zeros(n_steps + 1)
|
|
log_prices[0] = np.log(S0)
|
|
|
|
Z = rng.standard_normal(n_steps)
|
|
|
|
for t in range(n_steps):
|
|
log_prices[t + 1] = (
|
|
log_prices[t] + kappa * (theta - log_prices[t]) * dt + sigma * np.sqrt(dt) * Z[t]
|
|
)
|
|
|
|
return np.exp(log_prices)
|
|
|
|
|
|
# %% [markdown]
|
|
# ### Exact Transition Density
|
|
#
|
|
# The OU process has a closed-form transition density, eliminating
|
|
# discretization error entirely. We implement both to compare accuracy.
|
|
|
|
|
|
# %%
|
|
def simulate_mean_reversion_exact(
|
|
n_steps: int,
|
|
kappa: float,
|
|
theta: float,
|
|
sigma: float,
|
|
S0: float = 100.0,
|
|
dt: float = 1 / 252,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate OU process using exact transition density.
|
|
|
|
The exact solution eliminates discretization error entirely.
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
log_prices = np.zeros(n_steps + 1)
|
|
log_prices[0] = np.log(S0)
|
|
|
|
# Precompute constants
|
|
exp_neg_kappa_dt = np.exp(-kappa * dt)
|
|
std_dev = sigma * np.sqrt((1 - np.exp(-2 * kappa * dt)) / (2 * kappa))
|
|
|
|
Z = rng.standard_normal(n_steps)
|
|
|
|
for t in range(n_steps):
|
|
log_prices[t + 1] = theta + (log_prices[t] - theta) * exp_neg_kappa_dt + std_dev * Z[t]
|
|
|
|
return np.exp(log_prices)
|
|
|
|
|
|
# %% [markdown]
|
|
# ### Mean-Reversion Simulation
|
|
#
|
|
# Compare exact vs Euler discretization. With `kappa=2`, the half-life is
|
|
# $\ln(2)/2 \approx 0.35$ years (87 trading days).
|
|
|
|
# %%
|
|
# Example: mean-reverting around 100 using exact discretization
|
|
# kappa=2 gives half-life of ln(2)/2 ≈ 0.35 years (87 trading days)
|
|
mr_rng = np.random.default_rng(44)
|
|
mr_prices = simulate_mean_reversion_exact(
|
|
n_steps=504,
|
|
kappa=2.0,
|
|
theta=np.log(100), # Long-term mean at 100
|
|
sigma=0.15,
|
|
rng=mr_rng,
|
|
)
|
|
mr_returns = np.diff(np.log(mr_prices))
|
|
|
|
half_life_days = np.log(2) / 2.0 * 252
|
|
print(f"Mean-Reversion (exact) simulation: {len(mr_prices)} prices")
|
|
print(f"Half-life: {half_life_days:.0f} trading days")
|
|
print(f"Final price: {mr_prices[-1]:.2f} (equilibrium: 100)")
|
|
print(f"Return autocorr(1): {np.corrcoef(mr_returns[:-1], mr_returns[1:])[0, 1]:.4f}")
|
|
|
|
# Compare Euler vs Exact discretization
|
|
mr_rng_euler = np.random.default_rng(44) # Same seed
|
|
mr_euler = simulate_mean_reversion_euler(
|
|
n_steps=504, kappa=2.0, theta=np.log(100), sigma=0.15, rng=mr_rng_euler
|
|
)
|
|
max_diff = np.max(np.abs(mr_prices - mr_euler))
|
|
print(f"Euler vs Exact max difference: {max_diff:.6f}")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: Mean-Reversion
|
|
|
|
# %%
|
|
provider = SyntheticProvider(model="mean_revert", annual_volatility=0.15, seed=44)
|
|
df = provider.fetch_ohlcv("SYNTH", "2022-01-01", "2023-12-31", "daily")
|
|
print(f"SyntheticProvider Mean-Revert: {len(df)} bars, final close={float(df['close'][-1]):.2f}")
|
|
|
|
# %% [markdown]
|
|
# ## 1.4 Heston (Stochastic Volatility)
|
|
#
|
|
# Volatility itself is random, following a separate mean-reverting process:
|
|
#
|
|
# $$dS = \mu S \, dt + \sqrt{v} S \, dW_S$$
|
|
# $$dv = \kappa(\theta - v) \, dt + \xi \sqrt{v} \, dW_v$$
|
|
# $$\text{Corr}(dW_S, dW_v) = \rho$$
|
|
#
|
|
# where:
|
|
# - $v$ = instantaneous variance
|
|
# - $\kappa$ = variance mean-reversion speed
|
|
# - $\theta$ = long-term variance
|
|
# - $\xi$ = volatility of volatility ("vol of vol")
|
|
# - $\rho$ = correlation between price and variance shocks (leverage effect)
|
|
#
|
|
# ### Key Properties
|
|
#
|
|
# - **Stochastic volatility**: Vol changes unpredictably
|
|
# - **Leverage effect**: $\rho < 0$ means price drops increase volatility
|
|
# - **Fat tails**: From randomness in volatility
|
|
# - **Volatility clustering**: From mean-reversion in variance
|
|
# - **Feller condition**: $2\kappa\theta > \xi^2$ prevents variance from hitting zero
|
|
#
|
|
# ### Discretization: Full Truncation Euler
|
|
#
|
|
# To handle potential negative variance, we use **full truncation**:
|
|
# apply $\max(v, 0)$ consistently in both drift and diffusion terms.
|
|
|
|
|
|
# %%
|
|
def simulate_heston(
|
|
n_steps: int,
|
|
mu: float,
|
|
v0: float,
|
|
kappa: float,
|
|
theta: float,
|
|
xi: float,
|
|
rho: float,
|
|
S0: float = 100.0,
|
|
dt: float = 1 / 252,
|
|
rng: np.random.Generator | None = None,
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
"""
|
|
Generate Heston stochastic volatility path using full truncation Euler.
|
|
|
|
Full truncation applies max(v, 0) to the current variance before
|
|
computing both drift and diffusion terms, ensuring consistency.
|
|
|
|
Parameters
|
|
----------
|
|
n_steps : int
|
|
Number of time steps
|
|
mu : float
|
|
Drift
|
|
v0 : float
|
|
Initial variance
|
|
kappa : float
|
|
Variance mean-reversion speed
|
|
theta : float
|
|
Long-term variance
|
|
xi : float
|
|
Volatility of variance (vol of vol)
|
|
rho : float
|
|
Correlation between price and variance shocks
|
|
S0 : float
|
|
Initial price
|
|
dt : float
|
|
Time step
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
tuple[np.ndarray, np.ndarray]
|
|
Price path and variance path
|
|
|
|
Note
|
|
----
|
|
For production use, consider Andersen's QE scheme which has better
|
|
accuracy near the boundary.
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
prices = np.zeros(n_steps + 1)
|
|
variance = np.zeros(n_steps + 1)
|
|
prices[0] = S0
|
|
variance[0] = v0
|
|
|
|
sqrt_dt = np.sqrt(dt)
|
|
|
|
for t in range(n_steps):
|
|
# Full truncation: apply floor BEFORE computing terms
|
|
v_t = max(variance[t], 0)
|
|
sqrt_v_t = np.sqrt(v_t)
|
|
|
|
# Correlated Brownian motions
|
|
Z1 = rng.standard_normal()
|
|
Z2 = rng.standard_normal()
|
|
W_v = Z1
|
|
W_S = rho * Z1 + np.sqrt(1 - rho**2) * Z2
|
|
|
|
# Variance update (full truncation Euler)
|
|
variance[t + 1] = v_t + kappa * (theta - v_t) * dt + xi * sqrt_v_t * sqrt_dt * W_v
|
|
# Floor the result for next iteration
|
|
variance[t + 1] = max(variance[t + 1], 0)
|
|
|
|
# Price update
|
|
prices[t + 1] = prices[t] * np.exp((mu - 0.5 * v_t) * dt + sqrt_v_t * sqrt_dt * W_S)
|
|
|
|
return prices, variance
|
|
|
|
|
|
# %% [markdown]
|
|
# ### Heston Simulation
|
|
#
|
|
# Simulate with typical parameters. We verify the Feller condition
|
|
# ($2\kappa\theta > \xi^2$) to ensure variance stays positive.
|
|
|
|
# %%
|
|
# Example: typical Heston parameters
|
|
# Feller condition: 2*5*0.04 = 0.4 > 0.3^2 = 0.09 [OK]
|
|
heston_rng = np.random.default_rng(45)
|
|
heston_prices, heston_var = simulate_heston(
|
|
n_steps=504,
|
|
mu=0.05,
|
|
v0=0.04, # Initial vol = 20%
|
|
kappa=5.0, # Fast mean reversion
|
|
theta=0.04, # Long-term vol = 20%
|
|
xi=0.3, # Vol of vol = 30%
|
|
rho=-0.7, # Strong leverage effect
|
|
rng=heston_rng,
|
|
)
|
|
heston_returns = np.diff(np.log(heston_prices))
|
|
|
|
# Verify Feller condition
|
|
feller_lhs = 2 * 5.0 * 0.04
|
|
feller_rhs = 0.3**2
|
|
print(f"Heston simulation: {len(heston_prices)} prices")
|
|
print(
|
|
f"Feller condition: 2*kappa*theta = {feller_lhs:.2f} > xi^2 = {feller_rhs:.2f}: "
|
|
f"{'OK' if feller_lhs > feller_rhs else 'VIOLATED'}"
|
|
)
|
|
print(f"Initial vol: {np.sqrt(heston_var[0]) * 100:.1f}%")
|
|
print(f"Final vol: {np.sqrt(heston_var[-1]) * 100:.1f}%")
|
|
print(f"Min variance (should be >= 0): {heston_var.min():.6f}")
|
|
print(f"Excess kurtosis: {kurtosis(heston_returns, fisher=True, bias=False):.4f}")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: Heston
|
|
|
|
# %%
|
|
provider = SyntheticProvider(model="heston", annual_volatility=0.20, heston_xi=0.3, seed=45)
|
|
df = provider.fetch_ohlcv("SYNTH", "2022-01-01", "2023-12-31", "daily")
|
|
print(f"SyntheticProvider Heston: {len(df)} bars, final close={float(df['close'][-1]):.2f}")
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
# # Part 2: Discrete-Time Volatility Model (GARCH)
|
|
#
|
|
# Unlike the continuous-time SDEs above, GARCH is a **discrete-time model**
|
|
# for conditional variance. It models how return volatility evolves based on
|
|
# past shocks.
|
|
#
|
|
# ## GARCH(1,1)
|
|
#
|
|
# $$r_t = \mu + \sigma_t \varepsilon_t, \quad \varepsilon_t \sim N(0,1)$$
|
|
# $$\sigma^2_t = \omega + \alpha (r_{t-1} - \mu)^2 + \beta \sigma^2_{t-1}$$
|
|
#
|
|
# where:
|
|
# - $\mu$ = unconditional mean return (drift)
|
|
# - $\omega$ = base variance (intercept)
|
|
# - $\alpha$ = reaction to recent shocks (news impact)
|
|
# - $\beta$ = persistence of past variance (memory)
|
|
# - $\alpha + \beta < 1$ required for stationarity
|
|
#
|
|
# ### Key Properties
|
|
#
|
|
# - **Volatility clustering**: $\beta > 0$ means vol persists
|
|
# - **Fat tails**: From time-varying volatility
|
|
# - **Mean-reverting volatility**: Unconditional variance = $\omega / (1 - \alpha - \beta)$
|
|
# - **Leverage effect**: Requires asymmetric extensions (GJR-GARCH, EGARCH)
|
|
#
|
|
# ### GARCH vs Heston
|
|
#
|
|
# | Aspect | GARCH | Heston |
|
|
# |--------|-------|--------|
|
|
# | Time | Discrete | Continuous |
|
|
# | Leverage | Extensions needed | Built-in ($\rho$) |
|
|
# | Calibration | MLE from data | Option surface |
|
|
# | Analytical | Limited | Semi-closed form |
|
|
#
|
|
# ## Calibration: Fitting GARCH to Data
|
|
#
|
|
# Unlike SDEs where we **choose** parameters (drift, volatility), GARCH is
|
|
# typically **fitted** to historical data via maximum likelihood estimation.
|
|
|
|
# %%
|
|
# Load SPY returns for GARCH calibration
|
|
etf_data = load_etfs()
|
|
spy_close = (
|
|
etf_data.filter(pl.col("symbol") == "SPY")
|
|
.sort("timestamp")
|
|
.select("close")
|
|
.to_series()
|
|
.to_numpy()
|
|
)
|
|
|
|
# Compute log returns in percent (arch library convention; matches exp(cumsum) reconstruction)
|
|
spy_log_returns_pct = np.diff(np.log(spy_close)) * 100
|
|
|
|
print(f"SPY log-returns: {len(spy_log_returns_pct)} observations")
|
|
print(f"Mean: {spy_log_returns_pct.mean():.4f}% daily")
|
|
print(f"Std: {spy_log_returns_pct.std():.4f}%")
|
|
|
|
# %% [markdown]
|
|
# ### Fit GARCH(1,1) to SPY
|
|
|
|
# %%
|
|
# Fit GARCH(1,1) model
|
|
am = arch_model(spy_log_returns_pct, mean="Constant", vol="GARCH", p=1, q=1)
|
|
res = am.fit(disp="off")
|
|
|
|
# Extract calibrated parameters
|
|
mu_fit = res.params["mu"]
|
|
omega_fit = res.params["omega"]
|
|
alpha_fit = res.params["alpha[1]"]
|
|
beta_fit = res.params["beta[1]"]
|
|
|
|
print("Calibrated GARCH(1,1) parameters:")
|
|
print(f" mu (mean): {mu_fit:.6f}% daily")
|
|
print(f" omega: {omega_fit:.6f}")
|
|
print(f" alpha (news): {alpha_fit:.4f}")
|
|
print(f" beta (memory): {beta_fit:.4f}")
|
|
print(f" persistence: {alpha_fit + beta_fit:.4f}")
|
|
print(f" unconditional vol: {np.sqrt(omega_fit / (1 - alpha_fit - beta_fit)):.4f}% daily")
|
|
|
|
# %% [markdown]
|
|
# ### Simulate from Calibrated Parameters
|
|
#
|
|
# Now we can simulate new paths using the fitted parameters.
|
|
|
|
|
|
# %%
|
|
def simulate_garch(
|
|
n_steps: int,
|
|
mu: float,
|
|
omega: float,
|
|
alpha: float,
|
|
beta: float,
|
|
sigma0: float | None = None,
|
|
rng: np.random.Generator | None = None,
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
"""
|
|
Generate GARCH(1,1) return series with mean.
|
|
|
|
Parameters
|
|
----------
|
|
n_steps : int
|
|
Number of time steps
|
|
mu : float
|
|
Mean return (same units as omega)
|
|
omega : float
|
|
Base variance (intercept)
|
|
alpha : float
|
|
Shock coefficient (news impact)
|
|
beta : float
|
|
Persistence coefficient
|
|
sigma0 : float, optional
|
|
Initial volatility. If None, use unconditional volatility.
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
tuple[np.ndarray, np.ndarray]
|
|
Returns and volatility paths
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
# Unconditional variance
|
|
uncond_var = omega / (1 - alpha - beta)
|
|
if sigma0 is None:
|
|
sigma0 = np.sqrt(uncond_var)
|
|
|
|
returns = np.zeros(n_steps)
|
|
sigma = np.zeros(n_steps)
|
|
sigma[0] = sigma0
|
|
|
|
for t in range(n_steps):
|
|
# Generate return with mean
|
|
eps = rng.standard_normal()
|
|
returns[t] = mu + sigma[t] * eps
|
|
|
|
# Update volatility for next period
|
|
if t < n_steps - 1:
|
|
shock = returns[t] - mu # Deviation from mean
|
|
sigma[t + 1] = np.sqrt(omega + alpha * shock**2 + beta * sigma[t] ** 2)
|
|
|
|
return returns, sigma
|
|
|
|
|
|
# %% [markdown]
|
|
# ### GARCH Simulation from Calibrated Parameters
|
|
#
|
|
# Use the fitted SPY parameters to generate a synthetic path and compare
|
|
# the resulting moments with the calibrated values.
|
|
|
|
# %%
|
|
# Simulate using calibrated parameters
|
|
garch_rng = np.random.default_rng(46)
|
|
garch_log_returns_pct, garch_vol = simulate_garch(
|
|
n_steps=504,
|
|
mu=mu_fit,
|
|
omega=omega_fit,
|
|
alpha=alpha_fit,
|
|
beta=beta_fit,
|
|
rng=garch_rng,
|
|
)
|
|
|
|
# Convert to prices (from log returns in percent)
|
|
garch_log_returns = garch_log_returns_pct / 100 # Convert to decimal log returns
|
|
garch_prices = 100 * np.exp(np.cumsum(np.insert(garch_log_returns, 0, 0)))
|
|
|
|
print(f"GARCH simulation (calibrated to SPY): {len(garch_prices)} prices")
|
|
print(f"Simulated annual return: {garch_log_returns.mean() * 252:.2%}")
|
|
print(f"Simulated annual vol: {garch_log_returns.std() * np.sqrt(252):.1%}")
|
|
print(f"Excess kurtosis: {kurtosis(garch_log_returns, fisher=True, bias=False):.4f}")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: GARCH
|
|
|
|
# %%
|
|
provider = SyntheticProvider(
|
|
model="garch",
|
|
garch_omega=omega_fit,
|
|
garch_alpha=alpha_fit,
|
|
garch_beta=beta_fit,
|
|
seed=46,
|
|
)
|
|
df = provider.fetch_ohlcv("SYNTH", "2022-01-01", "2023-12-31", "daily")
|
|
print(f"SyntheticProvider GARCH: {len(df)} bars, final close={float(df['close'][-1]):.2f}")
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
# ## Visualize All Parametric Models
|
|
|
|
# %%
|
|
# Collect all simulations
|
|
all_models = {
|
|
"GBM": gbm_prices,
|
|
"Jump-Diffusion": jd_prices,
|
|
"Mean-Reversion": mr_prices,
|
|
"Heston": heston_prices,
|
|
"GARCH": garch_prices,
|
|
}
|
|
|
|
# %%
|
|
# Create comparison plot
|
|
fig = make_subplots(
|
|
rows=2,
|
|
cols=3,
|
|
subplot_titles=list(all_models.keys()) + ["Combined (Normalized)"],
|
|
vertical_spacing=0.12,
|
|
)
|
|
|
|
colors = [COLORS["blue"], COLORS["amber"], COLORS["copper"], COLORS["neutral"], COLORS["positive"]]
|
|
|
|
# Individual plots (no legend - subplot titles identify each)
|
|
for idx, (name, prices) in enumerate(all_models.items()):
|
|
row, col = (idx // 3) + 1, (idx % 3) + 1
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
y=prices, mode="lines", name=name, line=dict(color=colors[idx]), showlegend=False
|
|
),
|
|
row=row,
|
|
col=col,
|
|
)
|
|
|
|
# Combined normalized plot (with legend for comparison)
|
|
for idx, (name, prices) in enumerate(all_models.items()):
|
|
normalized = 100 * prices / prices[0]
|
|
fig.add_trace(
|
|
go.Scatter(y=normalized, mode="lines", name=name, line=dict(color=colors[idx])),
|
|
row=2,
|
|
col=3,
|
|
)
|
|
|
|
fig.update_layout(
|
|
title_text="Stochastic Models: Price Paths",
|
|
height=600,
|
|
width=950,
|
|
template="plotly_white",
|
|
)
|
|
fig.show()
|
|
|
|
# %% [markdown]
|
|
# ### Normalized Price Paths (Grayscale-Compatible)
|
|
#
|
|
# A matplotlib companion to the plotly grid above, using distinct line styles
|
|
# that remain distinguishable in grayscale print.
|
|
|
|
# %%
|
|
# Line styles for grayscale compatibility - varied grays + distinct patterns
|
|
LINE_STYLES = [
|
|
{"linestyle": "-", "linewidth": 1.8, "color": "black"}, # GBM: solid black
|
|
{"linestyle": "--", "linewidth": 1.8, "color": "#404040"}, # Jump-Diffusion: dashed dark gray
|
|
{
|
|
"linestyle": "-.",
|
|
"linewidth": 1.8,
|
|
"color": "#606060",
|
|
}, # Mean-Reversion: dash-dot medium gray
|
|
{"linestyle": ":", "linewidth": 2.2, "color": "#202020"}, # Heston: dotted near-black (thicker)
|
|
{
|
|
"linestyle": (0, (5, 2, 1, 2)),
|
|
"linewidth": 1.8,
|
|
"color": "#808080",
|
|
}, # GARCH: long-dash-dot gray
|
|
]
|
|
|
|
# Create wide-format figure (at least 2:1 aspect ratio). Build and style
|
|
# in a SINGLE cell so the inline backend does not flush the figure mid-
|
|
# construction with no title / labels / legend
|
|
# (feedback_split_cell_figure_bug).
|
|
fig, ax = plt.subplots(figsize=(12, 4.5))
|
|
|
|
# Plot each model normalized to 100
|
|
for idx, (name, prices) in enumerate(all_models.items()):
|
|
normalized = 100 * prices / prices[0]
|
|
ax.plot(normalized, label=name, **LINE_STYLES[idx])
|
|
|
|
# Styling
|
|
ax.set_xlabel("Trading Days")
|
|
ax.set_ylabel("Normalized Price (Start = 100)")
|
|
ax.set_title("Classical Simulation Models: Normalized Price Paths")
|
|
|
|
# Legend outside plot area to avoid overlap
|
|
ax.legend(
|
|
loc="upper left",
|
|
bbox_to_anchor=(0.01, 0.99),
|
|
frameon=True,
|
|
fancybox=False,
|
|
edgecolor="lightgray",
|
|
fontsize=9,
|
|
)
|
|
|
|
# Add horizontal reference line at 100
|
|
ax.axhline(y=100, color="gray", linewidth=0.5, linestyle="-", alpha=0.4)
|
|
|
|
# Despine (seaborn style)
|
|
sns.despine(ax=ax)
|
|
|
|
# Tight layout
|
|
fig.tight_layout()
|
|
fig.show()
|
|
|
|
# %% [markdown]
|
|
# **Interpretation**: GBM produces the smoothest paths — a direct consequence of
|
|
# continuous diffusion with constant volatility. Jump-diffusion adds sudden
|
|
# discontinuities mimicking flash crashes or earnings surprises. Heston's
|
|
# stochastic volatility creates volatility clustering: calm periods punctuated
|
|
# by turbulent episodes. GARCH captures similar clustering in discrete time,
|
|
# while mean-reversion pulls prices back toward equilibrium. The normalized
|
|
# comparison (bottom right) shows models diverge most during high-volatility
|
|
# regimes — precisely where model choice matters most for risk estimation.
|
|
|
|
# %% [markdown]
|
|
# ## Model Statistics Comparison
|
|
#
|
|
# Compute standardized metrics across all parametric models to quantify how
|
|
# each captures (or fails to capture) empirical stylized facts: fat tails
|
|
# (excess kurtosis), asymmetry (skewness), and drawdown behavior.
|
|
|
|
|
|
# %%
|
|
def compute_model_stats(prices: np.ndarray, name: str) -> dict:
|
|
"""Compute key statistics for a price path using log-returns."""
|
|
log_returns = np.diff(np.log(prices))
|
|
return {
|
|
"model": name,
|
|
"annual_return": log_returns.mean() * 252,
|
|
"annual_volatility": log_returns.std() * np.sqrt(252),
|
|
"skewness": skew(log_returns),
|
|
"excess_kurtosis": kurtosis(log_returns, fisher=True, bias=False),
|
|
"max_drawdown": np.min(prices / np.maximum.accumulate(prices) - 1),
|
|
}
|
|
|
|
|
|
stats_rows = [compute_model_stats(prices, name) for name, prices in all_models.items()]
|
|
stats_df = pl.DataFrame(stats_rows)
|
|
stats_df
|
|
|
|
# %%
|
|
# Return distribution comparison
|
|
fig = go.Figure()
|
|
|
|
for idx, (name, prices) in enumerate(all_models.items()):
|
|
log_returns = np.diff(np.log(prices))
|
|
fig.add_trace(
|
|
go.Histogram(
|
|
x=log_returns,
|
|
name=name,
|
|
opacity=0.6,
|
|
nbinsx=50,
|
|
histnorm="probability density",
|
|
marker_color=colors[idx],
|
|
)
|
|
)
|
|
|
|
fig.update_layout(
|
|
title="Return Distributions by Model (Log-Returns)",
|
|
xaxis_title="Daily Log-Return",
|
|
yaxis_title="Density",
|
|
barmode="overlay",
|
|
template="plotly_white",
|
|
)
|
|
fig.show()
|
|
|
|
# %% [markdown]
|
|
# **Interpretation**: GBM returns are near-Gaussian by construction (zero excess
|
|
# kurtosis). Jump-diffusion and GARCH produce the heaviest tails, closest to
|
|
# the empirical leptokurtosis observed in real markets. Heston generates moderate
|
|
# tail weight through its stochastic volatility channel. Mean-reversion shows a
|
|
# compressed distribution due to its pull toward equilibrium. For risk management
|
|
# (VaR, ES), models that understate kurtosis — like plain GBM — systematically
|
|
# underestimate tail losses.
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
# # Part 3: Bootstrap Methods
|
|
#
|
|
# Bootstrap methods resample historical data rather than assuming a parametric
|
|
# model. They preserve the **empirical distribution** exactly, including fat tails.
|
|
#
|
|
# | Method | Block Size | Preserves Autocorrelation | Best For |
|
|
# |--------|-----------|--------------------------|----------|
|
|
# | **IID Bootstrap** | 1 | No | i.i.d. assumption OK |
|
|
# | **Block Bootstrap** | Fixed | Yes (within blocks) | Time series |
|
|
# | **Stationary Bootstrap** | Random | Yes (smoother) | Financial returns |
|
|
#
|
|
# ### Key Trade-off
|
|
#
|
|
# - **Parametric**: Can generate scenarios *beyond* historical range
|
|
# - **Bootstrap**: Preserves empirical distribution *exactly* but limited to observed extremes
|
|
#
|
|
# ### Consistency Note
|
|
#
|
|
# We bootstrap **log-returns** to match the parametric models above.
|
|
|
|
# %% [markdown]
|
|
# ## Load Real Data for Bootstrap
|
|
|
|
# %%
|
|
# Compute log-returns (consistent with parametric models)
|
|
spy_log_returns = np.diff(np.log(spy_close))
|
|
|
|
print(f"SPY log-returns: {len(spy_log_returns)} observations")
|
|
print(f"Mean: {spy_log_returns.mean():.6f}")
|
|
print(f"Std: {spy_log_returns.std():.4f}")
|
|
print(f"Skewness: {skew(spy_log_returns):.4f}")
|
|
print(
|
|
f"Excess kurtosis: {kurtosis(spy_log_returns, fisher=True, bias=False):.4f} (Fisher, Gaussian=0)"
|
|
)
|
|
|
|
# %% [markdown]
|
|
# ## 3.1 IID Bootstrap
|
|
#
|
|
# The simplest resampling method: draw individual returns **with replacement**.
|
|
#
|
|
# ### Algorithm
|
|
#
|
|
# ```
|
|
# For each bootstrap sample of length T:
|
|
# For t = 1 to T:
|
|
# Draw index i uniformly from {1, ..., N}
|
|
# Set r*_t = r_i (original return i)
|
|
# Return r* = (r*_1, ..., r*_T)
|
|
# ```
|
|
#
|
|
# ### Properties
|
|
#
|
|
# - **Preserves marginal distribution**: Same histogram as original
|
|
# - **Destroys autocorrelation**: Each draw is independent
|
|
# - **Fast and simple**: No tuning parameters
|
|
|
|
|
|
# %%
|
|
def iid_bootstrap(
|
|
data: np.ndarray,
|
|
n_samples: int,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate IID bootstrap sample.
|
|
|
|
Parameters
|
|
----------
|
|
data : np.ndarray
|
|
Original data
|
|
n_samples : int
|
|
Length of bootstrap sample
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Bootstrap sample
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
indices = rng.choice(len(data), size=n_samples, replace=True)
|
|
return data[indices]
|
|
|
|
|
|
# Generate one bootstrap sample
|
|
iid_rng = np.random.default_rng(50)
|
|
iid_sample = iid_bootstrap(spy_log_returns, len(spy_log_returns), rng=iid_rng)
|
|
|
|
print("IID Bootstrap vs Original (log-returns):")
|
|
print(f" Mean: {iid_sample.mean():.6f} vs {spy_log_returns.mean():.6f}")
|
|
print(f" Std: {iid_sample.std():.4f} vs {spy_log_returns.std():.4f}")
|
|
print(f" Skew: {skew(iid_sample):.4f} vs {skew(spy_log_returns):.4f}")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: IID Bootstrap
|
|
|
|
# %%
|
|
bs = IIDBootstrap(spy_log_returns, seed=50)
|
|
means = [data[0].mean() for data, _ in bs.bootstrap(100)]
|
|
print(f"arch IIDBootstrap (100 samples): mean of means = {np.mean(means):.6f}")
|
|
|
|
# %% [markdown]
|
|
# ## 3.2 Block Bootstrap
|
|
#
|
|
# Resample **contiguous blocks** of fixed length to preserve local dependence.
|
|
#
|
|
# ### Algorithm (Moving Block Bootstrap)
|
|
#
|
|
# ```
|
|
# Choose block length b
|
|
# For each bootstrap sample of length T:
|
|
# While sample length < T:
|
|
# Draw start index i uniformly from {1, ..., N-b+1}
|
|
# Append block (r_i, r_{i+1}, ..., r_{i+b-1})
|
|
# Trim to length T
|
|
# ```
|
|
#
|
|
# ### Block Length Selection
|
|
#
|
|
# - Rule of thumb: $b \approx T^{1/3}$
|
|
# - Financial: ~22 days (one month) is common
|
|
# - Optimal: Cross-validation on out-of-sample statistics
|
|
|
|
|
|
# %%
|
|
def block_bootstrap(
|
|
data: np.ndarray,
|
|
block_size: int,
|
|
n_samples: int,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate moving block bootstrap sample.
|
|
|
|
Parameters
|
|
----------
|
|
data : np.ndarray
|
|
Original data
|
|
block_size : int
|
|
Fixed block length
|
|
n_samples : int
|
|
Length of bootstrap sample
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Bootstrap sample
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
n = len(data)
|
|
result = []
|
|
|
|
while len(result) < n_samples:
|
|
start = rng.integers(0, n - block_size + 1)
|
|
block = data[start : start + block_size]
|
|
result.extend(block)
|
|
|
|
return np.array(result[:n_samples])
|
|
|
|
|
|
# Generate block bootstrap sample (22-day blocks)
|
|
block_size = 22
|
|
block_rng = np.random.default_rng(51)
|
|
block_sample = block_bootstrap(spy_log_returns, block_size, len(spy_log_returns), rng=block_rng)
|
|
|
|
print(f"Block Bootstrap (block_size={block_size}) vs Original:")
|
|
print(f" Mean: {block_sample.mean():.6f} vs {spy_log_returns.mean():.6f}")
|
|
print(f" Std: {block_sample.std():.4f} vs {spy_log_returns.std():.4f}")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: Block Bootstrap
|
|
|
|
# %%
|
|
bs = MovingBlockBootstrap(block_size, spy_log_returns, seed=51)
|
|
means = [data[0].mean() for data, _ in bs.bootstrap(100)]
|
|
print(f"arch MovingBlockBootstrap (100 samples): mean of means = {np.mean(means):.6f}")
|
|
|
|
# %% [markdown]
|
|
# ## 3.3 Stationary Bootstrap
|
|
#
|
|
# Uses **random block lengths** from a geometric distribution, eliminating
|
|
# artificial block boundaries.
|
|
#
|
|
# ### Algorithm (Politis & Romano 1994)
|
|
#
|
|
# ```
|
|
# Choose expected block length b
|
|
# For each bootstrap sample of length T:
|
|
# Set t = 0
|
|
# While t < T:
|
|
# Draw start index i uniformly from {1, ..., N}
|
|
# Draw block length L from Geometric(1/b)
|
|
# Append (r_i, r_{i+1}, ..., r_{i+L-1}) with wrap-around
|
|
# t = t + L
|
|
# Trim to length T
|
|
# ```
|
|
#
|
|
# ### Why "Stationary"?
|
|
#
|
|
# With random block lengths, the bootstrap distribution is **stationary** -
|
|
# each position in the sample has the same marginal distribution.
|
|
|
|
|
|
# %%
|
|
def stationary_bootstrap(
|
|
data: np.ndarray,
|
|
expected_block_size: float,
|
|
n_samples: int,
|
|
rng: np.random.Generator | None = None,
|
|
) -> np.ndarray:
|
|
"""
|
|
Generate stationary bootstrap sample.
|
|
|
|
Parameters
|
|
----------
|
|
data : np.ndarray
|
|
Original data
|
|
expected_block_size : float
|
|
Expected block length (geometric distribution parameter)
|
|
n_samples : int
|
|
Length of bootstrap sample
|
|
rng : np.random.Generator, optional
|
|
Random number generator
|
|
|
|
Returns
|
|
-------
|
|
np.ndarray
|
|
Bootstrap sample
|
|
"""
|
|
if rng is None:
|
|
rng = np.random.default_rng()
|
|
|
|
n = len(data)
|
|
p = 1.0 / expected_block_size # Probability of ending block
|
|
result = []
|
|
|
|
while len(result) < n_samples:
|
|
pos = rng.integers(0, n)
|
|
|
|
while len(result) < n_samples:
|
|
result.append(data[pos])
|
|
pos = (pos + 1) % n # Wrap around
|
|
|
|
if rng.random() < p:
|
|
break
|
|
|
|
return np.array(result[:n_samples])
|
|
|
|
|
|
# Generate stationary bootstrap sample
|
|
stat_rng = np.random.default_rng(52)
|
|
stat_sample = stationary_bootstrap(spy_log_returns, block_size, len(spy_log_returns), rng=stat_rng)
|
|
|
|
print(f"Stationary Bootstrap (expected block={block_size}) vs Original:")
|
|
print(f" Mean: {stat_sample.mean():.6f} vs {spy_log_returns.mean():.6f}")
|
|
print(f" Std: {stat_sample.std():.4f} vs {spy_log_returns.std():.4f}")
|
|
|
|
# %% [markdown]
|
|
# ### Library Usage: Stationary Bootstrap
|
|
|
|
# %%
|
|
bs = StationaryBootstrap(block_size, spy_log_returns, seed=52)
|
|
means = [data[0].mean() for data, _ in bs.bootstrap(100)]
|
|
print(f"arch StationaryBootstrap (100 samples): mean of means = {np.mean(means):.6f}")
|
|
|
|
# %% [markdown]
|
|
# ## Bootstrap Method Comparison
|
|
|
|
# %%
|
|
# Collect bootstrap samples
|
|
bootstrap_samples = {
|
|
"Original": spy_log_returns,
|
|
"IID": iid_sample,
|
|
"Block": block_sample,
|
|
"Stationary": stat_sample,
|
|
}
|
|
|
|
# Compare moments
|
|
bootstrap_stats = []
|
|
for name, sample in bootstrap_samples.items():
|
|
bootstrap_stats.append(
|
|
{
|
|
"method": name,
|
|
"mean": sample.mean(),
|
|
"std": sample.std(),
|
|
"skew": skew(sample),
|
|
"excess_kurtosis": kurtosis(sample, fisher=True, bias=False),
|
|
}
|
|
)
|
|
|
|
pl.DataFrame(bootstrap_stats)
|
|
|
|
# %% [markdown]
|
|
# ## Autocorrelation Preservation
|
|
#
|
|
# The key difference between bootstrap methods is how they handle **temporal
|
|
# dependence**. We measure this via autocorrelation of **squared returns**
|
|
# (signature of volatility clustering).
|
|
|
|
# %%
|
|
# Compute ACF for squared returns
|
|
n_lags = 20
|
|
fig = go.Figure()
|
|
|
|
bootstrap_colors = [COLORS["blue"], COLORS["amber"], COLORS["copper"], COLORS["neutral"]]
|
|
|
|
for idx, (name, sample) in enumerate(bootstrap_samples.items()):
|
|
squared = sample**2
|
|
acf_values = acf(squared, nlags=n_lags, fft=True)
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=list(range(n_lags + 1)),
|
|
y=acf_values,
|
|
mode="lines+markers",
|
|
name=name,
|
|
line=dict(color=bootstrap_colors[idx]),
|
|
)
|
|
)
|
|
|
|
fig.update_layout(
|
|
title="ACF of Squared Returns: Bootstrap Method Comparison",
|
|
xaxis_title="Lag (days)",
|
|
yaxis_title="Autocorrelation",
|
|
template="plotly_white",
|
|
)
|
|
fig.show()
|
|
|
|
# %% [markdown]
|
|
# ### Bootstrap Key Takeaways
|
|
#
|
|
# 1. **IID Bootstrap**: Simple and fast, but **destroys** temporal dependence
|
|
# 2. **Block Bootstrap**: Preserves autocorrelation **within** blocks, has boundary artifacts
|
|
# 3. **Stationary Bootstrap**: Random blocks = **smoother** preservation of dependence
|
|
#
|
|
# For financial returns with volatility clustering, prefer **Block** or
|
|
# **Stationary** bootstrap. The ACF of squared returns shows preservation quality.
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
# # Part 4: Model Comparison Summary
|
|
#
|
|
# ## What Each Method Captures
|
|
#
|
|
# | Method | Fat Tails | Volatility Clustering | Beyond History |
|
|
# |--------|----------|----------------------|----------------|
|
|
# | GBM | No | No | Yes |
|
|
# | Jump-Diffusion | Yes | No | Yes |
|
|
# | Mean-Reversion | No | Negative autocorr | Yes |
|
|
# | Heston | Yes | Yes | Yes |
|
|
# | GARCH | Yes | Yes | Yes |
|
|
# | IID Bootstrap | Yes | No | No |
|
|
# | Block Bootstrap | Yes | Partial | No |
|
|
# | Stationary Bootstrap | Yes | Yes | No |
|
|
#
|
|
# ## Key Takeaways
|
|
#
|
|
# 1. **Parametric models** (GBM, Jump-Diffusion, Heston, GARCH) can generate scenarios
|
|
# beyond historical experience but require choosing or calibrating parameters
|
|
# 2. **Bootstrap methods** (IID, Block, Stationary) preserve the empirical distribution
|
|
# exactly but cannot produce extremes not seen in the original data
|
|
# 3. **No classical model captures all stylized facts**: Heston and GARCH come closest
|
|
# with fat tails and volatility clustering, but miss higher-order dependencies
|
|
# 4. **Drift compensation** (Jump-Diffusion) and **full truncation** (Heston) are
|
|
# critical implementation details that affect simulation correctness
|
|
# 5. **GARCH calibration** via MLE bridges the gap between assumed and observed dynamics,
|
|
# producing more realistic volatility paths than fixed-parameter models
|
|
#
|
|
# **Next**: See [`01_timegan`](01_timegan.ipynb) for the first learned generative model, which uses
|
|
# adversarial training to capture temporal dynamics that classical models miss.
|
|
#
|
|
# **Book**: Chapter 5, Section 5.2 covers the generative model taxonomy and explains
|
|
# why learned models complement (rather than replace) classical simulation.
|