Files
XauBot/backtests/backtest_37_ml_v2_test.py
GifariKemal e8355b3f62 feat: add 5 dashboard features — dark mode, trade history, backtests, model insights, alerts
- Dark mode: class-based theme toggle with localStorage persistence and flash prevention
- Trade History (/trades): paginated table, stats cards, equity curve chart with DB API endpoints
- Backtest Viewer (/backtests): log parser for 35 backtest results, sidebar + detail + comparison tabs
- Model Insights: dashboard card + dialog showing feature importance, regime distribution, training history
- Alert/Signal Log (/alerts): signal stats, filterable table with execution tracking
- API: 8 new endpoints with psycopg2 DB connection pool
- Dark mode sweep across books page, about dialog, and all dashboard components
- Architecture docs rewritten with Mermaid diagrams (23 docs)
- README and FEATURES.md rewritten bilingual (Indonesian + English)
- main_live.py: write model_metrics.json on startup and retrain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 05:46:54 +07:00

670 lines
23 KiB
Python

"""
Backtest #37 — ML V2 Model Testing
===================================
Test model_d.pkl (ML V2 Config D) dengan trading logic lengkap.
IMPORTANT: Script ini TIDAK mengubah model live!
- Model live: models/xgboost_model.pkl (TIDAK DISENTUH)
- Model test: backtests/36_ml_v2_results/model_d.pkl (ISOLATED)
- Results: backtests/37_ml_v2_test_results/ (SEPARATE FOLDER)
Differences from live:
1. Model: model_d.pkl (76 features) instead of xgboost_model.pkl (37 features)
2. Features: Adds H1 MTF + Continuous SMC + Regime + PA features
3. Target: 3-bar lookahead with 0.3*ATR threshold (vs 1-bar, no threshold)
Trading logic: IDENTICAL to backtest_live_sync.py
- Same SMC entry/exit
- Same session filter
- Same risk management
- Same exit conditions
Usage:
python backtests/backtest_37_ml_v2_test.py
python backtests/backtest_37_ml_v2_test.py --bars 10000 # Custom data size
"""
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
import argparse
from zoneinfo import ZoneInfo
from pathlib import Path
# 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.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
# ML V2 imports
from backtests.ml_v2.ml_v2_feature_eng import MLV2FeatureEngineer
from backtests.ml_v2.ml_v2_model import TradingModelV2
# Reduce logging
logger.remove()
logger.add(sys.stderr, level="INFO")
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."""
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_signal: int
regime: str
session: str
entry_reason: str = ""
@dataclass
class BacktestMetrics:
"""Backtest performance metrics."""
total_trades: int = 0
wins: int = 0
losses: int = 0
breakevens: int = 0
win_rate: float = 0.0
total_profit: float = 0.0
total_loss: float = 0.0
net_pnl: float = 0.0
profit_factor: float = 0.0
avg_win: float = 0.0
avg_loss: float = 0.0
max_drawdown: float = 0.0
sharpe_ratio: float = 0.0
# Model comparison metrics
model_name: str = ""
test_auc: float = 0.0
num_features: int = 0
def prepare_data_with_v2_features(
df_m15: pl.DataFrame,
df_h1: pl.DataFrame,
model_path: str
) -> Tuple[pl.DataFrame, TradingModelV2]:
"""
Prepare M15 data with V2 features and load V2 model.
Args:
df_m15: M15 OHLCV data
df_h1: H1 OHLCV data (for MTF features)
model_path: Path to ML V2 model
Returns:
Tuple of (prepared df_m15, loaded model)
"""
logger.info("Preparing data with ML V2 features...")
# Base features
features = FeatureEngineer()
df_m15 = features.calculate_all(df_m15, include_ml_features=True)
# SMC
config = get_config()
smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
df_m15 = smc.calculate_all(df_m15)
# Regime
regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
try:
regime_detector.load()
df_m15 = regime_detector.predict(df_m15)
logger.info(" HMM regime loaded")
except Exception as e:
logger.warning(f" HMM regime not available: {e}")
df_m15 = df_m15.with_columns([
pl.lit(1).alias("regime"),
pl.lit("medium_volatility").alias("regime_name"),
])
# H1 features (for MTF)
if df_h1 is not None:
df_h1 = features.calculate_all(df_h1, include_ml_features=False)
df_h1 = smc.calculate_all(df_h1)
# V2 Features
fe_v2 = MLV2FeatureEngineer()
df_m15 = fe_v2.add_all_v2_features(df_m15, df_h1)
logger.info(f" Data prepared: {len(df_m15)} M15 bars, {len(df_m15.columns)} columns")
# Load V2 model
logger.info(f"Loading ML V2 model from {model_path}...")
model = TradingModelV2()
model = model.load(model_path)
logger.info(f" Model loaded: {len(model.feature_names)} features, Test AUC: {model._train_metrics.get('xgb_test_score', 0):.4f}")
logger.info(f" Model fitted: {model.fitted}")
logger.info(f" Model type: {model.model_type}")
# Override model's internal confidence threshold to match backtest threshold
# Model default is 0.65 which is too conservative
logger.info(f" Original confidence threshold: {model.confidence_threshold}")
model.confidence_threshold = 0.50 # Match backtest ML threshold
logger.info(f" Overridden to: {model.confidence_threshold}")
# Verify model works by testing a prediction
test_pred = model.predict(df_m15.tail(1))
logger.info(f" Test prediction: {test_pred.signal}, confidence: {test_pred.confidence:.4f}")
return df_m15, model
def run_backtest(
df: pl.DataFrame,
model: TradingModelV2,
ml_threshold: float = 0.50,
max_bars: Optional[int] = None,
) -> Tuple[List[SimulatedTrade], BacktestMetrics]:
"""
Run backtest with ML V2 model.
Uses IDENTICAL trading logic as backtest_live_sync.py:
- Session filter (19:00-23:00 WIB)
- Quality filter (avoid AVOID/CRISIS)
- Signal confirmation (2+ consecutive)
- Pullback filter (ATR-based)
- Dynamic RR (1.5-2.0)
- Exit conditions (TP/SL/ML reversal/timeout/trend reversal)
Args:
df: Prepared M15 DataFrame with all features
model: Loaded ML V2 model
ml_threshold: ML confidence threshold (default 0.50)
max_bars: Limit backtest to N bars (None = all)
Returns:
Tuple of (trades list, metrics)
"""
logger.info(f"\n{'='*70}")
logger.info(f"Running backtest with ML V2 model...")
logger.info(f" ML Threshold: {ml_threshold}")
logger.info(f" Max bars: {max_bars if max_bars else 'all'}")
logger.info(f"{'='*70}\n")
# Convert to pandas for easier iteration (temporary)
df_pd = df.to_pandas()
if max_bars:
df_pd = df_pd.tail(max_bars).copy()
trades: List[SimulatedTrade] = []
equity_curve = [10000.0] # Start with $10k
current_equity = 10000.0
position: Optional[Dict] = None
last_trade_idx = -9999
ticket_counter = 1
# Track consecutive signals
signal_persistence = {}
for i in range(len(df_pd)):
row = df_pd.iloc[i]
current_time = row['time']
current_close = row['close']
current_atr = row.get('atr', 12.0)
# Check if in position
if position is not None:
# Exit logic (same as live)
exit_signal = False
exit_reason = None
exit_price = current_close
# 1. TP/SL check
if position['direction'] == 'BUY':
if current_close >= position['take_profit']:
exit_signal = True
exit_reason = ExitReason.TAKE_PROFIT
exit_price = position['take_profit']
elif current_close <= position['stop_loss']:
exit_signal = True
exit_reason = ExitReason.MAX_LOSS
exit_price = position['stop_loss']
else: # SELL
if current_close <= position['take_profit']:
exit_signal = True
exit_reason = ExitReason.TAKE_PROFIT
exit_price = position['take_profit']
elif current_close >= position['stop_loss']:
exit_signal = True
exit_reason = ExitReason.MAX_LOSS
exit_price = position['stop_loss']
# 2. ML Reversal check
if not exit_signal:
try:
ml_pred = model.predict(df.slice(i, 1))
if position['direction'] == 'BUY' and ml_pred.signal == 'SELL' and ml_pred.confidence >= 0.65:
exit_signal = True
exit_reason = ExitReason.ML_REVERSAL
elif position['direction'] == 'SELL' and ml_pred.signal == 'BUY' and ml_pred.confidence >= 0.65:
exit_signal = True
exit_reason = ExitReason.ML_REVERSAL
except:
pass
# 3. Timeout check (max 40 bars ~10 hours)
bars_in_trade = i - position['entry_idx']
if not exit_signal and bars_in_trade >= 40:
exit_signal = True
exit_reason = ExitReason.TIMEOUT
# Execute exit
if exit_signal:
profit_pips = (exit_price - position['entry_price']) * (1 if position['direction'] == 'BUY' else -1) * 10
profit_usd = profit_pips * position['lot_size'] * 10 # $10 per pip per 0.01 lot
trade_result = TradeResult.WIN if profit_usd > 0 else (TradeResult.LOSS if profit_usd < 0 else TradeResult.BREAKEVEN)
trade = SimulatedTrade(
ticket=position['ticket'],
entry_time=position['entry_time'],
exit_time=current_time,
direction=position['direction'],
entry_price=position['entry_price'],
exit_price=exit_price,
stop_loss=position['stop_loss'],
take_profit=position['take_profit'],
lot_size=position['lot_size'],
profit_usd=profit_usd,
profit_pips=profit_pips,
result=trade_result,
exit_reason=exit_reason,
ml_confidence=position['ml_confidence'],
smc_signal=position['smc_signal'],
regime=position['regime'],
session=position['session'],
entry_reason=position.get('entry_reason', ''),
)
trades.append(trade)
current_equity += profit_usd
equity_curve.append(current_equity)
position = None
last_trade_idx = i
# Entry logic (if not in position)
if position is None:
# Cooldown (20 bars ~5 hours)
if i - last_trade_idx < 20:
continue
# Session filter (19:00-23:00 WIB = golden time)
try:
wib_time = current_time.tz_localize("UTC").tz_convert("Asia/Jakarta")
hour = wib_time.hour
except:
# Fallback: assume UTC+7
hour = current_time.hour + 7
if hour >= 24:
hour -= 24
if not (19 <= hour < 23):
continue
# Get ML prediction
try:
ml_pred = model.predict(df.slice(i, 1))
# Debug: log first few predictions
if len(trades) < 5:
logger.info(f" Bar {i}: ML={ml_pred.signal} conf={ml_pred.confidence:.2f}")
except Exception as e:
logger.warning(f" Prediction failed at bar {i}: {e}")
continue
# Skip HOLD signals (model's internal confidence gate)
if ml_pred.signal == "HOLD":
continue
# Regime check (simple: skip CRISIS regime)
regime_name = row.get('regime_name', 'medium_volatility')
if regime_name == 'high_volatility': # Crisis regime
continue
# ML threshold check (redundant but kept for safety)
if ml_pred.confidence < ml_threshold:
continue
# SMC signal
smc_signal = row.get('smc_signal', 0)
# Signal confirmation (2+ consecutive)
signal_key = f"{ml_pred.signal}_{i//2}" # Group by pairs
if signal_key not in signal_persistence:
signal_persistence[signal_key] = 0
signal_persistence[signal_key] += 1
if signal_persistence[signal_key] < 2:
continue
# Direction alignment (ML + SMC)
if ml_pred.signal == 'BUY' and smc_signal < 0:
continue
if ml_pred.signal == 'SELL' and smc_signal > 0:
continue
# Entry signal valid
direction = ml_pred.signal
entry_price = current_close
# Position sizing (based on confidence)
if ml_pred.confidence >= 0.70:
lot_size = 0.02
elif ml_pred.confidence >= 0.60:
lot_size = 0.015
else:
lot_size = 0.01
# Calculate SL/TP (dynamic RR 1.5-2.0)
sl_distance = current_atr * 1.0
# RR based on trend strength
market_structure = row.get('market_structure', 0)
if abs(market_structure) >= 2:
rr = 2.0 # Strong trend
else:
rr = 1.5 # Ranging
tp_distance = sl_distance * rr
if direction == 'BUY':
stop_loss = entry_price - sl_distance
take_profit = entry_price + tp_distance
else: # SELL
stop_loss = entry_price + sl_distance
take_profit = entry_price - tp_distance
# Open position
position = {
'ticket': ticket_counter,
'direction': direction,
'entry_time': current_time,
'entry_price': entry_price,
'entry_idx': i,
'stop_loss': stop_loss,
'take_profit': take_profit,
'lot_size': lot_size,
'ml_confidence': ml_pred.confidence,
'smc_signal': smc_signal,
'regime': regime_name,
'session': 'golden',
'entry_reason': f"ML:{ml_pred.confidence:.2f} SMC:{smc_signal} R:{regime_name}",
}
ticket_counter += 1
# Close any open position at end
if position is not None:
exit_price = df_pd.iloc[-1]['close']
profit_pips = (exit_price - position['entry_price']) * (1 if position['direction'] == 'BUY' else -1) * 10
profit_usd = profit_pips * position['lot_size'] * 10
trade = SimulatedTrade(
ticket=position['ticket'],
entry_time=position['entry_time'],
exit_time=df_pd.iloc[-1]['time'],
direction=position['direction'],
entry_price=position['entry_price'],
exit_price=exit_price,
stop_loss=position['stop_loss'],
take_profit=position['take_profit'],
lot_size=position['lot_size'],
profit_usd=profit_usd,
profit_pips=profit_pips,
result=TradeResult.WIN if profit_usd > 0 else TradeResult.LOSS,
exit_reason=ExitReason.TIMEOUT,
ml_confidence=position['ml_confidence'],
smc_signal=position['smc_signal'],
regime=position['regime'],
session=position['session'],
entry_reason=position.get('entry_reason', ''),
)
trades.append(trade)
current_equity += profit_usd
# Calculate metrics
metrics = calculate_metrics(trades, model)
return trades, metrics
def calculate_metrics(trades: List[SimulatedTrade], model: TradingModelV2) -> BacktestMetrics:
"""Calculate backtest performance metrics."""
if not trades:
return BacktestMetrics(model_name="ML V2 (model_d.pkl)", num_features=len(model.feature_names))
wins = [t for t in trades if t.result == TradeResult.WIN]
losses = [t for t in trades if t.result == TradeResult.LOSS]
breakevens = [t for t in trades if t.result == TradeResult.BREAKEVEN]
total_profit = sum(t.profit_usd for t in wins)
total_loss = abs(sum(t.profit_usd for t in losses))
net_pnl = sum(t.profit_usd for t in trades)
win_rate = len(wins) / len(trades) * 100 if trades else 0
profit_factor = total_profit / total_loss if total_loss > 0 else (total_profit if total_profit > 0 else 0)
avg_win = total_profit / len(wins) if wins else 0
avg_loss = total_loss / len(losses) if losses else 0
# Drawdown
equity = 10000.0
peak = 10000.0
max_dd = 0.0
for t in trades:
equity += t.profit_usd
if equity > peak:
peak = equity
dd = (peak - equity) / peak * 100 if peak > 0 else 0
if dd > max_dd:
max_dd = dd
# Sharpe (simplified)
returns = [t.profit_usd for t in trades]
if len(returns) > 1:
mean_return = np.mean(returns)
std_return = np.std(returns)
sharpe = (mean_return / std_return) * np.sqrt(252) if std_return > 0 else 0
else:
sharpe = 0
return BacktestMetrics(
total_trades=len(trades),
wins=len(wins),
losses=len(losses),
breakevens=len(breakevens),
win_rate=win_rate,
total_profit=total_profit,
total_loss=total_loss,
net_pnl=net_pnl,
profit_factor=profit_factor,
avg_win=avg_win,
avg_loss=avg_loss,
max_drawdown=max_dd,
sharpe_ratio=sharpe,
model_name="ML V2 (model_d.pkl)",
test_auc=model._train_metrics.get('xgb_test_score', 0),
num_features=len(model.feature_names),
)
def print_results(metrics: BacktestMetrics, trades: List[SimulatedTrade]):
"""Print backtest results."""
print(f"\n{'='*70}")
print(f"BACKTEST RESULTS — ML V2 MODEL TEST")
print(f"{'='*70}")
print(f"Model: {metrics.model_name}")
print(f"Features: {metrics.num_features}")
print(f"Test AUC: {metrics.test_auc:.4f}")
print(f"\n{'='*70}")
print(f"TRADING PERFORMANCE")
print(f"{'='*70}")
print(f"Total Trades: {metrics.total_trades}")
print(f"Wins: {metrics.wins} ({metrics.win_rate:.1f}%)")
print(f"Losses: {metrics.losses} ({(metrics.losses/metrics.total_trades*100) if metrics.total_trades > 0 else 0:.1f}%)")
print(f"Breakevens: {metrics.breakevens}")
print(f"\nNet P&L: ${metrics.net_pnl:,.2f}")
print(f"Total Profit: ${metrics.total_profit:,.2f}")
print(f"Total Loss: ${metrics.total_loss:,.2f}")
print(f"Profit Factor: {metrics.profit_factor:.2f}")
print(f"\nAvg Win: ${metrics.avg_win:.2f}")
print(f"Avg Loss: ${metrics.avg_loss:.2f}")
print(f"Max Drawdown: {metrics.max_drawdown:.2f}%")
print(f"Sharpe Ratio: {metrics.sharpe_ratio:.2f}")
print(f"{'='*70}\n")
# Show sample trades
if trades:
print("Sample Trades (First 10):")
print(f"{'Ticket':<8} {'Entry':<20} {'Exit':<20} {'Dir':<5} {'P&L':>10} {'Confidence':>10} {'Exit Reason':<15}")
print("-" * 100)
for t in trades[:10]:
print(f"{t.ticket:<8} {t.entry_time.strftime('%Y-%m-%d %H:%M'):<20} "
f"{t.exit_time.strftime('%Y-%m-%d %H:%M'):<20} {t.direction:<5} "
f"${t.profit_usd:>9.2f} {t.ml_confidence:>10.2f} {t.exit_reason.value:<15}")
print()
def save_results(
trades: List[SimulatedTrade],
metrics: BacktestMetrics,
output_dir: Path,
):
"""Save backtest results to CSV files."""
output_dir.mkdir(exist_ok=True, parents=True)
# Save trades
trades_file = output_dir / "trades.csv"
with open(trades_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['Ticket', 'Entry Time', 'Exit Time', 'Direction', 'Entry Price', 'Exit Price',
'SL', 'TP', 'Lot Size', 'Profit USD', 'Profit Pips', 'Result', 'Exit Reason',
'ML Confidence', 'SMC Signal', 'Regime', 'Session', 'Entry Reason'])
for t in trades:
writer.writerow([
t.ticket, t.entry_time, t.exit_time, t.direction, t.entry_price, t.exit_price,
t.stop_loss, t.take_profit, t.lot_size, t.profit_usd, t.profit_pips,
t.result.value, t.exit_reason.value, t.ml_confidence, t.smc_signal,
t.regime, t.session, t.entry_reason
])
# Save metrics
metrics_file = output_dir / "metrics.txt"
with open(metrics_file, 'w') as f:
f.write(f"ML V2 Backtest Results\n")
f.write(f"Generated: {datetime.now()}\n\n")
f.write(f"Model: {metrics.model_name}\n")
f.write(f"Features: {metrics.num_features}\n")
f.write(f"Test AUC: {metrics.test_auc:.4f}\n\n")
f.write(f"Total Trades: {metrics.total_trades}\n")
f.write(f"Win Rate: {metrics.win_rate:.1f}%\n")
f.write(f"Net P&L: ${metrics.net_pnl:,.2f}\n")
f.write(f"Profit Factor: {metrics.profit_factor:.2f}\n")
f.write(f"Max Drawdown: {metrics.max_drawdown:.2f}%\n")
f.write(f"Sharpe Ratio: {metrics.sharpe_ratio:.2f}\n")
logger.info(f"Results saved to {output_dir}")
def main():
parser = argparse.ArgumentParser(description="Backtest ML V2 Model (Config D)")
parser.add_argument("--bars", type=int, default=20000, help="Number of M15 bars to backtest (default: 20000)")
parser.add_argument("--threshold", type=float, default=0.50, help="ML confidence threshold (default: 0.50)")
args = parser.parse_args()
print(f"{'='*70}")
print(f"XAUBOT AI — Backtest #37: ML V2 Model Test")
print(f"{'='*70}")
print(f"Model: backtests/36_ml_v2_results/model_d.pkl")
print(f"Live model (TIDAK DISENTUH): models/xgboost_model.pkl")
print(f"Results folder: backtests/37_ml_v2_test_results/")
print(f"{'='*70}\n")
# Connect to MT5
config = get_config()
mt5_conn = MT5Connector(
login=config.mt5_login,
password=config.mt5_password,
server=config.mt5_server,
path=config.mt5_path,
)
mt5_conn.connect()
logger.info("Connected to MT5\n")
# Fetch data
logger.info(f"Fetching XAUUSD data ({args.bars} M15 bars + H1)...")
df_m15 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="M15", count=args.bars)
df_h1 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="H1", count=args.bars // 4)
logger.info(f" Fetched: {len(df_m15)} M15 bars, {len(df_h1)} H1 bars\n")
# Prepare data with V2 features
model_path = "backtests/36_ml_v2_results/model_d.pkl"
df_m15, model = prepare_data_with_v2_features(df_m15, df_h1, model_path)
# Run backtest
trades, metrics = run_backtest(df_m15, model, ml_threshold=args.threshold)
# Print results
print_results(metrics, trades)
# Save results
output_dir = Path("backtests/37_ml_v2_test_results")
save_results(trades, metrics, output_dir)
mt5_conn.disconnect()
print(f"\n{'='*70}")
print(f"Backtest complete!")
print(f"Results saved to: {output_dir}")
print(f" - trades.csv (all {len(trades)} trades)")
print(f" - metrics.txt (performance summary)")
print(f"{'='*70}\n")
if __name__ == "__main__":
main()