edgekit

Simulating markets

You have exactly one history — the single path the market happened to print. Every metric you compute is one draw from a distribution you never get to see. Simulation is how you widen the sample: generate price series the market could have printed, run the strategy on all of them, and ask whether the edge survives worlds that never happened. Used for validation it is a trap; used for robustness it is one of the sharpest tools you have.

Start from scratch with a coin. You flip it 100 times, count 58 heads, and wonder: is this a biased coin, or did a fair one just have a good run? You cannot answer from the single sequence in your hand — so you do the obvious thing and imagine flipping a fair coin 100 times, over and over, to see how often pure chance produces 58-or-more heads. A market history is exactly that one sequence of flips. Simulation is imagining the fair coin: build a world where you know there is no edge, run your strategy on thousands of draws from it, and see whether your backtest number is ordinary or extraordinary against that null. Everything in this chapter is that one move, dressed up for prices.

Why simulate at all#

A backtest answers “what would this strategy have done on the one path we observed?” That is a sample of size one. The realised Sharpe, the max drawdown, the worst losing streak — each is a point estimate with a confidence interval you cannot read off a single equity curve. Two honest ways to widen the sample:

  • Resample the strategy's own trades — the bootstrap / Monte-Carlo of R-multiples covered in Monte Carlo. This reshuffles outcomes you actually observed.
  • Resample the market itself — generate synthetic price paths from a model, then run the whole strategy on each. This is what this chapter is about. It stresses the entry/exit logic, not just the trade order, because on each synthetic path the strategy makes fresh decisions.
!Synthetic data validates nothing
A synthetic series has exactly the structure you built into its generator and no more. If you fit parameters to make a strategy look good on your own GBM paths, you have learned the generator, not the market. Use simulation to test robustness — “does the edge degrade gracefully or fall off a cliff?” — never to confirm an edge. Confirmation comes from out-of-sample data, the permutation test, and walk-forward (Part IV), on real bars.

The random walk and geometric Brownian motion#

The textbook model of a price is a driftless-or-drifting random walk in log space. In continuous time it is geometric Brownian motion (GBM):

is the price, the drift, the volatility, and a Wiener increment. The reason GBM is the default is that it keeps prices positive and makes log-returns normal and i.i.d. To simulate it you discretise into a step of size and draw a standard-normal each bar:

The term is the Itô drift correction (the volatility drag from returns and compounding): without it the median path drifts up even at . Setting gives a pure random walk — the null world in which no trend-following or mean-reversion rule should make money net of costs.

A fan of simulated geometric-Brownian-motion price paths from a common start
Many GBM paths from one seed price. Each is a plausible market that never happened; the strategy runs on every one.
A right-skewed histogram of the terminal prices of many simulated GBM paths, with the median below the mean
Collapse that fan onto its endpoints and you get the terminal-price distribution — right-skewed and log-normal. It is the same view a terminal-R histogram gives you: where the many synthetic worlds actually end up, not just the average path.

Generating paths and running a strategy on them#

edgekit strategies consume an OHLC frame, so the pattern is: simulate a close path, wrap it into a minimal bar frame, and call .backtest. Here a random walk () is fed to the illustrative SmaCross trend template — on a driftless series a trend rule should bleed the costs and nothing else, which is exactly the sanity check you want.

gbm_sim.py
import numpy as np, pandas as pd
import edgekit as ek
from edgekit.strategy import SmaCross

def gbm_bars(n, mu=0.0, sigma=0.02, s0=100.0, seed=0):
    """One synthetic OHLC path from discretised GBM (dt = 1 bar)."""
    rng = np.random.default_rng(seed)
    z = rng.standard_normal(n)
    r = (mu - 0.5 * sigma**2) + sigma * z          # log-returns, dt = 1
    close = s0 * np.exp(np.cumsum(r))
    open_ = np.concatenate([[s0], close[:-1]])     # open = prior close
    wick = np.abs(rng.standard_normal(n)) * sigma * close
    idx = pd.date_range("2020-01-01", periods=n, freq="4h")
    return pd.DataFrame({
        "open": open_, "close": close,
        "high": np.maximum(open_, close) + wick,
        "low":  np.minimum(open_, close) - wick,
        "tick_volume": 1.0,
    }, index=idx)

