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.
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
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