diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index a4ccec2..13ad53c 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -1383,6 +1383,14 @@ def run_backtest( primary_timeframe=bt_cfg.primary_tf, confirmation_timeframes=list(bt_cfg.confirmation_tfs), ) + elif platform_cfg.primary_timeframe != bt_cfg.primary_tf: + # A silent mismatch yields 0 trades: the replayer walks + # bt_cfg.primary_tf bars while the aggregator looks for + # platform_cfg.primary_timeframe in the slices and never finds it. + raise ValueError( + f"primary_tf mismatch: BacktestConfig={bt_cfg.primary_tf.value} " + f"vs PlatformConfig={platform_cfg.primary_timeframe.value}" + ) provider = ForexDataProvider(platform_cfg) @@ -1464,6 +1472,15 @@ def run_backtest( if not (bt_filter.session_start_hour <= bar_hour < bt_filter.session_end_hour): skipped_reasons["SESSION_WINDOW"] = skipped_reasons.get("SESSION_WINDOW", 0) + 1 continue + # Blocked hours (live checks datetime.now().hour ≈ bar close). + # analyze_from_dataframes step 6b checks bar-open timestamp, + # which is one bar behind — misses the last 5min of each hour. + _is_xau = PAIR_INFO[bt_cfg.pair]["display"] == "XAU/USD" + _blocked = (platform_cfg.xau_strategy.blocked_hours_utc + if _is_xau else bt_filter.blocked_hours_utc) + if bar_hour in _blocked: + skipped_reasons["SESSION_WINDOW"] = skipped_reasons.get("SESSION_WINDOW", 0) + 1 + continue # Cooldown. if pos - last_entry_pos < bt_cfg.cooldown_bars: diff --git a/config.py b/config.py index c8dc843..333bd3b 100755 --- a/config.py +++ b/config.py @@ -124,6 +124,20 @@ class SignalConfig: # — smaller wins make the fixed spread a larger share of every TP. tp_tightening_factor: float = 1.0 + # ── Trade geometry ──────────────────────────────────────────── + # Round-trip transaction cost estimate in price units (XAU ~$0.30). + round_trip_cost: float = 0.30 + # Spread gate: reject setups whose TP1 distance is below + # round_trip_cost × this multiple (caps cost at ~3% of target). + # Validated 2026-07: small-target trades lose in both data halves + # on M5 while TP≥$10 trades earn ~+0.08R/trade. + min_tp_cost_multiple: float = 30.0 + # Fixed SL floor in pips (80 pips = $8 on XAU). 0 disables — SL then + # floors at ATR × v2_sl_atr_multiplier only. + min_sl_pips: float = 80.0 + # TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5). + tp_floor_rr: float = 0.5 + @dataclass class NewsConfig: @@ -166,6 +180,20 @@ class XauStrategyConfig: # shorts lose consistently against gold's macro uptrend). sell_vol_regimes: List[str] = field(default_factory=lambda: ["high", "extreme"]) + # ── M5-specific filters ────────────────────────────────────── + # M5 signals are noisy; require stronger confirmation before + # generating trade setups. Without these, M5 is breakeven + # (+0.01R/trade) with 21% drawdown. + m5_require_mtf: bool = True + m5_min_abs_score: float = 30.0 + m5_min_confirmation_score: float = 10.0 + # A-grade M5 SELLs enter at the extreme of temporary dips and + # lose (-0.017R/trade on 163 trades) — maximum conviction at + # the worst entry. Cap SELL quality so A-grade can't trigger + # the ATR-based setup generator; B-grade swing setups survive + # because they catch moderate drops, not extremes. + m5_sell_max_quality: List[str] = field(default_factory=lambda: ["B", "C"]) + @dataclass class FilterConfig: diff --git a/engine.py b/engine.py index 4edc884..0efb1cc 100755 --- a/engine.py +++ b/engine.py @@ -85,11 +85,12 @@ class TechnicalEngine: # ── Compute all indicators ───────────────────────────────── disabled = set(self.config.disabled_indicators or []) + # ATR first: other indicators (EMA ribbon, candlestick) use it + if "atr" not in disabled: self._compute_atr(df, result) if "ema_trend" not in disabled: self._compute_ema_signals(df, result) if "rsi" not in disabled: self._compute_rsi(df, result) if "bollinger" not in disabled: self._compute_bollinger(df, result) if "adx" not in disabled: self._compute_adx(df, result) - if "atr" not in disabled: self._compute_atr(df, result) self._compute_macd(df, result) @@ -817,7 +818,10 @@ class TechnicalEngine: def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis): """Detect swing high/low structure levels for S/R and trade setup.""" - lookback = min(self.config.swing_sr_lookback, len(df) - 1) + base_lookback = self.config.swing_sr_lookback + if result.timeframe in ("1min", "5min"): + base_lookback = max(base_lookback, 120) + lookback = min(base_lookback, len(df) - 1) if lookback < 10: return diff --git a/signal_aggregator.py b/signal_aggregator.py index 992ebc1..ee783d1 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -283,6 +283,73 @@ class SignalAggregator: return None return float(adx.iloc[-1]) + def _apply_m5_filters(self, report: SignalReport) -> None: + """Extra selectivity for M5 — noisy TF needs stronger confirmation.""" + if report.signal_quality not in ("A+", "A", "B", "C"): + return + + xau_cfg = self.config.xau_strategy + abs_score = abs(report.final_score) + + # Gate 1: minimum absolute score (M5 composite scores near the + # threshold are noise — require a wider margin). + if abs_score < xau_cfg.m5_min_abs_score: + report.warnings.append( + f"M5 FILTER: |score| {abs_score:.1f} < {xau_cfg.m5_min_abs_score:.0f} minimum" + ) + report.signal_quality = "D" + report.trade_setup = None + return + + # Gate 2: require MTF agreement (M5 alone is too noisy). + if xau_cfg.m5_require_mtf and not report.mtf_agreement: + report.warnings.append( + "M5 FILTER: no MTF agreement — M5 signals require confirmation" + ) + report.signal_quality = "D" + report.trade_setup = None + return + + # Gate 3: at least one confirmation TF must have a meaningful + # score in the same direction — NEUTRAL confirmations don't + # count for M5 since they indicate the move isn't visible on + # higher timeframes yet. + min_conf_score = xau_cfg.m5_min_confirmation_score + is_buy = report.final_score > 0 + confirmed = False + primary_tf = self.config.primary_timeframe.value + for tf_val, tf_score in report.mtf_scores.items(): + if tf_val == primary_tf: + continue + if is_buy and tf_score >= min_conf_score: + confirmed = True + break + if not is_buy and tf_score <= -min_conf_score: + confirmed = True + break + + if not confirmed: + report.warnings.append( + "M5 FILTER: no confirmation TF shows directional conviction" + ) + report.signal_quality = "D" + report.trade_setup = None + return + + # Gate 4: cap SELL quality on M5 — A-grade SELLs enter at the + # extreme of temporary dips in gold's macro uptrend and lose + # money. B-grade SELLs (weaker conviction, shallower entries) + # are marginally profitable. + is_sell = report.final_score < 0 + if is_sell and report.signal_quality not in xau_cfg.m5_sell_max_quality: + report.warnings.append( + f"M5 FILTER: SELL quality {report.signal_quality} capped — " + f"high-conviction M5 shorts enter at extremes" + ) + report.signal_quality = "D" + report.trade_setup = None + return + # ── Public: bar-replayable entry point ───────────────────────── def analyze_from_dataframes( @@ -467,6 +534,10 @@ class SignalAggregator: report.signal_quality = "D" report.trade_setup = None + # ── 6h. M5 noise gate ──────────────────────────────────── + if self.config.primary_timeframe == Timeframe.M5: + self._apply_m5_filters(report) + # ── 7. Trade setup for actionable signals ────────────────── if report.trade_setup is None and report.signal_quality in ("A+", "A"): atr = (report.primary_analysis.key_levels.get("atr", 0) @@ -634,6 +705,15 @@ class SignalAggregator: if rr < min_rr - 1e-9: return None + # Spread gate (same rule as _generate_trade_setup). + min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple + if tp_distance < min_tp: + report.warnings.append( + f"SPREAD GATE: target ${tp_distance:.2f} < ${min_tp:.2f} minimum — " + f"costs would eat the edge" + ) + return None + return TradeSetup( pair=report.symbol, display_name=report.display_name, @@ -903,8 +983,7 @@ class SignalAggregator: ) return None - min_sl_pips = 8.0 if pip_value < 0.01 else 80.0 - min_sl_price = min_sl_pips * pip_value + min_sl_price = self.config.signals.min_sl_pips * pip_value if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY): is_buy = True @@ -914,8 +993,9 @@ class SignalAggregator: is_buy = report.final_score >= 0 levels = report.primary_analysis.key_levels min_rr = self.config.signals.min_rr_ratio + tp_floor = max(min_rr, self.config.signals.tp_floor_rr) tf = self.config.signals.tp_tightening_factor - pre_tight_min_rr = min_rr / tf if tf > 0 else min_rr + pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor tp_mult = 1.5 tp_swing_cap = 2.5 @@ -1003,6 +1083,16 @@ class SignalAggregator: if rr < min_rr - 1e-9: return None + # Spread gate: a target too close to entry hands most of the win + # to transaction costs (validated on M5: sub-$10 targets lose). + min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple + if tp1_distance < min_tp: + report.warnings.append( + f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — " + f"costs would eat the edge" + ) + return None + risk_pips = sl_distance / pip_value reward_pips = tp1_distance / pip_value