# run the strategy on 500 independent null-world paths
finals = []
for seed in range(500):
    bars = gbm_bars(3000, mu=0.0, sigma=0.02, seed=seed)
    trades = SmaCross(fast=20, slow=100).backtest(bars, warmup=210, bars_per_day=6)
    finals.append(trades["r"].sum())              # terminal R on this path

finals = np.array(finals)
print(f"median terminal R = {np.median(finals):+.1f}   "
      f"P(profit) = {(finals > 0).mean():.0%}")

On a driftless random walk the distribution of terminal R should sit around a negative median once the default cost model bites, with near a coin flip. If a rule looks strongly profitable on pure noise, the bug is in the backtest (look-ahead), not the market — see why backtests lie.

Scenario: is SmaCross's BTCUSD result skill or luck?
You backtested SmaCross(20, 100) on your one real BTCUSD H4 history and it drew a tidy uphill equity curve. Before you believe a word of it, you build the null. With , per 4-hour bar (roughly BTC's realised vol) and , one call to gbm_bars(3000, mu=0.0) prints a single driftless path — about 500 days of a market that, by construction, trends nowhere. You run the same crossover on 500 such paths. The terminal-R histogram lands with a median near and : on pure noise the rule just bleeds the spread, exactly as it should. Your real BTC result sitting far in the right tail of that histogram is the first hint the trend was not manufactured by the crossover chasing random wiggles — a hint only, because the honest verdict still comes from the permutation test on real bars.

Adding jumps and fat tails#

Real returns are not Gaussian: they are fat-tailed and punctuated by jumps (gaps, flash crashes, headline moves). A GBM path drawn from a normal will systematically under-stress a strategy's stops and drawdowns. Two cheap upgrades:

  • Student-t innovations — replace with a (variance-rescaled) Student-t with low degrees of freedom . Smaller = fatter tails; recovers the normal.
  • A jump-diffusion (Merton) term — add a compound-Poisson jump: with small per-bar probability the return gets an extra shock .
fat_tails.py
def fat_tail_returns(n, sigma=0.02, nu=4, lam=0.01, jump_sd=0.10, seed=0):
    rng = np.random.default_rng(seed)
    t = rng.standard_t(nu, n) * np.sqrt((nu - 2) / nu)   # unit-variance t
    jumps = (rng.random(n) < lam) * rng.normal(0.0, jump_sd, n)
    return -0.5 * sigma**2 + sigma * t + jumps

Run the strategy across a sweep of and jump intensities and watch the max drawdown and worst-day statistics move. A rule whose survival depends on tails never appearing is not one you want sized to a hard drawdown budget (see prop-firm and capital).

Scenario: the jump your stop never saw
Your Gaussian stress said SmaCross survives with a 22% worst drawdown, so you size it against a 25% budget and feel safe. Then you switch on one Merton jump: (a shock roughly every 100 bars) with jump SD — a 10% gap, the kind BTC prints on a liquidation cascade or a headline. Re-run the same 200 seeds and the worst-drawdown distribution shifts right: the median max-DD climbs from ~22% to ~31%, and the ugliest seed touches 40% — right through your budget. Nothing about the rule changed; only the world got one honest feature meaner. The desk lesson: a stop sized to a Gaussian world is a stop that has never met a bad Tuesday. Size to the fat-tailed drawdown, or the first real gap sizes you.

Regime-switching simulation#

Markets are not stationary: they flip between calm and turbulent, trending and ranging. A single can never produce that. The simplest realistic generator is a two-state Markov chain — a low-vol trending regime and a high-vol choppy regime — with sticky transition probabilities so each state persists:

High and (say 0.98) make regimes last on average bars. This is the generator behind the classic test: a trend rule that prints beautifully in the trending state but gives it all back in the choppy state has a regime-dependent edge, and its live results will depend entirely on which regime it lands in.

A simulated series switching between a calm low-volatility regime and a turbulent high-volatility regime
A two-state regime-switching path. The strategy's edge — if any — is rarely uniform across the shaded regimes.
regime_sim.py
def regime_returns(n, mus=(0.0008, -0.0002), sigmas=(0.008, 0.03),
                   p_stay=(0.98, 0.98), seed=0):
    rng = np.random.default_rng(seed)
    s = 0
    out = np.empty(n)
    for i in range(n):
        out[i] = rng.normal(mus[s], sigmas[s])
        if rng.random() > p_stay[s]:               # sticky switch
            s = 1 - s
    return out                                     # log-returns; exp-cumsum -> price
Scenario: the edge that only exists in half the tape
Run SmaCross on a two-state path — a calm trending regime () and a choppy one (), each persisting ~50 bars on average — and tag every trade by the regime it opened in. The split is stark: the trend rule earns about per trade in the trending state and loses about in the choppy one. Its whole equity curve is just a race between how many bars each regime happened to get. Deploy it into a six-month chop and the “edge” is gone — not because it broke, but because it was always regime-conditional and your single blended Sharpe never told you which regime funded the backtest. That is the trap a lone summary statistic hides: it averages two different strategies into one comforting number.

Stress-testing a strategy on synthetic series#

Put the pieces together into a stress harness: sweep a generator parameter, run the strategy on many seeds per setting, and summarise the R distribution with edgekit's metrics. The output is not “is this an edge” — it is “how does this rule degradeas the world gets meaner?”

stress.py
import numpy as np
from edgekit import trade_stats

def stress(sigma, seeds=200):
    pfs = []
    for seed in range(seeds):
        bars = gbm_bars(3000, mu=0.0, sigma=sigma, seed=seed)
        tr = SmaCross(fast=20, slow=100).backtest(bars, warmup=210, bars_per_day=6)
        if len(tr):
            pfs.append(trade_stats(tr["r"].to_numpy())["pf"])
    return np.median(pfs)

for sigma in (0.01, 0.02, 0.04):
    print(f"sigma={sigma:.2f}  median PF over paths = {stress(sigma):.2f}")
What a healthy stress result looks like
A robust rule shows a smooth, monotone response to the knob — profit factor sliding gently as volatility rises, not a cliff. Sharp non-monotone jumps usually mean the strategy is exploiting a fragile artifact of one generator setting. And remember the baseline: on the null world, net-of-cost profit factor below 1.0 is the correct answer, not a failure.

At the desk. The sweep prints sigma=0.01 → PF 0.98, sigma=0.02 → PF 0.94, sigma=0.04 → PF 0.87. Read it as a slope, not three numbers: profit factor slides gently and monotonically as the world gets noisier, and every value sits just under 1.0 — precisely what a no-edge rule should do on a null. That is a healthy result: there is no hidden fragility that one particular vol setting detonates. Had a single setting jumped to PF 1.6 while its neighbours sat at 0.9, you would suspect the backtest was harvesting an artifact of that one generator — and you would go hunting for the look-ahead before trusting a live dollar to it.

The caveat, restated#

Synthetic markets are a lens for robustness, not a source of truth. They can tell you a strategy is fragile — a rule that dies the moment you add tails or flip regimes is telling you something real. They cannot tell you a strategy is good, because you built the world it succeeded in. Keep the two jobs separate: simulate to break things, validate on real out-of-sample data to believe things.

Next: from hand-written rules to learned ones — Machine learning in trading covers where ML actually helps (meta-labeling a rule, not predicting price) and the leakage traps that make most ML backtests fiction.