lignarr/Trainer-PPO
0
1"""2backtest.py — Component 5: Walk-Forward Backtesting Engine.3 4Monthly walk-forward windows over the 7-month test period.5Realistic execution with spread, slippage, and all risk management rules.6Uses ConfidentPPO wrapper. Generates comprehensive metrics and plots.7"""8 9import os10import logging11import numpy as np12import pandas as pd13import matplotlib14matplotlib.use("Agg")15import matplotlib.pyplot as plt16import matplotlib.dates as mdates17import seaborn as sns18from datetime import datetime, timedelta19from typing import Dict, List20 21from stable_baselines3 import PPO22 23import config24from trading_env import ForexTradingEnv25from train_ppo import ConfidentPPO26from utils import (27 setup_logging, set_all_seeds, compute_all_metrics,28 monte_carlo_simulation, risk_of_ruin_analytical,29 compute_win_rate, compute_profit_factor, compute_expectancy30)31from telegram_utils import send_telegram_message, send_telegram_document32 33logger = setup_logging("backtest")34 35 36def get_monthly_windows(start: datetime, end: datetime) -> List[Dict]:37 """Split test period into monthly walk-forward windows."""38 windows = []39 current = start40 while current < end:41 month_end = min(42 datetime(current.year + (current.month // 12), ((current.month % 12) + 1), 1) - timedelta(days=1),43 end44 )45 windows.append({"start": current, "end": month_end, "label": current.strftime("%Y-%m")})46 current = month_end + timedelta(days=1)47 return windows48 49 50def run_single_window(model, data: pd.DataFrame, window: Dict, use_confident: bool = True) -> Dict:51 """Run backtest on a single monthly window."""52 w_start = pd.Timestamp(window["start"])53 w_end = pd.Timestamp(window["end"])54 label = window["label"]55 56 window_data = data[(data["datetime"] >= w_start) & (data["datetime"] <= w_end)]57 if len(window_data) < 10:58 logger.warning(f"Window {label}: insufficient data ({len(window_data)} bars). Skipping.")59 return None60 61 logger.info(f"Window {label}: {len(window_data)} bars ({w_start.date()} → {w_end.date()})")62 63 env = ForexTradingEnv(64 data=window_data,65 is_eval=True,66 initial_balance=config.INITIAL_BALANCE,67 )68 69 obs, info = env.reset(seed=config.RANDOM_SEED)70 done = False71 72 while not done:73 if use_confident and isinstance(model, ConfidentPPO):74 action, _ = model.predict(obs)75 else:76 action, _ = model.predict(obs, deterministic=True)77 obs, reward, terminated, truncated, info = env.step(action)78 done = terminated or truncated79 80 # Update confident PPO loss counter81 if use_confident and isinstance(model, ConfidentPPO) and env.trade_pnls:82 last_pnl = env.trade_pnls[-1]83 model.update_loss_counter(last_pnl > 0)84 85 return {86 "label": label,87 "start": w_start,88 "end": w_end,89 "trade_pnls": np.array(env.trade_pnls) if env.trade_pnls else np.array([]),90 "balance_history": np.array(env.balance_history),91 "trade_log": env.trade_log,92 "final_balance": env.balance,93 "total_trades": env.total_trades,94 "n_bars": len(window_data),95 }96 97 98def run_backtest() -> Dict:99 """Run full walk-forward backtest over the test period."""100 logger.info("=" * 60)101 logger.info("STARTING WALK-FORWARD BACKTEST")102 logger.info("=" * 60)103 104 set_all_seeds(config.RANDOM_SEED)105 os.makedirs(config.PLOTS_DIR, exist_ok=True)106 107 # Load model108 model_path = config.BEST_MODEL_FILE109 if not os.path.exists(model_path):110 model_path = os.path.join(config.MODEL_DIR, "final_model.zip")111 if not os.path.exists(model_path):112 raise FileNotFoundError(f"No trained model found at {model_path}")113 114 logger.info(f"Loading model: {model_path}")115 raw_model = PPO.load(model_path)116 117 # Load data118 if not os.path.exists(config.FEATURES_CSV):119 raise FileNotFoundError(f"Features not found: {config.FEATURES_CSV}")120 121 data = pd.read_csv(config.FEATURES_CSV, parse_dates=["datetime"])122 123 # Compute n_features for ConfidentPPO (must match ForexTradingEnv's feature count)124 ohlcv_cols = {"datetime", "date", "open", "high", "low", "close", "volume",125 "spread", "spread_pips", "atr_14_raw"}126 n_features = len([c for c in data.columns if c not in ohlcv_cols])127 model = ConfidentPPO(raw_model, n_features=n_features)128 logger.info(f"Data loaded: {len(data):,} rows")129 130 # Filter to test period only131 test_data = data[(data["datetime"] >= config.TEST_START) & (data["datetime"] <= config.TEST_END)]132 logger.info(f"Test data: {len(test_data):,} rows ({config.TEST_START.date()} → {config.TEST_END.date()})")133 134 if len(test_data) == 0:135 raise ValueError("No test data available for the specified backtest period!")136 137 # Verify no leakage138 train_end = data[data["datetime"] <= config.TRAIN_END]["datetime"].max()139 test_start = test_data["datetime"].min()140 assert train_end < test_start, f"DATA LEAK: train_end={train_end} >= test_start={test_start}"141 logger.info(f"Leakage check passed ✓ (train_end={train_end.date()}, test_start={test_start.date()})")142 143 # Monthly walk-forward144 windows = get_monthly_windows(config.TEST_START, config.TEST_END)145 logger.info(f"Walk-forward windows: {len(windows)}")146 147 all_trade_pnls = []148 all_balance_history = [config.INITIAL_BALANCE]149 monthly_results = []150 151 running_balance = config.INITIAL_BALANCE152 153 for window in windows:154 result = run_single_window(model, data, window)155 if result is None:156 continue157 158 monthly_results.append(result)159 all_trade_pnls.extend(result["trade_pnls"].tolist())160 161 # Chain balance across windows162 if len(result["balance_history"]) > 1:163 offset = running_balance - result["balance_history"][0]164 adjusted = result["balance_history"][1:] + offset165 all_balance_history.extend(adjusted.tolist())166 running_balance = adjusted[-1]167 168 all_trade_pnls = np.array(all_trade_pnls)169 all_balance_history = np.array(all_balance_history)170 171 # Compute metrics172 total_bars = sum(r["n_bars"] for r in monthly_results)173 metrics = compute_all_metrics(all_trade_pnls, all_balance_history, total_bars)174 175 # Add monthly breakdown176 monthly_returns = []177 for r in monthly_results:178 if r["total_trades"] > 0:179 month_return = (r["final_balance"] - config.INITIAL_BALANCE) / config.INITIAL_BALANCE * 100180 else:181 month_return = 0.0182 monthly_returns.append({"month": r["label"], "return_pct": round(month_return, 2),183 "trades": r["total_trades"]})184 185 # Monte Carlo186 logger.info("Running Monte Carlo simulation...")187 mc_results = monte_carlo_simulation(all_trade_pnls) if len(all_trade_pnls) > 5 else {}188 189 # Risk of Ruin190 wr = compute_win_rate(all_trade_pnls) / 100191 wins = all_trade_pnls[all_trade_pnls > 0]192 losses = all_trade_pnls[all_trade_pnls < 0]193 avg_w = float(np.mean(wins)) if len(wins) > 0 else 0194 avg_l = float(abs(np.mean(losses))) if len(losses) > 0 else 0195 ror = risk_of_ruin_analytical(wr, avg_w, avg_l) if wr > 0 else 1.0196 197 # Print results198 logger.info("\n" + "=" * 60)199 logger.info("BACKTEST RESULTS")200 logger.info("=" * 60)201 for k, v in metrics.items():202 logger.info(f" {k}: {v}")203 logger.info(f"\n Risk of Ruin: {ror:.4f} ({ror*100:.2f}%)")204 if mc_results:205 logger.info(f" MC P(Ruin): {mc_results.get('probability_of_ruin', 'N/A')}%")206 logger.info(f" MC Median Balance: ${mc_results.get('median_final_balance', 'N/A')}")207 logger.info(f" MC 95% DD: {mc_results.get('p95_max_drawdown_pct', 'N/A')}%")208 209 logger.info("\n Monthly Breakdown:")210 for m in monthly_returns:211 logger.info(f" {m['month']}: {m['return_pct']:+.2f}% ({m['trades']} trades)")212 213 # Generate plots214 _generate_plots(all_balance_history, all_trade_pnls, monthly_results, mc_results)215 216 # Save results217 results = {218 "metrics": metrics,219 "monthly_returns": monthly_returns,220 "risk_of_ruin": round(ror, 4),221 "monte_carlo": {k: v for k, v in mc_results.items() if k not in ["final_balances", "max_drawdowns"]}222 if mc_results else {},223 }224 225 results_path = os.path.join(config.RESULTS_DIR, "backtest_results.json")226 import json227 with open(results_path, "w") as f:228 json.dump(results, f, indent=2, default=str)229 logger.info(f"\nResults saved: {results_path}")230 231 # Save trade journal232 all_trades = []233 for r in monthly_results:234 all_trades.extend(r["trade_log"])235 if all_trades:236 trade_df = pd.DataFrame(all_trades)237 trade_df.to_csv(config.TRADE_JOURNAL_CSV, index=False)238 logger.info(f"Trade journal saved: {config.TRADE_JOURNAL_CSV}")239 240 logger.info("=" * 60)241 logger.info("BACKTEST COMPLETE ✓")242 logger.info("=" * 60)243 244 # Send Telegram Results245 msg = (246 "📊 <b>Backtest Complete!</b>\n\n"247 f"Initial Bal: ${metrics.get('Initial Balance', 0)}\n"248 f"Final Bal: ${metrics.get('Final Balance', 0)}\n"249 f"Win Rate: {metrics.get('Win Rate (%)', 0)}%\n"250 f"Profit Factor: {metrics.get('Profit Factor', 0)}\n"251 f"Max Drawdown: {metrics.get('Max Drawdown (%)', 0)}%\n"252 f"Total Trades: {metrics.get('Total Trades', 0)}\n\n"253 f"Risk of Ruin: {ror*100:.2f}%"254 )255 send_telegram_message(msg)256 257 # Send Equity Curve plot if available258 plot_path = os.path.join(config.PLOTS_DIR, "equity_curve.png")259 if os.path.exists(plot_path):260 send_telegram_document(plot_path, "Equity Curve")261 262 return results263 264 265# ─────────────────────────────────────────────────────────────────────266# VISUALIZATION267# ─────────────────────────────────────────────────────────────────────268def _generate_plots(equity: np.ndarray, trade_pnls: np.ndarray,269 monthly_results: List, mc_results: Dict) -> None:270 """Generate all backtest visualization plots."""271 plt.style.use("dark_background")272 sns.set_palette("bright")273 274 # 1. Equity curve with drawdown overlay275 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), height_ratios=[3, 1], sharex=True)276 ax1.plot(equity, color="#00ff88", linewidth=1.2, label="Equity")277 ax1.axhline(y=config.INITIAL_BALANCE, color="gray", linestyle="--", alpha=0.5, label="Initial")278 ax1.set_title("Equity Curve — Walk-Forward Backtest", fontsize=14, fontweight="bold")279 ax1.set_ylabel("Balance ($)")280 ax1.legend()281 ax1.grid(alpha=0.2)282 283 # Drawdown284 peak = np.maximum.accumulate(equity)285 dd = (peak - equity) / peak * 100286 ax2.fill_between(range(len(dd)), dd, color="#ff4444", alpha=0.5)287 ax2.set_ylabel("Drawdown (%)")288 ax2.set_xlabel("Bar")289 ax2.grid(alpha=0.2)290 ax2.invert_yaxis()291 292 plt.tight_layout()293 plt.savefig(os.path.join(config.PLOTS_DIR, "equity_curve.png"), dpi=150, bbox_inches="tight")294 plt.close()295 296 # 2. Trade PnL distribution297 if len(trade_pnls) > 0:298 fig, ax = plt.subplots(figsize=(10, 6))299 colors = ["#00ff88" if p > 0 else "#ff4444" for p in trade_pnls]300 ax.bar(range(len(trade_pnls)), trade_pnls, color=colors, alpha=0.7)301 ax.axhline(y=0, color="white", linewidth=0.5)302 ax.set_title("Trade PnL Distribution", fontsize=14, fontweight="bold")303 ax.set_xlabel("Trade #")304 ax.set_ylabel("PnL ($)")305 ax.grid(alpha=0.2)306 plt.tight_layout()307 plt.savefig(os.path.join(config.PLOTS_DIR, "trade_distribution.png"), dpi=150, bbox_inches="tight")308 plt.close()309 310 # 3. Rolling Sharpe311 if len(trade_pnls) >= config.ROLLING_SHARPE_WINDOW:312 fig, ax = plt.subplots(figsize=(10, 5))313 rolling_sharpe = []314 for i in range(config.ROLLING_SHARPE_WINDOW, len(trade_pnls)):315 window = trade_pnls[i - config.ROLLING_SHARPE_WINDOW:i]316 s = np.mean(window) / (np.std(window) + 1e-8) * np.sqrt(252)317 rolling_sharpe.append(s)318 ax.plot(rolling_sharpe, color="#00ccff", linewidth=1)319 ax.axhline(y=0, color="gray", linestyle="--", alpha=0.5)320 ax.axhline(y=1, color="#00ff88", linestyle="--", alpha=0.3, label="Sharpe=1")321 ax.set_title(f"Rolling Sharpe Ratio ({config.ROLLING_SHARPE_WINDOW}-trade)", fontsize=14, fontweight="bold")322 ax.set_ylabel("Sharpe Ratio")323 ax.legend()324 ax.grid(alpha=0.2)325 plt.tight_layout()326 plt.savefig(os.path.join(config.PLOTS_DIR, "rolling_sharpe.png"), dpi=150, bbox_inches="tight")327 plt.close()328 329 # 4. Position duration distribution330 all_durations = []331 for r in monthly_results:332 for t in r["trade_log"]:333 all_durations.append(t.get("bars_held", 0))334 if all_durations:335 fig, ax = plt.subplots(figsize=(10, 5))336 ax.hist(all_durations, bins=30, color="#9966ff", alpha=0.7, edgecolor="white")337 ax.set_title("Position Duration Distribution", fontsize=14, fontweight="bold")338 ax.set_xlabel("Bars Held")339 ax.set_ylabel("Frequency")340 ax.grid(alpha=0.2)341 plt.tight_layout()342 plt.savefig(os.path.join(config.PLOTS_DIR, "position_duration.png"), dpi=150, bbox_inches="tight")343 plt.close()344 345 # 5. Monthly returns bar chart346 if monthly_results:347 fig, ax = plt.subplots(figsize=(10, 5))348 labels = [r["label"] for r in monthly_results]349 returns = [(r["final_balance"] - config.INITIAL_BALANCE) / config.INITIAL_BALANCE * 100350 for r in monthly_results]351 colors = ["#00ff88" if r > 0 else "#ff4444" for r in returns]352 ax.bar(labels, returns, color=colors, alpha=0.8, edgecolor="white")353 ax.axhline(y=0, color="white", linewidth=0.5)354 ax.set_title("Monthly Returns (%)", fontsize=14, fontweight="bold")355 ax.set_ylabel("Return (%)")356 ax.grid(alpha=0.2)357 plt.xticks(rotation=45)358 plt.tight_layout()359 plt.savefig(os.path.join(config.PLOTS_DIR, "monthly_returns.png"), dpi=150, bbox_inches="tight")360 plt.close()361 362 # 6. Monte Carlo fan chart363 if mc_results and "final_balances" in mc_results:364 fig, ax = plt.subplots(figsize=(10, 6))365 fb = mc_results["final_balances"]366 ax.hist(fb, bins=50, color="#00ccff", alpha=0.7, edgecolor="white")367 ax.axvline(x=config.INITIAL_BALANCE, color="#ff4444", linestyle="--", label="Break-even")368 ax.axvline(x=np.median(fb), color="#00ff88", linestyle="--", label=f"Median: ${np.median(fb):.2f}")369 ax.set_title("Monte Carlo — Final Balance Distribution", fontsize=14, fontweight="bold")370 ax.set_xlabel("Final Balance ($)")371 ax.set_ylabel("Frequency")372 ax.legend()373 ax.grid(alpha=0.2)374 plt.tight_layout()375 plt.savefig(os.path.join(config.PLOTS_DIR, "monte_carlo.png"), dpi=150, bbox_inches="tight")376 plt.close()377 378 logger.info(f"Plots saved to {config.PLOTS_DIR}")379 380 381if __name__ == "__main__":382 run_backtest()383 