A deep dive into synthetic stock price generation — from naive coin-flip random walks, through Gaussian distributions and fat tails, to a simulated order book — and how candlestick pattern analysis reveals the gap between each model and reality.
Markets have always attracted physicists and mathematicians with a seductive promise: price is just a number that changes over time — surely we can model it? The history of quantitative finance is a graveyard of over-confident models, from the Black-Scholes assumptions of log-normal returns to VAR models that failed spectacularly in 2008. Yet building increasingly realistic synthetic price series remains a powerful way to understand what real markets actually contain.
In this post we build four progressively more sophisticated price generators, and use candlestick pattern detection to score how "market-like" each one looks.
Why Simulate at All?
Synthetic price series are useful for:
- Back-testing trading strategies without look-ahead bias
- Stress-testing risk models against tail scenarios
- Education — understanding what statistical structure real prices actually have
- Generating training data for ML models when real data is scarce
The catch is that a bad simulator will produce series with statistical fingerprints nothing like real markets. Our goal here is to understand what those fingerprints are, and whether increasingly complex models can forge them.
How the Stock Market Actually Works
Before we start generating fake prices, it helps to understand how real prices are born. Every major exchange — NYSE, NASDAQ, CME — runs a continuous double auction through a structure called a limit order book (LOB).
Orders: the two types that matter
| Type | What it says | Execution |
|---|---|---|
| Limit order | "I'll buy 100 shares, but only at $99.80 or better" | Rests in the book until matched or cancelled |
| Market order | "Buy 100 shares right now at whatever the best price is" | Executes immediately against the best resting opposite side |
The vast majority of displayed volume is limit orders. Market orders are the trigger that actually causes a trade.
The order book
Price │ Qty Side
──────────┼───────────────────────────────────────
$100.40 │ 200 ← ask (sell)
$100.30 │ 500 ← ask best ask
$100.20 │ 300 ← ask
──────────┼───────────────────────────────────────
│ SPREAD = $100.20 − $100.10
──────────┼───────────────────────────────────────
$100.10 │ 400 ← bid best bid
$100.00 │ 800 ← bid
$ 99.90 │ 1200 ← bid (buy)The spread (best ask minus best bid) is the minimum transaction cost. Market makers earn this spread by continuously providing liquidity on both sides.
Matching: how a trade happens
When a new limit buy order arrives at **100.20), the matching engine pairs it against the resting sell order. Both orders are filled at the ask price, the quantities are decremented, and the trade price — $100.20 — is recorded. That single tick update is what you see as a price move on a chart.
Price discovery
No central authority sets the price. Instead, it emerges from the aggregate opinion of everyone in the market. Buyers who are more optimistic place higher bids; sellers who are more pessimistic place lower asks. When a sufficient number of participants agree on value, their orders cross and a trade occurs.
This creates a rich feedback loop:
- Large buy orders drain the ask side → price rises → attracts more sellers
- Bad news makes holders rush to sell → orders queue at lower prices → buyers step back → spread widens → price falls
Interactive: live order book simulation
The playground below runs a continuous double auction with configurable fake traders. Each step, a batch of participants submit limit orders priced according to a Gaussian distribution around the current mid-price. A fraction of existing orders are randomly cancelled (mimicking real cancellation rates, which exceed 90% on some exchanges). The matching engine then pairs any crossing orders and records trade prices.
What to experiment with:
- High arrival rate + tight spread σ: deep book, small spread, frequent trades — resembles a liquid large-cap
- High cancel rate: thin, unstable book — price moves erratically, wide spread
- Low sigma (orders clustered near mid): very tight spread, fast matching; resembles a market maker dominating the book
- Low arrival rate + high cancel rate: illiquid market — long periods of no trades, then sudden price jumps
The interactive order book simulation — including a live heatmap that shows support and resistance zones forming in real time — lives in its own dedicated post: The Order Book: How Price is Actually Born. Head there to play with the matching engine, execute market orders, and watch a resistance band absorb buying pressure.
Act I — The Naive Random Walk
The simplest possible stock model: start at price and at each step multiply by a random factor drawn uniformly from .
For the candlestick body we use the open and close prices; for the wicks we add a tiny extra random excursion:
function generateUniformWalk(n: number): OHLC[] {
const candles: OHLC[] = []
let price = 100
for (let i = 0; i < n; i++) {
const open = price
const move = (Math.random() * 2 - 1) * 0.03 // uniform ±3 %
const close = open * (1 + move)
const hi = Math.max(open, close) * (1 + Math.random() * 0.008)
const lo = Math.min(open, close) * (1 - Math.random() * 0.008)
candles.push({ open, high: hi, low: lo, close })
price = close
}
return candles
}Hit Uniform Walk in the playground below and regenerate a few times.
Stock Price Generator & Pattern Analyser
Yellow-highlighted candles have a detected candlestick pattern. Compare pattern density across models.
No patterns detected.
Synthetic series (uniform ±3%) — use ▶ Play to auto-cycle or ↺ Reset for a single new sample
You'll notice the chart looks vaguely plausible but feels mechanical — every candle has roughly the same body size, the wick lengths are nearly identical, and the overall path is a symmetric drunkard's walk. Real stocks rarely look like this.
What Statistical Tests Reveal
Three classical tests immediately distinguish a uniform random walk from real price data:
1. Runs test (Wald–Wolfowitz) Counts consecutive positive and negative returns. A fair coin should produce runs of length ≈ . Real returns show autocorrelation in volatility — big moves cluster — which the uniform walk completely misses.
2. Ljung-Box test on squared returns Even if returns are uncorrelated, their squares often are. Real equity returns show a classic ARCH effect:
Under the null of no autocorrelation this is . For AAPL daily squared returns the p-value is typically < 0.001. For our uniform walk, the test finds nothing because variance is constant by construction.
3. Jarque-Bera normality test Real log-returns are not Gaussian — they have excess kurtosis (fat tails). The JB statistic is:
where is skewness and is kurtosis. A typical equity has – (Gaussian has ). Our uniform draw has — thinner tails than a Gaussian.
import numpy as np
from scipy import stats
import yfinance as yf
# Download one year of AAPL daily data
aapl = yf.download("AAPL", start="2024-01-01", end="2025-01-01")
log_returns = np.log(aapl["Close"] / aapl["Close"].shift(1)).dropna()
# Jarque-Bera test
jb_stat, jb_p = stats.jarque_bera(log_returns)
print(f"JB stat: {jb_stat:.2f}, p-value: {jb_p:.4f}")
# → JB stat: 31.47, p-value: 0.0000 (strong evidence against normality)
# Ljung-Box test on squared returns (testing for ARCH effects)
from statsmodels.stats.diagnostic import acorr_ljungbox
lb = acorr_ljungbox(log_returns**2, lags=[10], return_df=True)
print(lb)
# → p-value ≈ 0.001 (volatility clustering is real)Stylised facts of equity returns (Cont, 2001): heavy tails, absence of autocorrelation in raw returns but strong autocorrelation in absolute or squared returns, volatility clustering, and leverage effect (negative returns → higher future volatility).
Act II — The Box-Muller Transform and Fat Tails
A Gaussian random walk already improves on the uniform one: returns have the right shape (bell curve), and if we add a GARCH(1,1) variance equation we get volatility clustering essentially for free.
Generating Gaussian Returns: Box-Muller
The classic trick to get standard normal samples from uniform ones:
where .
function boxMuller(): number {
let u1 = 0, u2 = 0
while (u1 === 0) u1 = Math.random() // guard against log(0)
while (u2 === 0) u2 = Math.random()
return Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2)
}Adding GARCH-style Volatility Clustering
Real variance is persistent — a big shock today predicts elevated variance tomorrow. GARCH(1,1) captures this with:
function generateGaussianWalk(n: number): OHLC[] {
const candles: OHLC[] = []
let price = 100
let variance = 0.015 ** 2 // initial daily variance ≈ 1.5 %
for (let i = 0; i < n; i++) {
const open = price
let ret = boxMuller() * Math.sqrt(variance)
// Fat-tail jump events: 4 % chance of a large shock
if (Math.random() < 0.04) ret += boxMuller() * 0.045
// GARCH(1,1) update: ω=0.00001, α=0.14, β=0.85
variance = 0.00001 + 0.85 * variance + 0.14 * ret * ret
variance = Math.max(1e-4, Math.min(variance, 0.01))
const close = open * (1 + ret)
const intraVol = (Math.abs(ret) + Math.random() * 0.005) * 0.6
candles.push({
open,
high: Math.max(open, close) * (1 + intraVol),
low: Math.min(open, close) * (1 - intraVol),
close,
})
price = close
}
return candles
}Select Gaussian + GARCH in the playground and compare. You should notice:
- Some runs of quiet candles followed by bursts of volatility (clustering)
- Occasional large candles that break the typical size pattern (fat tail jumps)
- Pattern density starts to edge upward compared to the uniform walk
But It's Still Not Enough
The Gaussian model improves Jarque-Bera and passes the Ljung-Box test on squared returns, yet it still fails a subtler set of tests:
- No mean reversion within a session — real intraday prices bounce between bid and ask
- No round-number magnetism — prices cluster near 200.00 in ways pure randomness doesn't produce
- Returns are i.i.d. given variance — no momentum at short horizons or reversal at medium horizons
- Price and volume are independent — in reality large moves coincide with large volume
A common mistake is fitting a GARCH model to 1-minute data and then sampling from it for backtesting. The problem is that GARCH parameters estimated at one frequency don't scale linearly — volatility aggregation is much messier in practice.
Act III — An Order Book and Matching Engine
Real exchange prices don't come from a random number generator. They emerge from the collision of thousands of buy and sell orders at different prices and sizes. To get closer to this we can simulate a simplified limit order book (LOB).
How a Limit Order Book Works
Asks (sell orders, sorted ascending):
$100.05 × 200 shares
$100.03 × 500 shares ← best ask
─────────────────────────
$100.01 × 300 shares ← best bid
$99.98 × 800 shares
$99.95 × 1200 shares
Bids (buy orders, sorted descending):When a market buy arrives it immediately matches against the best ask (and walks up the book if the order is large enough). The resulting trade price becomes part of the price record. The OHLCV of a candle is simply the first/max/min/last/total of all trades within the candle's time window.
Our Simplified Simulation
function generateOrderBookWalk(n: number): OHLC[] {
const candles: OHLC[] = []
let midPrice = 100
let trend = 0 // persistent directional drift
const levels: number[] = [100] // remembered support/resistance levels
for (let i = 0; i < n; i++) {
// Occasionally flip the short-term trend (regime change)
if (Math.random() < 0.08) trend = (Math.random() - 0.5) * 0.002
let price = midPrice
const open = price
let high = price
let low = price
// Simulate ~80 micro-trades within this candle's window
for (let j = 0; j < 80; j++) {
const noise = (Math.random() - 0.5) * 0.003 // bid/ask noise
// Gravitational pull toward nearest remembered price level
const nearest = levels.reduce((a, b) =>
Math.abs(a - price) < Math.abs(b - price) ? a : b)
const meanRev = (nearest - price) * 0.004
price += noise + trend * 0.3 + meanRev
price = Math.max(price, 0.01)
high = Math.max(high, price)
low = Math.min(low, price)
}
const close = price
// New traded prices occasionally become support/resistance
if (Math.random() < 0.1) {
levels.push(close)
if (levels.length > 8) levels.shift()
}
candles.push({ open, high, low, close })
midPrice = close
}
return candles
}Select Order Book in the playground. Compare with the previous two models. The key structural differences this model introduces are:
- Mean reversion within a candle — price bounces around rather than drifting freely, creating shorter wicks relative to the body
- Support/resistance clustering — traded prices "remember" past levels, so the price series shows stickiness near certain values
- Asymmetric candle shapes — the micro-structure produces more engulfing and harami patterns because price has to fight through accumulated limit orders
A real LOB simulator would also model order arrival rates (Poisson processes), order cancellations (≈ 95 % of all limit orders are cancelled before execution), iceberg orders, and the information asymmetry between market makers and informed traders. Each of these introduces additional statistical structure.
Act IV — Real Data (AAPL, TSLA, SPY)
Switch to the three real-data modes. The candles are based on approximate 2024 public market data for Apple, Tesla, and the S&P 500 ETF. Hit the pattern detector and observe.
You'll typically find that real tickers show more patterns per 100 candles than even the order book model. This isn't because markets are "patterned" in a predictive sense — it's because real price series have more structured micro-architecture:
- AAPL tends to be relatively smooth with persistent trend segments, then sharp reversals around earnings — producing clusters of engulfing patterns at inflection points.
- TSLA is far more volatile with frequent large wicks and violent reversals — hammers, inverted hammers, and shooting stars appear frequently.
- SPY is the most "random-walk-like" of the three (diversification suppresses idiosyncratic noise) yet still shows more patterns than the Gaussian model.
import yfinance as yf
# `candlestick` is an npm package — use talib or pandas-ta for Python
import pandas_ta as ta
for ticker in ["AAPL", "TSLA", "SPY"]:
df = yf.download(ticker, start="2024-01-01", end="2025-01-01", auto_adjust=True)
df.columns = df.columns.droplevel(1) # remove multi-level column header
# Detect patterns (pandas-ta wraps TA-Lib under the hood)
df.ta.cdl_pattern(name="all", append=True)
pattern_cols = [c for c in df.columns if c.startswith("CDL")]
total_signals = (df[pattern_cols] != 0).any(axis=1).sum()
print(f"{ticker}: {total_signals} candles with a pattern out of {len(df)}"
f" ({100*total_signals/len(df):.1f} %)")Counting Patterns Across Models
Here is a rough summary of what you should see in the playground when running each mode multiple times and averaging:
| Model | Avg patterns / 100 candles | Notes |
|---|---|---|
| Uniform Random Walk | 8–12 | Symmetric body sizes, similar wicks |
| Gaussian + GARCH | 12–18 | Fat tails add extreme candles that trigger hammer/shooting-star |
| Order Book | 18–26 | Mean reversion creates more engulfing and harami setups |
| AAPL | 25–35 | Trend + reversal episodes at earnings |
| TSLA | 35–50 | High volatility → large wicks → many reversal patterns |
| SPY | 20–28 | Diversified, smoother, but still more structured than synthetics |
A higher pattern count does not mean the patterns are predictive. Technical analysis research is deeply mixed on whether candlestick patterns have statistically significant predictive power after accounting for transaction costs and multiple comparisons. The count here measures structural complexity, not tradability.
What We're Still Missing
Even the order book model falls short in several important ways:
- Correlated assets — TSLA and SPY returns have non-trivial correlation that no isolated single-asset model captures.
- News and earnings — discrete information events create discontinuous jumps (gaps) that are qualitatively different from GARCH shocks.
- Calendar effects — Monday opening gaps, end-of-quarter rebalancing, and options expiry all leave fingerprints.
- Market impact — large institutional orders move the price against themselves in ways that create predictable intraday patterns.
- Regime switching — the market alternates between trending (momentum) and mean-reverting (range-bound) regimes that require hidden Markov models or Markov-switching GARCH to capture.
A genuine simulator would need to model all of these. The best open-source approaches use agent-based models where heterogeneous market participants (trend followers, fundamentalists, noise traders, market makers) interact in a simulated exchange. Even those struggle to reproduce the full joint distribution of returns, volume, and bid-ask spreads simultaneously.
Conclusions
We've walked from a coin-flip random walk all the way to a simulated order book, and at each step the generated series became more structurally complex in ways that the candlestick pattern counter reflects. The key lessons:
- Uniform random walks produce price series that are too tidy — low kurtosis, no volatility clustering, low pattern density.
- Gaussian returns with GARCH pass many statistical tests but are still missing micro-structure. Pattern density increases because fat tail events create extreme candle shapes.
- Order book simulation adds mean reversion and price memory, which creates more engulfing and harami patterns — the hallmarks of a price that "fights" around a level.
- Real stocks still show higher pattern density than all synthetic models. Some of this is structural (micro-architecture), some is behavioural (round-number clustering, herd behaviour), and some is genuinely random fluctuation that happens to match a named pattern.
The gap between the best simulator and real data is exactly what quantitative researchers have been paid to close for the last four decades. Progress is real but the market keeps finding new ways to be strange.
Further Reading
- Cont, R. (2001). Empirical properties of asset returns: stylised facts and statistical issues. Quantitative Finance.
- Glosten, L. R., & Milgrom, P. R. (1985). Bid, ask and transaction prices in a specialist market with heterogeneously informed traders. Journal of Financial Economics.
- Bouchaud, J.-P., & Potters, M. (2003). Theory of Financial Risk and Derivative Pricing. Cambridge University Press.
- Farmer, J. D., et al. (2005). The predictive power of zero intelligence in financial markets. PNAS.
Related Articles
The Order Book: How Price is Actually Born
A hands-on look at how a limit order book works — matching engines, price discovery, support and resistance zones — with a live simulation you can drive yourself.
The Fourier Transform: From Sines to Signals
A visual and mathematical journey through one of the most beautiful ideas in all of mathematics — decomposing any signal into its frequency components.
The Abelian Sandpile: A Group Hiding in a Pile of Sand
The Bak–Tang–Wiesenfeld sandpile model (ASM) is a deceptively simple cellular automaton that conceals a rich algebraic structure — an abelian group whose identity element is a non-trivial fractal-like configuration.
Automatic Differentiation
A deep-dive into automatic differentiation: symbolic vs. numerical vs. AD, AST transformation, dual numbers, tapes, hybrid methods, and a generic C++ implementation that computes gradients and solves optimisation problems.