82d010fcf2
- Delete all old XGBoost/HMM models trained with the order-block look-ahead leak (models/backups/* + root models). They reproduced a fake 63.9% WR / 2.64 PF that collapses to ~35% WR / 0.95 PF once the leak is fixed. - scripts/collect_data.py: dedicated raw M1+M15 collector (paginated) - scripts/fast_backtest.py: vectorized GPU backtest for honest validation - backtest_live_sync.py: read SYMBOL from env (XM uses GOLD, not XAUUSD) - stop tracking generated data/training_data.parquet See upstream report: GifariKemal/xaubot-ai#4
1026 lines
39 KiB
Python
1026 lines
39 KiB
Python
"""
|
|
Backtest Live Sync - 100% Identical to main_live.py
|
|
====================================================
|
|
This backtest MUST be identical to live trading logic.
|
|
|
|
SYNCED with Critical & Major Fixes (Feb 2025):
|
|
1. SMC Signal: No lookahead bias, current_close entry, Fixed RR 1:1.5
|
|
2. Pullback Filter: ATR-based thresholds (not hardcoded $2, $1.5)
|
|
3. Time-Based Exit: Checks profit_growing + ML agreement before exit
|
|
4. Trend Reversal: ATR-based momentum thresholds (0.6x multiplier)
|
|
5. Signal Persistence: Index-based cleanup (prevents memory leak)
|
|
6. Calibrated Confidence: Uses SMC's weighted confidence calculation
|
|
7. Dynamic RR: 1.5 (ranging) to 2.0 (strong trend) based on market conditions
|
|
8. SELL Filter: Requires ML agreement + 55% confidence
|
|
|
|
Synchronized elements:
|
|
1. ML Model: XGBoost with same features, 50-bar train/test gap
|
|
2. SMC Analyzer: Same swing_length, ob_lookback, NO LOOKAHEAD
|
|
3. Regime Detection: HMM with MarketRegimeDetector
|
|
4. Session Filter: Golden Time 19:00-23:00 WIB
|
|
5. Signal Logic:
|
|
- Skip if market quality AVOID or CRISIS
|
|
- ML confidence >= ML_THRESHOLD required (default 50%)
|
|
- ML shouldn't strongly disagree (>65% opposite)
|
|
- Signal confirmation (2+ consecutive signals)
|
|
- Pullback filter (ATR-based thresholds)
|
|
6. Position Sizing: Based on ML confidence tiers (0.01-0.02 lot)
|
|
7. Trade Cooldown: 20 bars (~5 hours on M15)
|
|
8. Exit Logic:
|
|
- TP hit (Dynamic RR 1.5-2.0)
|
|
- ML reversal (>65% opposite signal)
|
|
- Trend reversal (ATR * 0.6 momentum shift)
|
|
- Smart timeout (checks profit_growing before exit)
|
|
- Max loss per trade ($50 default)
|
|
|
|
Usage:
|
|
python backtests/backtest_live_sync.py --tune # Find optimal thresholds
|
|
python backtests/backtest_live_sync.py --save # Save results to CSV
|
|
"""
|
|
|
|
import polars as pl
|
|
import pandas as pd
|
|
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
|
|
import csv
|
|
from zoneinfo import ZoneInfo
|
|
|
|
# Add parent to path
|
|
sys.path.insert(0, os.path.dirname(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
|
|
from src.config import get_config
|
|
from src.session_filter import create_wib_session_filter
|
|
from src.dynamic_confidence import create_dynamic_confidence, MarketQuality
|
|
from loguru import logger
|
|
|
|
# Reduce logging noise
|
|
logger.remove()
|
|
logger.add(sys.stderr, level="WARNING")
|
|
|
|
|
|
class TradeResult(Enum):
|
|
WIN = "WIN"
|
|
LOSS = "LOSS"
|
|
BREAKEVEN = "BREAKEVEN"
|
|
|
|
|
|
class ExitReason(Enum):
|
|
TAKE_PROFIT = "take_profit"
|
|
MAX_LOSS = "max_loss"
|
|
ML_REVERSAL = "ml_reversal"
|
|
TIMEOUT = "timeout"
|
|
TREND_REVERSAL = "trend_reversal"
|
|
|
|
|
|
@dataclass
|
|
class SimulatedTrade:
|
|
"""Simulated trade record - matches live trade logging."""
|
|
ticket: int
|
|
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: ExitReason
|
|
ml_confidence: float
|
|
smc_confidence: float
|
|
regime: str
|
|
session: str
|
|
signal_reason: str
|
|
|
|
|
|
@dataclass
|
|
class BacktestStats:
|
|
"""Backtest statistics."""
|
|
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
|
|
max_drawdown_usd: float = 0.0
|
|
win_rate: float = 0.0
|
|
profit_factor: float = 0.0
|
|
avg_win: float = 0.0
|
|
avg_loss: float = 0.0
|
|
avg_trade: float = 0.0
|
|
expectancy: float = 0.0
|
|
sharpe_ratio: float = 0.0
|
|
trades: List[SimulatedTrade] = field(default_factory=list)
|
|
|
|
|
|
class LiveSyncBacktest:
|
|
"""
|
|
Backtest engine that is 100% synchronized with main_live.py
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
ml_threshold: float = 0.50,
|
|
signal_confirmation: int = 2,
|
|
pullback_filter: bool = True,
|
|
golden_time_only: bool = False,
|
|
max_loss_per_trade: float = 50.0,
|
|
trade_cooldown_bars: int = 10, # OPTIMIZED: was 20, now 10 (~2.5 hours)
|
|
trend_reversal_mult: float = 0.6, # OPTIMIZED: was 0.4, now 0.6 (less aggressive exit)
|
|
sell_filter_strict: bool = True, # OPTIMIZED: require ML agreement for SELL
|
|
):
|
|
"""
|
|
Initialize backtest with configurable parameters.
|
|
|
|
Args:
|
|
ml_threshold: Minimum ML confidence to trade (0.50-0.70)
|
|
signal_confirmation: Number of consecutive signals required
|
|
pullback_filter: Enable pullback detection filter
|
|
golden_time_only: Only trade during 19:00-23:00 WIB
|
|
max_loss_per_trade: Maximum loss before smart exit
|
|
trade_cooldown_bars: Minimum bars between trades (OPTIMIZED: 10)
|
|
trend_reversal_mult: ATR multiplier for trend reversal exit (OPTIMIZED: 0.6)
|
|
sell_filter_strict: Require ML agreement for SELL signals (OPTIMIZED: True)
|
|
"""
|
|
self.ml_threshold = ml_threshold
|
|
self.signal_confirmation = signal_confirmation
|
|
self.pullback_filter = pullback_filter
|
|
self.golden_time_only = golden_time_only
|
|
self.max_loss_per_trade = max_loss_per_trade
|
|
self.trade_cooldown_bars = trade_cooldown_bars
|
|
self.trend_reversal_mult = trend_reversal_mult
|
|
self.sell_filter_strict = sell_filter_strict
|
|
|
|
# Initialize components (same as main_live.py)
|
|
config = get_config()
|
|
|
|
self.smc = SMCAnalyzer(
|
|
swing_length=config.smc.swing_length,
|
|
ob_lookback=config.smc.ob_lookback,
|
|
)
|
|
self.features = FeatureEngineer()
|
|
self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
|
|
self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
|
|
self.dynamic_confidence = create_dynamic_confidence()
|
|
|
|
# Load models
|
|
self.regime_detector.load()
|
|
self.ml_model.load()
|
|
|
|
# State tracking
|
|
self._signal_persistence = {}
|
|
self._ticket_counter = 1000000
|
|
|
|
def _get_session_from_time(self, dt: datetime) -> Tuple[str, bool, float]:
|
|
"""
|
|
Get trading session info from datetime.
|
|
Returns: (session_name, can_trade, lot_multiplier)
|
|
"""
|
|
# Convert to WIB
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
wib_time = dt.astimezone(ZoneInfo("Asia/Jakarta"))
|
|
hour = wib_time.hour
|
|
|
|
# Session definitions (same as session_filter.py)
|
|
if 6 <= hour < 15:
|
|
return "Sydney-Tokyo", True, 0.5 # Lower confidence required
|
|
elif 15 <= hour < 16:
|
|
return "Tokyo-London Overlap", True, 0.75
|
|
elif 16 <= hour < 19:
|
|
return "London Early", True, 0.8
|
|
elif 19 <= hour < 24:
|
|
return "London-NY Overlap (Golden)", True, 1.0 # Best session
|
|
elif 0 <= hour < 4:
|
|
return "NY Session", True, 0.9
|
|
else:
|
|
return "Off Hours", False, 0.0
|
|
|
|
def _is_golden_time(self, dt: datetime) -> bool:
|
|
"""Check if datetime is in golden time (19:00-23:00 WIB)."""
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
wib_time = dt.astimezone(ZoneInfo("Asia/Jakarta"))
|
|
return 19 <= wib_time.hour < 24
|
|
|
|
def _check_pullback_filter(
|
|
self,
|
|
df: pl.DataFrame,
|
|
signal_direction: str,
|
|
idx: int,
|
|
) -> Tuple[bool, str]:
|
|
"""
|
|
Check pullback filter - SYNCED with main_live.py (ATR-based thresholds)
|
|
"""
|
|
if not self.pullback_filter:
|
|
return True, "Pullback filter disabled"
|
|
|
|
try:
|
|
if idx < 5:
|
|
return True, "Not enough data"
|
|
|
|
# Get data up to current index
|
|
closes = df["close"].to_list()[:idx+1]
|
|
last_3 = closes[-3:]
|
|
|
|
# Get ATR for dynamic thresholds (SYNCED: no more hardcoded values)
|
|
atr = 12.0 # Default for XAUUSD
|
|
if "atr" in df.columns:
|
|
atr_list = df["atr"].to_list()[:idx+1]
|
|
if atr_list[-1] is not None and atr_list[-1] > 0:
|
|
atr = atr_list[-1]
|
|
|
|
# Dynamic thresholds based on ATR (SYNCED with main_live.py)
|
|
bounce_threshold = atr * 0.15 # 15% of ATR = significant bounce
|
|
consolidation_threshold = atr * 0.10 # 10% of ATR = consolidation
|
|
|
|
# Short-term momentum
|
|
short_momentum = last_3[-1] - last_3[0]
|
|
momentum_dir = "UP" if short_momentum > 0 else "DOWN"
|
|
|
|
# MACD histogram direction
|
|
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"
|
|
|
|
# Price vs EMA
|
|
price_vs_ema = "NEUTRAL"
|
|
if "ema_9" in df.columns:
|
|
ema_9 = df["ema_9"].to_list()[:idx+1][-1]
|
|
current_price = closes[-1]
|
|
if ema_9 is not None:
|
|
if current_price > ema_9 * 1.001:
|
|
price_vs_ema = "ABOVE"
|
|
elif current_price < ema_9 * 0.999:
|
|
price_vs_ema = "BELOW"
|
|
|
|
# SELL signal pullback check (ATR-based thresholds)
|
|
if signal_direction == "SELL":
|
|
if momentum_dir == "UP" and short_momentum > bounce_threshold:
|
|
return False, f"SELL blocked: Price bouncing UP (+${short_momentum:.2f} > {bounce_threshold:.2f})"
|
|
if macd_dir == "RISING" and momentum_dir == "UP":
|
|
return False, "SELL blocked: MACD bullish + price rising"
|
|
if price_vs_ema == "ABOVE" and momentum_dir == "UP":
|
|
return False, "SELL blocked: Price above EMA9 and rising"
|
|
if momentum_dir == "DOWN":
|
|
return True, f"SELL OK: Momentum aligned (${short_momentum:.2f})"
|
|
if abs(short_momentum) < consolidation_threshold:
|
|
return True, f"SELL OK: Consolidation phase (<{consolidation_threshold:.2f})"
|
|
|
|
# BUY signal pullback check (ATR-based thresholds)
|
|
elif signal_direction == "BUY":
|
|
if momentum_dir == "DOWN" and short_momentum < -bounce_threshold:
|
|
return False, f"BUY blocked: Price falling DOWN (${short_momentum:.2f} < -{bounce_threshold:.2f})"
|
|
if macd_dir == "FALLING" and momentum_dir == "DOWN":
|
|
return False, "BUY blocked: MACD bearish + price falling"
|
|
if price_vs_ema == "BELOW" and momentum_dir == "DOWN":
|
|
return False, "BUY blocked: Price below EMA9 and falling"
|
|
if momentum_dir == "UP":
|
|
return True, f"BUY OK: Momentum aligned (+${short_momentum:.2f})"
|
|
if abs(short_momentum) < consolidation_threshold:
|
|
return True, f"BUY OK: Consolidation phase (<{consolidation_threshold:.2f})"
|
|
|
|
return True, f"Pullback check passed (mom={momentum_dir}, macd={macd_dir})"
|
|
|
|
except Exception as e:
|
|
return True, f"Pullback error: {e}"
|
|
|
|
def _simulate_trade_exit(
|
|
self,
|
|
df: pl.DataFrame,
|
|
entry_idx: int,
|
|
direction: str,
|
|
entry_price: float,
|
|
take_profit: float,
|
|
lot_size: float,
|
|
max_bars: int = 100,
|
|
) -> Tuple[float, float, ExitReason, int, float]:
|
|
"""
|
|
Simulate trade exit with smart exit logic (no hard SL).
|
|
SYNCED with main_live.py and smart_risk_manager.py
|
|
|
|
Returns: (profit_usd, profit_pips, exit_reason, exit_idx, exit_price)
|
|
"""
|
|
pip_value = 10 # XAUUSD: 1 pip = $10 per lot
|
|
|
|
highs = df["high"].to_list()
|
|
lows = df["low"].to_list()
|
|
closes = df["close"].to_list()
|
|
|
|
# Get ATR for dynamic thresholds (SYNCED: no more hardcoded values)
|
|
atr = 12.0 # Default for XAUUSD
|
|
if "atr" in df.columns:
|
|
atr_list = df["atr"].to_list()
|
|
if entry_idx < len(atr_list) and atr_list[entry_idx] is not None:
|
|
atr = atr_list[entry_idx]
|
|
|
|
# Dynamic thresholds based on ATR (OPTIMIZED: configurable multiplier)
|
|
reversal_momentum_threshold = atr * self.trend_reversal_mult # OPTIMIZED: 0.6 default
|
|
min_loss_for_reversal_exit = atr * 0.8 # 80% of ATR = ~$10 equivalent
|
|
|
|
# Get ML predictions for exit logic
|
|
feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
|
|
|
|
# Track profit history for profit_growing check (SYNCED with smart_risk_manager)
|
|
profit_history = []
|
|
|
|
for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
|
|
high = highs[i]
|
|
low = lows[i]
|
|
close = closes[i]
|
|
|
|
# === EXIT LOGIC 1: Take Profit ===
|
|
if direction == "BUY":
|
|
if high >= take_profit:
|
|
pips = (take_profit - entry_price) / 0.1
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, ExitReason.TAKE_PROFIT, i, take_profit
|
|
else: # SELL
|
|
if low <= take_profit:
|
|
pips = (entry_price - take_profit) / 0.1
|
|
profit = pips * pip_value * lot_size
|
|
return profit, pips, ExitReason.TAKE_PROFIT, i, take_profit
|
|
|
|
# Calculate current profit/loss
|
|
if direction == "BUY":
|
|
current_pips = (close - entry_price) / 0.1
|
|
else:
|
|
current_pips = (entry_price - close) / 0.1
|
|
current_profit = current_pips * pip_value * lot_size
|
|
|
|
# Track profit history for growth check
|
|
profit_history.append(current_profit)
|
|
|
|
# === EXIT LOGIC 2: Maximum Loss ===
|
|
if current_profit < -self.max_loss_per_trade:
|
|
return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
|
|
|
|
# === EXIT LOGIC 3: SMART TIME-BASED EXIT (SYNCED with smart_risk_manager) ===
|
|
# 4 hours = 16 bars on M15, 6 hours = 24 bars
|
|
bars_since_entry = i - entry_idx
|
|
|
|
# Check if profit is growing (SYNCED: positive momentum = don't exit early)
|
|
profit_growing = False
|
|
if len(profit_history) >= 4:
|
|
recent_profits = profit_history[-4:]
|
|
profit_momentum = recent_profits[-1] - recent_profits[0]
|
|
profit_growing = profit_momentum > 0
|
|
|
|
# Get ML prediction for agreement check
|
|
ml_agrees = False
|
|
try:
|
|
if (i - entry_idx) % 4 == 0: # Check every 4 bars
|
|
df_slice = df.head(i + 1)
|
|
ml_pred = self.ml_model.predict(df_slice, feature_cols)
|
|
ml_agrees = (
|
|
(direction == "BUY" and ml_pred.signal == "BUY") or
|
|
(direction == "SELL" and ml_pred.signal == "SELL")
|
|
)
|
|
except:
|
|
pass
|
|
|
|
# 4+ hours: Only exit if stuck (no profit growth) - SYNCED
|
|
if bars_since_entry >= 16:
|
|
if current_profit < 5 and not profit_growing:
|
|
# Stuck with no growth - exit
|
|
if current_profit >= 0:
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
elif current_profit > -15:
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
# If profitable and growing and ML agrees - extend time (don't exit)
|
|
|
|
# 6+ hours: Exit unless significantly profitable AND still growing
|
|
if bars_since_entry >= 24:
|
|
if current_profit < 10 or not profit_growing:
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
# If profit > $10 and growing, allow up to 8 hours (32 bars)
|
|
|
|
# 8+ hours: Hard max - exit regardless
|
|
if bars_since_entry >= 32:
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
|
|
# === EXIT LOGIC 4: ML Reversal (check every 5 bars) ===
|
|
if (i - entry_idx) % 5 == 0 and i > entry_idx + 5:
|
|
try:
|
|
df_slice = df.head(i + 1)
|
|
ml_pred = self.ml_model.predict(df_slice, feature_cols)
|
|
|
|
# Strong reversal signal (>65% confidence - synced with live)
|
|
if direction == "BUY" and ml_pred.signal == "SELL" and ml_pred.confidence > 0.65:
|
|
return current_profit, current_pips, ExitReason.ML_REVERSAL, i, close
|
|
elif direction == "SELL" and ml_pred.signal == "BUY" and ml_pred.confidence > 0.65:
|
|
return current_profit, current_pips, ExitReason.ML_REVERSAL, i, close
|
|
except:
|
|
pass
|
|
|
|
# === EXIT LOGIC 5: Trend Reversal (ATR-based momentum shift) ===
|
|
if i > entry_idx + 10:
|
|
recent_closes = closes[i-5:i+1]
|
|
momentum = recent_closes[-1] - recent_closes[0]
|
|
|
|
# Strong momentum against position (ATR-based thresholds)
|
|
if direction == "BUY" and momentum < -reversal_momentum_threshold:
|
|
if current_profit < -min_loss_for_reversal_exit: # Only if already losing
|
|
return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
|
|
elif direction == "SELL" and momentum > reversal_momentum_threshold:
|
|
if current_profit < -min_loss_for_reversal_exit:
|
|
return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
|
|
|
|
# Timeout - close at last price
|
|
final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
|
|
final_price = closes[final_idx]
|
|
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, ExitReason.TIMEOUT, final_idx, final_price
|
|
|
|
def run(
|
|
self,
|
|
df: pl.DataFrame,
|
|
start_date: Optional[datetime] = None,
|
|
end_date: Optional[datetime] = None,
|
|
initial_capital: float = 5000.0,
|
|
) -> BacktestStats:
|
|
"""
|
|
Run backtest on historical data.
|
|
|
|
Args:
|
|
df: DataFrame with OHLCV and indicators
|
|
start_date: Start date filter (default: all data)
|
|
end_date: End date filter (default: all data)
|
|
initial_capital: Starting capital
|
|
|
|
Returns:
|
|
BacktestStats with all trade details
|
|
"""
|
|
stats = BacktestStats()
|
|
capital = initial_capital
|
|
peak_capital = initial_capital
|
|
|
|
# Get feature columns
|
|
feature_cols = [f for f in self.ml_model.feature_names if f in df.columns]
|
|
|
|
# Filter by date if specified
|
|
times = df["time"].to_list()
|
|
|
|
if start_date:
|
|
start_idx = next((i for i, t in enumerate(times) if t >= start_date), 100)
|
|
else:
|
|
start_idx = 100
|
|
|
|
if end_date:
|
|
end_idx = next((i for i, t in enumerate(times) if t > end_date), len(df) - 100)
|
|
else:
|
|
end_idx = len(df) - 100
|
|
|
|
# State tracking
|
|
last_trade_idx = -self.trade_cooldown_bars * 2
|
|
self._signal_persistence = {}
|
|
|
|
print(f"\nRunning backtest (ML threshold: {self.ml_threshold:.0%})...")
|
|
print(f" Date range: {times[start_idx]} to {times[end_idx-1]}")
|
|
print(f" Total bars: {end_idx - start_idx}")
|
|
|
|
# Iterate through data
|
|
for i in range(start_idx, end_idx):
|
|
# === COOLDOWN CHECK ===
|
|
if i - last_trade_idx < self.trade_cooldown_bars:
|
|
continue
|
|
|
|
current_time = times[i]
|
|
|
|
# === SESSION FILTER ===
|
|
session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
|
|
|
|
if not can_trade:
|
|
self._signal_persistence = {}
|
|
continue
|
|
|
|
if self.golden_time_only and not self._is_golden_time(current_time):
|
|
self._signal_persistence = {}
|
|
continue
|
|
|
|
# Get data slice
|
|
df_slice = df.head(i + 1)
|
|
|
|
# === REGIME CHECK ===
|
|
try:
|
|
regime_state = self.regime_detector.get_current_state(df_slice)
|
|
regime = regime_state.regime.value if regime_state else "normal"
|
|
|
|
if regime_state and regime_state.regime == MarketRegime.CRISIS:
|
|
self._signal_persistence = {}
|
|
continue
|
|
except:
|
|
regime = "normal"
|
|
|
|
# === SMC SIGNAL ===
|
|
try:
|
|
smc_signal = self.smc.generate_signal(df_slice)
|
|
except:
|
|
continue
|
|
|
|
if smc_signal is None:
|
|
self._signal_persistence = {}
|
|
continue
|
|
|
|
# === ML PREDICTION ===
|
|
try:
|
|
ml_pred = self.ml_model.predict(df_slice, feature_cols)
|
|
except:
|
|
continue
|
|
|
|
# === DYNAMIC CONFIDENCE CHECK ===
|
|
try:
|
|
market_analysis = self.dynamic_confidence.analyze_market(
|
|
session=session_name,
|
|
regime=regime,
|
|
volatility="medium",
|
|
trend_direction=regime,
|
|
has_smc_signal=True,
|
|
ml_signal=ml_pred.signal,
|
|
ml_confidence=ml_pred.confidence,
|
|
)
|
|
|
|
if market_analysis.quality == MarketQuality.AVOID:
|
|
self._signal_persistence = {}
|
|
continue
|
|
except:
|
|
pass
|
|
|
|
# === ML THRESHOLD CHECK ===
|
|
if ml_pred.confidence < self.ml_threshold:
|
|
self._signal_persistence = {}
|
|
continue
|
|
|
|
# === ML DISAGREEMENT CHECK ===
|
|
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:
|
|
self._signal_persistence = {}
|
|
continue
|
|
|
|
# === SELL FILTER (OPTIMIZED: stricter requirements for SELL) ===
|
|
if self.sell_filter_strict and smc_signal.signal_type == "SELL":
|
|
# Require ML to agree for SELL signals (SELL has lower WR historically)
|
|
if ml_pred.signal != "SELL":
|
|
self._signal_persistence = {}
|
|
continue
|
|
# Require higher ML confidence for SELL
|
|
if ml_pred.confidence < 0.55:
|
|
self._signal_persistence = {}
|
|
continue
|
|
|
|
# === SIGNAL CONFIRMATION (SYNCED with main_live.py) ===
|
|
signal_key = f"{smc_signal.signal_type}_{int(smc_signal.entry_price)}"
|
|
|
|
# Cleanup: Remove entries older than 20 bars (equivalent to 5 min cleanup in live)
|
|
# This prevents memory leak from accumulating stale signals
|
|
self._signal_persistence = {
|
|
k: v for k, v in self._signal_persistence.items()
|
|
if i - v[1] < 20 # Keep only signals seen in last 20 bars
|
|
}
|
|
|
|
# Also limit to max 50 entries as safety (SYNCED)
|
|
if len(self._signal_persistence) > 50:
|
|
# Keep only 20 most recent
|
|
sorted_signals = sorted(self._signal_persistence.items(), key=lambda x: x[1][1], reverse=True)
|
|
self._signal_persistence = dict(sorted_signals[:20])
|
|
|
|
if signal_key not in self._signal_persistence:
|
|
self._signal_persistence[signal_key] = (1, i) # (count, last_seen_idx)
|
|
continue
|
|
else:
|
|
count, _ = self._signal_persistence[signal_key]
|
|
self._signal_persistence[signal_key] = (count + 1, i)
|
|
|
|
# Require at least N consecutive confirmations
|
|
count, _ = self._signal_persistence[signal_key]
|
|
if count < self.signal_confirmation:
|
|
continue
|
|
|
|
# Signal confirmed! Reset counter (SYNCED)
|
|
self._signal_persistence[signal_key] = (0, i)
|
|
|
|
# === PULLBACK FILTER ===
|
|
pullback_ok, pullback_reason = self._check_pullback_filter(
|
|
df_slice, smc_signal.signal_type, i
|
|
)
|
|
if not pullback_ok:
|
|
continue
|
|
|
|
# === CALCULATE LOT SIZE ===
|
|
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
|
|
|
|
# Apply session multiplier
|
|
lot_size = max(0.01, lot_size * lot_mult)
|
|
|
|
# === EXECUTE TRADE ===
|
|
entry_price = smc_signal.entry_price
|
|
take_profit = smc_signal.take_profit
|
|
|
|
profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
|
|
df=df,
|
|
entry_idx=i,
|
|
direction=smc_signal.signal_type,
|
|
entry_price=entry_price,
|
|
take_profit=take_profit,
|
|
lot_size=lot_size,
|
|
)
|
|
|
|
# Record trade
|
|
self._ticket_counter += 1
|
|
result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
|
|
|
|
# ML agrees?
|
|
ml_agrees = (
|
|
(smc_signal.signal_type == "BUY" and ml_pred.signal == "BUY") or
|
|
(smc_signal.signal_type == "SELL" and ml_pred.signal == "SELL")
|
|
)
|
|
combined_conf = (smc_signal.confidence + ml_pred.confidence) / 2 if ml_agrees else smc_signal.confidence
|
|
|
|
trade = SimulatedTrade(
|
|
ticket=self._ticket_counter,
|
|
entry_time=current_time,
|
|
exit_time=times[exit_idx] if exit_idx < len(times) else times[-1],
|
|
direction=smc_signal.signal_type,
|
|
entry_price=entry_price,
|
|
exit_price=exit_price,
|
|
stop_loss=smc_signal.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,
|
|
session=session_name,
|
|
signal_reason=smc_signal.reason,
|
|
)
|
|
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_pct = (peak_capital - capital) / peak_capital * 100
|
|
drawdown_usd = peak_capital - capital
|
|
if drawdown_pct > stats.max_drawdown:
|
|
stats.max_drawdown = drawdown_pct
|
|
stats.max_drawdown_usd = drawdown_usd
|
|
|
|
# Update last trade index
|
|
last_trade_idx = exit_idx
|
|
|
|
# Progress
|
|
if stats.total_trades % 100 == 0:
|
|
print(f" {stats.total_trades} trades processed...")
|
|
|
|
# Calculate final statistics
|
|
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.avg_trade = (stats.total_profit - stats.total_loss) / stats.total_trades
|
|
stats.profit_factor = stats.total_profit / stats.total_loss if stats.total_loss > 0 else float('inf')
|
|
|
|
# Expectancy
|
|
win_prob = stats.wins / stats.total_trades
|
|
loss_prob = stats.losses / stats.total_trades
|
|
stats.expectancy = (win_prob * stats.avg_win) - (loss_prob * stats.avg_loss)
|
|
|
|
# Sharpe ratio (simplified)
|
|
returns = [t.profit_usd for t in stats.trades]
|
|
if len(returns) > 1:
|
|
avg_return = np.mean(returns)
|
|
std_return = np.std(returns)
|
|
stats.sharpe_ratio = (avg_return / std_return) * np.sqrt(252) if std_return > 0 else 0
|
|
|
|
return stats
|
|
|
|
def save_results(self, stats: BacktestStats, filepath: str):
|
|
"""Save backtest results to CSV."""
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
|
|
# Save trades
|
|
trades_data = []
|
|
for t in stats.trades:
|
|
trades_data.append({
|
|
"ticket": t.ticket,
|
|
"entry_time": t.entry_time.isoformat(),
|
|
"exit_time": t.exit_time.isoformat(),
|
|
"direction": t.direction,
|
|
"entry_price": t.entry_price,
|
|
"exit_price": t.exit_price,
|
|
"stop_loss": t.stop_loss,
|
|
"take_profit": t.take_profit,
|
|
"lot_size": t.lot_size,
|
|
"profit_usd": t.profit_usd,
|
|
"profit_pips": t.profit_pips,
|
|
"result": t.result.value,
|
|
"exit_reason": t.exit_reason.value,
|
|
"ml_confidence": t.ml_confidence,
|
|
"smc_confidence": t.smc_confidence,
|
|
"regime": t.regime,
|
|
"session": t.session,
|
|
"signal_reason": t.signal_reason,
|
|
})
|
|
|
|
df_trades = pd.DataFrame(trades_data)
|
|
df_trades.to_csv(filepath, index=False)
|
|
print(f"Trades saved to: {filepath}")
|
|
|
|
# Save summary
|
|
summary_path = filepath.replace(".csv", "_summary.csv")
|
|
summary_data = {
|
|
"metric": [
|
|
"total_trades", "wins", "losses", "win_rate",
|
|
"total_profit", "total_loss", "net_pnl",
|
|
"profit_factor", "avg_win", "avg_loss", "avg_trade",
|
|
"max_drawdown_pct", "max_drawdown_usd",
|
|
"expectancy", "sharpe_ratio"
|
|
],
|
|
"value": [
|
|
stats.total_trades, stats.wins, stats.losses, f"{stats.win_rate:.1f}%",
|
|
f"${stats.total_profit:.2f}", f"${stats.total_loss:.2f}",
|
|
f"${stats.total_profit - stats.total_loss:.2f}",
|
|
f"{stats.profit_factor:.2f}", f"${stats.avg_win:.2f}", f"${stats.avg_loss:.2f}",
|
|
f"${stats.avg_trade:.2f}",
|
|
f"{stats.max_drawdown:.1f}%", f"${stats.max_drawdown_usd:.2f}",
|
|
f"${stats.expectancy:.2f}", f"{stats.sharpe_ratio:.2f}"
|
|
]
|
|
}
|
|
df_summary = pd.DataFrame(summary_data)
|
|
df_summary.to_csv(summary_path, index=False)
|
|
print(f"Summary saved to: {summary_path}")
|
|
|
|
|
|
def tune_thresholds(df: pl.DataFrame, start_date: datetime, end_date: datetime):
|
|
"""
|
|
Find optimal ML threshold and other parameters.
|
|
"""
|
|
print("\n" + "=" * 70)
|
|
print("THRESHOLD TUNING")
|
|
print("=" * 70)
|
|
|
|
results = []
|
|
|
|
# Test different ML thresholds
|
|
ml_thresholds = [0.50, 0.52, 0.55, 0.58, 0.60, 0.65]
|
|
|
|
for ml_thresh in ml_thresholds:
|
|
print(f"\nTesting ML threshold: {ml_thresh:.0%}")
|
|
|
|
backtest = LiveSyncBacktest(
|
|
ml_threshold=ml_thresh,
|
|
signal_confirmation=2,
|
|
pullback_filter=True,
|
|
golden_time_only=False,
|
|
)
|
|
|
|
stats = backtest.run(df, start_date=start_date, end_date=end_date)
|
|
|
|
net_pnl = stats.total_profit - stats.total_loss
|
|
|
|
results.append({
|
|
"ml_threshold": ml_thresh,
|
|
"trades": stats.total_trades,
|
|
"win_rate": stats.win_rate,
|
|
"net_pnl": net_pnl,
|
|
"profit_factor": stats.profit_factor,
|
|
"max_drawdown": stats.max_drawdown,
|
|
"expectancy": stats.expectancy,
|
|
})
|
|
|
|
print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}% | Net: ${net_pnl:.2f} | PF: {stats.profit_factor:.2f}")
|
|
|
|
# Find optimal
|
|
print("\n" + "=" * 70)
|
|
print("TUNING RESULTS")
|
|
print("=" * 70)
|
|
|
|
# Sort by net P/L
|
|
results_sorted = sorted(results, key=lambda x: x["net_pnl"], reverse=True)
|
|
|
|
print(f"\n{'ML Thresh':>10} {'Trades':>8} {'Win Rate':>10} {'Net P/L':>12} {'PF':>8} {'DD':>8}")
|
|
print("-" * 60)
|
|
for r in results_sorted:
|
|
print(f"{r['ml_threshold']:>10.0%} {r['trades']:>8} {r['win_rate']:>9.1f}% ${r['net_pnl']:>10.2f} {r['profit_factor']:>7.2f} {r['max_drawdown']:>7.1f}%")
|
|
|
|
# Best result
|
|
best = results_sorted[0]
|
|
print(f"\nOPTIMAL ML THRESHOLD: {best['ml_threshold']:.0%}")
|
|
print(f" Net P/L: ${best['net_pnl']:.2f}")
|
|
print(f" Win Rate: {best['win_rate']:.1f}%")
|
|
print(f" Profit Factor: {best['profit_factor']:.2f}")
|
|
|
|
return results
|
|
|
|
|
|
def main():
|
|
"""Main entry point."""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Live-Sync Backtest")
|
|
parser.add_argument("--tune", action="store_true", help="Run threshold tuning")
|
|
parser.add_argument("--save", action="store_true", help="Save results to CSV")
|
|
parser.add_argument("--threshold", type=float, default=0.50, help="ML confidence threshold")
|
|
parser.add_argument("--golden-only", action="store_true", help="Only trade golden time")
|
|
parser.add_argument("--cooldown", type=int, default=10, help="Trade cooldown in bars (default: 10)")
|
|
parser.add_argument("--trend-mult", type=float, default=0.6, help="Trend reversal ATR multiplier (default: 0.6)")
|
|
parser.add_argument("--no-sell-filter", action="store_true", help="Disable strict SELL filter")
|
|
parser.add_argument("--baseline", action="store_true", help="Run with baseline settings (old params)")
|
|
args = parser.parse_args()
|
|
|
|
print("=" * 70)
|
|
print("BACKTEST LIVE SYNC - 100% Identical to main_live.py")
|
|
print("=" * 70)
|
|
|
|
# Connect to MT5 and fetch data
|
|
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")
|
|
|
|
# Fetch maximum historical data
|
|
print("Fetching historical data...")
|
|
import os
|
|
_symbol = os.getenv("SYMBOL", "XAUUSD")
|
|
df = mt5.get_market_data(symbol=_symbol, timeframe="M15", count=50000)
|
|
|
|
if len(df) == 0:
|
|
print("ERROR: No data received")
|
|
return
|
|
|
|
print(f"Received {len(df)} bars")
|
|
|
|
# Get date range
|
|
times = df["time"].to_list()
|
|
data_start = times[0]
|
|
data_end = times[-1]
|
|
print(f"Data range: {data_start} to {data_end}")
|
|
|
|
# Filter to January 2025 - Today
|
|
start_date = datetime(2025, 1, 1)
|
|
end_date = datetime.now()
|
|
|
|
# Calculate indicators
|
|
print("\nCalculating indicators...")
|
|
features = FeatureEngineer()
|
|
smc = SMCAnalyzer()
|
|
regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
|
|
regime_detector.load()
|
|
|
|
df = features.calculate_all(df, include_ml_features=True)
|
|
df = smc.calculate_all(df)
|
|
|
|
try:
|
|
df = regime_detector.predict(df)
|
|
except:
|
|
pass
|
|
|
|
print("Indicators calculated")
|
|
|
|
if args.tune:
|
|
# Run threshold tuning
|
|
tune_thresholds(df, start_date, end_date)
|
|
else:
|
|
# Run single backtest
|
|
# Use baseline settings if requested
|
|
if args.baseline:
|
|
cooldown = 20
|
|
trend_mult = 0.4
|
|
sell_filter = False
|
|
print("\n*** BASELINE MODE (old settings) ***")
|
|
else:
|
|
cooldown = args.cooldown
|
|
trend_mult = args.trend_mult
|
|
sell_filter = not args.no_sell_filter
|
|
|
|
backtest = LiveSyncBacktest(
|
|
ml_threshold=args.threshold,
|
|
signal_confirmation=2,
|
|
pullback_filter=True,
|
|
golden_time_only=args.golden_only,
|
|
trade_cooldown_bars=cooldown,
|
|
trend_reversal_mult=trend_mult,
|
|
sell_filter_strict=sell_filter,
|
|
)
|
|
|
|
stats = backtest.run(df, start_date=start_date, end_date=end_date)
|
|
|
|
# Print results
|
|
print("\n" + "=" * 70)
|
|
print("BACKTEST RESULTS")
|
|
print("=" * 70)
|
|
|
|
net_pnl = stats.total_profit - stats.total_loss
|
|
|
|
print(f"\nConfiguration:")
|
|
print(f" ML Threshold: {args.threshold:.0%}")
|
|
print(f" Signal Confirmation: 2 consecutive")
|
|
print(f" Pullback Filter: Enabled")
|
|
print(f" Golden Time Only: {args.golden_only}")
|
|
print(f" Trade Cooldown: {cooldown} bars")
|
|
print(f" Trend Reversal Mult: {trend_mult}")
|
|
print(f" Sell Filter Strict: {sell_filter}")
|
|
|
|
print(f"\nPerformance:")
|
|
print(f" Total Trades: {stats.total_trades}")
|
|
print(f" Wins: {stats.wins}")
|
|
print(f" Losses: {stats.losses}")
|
|
print(f" Win Rate: {stats.win_rate:.1f}%")
|
|
|
|
print(f"\nProfit/Loss:")
|
|
print(f" Total Profit: ${stats.total_profit:.2f}")
|
|
print(f" Total Loss: ${stats.total_loss:.2f}")
|
|
print(f" Net P/L: ${net_pnl:.2f}")
|
|
print(f" Profit Factor: {stats.profit_factor:.2f}")
|
|
|
|
print(f"\nRisk Metrics:")
|
|
print(f" Max Drawdown: {stats.max_drawdown:.1f}% (${stats.max_drawdown_usd:.2f})")
|
|
print(f" Avg Win: ${stats.avg_win:.2f}")
|
|
print(f" Avg Loss: ${stats.avg_loss:.2f}")
|
|
print(f" Expectancy: ${stats.expectancy:.2f}")
|
|
print(f" Sharpe Ratio: {stats.sharpe_ratio:.2f}")
|
|
|
|
# Exit reason breakdown
|
|
print(f"\nExit Reasons:")
|
|
exit_counts = {}
|
|
for t in stats.trades:
|
|
reason = t.exit_reason.value
|
|
exit_counts[reason] = exit_counts.get(reason, 0) + 1
|
|
for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
|
|
pct = count / stats.total_trades * 100
|
|
print(f" {reason}: {count} ({pct:.1f}%)")
|
|
|
|
# Session breakdown
|
|
print(f"\nSession Performance:")
|
|
session_stats = {}
|
|
for t in stats.trades:
|
|
if t.session not in session_stats:
|
|
session_stats[t.session] = {"wins": 0, "losses": 0, "profit": 0}
|
|
if t.result == TradeResult.WIN:
|
|
session_stats[t.session]["wins"] += 1
|
|
else:
|
|
session_stats[t.session]["losses"] += 1
|
|
session_stats[t.session]["profit"] += t.profit_usd
|
|
|
|
for session, data in session_stats.items():
|
|
total = data["wins"] + data["losses"]
|
|
wr = data["wins"] / total * 100 if total > 0 else 0
|
|
print(f" {session}: {total} trades, {wr:.1f}% WR, ${data['profit']:.2f}")
|
|
|
|
if args.save:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filepath = f"backtests/results/backtest_{timestamp}.csv"
|
|
backtest.save_results(stats, filepath)
|
|
|
|
mt5.disconnect()
|
|
print("\n" + "=" * 70)
|
|
print("Backtest complete!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|