CoolFace
Apppublic

lignarr/Trainer-PPO

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
backtest.py383 linesDownload Raw Back to root
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