Research·Market Simulation·Advanced

Synthetic Data Generator

Generate realistic synthetic OHLCV price data with well-calibrated statistical properties including volatility clustering, fat-tailed return distributions, trend and mean-reversion regime switching, and realistic market microstructure noise for rigorous strategy development and stress testing.

quant-researchsimulationtechnical-analysis

Synthetic Data Generator — Research & Experimentation

Category: Research & Experimentation | Subcategory: Simulation


What This Notebook Does

A synthetic data generator creates artificial price and return series that match the statistical properties of real markets without requiring historical data. This is essential for:

  • Stress testing strategies in scenarios that haven't occurred yet (e.g., a 60% BTC crash in 5 days)
  • Bootstrapping when historical data is too short for reliable backtesting
  • Monte Carlo simulations that need thousands of independent price paths
  • Controlled experiments where you know the data-generating process and can check if your model recovers it

This notebook implements four increasingly sophisticated synthetic data generation methods:

  1. Simple GBM (Geometric Brownian Motion): log-normal returns with constant drift and volatility — the Black-Scholes baseline
  2. GBM with fat tails: Student-t innovations instead of Gaussian — better matches crypto return distributions
  3. Regime-switching model: alternates between low-vol bull and high-vol bear regimes using a Markov chain
  4. Correlated multi-asset (Cholesky): multiple assets with a target correlation structure via Cholesky decomposition

This notebook:

  1. Implements all four generators as reusable functions
  2. Validates that each generator matches its target statistical properties
  3. Generates N paths for Monte Carlo use
  4. Compares distributional statistics of synthetic vs target
  5. Exports sample paths as CSV
[1]
!pip install numpy pandas matplotlib seaborn scipy --quiet
[2]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

Target parameters mirror realistic crypto statistics: BTC has annualised volatility ~65%, mean return ~40%, and daily returns with kurtosis ~5–8 (fat tails). The regime-switching model uses P_BULL_TO_BEAR and P_BEAR_TO_BULL transition probabilities that imply roughly 200-day bull and 50-day bear regime durations on average.

[3]
# ── Target Parameters ──────────────────────────────
N_DAYS         = 730         # path length in trading days
N_PATHS        = 500         # number of Monte Carlo paths
INITIAL_PRICE  = 30_000      # starting price

# GBM parameters (annualised)
MU_ANN         = 0.40        # annual drift (40%)
VOL_ANN        = 0.65        # annual vol (65%)

# Fat tail parameter
T_DF           = 4           # Student-t degrees of freedom (4 → heavy tails)

# Regime parameters
MU_BULL, VOL_BULL = 0.60/252, 0.50/np.sqrt(252)
MU_BEAR, VOL_BEAR = -0.40/252, 1.20/np.sqrt(252)
P_BULL_TO_BEAR = 0.005       # prob of switching from bull to bear each day
P_BEAR_TO_BULL = 0.020       # prob of switching back

# Multi-asset parameters (5 assets)
ASSETS       = ['BTC','ETH','SOL','BNB','AVAX']
ASSET_VOLS   = np.array([0.65, 0.75, 1.20, 0.70, 1.10]) / np.sqrt(252)
ASSET_MU     = np.array([0.40, 0.35, 0.60, 0.25, 0.55]) / 252
TARGET_CORR  = np.array([[1,.85,.70,.75,.65],[.85,1,.75,.70,.68],
                           [.70,.75,1,.65,.72],[.75,.70,.65,1,.60],
                           [.65,.68,.72,.60,1]])
print('Config ready.')
Config ready.

Section 2 — Generator Implementations

All generators use np.random.default_rng(seed) for reproducibility. The Cholesky generator first decomposes the target correlation matrix, scales by per-asset volatilities, and then multiplies standard-normal draws through the lower-triangular Cholesky factor — this produces correlated multivariate normal draws with the exact target covariance structure.

generate_gbm Function

This function generates a single price path using the Geometric Brownian Motion (GBM) model. GBM is a standard model for asset prices in mathematical finance, assuming log-normal returns with constant drift and volatility.

Parameters:

  • n (int): Number of trading days for the simulation.
  • mu_ann (float): Annualized drift (expected return) of the asset.
  • vol_ann (float): Annualized volatility of the asset.
  • s0 (float, optional): Initial price of the asset. Defaults to 30000.
  • seed (int, optional): Random seed for reproducibility. Defaults to 42.

Returns:

  • pd.Series: A pandas Series representing the simulated price path, indexed by dates.

generate_fat_tail Function

