import random def _calculate_percentile(data, percentile): if not data: return 0.0 sorted_data = sorted(data) index = (len(sorted_data) - 1) * (percentile / 100.0) lower = int(index) upper = lower + 1 if upper >= len(sorted_data): return sorted_data[-1] return sorted_data[lower] + (index - lower) * (sorted_data[upper] - sorted_data[lower]) def _run_metrics(pnls, starting_balance): if not pnls: return {"net_pnl": 0.0, "win_rate": 0.0, "profit_factor": 0.0, "max_drawdown_pct": 0.0} winners = [p for p in pnls if p > 0] losers = [p for p in pnls if p < 0] net_pnl = sum(pnls) trade_count = len(pnls) win_rate = (len(winners) / trade_count) * 100 if trade_count > 0 else 0.0 gross_profit = sum(winners) gross_loss = abs(sum(losers)) profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else (999.0 if gross_profit > 0 else 0.0) equity = starting_balance peak = starting_balance max_drawdown_pct = 0.0 for pnl in pnls: equity += pnl if equity > peak: peak = equity drawdown_pct = ((peak - equity) / peak) * 100 if peak > 0 else 0.0 if drawdown_pct > max_drawdown_pct: max_drawdown_pct = drawdown_pct return { "net_pnl": net_pnl, "win_rate": win_rate, "profit_factor": profit_factor, "max_drawdown_pct": max_drawdown_pct, } def run_monte_carlo( trade_r_multiples, runs=1000, starting_balance=10000.0, risk_per_trade_pct=1.0, sampling_method="bootstrap", missed_trade_pct=5.0, pnl_variation_pct=10.0, price_noise_pct=0.0, slippage_per_trade=0.0, spread_per_trade=0.0, per_trade_cost=None, ruin_drawdown_pct=20.0, seed=None, ): if not trade_r_multiples: return {"summary": {"runs": 0}, "distribution": [], "sample_runs": []} run_count = max(1, int(runs)) risk_pct = max(0.0, float(risk_per_trade_pct)) / 100.0 pnl_var = max(0.0, float(pnl_variation_pct)) / 100.0 price_var = max(0.0, float(price_noise_pct)) / 100.0 miss_pct = max(0.0, float(missed_trade_pct)) / 100.0 ruin_threshold = max(0.0, float(ruin_drawdown_pct)) fixed_cost = float(per_trade_cost) if per_trade_cost is not None else (max(0.0, float(slippage_per_trade)) + max(0.0, float(spread_per_trade))) effective_var = max(pnl_var, price_var) rng = random.Random(seed) run_results = [] base_trades = list(trade_r_multiples) trade_count = len(base_trades) for run_idx in range(run_count): # 1. Generate the Trade Sequence if sampling_method == "bootstrap": path = [rng.choice(base_trades) for _ in range(trade_count)] elif sampling_method == "shuffle": path = base_trades[:] rng.shuffle(path) else: path = base_trades[:] adjusted_pnls = [] equity = float(starting_balance) peak = float(starting_balance) ruin_hit = False # 2. Execute the trades sequentially for base_r in path: # Execution Risk: Did the broker drop our connection? if rng.random() < miss_pct: continue r_multiple = float(base_r) # Add volatility noise to the outcome (Slippage) if effective_var > 0: r_multiple *= rng.uniform(1 - effective_var, 1 + effective_var) # Calculate PnL in dollars based on CURRENT equity (Compounding) pnl = (equity * risk_pct * r_multiple) - fixed_cost adjusted_pnls.append(pnl) equity += pnl # 3. Live Drawdown & Ruin Check (Prevents Zombie Trading) if equity > peak: peak = equity current_dd = ((peak - equity) / peak) * 100 if peak > 0 else 0.0 if current_dd >= ruin_threshold or equity <= 0: ruin_hit = True break # Account blown or max DD hit. STOP trading. # Calculate metrics for the surviving trades metrics = _run_metrics(adjusted_pnls, starting_balance) metrics["run"] = run_idx + 1 metrics["ruin"] = ruin_hit metrics["trades_taken"] = len(adjusted_pnls) run_results.append(metrics) # --- Aggregate Statistics --- pnls = [r["net_pnl"] for r in run_results] dds = [r["max_drawdown_pct"] for r in run_results] profitable_runs = sum(1 for p in pnls if p > 0) ruin_count = sum(1 for r in run_results if r["ruin"]) summary = { "runs": run_count, "avg_pnl": round(sum(pnls) / run_count, 2), "worst_case_pnl_5th_pct": round(_calculate_percentile(pnls, 5), 2), # 95% Confidence you make at least this much "profitable_run_pct": round((profitable_runs / run_count) * 100, 2), "avg_max_drawdown_pct": round(sum(dds) / run_count, 2), "worst_case_dd_95th_pct": round(_calculate_percentile(dds, 95), 2), # 95% Confidence your DD won't exceed this "worst_max_drawdown_pct": round(max(dds), 2), "avg_win_rate": round(sum(r["win_rate"] for r in run_results) / run_count, 2), "avg_profit_factor": round(sum(r["profit_factor"] for r in run_results) / run_count, 2), "probability_of_ruin": round((ruin_count / run_count) * 100, 2), } return { "summary": summary, "distribution": run_results, "sample_runs": run_results, }