Skip to main content

monte carlo backtesting - why single backtest runs lie to you

2:30am wednesday.

ran a single backtest last week. looked incredible. sharpe of 2.4. max drawdown 8%.

then I ran 10,000 of them.

reality check.

the problem with one backtest
#

you run a backtest. it returns +22% over 2 years.

you celebrate.

but that’s ONE path through the data. one sequence of trades.

shuffle the order. randomize entry timing by a few minutes. add realistic slippage distributions instead of fixed assumptions.

suddenly your +22% is anywhere from +35% to -8%.

that single run told you almost nothing.

monte carlo for algo traders
#

the idea is simple. take your strategy’s trade results and resample them thousands of times.

each resample gives you a different equity curve.

the distribution of outcomes tells you way more than any single backtest.

Monte Carlo Equity Curves

10,000 simulated equity curves from the same strategy. median outcome is solid. but look at the tails - the worst 5% of paths show drawdowns over 25%. one backtest won’t show you that.

the code
#

from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
import numpy as np
import pandas as pd
from concurrent.futures import ProcessPoolExecutor
import warnings

warnings.filterwarnings('ignore')


@dataclass
class TradeResult:
    """Single trade outcome"""
    pnl: float
    duration_hours: float
    instrument: str
    entry_price: float
    exit_price: float
    position_size: float


@dataclass
class MonteCarloConfig:
    """Configuration for MC simulation"""
    n_simulations: int = 10_000
    confidence_levels: List[float] = field(
        default_factory=lambda: [0.05, 0.25, 0.50, 0.75, 0.95]
    )
    use_block_bootstrap: bool = True
    block_size: int = 5  # preserve some autocorrelation
    account_size: float = 1_200_000.0
    max_workers: int = 8


class MonteCarloBacktest:
    """Monte Carlo simulation engine for trade sequences"""

    def __init__(self, config: MonteCarloConfig = None):
        self.config = config or MonteCarloConfig()
        self.trades: List[TradeResult] = []
        self.results: Optional[Dict] = None

    def load_trades(self, trades: List[TradeResult]):
        """Load historical trade results"""
        self.trades = trades
        self._pnl_array = np.array([t.pnl for t in trades])

    def _single_simulation(self, seed: int) -> np.ndarray:
        """Run one MC path"""
        rng = np.random.RandomState(seed)
        n_trades = len(self._pnl_array)

        if self.config.use_block_bootstrap:
            # block bootstrap preserves short-term autocorrelation
            blocks = []
            while len(blocks) < n_trades:
                start = rng.randint(0, n_trades - self.config.block_size)
                block = self._pnl_array[start:start + self.config.block_size]
                blocks.extend(block)
            resampled = np.array(blocks[:n_trades])
        else:
            # simple bootstrap - iid assumption
            indices = rng.randint(0, n_trades, size=n_trades)
            resampled = self._pnl_array[indices]

        # cumulative equity curve
        equity = self.config.account_size + np.cumsum(resampled)
        return equity

    def run(self) -> Dict:
        """Run full Monte Carlo simulation"""
        seeds = range(self.config.n_simulations)

        # parallel execution
        with ProcessPoolExecutor(max_workers=self.config.max_workers) as executor:
            equity_curves = list(executor.map(self._single_simulation, seeds))

        curves = np.array(equity_curves)

        # calculate stats across all simulations
        final_values = curves[:, -1]
        returns = (final_values - self.config.account_size) / self.config.account_size

        # drawdown calculation for each path
        max_drawdowns = []
        for curve in curves:
            running_max = np.maximum.accumulate(curve)
            drawdowns = (curve - running_max) / running_max
            max_drawdowns.append(np.min(drawdowns))

        max_drawdowns = np.array(max_drawdowns)

        # percentile analysis
        percentiles = {}
        for level in self.config.confidence_levels:
            pct = int(level * 100)
            percentiles[f'p{pct}'] = {
                'final_return': float(np.percentile(returns, pct)),
                'max_drawdown': float(np.percentile(max_drawdowns, pct)),
                'final_value': float(np.percentile(final_values, pct))
            }

        # probability of ruin (losing > 25% of account)
        ruin_threshold = self.config.account_size * 0.75
        prob_ruin = np.mean(np.min(curves, axis=1) < ruin_threshold)

        # probability of profit
        prob_profit = np.mean(final_values > self.config.account_size)

        self.results = {
            'n_simulations': self.config.n_simulations,
            'n_trades': len(self.trades),
            'median_return': float(np.median(returns)),
            'mean_return': float(np.mean(returns)),
            'std_return': float(np.std(returns)),
            'median_max_drawdown': float(np.median(max_drawdowns)),
            'worst_drawdown': float(np.min(max_drawdowns)),
            'prob_profit': float(prob_profit),
            'prob_ruin': float(prob_ruin),
            'percentiles': percentiles,
            'curves': curves,  # full data for plotting
            'max_drawdowns': max_drawdowns
        }

        return self.results

    def summary(self) -> str:
        """Print human-readable summary"""
        if self.results is None:
            raise ValueError("run simulation first")

        r = self.results
        lines = [
            f"Monte Carlo Simulation ({r['n_simulations']:,} paths, {r['n_trades']} trades)",
            f"",
            f"  median return:      {r['median_return']:+.1%}",
            f"  mean return:        {r['mean_return']:+.1%}",
            f"  std of returns:     {r['std_return']:.1%}",
            f"  median max DD:      {r['median_max_drawdown']:.1%}",
            f"  worst max DD:       {r['worst_drawdown']:.1%}",
            f"  prob of profit:     {r['prob_profit']:.1%}",
            f"  prob of ruin (>25%): {r['prob_ruin']:.1%}",
            f"",
            f"  percentile breakdown:",
        ]

        for level, stats in r['percentiles'].items():
            lines.append(
                f"    {level}: return={stats['final_return']:+.1%}  "
                f"DD={stats['max_drawdown']:.1%}  "
                f"value=${stats['final_value']:,.0f}"
            )

        return "\n".join(lines)