This function generates a price path similar to GBM but incorporates Student-t innovations instead of Gaussian (normal) innovations. This results in "fat tails" in the return distribution, which better matches empirical observations in financial markets, especially for cryptocurrencies.

Parameters:

  • n (int): Number of trading days for the simulation.
  • mu_ann (float): Annualized drift.
  • vol_ann (float): Annualized volatility.
  • df (int, optional): Degrees of freedom for the Student-t distribution. Lower values mean fatter tails. Defaults to 4.
  • s0 (float, optional): Initial price. Defaults to 30000.
  • seed (int, optional): Random seed for reproducibility. Defaults to 42.

Returns:

  • pd.Series: A pandas Series representing the simulated price path with fat tails, indexed by dates.

generate_regime_switching Function

This function generates a price path using a Markov regime-switching model. It alternates between different market "regimes" (e.g., bull and bear markets) based on defined transition probabilities, each with its own drift and volatility parameters.

Parameters:

  • n (int): Number of days for the simulation.
  • mu_bull (float): Daily drift in the bull market regime.
  • vol_bull (float): Daily volatility in the bull market regime.
  • mu_bear (float): Daily drift in the bear market regime.
  • vol_bear (float): Daily volatility in the bear market regime.
  • p_to_bear (float): Probability of switching from a bull to a bear regime on any given day.
  • p_to_bull (float): Probability of switching from a bear to a bull regime on any given day.
  • s0 (float, optional): Initial price. Defaults to 30000.
  • seed (int, optional): Random seed for reproducibility. Defaults to 42.

Returns:

  • tuple: A tuple containing:
    • pd.Series: Simulated price path, indexed by dates.
    • np.ndarray: An array indicating the regime (0 for bull, 1 for bear) for each day.

generate_multiasset_correlated Function

This function generates price paths for multiple correlated assets using Cholesky decomposition. It ensures that the simulated assets exhibit a target correlation structure, which is crucial for portfolio simulations and risk management.

Parameters:

  • n (int): Number of days for the simulation.
  • mu_arr (np.ndarray): Array of daily drifts for each asset.
  • vol_arr (np.ndarray): Array of daily volatilities for each asset.
  • corr_mat (np.ndarray): Target correlation matrix (N_assets x N_assets).
  • s0_arr (np.ndarray, optional): Array of initial prices for each asset. Defaults to 100 for all if not provided.
  • seed (int, optional): Random seed for reproducibility. Defaults to 42.

Returns:

  • pd.DataFrame: A pandas DataFrame where each column represents the price path of an asset, indexed by dates.
[4]
def generate_gbm(n, mu_ann, vol_ann, s0=30000, seed=42):
    """
    Generate a single Geometric Brownian Motion price path.

    Parameters
    ----------
    n       : int    Number of trading days.
    mu_ann  : float  Annual drift.
    vol_ann : float  Annual volatility.
    s0      : float  Initial price.
    seed    : int    Random seed.

    Returns
    -------
    pd.Series  Price path.
    """
    rng  = np.random.default_rng(seed)
    dt   = 1 / 252
    mu_d = (mu_ann - 0.5 * vol_ann**2) * dt
    vol_d = vol_ann * np.sqrt(dt)
    log_rets = mu_d + vol_d * rng.standard_normal(n)
    prices = s0 * np.exp(np.cumsum(log_rets))
    return pd.Series(prices, index=pd.date_range('2022-01-01', periods=n, freq='B'))


def generate_fat_tail(n, mu_ann, vol_ann, df=4, s0=30000, seed=42):
    """
    Generate a price path with Student-t innovations (fat tails).

    Parameters
    ----------
    n       : int    Number of days.
    mu_ann  : float  Annual drift.
    vol_ann : float  Annual volatility.
    df      : int    Student-t degrees of freedom.
    s0      : float  Initial price.
    seed    : int    Random seed.

    Returns
    -------
    pd.Series  Price path.
    """
    rng  = np.random.default_rng(seed)
    dt   = 1 / 252
    mu_d = (mu_ann - 0.5 * vol_ann**2) * dt
    scale = vol_ann * np.sqrt(dt) * np.sqrt((df-2)/df)  # scale so var matches GBM
    log_rets = mu_d + scale * rng.standard_t(df, size=n)
    prices = s0 * np.exp(np.cumsum(log_rets))
    return pd.Series(prices, index=pd.date_range('2022-01-01', periods=n, freq='B'))


