Monte Carlo Simulation: Validating Trading Strategies with Randomness
Use Monte Carlo methods to stress-test trading strategies, estimate risk metrics, and build confidence in your system's robustness.
Your strategy shows a Sharpe ratio of 2.3 over 5 years. But is this result robust, or just lucky? Monte Carlo simulation answers this question by generating thousands of alternative scenarios, revealing the true distribution of possible outcomes.
This guide teaches you how to use Monte Carlo methods to validate trading strategies, estimate worst-case scenarios, and build confidence in your system’s robustness for Indian markets.
What is Monte Carlo Simulation?
Monte Carlo simulation uses repeated random sampling to understand probability distributions and risk. For trading strategies, we create thousands of “what-if” scenarios by:
- Resampling trades (bootstrap method)
- Randomizing entry/exit timing
- Simulating price paths (geometric Brownian motion)
- Stress-testing parameters
Part 1: Bootstrap Resampling
Trade Sequence Randomization
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import List, Dict
class MonteCarloValidator:
"""
Monte Carlo simulation for strategy validation
"""
def __init__(self, trades: List[Dict], initial_capital: float = 100000):
self.trades = trades
self.initial_capital = initial_capital
self.simulations = []
def bootstrap_simulation(self, n_simulations: int = 10000) -> pd.DataFrame:
"""
Bootstrap resampling of trade sequence
Method: Randomly sample trades with replacement
to create alternative equity curves
"""
results = []
for sim in range(n_simulations):
# Random sample with replacement
sampled_trades = np.random.choice(
self.trades,
size=len(self.trades),
replace=True
)
# Calculate equity curve
equity = self.initial_capital
equity_curve = [equity]
for trade in sampled_trades:
pnl = trade['pnl']
equity += pnl
equity_curve.append(equity)
equity_curve = np.array(equity_curve)
# Calculate metrics
final_equity = equity_curve[-1]
total_return = (final_equity - self.initial_capital) / self.initial_capital
# Calculate max drawdown
running_max = np.maximum.accumulate(equity_curve)
drawdown = (running_max - equity_curve) / running_max
max_drawdown = drawdown.max()
results.append({
'simulation': sim,
'final_equity': final_equity,
'total_return': total_return,
'max_drawdown': max_drawdown,
'equity_curve': equity_curve
})
self.simulations = results
return pd.DataFrame([{k: v for k, v in r.items() if k != 'equity_curve'} for r in results])
def analyze_distribution(self, results: pd.DataFrame):
"""
Analyze Monte Carlo results
"""
print("=" * 60)
print("MONTE CARLO SIMULATION RESULTS")
print("=" * 60)
# Return statistics
print(f"\n📊 RETURN DISTRIBUTION:")
print(f" Mean Return: {results['total_return'].mean():.2%}")
print(f" Median Return: {results['total_return'].median():.2%}")
print(f" Std Dev: {results['total_return'].std():.2%}")
print(f" Sharpe (approx): {results['total_return'].mean() / results['total_return'].std():.2f}")
# Percentiles
print(f"\n📈 RETURN PERCENTILES:")
for pct in [5, 25, 50, 75, 95]:
value = results['total_return'].quantile(pct / 100)
print(f" {pct}th percentile: {value:.2%}")
# Win probability
win_pct = (results['total_return'] > 0).mean() * 100
print(f"\n🎯 WIN PROBABILITY: {win_pct:.1f}%")
# Risk metrics
print(f"\n⚠️ RISK METRICS:")
print(f" Mean Max Drawdown: {results['max_drawdown'].mean():.2%}")
print(f" 95th %ile Max DD: {results['max_drawdown'].quantile(0.95):.2%}")
print(f" Worst Case DD: {results['max_drawdown'].max():.2%}")
# Risk of ruin
ruin_threshold = 0.80 * self.initial_capital # 20% loss
ruin_pct = (results['final_equity'] < ruin_threshold).mean() * 100
print(f" Risk of 20% Loss: {ruin_pct:.2f}%")
def plot_distribution(self, results: pd.DataFrame):
"""
Visualize Monte Carlo results
"""
fig, axes = plt.subplots(2, 2, figsize=(15, 10))
# 1. Return histogram
ax1 = axes[0, 0]
returns_pct = results['total_return'] * 100
ax1.hist(returns_pct, bins=50, alpha=0.7, color='#5AC8FB', edgecolor='black')
ax1.axvline(0, color='red', linestyle='--', linewidth=2, label='Breakeven')
ax1.axvline(returns_pct.mean(), color='green', linestyle='-', linewidth=2, label='Mean')
ax1.set_xlabel('Return (%)')
ax1.set_ylabel('Frequency')
ax1.set_title('Return Distribution', fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3)
# 2. Drawdown distribution
ax2 = axes[0, 1]
dd_pct = results['max_drawdown'] * 100
ax2.hist(dd_pct, bins=50, alpha=0.7, color='#ff6b6b', edgecolor='black')
ax2.axvline(dd_pct.mean(), color='darkred', linestyle='-', linewidth=2, label='Mean')
ax2.set_xlabel('Max Drawdown (%)')
ax2.set_ylabel('Frequency')
ax2.set_title('Max Drawdown Distribution', fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
# 3. Equity curves (sample)
ax3 = axes[1, 0]
n_curves = min(100, len(self.simulations))
for i in range(n_curves):
equity_curve = self.simulations[i]['equity_curve']
ax3.plot(equity_curve, alpha=0.05, color='gray')
# Plot mean equity curve
mean_equity = np.mean([s['equity_curve'] for s in self.simulations], axis=0)
ax3.plot(mean_equity, color='#5AC8FB', linewidth=3, label='Mean')
ax3.axhline(self.initial_capital, color='red', linestyle='--', label='Initial Capital')
ax3.set_xlabel('Trade Number')
ax3.set_ylabel('Equity (₹)')
ax3.set_title('Equity Curve Simulations', fontweight='bold')
ax3.legend()
ax3.grid(True, alpha=0.3)
# 4. Risk-Return scatter
ax4 = axes[1, 1]
ax4.scatter(
results['max_drawdown'] * 100,
results['total_return'] * 100,
alpha=0.3,
s=10,
color='#5AC8FB'
)
ax4.set_xlabel('Max Drawdown (%)')
ax4.set_ylabel('Total Return (%)')
ax4.set_title('Risk-Return Profile', fontweight='bold')
ax4.axhline(0, color='red', linestyle='--', alpha=0.5)
ax4.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('monte_carlo_results.png', dpi=300, bbox_inches='tight')
plt.show()
# Example usage
trades = [
{'date': '2024-01-15', 'pnl': 2500},
{'date': '2024-01-20', 'pnl': -800},
{'date': '2024-01-25', 'pnl': 1200},
{'date': '2024-02-01', 'pnl': 3500},
{'date': '2024-02-10', 'pnl': -1500},
# ... more trades
]
# Run simulation
validator = MonteCarloValidator(trades, initial_capital=100000)
results = validator.bootstrap_simulation(n_simulations=10000)
# Analyze results
validator.analyze_distribution(results)
validator.plot_distribution(results)
Part 2: Price Path Simulation
Geometric Brownian Motion
class PricePathSimulator:
"""
Simulate stock price paths using GBM
"""
def __init__(self, S0: float, mu: float, sigma: float, T: float, dt: float = 1/252):
"""
Parameters:
S0: Initial price
mu: Expected return (annual)
sigma: Volatility (annual)
T: Time horizon (years)
dt: Time step (1/252 for daily)
"""
self.S0 = S0
self.mu = mu
self.sigma = sigma
self.T = T
self.dt = dt
self.n_steps = int(T / dt)
def simulate_path(self) -> np.array:
"""
Generate single price path
"""
prices = np.zeros(self.n_steps + 1)
prices[0] = self.S0
for t in range(1, self.n_steps + 1):
# Random shock
Z = np.random.standard_normal()
# GBM formula
prices[t] = prices[t-1] * np.exp(
(self.mu - 0.5 * self.sigma**2) * self.dt +
self.sigma * np.sqrt(self.dt) * Z
)
return prices
def simulate_multiple_paths(self, n_simulations: int = 1000) -> np.array:
"""
Generate multiple price paths
"""
paths = np.zeros((n_simulations, self.n_steps + 1))
for i in range(n_simulations):
paths[i] = self.simulate_path()
return paths
def plot_paths(self, paths: np.array, n_display: int = 100):
"""
Visualize price paths
"""
plt.figure(figsize=(12, 6))
# Plot subset of paths
for i in range(min(n_display, len(paths))):
plt.plot(paths[i], alpha=0.1, color='gray')
# Plot mean path
mean_path = paths.mean(axis=0)
plt.plot(mean_path, color='#5AC8FB', linewidth=3, label='Mean Path')
# Plot percentiles
p5 = np.percentile(paths, 5, axis=0)
p95 = np.percentile(paths, 95, axis=0)
plt.fill_between(range(len(p5)), p5, p95, alpha=0.3, color='#5AC8FB', label='5th-95th Percentile')
plt.axhline(self.S0, color='red', linestyle='--', label='Initial Price')
plt.xlabel('Time Steps (days)')
plt.ylabel('Price (₹)')
plt.title('Simulated Price Paths', fontweight='bold')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
# Example: Simulate Nifty 50
simulator = PricePathSimulator(
S0=18000, # Current Nifty
mu=0.12, # 12% expected return
sigma=0.18, # 18% volatility
T=1, # 1 year
dt=1/252 # Daily steps
)
# Generate 1000 paths
paths = simulator.simulate_multiple_paths(n_simulations=1000)
# Visualize
simulator.plot_paths(paths)
# Analyze outcomes
final_prices = paths[:, -1]
print(f"Expected final price: ₹{final_prices.mean():.2f}")
print(f"Std dev: ₹{final_prices.std():.2f}")
print(f"5th percentile: ₹{np.percentile(final_prices, 5):.2f}")
print(f"95th percentile: ₹{np.percentile(final_prices, 95):.2f}")
Part 3: Strategy Stress Testing
Parameter Robustness
class ParameterStressTester:
"""
Test strategy sensitivity to parameter variations
"""
def __init__(self, strategy_function, base_params: dict):
self.strategy_function = strategy_function
self.base_params = base_params
def stress_test_parameters(
self,
data: pd.DataFrame,
param_ranges: dict,
n_simulations: int = 1000
) -> pd.DataFrame:
"""
Randomly vary parameters and test performance
"""
results = []
for sim in range(n_simulations):
# Randomly perturb parameters
test_params = self.base_params.copy()
for param_name, (min_mult, max_mult) in param_ranges.items():
base_value = self.base_params[param_name]
multiplier = np.random.uniform(min_mult, max_mult)
test_params[param_name] = int(base_value * multiplier)
# Run strategy with perturbed parameters
try:
performance = self.strategy_function(data, **test_params)
results.append({
'simulation': sim,
'params': test_params,
'sharpe': performance['sharpe'],
'return': performance['total_return'],
'max_dd': performance['max_drawdown']
})
except Exception as e:
print(f"Simulation {sim} failed: {e}")
return pd.DataFrame(results)
def analyze_robustness(self, results: pd.DataFrame):
"""
Analyze parameter sensitivity
"""
print("\n" + "=" * 60)
print("PARAMETER ROBUSTNESS ANALYSIS")
print("=" * 60)
# Overall statistics
print(f"\n📊 PERFORMANCE DISTRIBUTION:")
print(f" Mean Sharpe: {results['sharpe'].mean():.2f}")
print(f" Std Dev: {results['sharpe'].std():.2f}")
print(f" Min Sharpe: {results['sharpe'].min():.2f}")
print(f" Max Sharpe: {results['sharpe'].max():.2f}")
# Consistency
positive_sharpe_pct = (results['sharpe'] > 0).mean() * 100
print(f"\n🎯 CONSISTENCY:")
print(f" Positive Sharpe: {positive_sharpe_pct:.1f}% of simulations")
# Risk assessment
print(f"\n⚠️ RISK METRICS:")
print(f" Mean Max DD: {results['max_dd'].mean():.2%}")
print(f" Worst DD: {results['max_dd'].max():.2%}")
# Verdict
if positive_sharpe_pct > 80 and results['sharpe'].mean() > 1.0:
print(f"\n✅ VERDICT: Strategy is ROBUST")
elif positive_sharpe_pct > 60:
print(f"\n⚠️ VERDICT: Strategy is MODERATELY ROBUST")
else:
print(f"\n❌ VERDICT: Strategy is FRAGILE - high parameter sensitivity")
# Example: Test SMA crossover with parameter uncertainty
def sma_crossover_strategy(data, fast_period, slow_period):
# Calculate indicators
data['SMA_Fast'] = data['Close'].rolling(fast_period).mean()
data['SMA_Slow'] = data['Close'].rolling(slow_period).mean()
# Generate signals
data['Signal'] = 0
data.loc[data['SMA_Fast'] > data['SMA_Slow'], 'Signal'] = 1
# Calculate returns
data['Returns'] = data['Close'].pct_change()
data['Strategy_Returns'] = data['Signal'].shift(1) * data['Returns']
# Performance metrics
total_return = data['Strategy_Returns'].sum()
sharpe = data['Strategy_Returns'].mean() / data['Strategy_Returns'].std() * np.sqrt(252)
cumulative = (1 + data['Strategy_Returns']).cumprod()
running_max = cumulative.expanding().max()
drawdown = (running_max - cumulative) / running_max
max_drawdown = drawdown.max()
return {
'total_return': total_return,
'sharpe': sharpe,
'max_drawdown': max_drawdown
}
# Load data
data = pd.read_csv('nifty_data.csv')
# Base parameters
base_params = {
'fast_period': 20,
'slow_period': 50
}
# Parameter ranges (±30% variation)
param_ranges = {
'fast_period': (0.7, 1.3),
'slow_period': (0.7, 1.3)
}
# Run stress test
tester = ParameterStressTester(sma_crossover_strategy, base_params)
results = tester.stress_test_parameters(data, param_ranges, n_simulations=1000)
# Analyze
tester.analyze_robustness(results)
Part 4: Real-World Applications
Risk of Ruin Analysis
def calculate_risk_of_ruin(
win_rate: float,
avg_win: float,
avg_loss: float,
risk_per_trade: float,
n_simulations: int = 10000
) -> dict:
"""
Calculate probability of losing X% of capital
Based on random trade sequence generation
"""
results = []
for sim in range(n_simulations):
capital = 100000 # Starting capital
equity_curve = [capital]
# Simulate 1000 trades
for trade in range(1000):
# Random outcome based on win rate
if np.random.random() < win_rate:
# Win
pnl = capital * risk_per_trade * (avg_win / avg_loss)
else:
# Loss
pnl = -capital * risk_per_trade
capital += pnl
equity_curve.append(capital)
# Stop if ruined
if capital < 50000: # 50% drawdown
break
results.append({
'final_capital': capital,
'ruined': capital < 50000,
'max_equity': max(equity_curve),
'equity_curve': equity_curve
})
# Calculate metrics
ruin_pct = sum(r['ruined'] for r in results) / len(results) * 100
avg_final = np.mean([r['final_capital'] for r in results])
return {
'risk_of_ruin': ruin_pct,
'avg_final_capital': avg_final,
'simulations': results
}
# Example
risk_analysis = calculate_risk_of_ruin(
win_rate=0.55, # 55% win rate
avg_win=1.5, # Avg win = 1.5 × avg loss
avg_loss=1.0,
risk_per_trade=0.02, # 2% risk per trade
n_simulations=10000
)
print(f"Risk of Ruin (50% DD): {risk_analysis['risk_of_ruin']:.2f}%")
print(f"Average Final Capital: ₹{risk_analysis['avg_final_capital']:,.0f}")
Conclusion
Monte Carlo simulation transforms uncertainty into actionable insights:
- Bootstrap resampling reveals true strategy robustness
- Price path simulation tests strategies under varied market conditions
- Parameter stress testing identifies fragile optimizations
- Risk of ruin quantifies downside scenarios
Use Monte Carlo to build confidence before risking real capital.
Ready to validate your strategy? Contact us for professional Monte Carlo analysis.