e8355b3f62
- 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>
1038 lines
43 KiB
Python
1038 lines
43 KiB
Python
"""
|
|
Backtest #35 -- Fix S/L Bug (hours_to_golden NameError)
|
|
========================================================
|
|
Base: #34A (skip WIB hours 9 & 21) -- best from #34
|
|
|
|
Bug: In smart_risk_manager.py Check 5, `hours_to_golden` was referenced but
|
|
never defined (the golden time logic in Check 3 was removed). This caused a
|
|
NameError when loss hit 50% of max_loss_per_trade, meaning positions were NOT
|
|
closed by software S/L -- only broker emergency S/L (2% capital) would catch them.
|
|
|
|
Fix: Remove the "last chance hold" pass-through, close immediately at threshold.
|
|
|
|
Configs:
|
|
Baseline: #34A logic with the bug (hours_to_golden pass-through in backtest)
|
|
A: Fix S/L 50% -- close at 50% max loss, no exceptions (the actual fix)
|
|
B: Fix S/L 40% -- tighter, close at 40% max loss
|
|
C: Fix S/L 60% -- looser, close at 60% max loss
|
|
|
|
Usage:
|
|
python backtests/backtest_35_fix_sl_bug.py
|
|
"""
|
|
|
|
import polars as pl
|
|
import pandas as pd
|
|
import numpy as np
|
|
from datetime import datetime, timedelta, date
|
|
from typing import Dict, List, Tuple, Optional, Set
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from collections import defaultdict
|
|
import sys
|
|
import os
|
|
from zoneinfo import ZoneInfo
|
|
|
|
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.dynamic_confidence import DynamicConfidenceManager, create_dynamic_confidence, MarketQuality
|
|
from loguru import logger
|
|
|
|
logger.remove()
|
|
logger.add(sys.stderr, level="WARNING")
|
|
|
|
WIB = ZoneInfo("Asia/Jakarta")
|
|
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
|
|
|
|
# --- Enums & Dataclasses ---
|
|
|
|
class TradeResult(Enum):
|
|
WIN = "WIN"
|
|
LOSS = "LOSS"
|
|
BREAKEVEN = "BREAKEVEN"
|
|
|
|
class ExitReason(Enum):
|
|
TAKE_PROFIT = "take_profit"
|
|
SMART_TP = "smart_tp"
|
|
PEAK_PROTECT = "peak_protect"
|
|
EARLY_EXIT = "early_exit"
|
|
EARLY_CUT = "early_cut"
|
|
MAX_LOSS = "max_loss"
|
|
STALL = "stall"
|
|
TREND_REVERSAL = "trend_reversal"
|
|
TIMEOUT = "timeout"
|
|
WEEKEND_CLOSE = "weekend_close"
|
|
TRAILING_SL = "trailing_sl"
|
|
BREAKEVEN_EXIT = "breakeven_exit"
|
|
DAILY_LIMIT = "daily_limit"
|
|
REGIME_DANGER = "regime_danger"
|
|
MARKET_SIGNAL = "market_signal"
|
|
|
|
class TradingMode(Enum):
|
|
NORMAL = "normal"
|
|
RECOVERY = "recovery"
|
|
PROTECTED = "protected"
|
|
STOPPED = "stopped"
|
|
|
|
@dataclass
|
|
class SimulatedTrade:
|
|
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
|
|
smc_confidence: float
|
|
regime: str
|
|
session: str
|
|
signal_reason: str
|
|
has_bos: bool = False
|
|
has_choch: bool = False
|
|
has_fvg: bool = False
|
|
has_ob: bool = False
|
|
atr_at_entry: float = 0.0
|
|
rr_ratio: float = 0.0
|
|
trading_mode: str = "normal"
|
|
h1_trend: str = "NEUTRAL"
|
|
wib_hour: int = 0
|
|
weekday: int = 0
|
|
|
|
@dataclass
|
|
class BacktestStats:
|
|
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)
|
|
equity_curve: List[float] = field(default_factory=list)
|
|
avoided_signals: int = 0
|
|
daily_limit_stops: int = 0
|
|
recovery_mode_trades: int = 0
|
|
session_blocked: int = 0
|
|
h1_filtered: int = 0
|
|
time_filtered: int = 0
|
|
|
|
|
|
# --- S/L Bug Fix Backtest ---
|
|
|
|
class SLBugFixBacktest:
|
|
"""#34A base + S/L bug fix testing with configurable max loss threshold."""
|
|
|
|
def __init__(
|
|
self,
|
|
capital: float = 5000.0,
|
|
max_daily_loss_percent: float = 5.0,
|
|
max_loss_per_trade_percent: float = 1.0,
|
|
base_lot_size: float = 0.01,
|
|
max_lot_size: float = 0.02,
|
|
recovery_lot_size: float = 0.01,
|
|
max_concurrent_positions: int = 2,
|
|
min_profit_to_protect: float = 5.0,
|
|
max_drawdown_from_peak: float = 50.0,
|
|
trade_cooldown_bars: int = 10,
|
|
# #24B base
|
|
skip_tokyo_london: bool = True,
|
|
early_cut_momentum: float = -50.0,
|
|
early_cut_loss_pct: float = 30.0,
|
|
be_mult: float = 2.0,
|
|
trail_start_mult: float = 4.0,
|
|
trail_step_mult: float = 3.0,
|
|
# #28B: Smart breakeven
|
|
be_profit_lock_atr_mult: float = 0.5,
|
|
# #34A: Time filter (skip hours 9 & 21 WIB)
|
|
skip_wib_hours: Set[int] = None,
|
|
skip_weekdays: Set[int] = None,
|
|
trend_reversal_mult: float = 0.6,
|
|
# === #35: S/L bug fix params ===
|
|
max_loss_threshold: float = 0.50, # Fraction of max_loss_per_trade to trigger S/L
|
|
use_golden_hold: bool = False, # True = old buggy behavior (hold near golden)
|
|
):
|
|
self.capital = capital
|
|
self.max_daily_loss_usd = capital * (max_daily_loss_percent / 100)
|
|
self.max_loss_per_trade = capital * (max_loss_per_trade_percent / 100)
|
|
self.base_lot_size = base_lot_size
|
|
self.max_lot_size = max_lot_size
|
|
self.recovery_lot_size = recovery_lot_size
|
|
self.max_concurrent_positions = max_concurrent_positions
|
|
self.min_profit_to_protect = min_profit_to_protect
|
|
self.max_drawdown_from_peak = max_drawdown_from_peak
|
|
self.trade_cooldown_bars = trade_cooldown_bars
|
|
self.trend_reversal_mult = trend_reversal_mult
|
|
|
|
self.skip_tokyo_london = skip_tokyo_london
|
|
self.early_cut_momentum = early_cut_momentum
|
|
self.early_cut_loss_pct = early_cut_loss_pct
|
|
self.be_mult = be_mult
|
|
self.trail_start_mult = trail_start_mult
|
|
self.trail_step_mult = trail_step_mult
|
|
self.be_profit_lock_atr_mult = be_profit_lock_atr_mult
|
|
|
|
# #34A params
|
|
self.skip_wib_hours = skip_wib_hours or {9, 21}
|
|
self.skip_weekdays = skip_weekdays or set()
|
|
|
|
# #35 params
|
|
self.max_loss_threshold = max_loss_threshold
|
|
self.use_golden_hold = use_golden_hold
|
|
|
|
config = get_config()
|
|
self.smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
|
|
self.features = FeatureEngineer()
|
|
self.dynamic_confidence = create_dynamic_confidence()
|
|
|
|
self.ml_model = TradingModel(model_path="models/xgboost_model.pkl")
|
|
try:
|
|
self.ml_model.load()
|
|
except Exception:
|
|
pass
|
|
|
|
self.regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
|
|
try:
|
|
self.regime_detector.load()
|
|
except Exception:
|
|
pass
|
|
|
|
self._ticket_counter = 2350000
|
|
|
|
def _get_session_from_time(self, dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
wib_time = dt.astimezone(WIB)
|
|
hour = wib_time.hour
|
|
if 6 <= hour < 15:
|
|
return "Sydney-Tokyo", True, 0.5
|
|
elif 15 <= hour < 16:
|
|
if self.skip_tokyo_london:
|
|
return "Tokyo-London Overlap", False, 0.0
|
|
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
|
|
elif 0 <= hour < 4:
|
|
return "NY Session", True, 0.9
|
|
else:
|
|
return "Off Hours", False, 0.0
|
|
|
|
def _get_wib_hour(self, dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
return dt.astimezone(WIB).hour
|
|
|
|
def _get_wib_weekday(self, dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
return dt.astimezone(WIB).weekday()
|
|
|
|
def _hours_to_golden(self, dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
wib = dt.astimezone(WIB)
|
|
if 19 <= wib.hour < 24:
|
|
return 0
|
|
target = wib.replace(hour=19, minute=0, second=0, microsecond=0)
|
|
if wib.hour >= 19:
|
|
target += timedelta(days=1)
|
|
return max(0, (target - wib).total_seconds() / 3600)
|
|
|
|
def _is_near_weekend_close(self, dt):
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
wib = dt.astimezone(WIB)
|
|
return wib.weekday() == 5 and wib.hour >= 4 and wib.minute >= 30
|
|
|
|
def _calculate_lot_size(self, confidence, regime, trading_mode, session_mult):
|
|
if trading_mode == TradingMode.STOPPED:
|
|
return 0
|
|
lot = self.base_lot_size
|
|
if trading_mode in (TradingMode.RECOVERY, TradingMode.PROTECTED):
|
|
lot = self.recovery_lot_size
|
|
else:
|
|
if confidence >= 0.65:
|
|
lot = self.max_lot_size
|
|
elif confidence >= 0.55:
|
|
lot = self.base_lot_size
|
|
else:
|
|
lot = self.recovery_lot_size
|
|
if regime.lower() in ["high_volatility", "crisis"]:
|
|
lot = self.recovery_lot_size
|
|
lot = max(0.01, lot * session_mult)
|
|
return round(lot, 2)
|
|
|
|
def _calc_ema(self, data, period):
|
|
if len(data) < period:
|
|
return data[-1] if data else 0
|
|
multiplier = 2 / (period + 1)
|
|
ema = np.mean(data[:period])
|
|
for val in data[period:]:
|
|
ema = (val - ema) * multiplier + ema
|
|
return ema
|
|
|
|
def _get_h1_trend(self, df_h1_slice):
|
|
if df_h1_slice is None or len(df_h1_slice) < 20:
|
|
return "NEUTRAL"
|
|
closes = df_h1_slice["close"].to_list()
|
|
ema20 = self._calc_ema(closes, 20)
|
|
current_price = closes[-1]
|
|
if current_price > ema20 * 1.001:
|
|
return "BULLISH"
|
|
elif current_price < ema20 * 0.999:
|
|
return "BEARISH"
|
|
return "NEUTRAL"
|
|
|
|
def _simulate_trade_exit(
|
|
self, df, entry_idx, direction, entry_price, take_profit, stop_loss,
|
|
lot_size, daily_loss_so_far, feature_cols, max_bars=100,
|
|
):
|
|
pip_value = 10
|
|
highs = df["high"].to_list()
|
|
lows = df["low"].to_list()
|
|
closes = df["close"].to_list()
|
|
times = df["time"].to_list()
|
|
|
|
atr = 12.0
|
|
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]
|
|
|
|
adaptive_breakeven_pips = atr * self.be_mult
|
|
adaptive_trail_start_pips = atr * self.trail_start_mult
|
|
adaptive_trail_step_pips = atr * self.trail_step_mult
|
|
reversal_momentum_threshold = atr * self.trend_reversal_mult
|
|
min_loss_for_reversal_exit = atr * 0.8
|
|
|
|
if self.be_profit_lock_atr_mult > 0:
|
|
be_lock_distance = atr * self.be_profit_lock_atr_mult
|
|
else:
|
|
be_lock_distance = 2.0
|
|
|
|
profit_history = []
|
|
peak_profit = 0.0
|
|
stall_count = 0
|
|
reversal_warnings = 0
|
|
current_sl = stop_loss
|
|
breakeven_moved = False
|
|
|
|
if direction == "BUY":
|
|
target_tp_profit = (take_profit - entry_price) / 0.1 * pip_value * lot_size
|
|
else:
|
|
target_tp_profit = (entry_price - take_profit) / 0.1 * pip_value * lot_size
|
|
|
|
cached_ml_signal = ""
|
|
cached_ml_confidence = 0.5
|
|
|
|
for i in range(entry_idx + 1, min(entry_idx + max_bars, len(df))):
|
|
high = highs[i]
|
|
low = lows[i]
|
|
close = closes[i]
|
|
current_time = times[i]
|
|
|
|
if direction == "BUY":
|
|
current_pips = (close - entry_price) / 0.1
|
|
pip_profit_from_entry = current_pips
|
|
else:
|
|
current_pips = (entry_price - close) / 0.1
|
|
pip_profit_from_entry = current_pips
|
|
current_profit = current_pips * pip_value * lot_size
|
|
|
|
profit_history.append(current_profit)
|
|
if current_profit > peak_profit:
|
|
peak_profit = current_profit
|
|
|
|
bars_since_entry = i - entry_idx
|
|
|
|
if bars_since_entry % 4 == 0 and self.ml_model.fitted:
|
|
try:
|
|
df_slice = df.head(i + 1)
|
|
ml_pred = self.ml_model.predict(df_slice, feature_cols)
|
|
cached_ml_signal = ml_pred.signal
|
|
cached_ml_confidence = ml_pred.confidence
|
|
except Exception:
|
|
pass
|
|
|
|
momentum = 0.0
|
|
if len(profit_history) >= 3:
|
|
recent = profit_history[-5:] if len(profit_history) >= 5 else profit_history
|
|
profit_change = recent[-1] - recent[0]
|
|
momentum = max(-100, min(100, (profit_change / 10) * 50))
|
|
profit_growing = momentum > 0
|
|
|
|
# A.0 TP hit
|
|
if direction == "BUY" and high >= take_profit:
|
|
pips = (take_profit - entry_price) / 0.1
|
|
return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
|
|
elif direction == "SELL" and low <= take_profit:
|
|
pips = (entry_price - take_profit) / 0.1
|
|
return pips * pip_value * lot_size, pips, ExitReason.TAKE_PROFIT, i, take_profit
|
|
|
|
# A.0b Trailing SL hit
|
|
if breakeven_moved and current_sl > 0:
|
|
if direction == "BUY" and low <= current_sl:
|
|
pips = (current_sl - entry_price) / 0.1
|
|
reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
|
|
return pips * pip_value * lot_size, pips, reason, i, current_sl
|
|
elif direction == "SELL" and high >= current_sl:
|
|
pips = (entry_price - current_sl) / 0.1
|
|
reason = ExitReason.TRAILING_SL if pip_profit_from_entry >= adaptive_trail_start_pips else ExitReason.BREAKEVEN_EXIT
|
|
return pips * pip_value * lot_size, pips, reason, i, current_sl
|
|
|
|
# A.1 Breakeven (#28B: Smart)
|
|
if pip_profit_from_entry >= adaptive_breakeven_pips and not breakeven_moved:
|
|
if direction == "BUY":
|
|
current_sl = entry_price + be_lock_distance
|
|
else:
|
|
current_sl = entry_price - be_lock_distance
|
|
breakeven_moved = True
|
|
|
|
# A.2 Trailing SL
|
|
if pip_profit_from_entry >= adaptive_trail_start_pips:
|
|
trail_distance = adaptive_trail_step_pips * 0.1
|
|
if direction == "BUY":
|
|
new_trail_sl = close - trail_distance
|
|
if new_trail_sl > current_sl:
|
|
current_sl = new_trail_sl
|
|
else:
|
|
new_trail_sl = close + trail_distance
|
|
if current_sl == 0 or new_trail_sl < current_sl:
|
|
current_sl = new_trail_sl
|
|
|
|
# A.3 Peak protect
|
|
if peak_profit > self.min_profit_to_protect:
|
|
drawdown_pct = ((peak_profit - current_profit) / peak_profit) * 100 if peak_profit > 0 else 0
|
|
if drawdown_pct > self.max_drawdown_from_peak:
|
|
return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
|
|
|
|
# A.4 Market analysis
|
|
if bars_since_entry % 5 == 0 and bars_since_entry >= 5 and i >= 20:
|
|
ma_fast = np.mean(closes[i-4:i+1])
|
|
ma_slow = np.mean(closes[i-19:i+1])
|
|
trend = "BULLISH" if ma_fast > ma_slow * 1.001 else ("BEARISH" if ma_fast < ma_slow * 0.999 else "NEUTRAL")
|
|
roc = (closes[i] / closes[max(0,i-4)] - 1) * 100
|
|
mom_dir = "BULLISH" if roc > 0.3 else ("BEARISH" if roc < -0.3 else "NEUTRAL")
|
|
|
|
rsi_val = None
|
|
if "rsi" in df.columns:
|
|
rsi_list = df["rsi"].to_list()
|
|
if i < len(rsi_list):
|
|
rsi_val = rsi_list[i]
|
|
|
|
urgency = 0
|
|
should_exit = False
|
|
if cached_ml_confidence > 0.75:
|
|
if (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY"):
|
|
should_exit = True; urgency += 2
|
|
if rsi_val:
|
|
if (rsi_val > 75 and direction == "BUY") or (rsi_val < 25 and direction == "SELL"):
|
|
should_exit = True; urgency += 2
|
|
if (direction == "BUY" and trend == "BEARISH" and mom_dir == "BEARISH") or \
|
|
(direction == "SELL" and trend == "BULLISH" and mom_dir == "BULLISH"):
|
|
should_exit = True; urgency += 3
|
|
|
|
if should_exit and current_profit > self.min_profit_to_protect / 2:
|
|
return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
|
|
if urgency >= 7 and current_profit > 0:
|
|
return current_profit, current_pips, ExitReason.MARKET_SIGNAL, i, close
|
|
|
|
# A.5 Weekend close
|
|
if self._is_near_weekend_close(current_time):
|
|
if current_profit > 0 or current_profit > -10:
|
|
return current_profit, current_pips, ExitReason.WEEKEND_CLOSE, i, close
|
|
|
|
# B.1 Smart TP
|
|
if current_profit >= 15:
|
|
if current_profit >= 40:
|
|
return current_profit, current_pips, ExitReason.SMART_TP, i, close
|
|
if current_profit >= 25 and momentum < -30:
|
|
return current_profit, current_pips, ExitReason.SMART_TP, i, close
|
|
if peak_profit > 30 and current_profit < peak_profit * 0.6:
|
|
return current_profit, current_pips, ExitReason.PEAK_PROTECT, i, close
|
|
if current_profit >= 20:
|
|
progress = (current_profit / target_tp_profit) * 100 if target_tp_profit > 0 else 0
|
|
progress_score = min(40, max(0, progress * 0.4))
|
|
momentum_score = ((momentum + 100) / 200) * 30
|
|
time_penalty = min(10, bars_since_entry / 4 * 2)
|
|
tp_probability = progress_score + momentum_score + 10 - time_penalty
|
|
if tp_probability < 25:
|
|
return current_profit, current_pips, ExitReason.SMART_TP, i, close
|
|
|
|
# B.2 Smart Early Exit
|
|
if 5 <= current_profit < 15:
|
|
if momentum < -50 and cached_ml_confidence >= 0.65:
|
|
is_reversal = (direction == "BUY" and cached_ml_signal == "SELL") or (direction == "SELL" and cached_ml_signal == "BUY")
|
|
if is_reversal:
|
|
return current_profit, current_pips, ExitReason.EARLY_EXIT, i, close
|
|
|
|
# B.3 Early cut
|
|
if current_profit < 0:
|
|
loss_percent_of_max = abs(current_profit) / self.max_loss_per_trade * 100
|
|
if momentum < self.early_cut_momentum and loss_percent_of_max >= self.early_cut_loss_pct:
|
|
return current_profit, current_pips, ExitReason.EARLY_CUT, i, close
|
|
|
|
# B.4 Trend Reversal
|
|
is_ml_reversal = False
|
|
if (direction == "BUY" and cached_ml_signal == "SELL" and cached_ml_confidence >= 0.75) or \
|
|
(direction == "SELL" and cached_ml_signal == "BUY" and cached_ml_confidence >= 0.75):
|
|
is_ml_reversal = True
|
|
reversal_warnings += 1
|
|
loss_moderate = abs(current_profit) > (self.max_loss_per_trade * 0.4)
|
|
if is_ml_reversal and current_profit < -8 and loss_moderate:
|
|
return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
|
|
if reversal_warnings >= 3 and current_profit < -10:
|
|
return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
|
|
|
|
# B.5 Max loss — THE KEY DIFFERENCE FOR #35
|
|
if current_profit <= -(self.max_loss_per_trade * self.max_loss_threshold):
|
|
if self.use_golden_hold:
|
|
# OLD BUGGY BEHAVIOR: hold if near golden time
|
|
htg = self._hours_to_golden(current_time)
|
|
if htg <= 1 and htg > 0 and momentum > -40:
|
|
pass # hold — this is what the bug prevented from working
|
|
else:
|
|
return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
|
|
else:
|
|
# FIXED: close immediately, no exceptions
|
|
return current_profit, current_pips, ExitReason.MAX_LOSS, i, close
|
|
|
|
# B.6 Stall
|
|
if len(profit_history) >= 10:
|
|
recent_range = max(profit_history[-10:]) - min(profit_history[-10:])
|
|
if recent_range < 3 and current_profit < -15:
|
|
stall_count += 1
|
|
if stall_count >= 5:
|
|
return current_profit, current_pips, ExitReason.STALL, i, close
|
|
|
|
# B.7 Daily loss limit
|
|
potential_daily_loss = daily_loss_so_far + abs(min(0, current_profit))
|
|
if potential_daily_loss >= self.max_daily_loss_usd:
|
|
return current_profit, current_pips, ExitReason.DAILY_LIMIT, i, close
|
|
|
|
# C) Time-based
|
|
if bars_since_entry >= 16 and current_profit < 5 and not profit_growing:
|
|
if current_profit >= 0 or current_profit > -15:
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
if bars_since_entry >= 24 and (current_profit < 10 or not profit_growing):
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
if bars_since_entry >= 32:
|
|
return current_profit, current_pips, ExitReason.TIMEOUT, i, close
|
|
|
|
# C.2 ATR trend reversal
|
|
if bars_since_entry > 10:
|
|
recent_closes = closes[i-5:i+1]
|
|
mom = recent_closes[-1] - recent_closes[0]
|
|
if (direction == "BUY" and mom < -reversal_momentum_threshold) or \
|
|
(direction == "SELL" and mom > reversal_momentum_threshold):
|
|
if current_profit < -min_loss_for_reversal_exit:
|
|
return current_profit, current_pips, ExitReason.TREND_REVERSAL, i, close
|
|
|
|
final_idx = min(entry_idx + max_bars - 1, len(df) - 1)
|
|
final_price = closes[final_idx]
|
|
pips = ((final_price - entry_price) if direction == "BUY" else (entry_price - final_price)) / 0.1
|
|
return pips * pip_value * lot_size, pips, ExitReason.TIMEOUT, final_idx, final_price
|
|
|
|
def run(self, df_m15, df_h1, start_date=None, end_date=None, initial_capital=5000.0):
|
|
stats = BacktestStats()
|
|
capital = initial_capital
|
|
peak_capital = initial_capital
|
|
stats.equity_curve.append(capital)
|
|
|
|
daily_loss = 0.0
|
|
daily_profit = 0.0
|
|
daily_trades = 0
|
|
consecutive_losses = 0
|
|
trading_mode = TradingMode.NORMAL
|
|
current_date = None
|
|
|
|
feature_cols = []
|
|
if self.ml_model.fitted and self.ml_model.feature_names:
|
|
feature_cols = [f for f in self.ml_model.feature_names if f in df_m15.columns]
|
|
|
|
times_m15 = df_m15["time"].to_list()
|
|
times_h1 = df_h1["time"].to_list() if df_h1 is not None else []
|
|
|
|
start_idx = next((i for i, t in enumerate(times_m15) if t >= start_date), 100) if start_date else 100
|
|
end_idx = next((i for i, t in enumerate(times_m15) if t > end_date), len(df_m15) - 100) if end_date else len(df_m15) - 100
|
|
|
|
last_trade_idx = -self.trade_cooldown_bars * 2
|
|
|
|
skip_hours_str = ",".join(str(h) for h in sorted(self.skip_wib_hours)) if self.skip_wib_hours else "none"
|
|
golden_hold = "YES (old buggy)" if self.use_golden_hold else "NO (fixed)"
|
|
print(f" S/L threshold: {self.max_loss_threshold:.0%} of max | Golden hold: {golden_hold}")
|
|
print(f" Skip hours(WIB): [{skip_hours_str}]")
|
|
print(f" Date range: {times_m15[start_idx]} to {times_m15[end_idx - 1]}")
|
|
print(f" Total bars: {end_idx - start_idx}")
|
|
|
|
for i in range(start_idx, end_idx):
|
|
if i - last_trade_idx < self.trade_cooldown_bars:
|
|
continue
|
|
|
|
current_time = times_m15[i]
|
|
trade_date = current_time.date() if hasattr(current_time, 'date') else current_time
|
|
if current_date is None or trade_date != current_date:
|
|
daily_loss = 0.0
|
|
daily_profit = 0.0
|
|
daily_trades = 0
|
|
current_date = trade_date
|
|
if consecutive_losses < 2:
|
|
trading_mode = TradingMode.NORMAL
|
|
|
|
if trading_mode == TradingMode.STOPPED:
|
|
continue
|
|
|
|
session_name, can_trade, lot_mult = self._get_session_from_time(current_time)
|
|
if not can_trade:
|
|
if session_name == "Tokyo-London Overlap":
|
|
stats.session_blocked += 1
|
|
continue
|
|
|
|
if hasattr(current_time, 'weekday') and current_time.weekday() >= 5:
|
|
continue
|
|
|
|
# #34A: Time-of-hour filter
|
|
wib_hour = self._get_wib_hour(current_time)
|
|
if wib_hour in self.skip_wib_hours:
|
|
stats.time_filtered += 1
|
|
continue
|
|
|
|
# #34A: Day-of-week filter
|
|
wib_weekday = self._get_wib_weekday(current_time)
|
|
if wib_weekday in self.skip_weekdays:
|
|
stats.time_filtered += 1
|
|
continue
|
|
|
|
df_slice = df_m15.head(i + 1)
|
|
|
|
regime = "normal"
|
|
try:
|
|
if self.regime_detector.fitted:
|
|
regime_state = self.regime_detector.get_current_state(df_slice)
|
|
if regime_state:
|
|
regime = regime_state.regime.value
|
|
if regime_state.regime == MarketRegime.CRISIS:
|
|
continue
|
|
if regime_state.recommendation == "SLEEP":
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
ml_signal = ""
|
|
ml_confidence = 0.5
|
|
if self.ml_model.fitted and feature_cols:
|
|
ml_pred = self.ml_model.predict(df_slice, feature_cols)
|
|
ml_signal = ml_pred.signal
|
|
ml_confidence = ml_pred.confidence
|
|
|
|
market_analysis = self.dynamic_confidence.analyze_market(
|
|
session=session_name, regime=regime, volatility="medium",
|
|
trend_direction=regime, has_smc_signal=True,
|
|
ml_signal=ml_signal, ml_confidence=ml_confidence,
|
|
)
|
|
if market_analysis.quality == MarketQuality.AVOID:
|
|
stats.avoided_signals += 1
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
smc_signal = self.smc.generate_signal(df_slice)
|
|
except Exception:
|
|
continue
|
|
|
|
if smc_signal is None:
|
|
continue
|
|
|
|
# #31B: H1 Price vs EMA20 filter
|
|
h1_trend = "NEUTRAL"
|
|
if df_h1 is not None and len(times_h1) > 0:
|
|
h1_idx = 0
|
|
for j, t in enumerate(times_h1):
|
|
if t <= current_time:
|
|
h1_idx = j
|
|
else:
|
|
break
|
|
if h1_idx > 20:
|
|
df_h1_slice = df_h1.head(h1_idx + 1)
|
|
h1_trend = self._get_h1_trend(df_h1_slice)
|
|
|
|
if smc_signal.signal_type == "BUY" and h1_trend != "BULLISH":
|
|
stats.h1_filtered += 1
|
|
continue
|
|
if smc_signal.signal_type == "SELL" and h1_trend != "BEARISH":
|
|
stats.h1_filtered += 1
|
|
continue
|
|
|
|
recent_df = df_slice.tail(10)
|
|
recent_bos = recent_df["bos"].to_list() if "bos" in df_slice.columns else []
|
|
recent_choch = recent_df["choch"].to_list() if "choch" in df_slice.columns else []
|
|
recent_fvg_bull = recent_df["is_fvg_bull"].to_list() if "is_fvg_bull" in df_slice.columns else []
|
|
recent_fvg_bear = recent_df["is_fvg_bear"].to_list() if "is_fvg_bear" in df_slice.columns else []
|
|
recent_obs = recent_df["ob"].to_list() if "ob" in df_slice.columns else []
|
|
|
|
has_bos = 1 in recent_bos or -1 in recent_bos
|
|
has_choch = 1 in recent_choch or -1 in recent_choch
|
|
has_fvg = any(recent_fvg_bull) or any(recent_fvg_bear)
|
|
has_ob = 1 in recent_obs or -1 in recent_obs
|
|
|
|
atr_at_entry = 12.0
|
|
if "atr" in df_slice.columns:
|
|
atr_val = df_slice.tail(1)["atr"].item()
|
|
if atr_val is not None and atr_val > 0:
|
|
atr_at_entry = atr_val
|
|
|
|
confidence = smc_signal.confidence
|
|
ml_agrees = (smc_signal.signal_type == "BUY" and ml_signal == "BUY") or \
|
|
(smc_signal.signal_type == "SELL" and ml_signal == "SELL")
|
|
if ml_agrees:
|
|
confidence = (smc_signal.confidence + ml_confidence) / 2
|
|
if regime == "high_volatility":
|
|
confidence *= 0.9
|
|
|
|
lot_size = self._calculate_lot_size(confidence, regime, trading_mode, lot_mult)
|
|
if lot_size <= 0:
|
|
continue
|
|
|
|
if trading_mode == TradingMode.RECOVERY:
|
|
stats.recovery_mode_trades += 1
|
|
|
|
entry_price = smc_signal.entry_price
|
|
take_profit_price = smc_signal.take_profit
|
|
stop_loss_price = smc_signal.stop_loss
|
|
risk = abs(entry_price - stop_loss_price)
|
|
rr = abs(take_profit_price - entry_price) / risk if risk > 0 else 0
|
|
|
|
profit, pips, exit_reason, exit_idx, exit_price = self._simulate_trade_exit(
|
|
df=df_m15, entry_idx=i, direction=smc_signal.signal_type,
|
|
entry_price=entry_price, take_profit=take_profit_price,
|
|
stop_loss=stop_loss_price, lot_size=lot_size,
|
|
daily_loss_so_far=daily_loss, feature_cols=feature_cols,
|
|
)
|
|
|
|
self._ticket_counter += 1
|
|
result = TradeResult.WIN if profit > 0 else (TradeResult.LOSS if profit < 0 else TradeResult.BREAKEVEN)
|
|
|
|
trade = SimulatedTrade(
|
|
ticket=self._ticket_counter,
|
|
entry_time=current_time,
|
|
exit_time=times_m15[exit_idx] if exit_idx < len(times_m15) else times_m15[-1],
|
|
direction=smc_signal.signal_type,
|
|
entry_price=entry_price, exit_price=exit_price,
|
|
stop_loss=stop_loss_price, take_profit=take_profit_price,
|
|
lot_size=lot_size, profit_usd=profit, profit_pips=pips,
|
|
result=result, exit_reason=exit_reason,
|
|
smc_confidence=confidence, regime=regime,
|
|
session=session_name, signal_reason=smc_signal.reason,
|
|
has_bos=has_bos, has_choch=has_choch,
|
|
has_fvg=has_fvg, has_ob=has_ob,
|
|
atr_at_entry=atr_at_entry, rr_ratio=rr,
|
|
trading_mode=trading_mode.value,
|
|
h1_trend=h1_trend,
|
|
wib_hour=wib_hour,
|
|
weekday=wib_weekday,
|
|
)
|
|
stats.trades.append(trade)
|
|
stats.total_trades += 1
|
|
daily_trades += 1
|
|
capital += profit
|
|
|
|
if profit > 0:
|
|
stats.wins += 1
|
|
stats.total_profit += profit
|
|
daily_profit += profit
|
|
consecutive_losses = 0
|
|
if trading_mode == TradingMode.RECOVERY:
|
|
trading_mode = TradingMode.NORMAL
|
|
else:
|
|
stats.losses += 1
|
|
stats.total_loss += abs(profit)
|
|
daily_loss += abs(profit)
|
|
consecutive_losses += 1
|
|
|
|
if daily_loss >= self.max_daily_loss_usd:
|
|
trading_mode = TradingMode.STOPPED
|
|
stats.daily_limit_stops += 1
|
|
elif consecutive_losses >= 3 or daily_loss >= self.max_daily_loss_usd * 0.6:
|
|
trading_mode = TradingMode.PROTECTED
|
|
elif consecutive_losses >= 2:
|
|
trading_mode = TradingMode.RECOVERY
|
|
|
|
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
|
|
|
|
stats.equity_curve.append(capital)
|
|
last_trade_idx = exit_idx
|
|
|
|
if stats.total_trades % 100 == 0:
|
|
print(f" {stats.total_trades} trades processed...")
|
|
|
|
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")
|
|
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)
|
|
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
|
|
|
|
|
|
# --- Main ---
|
|
|
|
def main():
|
|
print("=" * 70)
|
|
print("XAUBOT AI -- #35 Fix S/L Bug (hours_to_golden NameError)")
|
|
print("Base: #34A (skip WIB hours 9 & 21) | Fix: Remove golden hold in max loss check")
|
|
print("=" * 70)
|
|
|
|
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()
|
|
print(f"\nConnected to MT5")
|
|
|
|
print("Fetching XAUUSD M15 historical data...")
|
|
df_m15 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="M15", count=50000)
|
|
print(f" M15: {len(df_m15)} bars")
|
|
|
|
print("Fetching XAUUSD H1 historical data...")
|
|
df_h1 = mt5_conn.get_market_data(symbol="XAUUSD", timeframe="H1", count=15000)
|
|
print(f" H1: {len(df_h1)} bars")
|
|
|
|
times = df_m15["time"].to_list()
|
|
print(f" M15 range: {times[0]} to {times[-1]}")
|
|
|
|
end_date = datetime.now()
|
|
start_date = datetime(2025, 8, 1)
|
|
data_start = times[0]
|
|
if hasattr(data_start, 'replace') and data_start.tzinfo:
|
|
start_date = start_date.replace(tzinfo=data_start.tzinfo)
|
|
end_date = end_date.replace(tzinfo=data_start.tzinfo)
|
|
if data_start > start_date:
|
|
start_date = data_start + timedelta(days=5)
|
|
|
|
print(f"\n Backtest period: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
|
|
|
|
print("\nCalculating M15 indicators...")
|
|
features = FeatureEngineer()
|
|
smc = SMCAnalyzer(swing_length=config.smc.swing_length, ob_lookback=config.smc.ob_lookback)
|
|
df_m15 = features.calculate_all(df_m15, include_ml_features=True)
|
|
df_m15 = smc.calculate_all(df_m15)
|
|
|
|
regime_detector = MarketRegimeDetector(model_path="models/hmm_regime.pkl")
|
|
try:
|
|
regime_detector.load()
|
|
df_m15 = regime_detector.predict(df_m15)
|
|
print(" HMM regime loaded")
|
|
except Exception:
|
|
print(" [WARN] HMM not available")
|
|
|
|
print("Calculating H1 indicators...")
|
|
df_h1 = features.calculate_all(df_h1, include_ml_features=False)
|
|
print(" All indicators calculated")
|
|
|
|
baseline_34a_pnl = None # Will be set from baseline run
|
|
|
|
# ===============================================================
|
|
# CONFIGS
|
|
# ===============================================================
|
|
configs = [
|
|
("Baseline: #34A (golden hold)", {
|
|
"max_loss_threshold": 0.50,
|
|
"use_golden_hold": True, # Simulate the old behavior (what it WOULD have done if not crashing)
|
|
}),
|
|
("A: Fix S/L 50% (no hold)", {
|
|
"max_loss_threshold": 0.50,
|
|
"use_golden_hold": False, # THE FIX
|
|
}),
|
|
("B: Fix S/L 40% (tighter)", {
|
|
"max_loss_threshold": 0.40,
|
|
"use_golden_hold": False,
|
|
}),
|
|
("C: Fix S/L 60% (looser)", {
|
|
"max_loss_threshold": 0.60,
|
|
"use_golden_hold": False,
|
|
}),
|
|
]
|
|
|
|
all_results = []
|
|
|
|
for cfg_name, cfg_params in configs:
|
|
print(f"\n{'=' * 60}")
|
|
print(f" Config: {cfg_name}")
|
|
|
|
bt = SLBugFixBacktest(**cfg_params)
|
|
stats = bt.run(df_m15=df_m15, df_h1=df_h1, start_date=start_date, end_date=end_date, initial_capital=5000.0)
|
|
net_pnl = stats.total_profit - stats.total_loss
|
|
|
|
if baseline_34a_pnl is None:
|
|
baseline_34a_pnl = net_pnl
|
|
|
|
diff = net_pnl - baseline_34a_pnl
|
|
|
|
buy_trades = [t for t in stats.trades if t.direction == "BUY"]
|
|
sell_trades = [t for t in stats.trades if t.direction == "SELL"]
|
|
buy_wins = sum(1 for t in buy_trades if t.result == TradeResult.WIN)
|
|
sell_wins = sum(1 for t in sell_trades if t.result == TradeResult.WIN)
|
|
buy_wr = buy_wins / len(buy_trades) * 100 if buy_trades else 0
|
|
sell_wr = sell_wins / len(sell_trades) * 100 if sell_trades else 0
|
|
buy_pnl = sum(t.profit_usd for t in buy_trades)
|
|
sell_pnl = sum(t.profit_usd for t in sell_trades)
|
|
|
|
# Count max_loss exits
|
|
max_loss_exits = sum(1 for t in stats.trades if t.exit_reason == ExitReason.MAX_LOSS)
|
|
|
|
print(f"\n [{cfg_name}] Results:")
|
|
print(f" Trades: {stats.total_trades} | WR: {stats.win_rate:.1f}%")
|
|
print(f" Net PnL: ${net_pnl:,.2f} | PF: {stats.profit_factor:.2f}")
|
|
print(f" Max DD: {stats.max_drawdown:.1f}% | Sharpe: {stats.sharpe_ratio:.2f}")
|
|
print(f" BUY: {len(buy_trades)}, {buy_wr:.1f}% WR, ${buy_pnl:,.2f}")
|
|
print(f" SELL: {len(sell_trades)}, {sell_wr:.1f}% WR, ${sell_pnl:,.2f}")
|
|
print(f" Max-loss exits: {max_loss_exits}")
|
|
print(f" vs Baseline: ${diff:+,.2f}")
|
|
|
|
all_results.append((cfg_name, stats, net_pnl, diff, max_loss_exits))
|
|
|
|
# === FINAL SUMMARY ===
|
|
print(f"\n{'=' * 70}")
|
|
print("#35 FIX S/L BUG -- ALL CONFIGURATIONS")
|
|
print("=" * 70)
|
|
|
|
print(f"\n {'Config':<30} {'Trades':>6} {'WR':>6} {'Net PnL':>10} {'DD':>6} {'Sharpe':>7} {'PF':>5} {'MaxLoss':>8} {'vs Base':>10}")
|
|
print(f" {'-' * 95}")
|
|
for cfg_name, stats, net_pnl, diff, ml_exits in all_results:
|
|
print(f" {cfg_name:<30} {stats.total_trades:>6} {stats.win_rate:>5.1f}% ${net_pnl:>9,.2f} {stats.max_drawdown:>5.1f}% {stats.sharpe_ratio:>7.2f} {stats.profit_factor:>5.2f} {ml_exits:>8} ${diff:>+9,.2f}")
|
|
|
|
# Find best (excluding baseline)
|
|
best_pnl = -999999
|
|
best_name = ""
|
|
best_stats = None
|
|
for entry in all_results[1:]: # Skip baseline
|
|
if entry[2] > best_pnl:
|
|
best_pnl = entry[2]
|
|
best_name = entry[0]
|
|
best_stats = entry[1]
|
|
|
|
print(f"\n Best config: {best_name}")
|
|
|
|
# Exit reasons for best
|
|
if best_stats:
|
|
print(f"\n Exit Reasons (best config):")
|
|
exit_counts = {}
|
|
for t in best_stats.trades:
|
|
r = t.exit_reason.value
|
|
exit_counts[r] = exit_counts.get(r, 0) + 1
|
|
for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
|
|
pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
|
|
print(f" {reason:20s}: {count} ({pct:.1f}%)")
|
|
|
|
# Compare max_loss exit details: avg loss per max_loss exit
|
|
print(f"\n Max-Loss Exit Analysis:")
|
|
for cfg_name, stats, net_pnl, diff, ml_exits in all_results:
|
|
ml_trades = [t for t in stats.trades if t.exit_reason == ExitReason.MAX_LOSS]
|
|
if ml_trades:
|
|
avg_ml_loss = np.mean([t.profit_usd for t in ml_trades])
|
|
worst_ml = min(t.profit_usd for t in ml_trades)
|
|
print(f" {cfg_name:<30}: {len(ml_trades)} exits, avg ${avg_ml_loss:,.2f}, worst ${worst_ml:,.2f}")
|
|
else:
|
|
print(f" {cfg_name:<30}: 0 exits")
|
|
|
|
# Save
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "35_fix_sl_bug_results")
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
log_path = os.path.join(output_dir, f"fix_sl_bug_{timestamp}.log")
|
|
with open(log_path, "w") as f:
|
|
f.write(f"#35 Fix S/L Bug Results\n")
|
|
f.write(f"Generated: {datetime.now()}\n")
|
|
f.write(f"Bug: hours_to_golden NameError in Check 5 max loss\n")
|
|
f.write(f"Fix: Remove golden hold pass-through, close immediately at threshold\n\n")
|
|
|
|
f.write(f"=== RESULTS ===\n")
|
|
for cfg_name, stats, net_pnl, diff, ml_exits in all_results:
|
|
f.write(f" {cfg_name}: {stats.total_trades} trades, {stats.win_rate:.1f}% WR, "
|
|
f"${net_pnl:,.2f}, DD: {stats.max_drawdown:.1f}%, "
|
|
f"Sharpe: {stats.sharpe_ratio:.2f}, PF: {stats.profit_factor:.2f}, "
|
|
f"MaxLoss exits: {ml_exits}, vs Base: ${diff:+,.2f}\n")
|
|
|
|
f.write(f"\nBest: {best_name}\n")
|
|
|
|
f.write(f"\n=== MAX-LOSS EXIT ANALYSIS ===\n")
|
|
for cfg_name, stats, net_pnl, diff, ml_exits in all_results:
|
|
ml_trades = [t for t in stats.trades if t.exit_reason == ExitReason.MAX_LOSS]
|
|
if ml_trades:
|
|
avg_ml_loss = np.mean([t.profit_usd for t in ml_trades])
|
|
worst_ml = min(t.profit_usd for t in ml_trades)
|
|
f.write(f" {cfg_name}: {len(ml_trades)} exits, avg ${avg_ml_loss:,.2f}, worst ${worst_ml:,.2f}\n")
|
|
else:
|
|
f.write(f" {cfg_name}: 0 exits\n")
|
|
|
|
f.write(f"\n=== EXIT REASONS (BEST) ===\n")
|
|
if best_stats:
|
|
exit_counts = {}
|
|
for t in best_stats.trades:
|
|
r = t.exit_reason.value
|
|
exit_counts[r] = exit_counts.get(r, 0) + 1
|
|
for reason, count in sorted(exit_counts.items(), key=lambda x: -x[1]):
|
|
pct = count / best_stats.total_trades * 100 if best_stats.total_trades > 0 else 0
|
|
f.write(f" {reason:20s}: {count} ({pct:.1f}%)\n")
|
|
|
|
print(f"\n Log saved: {log_path}")
|
|
|
|
try:
|
|
from backtests.backtest_01_smc_only import generate_xlsx_report as gen_xlsx
|
|
xlsx_path = os.path.join(output_dir, f"fix_sl_bug_{timestamp}.xlsx")
|
|
gen_xlsx(best_stats, xlsx_path, start_date, end_date)
|
|
print(f"\n Report saved: {xlsx_path}")
|
|
except Exception as e:
|
|
print(f" [WARN] XLSX: {e}")
|
|
|
|
mt5_conn.disconnect()
|
|
|
|
print(f"\n{'=' * 70}")
|
|
print(f"Output: {output_dir}")
|
|
print(f" Log: {os.path.basename(log_path)}")
|
|
print("=" * 70)
|
|
print("Backtest complete!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|