diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index 1b88100..fc6b6e1 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -1145,6 +1145,8 @@ def simulate_trades( trades: List[Trade], sim_df: pd.DataFrame, max_bars: int = 200, + be_trigger_r: float = 0.0, + be_buffer_r: float = 0.0, ) -> None: """Walk forward bar-by-bar after each entry and resolve SL / TP1. @@ -1234,41 +1236,107 @@ def simulate_trades( t.exit_reason = "NO_DATA" continue - sl_hit_idx = int(sl_first_arr[i]) - tp_hit_idx = int(tp_first_arr[i]) + risk = abs(t.entry_price - t.sl) + is_buy_flag = t.direction == "BUY" - # SL wins ties (conservative same-bar handling). - if sl_hit_idx < n_valid and sl_hit_idx <= tp_hit_idx: - t.exit_reason = "SL" - t.bars_held = sl_hit_idx + 1 - t.exit_price = t.sl - t.r_multiple = -1.0 - t.exit_time = exit_times[i][sl_hit_idx] - elif tp_hit_idx < n_valid: - t.exit_reason = "TP1" - t.bars_held = tp_hit_idx + 1 - t.exit_price = t.tp1 - t.r_multiple = t.rr_planned - t.exit_time = exit_times[i][tp_hit_idx] + if be_trigger_r > 0 and risk > 0: + triggered = False + eff_sl = t.sl + exit_found = False + for j in range(n_valid): + h = float(highs_arr[i, j]) + l = float(lows_arr[i, j]) + if is_buy_flag: + sl_hit_bar = l <= eff_sl + tp_hit_bar = h >= t.tp1 + else: + sl_hit_bar = h >= eff_sl + tp_hit_bar = l <= t.tp1 + if sl_hit_bar and tp_hit_bar: + if triggered: + t.exit_reason = "BE" + t.r_multiple = be_buffer_r + t.exit_price = eff_sl + else: + t.exit_reason = "SL" + t.r_multiple = -1.0 + t.exit_price = t.sl + t.bars_held = j + 1 + t.exit_time = exit_times[i][j] + exit_found = True + break + elif sl_hit_bar: + if triggered: + t.exit_reason = "BE" + t.r_multiple = be_buffer_r + t.exit_price = eff_sl + else: + t.exit_reason = "SL" + t.r_multiple = -1.0 + t.exit_price = t.sl + t.bars_held = j + 1 + t.exit_time = exit_times[i][j] + exit_found = True + break + elif tp_hit_bar: + t.exit_reason = "TP1" + t.bars_held = j + 1 + t.exit_price = t.tp1 + t.r_multiple = t.rr_planned + t.exit_time = exit_times[i][j] + exit_found = True + break + if not triggered: + if is_buy_flag: + bar_mfe = (h - t.entry_price) / risk + else: + bar_mfe = (t.entry_price - l) / risk + if bar_mfe >= be_trigger_r: + triggered = True + if is_buy_flag: + eff_sl = t.entry_price + be_buffer_r * risk + else: + eff_sl = t.entry_price - be_buffer_r * risk + if not exit_found: + t.exit_reason = "TIMEOUT" + t.bars_held = int(n_valid) + last_idx = int(n_valid) - 1 + t.exit_price = float(closes_arr[i, last_idx]) + if risk > 0: + pnl = (t.exit_price - t.entry_price) if is_buy_flag else (t.entry_price - t.exit_price) + t.r_multiple = pnl / risk + t.exit_time = exit_times[i][last_idx] else: - # Time out at last simulated bar. - t.exit_reason = "TIMEOUT" - t.bars_held = int(n_valid) - last_idx = int(n_valid) - 1 - t.exit_price = float(closes_arr[i, last_idx]) - risk = abs(t.entry_price - t.sl) - if risk > 0: - pnl = (t.exit_price - t.entry_price) if t.direction == "BUY" else (t.entry_price - t.exit_price) - t.r_multiple = pnl / risk - t.exit_time = exit_times[i][last_idx] + sl_hit_idx = int(sl_first_arr[i]) + tp_hit_idx = int(tp_first_arr[i]) + if sl_hit_idx < n_valid and sl_hit_idx <= tp_hit_idx: + t.exit_reason = "SL" + t.bars_held = sl_hit_idx + 1 + t.exit_price = t.sl + t.r_multiple = -1.0 + t.exit_time = exit_times[i][sl_hit_idx] + elif tp_hit_idx < n_valid: + t.exit_reason = "TP1" + t.bars_held = tp_hit_idx + 1 + t.exit_price = t.tp1 + t.r_multiple = t.rr_planned + t.exit_time = exit_times[i][tp_hit_idx] + else: + t.exit_reason = "TIMEOUT" + t.bars_held = int(n_valid) + last_idx = int(n_valid) - 1 + t.exit_price = float(closes_arr[i, last_idx]) + if risk > 0: + pnl = (t.exit_price - t.entry_price) if is_buy_flag else (t.entry_price - t.exit_price) + t.r_multiple = pnl / risk + t.exit_time = exit_times[i][last_idx] # MFE / MAE measured only over bars [0 .. bars_held). bars = t.bars_held - risk = abs(t.entry_price - t.sl) if bars > 0 and risk > 0: h_win = highs_arr[i, :bars] l_win = lows_arr[i, :bars] - if t.direction == "BUY": + if is_buy_flag: mfe = (np.nanmax(h_win) - t.entry_price) / risk mae = (t.entry_price - np.nanmin(l_win)) / risk else: @@ -1512,7 +1580,10 @@ def run_backtest( # ── Resolve trade outcomes ── t_sim = time.perf_counter() - simulate_trades(trades, primary_df, max_bars=bt_cfg.max_hold_bars) + be_trigger = getattr(platform_cfg.eur_strategy, 'be_trigger_r', 0.0) if platform_cfg.eur_strategy.enabled and bt_cfg.pair != XAUUSD else 0.0 + be_buffer = getattr(platform_cfg.eur_strategy, 'be_buffer_r', 0.0) if be_trigger > 0 else 0.0 + simulate_trades(trades, primary_df, max_bars=bt_cfg.max_hold_bars, + be_trigger_r=be_trigger, be_buffer_r=be_buffer) logger.info( f"Trade simulation took {time.perf_counter() - t_sim:.2f}s " f"(GPU={'yes' if _GPU_AVAILABLE else 'no'})" diff --git a/config.py b/config.py index 6679bb5..49fa025 100755 --- a/config.py +++ b/config.py @@ -173,11 +173,21 @@ class EurUsdStrategyConfig: tp_atr_multiplier: float = 1.0 sl_atr_floor: float = 1.2 - min_rr_ratio: float = 1.0 + min_rr_ratio: float = 0.5 mature_move_threshold: float = 35.0 tp_quality_scale: Dict[str, float] = field(default_factory=lambda: { - "A+": 1.8, "A": 1.2, "B": 1.1, "C": 1.0, + "A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0, }) + blocked_regimes: List[str] = field(default_factory=lambda: ["ranging"]) + min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) + max_exhaustion_tags: int = 99 + block_near_ema200: bool = False + session_hours: List[int] = field(default_factory=list) + min_entry_score: float = 38.0 + min_entry_confidence: float = 60.0 + block_rsi_extended: bool = True + be_trigger_r: float = 0.3 + be_buffer_r: float = 0.05 @dataclass diff --git a/signal_aggregator.py b/signal_aggregator.py index 4d471b4..ad8971e 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -426,6 +426,72 @@ class SignalAggregator: report.signal_quality = "D" report.trade_setup = None + # ── 6e. EUR/USD regime + quality + risk gate ───────────── + if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled: + eur = self.config.eur_strategy + if eur.blocked_regimes and report.market_regime in eur.blocked_regimes: + report.warnings.append( + f"EUR REGIME FILTER: {report.market_regime} blocked" + ) + report.signal_quality = "F" + if (eur.min_quality_grades + and report.signal_quality not in eur.min_quality_grades + and report.signal_quality != "F"): + report.warnings.append( + f"EUR QUALITY FILTER: {report.signal_quality} below minimum" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and getattr(report, 'exhaustion_count', 0) > eur.max_exhaustion_tags): + report.warnings.append( + f"EUR EXHAUSTION FILTER: {report.exhaustion_count} tags > max {eur.max_exhaustion_tags}" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and eur.block_near_ema200 + and any(w.startswith("NEAR EMA200") for w in report.warnings)): + report.warnings.append("EUR EMA200 FILTER: too close to EMA200") + report.signal_quality = "F" + if eur.session_hours and report.signal_quality != "F": + try: + bar_hour = pd.Timestamp(report.timestamp).hour + if bar_hour not in eur.session_hours: + report.warnings.append( + f"EUR SESSION FILTER: {bar_hour:02}:00 UTC outside allowed hours" + ) + report.signal_quality = "F" + except Exception: + pass + if report.signal_quality != "F": + abs_s = abs(report.final_score) + if abs_s < eur.min_entry_score: + report.warnings.append( + f"EUR SCORE FILTER: |{abs_s:.1f}| < {eur.min_entry_score}" + ) + report.signal_quality = "F" + elif report.final_confidence < eur.min_entry_confidence: + report.warnings.append( + f"EUR CONF FILTER: {report.final_confidence} < {eur.min_entry_confidence}" + ) + report.signal_quality = "F" + if (report.signal_quality != "F" + and eur.block_rsi_extended + and report.primary_analysis): + rsi_ind = report.primary_analysis.indicators.get("rsi") + if rsi_ind: + rsi_v = rsi_ind.value + is_buy = report.final_score > 0 + if is_buy and rsi_v > 70: + report.warnings.append( + f"EUR RSI FILTER: RSI {rsi_v:.0f} overbought for BUY" + ) + report.signal_quality = "F" + elif not is_buy and rsi_v < 30: + report.warnings.append( + f"EUR RSI FILTER: RSI {rsi_v:.0f} oversold for SELL" + ) + report.signal_quality = "F" + # ── 7. Trade setup for actionable signals ────────────────── if report.trade_setup is None and report.signal_quality in ("A+", "A") and report.display_name == "XAU/USD": atr = (report.primary_analysis.key_levels.get("atr", 0) @@ -864,12 +930,10 @@ class SignalAggregator: if _is_eur: eur = self.config.eur_strategy - q_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0) - min_rr = eur.min_rr_ratio * q_scale + min_rr = eur.min_rr_ratio mature_move = abs(report.final_score) > eur.mature_move_threshold - base_tp = eur.tp_atr_multiplier if mature_move else eur.tp_atr_multiplier * 1.5 - tp_mult = base_tp * q_scale - tp_swing_cap = (2.0 if mature_move else 2.5) * q_scale + tp_mult = eur.tp_atr_multiplier if mature_move else eur.tp_atr_multiplier * 1.5 + tp_swing_cap = 2.0 if mature_move else 2.5 sl_atr_floor = eur.sl_atr_floor else: min_rr = self.config.signals.min_rr_ratio diff --git a/test_all.py b/test_all.py index add0e10..bc6faed 100644 --- a/test_all.py +++ b/test_all.py @@ -1677,11 +1677,14 @@ def _(): assert 6 not in cfg.allowed_weekdays # No Sunday assert cfg.min_atr_threshold == 0.0003 assert cfg.sl_atr_floor == 1.2 - assert cfg.min_rr_ratio == 1.0 + assert cfg.min_rr_ratio == 0.5 assert cfg.mature_move_threshold == 35.0 - assert cfg.tp_quality_scale["A+"] == 1.8 - assert cfg.tp_quality_scale["A"] == 1.2 + assert cfg.tp_quality_scale["A+"] == 1.0 + assert cfg.tp_quality_scale["A"] == 1.0 assert cfg.tp_quality_scale["C"] == 1.0 + assert "ranging" in cfg.blocked_regimes + assert "A" in cfg.min_quality_grades + assert "C" not in cfg.min_quality_grades @test("PlatformConfig has eur_strategy field") @@ -1764,21 +1767,22 @@ def _(): assert "EUR/USD" not in NewsEngine._get_affected_pairs("GBP") -@test("EUR/USD trade setup uses quality-scaled TP and tighter SL") +@test("EUR/USD trade setup uses tighter RR and SL") def _(): from pair_signals.eurusd import make_config from signal_aggregator import SignalAggregator config = make_config(Timeframe.H1) config.backtest.session_filter = False + config.eur_strategy.blocked_regimes = [] + config.eur_strategy.min_quality_grades = [] assert config.eur_strategy.sl_atr_floor == 1.2 - assert config.eur_strategy.min_rr_ratio == 1.0 - assert config.eur_strategy.tp_quality_scale["A"] == 1.2 + assert config.eur_strategy.min_rr_ratio == 0.5 agg = SignalAggregator(config) df = make_ohlcv(n=300, start_price=1.0800, trend=0.0003, seed=77) dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False) if report.trade_setup: - assert report.trade_setup.risk_reward >= 1.0 - 0.01 + assert report.trade_setup.risk_reward >= 0.5 - 0.01 # ══════════════════════════════════════════════════════════════════════