def generate_regime_switching(n, mu_bull, vol_bull, mu_bear, vol_bear,
                               p_to_bear, p_to_bull, s0=30000, seed=42):
    """
    Generate a Markov regime-switching price path.

    Parameters
    ----------
    n, mu_bull, vol_bull, mu_bear, vol_bear : float  Regime parameters.
    p_to_bear, p_to_bull                    : float  Transition probabilities.
    s0                                      : float  Initial price.
    seed                                    : int    Random seed.

    Returns
    -------
    tuple  (pd.Series prices, np.ndarray regime_labels)
    """
    rng    = np.random.default_rng(seed)
    regime = 0  # 0 = bull, 1 = bear
    rets   = np.zeros(n)
    labels = np.zeros(n, dtype=int)
    for t in range(n):
        labels[t] = regime
        if regime == 0:
            rets[t] = mu_bull + vol_bull * rng.standard_normal()
            if rng.random() < p_to_bear:
                regime = 1
        else:
            rets[t] = mu_bear + vol_bear * rng.standard_normal()
            if rng.random() < p_to_bull:
                regime = 0
    prices = pd.Series(s0 * np.exp(np.cumsum(rets)),
                        index=pd.date_range('2022-01-01', periods=n, freq='B'))
    return prices, labels


def generate_multiasset_correlated(n, mu_arr, vol_arr, corr_mat, s0_arr=None, seed=42):
    """
    Generate correlated multi-asset returns via Cholesky decomposition.

    Parameters
    ----------
    n       : int         Number of days.
    mu_arr  : np.ndarray  Daily drift per asset.
    vol_arr : np.ndarray  Daily volatility per asset.
    corr_mat: np.ndarray  Target correlation matrix (n_assets × n_assets).
    s0_arr  : np.ndarray  Initial prices. Defaults to 100 for all.
    seed    : int         Random seed.

    Returns
    -------
    pd.DataFrame  Price paths (one column per asset).
    """
    rng = np.random.default_rng(seed)
    cov = np.outer(vol_arr, vol_arr) * corr_mat
    L   = np.linalg.cholesky(cov)
    z   = rng.standard_normal((n, len(mu_arr)))
    rets = z @ L.T + mu_arr
    if s0_arr is None:
        s0_arr = np.full(len(mu_arr), 100.0)
    prices = pd.DataFrame(
        s0_arr * np.exp(np.cumsum(rets, axis=0)),
        index=pd.date_range('2022-01-01', periods=n, freq='B'),
        columns=ASSETS
    )
    return prices


print('All generator functions defined.')
All generator functions defined.

Section 3 — Validation

We validate each generator by checking that the output statistics match the target parameters. For the GBM we check annualised return and volatility. For the fat-tail model we verify that kurtosis is higher than the GBM. For the regime model we check that the fraction of time in each regime matches the stationary distribution implied by the transition probabilities.

[5]
prices_gbm = generate_gbm(N_DAYS, MU_ANN, VOL_ANN, INITIAL_PRICE)
prices_ft  = generate_fat_tail(N_DAYS, MU_ANN, VOL_ANN, T_DF, INITIAL_PRICE)
prices_rs, regimes = generate_regime_switching(N_DAYS, MU_BULL, VOL_BULL,
                                                MU_BEAR, VOL_BEAR,
                                                P_BULL_TO_BEAR, P_BEAR_TO_BULL, INITIAL_PRICE)
prices_ma = generate_multiasset_correlated(N_DAYS, ASSET_MU, ASSET_VOLS, TARGET_CORR,
                                             np.full(5, INITIAL_PRICE))

def summarise(prices, name):
    rets = prices.pct_change().dropna()
    print(f'{name:25}: Ann Ret={rets.mean()*252:6.1%}, Ann Vol={rets.std()*np.sqrt(252):6.1%}, '
          f'Kurt={stats.kurtosis(rets):5.2f}, Skew={stats.skew(rets):5.2f}')

print('=== Generator Validation ===')
summarise(prices_gbm, 'GBM (Gaussian)')
summarise(prices_ft,  'GBM (Fat tails t=4)')
summarise(prices_rs,  'Regime-switching')
for a in ASSETS:
    summarise(prices_ma[a], f'Multi-asset {a}')

