The Problem With Geometric Brownian Motion
The standard textbook model for synthetic price data is GBM:
P(t+1) = P(t) * exp((mu - sigma²/2)*dt + sigma*sqrt(dt)*Z)
It's mathematically convenient: log-returns are normally distributed, paths never go negative, but it produces data that is useless for pattern recognition testing. Real markets exhibit regime switching (alternating chop and trend), time-of-day volume structure, session gaps, liquidity-driven wick asymmetry, and volatility clustering. GBM gives you none of that. Every candle is identically distributed regardless of context.
We needed a generator that produces OHLCV data a pattern scanner can actually operate on: data where Fair Value Gaps form and fill, where impulse moves create displacement, where consolidation ranges develop and break, where volume spikes cluster at session open and close. The statistical fingerprint of the output needed to match real intraday futures and equities, not for data-replay purposes, but so that strategies tested on synthetic data transfer meaningfully to live execution.
So we built a multi-engine pipeline that composes seven independent generation stages into a single vectorized pass.
Architecture: Seven Engines, One Orchestrator
The generator runs as a pipeline. Each engine is a pure function: it takes a CharacterSpec (a composable parameter bundle) plus upstream arrays, and returns a new array. No shared mutable state. All heavy math runs on GPU arrays via CuPy with automatic NumPy fallback.
orchestrator.generate_v2(n, spec, seed)
├─> regime_engine(spec, n, session_len) → regime_labels[n]
├─> drift_engine(spec, regime_labels, xp) → drift[n]
├─> volatility_engine(spec, regime_labels, xp) → vol[n]
├─> gap_engine(spec, regime_labels, bounds, xp) → offsets[n]
├─> [integration step] → prices[n+1]
├─> wick_engine(spec, regime_labels, drift, vol, xp) → upper[n], lower[n]
├─> event_engine(spec, regime_labels, drift, O, H, L, C, xp) → modified OHLC
└─> volume_engine(spec, regime_labels, idx, range, xp) → volume[n]
The orchestrator wires outputs into inputs and handles the price integration step between drift/volatility generation and wick/event application. Total wall-clock for 500K candles on an RTX 4070: under 500ms. CPU fallback (NumPy only): under 10 seconds for the same workload.
The Regime Engine: A Markov Chain Over Market States
Real intraday price action alternates between distinct behavioral modes. A chop period has small bodies, mixed colors, overlapping candles. A trend period has directional persistence, larger bodies, same-color runs. An impulse burst is a short (2-5 candle) explosion of displacement: a breakout, a news reaction, a stop cascade.
We model this as a discrete-state Markov chain with geometric duration sampling. The states are:
REGIME_IDS = {'chop': 0, 'trend_up': 1, 'trend_down': 2, 'impulse': 3, 'gap_hold': 4}
Each character defines a transition matrix and per-state mean durations:
RegimeSpec(
states=('chop', 'trend_up', 'trend_down'),
mean_duration={'chop': 25, 'trend_up': 20, 'trend_down': 20, 'impulse': 3},
transition={
('chop', 'trend_up'): 0.4, ('chop', 'trend_down'): 0.4,
('chop', 'impulse'): 0.2,
('trend_up', 'chop'): 0.7, ('trend_up', 'impulse'): 0.3,
('trend_down', 'chop'): 0.7, ('trend_down', 'impulse'): 0.3,
('impulse', 'chop'): 1.0,
}
)
The engine pre-samples enough transitions to cover n candles (geometric draws from 1/mean_duration), then expands to per-candle labels via np.repeat. This is O(n/mean_duration) transitions. For 500K candles with 20-candle average runs, that's ~25K samples on CPU, then a single repeat + searchsorted for the full expansion. The Markov walk stays on CPU (it's inherently sequential), but it's 40x smaller than n so the cost is negligible.
Reference: Hamilton, J.D. (1989). "A New Approach to the Economic Analysis of Nonstationary Time Series and the Business Cycle." Econometrica, 57(2), 357-384.
The regime-switching approach to financial time series was formalized by Hamilton's seminal work on Markov-switching models. Our implementation is a simplified variant: rather than estimating transition probabilities from data, we parameterize them directly per instrument character.
Drift and Volatility: Per-Regime Lookup Tables
Once regime labels exist as a GPU integer array, drift and volatility computation becomes a table lookup followed by element-wise noise:
def drift_engine(spec, regime_labels, xp):
regime = xp.asarray(regime_labels, dtype=xp.int32)
mean_table = xp.asarray([0.0, d.trend_magnitude, -d.trend_magnitude, 0.0, 0.0])
sigma_table = xp.asarray([d.chop_sigma, d.trend_sigma, d.trend_sigma, 0.0, 0.0])
mean_per = mean_table[regime]
sigma_per = sigma_table[regime]
noise = xp.random.normal(0, 1, n).astype(xp.float32)
drift = mean_per + noise * sigma_per
Advanced integer indexing (mean_table[regime]) broadcasts a 5-element constant table into an n-element array in a single GPU kernel. No loops. No conditionals per element.
Counter-trend noise prevents trends from being unnaturally monotonic:
counter_mask = trend_mask & (xp.random.rand(n) < 0.35)
flip_scale = xp.random.uniform(0.3, 0.8, n)
drift = xp.where(counter_mask, -drift * flip_scale, drift)
35% of candles within a trend period get their drift partially reversed. This produces the realistic pullback bars you see in any real trend: 3 green, 1 red, 2 green, etc., without scripting specific patterns.
Geometric Integration With Mean-Reverting Tether
Price integration happens in log space. The innovations from the drift engine are interpreted as fractional price changes:
log_returns = innovations / initial_price
log_returns = clip(log_returns, -0.05, 0.05)
cum_log = zeros(n)
running = 0.0
tether = 0.0005
for i in range(n):
running += log_returns[i] + gap_log[i] - tether * running
cum_log[i] = running
prices = initial_price * exp(cum_log)
The geometric (multiplicative) model is critical. A $2 move matters more at $10 than at $10,000. In additive space, a long bear run can drive prices negative, requiring either artificial clamping (which distorts the tail behavior) or constant monitoring. In log space, prices are bounded above zero by construction: it takes an infinite number of -50% steps to reach zero.
The mean-reverting tether (-0.0005 * running) is a weak pull toward the starting price that prevents runaway drift without hard boundaries. The strength (0.05% per candle of accumulated deviation) is calibrated so that over 500K candles, the price stays within a plausible range for the instrument while still allowing multi-hundred-point trends within any given session.
Reference: Diebold, F.X. & Inoue, A. (2001). "Long Memory and Regime Switching." Journal of Econometrics, 105(1), 131-159.
The combination of regime-driven drift with mean reversion mirrors the empirical observation that financial returns exhibit both short-term momentum (within regimes) and long-term mean reversion (across regimes).
Wick Generation: Asymmetric, Regime-Aware
Wicks are generated independently of the body (open-to-close move) and scaled by both regime volatility and drift direction:
def wick_engine(spec, regime_labels, drift, vol, xp):
ratio_table = xp.asarray([w.chop_ratio, w.trend_ratio, w.trend_ratio,
w.impulse_ratio, w.gap_hold_ratio])
ratio = ratio_table[regime]
base = vol * ratio
upper = abs(N(0,1)) * base
lower = abs(N(0,1)) * base
# Asymmetry: bias wicks AGAINST drift direction
drift_sign = sign(drift)
lower = lower * (1 + asymmetry * drift_sign)
upper = upper * (1 - asymmetry * drift_sign)
The asymmetry parameter (0–1) creates longer wicks on the side opposite to the candle's direction. This is the liquidity-grab signature: bullish candles develop longer lower wicks (probing into sell stops before reversing up), bearish candles develop longer upper wicks. The effect is subtle (default asymmetry 0.2–0.3) but produces the wick distribution shapes that pattern recognition systems expect.
Chop regimes get a 2x wick ratio (long wicks relative to body: dojis and spinning tops), while impulse regimes get 0.3x (strong bodies, minimal wicks: the displacement candles that form FVGs).
The Event Engine: Liquidity Sweeps as Post-Hoc Modification
Occasional extreme wicks, e.g. the "stop hunt" or "liquidity sweep" pattern, are handled by a post-hoc event engine rather than baked into the base wick generation:
def event_engine(spec, regime_labels, drift, opens, highs, lows, closes, xp):
stab_mask = xp.random.rand(n) < e.wick_stab_prob # ~2-4% of candles
stab_sign = xp.where(drift >= 0, -1.0, 1.0) # against drift
stab_size = abs(N(0,1)) * e.wick_stab_magnitude # 2-3x candle range
stab_size = minimum(stab_size, 3.0) # safety cap
lo_adj = where(stab_mask & (stab_sign < 0),
lows - stab_size * (highs - lows), lows)
hi_adj = where(stab_mask & (stab_sign > 0),
highs + stab_size * (highs - lows), highs)
This produces the long-wick candles that real markets create when price briefly spikes through a cluster of stop orders, triggers fills, then reverses. The probability (2-4%) and magnitude (2-3x candle range, capped at 3x) are calibrated per character, f.e. TSLA and GME get more frequent stabs than ES.
The post-hoc design is deliberate: it's simpler to generate "normal" candles first and then stretch occasional wicks than to embed rare-event logic into the base distribution.
Volume: Time-of-Day Curve Plus Stochastic Spikes
Real intraday volume follows a U-shaped curve: high at open, low at midday, rising into close. We model this as a piecewise multiplier on the base volume:
session_pos = (idx % candles_per_session) / candles_per_session
tod = where(session_pos < 0.2, tod_open_mult, 1.0) # 2.2x at open
tod = where((0.2 <= session_pos) & (session_pos < 0.6), tod_midday_mult, tod) # 0.8x midday
tod = where(session_pos >= 0.8, tod_close_mult, tod) # 1.3x at close
Volume spikes, the burst of activity when a level breaks or news hits, are modeled as independent Bernoulli events with exponential decay:
spike_starts = random.rand(n) < spike_prob # 10-15% per candle
spike_mags = Uniform(spike_min, spike_max) # 1.5-2.5x multiplier
volume = base * tod * spike_mult * range_mult * noise
The range_mult factor ties volume to candle size: wider-range candles (breakouts, impulses) naturally attract more volume. This produces the volume-confirms-breakout signal that traders rely on.
CharacterSpec: Parametric Instrument Personalities
All seven engines read from a single CharacterSpec dataclass. A character is a named bundle of sub-specs that fully describes an instrument's statistical personality:
@dataclass
class CharacterSpec:
name: str
price_range: tuple # (lo, hi) starting price
tick: float # minimum increment
regime: RegimeSpec # Markov states + transitions
drift: DriftSpec # per-regime magnitudes
volatility: VolatilitySpec # per-regime noise
wick: WickSpec # per-regime ratios + asymmetry
volume: VolumeSpec # TOD curve + spikes
gap: GapSpec # session/intraday gaps
event: EventSpec # wick stabs
Characters are pure data. Creating a new instrument personality requires zero code changes, just instantiate a new CharacterSpec with different parameters. We ship six built-in characters calibrated against real market statistics:
| Character | Personality | Key Signature |
|---|---|---|
| ES | Calm index futures | Long chop periods, clean trends, tight wicks |
| NQ | Active Nasdaq futures | Faster transitions, more impulse bursts, 3x drift |
| SPY | Quiet equity baseline | Narrow range, smooth, rare impulses |
| TSLA | Gappy stock | Overnight gaps (25%), impulsive, wide wick stabs |
| GME | High-beta retail | Short regimes, 40% impulse entry from chop, very gappy |
| CL | Choppy commodity | Long chop (30-candle avg), wide wicks, low volume |
The parametric differences produce qualitatively distinct chart experiences. NQ's transition matrix sends 45% of trend exits into impulse (creating the "trend accelerates into a stop cascade" pattern), while ES sends only 30%. GME's chop duration of 10 candles means the instrument barely consolidates before the next directional move, matching the meme-stock experience.
Session Structure: Temporal Realism as a Post-Processor
The raw output of generate_v2() is an untimed array of candles. A separate post-processor (apply_session_structure) maps these onto realistic weekly market hours:
Sessions: overnight (18:00-03:00) → london (03:00-06:00) →
pre_market (06:00-09:30) → RTH (09:30-16:00) →
post_market (16:00-18:00)
Off-hours bars are dampened rather than generated separately. The body of each non-RTH candle is scaled toward its open by an oh_vol_mult factor (default 0.35), and volume is reduced proportionally. This preserves the structural shape of the underlying data while producing the quieter, choppier price action characteristic of extended hours.
Session-open gaps are applied as multiplicative jumps at RTH boundaries:
if is_rth_open and random() < gap_prob:
gap_mult = 1.0 + direction * Uniform(min_size, max_size)
cumulative_multiplier *= gap_mult
The cumulative multiplier means gaps compound across the series, a Monday gap followed by a Tuesday gap produces realistic multi-day price displacement. Monday gets a 1.8x probability boost (weekend news accumulation).
Reference: Andersen, T.G. & Bollerslev, T. (1997). "Intraday Periodicity and Volatility Persistence in Financial Markets." Journal of Empirical Finance, 4(2-3), 115-158.
The U-shaped intraday volume and volatility patterns we model are well-documented in the literature. Our implementation uses a simplified piecewise approximation rather than a flexible Fourier form, trading precision for computational efficiency at scale.
GPU Vectorization Strategy
The design principle is: no Python loops over n. Every operation is an array operation dispatched as a single GPU kernel. The exception is the regime engine's Markov walk (inherently sequential), but since average regime duration is 15-30 candles, the walk length is O(n/20), roughly 25K iterations for 500K candles. This runs on CPU in under 10ms and is irrelevant to total runtime.
Key vectorization techniques used throughout:
- Integer-indexed lookup tables: Regime-dependent constants stored as 5-element arrays;
table[regime_array]broadcasts in one kernel - Masked assignment via
xp.where:Conditional logic without branching:where(mask, value_if_true, value_if_false) - Bulk random draws: All stochastic elements draw n samples at once (
xp.random.normal(0, 1, n)) rather than per-candle - In-place accumulation via
cumsum: Price integration in log space uses a single cumulative sum (with tether applied via a scalar loop only when necessary)
The CuPy/NumPy abstraction layer (_xp()) returns whichever backend is available:
def _xp():
return cp if _GPU_AVAILABLE else np
All downstream code is written against the common array API. No code paths diverge based on backend.
Seed Determinism
Same seed + same character + same backend = identical output. This is essential for reproducible Monte Carlo: when a particular session produces an interesting result (a high-scoring match that loses, a phantom trade that should have been taken), the user can reload that exact session for inspection.
def _seed_all(seed):
np.random.seed(int(seed))
if _GPU_AVAILABLE:
cp.random.seed(int(seed))
We accept that CuPy and NumPy produce different sequences for the same seed, the guarantee is per-backend determinism, not cross-backend identity. Since the Monte Carlo uses fresh random seeds per session anyway, this is a non-issue in practice.
Where This Sits in the Monte Carlo Pipeline
The generator is the foundation of the Monte Carlo screener. Each MC iteration:
- Fresh seed →
generate_v2(n, spec, seed)→ 10,000+ candles - Session structure →
apply_session_structure(df, gap_cfg)→ timestamped OHLCV - Pattern scan →
scan_for_pattern(candles, sketch)→ scored matches - Trade execution →
strategy.generate_trades(session_df)→ entry/stop/target - Equity simulation →
simulate_equity(trades, capital, risk_pct)→ P/L curve
Steps 1-2 produce a unique synthetic market day. Steps 3-4 determine whether and where the user's drawn pattern appears and generates trades. Step 5 applies position sizing and tracks the equity curve with early stopping at a minimum equity threshold (ruin detection).
A typical MC run (5 batches × 200 sessions = 1,000 sessions) generates approximately 10 million candles total. On GPU, the generation step accounts for less than 15% of total runtime — the pattern scanner dominates.
What the Generator Does NOT Model
Transparency about limitations:
- Microstructure: No bid/ask spread, no order book depth, no tick-by-tick execution simulation at the session-generation level. (A separate tick path generator exists for the live simulator, but the MC screener operates on 1-minute OHLCV.)
- Cross-asset correlation: Each session is independent. No modeling of correlated moves across instruments.
- Calendar effects: Beyond Monday gap boost, no modeling of FOMC days, triple witching, earnings releases.
- Adaptive parameters: Character specs are static within a generation. No within-session parameter evolution beyond the Markov regime switches.
These are deliberate scope limits. The generator's job is to produce data with the right statistical shape for pattern recognition testing, not to replicate any specific market microstructure. A Monte Carlo result that says "this pattern has 47% win rate across 1,000 sessions on NQ-like data" is a statement about the pattern's robustness to varying market conditions, not a prediction about next Tuesday's NQ session.
Performance
Benchmarked on RTX 4070 (12GB VRAM) with CuPy 12.x:
| Candles | GPU (CuPy) | CPU (NumPy) |
|---|---|---|
| 10,000 | 12ms | 45ms |
| 100,000 | 48ms | 380ms |
| 500,000 | 210ms | 4.2s |
| 1,000,000 | 420ms | 9.1s |
Memory footprint scales linearly: ~10 float32 arrays × n × 4 bytes. 1M candles = ~40MB GPU memory, well within budget for any modern card.
The CPU fallback is fast enough for interactive use (a single 10K-candle session generates in 45ms), but the GPU path is essential for Monte Carlo runs where the generator is called hundreds of times in sequence.
Further Reading
For the theoretical foundations of regime-switching models in finance:
Reference: Ang, A. & Timmermann, A. (2012). "Regime Changes and Financial Markets." Annual Review of Financial Economics, 4, 313-337.
For empirical calibration of intraday volatility and volume patterns:
Reference: Wood, R.A., McInish, T.H., & Ord, J.K. (1985). "An Investigation of Transactions Data for NYSE Stocks." The Journal of Finance, 40(3), 723-739.
For GPU-accelerated Monte Carlo methods in computational finance:
Reference: Giles, M.B. (2008). "Multilevel Monte Carlo path simulation." Operations Research, 56(3), 607-617. Link