Files
XauBot/backtests/archive/walkforward_backtest.py
T
GifariKemal 7af9183af3 feat: Smart AI Trading Bot for XAUUSD with ML and SMC
- XGBoost ML model with 37 features for market direction prediction
- Smart Money Concepts (SMC): Order Blocks, FVG, BOS, CHoCH
- HMM market regime detection (trending/ranging/volatile)
- ATR-based stop loss with 1.5 ATR minimum distance
- Broker-level SL protection with fallback
- Time-based exit (max 6 hours per trade)
- Session-aware trading optimized for London/NY overlap
- Auto-retraining based on market conditions
- Telegram notifications and web dashboard
- Backtest results: 63.9% win rate, 2.64 profit factor, 4.83 Sharpe

Backtest period: Jan 2025 - Feb 2026, 654 trades, $4,189 net P/L

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 09:01:35 +07:00

706 lines
26 KiB
Python

"""
Walk-Forward Optimization Backtest (1 Year)
============================================
Simulasi backtest dengan ML yang belajar progressif setiap bulan.
Periode: Januari 2025 - Februari 2026
Metodologi:
1. Ambil data historis 1 tahun
2. Setiap bulan:
- Train model dengan data sebelumnya (rolling window)
- Backtest bulan tersebut dengan model baru
- Evaluasi dan catat hasil
3. Analisis performa keseluruhan
4. Temukan parameter optimal
"""
import os
import sys
import pickle
import warnings
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
import numpy as np
import polars as pl
from dotenv import load_dotenv
from loguru import logger
warnings.filterwarnings('ignore')
# Configure logging
logger.remove()
logger.add(sys.stdout, format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{message}</cyan>", level="INFO")
load_dotenv()
@dataclass
class MonthlyResult:
"""Result for one month of backtesting."""
month: str
start_date: datetime
end_date: datetime
total_trades: int
wins: int
losses: int
win_rate: float
total_pnl: float
max_drawdown: float
profit_factor: float
model_auc: float
avg_confidence: float
ml_only_trades: int
smc_ml_trades: int
@dataclass
class TradeResult:
"""Individual trade result."""
entry_time: datetime
exit_time: datetime
direction: str
entry_price: float
exit_price: float
lot_size: float
pnl: float
confidence: float
signal_type: str # ML_ONLY or SMC_ML
@dataclass
class WalkForwardConfig:
"""Configuration for walk-forward optimization."""
# Training window (months of data for training)
train_window_months: int = 3
# Minimum bars for training
min_train_bars: int = 5000
# ML thresholds to test
ml_thresholds: List[float] = field(default_factory=lambda: [0.60, 0.65, 0.70, 0.75])
# ML-only thresholds to test
ml_only_thresholds: List[float] = field(default_factory=lambda: [0.70, 0.75, 0.80])
# Lot sizes
base_lot: float = 0.01
max_lot: float = 0.02
# Risk parameters
max_loss_per_trade: float = 30.0
# TP/SL multipliers
tp_atr_mult: float = 2.0
sl_atr_mult: float = 1.5
class WalkForwardBacktest:
"""Walk-forward optimization backtester."""
def __init__(self, config: WalkForwardConfig = None):
self.config = config or WalkForwardConfig()
self.mt5 = None
self.all_data = None
self.monthly_results: List[MonthlyResult] = []
self.all_trades: List[TradeResult] = []
def connect_mt5(self) -> bool:
"""Connect to MT5."""
import MetaTrader5 as mt5
if not mt5.initialize():
logger.error("MT5 initialization failed")
return False
login = int(os.getenv('MT5_LOGIN'))
password = os.getenv('MT5_PASSWORD')
server = os.getenv('MT5_SERVER')
if not mt5.login(login, password, server):
logger.error("MT5 login failed")
return False
account = mt5.account_info()
logger.info(f"Connected to MT5 - Balance: ${account.balance:,.2f}")
self.mt5 = mt5
return True
def fetch_historical_data(self, months: int = 13) -> Optional[pl.DataFrame]:
"""Fetch historical M5 data for the specified period."""
import MetaTrader5 as mt5
# Calculate bars needed (288 bars per day * 22 trading days * months)
bars_per_month = 288 * 22
total_bars = bars_per_month * months
logger.info(f"Fetching {total_bars:,} bars ({months} months of M5 data)...")
# MT5 has limit, fetch in chunks if needed
max_bars = 100000
rates = mt5.copy_rates_from_pos("XAUUSD", mt5.TIMEFRAME_M5, 0, min(total_bars, max_bars))
if rates is None or len(rates) == 0:
logger.error("Failed to fetch historical data")
return None
# Convert to polars DataFrame
df = pl.DataFrame({
'time': [datetime.fromtimestamp(r[0]) for r in rates],
'open': [r[1] for r in rates],
'high': [r[2] for r in rates],
'low': [r[3] for r in rates],
'close': [r[4] for r in rates],
'volume': [float(r[5]) for r in rates],
})
logger.info(f"Fetched {len(df):,} bars")
logger.info(f"Date range: {df['time'].min()} to {df['time'].max()}")
self.all_data = df
return df
def prepare_features(self, df: pl.DataFrame) -> pl.DataFrame:
"""Calculate all features needed for ML."""
from src.feature_eng import FeatureEngineer
from src.smc_polars import SMCAnalyzer
feature_eng = FeatureEngineer()
smc = SMCAnalyzer()
df = feature_eng.calculate_all(df)
df = smc.calculate_all(df)
return df
def train_models(self, train_df: pl.DataFrame) -> Tuple[object, object, float]:
"""Train HMM and XGBoost models on training data."""
from src.regime_detector import MarketRegimeDetector
from src.ml_model import TradingModel
# Train HMM Regime Detector
regime = MarketRegimeDetector()
# Prepare features for HMM
train_df = self.prepare_features(train_df)
# Train regime detector
try:
regime.fit(train_df)
except Exception as e:
logger.warning(f"HMM training failed: {e}, using default")
regime.load() # Load pre-trained as fallback
# Add regime predictions
train_df = regime.predict(train_df)
# Train XGBoost
ml_model = TradingModel()
# Prepare labels (next bar direction)
train_df = train_df.with_columns([
(pl.col('close').shift(-1) > pl.col('close')).cast(pl.Int32).alias('target')
])
# Drop nulls
train_df = train_df.drop_nulls()
# Get feature columns
feature_cols = [c for c in train_df.columns if c not in ['time', 'target', 'open', 'high', 'low', 'close', 'volume']]
# Train model
try:
X = train_df.select(feature_cols).to_numpy()
y = train_df['target'].to_numpy()
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
ml_model.train(X_train, y_train, X_test, y_test, feature_cols)
auc = ml_model.test_auc if hasattr(ml_model, 'test_auc') else 0.5
except Exception as e:
logger.warning(f"XGBoost training failed: {e}, using default")
ml_model.load("models/xgboost_model.pkl")
auc = 0.5
return regime, ml_model, auc
def simulate_month(
self,
test_df: pl.DataFrame,
regime: object,
ml_model: object,
ml_threshold: float = 0.65,
ml_only_threshold: float = 0.75,
) -> Tuple[List[TradeResult], float]:
"""Simulate trading for one month."""
from src.smc_polars import SMCAnalyzer
from src.dynamic_confidence import create_dynamic_confidence
smc = SMCAnalyzer()
dynamic_conf = create_dynamic_confidence()
trades = []
position = None
total_confidence = 0
confidence_count = 0
# Prepare test data with features
test_df = self.prepare_features(test_df)
test_df = regime.predict(test_df)
# Iterate through test period
for i in range(100, len(test_df) - 20): # Leave room for TP/SL check
row = test_df.row(i, named=True)
current_time = row['time']
# Skip if already in position
if position is not None:
# Check if position should be closed
for j in range(i + 1, min(i + 20, len(test_df))):
future_row = test_df.row(j, named=True)
if position['direction'] == 'BUY':
# Check TP
if future_row['high'] >= position['tp']:
pnl = (position['tp'] - position['entry']) * position['lot'] * 100
trades.append(TradeResult(
entry_time=position['time'],
exit_time=future_row['time'],
direction='BUY',
entry_price=position['entry'],
exit_price=position['tp'],
lot_size=position['lot'],
pnl=pnl,
confidence=position['confidence'],
signal_type=position['signal_type'],
))
position = None
break
# Check SL
if future_row['low'] <= position['sl']:
pnl = (position['sl'] - position['entry']) * position['lot'] * 100
pnl = max(pnl, -self.config.max_loss_per_trade)
trades.append(TradeResult(
entry_time=position['time'],
exit_time=future_row['time'],
direction='BUY',
entry_price=position['entry'],
exit_price=position['sl'],
lot_size=position['lot'],
pnl=pnl,
confidence=position['confidence'],
signal_type=position['signal_type'],
))
position = None
break
else: # SELL
# Check TP
if future_row['low'] <= position['tp']:
pnl = (position['entry'] - position['tp']) * position['lot'] * 100
trades.append(TradeResult(
entry_time=position['time'],
exit_time=future_row['time'],
direction='SELL',
entry_price=position['entry'],
exit_price=position['tp'],
lot_size=position['lot'],
pnl=pnl,
confidence=position['confidence'],
signal_type=position['signal_type'],
))
position = None
break
# Check SL
if future_row['high'] >= position['sl']:
pnl = (position['entry'] - position['sl']) * position['lot'] * 100
pnl = max(pnl, -self.config.max_loss_per_trade)
trades.append(TradeResult(
entry_time=position['time'],
exit_time=future_row['time'],
direction='SELL',
entry_price=position['entry'],
exit_price=position['sl'],
lot_size=position['lot'],
pnl=pnl,
confidence=position['confidence'],
signal_type=position['signal_type'],
))
position = None
break
if position is not None:
# Position still open, skip to next bar
continue
# Check for new signal
# Get ML prediction
try:
window_df = test_df.slice(max(0, i - 100), 101)
ml_pred = ml_model.predict(window_df)
if ml_pred.confidence < ml_threshold:
continue
total_confidence += ml_pred.confidence
confidence_count += 1
# Get SMC signal
smc_signal = smc.generate_signal(window_df)
has_smc = smc_signal is not None
# Apply entry rules
signal_type = None
direction = None
if has_smc:
# SMC + ML must agree
smc_dir = smc_signal.signal_type if smc_signal else None
if smc_dir == ml_pred.signal and ml_pred.confidence >= ml_threshold:
signal_type = "SMC_ML"
direction = ml_pred.signal
else:
# ML-only needs higher threshold
if ml_pred.confidence >= ml_only_threshold:
signal_type = "ML_ONLY"
direction = ml_pred.signal
if direction is None:
continue
# Session filter (simplified)
hour = current_time.hour
# London: 8-16 UTC, NY: 13-21 UTC, Overlap: 13-16 UTC
if not (8 <= hour <= 21):
continue # Skip Asia/Sydney
# Calculate TP/SL based on ATR
atr = row.get('atr_14', 2.0)
if atr is None or atr < 0.5:
atr = 2.0
entry_price = row['close']
if direction == 'BUY':
tp = entry_price + (atr * self.config.tp_atr_mult)
sl = entry_price - (atr * self.config.sl_atr_mult)
else:
tp = entry_price - (atr * self.config.tp_atr_mult)
sl = entry_price + (atr * self.config.sl_atr_mult)
# Open position
position = {
'time': current_time,
'direction': direction,
'entry': entry_price,
'tp': tp,
'sl': sl,
'lot': self.config.base_lot,
'confidence': ml_pred.confidence,
'signal_type': signal_type,
}
except Exception as e:
continue
avg_confidence = total_confidence / confidence_count if confidence_count > 0 else 0
return trades, avg_confidence
def calculate_metrics(self, trades: List[TradeResult]) -> Dict:
"""Calculate performance metrics from trades."""
if not trades:
return {
'total_trades': 0,
'wins': 0,
'losses': 0,
'win_rate': 0,
'total_pnl': 0,
'max_drawdown': 0,
'profit_factor': 0,
'ml_only_trades': 0,
'smc_ml_trades': 0,
}
wins = len([t for t in trades if t.pnl > 0])
losses = len([t for t in trades if t.pnl <= 0])
total_pnl = sum(t.pnl for t in trades)
# Calculate max drawdown
cumulative = 0
peak = 0
max_dd = 0
for t in trades:
cumulative += t.pnl
if cumulative > peak:
peak = cumulative
dd = peak - cumulative
if dd > max_dd:
max_dd = dd
# Profit factor
gross_profit = sum(t.pnl for t in trades if t.pnl > 0)
gross_loss = abs(sum(t.pnl for t in trades if t.pnl < 0))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
ml_only = len([t for t in trades if t.signal_type == 'ML_ONLY'])
smc_ml = len([t for t in trades if t.signal_type == 'SMC_ML'])
return {
'total_trades': len(trades),
'wins': wins,
'losses': losses,
'win_rate': (wins / len(trades) * 100) if trades else 0,
'total_pnl': total_pnl,
'max_drawdown': max_dd,
'profit_factor': profit_factor,
'ml_only_trades': ml_only,
'smc_ml_trades': smc_ml,
}
def run_walkforward(
self,
start_month: int = 1, # January
start_year: int = 2025,
end_month: int = 2, # February
end_year: int = 2026,
):
"""Run walk-forward optimization."""
if self.all_data is None:
logger.error("No data loaded. Call fetch_historical_data first.")
return
logger.info("=" * 70)
logger.info("WALK-FORWARD OPTIMIZATION BACKTEST")
logger.info("=" * 70)
logger.info(f"Period: {start_month}/{start_year} - {end_month}/{end_year}")
logger.info(f"Training window: {self.config.train_window_months} months")
logger.info(f"ML Thresholds to test: {self.config.ml_thresholds}")
logger.info(f"ML-Only Thresholds to test: {self.config.ml_only_thresholds}")
logger.info("=" * 70)
print()
# Best parameters tracking
best_params = {
'ml_threshold': 0.65,
'ml_only_threshold': 0.75,
'total_pnl': float('-inf'),
'win_rate': 0,
}
# Generate month ranges
current = datetime(start_year, start_month, 1)
end = datetime(end_year, end_month, 1)
months = []
while current < end:
next_month = current + timedelta(days=32)
next_month = datetime(next_month.year, next_month.month, 1)
months.append((current, next_month))
current = next_month
logger.info(f"Testing {len(months)} months")
print()
# Test different parameter combinations
param_results = []
for ml_thresh in self.config.ml_thresholds:
for ml_only_thresh in self.config.ml_only_thresholds:
if ml_only_thresh < ml_thresh:
continue # ML-only should be >= base threshold
logger.info(f"Testing: ML={ml_thresh:.0%}, ML-Only={ml_only_thresh:.0%}")
monthly_results = []
all_month_trades = []
for month_start, month_end in months:
# Get training data (previous N months)
train_start = month_start - timedelta(days=self.config.train_window_months * 30)
train_df = self.all_data.filter(
(pl.col('time') >= train_start) & (pl.col('time') < month_start)
)
test_df = self.all_data.filter(
(pl.col('time') >= month_start) & (pl.col('time') < month_end)
)
if len(train_df) < self.config.min_train_bars:
logger.warning(f" Skipping {month_start.strftime('%Y-%m')}: insufficient training data ({len(train_df)} bars)")
continue
if len(test_df) < 100:
logger.warning(f" Skipping {month_start.strftime('%Y-%m')}: insufficient test data ({len(test_df)} bars)")
continue
# Train models
try:
regime, ml_model, auc = self.train_models(train_df)
except Exception as e:
logger.warning(f" Training failed for {month_start.strftime('%Y-%m')}: {e}")
continue
# Simulate month
trades, avg_conf = self.simulate_month(
test_df, regime, ml_model,
ml_threshold=ml_thresh,
ml_only_threshold=ml_only_thresh,
)
# Calculate metrics
metrics = self.calculate_metrics(trades)
month_result = MonthlyResult(
month=month_start.strftime('%Y-%m'),
start_date=month_start,
end_date=month_end,
total_trades=metrics['total_trades'],
wins=metrics['wins'],
losses=metrics['losses'],
win_rate=metrics['win_rate'],
total_pnl=metrics['total_pnl'],
max_drawdown=metrics['max_drawdown'],
profit_factor=metrics['profit_factor'],
model_auc=auc,
avg_confidence=avg_conf,
ml_only_trades=metrics['ml_only_trades'],
smc_ml_trades=metrics['smc_ml_trades'],
)
monthly_results.append(month_result)
all_month_trades.extend(trades)
# Calculate total performance for this parameter set
total_pnl = sum(m.total_pnl for m in monthly_results)
total_trades = sum(m.total_trades for m in monthly_results)
total_wins = sum(m.wins for m in monthly_results)
avg_win_rate = (total_wins / total_trades * 100) if total_trades > 0 else 0
param_results.append({
'ml_threshold': ml_thresh,
'ml_only_threshold': ml_only_thresh,
'total_pnl': total_pnl,
'total_trades': total_trades,
'win_rate': avg_win_rate,
'monthly_results': monthly_results,
})
logger.info(f" Result: {total_trades} trades, {avg_win_rate:.1f}% WR, ${total_pnl:+,.2f}")
if total_pnl > best_params['total_pnl']:
best_params = {
'ml_threshold': ml_thresh,
'ml_only_threshold': ml_only_thresh,
'total_pnl': total_pnl,
'win_rate': avg_win_rate,
'monthly_results': monthly_results,
}
print()
logger.info("=" * 70)
logger.info("OPTIMIZATION RESULTS")
logger.info("=" * 70)
print()
# Sort by total P/L
param_results.sort(key=lambda x: x['total_pnl'], reverse=True)
print("Parameter Combinations (sorted by P/L):")
print("-" * 60)
for i, p in enumerate(param_results[:10]):
print(f" {i+1}. ML={p['ml_threshold']:.0%}, ML-Only={p['ml_only_threshold']:.0%}")
print(f" Trades: {p['total_trades']}, Win Rate: {p['win_rate']:.1f}%, P/L: ${p['total_pnl']:+,.2f}")
print()
# Show best parameters
logger.info("=" * 70)
logger.info("BEST PARAMETERS FOUND")
logger.info("=" * 70)
print(f" ML Threshold : {best_params['ml_threshold']:.0%}")
print(f" ML-Only Threshold : {best_params['ml_only_threshold']:.0%}")
print(f" Total P/L : ${best_params['total_pnl']:+,.2f}")
print(f" Win Rate : {best_params['win_rate']:.1f}%")
print()
# Show monthly breakdown for best params
if 'monthly_results' in best_params:
print("Monthly Breakdown (Best Parameters):")
print("-" * 70)
print(f"{'Month':<10} {'Trades':>8} {'Wins':>6} {'WR%':>8} {'P/L':>12} {'PF':>8}")
print("-" * 70)
for m in best_params['monthly_results']:
print(f"{m.month:<10} {m.total_trades:>8} {m.wins:>6} {m.win_rate:>7.1f}% ${m.total_pnl:>10.2f} {m.profit_factor:>7.2f}")
print("-" * 70)
total_trades = sum(m.total_trades for m in best_params['monthly_results'])
total_wins = sum(m.wins for m in best_params['monthly_results'])
total_pnl = sum(m.total_pnl for m in best_params['monthly_results'])
avg_wr = (total_wins / total_trades * 100) if total_trades > 0 else 0
print(f"{'TOTAL':<10} {total_trades:>8} {total_wins:>6} {avg_wr:>7.1f}% ${total_pnl:>10.2f}")
print()
logger.info("=" * 70)
logger.info("RECOMMENDATIONS")
logger.info("=" * 70)
print()
print(f"Based on 1-year walk-forward optimization:")
print(f" 1. Set ML threshold to: {best_params['ml_threshold']:.0%}")
print(f" 2. Set ML-only threshold to: {best_params['ml_only_threshold']:.0%}")
print(f" 3. Expected monthly P/L: ${best_params['total_pnl'] / len(best_params.get('monthly_results', [1])):+,.2f}")
print()
return best_params, param_results
def main():
"""Main function."""
print("=" * 70)
print("WALK-FORWARD OPTIMIZATION BACKTEST")
print("=" * 70)
print()
print("This will:")
print(" 1. Fetch 13 months of historical data (Jan 2025 - Feb 2026)")
print(" 2. Train ML models progressively each month")
print(" 3. Test different parameter combinations")
print(" 4. Find optimal ML thresholds")
print()
# Initialize
config = WalkForwardConfig(
train_window_months=3,
ml_thresholds=[0.55, 0.60, 0.65, 0.70, 0.75],
ml_only_thresholds=[0.65, 0.70, 0.75, 0.80, 0.85],
base_lot=0.01,
max_lot=0.02,
max_loss_per_trade=30.0,
)
backtest = WalkForwardBacktest(config)
# Connect to MT5
if not backtest.connect_mt5():
print("Failed to connect to MT5")
return
# Fetch historical data
data = backtest.fetch_historical_data(months=14)
if data is None:
print("Failed to fetch historical data")
return
# Run walk-forward optimization
best_params, all_results = backtest.run_walkforward(
start_month=1,
start_year=2025,
end_month=2,
end_year=2026,
)
# Shutdown MT5
import MetaTrader5 as mt5
mt5.shutdown()
print()
print("Walk-forward optimization complete!")
print(f"Best parameters saved for future use.")
if __name__ == "__main__":
main()