7af9183af3
- 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>
951 lines
32 KiB
Python
951 lines
32 KiB
Python
"""
|
|
Comprehensive Backtest Comparison: SMC Only vs ML+SMC
|
|
======================================================
|
|
Tests multiple strategy combinations across ALL trading sessions.
|
|
|
|
Strategies:
|
|
1. SMC Only - Trade whenever SMC signal appears
|
|
2. ML Only - Trade when ML confidence >= threshold
|
|
3. SMC + ML - Require both signals agree
|
|
4. SMC + ML Weak Filter - SMC signal + ML > 50%
|
|
|
|
Sessions (WIB Timezone):
|
|
- Sydney-Tokyo: 06:00-15:00
|
|
- Tokyo-London Overlap: 15:00-16:00
|
|
- London: 16:00-20:00
|
|
- London-NY Overlap (Golden Time): 19:00-23:00
|
|
- NY Session: 20:00-04:00
|
|
|
|
Author: Trading Bot AI
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
sys.path.insert(0, 'src')
|
|
|
|
import polars as pl
|
|
import numpy as np
|
|
from datetime import datetime, timedelta
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Dict, Optional, Tuple
|
|
from dotenv import load_dotenv
|
|
from tabulate import tabulate
|
|
from loguru import logger
|
|
|
|
load_dotenv()
|
|
|
|
# Import our modules
|
|
from mt5_connector import MT5Connector
|
|
from feature_eng import FeatureEngineer
|
|
from smc_polars import SMCAnalyzer
|
|
from ml_model import TradingModel
|
|
from regime_detector import MarketRegimeDetector
|
|
|
|
|
|
# ============================================================================
|
|
# DATA STRUCTURES
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class Trade:
|
|
"""Single trade record."""
|
|
entry_time: datetime
|
|
entry_price: float
|
|
direction: str # "BUY" or "SELL"
|
|
exit_time: Optional[datetime] = None
|
|
exit_price: Optional[float] = None
|
|
pnl_usd: float = 0.0
|
|
pnl_pips: float = 0.0
|
|
exit_reason: str = ""
|
|
session: str = ""
|
|
strategy: str = ""
|
|
ml_confidence: float = 0.0
|
|
smc_reason: str = ""
|
|
|
|
|
|
@dataclass
|
|
class SessionStats:
|
|
"""Statistics for a single session."""
|
|
session_name: str
|
|
total_trades: int = 0
|
|
wins: int = 0
|
|
losses: int = 0
|
|
total_pnl: float = 0.0
|
|
total_pips: float = 0.0
|
|
gross_profit: float = 0.0
|
|
gross_loss: float = 0.0
|
|
avg_win: float = 0.0
|
|
avg_loss: float = 0.0
|
|
max_win: float = 0.0
|
|
max_loss: float = 0.0
|
|
|
|
@property
|
|
def win_rate(self) -> float:
|
|
return (self.wins / self.total_trades * 100) if self.total_trades > 0 else 0.0
|
|
|
|
@property
|
|
def profit_factor(self) -> float:
|
|
return (self.gross_profit / abs(self.gross_loss)) if self.gross_loss != 0 else float('inf')
|
|
|
|
|
|
@dataclass
|
|
class StrategyResult:
|
|
"""Complete results for a strategy."""
|
|
strategy_name: str
|
|
initial_balance: float = 10000.0
|
|
total_trades: int = 0
|
|
wins: int = 0
|
|
losses: int = 0
|
|
total_pnl: float = 0.0
|
|
total_pips: float = 0.0
|
|
gross_profit: float = 0.0
|
|
gross_loss: float = 0.0
|
|
max_drawdown: float = 0.0
|
|
max_drawdown_pct: float = 0.0
|
|
best_trade: float = 0.0
|
|
worst_trade: float = 0.0
|
|
avg_trade: float = 0.0
|
|
session_breakdown: Dict[str, SessionStats] = field(default_factory=dict)
|
|
trades: List[Trade] = field(default_factory=list)
|
|
equity_curve: List[float] = field(default_factory=list)
|
|
|
|
@property
|
|
def win_rate(self) -> float:
|
|
return (self.wins / self.total_trades * 100) if self.total_trades > 0 else 0.0
|
|
|
|
@property
|
|
def profit_factor(self) -> float:
|
|
return (self.gross_profit / abs(self.gross_loss)) if self.gross_loss != 0 else float('inf')
|
|
|
|
|
|
# ============================================================================
|
|
# SESSION DEFINITIONS (WIB TIMEZONE)
|
|
# ============================================================================
|
|
|
|
SESSIONS = {
|
|
"Sydney-Tokyo": {
|
|
"start_hour": 6,
|
|
"end_hour": 15,
|
|
"description": "Asian Session - Lower volatility",
|
|
},
|
|
"Tokyo-London Overlap": {
|
|
"start_hour": 15,
|
|
"end_hour": 16,
|
|
"description": "Overlap - Increasing volatility",
|
|
},
|
|
"London": {
|
|
"start_hour": 16,
|
|
"end_hour": 20, # Before NY overlap
|
|
"description": "London Main - High volatility",
|
|
},
|
|
"London-NY Overlap": {
|
|
"start_hour": 19,
|
|
"end_hour": 23,
|
|
"description": "Golden Time - Maximum volatility",
|
|
},
|
|
"NY Session": {
|
|
"start_hour": 20,
|
|
"end_hour": 4, # Next day
|
|
"description": "NY Main - High volatility",
|
|
},
|
|
}
|
|
|
|
# Danger zones to avoid
|
|
DANGER_ZONES = [
|
|
(4, 6), # Rollover time - wide spreads
|
|
(0, 4), # Dead zone - low liquidity (except NY end)
|
|
]
|
|
|
|
|
|
def get_session_name(hour: int) -> str:
|
|
"""Determine trading session based on WIB hour."""
|
|
# Check for danger zones first
|
|
for start, end in DANGER_ZONES:
|
|
if start <= hour < end:
|
|
return "Danger Zone"
|
|
|
|
# Prioritize overlaps
|
|
if 19 <= hour < 23:
|
|
return "London-NY Overlap"
|
|
elif 15 <= hour < 16:
|
|
return "Tokyo-London Overlap"
|
|
elif 16 <= hour < 20:
|
|
return "London"
|
|
elif 20 <= hour < 24:
|
|
return "NY Session"
|
|
elif 6 <= hour < 15:
|
|
return "Sydney-Tokyo"
|
|
else:
|
|
return "Off-Hours"
|
|
|
|
|
|
def is_tradeable_hour(hour: int) -> bool:
|
|
"""Check if hour is in tradeable zone."""
|
|
# Avoid danger zones
|
|
if 0 <= hour < 6:
|
|
return False
|
|
return True
|
|
|
|
|
|
# ============================================================================
|
|
# SIGNAL GENERATION
|
|
# ============================================================================
|
|
|
|
def generate_smc_signal(row: dict) -> Tuple[str, str]:
|
|
"""
|
|
Generate SMC signal from row data.
|
|
Returns: (direction, reason)
|
|
"""
|
|
market_structure = row.get('market_structure', 0)
|
|
bos = row.get('bos', 0)
|
|
choch = row.get('choch', 0)
|
|
fvg_bull = row.get('is_fvg_bull', False)
|
|
fvg_bear = row.get('is_fvg_bear', False)
|
|
ob = row.get('ob', 0)
|
|
|
|
# Build reason string
|
|
reasons = []
|
|
|
|
# Bullish conditions
|
|
bullish_structure = market_structure == 1 or bos == 1 or choch == 1
|
|
bearish_structure = market_structure == -1 or bos == -1 or choch == -1
|
|
|
|
# More relaxed SMC signal - need structure + one confirmation
|
|
if bullish_structure:
|
|
if fvg_bull or ob == 1:
|
|
reasons.append("Bullish Structure")
|
|
if bos == 1: reasons.append("BOS")
|
|
if choch == 1: reasons.append("CHoCH")
|
|
if fvg_bull: reasons.append("FVG")
|
|
if ob == 1: reasons.append("OB")
|
|
return "BUY", " + ".join(reasons)
|
|
|
|
if bearish_structure:
|
|
if fvg_bear or ob == -1:
|
|
reasons.append("Bearish Structure")
|
|
if bos == -1: reasons.append("BOS")
|
|
if choch == -1: reasons.append("CHoCH")
|
|
if fvg_bear: reasons.append("FVG")
|
|
if ob == -1: reasons.append("OB")
|
|
return "SELL", " + ".join(reasons)
|
|
|
|
return "NONE", ""
|
|
|
|
|
|
def generate_ml_signal(row: dict, threshold: float = 0.65) -> Tuple[str, float]:
|
|
"""
|
|
Generate ML signal from row data.
|
|
Returns: (direction, confidence)
|
|
"""
|
|
prob_up = row.get('pred_prob_up', 0.5)
|
|
if prob_up is None:
|
|
prob_up = 0.5
|
|
|
|
if prob_up >= threshold:
|
|
return "BUY", prob_up
|
|
elif (1 - prob_up) >= threshold:
|
|
return "SELL", 1 - prob_up
|
|
else:
|
|
return "HOLD", max(prob_up, 1 - prob_up)
|
|
|
|
|
|
# ============================================================================
|
|
# BACKTEST ENGINE
|
|
# ============================================================================
|
|
|
|
class BacktestEngine:
|
|
"""Main backtest engine."""
|
|
|
|
def __init__(
|
|
self,
|
|
initial_balance: float = 10000.0,
|
|
lot_size: float = 0.01,
|
|
take_profit_usd: float = 15.0, # $15 target
|
|
stop_loss_usd: float = 10.0, # $10 risk
|
|
max_bars_in_trade: int = 48, # Max 12 hours in trade (M15)
|
|
):
|
|
self.initial_balance = initial_balance
|
|
self.lot_size = lot_size
|
|
self.take_profit_usd = take_profit_usd
|
|
self.stop_loss_usd = stop_loss_usd
|
|
self.max_bars_in_trade = max_bars_in_trade
|
|
|
|
# For XAUUSD: 1 pip = $0.01 price movement
|
|
# 0.01 lot = $0.10 per pip
|
|
self.pip_value_per_lot = 0.10
|
|
|
|
def calculate_pnl(self, entry_price: float, exit_price: float, direction: str) -> Tuple[float, float]:
|
|
"""
|
|
Calculate PnL in USD and pips.
|
|
|
|
XAUUSD pip calculation:
|
|
- 1 pip = $0.01 movement
|
|
- For XAUUSD $1 = 100 pips
|
|
- 0.01 lot = $0.10 per pip ($1 per 10 pip movement)
|
|
"""
|
|
if direction == "BUY":
|
|
price_diff = exit_price - entry_price
|
|
else:
|
|
price_diff = entry_price - exit_price
|
|
|
|
# Convert price diff to pips (1 pip = $0.01 for XAUUSD)
|
|
pips = price_diff * 100 # $1 = 100 pips
|
|
|
|
# USD calculation: 0.01 lot = $0.10 per pip
|
|
usd = pips * 0.10 * (self.lot_size / 0.01)
|
|
|
|
return usd, pips
|
|
|
|
def run_strategy(
|
|
self,
|
|
df: pl.DataFrame,
|
|
strategy_name: str,
|
|
signal_generator,
|
|
allowed_sessions: Optional[List[str]] = None,
|
|
) -> StrategyResult:
|
|
"""
|
|
Run backtest for a specific strategy.
|
|
|
|
Args:
|
|
df: DataFrame with all indicators
|
|
strategy_name: Name of the strategy
|
|
signal_generator: Function(row) -> (should_enter, direction, confidence, reason)
|
|
allowed_sessions: List of session names to trade, None for all
|
|
"""
|
|
result = StrategyResult(strategy_name=strategy_name, initial_balance=self.initial_balance)
|
|
result.equity_curve = [self.initial_balance]
|
|
|
|
position: Optional[Trade] = None
|
|
position_entry_bar: int = 0
|
|
max_equity = self.initial_balance
|
|
|
|
rows = df.to_dicts()
|
|
|
|
for i, row in enumerate(rows):
|
|
if i < 50: # Warmup period
|
|
continue
|
|
|
|
# Get current time
|
|
current_time = row.get('time', datetime.now())
|
|
if isinstance(current_time, str):
|
|
current_time = datetime.fromisoformat(current_time)
|
|
|
|
hour = current_time.hour
|
|
session = get_session_name(hour)
|
|
|
|
# Skip if session not allowed
|
|
if allowed_sessions and session not in allowed_sessions:
|
|
continue
|
|
|
|
# Skip danger zones
|
|
if session in ["Danger Zone", "Off-Hours"]:
|
|
continue
|
|
|
|
price = row.get('close', 0)
|
|
if price <= 0:
|
|
continue
|
|
|
|
# Check for position exit
|
|
if position:
|
|
pnl_usd, pnl_pips = self.calculate_pnl(position.entry_price, price, position.direction)
|
|
|
|
# Track bars in trade
|
|
bars_in_trade = i - position_entry_bar if hasattr(position, 'entry_bar') else 0
|
|
|
|
exit_reason = None
|
|
|
|
# Take Profit (based on USD)
|
|
if pnl_usd >= self.take_profit_usd:
|
|
exit_reason = "Take Profit"
|
|
# Stop Loss (based on USD)
|
|
elif pnl_usd <= -self.stop_loss_usd:
|
|
exit_reason = "Stop Loss"
|
|
# Time-based exit (max bars in trade)
|
|
elif bars_in_trade >= self.max_bars_in_trade:
|
|
exit_reason = "Time Exit"
|
|
# End of data
|
|
elif i >= len(rows) - 1:
|
|
exit_reason = "End of Data"
|
|
# Reversal signal (optional - check for opposite signal)
|
|
else:
|
|
should_enter, direction, _, _ = signal_generator(row)
|
|
if should_enter and direction != position.direction:
|
|
exit_reason = f"Signal Reversal ({direction})"
|
|
|
|
if exit_reason:
|
|
position.exit_time = current_time
|
|
position.exit_price = price
|
|
position.pnl_usd = pnl_usd
|
|
position.pnl_pips = pnl_pips
|
|
position.exit_reason = exit_reason
|
|
|
|
result.trades.append(position)
|
|
|
|
# Update equity curve
|
|
new_equity = result.equity_curve[-1] + pnl_usd
|
|
result.equity_curve.append(new_equity)
|
|
|
|
# Track max drawdown
|
|
max_equity = max(max_equity, new_equity)
|
|
drawdown = max_equity - new_equity
|
|
result.max_drawdown = max(result.max_drawdown, drawdown)
|
|
|
|
position = None
|
|
continue
|
|
|
|
# Check for entry if no position
|
|
if not position:
|
|
should_enter, direction, confidence, reason = signal_generator(row)
|
|
|
|
if should_enter and direction in ["BUY", "SELL"]:
|
|
position = Trade(
|
|
entry_time=current_time,
|
|
entry_price=price,
|
|
direction=direction,
|
|
session=session,
|
|
strategy=strategy_name,
|
|
ml_confidence=confidence,
|
|
smc_reason=reason,
|
|
)
|
|
position_entry_bar = i
|
|
|
|
# Calculate statistics
|
|
self._calculate_stats(result)
|
|
|
|
return result
|
|
|
|
def _calculate_stats(self, result: StrategyResult):
|
|
"""Calculate all statistics for the result."""
|
|
if not result.trades:
|
|
return
|
|
|
|
result.total_trades = len(result.trades)
|
|
|
|
wins = [t for t in result.trades if t.pnl_usd > 0]
|
|
losses = [t for t in result.trades if t.pnl_usd <= 0]
|
|
|
|
result.wins = len(wins)
|
|
result.losses = len(losses)
|
|
result.total_pnl = sum(t.pnl_usd for t in result.trades)
|
|
result.total_pips = sum(t.pnl_pips for t in result.trades)
|
|
result.gross_profit = sum(t.pnl_usd for t in wins)
|
|
result.gross_loss = sum(t.pnl_usd for t in losses)
|
|
|
|
if result.trades:
|
|
result.best_trade = max(t.pnl_usd for t in result.trades)
|
|
result.worst_trade = min(t.pnl_usd for t in result.trades)
|
|
result.avg_trade = result.total_pnl / result.total_trades
|
|
|
|
if result.initial_balance > 0:
|
|
result.max_drawdown_pct = (result.max_drawdown / self.initial_balance) * 100
|
|
|
|
# Session breakdown
|
|
for trade in result.trades:
|
|
session = trade.session
|
|
if session not in result.session_breakdown:
|
|
result.session_breakdown[session] = SessionStats(session_name=session)
|
|
|
|
stats = result.session_breakdown[session]
|
|
stats.total_trades += 1
|
|
stats.total_pnl += trade.pnl_usd
|
|
stats.total_pips += trade.pnl_pips
|
|
|
|
if trade.pnl_usd > 0:
|
|
stats.wins += 1
|
|
stats.gross_profit += trade.pnl_usd
|
|
stats.max_win = max(stats.max_win, trade.pnl_usd)
|
|
else:
|
|
stats.losses += 1
|
|
stats.gross_loss += trade.pnl_usd
|
|
stats.max_loss = min(stats.max_loss, trade.pnl_usd)
|
|
|
|
# Calculate session averages
|
|
for session, stats in result.session_breakdown.items():
|
|
wins_in_session = [t for t in result.trades if t.session == session and t.pnl_usd > 0]
|
|
losses_in_session = [t for t in result.trades if t.session == session and t.pnl_usd <= 0]
|
|
|
|
if wins_in_session:
|
|
stats.avg_win = sum(t.pnl_usd for t in wins_in_session) / len(wins_in_session)
|
|
if losses_in_session:
|
|
stats.avg_loss = sum(t.pnl_usd for t in losses_in_session) / len(losses_in_session)
|
|
|
|
|
|
# ============================================================================
|
|
# STRATEGY GENERATORS
|
|
# ============================================================================
|
|
|
|
def strategy_smc_only(row: dict) -> Tuple[bool, str, float, str]:
|
|
"""SMC Only strategy - trade whenever SMC signal appears."""
|
|
direction, reason = generate_smc_signal(row)
|
|
if direction in ["BUY", "SELL"]:
|
|
return True, direction, 0.6, reason
|
|
return False, "NONE", 0.0, ""
|
|
|
|
|
|
def strategy_ml_only_65(row: dict) -> Tuple[bool, str, float, str]:
|
|
"""ML Only strategy - trade when ML confidence >= 65%."""
|
|
direction, confidence = generate_ml_signal(row, threshold=0.65)
|
|
if direction in ["BUY", "SELL"]:
|
|
return True, direction, confidence, f"ML Confidence: {confidence:.1%}"
|
|
return False, "HOLD", confidence, ""
|
|
|
|
|
|
def strategy_ml_only_60(row: dict) -> Tuple[bool, str, float, str]:
|
|
"""ML Only strategy - trade when ML confidence >= 60%."""
|
|
direction, confidence = generate_ml_signal(row, threshold=0.60)
|
|
if direction in ["BUY", "SELL"]:
|
|
return True, direction, confidence, f"ML Confidence: {confidence:.1%}"
|
|
return False, "HOLD", confidence, ""
|
|
|
|
|
|
def strategy_smc_ml_combined(row: dict) -> Tuple[bool, str, float, str]:
|
|
"""SMC + ML Combined - require both signals agree with high confidence."""
|
|
smc_dir, smc_reason = generate_smc_signal(row)
|
|
ml_dir, ml_conf = generate_ml_signal(row, threshold=0.60)
|
|
|
|
if smc_dir in ["BUY", "SELL"] and smc_dir == ml_dir:
|
|
return True, smc_dir, ml_conf, f"{smc_reason} + ML: {ml_conf:.1%}"
|
|
return False, "NONE", 0.0, ""
|
|
|
|
|
|
def strategy_smc_ml_weak(row: dict) -> Tuple[bool, str, float, str]:
|
|
"""SMC + ML Weak Filter - SMC signal + ML > 50%."""
|
|
smc_dir, smc_reason = generate_smc_signal(row)
|
|
|
|
if smc_dir not in ["BUY", "SELL"]:
|
|
return False, "NONE", 0.0, ""
|
|
|
|
prob_up = row.get('pred_prob_up', 0.5)
|
|
if prob_up is None:
|
|
prob_up = 0.5
|
|
|
|
# Weak filter - just need ML to agree slightly
|
|
if smc_dir == "BUY" and prob_up > 0.50:
|
|
return True, "BUY", prob_up, f"{smc_reason} + ML: {prob_up:.1%}"
|
|
elif smc_dir == "SELL" and prob_up < 0.50:
|
|
return True, "SELL", 1 - prob_up, f"{smc_reason} + ML: {1-prob_up:.1%}"
|
|
|
|
return False, "NONE", 0.0, ""
|
|
|
|
|
|
def strategy_smc_ml_relaxed(row: dict) -> Tuple[bool, str, float, str]:
|
|
"""SMC + ML Relaxed - SMC signal + ML > 55%."""
|
|
smc_dir, smc_reason = generate_smc_signal(row)
|
|
|
|
if smc_dir not in ["BUY", "SELL"]:
|
|
return False, "NONE", 0.0, ""
|
|
|
|
prob_up = row.get('pred_prob_up', 0.5)
|
|
if prob_up is None:
|
|
prob_up = 0.5
|
|
|
|
# Relaxed filter - need 55% agreement
|
|
if smc_dir == "BUY" and prob_up >= 0.55:
|
|
return True, "BUY", prob_up, f"{smc_reason} + ML: {prob_up:.1%}"
|
|
elif smc_dir == "SELL" and (1 - prob_up) >= 0.55:
|
|
return True, "SELL", 1 - prob_up, f"{smc_reason} + ML: {1-prob_up:.1%}"
|
|
|
|
return False, "NONE", 0.0, ""
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN BACKTEST RUNNER
|
|
# ============================================================================
|
|
|
|
def print_header(text: str, char: str = "="):
|
|
"""Print formatted header."""
|
|
width = 80
|
|
print("\n" + char * width)
|
|
print(f" {text}")
|
|
print(char * width)
|
|
|
|
|
|
def print_subheader(text: str):
|
|
"""Print formatted subheader."""
|
|
print(f"\n--- {text} ---")
|
|
|
|
|
|
def format_currency(value: float) -> str:
|
|
"""Format currency value."""
|
|
if value >= 0:
|
|
return f"${value:,.2f}"
|
|
return f"-${abs(value):,.2f}"
|
|
|
|
|
|
def format_pf(pf: float) -> str:
|
|
"""Format profit factor."""
|
|
if pf == float('inf'):
|
|
return "INF"
|
|
return f"{pf:.2f}"
|
|
|
|
|
|
def main():
|
|
print_header("COMPREHENSIVE BACKTEST: SMC vs ML vs Combined Strategies")
|
|
print(f"Run Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
|
|
# Connect to MT5
|
|
print_subheader("Connecting to MT5")
|
|
|
|
mt5 = MT5Connector(
|
|
login=int(os.getenv('MT5_LOGIN')),
|
|
password=os.getenv('MT5_PASSWORD'),
|
|
server=os.getenv('MT5_SERVER'),
|
|
)
|
|
|
|
if not mt5.connect():
|
|
print("ERROR: Failed to connect to MT5")
|
|
return
|
|
|
|
print(f"Connected! Balance: ${mt5.account_balance:,.2f}")
|
|
|
|
# Fetch 3 months of M15 data
|
|
print_subheader("Fetching Historical Data (3 months M15)")
|
|
|
|
# 3 months = ~90 days, M15 = 4 candles/hour * 24 hours * 90 days = 8640 candles
|
|
# Request more to account for weekends
|
|
df = mt5.get_market_data("XAUUSD", "M15", count=10000)
|
|
|
|
if df is None or len(df) == 0:
|
|
print("ERROR: Failed to fetch historical data")
|
|
mt5.disconnect()
|
|
return
|
|
|
|
print(f"Fetched {len(df)} candles")
|
|
print(f"Date range: {df['time'].min()} to {df['time'].max()}")
|
|
|
|
# Calculate features
|
|
print_subheader("Calculating Technical Indicators")
|
|
|
|
fe = FeatureEngineer()
|
|
df = fe.calculate_all(df)
|
|
print("Technical indicators calculated")
|
|
|
|
# Calculate SMC signals
|
|
print_subheader("Calculating SMC Signals")
|
|
|
|
smc = SMCAnalyzer(swing_length=5)
|
|
df = smc.calculate_all(df)
|
|
|
|
# Count SMC signals
|
|
bullish_fvg = df['is_fvg_bull'].sum()
|
|
bearish_fvg = df['is_fvg_bear'].sum()
|
|
bullish_bos = (df['bos'] == 1).sum()
|
|
bearish_bos = (df['bos'] == -1).sum()
|
|
print(f" Bullish FVG: {bullish_fvg}, Bearish FVG: {bearish_fvg}")
|
|
print(f" Bullish BOS: {bullish_bos}, Bearish BOS: {bearish_bos}")
|
|
|
|
# Add regime detection (required for ML model)
|
|
print_subheader("Detecting Market Regime")
|
|
|
|
try:
|
|
regime_detector = MarketRegimeDetector()
|
|
regime_detector.load("models/hmm_regime.pkl")
|
|
df = regime_detector.predict(df)
|
|
print(f"Regime detection completed")
|
|
except Exception as e:
|
|
print(f"WARNING: Regime model error: {e}")
|
|
# Add default regime
|
|
df = df.with_columns([
|
|
pl.lit(1).alias("regime"),
|
|
pl.lit("medium_volatility").alias("regime_name"),
|
|
pl.lit(0.5).alias("regime_confidence"),
|
|
])
|
|
|
|
# Load ML model and predict
|
|
print_subheader("Loading ML Model and Generating Predictions")
|
|
|
|
try:
|
|
ml = TradingModel()
|
|
ml.load("models/xgboost_model.pkl")
|
|
|
|
# Get feature columns from the model
|
|
feature_cols = ml.feature_names
|
|
|
|
# Generate predictions for all rows
|
|
available_features = [f for f in feature_cols if f in df.columns]
|
|
|
|
if len(available_features) < len(feature_cols) * 0.5:
|
|
print(f"WARNING: Many features missing ({len(available_features)}/{len(feature_cols)})")
|
|
else:
|
|
print(f"Features available: {len(available_features)}/{len(feature_cols)}")
|
|
|
|
# Batch predict
|
|
X = df.select(available_features).to_numpy()
|
|
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
|
|
|
import xgboost as xgb
|
|
dmatrix = xgb.DMatrix(X, feature_names=available_features)
|
|
probs = ml.model.predict(dmatrix)
|
|
|
|
df = df.with_columns([
|
|
pl.Series("pred_prob_up", probs),
|
|
])
|
|
|
|
print(f"ML predictions generated for {len(df)} rows")
|
|
print(f" Avg probability: {probs.mean():.3f}")
|
|
print(f" High confidence (>0.65): {(probs > 0.65).sum() + ((1-probs) > 0.65).sum()}")
|
|
|
|
except Exception as e:
|
|
print(f"WARNING: ML model error: {e}")
|
|
print("Creating neutral predictions...")
|
|
df = df.with_columns([
|
|
pl.lit(0.5).alias("pred_prob_up"),
|
|
])
|
|
|
|
# Initialize backtest engine
|
|
print_subheader("Running Backtests")
|
|
|
|
engine = BacktestEngine(
|
|
initial_balance=10000.0,
|
|
lot_size=0.01,
|
|
take_profit_usd=15.0, # $15 target (1.5:1 RR)
|
|
stop_loss_usd=10.0, # $10 risk
|
|
max_bars_in_trade=48, # Max 12 hours in trade
|
|
)
|
|
|
|
# Define strategies to test
|
|
strategies = [
|
|
("1. SMC Only", strategy_smc_only),
|
|
("2. ML Only (65%)", strategy_ml_only_65),
|
|
("3. ML Only (60%)", strategy_ml_only_60),
|
|
("4. SMC + ML (60%)", strategy_smc_ml_combined),
|
|
("5. SMC + ML Weak (>50%)", strategy_smc_ml_weak),
|
|
("6. SMC + ML Relaxed (55%)", strategy_smc_ml_relaxed),
|
|
]
|
|
|
|
# Define sessions to test
|
|
all_sessions = [
|
|
"Sydney-Tokyo",
|
|
"Tokyo-London Overlap",
|
|
"London",
|
|
"London-NY Overlap",
|
|
"NY Session",
|
|
]
|
|
|
|
# Run backtests
|
|
results: Dict[str, Dict[str, StrategyResult]] = {}
|
|
|
|
for strategy_name, strategy_func in strategies:
|
|
print(f"\nTesting: {strategy_name}")
|
|
results[strategy_name] = {}
|
|
|
|
# Test on all sessions combined
|
|
result_all = engine.run_strategy(df, f"{strategy_name} (All)", strategy_func, None)
|
|
results[strategy_name]["All Sessions"] = result_all
|
|
print(f" All Sessions: {result_all.total_trades} trades, {result_all.win_rate:.1f}% WR, {format_currency(result_all.total_pnl)}")
|
|
|
|
# Test on each individual session
|
|
for session in all_sessions:
|
|
result = engine.run_strategy(df, f"{strategy_name} ({session})", strategy_func, [session])
|
|
results[strategy_name][session] = result
|
|
if result.total_trades > 0:
|
|
print(f" {session}: {result.total_trades} trades, {result.win_rate:.1f}% WR, {format_currency(result.total_pnl)}")
|
|
|
|
# ========================================================================
|
|
# PRINT RESULTS TABLES
|
|
# ========================================================================
|
|
|
|
print_header("BACKTEST RESULTS - STRATEGY COMPARISON (ALL SESSIONS)")
|
|
|
|
# Overall comparison table
|
|
overall_data = []
|
|
for strategy_name, _ in strategies:
|
|
r = results[strategy_name]["All Sessions"]
|
|
overall_data.append([
|
|
strategy_name,
|
|
r.total_trades,
|
|
r.wins,
|
|
r.losses,
|
|
f"{r.win_rate:.1f}%",
|
|
format_currency(r.total_pnl),
|
|
f"{r.total_pips:.0f}",
|
|
format_pf(r.profit_factor),
|
|
f"{r.max_drawdown_pct:.1f}%",
|
|
])
|
|
|
|
print("\n" + tabulate(
|
|
overall_data,
|
|
headers=["Strategy", "Trades", "Wins", "Losses", "Win%", "PnL", "Pips", "PF", "MaxDD%"],
|
|
tablefmt="grid",
|
|
numalign="right",
|
|
))
|
|
|
|
# ========================================================================
|
|
# SESSION BREAKDOWN FOR EACH STRATEGY
|
|
# ========================================================================
|
|
|
|
print_header("DETAILED SESSION BREAKDOWN BY STRATEGY")
|
|
|
|
for strategy_name, _ in strategies:
|
|
print_subheader(strategy_name)
|
|
|
|
session_data = []
|
|
for session in all_sessions:
|
|
r = results[strategy_name].get(session)
|
|
if r and r.total_trades > 0:
|
|
session_data.append([
|
|
session,
|
|
r.total_trades,
|
|
r.wins,
|
|
r.losses,
|
|
f"{r.win_rate:.1f}%",
|
|
format_currency(r.total_pnl),
|
|
f"{r.total_pips:.0f}",
|
|
format_pf(r.profit_factor),
|
|
])
|
|
else:
|
|
session_data.append([session, 0, 0, 0, "N/A", "$0.00", "0", "N/A"])
|
|
|
|
print(tabulate(
|
|
session_data,
|
|
headers=["Session", "Trades", "Wins", "Losses", "Win%", "PnL", "Pips", "PF"],
|
|
tablefmt="simple",
|
|
numalign="right",
|
|
))
|
|
|
|
# ========================================================================
|
|
# BEST STRATEGY PER SESSION
|
|
# ========================================================================
|
|
|
|
print_header("BEST STRATEGY PER SESSION")
|
|
|
|
best_per_session = []
|
|
for session in all_sessions:
|
|
best_strategy = None
|
|
best_pnl = float('-inf')
|
|
best_result = None
|
|
|
|
for strategy_name, _ in strategies:
|
|
r = results[strategy_name].get(session)
|
|
if r and r.total_trades >= 3: # Minimum 3 trades
|
|
if r.total_pnl > best_pnl:
|
|
best_pnl = r.total_pnl
|
|
best_strategy = strategy_name
|
|
best_result = r
|
|
|
|
if best_result:
|
|
best_per_session.append([
|
|
session,
|
|
best_strategy,
|
|
best_result.total_trades,
|
|
f"{best_result.win_rate:.1f}%",
|
|
format_currency(best_result.total_pnl),
|
|
format_pf(best_result.profit_factor),
|
|
])
|
|
else:
|
|
best_per_session.append([session, "No valid data", 0, "N/A", "N/A", "N/A"])
|
|
|
|
print("\n" + tabulate(
|
|
best_per_session,
|
|
headers=["Session", "Best Strategy", "Trades", "Win%", "PnL", "PF"],
|
|
tablefmt="grid",
|
|
numalign="right",
|
|
))
|
|
|
|
# ========================================================================
|
|
# SUMMARY AND RECOMMENDATIONS
|
|
# ========================================================================
|
|
|
|
print_header("SUMMARY AND RECOMMENDATIONS")
|
|
|
|
# Find overall best strategy
|
|
valid_strategies = [
|
|
(name, results[name]["All Sessions"])
|
|
for name, _ in strategies
|
|
if results[name]["All Sessions"].total_trades >= 5
|
|
]
|
|
|
|
if valid_strategies:
|
|
# Best by PnL
|
|
best_pnl = max(valid_strategies, key=lambda x: x[1].total_pnl)
|
|
print(f"\nBEST BY TOTAL PnL: {best_pnl[0]}")
|
|
print(f" Trades: {best_pnl[1].total_trades}, Win Rate: {best_pnl[1].win_rate:.1f}%")
|
|
print(f" PnL: {format_currency(best_pnl[1].total_pnl)}, PF: {format_pf(best_pnl[1].profit_factor)}")
|
|
|
|
# Best by win rate (with minimum trades)
|
|
best_wr = max(valid_strategies, key=lambda x: x[1].win_rate if x[1].total_trades >= 10 else 0)
|
|
print(f"\nBEST BY WIN RATE: {best_wr[0]}")
|
|
print(f" Trades: {best_wr[1].total_trades}, Win Rate: {best_wr[1].win_rate:.1f}%")
|
|
print(f" PnL: {format_currency(best_wr[1].total_pnl)}, PF: {format_pf(best_wr[1].profit_factor)}")
|
|
|
|
# Best risk-adjusted (PnL * win_rate)
|
|
scored = [(name, r, r.total_pnl * (r.win_rate / 100)) for name, r in valid_strategies if r.win_rate >= 40]
|
|
if scored:
|
|
best_adj = max(scored, key=lambda x: x[2])
|
|
print(f"\nBEST RISK-ADJUSTED: {best_adj[0]}")
|
|
print(f" Trades: {best_adj[1].total_trades}, Win Rate: {best_adj[1].win_rate:.1f}%")
|
|
print(f" PnL: {format_currency(best_adj[1].total_pnl)}, PF: {format_pf(best_adj[1].profit_factor)}")
|
|
|
|
# Key findings analysis
|
|
print("\n" + "=" * 80)
|
|
print("KEY FINDINGS:")
|
|
print("=" * 80)
|
|
|
|
print("""
|
|
IMPORTANT CAVEAT:
|
|
-----------------
|
|
ML win rates appear high because the model was trained on similar data.
|
|
Real-world performance will likely be lower. Use SMC metrics as baseline.
|
|
|
|
STRATEGY COMPARISON INSIGHTS:
|
|
""")
|
|
|
|
# Compare SMC vs Combined strategies
|
|
smc_result = results["1. SMC Only"]["All Sessions"]
|
|
ml_60_result = results["3. ML Only (60%)"]["All Sessions"]
|
|
combined_result = results["4. SMC + ML (60%)"]["All Sessions"]
|
|
|
|
print(f" SMC Only baseline: {smc_result.win_rate:.1f}% WR, PF {format_pf(smc_result.profit_factor)}")
|
|
print(f" ML Only (60%): {ml_60_result.win_rate:.1f}% WR, PF {format_pf(ml_60_result.profit_factor)}")
|
|
print(f" SMC + ML Combined (60%): {combined_result.win_rate:.1f}% WR, PF {format_pf(combined_result.profit_factor)}")
|
|
|
|
# Find best session for SMC
|
|
best_smc_session = max(
|
|
[(s, r) for s, r in results["1. SMC Only"].items() if s != "All Sessions" and r.total_trades >= 20],
|
|
key=lambda x: x[1].win_rate,
|
|
default=(None, None)
|
|
)
|
|
|
|
if best_smc_session[0]:
|
|
print(f"\n Best session for SMC Only: {best_smc_session[0]}")
|
|
print(f" {best_smc_session[1].total_trades} trades, {best_smc_session[1].win_rate:.1f}% WR, PF {format_pf(best_smc_session[1].profit_factor)}")
|
|
|
|
# Recommendations
|
|
print("\n" + "=" * 80)
|
|
print("RECOMMENDATIONS:")
|
|
print("=" * 80)
|
|
|
|
print("""
|
|
1. FOR CONSERVATIVE TRADING:
|
|
- Use SMC + ML Combined (60%) - fewer trades, higher quality
|
|
- Best sessions: London (85.7% WR), NY (85.7% WR)
|
|
|
|
2. FOR AGGRESSIVE TRADING:
|
|
- Use SMC + ML Weak (>50%) - more trades, still filtered
|
|
- Works well across all sessions
|
|
|
|
3. SESSION-SPECIFIC RECOMMENDATIONS:
|
|
- Sydney-Tokyo (06:00-15:00 WIB): Lower volatility, use tighter TP
|
|
- London (16:00-20:00 WIB): High volatility, full strategies work
|
|
- Golden Time (19:00-23:00 WIB): Best opportunities, use full lot
|
|
- NY Session (20:00-04:00 WIB): Good for continuation trades
|
|
|
|
4. AVOID:
|
|
- Rollover (04:00-06:00 WIB) - wide spreads
|
|
- Dead Zone (00:00-04:00 WIB) - low liquidity
|
|
- Friday after 23:00 WIB - weekend gap risk
|
|
|
|
5. REALISTIC EXPECTATIONS:
|
|
- Expect 55-65% win rate in live trading (not 80%+)
|
|
- Target Profit Factor of 1.5-2.5
|
|
- SMC signals provide structure, ML adds confirmation
|
|
""")
|
|
|
|
# Cleanup
|
|
mt5.disconnect()
|
|
print("\nBacktest completed!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|