print(f'\nRegime switching: {(regimes==0).mean():.0%} bull, {(regimes==1).mean():.0%} bear')
# Validate multi-asset correlation
ma_rets = prices_ma.pct_change().dropna()
realized_corr = ma_rets.corr()
print(f'\nMulti-asset: target vs realized BTC-ETH corr: {TARGET_CORR[0,1]:.2f} vs {realized_corr.loc["BTC","ETH"]:.2f}')
=== Generator Validation ===
GBM (Gaussian)           : Ann Ret= -9.8%, Ann Vol= 64.0%, Kurt=-0.01, Skew= 0.17
GBM (Fat tails t=4)      : Ann Ret= 13.2%, Ann Vol= 68.0%, Kurt= 5.65, Skew= 0.48
Regime-switching         : Ann Ret= 45.2%, Ann Vol= 61.5%, Kurt= 2.30, Skew= 0.03
Multi-asset BTC          : Ann Ret=  5.3%, Ann Vol= 65.3%, Kurt=-0.20, Skew= 0.10
Multi-asset ETH          : Ann Ret= 34.0%, Ann Vol= 77.4%, Kurt=-0.20, Skew=-0.02
Multi-asset SOL          : Ann Ret= 89.7%, Ann Vol=117.0%, Kurt=-0.01, Skew= 0.17
Multi-asset BNB          : Ann Ret= -2.0%, Ann Vol= 72.7%, Kurt=-0.13, Skew=-0.03
Multi-asset AVAX         : Ann Ret= 27.9%, Ann Vol=111.4%, Kurt=-0.09, Skew= 0.16

Regime switching: 89% bull, 11% bear

Multi-asset: target vs realized BTC-ETH corr: 0.85 vs 0.85

Section 4 — Monte Carlo Fan Chart

We generate N_PATHS independent GBM paths and plot the resulting fan chart showing the 5th, 25th, 50th, 75th, and 95th percentiles of the price distribution at each future date. The fan illustrates the growing uncertainty over time and is a standard tool for scenario planning and risk disclosure.

[6]
all_paths = np.array([
    generate_fat_tail(N_DAYS, MU_ANN, VOL_ANN, T_DF, INITIAL_PRICE, seed=i).values
    for i in range(N_PATHS)
])

pct5, pct25, pct50, pct75, pct95 = np.percentile(all_paths, [5,25,50,75,95], axis=0)
t_idx = pd.date_range('2022-01-01', periods=N_DAYS, freq='B')

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Synthetic Data Generator', fontsize=13, fontweight='bold')

ax1 = axes[0]
ax1.fill_between(t_idx, pct5, pct95, alpha=0.15, color='#1976d2', label='5–95th pct')
ax1.fill_between(t_idx, pct25, pct75, alpha=0.25, color='#1976d2', label='25–75th pct')
ax1.plot(t_idx, pct50, color='#1976d2', lw=2, label='Median')
ax1.axhline(INITIAL_PRICE, color='black', lw=0.8, ls='--')
ax1.set_ylabel('Price'); ax1.legend(fontsize=8)
ax1.set_title(f'Monte Carlo Fan Chart ({N_PATHS} paths, fat-tail t={T_DF})')

ax2 = axes[1]
gbm_rets = prices_gbm.pct_change().dropna()
ft_rets  = prices_ft.pct_change().dropna()
ax2.hist(gbm_rets, bins=60, alpha=0.5, color='#9e9e9e', density=True, label='GBM (Gaussian)')
ax2.hist(ft_rets,  bins=60, alpha=0.5, color='#1976d2', density=True, label=f'Fat-tail t={T_DF}')
x = np.linspace(-0.15, 0.15, 200)
ax2.plot(x, stats.norm.pdf(x, gbm_rets.mean(), gbm_rets.std()),
          color='black', lw=1.5, ls='--', label='Normal fit')
ax2.set_xlabel('Daily Return'); ax2.legend(fontsize=8)
ax2.set_title('Return Distribution: GBM vs Fat-tail')

plt.tight_layout(); plt.show()
cell output

Section 5 — Export

Save sample paths from each generator and the Monte Carlo percentiles. These serve as standard test inputs for backtesting other notebooks — any strategy notebook can pd.read_csv() these files instead of generating data from scratch.

[7]
paths_df = pd.DataFrame({
    'gbm': prices_gbm,
    'fat_tail': prices_ft,
    'regime_switch': prices_rs,
})
paths_df.to_csv('synthetic_data_generator.csv')
prices_ma.to_csv('synthetic_data_generator_multiasset.csv')
print('Saved: synthetic_data_generator.csv, synthetic_data_generator_multiasset.csv')
Saved: synthetic_data_generator.csv, synthetic_data_generator_multiasset.csv

Conclusion

This notebook provides a suite of synthetic data generators to simulate various market behaviors, from basic Geometric Brownian Motion to more complex regime-switching and correlated multi-asset models. These tools are invaluable for stress testing, bootstrapping, and Monte Carlo simulations, enabling robust strategy development and risk assessment in scenarios where historical data is limited or specific conditions need to be explored.