# usage example with my actual trade log format
def load_from_csv(filepath: str) -> List[TradeResult]:
    """Load trades from my standard CSV export"""
    df = pd.read_csv(filepath)
    trades = []
    for _, row in df.iterrows():
        trades.append(TradeResult(
            pnl=row['realized_pnl'],
            duration_hours=row['hold_hours'],
            instrument=row['symbol'],
            entry_price=row['entry'],
            exit_price=row['exit'],
            position_size=row['qty']
        ))
    return trades

nothing fancy. block bootstrap to preserve some trade clustering. parallel execution because 10,000 paths takes a minute otherwise.

the key insight is use_block_bootstrap. simple resampling assumes trades are independent. they’re not. winning streaks and losing streaks cluster. block bootstrap captures that.

what I found
#

ran this on my options premium selling strategy. 847 trades from all of 2025.

Monte Carlo Simulation (10,000 paths, 847 trades)

  median return:      +14.2%
  mean return:        +13.8%
  std of returns:     6.3%
  median max DD:      -11.4%
  worst max DD:       -31.2%
  prob of profit:     93.1%
  prob of ruin (>25%): 1.8%

  percentile breakdown:
    p5:  return=+3.1%   DD=-24.8%  value=$1,237,200
    p25: return=+10.2%  DD=-14.6%  value=$1,322,400
    p50: return=+14.2%  DD=-11.4%  value=$1,370,400
    p75: return=+17.8%  DD=-8.9%   value=$1,413,600
    p95: return=+24.1%  DD=-5.8%   value=$1,489,200

Monte Carlo Distribution

distribution of final returns across 10,000 simulations. median at +14.2% is solid. but the 5th percentile at +3.1% means in a bad year this strategy barely breaks even. that’s information a single backtest hides.

the numbers that matter
#

prob of profit: 93.1%

good. but not 100%.

there’s a 6.9% chance this strategy loses money over a year even with an actual edge. let that sink in.

prob of ruin: 1.8%

small. but not zero.

1 in 55 chance of losing more than 25% of account. with $1.2M that’s $300k.

would a single backtest show you that? no.

the spread matters most:

p5 return: +3.1% p95 return: +24.1%

that’s a massive range. same strategy. same edge. different trade sequences.

how I use this
#

position sizing:

I size positions so that even the p5 outcome doesn’t blow past my risk tolerance.

if p5 max drawdown is -24.8%, I size so that translates to acceptable dollar loss.

strategy comparison:

I don’t compare strategies by median return anymore.

I compare by p5 return and p5 max drawdown.

the strategy with the best worst case is usually the best strategy.

confidence in live deployment:

if MC says 93% probability of profit, I’m confident enough to run it live.

if it said 70%, I’d keep iterating.

been discussing MC approaches with some quant traders on NexusFi who run similar simulation frameworks. there’s a solid thread on statistical validation methods that helped me refine the block bootstrap approach.

block size matters
#

played with different block sizes.

block_size=1 is just simple bootstrap. no autocorrelation preserved.

block_size=20 preserves too much structure. you’re basically replaying the original sequence.

block_size=5 is my sweet spot for daily trades. preserves weekly clustering without overfitting to the original sequence.

for HFT with hundreds of trades per day you’d want block_size=50+.

for swing trading with 2-3 trades per week, block_size=2-3.

match block size to your trading frequency.

the takeaway
#

stop trusting single backtests.

run 10,000 of them.

look at the distribution.

your strategy’s edge is real only if the worst 5% of outcomes is still acceptable.

if it’s not, you need to resize or rethink.


2:30am wednesday. ran 10,000 monte carlo simulations on my premium selling strategy. median return +14.2% but 5th percentile is only +3.1%. probability of profit 93.1%. probability of losing 25%+ is 1.8%. single backtests hide the variance. block bootstrap with size 5 preserves trade clustering. always look at worst case, not median.

-AK

Related

cross-asset correlation tracking - why diversification is a lie
1:30am and i’m staring at correlation matrices again. everyone talks about diversification like it’s free lunch. it’s not. the diversification myth # portfolios are “diversified” until they’re not.
regime detection filter - why it failed march, python implementation fix
march disaster taught lesson. regime detection lagged. cost $6,690 before pausing. fixing implementation. what went wrong # my current filter:
theta decay tracking - why i obsess over time
3am on a monday and i’m watching theta tick down across my options book. most people don’t realize how much money they’re leaving on the table by not tracking theta properly.
saturday slippage deep dive - where your edge goes to die
woke up at 2am couldn’t sleep. decided to run a full slippage analysis on last quarter’s trades. what i found is annoying but fixable. the invisible tax # every algo trader knows slippage exists.
volatility regime detection - when to switch strategies
the market doesn’t care what strategy you’re running. it runs whatever regime it wants. your job is to detect the regime and adapt. why regime matters # every strategy has conditions where it crushes and conditions where it bleeds.
first week 2026 - january momentum algo kicking off
new year. new momentum. first real trading week of 2026 in the books. january effect algo activated. the january effect # some people think it’s BS.