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>
552 lines
18 KiB
Python
552 lines
18 KiB
Python
"""
|
|
Backtest 1 Year: 2025 - Today
|
|
=============================
|
|
Comprehensive backtest comparing old vs new filter logic.
|
|
|
|
Tests:
|
|
1. Old Logic: SMC-only with ML weak filter
|
|
2. New Logic: ML threshold (55%) + Signal Confirmation + Pullback Filter
|
|
"""
|
|
|
|
import polars as pl
|
|
import numpy as np
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Tuple, Optional
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
import sys
|
|
import os
|
|
|
|
# Add src to path
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from src.mt5_connector import MT5Connector
|
|
from src.smc_polars import SMCAnalyzer, SMCSignal
|
|
from src.feature_eng import FeatureEngineer
|
|
from src.regime_detector import MarketRegimeDetector, MarketRegime
|
|
from src.ml_model import TradingModel, get_default_feature_columns
|
|
from src.config import get_config
|
|
from loguru import logger
|
|
|
|
# Reduce logging noise
|
|
logger.remove()
|
|
logger.add(sys.stderr, level="WARNING")
|
|
|
|
|
|
class TradeResult(Enum):
|
|
WIN = "WIN"
|
|
LOSS = "LOSS"
|
|
BREAKEVEN = "BREAKEVEN"
|
|
|
|
|
|
@dataclass
|
|
class SimulatedTrade:
|
|
"""A simulated trade."""
|
|
entry_time: datetime
|
|
exit_time: datetime
|
|
direction: str
|
|
entry_price: float
|
|
exit_price: float
|
|
stop_loss: float
|
|
take_profit: float
|
|
lot_size: float
|
|
profit_usd: float
|
|
profit_pips: float
|
|
result: TradeResult
|
|
exit_reason: str
|
|
ml_confidence: float
|
|
smc_confidence: float
|
|
regime: str
|
|
filter_version: str # "old" or "new"
|
|
|
|
|
|
@dataclass
|
|
class BacktestStats:
|
|
"""Statistics for a backtest run."""
|
|
total_trades: int = 0
|
|
wins: int = 0
|
|
losses: int = 0
|
|
total_profit: float = 0.0
|
|
total_loss: float = 0.0
|
|
max_drawdown: float = 0.0
|
|
win_rate: float = 0.0
|
|
profit_factor: float = 0.0
|
|
avg_win: float = 0.0
|
|
avg_loss: float = 0.0
|
|
trades: List[SimulatedTrade] = field(default_factory=list)
|
|
|
|
|
|
def check_pullback_filter(df: pl.DataFrame, signal_direction: str, idx: int) -> Tuple[bool, str]:
|
|
"""Check if pullback filter would block at given index."""
|
|
try:
|
|
if idx < 5:
|
|
return False, "OK"
|
|
|
|
# Get data up to current index
|
|
closes = df["close"].to_list()[:idx+1]
|
|
last_3 = closes[-3:]
|
|
|
|
short_momentum = last_3[-1] - last_3[0]
|
|
momentum_dir = "UP" if short_momentum > 0 else "DOWN"
|
|
|
|
# MACD histogram
|
|
macd_dir = "NEUTRAL"
|
|
if "macd_histogram" in df.columns:
|
|
macd_hist = df["macd_histogram"].to_list()[:idx+1]
|
|
if len(macd_hist) >= 2 and macd_hist[-1] is not None and macd_hist[-2] is not None:
|
|
macd_dir = "RISING" if macd_hist[-1] > macd_hist[-2] else "FALLING"
|
|
|
|
# Pullback logic
|
|
if signal_direction == "SELL":
|
|
if momentum_dir == "UP" and short_momentum > 2:
|
|
return True, f"Price bouncing UP (+${short_momentum:.2f})"
|
|
if macd_dir == "RISING" and momentum_dir == "UP":
|
|
return True, "MACD bullish + price rising"
|
|
elif signal_direction == "BUY":
|
|
if momentum_dir == "DOWN" and short_momentum < -2:
|
|
return True, f"Price falling DOWN (${short_momentum:.2f})"
|
|
if macd_dir == "FALLING" and momentum_dir == "DOWN":
|
|
return True, "MACD bearish + price falling"
|
|
|
|
return False, "OK"
|
|
except:
|
|
return False, "OK"
|
|
|
|
|
|
def simulate_trade_outcome(
|
|
df: pl.DataFrame,
|
|
entry_idx: int,
|
|
direction: str,
|
|
entry_price: float,
|
|
stop_loss: float,
|
|
take_profit: float,
|
|
lot_size: float = 0.01,
|
|
max_bars: int = 100, # Max bars to hold position
|
|
) -> Tuple[float, float, str, int]:
|
|
"""
|
|
Simulate trade outcome by walking forward through price data.
|
|
|
|
Returns: (profit_usd, profit_pips, exit_reason, exit_idx)
|
|
"""
|
|
pip_value = 10 # For XAUUSD, 1 pip = $10 per lot
|
|
|
|
highs = df["high"].to_list()
|
|
lows = df["low"].to_list()
|
|
closes = df["close"].to_list()
|
|
|
|
for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
|
|
high = highs[i]
|
|
low = lows[i]
|
|
close = closes[i]
|
|
|
|
if direction == "BUY":
|
|
# Check stop loss
|
|
if low <= stop_loss:
|
|
pips = (stop_loss - entry_price) / 0.1
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, "stop_loss", i
|
|
|
|
# Check take profit
|
|
if high >= take_profit:
|
|
pips = (take_profit - entry_price) / 0.1
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, "take_profit", i
|
|
|
|
else: # SELL
|
|
# Check stop loss
|
|
if high >= stop_loss:
|
|
pips = (entry_price - stop_loss) / 0.1
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, "stop_loss", i
|
|
|
|
# Check take profit
|
|
if low <= take_profit:
|
|
pips = (entry_price - take_profit) / 0.1
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, "take_profit", i
|
|
|
|
# Position still open after max_bars - close at current price
|
|
final_price = closes[min(entry_idx + max_bars - 1, len(df) - 1)]
|
|
if direction == "BUY":
|
|
pips = (final_price - entry_price) / 0.1
|
|
else:
|
|
pips = (entry_price - final_price) / 0.1
|
|
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, "timeout", min(entry_idx + max_bars - 1, len(df) - 1)
|
|
|
|
|
|
def run_backtest(
|
|
df: pl.DataFrame,
|
|
smc: SMCAnalyzer,
|
|
ml_model: TradingModel,
|
|
regime_detector: MarketRegimeDetector,
|
|
filter_version: str = "old",
|
|
initial_capital: float = 5000.0,
|
|
) -> BacktestStats:
|
|
"""
|
|
Run backtest with specified filter version.
|
|
|
|
Args:
|
|
df: Full DataFrame with all indicators
|
|
smc: SMC analyzer
|
|
ml_model: ML model for predictions
|
|
regime_detector: Regime detector
|
|
filter_version: "old" or "new"
|
|
initial_capital: Starting capital
|
|
"""
|
|
stats = BacktestStats()
|
|
capital = initial_capital
|
|
peak_capital = initial_capital
|
|
|
|
# Get feature columns
|
|
feature_cols = [f for f in ml_model.feature_names if f in df.columns]
|
|
|
|
# Track for signal confirmation (new filter)
|
|
signal_persistence = {}
|
|
last_trade_idx = -100 # Cooldown tracking
|
|
cooldown_bars = 20 # ~5 hours on M15
|
|
|
|
# Iterate through data
|
|
print(f"\nRunning backtest with {filter_version.upper()} filters...")
|
|
|
|
for i in range(100, len(df) - 100): # Leave margin for lookback and forward simulation
|
|
# Cooldown check
|
|
if i - last_trade_idx < cooldown_bars:
|
|
continue
|
|
|
|
# Get data slice up to current bar
|
|
df_slice = df.head(i + 1)
|
|
|
|
# Generate SMC signal
|
|
try:
|
|
smc_signal = smc.generate_signal(df_slice)
|
|
except:
|
|
continue
|
|
|
|
if smc_signal is None:
|
|
# Reset signal persistence
|
|
signal_persistence = {}
|
|
continue
|
|
|
|
# Get ML prediction
|
|
try:
|
|
ml_pred = ml_model.predict(df_slice, feature_cols)
|
|
except:
|
|
continue
|
|
|
|
# Get regime
|
|
try:
|
|
regime_state = regime_detector.get_current_state(df_slice)
|
|
regime = regime_state.regime.value if regime_state else "normal"
|
|
except:
|
|
regime = "normal"
|
|
|
|
# Skip if CRISIS regime
|
|
if regime == "crisis":
|
|
continue
|
|
|
|
# === FILTER LOGIC ===
|
|
should_trade = False
|
|
|
|
if filter_version == "old":
|
|
# OLD LOGIC: SMC signal with weak ML filter
|
|
# Only block if ML strongly disagrees (>65% opposite)
|
|
ml_strongly_disagrees = (
|
|
(smc_signal.signal_type == "BUY" and ml_pred.signal == "SELL" and ml_pred.confidence > 0.65) or
|
|
(smc_signal.signal_type == "SELL" and ml_pred.signal == "BUY" and ml_pred.confidence > 0.65)
|
|
)
|
|
should_trade = not ml_strongly_disagrees
|
|
|
|
else: # "new"
|
|
# NEW LOGIC: ML threshold + confirmation + pullback filter
|
|
|
|
# Filter 1: ML confidence threshold (>= 55%)
|
|
if ml_pred.confidence < 0.55:
|
|
signal_persistence = {}
|
|
continue
|
|
|
|
# Filter 2: ML shouldn't strongly disagree
|
|
ml_strongly_disagrees = (
|
|
(smc_signal.signal_type == "BUY" and ml_pred.signal == "SELL" and ml_pred.confidence > 0.65) or
|
|
(smc_signal.signal_type == "SELL" and ml_pred.signal == "BUY" and ml_pred.confidence > 0.65)
|
|
)
|
|
if ml_strongly_disagrees:
|
|
signal_persistence = {}
|
|
continue
|
|
|
|
# Filter 3: Signal confirmation
|
|
signal_key = f"{smc_signal.signal_type}_{int(smc_signal.entry_price)}"
|
|
if signal_key not in signal_persistence:
|
|
signal_persistence[signal_key] = 1
|
|
continue # Wait for confirmation
|
|
else:
|
|
signal_persistence[signal_key] += 1
|
|
|
|
if signal_persistence[signal_key] < 2:
|
|
continue
|
|
|
|
# Reset persistence
|
|
signal_persistence = {}
|
|
|
|
# Filter 4: Pullback filter
|
|
pullback_blocked, _ = check_pullback_filter(df_slice, smc_signal.signal_type, i)
|
|
if pullback_blocked:
|
|
continue
|
|
|
|
should_trade = True
|
|
|
|
if not should_trade:
|
|
continue
|
|
|
|
# === EXECUTE TRADE ===
|
|
# Determine lot size based on ML confidence (new) or fixed (old)
|
|
if filter_version == "new":
|
|
if ml_pred.confidence >= 0.65:
|
|
lot_size = 0.02
|
|
elif ml_pred.confidence >= 0.55:
|
|
lot_size = 0.01
|
|
else:
|
|
lot_size = 0.01
|
|
else:
|
|
lot_size = 0.01
|
|
|
|
# Simulate trade
|
|
entry_price = smc_signal.entry_price
|
|
stop_loss = smc_signal.stop_loss
|
|
take_profit = smc_signal.take_profit
|
|
|
|
profit, pips, exit_reason, exit_idx = simulate_trade_outcome(
|
|
df=df,
|
|
entry_idx=i,
|
|
direction=smc_signal.signal_type,
|
|
entry_price=entry_price,
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
lot_size=lot_size,
|
|
)
|
|
|
|
# Record trade
|
|
entry_time = df["time"].to_list()[i]
|
|
exit_time = df["time"].to_list()[exit_idx]
|
|
|
|
result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
|
|
|
|
trade = SimulatedTrade(
|
|
entry_time=entry_time,
|
|
exit_time=exit_time,
|
|
direction=smc_signal.signal_type,
|
|
entry_price=entry_price,
|
|
exit_price=df["close"].to_list()[exit_idx],
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
lot_size=lot_size,
|
|
profit_usd=profit,
|
|
profit_pips=pips,
|
|
result=result,
|
|
exit_reason=exit_reason,
|
|
ml_confidence=ml_pred.confidence,
|
|
smc_confidence=smc_signal.confidence,
|
|
regime=regime,
|
|
filter_version=filter_version,
|
|
)
|
|
stats.trades.append(trade)
|
|
|
|
# Update stats
|
|
stats.total_trades += 1
|
|
capital += profit
|
|
|
|
if profit > 0:
|
|
stats.wins += 1
|
|
stats.total_profit += profit
|
|
else:
|
|
stats.losses += 1
|
|
stats.total_loss += abs(profit)
|
|
|
|
# Track drawdown
|
|
if capital > peak_capital:
|
|
peak_capital = capital
|
|
drawdown = (peak_capital - capital) / peak_capital * 100
|
|
if drawdown > stats.max_drawdown:
|
|
stats.max_drawdown = drawdown
|
|
|
|
# Update last trade index for cooldown
|
|
last_trade_idx = exit_idx
|
|
|
|
# Progress
|
|
if stats.total_trades % 50 == 0:
|
|
print(f" {stats.total_trades} trades processed...")
|
|
|
|
# Calculate final stats
|
|
if stats.total_trades > 0:
|
|
stats.win_rate = stats.wins / stats.total_trades * 100
|
|
stats.avg_win = stats.total_profit / stats.wins if stats.wins > 0 else 0
|
|
stats.avg_loss = stats.total_loss / stats.losses if stats.losses > 0 else 0
|
|
stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float('inf')
|
|
|
|
return stats
|
|
|
|
|
|
def main():
|
|
"""Run 1-year backtest."""
|
|
print("=" * 70)
|
|
print("BACKTEST: 1 Year (2025 - Today)")
|
|
print("=" * 70)
|
|
|
|
# Initialize
|
|
config = get_config()
|
|
|
|
mt5 = MT5Connector(
|
|
login=config.mt5_login,
|
|
password=config.mt5_password,
|
|
server=config.mt5_server,
|
|
path=config.mt5_path,
|
|
)
|
|
mt5.connect()
|
|
print(f"\nConnected to MT5")
|
|
|
|
# Initialize components
|
|
smc = SMCAnalyzer()
|
|
features = FeatureEngineer()
|
|
|
|
regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
|
|
regime_detector.load()
|
|
|
|
ml_model = TradingModel(model_path="models/xgboost_model.pkl")
|
|
ml_model.load()
|
|
print(f"Models loaded")
|
|
|
|
# Fetch historical data
|
|
# MT5 typically allows ~10000 bars, which is about 3-4 months on M15
|
|
# For 1 year, we need to fetch in chunks or use a larger timeframe
|
|
print(f"\nFetching historical data...")
|
|
|
|
# Try to get maximum available data
|
|
df = mt5.get_market_data(
|
|
symbol="XAUUSD",
|
|
timeframe="M15",
|
|
count=50000, # Request max, MT5 will return what's available
|
|
)
|
|
|
|
if len(df) == 0:
|
|
print("ERROR: No data received")
|
|
return
|
|
|
|
print(f"Received {len(df)} bars")
|
|
|
|
# Get date range
|
|
times = df["time"].to_list()
|
|
start_date = times[0]
|
|
end_date = times[-1]
|
|
print(f"Date range: {start_date} to {end_date}")
|
|
|
|
# Calculate indicators
|
|
print(f"\nCalculating indicators...")
|
|
df = features.calculate_all(df, include_ml_features=True)
|
|
df = smc.calculate_all(df)
|
|
|
|
try:
|
|
df = regime_detector.predict(df)
|
|
except:
|
|
pass
|
|
|
|
print(f"Indicators calculated")
|
|
|
|
# Run backtests
|
|
print("\n" + "=" * 70)
|
|
|
|
# OLD filters
|
|
old_stats = run_backtest(
|
|
df=df,
|
|
smc=smc,
|
|
ml_model=ml_model,
|
|
regime_detector=regime_detector,
|
|
filter_version="old",
|
|
)
|
|
|
|
# NEW filters
|
|
new_stats = run_backtest(
|
|
df=df,
|
|
smc=smc,
|
|
ml_model=ml_model,
|
|
regime_detector=regime_detector,
|
|
filter_version="new",
|
|
)
|
|
|
|
# Print results
|
|
print("\n" + "=" * 70)
|
|
print("BACKTEST RESULTS COMPARISON")
|
|
print("=" * 70)
|
|
|
|
print(f"\nData Period: {start_date} to {end_date}")
|
|
print(f"Total Bars: {len(df)}")
|
|
|
|
print(f"\n{'Metric':<25} {'OLD Filters':>15} {'NEW Filters':>15} {'Diff':>15}")
|
|
print("-" * 70)
|
|
|
|
metrics = [
|
|
("Total Trades", old_stats.total_trades, new_stats.total_trades),
|
|
("Wins", old_stats.wins, new_stats.wins),
|
|
("Losses", old_stats.losses, new_stats.losses),
|
|
("Win Rate (%)", f"{old_stats.win_rate:.1f}", f"{new_stats.win_rate:.1f}"),
|
|
("Total Profit ($)", f"{old_stats.total_profit:.2f}", f"{new_stats.total_profit:.2f}"),
|
|
("Total Loss ($)", f"{old_stats.total_loss:.2f}", f"{new_stats.total_loss:.2f}"),
|
|
("Net P/L ($)", f"{old_stats.total_profit - old_stats.total_loss:.2f}",
|
|
f"{new_stats.total_profit - new_stats.total_loss:.2f}"),
|
|
("Profit Factor", f"{old_stats.profit_factor:.2f}" if old_stats.profit_factor != float('inf') else "∞",
|
|
f"{new_stats.profit_factor:.2f}" if new_stats.profit_factor != float('inf') else "∞"),
|
|
("Avg Win ($)", f"{old_stats.avg_win:.2f}", f"{new_stats.avg_win:.2f}"),
|
|
("Avg Loss ($)", f"{old_stats.avg_loss:.2f}", f"{new_stats.avg_loss:.2f}"),
|
|
("Max Drawdown (%)", f"{old_stats.max_drawdown:.1f}", f"{new_stats.max_drawdown:.1f}"),
|
|
]
|
|
|
|
for name, old_val, new_val in metrics:
|
|
if isinstance(old_val, (int, float)) and isinstance(new_val, (int, float)):
|
|
diff = new_val - old_val
|
|
diff_str = f"{diff:+.2f}" if isinstance(diff, float) else f"{diff:+d}"
|
|
else:
|
|
diff_str = "-"
|
|
print(f"{name:<25} {str(old_val):>15} {str(new_val):>15} {diff_str:>15}")
|
|
|
|
# Net P/L comparison
|
|
old_net = old_stats.total_profit - old_stats.total_loss
|
|
new_net = new_stats.total_profit - new_stats.total_loss
|
|
improvement = new_net - old_net
|
|
|
|
print("\n" + "=" * 70)
|
|
print("SUMMARY")
|
|
print("=" * 70)
|
|
print(f"\nOLD Filters Net P/L: ${old_net:.2f}")
|
|
print(f"NEW Filters Net P/L: ${new_net:.2f}")
|
|
print(f"IMPROVEMENT: ${improvement:.2f} ({improvement/abs(old_net)*100 if old_net != 0 else 0:.1f}%)")
|
|
|
|
if new_stats.win_rate > old_stats.win_rate:
|
|
print(f"\nWin Rate improved: {old_stats.win_rate:.1f}% -> {new_stats.win_rate:.1f}%")
|
|
|
|
if new_stats.max_drawdown < old_stats.max_drawdown:
|
|
print(f"Max Drawdown reduced: {old_stats.max_drawdown:.1f}% -> {new_stats.max_drawdown:.1f}%")
|
|
|
|
# Trade distribution by ML confidence (NEW)
|
|
if new_stats.trades:
|
|
print(f"\n--- NEW Filter Trade Analysis ---")
|
|
high_conf = [t for t in new_stats.trades if t.ml_confidence >= 0.65]
|
|
med_conf = [t for t in new_stats.trades if 0.55 <= t.ml_confidence < 0.65]
|
|
|
|
if high_conf:
|
|
high_wr = len([t for t in high_conf if t.result == TradeResult.WIN]) / len(high_conf) * 100
|
|
high_pnl = sum(t.profit_usd for t in high_conf)
|
|
print(f"High Confidence (>=65%): {len(high_conf)} trades, {high_wr:.1f}% WR, ${high_pnl:.2f}")
|
|
|
|
if med_conf:
|
|
med_wr = len([t for t in med_conf if t.result == TradeResult.WIN]) / len(med_conf) * 100
|
|
med_pnl = sum(t.profit_usd for t in med_conf)
|
|
print(f"Med Confidence (55-65%): {len(med_conf)} trades, {med_wr:.1f}% WR, ${med_pnl:.2f}")
|
|
|
|
mt5.disconnect()
|
|
print("\n" + "=" * 70)
|
|
print("Backtest complete!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|