From 665ffdda4809df8af3ecbbe598c9bebe2cbe7325 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 16 Jul 2026 12:17:56 -0400 Subject: [PATCH] Fix on Sell order lossing by blocking time --- CLAUDE.md | 3 ++ config.py | 4 +++ logs/telegram.log.2026-07-14 | 21 ++++++++++++++ pair_signals/xauusd.py | 22 ++++++++++++++ signal_aggregator.py | 22 ++++++++++++++ test_all.py | 56 ++++++++++++++++++++++++++++++++++-- 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 logs/telegram.log.2026-07-14 diff --git a/CLAUDE.md b/CLAUDE.md index a85ac10..bb494a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,7 @@ Tracked currencies `["USD","XAU","EUR"]`; pre-event buffer 30 min, post-event 15 | `sell_min_quality` | ["A+","A","B"] | SELLs below B are blocked | | `sell_vol_regimes` | ["high","extreme"] | SELLs only in these volatility regimes (quiet shorts fade vs gold's macro uptrend) | | `blocked_regimes` / `sell_blocked_regimes` | [] / [] | per-TF factory opt-ins. M15 factory sets `blocked_regimes=["trending"]` (mixed regime: strong ADX, EMAs unaligned — lost −6.8R/24) | +| `sell_blocked_hours_utc` | [] | bar-CLOSE UTC hours where SELLs are blocked (BUYs unaffected). Per-TF factory opt-in: M15 sets [15, 17] (NY-session shorts, −33.5R/165 over 5 yr, negative 6/6 years both halves; BUYs there +34.3R) | | M5 gates | | `m5_require_mtf=True`; `m5_min_abs_score=30`; `m5_min_confirmation_score=10`; `m5_sell_max_quality=["B","C"]` (A-grade M5 SELLs enter at dip extremes and lose); `m5_blocked_vol_regimes=["high"]` (chop transition — 61% WR, negative total); `m5_sell_min_rsi=25` | | M5 pullback (XAU_PB) | | `m5_pullback_enabled=True`; min score 15 (lower than gate 1 BY DESIGN — mid-pullback scores weaker); ADX ≥ 20; RSI zones BUY (35,62) / SELL (38,65); retrace depth 0.8–6.0 ATR from the 40-bar swing extreme; band touch within 3 bars. Validated +38.3R/254 trades on 12mo | @@ -197,6 +198,7 @@ Two entry points: - **6f MTF opposition**: ≥2 TFs pointing against the trade direction → D (never trade against consensus). - **6g XAU SELL volatility gate**: sell-direction & vol regime not in {high, extreme} → D. - **6g2 XAU regime gates**: regime in `blocked_regimes` (M15: mixed `trending`) → D; sell-direction & regime in `sell_blocked_regimes` → D. + - **6g3 XAU SELL hour gate**: sell-direction & bar-CLOSE hour (label + candle duration — the entry hour under next-open fills) in `sell_blocked_hours_utc` → D. Per-TF opt-in; M15 factory blocks 15/17 UTC. - **6h EUR gates** (EUR only): blocked regimes (ranging/transitioning), quality < B, exhaustion count, session hours, |score| < min_entry_score, conf < min_entry_confidence, blocked vol regimes, indicator-agreement floor, RSI-extended block → each sets grade F with a warning. - **6i XAU M5 noise gates** (`_apply_m5_filters`, primary TF = M5 only): (1) |score| ≥ 30; (2) MTF agreement required; (3) ≥1 confirmation TF with score ≥ ±10 in trade direction (NEUTRAL confirms don't count); (4) SELL grade must be B/C (A-grade M5 shorts lose −0.017R/trade); (5) vol regime `high` blocked; (6) SELL blocked when RSI < 25. Each failure → D, setup killed. 8. **Trade setup**: XAU + grade A+/A → `_generate_xau_atr_trade_setup` first; any pair + grade A+/A/B/C and no setup yet → `_generate_trade_setup` (swing-based). @@ -252,6 +254,7 @@ Default confirmation map (same in both factories, the bot, and backtest usage): **xauusd.py**: `PlatformConfig()` defaults + per-TF overrides (each validated; full evidence in the module docstring): - M5: append hour 9 UTC to blocked hours (6-yr replay: +207.1R vs +178.3R; M15-and-up keep hour 9 — it's positive there). - M15: `signals.tp_floor_rr = 1.0` (1R minimum targets: +116.6R vs +105.4R, better in train/val and 2024 OOS; **hurts M5** — per-TF only) and `xau_strategy.blocked_regimes = ["trending"]` (mixed regime block: +65.7R vs +59.0R). A transitioning-SELL block was tested and REJECTED (bull-market artifact — failed 2024 OOS). +- M15: `xau_strategy.sell_blocked_hours_utc = [15, 17]` (SELL-only hour block, validated 2026-07-16 on the 5-yr replay: +176.7R PF 1.31 vs +144.9R PF 1.22, better in all 6 years, both 12-mo halves, and 2024-06→2025-03 OOS; M5 cross-TF pocket confirms; BUYs at those hours are +34.3R so `blocked_hours_utc` would be wrong). - The factory must stay equivalent to inline `PlatformConfig(symbol=XAUUSD, …)` + overrides — **enforced by a test in test_all.py**. **eurusd.py**: sets symbol/display/pip, `xau_strategy.enabled = False` (belt-and-braces; XAU gates are also display-gated), `news.event_risk_caps_signals = True`, EUR session (7–20 h window, blocked [22,23,0-3], Mon–Fri), EUR regime-weight overrides (`EUR_REGIME_WEIGHTS`, and `M5_REGIME_WEIGHTS` + tighter trailing 0.45/0.5 + Mon–Thu + extra blocked hours [8,12,13,18,19] + swing lookback 20 on M5). Docstring = the complete no-edge verdict and closed research avenues. diff --git a/config.py b/config.py index 4ba24ec..9ae64c6 100755 --- a/config.py +++ b/config.py @@ -228,6 +228,10 @@ class XauStrategyConfig: # Market regimes in which XAU SELL setups may not fire. Empty by # default; per-timeframe factories opt in. sell_blocked_regimes: List[str] = field(default_factory=list) + # Bar-CLOSE (decision-time) UTC hours in which XAU SELL setups may + # not fire. Empty by default; per-timeframe factories opt in. + # BUYs are unaffected (unlike blocked_hours_utc, which is both-way). + sell_blocked_hours_utc: List[int] = field(default_factory=list) # ── M5-specific filters ────────────────────────────────────── # M5 signals are noisy; require stronger confirmation before diff --git a/logs/telegram.log.2026-07-14 b/logs/telegram.log.2026-07-14 new file mode 100644 index 0000000..fe40ece --- /dev/null +++ b/logs/telegram.log.2026-07-14 @@ -0,0 +1,21 @@ +01:51:29 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 05:51 is 4320m old (limit: 180m) +01:52:02 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 05:52 is 4320m old (limit: 180m) +02:42:59 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 06:42 is 4320m old (limit: 180m) +03:30:24 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 07:30 is 4320m old (limit: 180m) +03:31:32 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 07:31 is 4320m old (limit: 180m) +11:30:20 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 15:30 is 4320m old (limit: 180m) +11:41:34 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 15:41 is 4320m old (limit: 180m) +11:45:46 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 15:45 is 4320m old (limit: 180m) +11:56:38 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 15:56 is 4320m old (limit: 180m) +11:57:11 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 15:57 is 4320m old (limit: 180m) +11:59:52 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 15:59 is 4320m old (limit: 180m) +12:10:05 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 16:10 is 4320m old (limit: 180m) +13:05:37 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 17:05 is 4320m old (limit: 180m) +13:14:25 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 17:14 is 4320m old (limit: 180m) +13:38:50 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 17:38 is 4320m old (limit: 180m) +13:45:14 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 17:45 is 4320m old (limit: 180m) +13:51:33 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 17:51 is 4320m old (limit: 180m) +15:04:41 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 19:04 is 4320m old (limit: 180m) +15:04:50 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 19:04 is 4320m old (limit: 180m) +15:05:17 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 19:05 is 4320m old (limit: 180m) +15:06:10 [WARNING] data_provider: BTC/USD 1h: last bar 2026-07-11 19:06 is 4320m old (limit: 180m) diff --git a/pair_signals/xauusd.py b/pair_signals/xauusd.py index 0309e12..7ea1c7a 100644 --- a/pair_signals/xauusd.py +++ b/pair_signals/xauusd.py @@ -27,6 +27,26 @@ exclude it), so the block is M15-only. A second candidate — blocking transitioning-regime SELLs (-4.0R/47 on 12mo) — was REJECTED: those sells made +5.7R/32 in the 2024 OOS window (bull-market artifact). +M15 override 3 (validated 2026-07-16, 5-year replay 2021-07-17 -> +2026-07-16 under the audit-fixed backtester, $0.30 spread, trailing +exits): block SELL entries whose bar CLOSES at 15:00 or 17:00 UTC +(sell_blocked_hours_utc=[15, 17], gate 6g3 — BUYs unaffected; those +hours flank the already-blocked hour 16). The pocket lost -33.5R/165 +trades, negative in 6/6 calendar years and in BOTH halves of the last +12 months (H1 -6.8R, H2 -4.2R); each hour independently is 5/6 years +negative with both halves negative (hours 14/18 fail that screen and +stay open). Cross-TF the M5 book CONFIRMS (same pocket -14.1R/59, +5/6 years negative) rather than contradicts — unlike the rejected +trending_down block. Full replay with the gate: +176.7R/1627 PF 1.31 +DD 29.1R vs +144.9R/1781 PF 1.22 DD 34.4R control; better in ALL SIX +years; last-12mo halves both improve (H1 +36.3 vs +29.5, H2 +46.9 vs ++43.2; 12mo PF 1.55 vs 1.43); OOS 2024-06-01 -> 2025-03-30 improves +(+45.4R PF 1.47 DD 17.8 vs +41.9R PF 1.39 DD 21.7). SELL side goes +-1.2R -> +30.6R over 5 years. BUYs at those hours made +34.3R, hence +SELL-only rather than a blocked_hours_utc entry. Mechanism: NY-session +shorts against gold's macro uptrend (15:00 = post-US-data reversal +window, 17:00 = London close unwind). + M5 override (validated 2026-07-11, full-history replay 2020-03-17 -> 2026-07-11 + 12mo book, $0.30 spread): block hour 9 UTC (post-London- open whipsaw). Hour-9 entries lost -28.9R/143 over 6 years, negative @@ -68,4 +88,6 @@ def make_config( config.signals.tp_floor_rr = 1.0 # Block the mixed "trending" regime on M15 only — see docstring. config.xau_strategy.blocked_regimes = ["trending"] + # Block SELL entries at 15:00/17:00 UTC on M15 only — see docstring. + config.xau_strategy.sell_blocked_hours_utc = [15, 17] return config diff --git a/signal_aggregator.py b/signal_aggregator.py index 7a234c7..2038b05 100755 --- a/signal_aggregator.py +++ b/signal_aggregator.py @@ -804,6 +804,28 @@ class SignalAggregator: ) report.signal_quality = "D" report.trade_setup = None + elif xau_is_sell_dir and self.config.xau_strategy.sell_blocked_hours_utc: + # ── 6g3. XAU SELL hour gate (opt-in via per-TF factories) ── + # Evaluated on the bar's CLOSE time (= entry time under + # next-open fills), the same clock the session filter and + # backtest replay use — NOT the bar label hour. + try: + close_h = ( + pd.Timestamp(report.timestamp) + + pd.Timedelta( + seconds=ForexDataProvider.CANDLE_SECONDS[ + self.config.primary_timeframe + ] + ) + ).hour + except Exception: + close_h = None + if close_h in self.config.xau_strategy.sell_blocked_hours_utc: + report.warnings.append( + f"SELL FILTER: {close_h:02}:00 UTC entry blocked for shorts" + ) + report.signal_quality = "D" + report.trade_setup = None # ── 6h. EUR/USD regime + quality + risk gate ────────────── if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled: diff --git a/test_all.py b/test_all.py index 0db3c2d..fc2be4e 100644 --- a/test_all.py +++ b/test_all.py @@ -198,6 +198,8 @@ def _(): assert xc.tp_atr_multiplier == 1.0 assert xc.sl_atr_multiplier == 2.0 assert isinstance(xc.blocked_hours_utc, list) + assert xc.sell_blocked_hours_utc == [], \ + "sell hour gate must be off by default (per-TF factories opt in)" @test("FilterConfig defaults") @@ -1846,10 +1848,11 @@ def _(): ) if tf == Timeframe.M15: # Documented per-TF overrides (see xauusd.py docstring): - # 1R minimum targets + mixed-"trending"-regime block, - # both validated for M15 only. + # 1R minimum targets + mixed-"trending"-regime block + + # SELL hour block (15/17 UTC), all validated for M15 only. inline_cfg.signals.tp_floor_rr = 1.0 inline_cfg.xau_strategy.blocked_regimes = ["trending"] + inline_cfg.xau_strategy.sell_blocked_hours_utc = [15, 17] if tf == Timeframe.M5: # Documented per-TF override (see xauusd.py docstring): # hour 9 UTC blocked on M5 only. @@ -2063,6 +2066,55 @@ def _(): assert make_config(Timeframe.M5).xau_strategy.blocked_regimes == [] +@test("XAU SELL hour gate: blocks 15/17 UTC bar-close entries, spares others") +def _(): + from pair_signals.xauusd import make_config + + def xau_15min_df(end): + np.random.seed(9) + dates = pd.date_range(end=pd.Timestamp(end, tz="UTC"), + periods=300, freq="15min") + close = 3300.0 + np.cumsum(np.random.randn(300) * 2.0 - 1.0) + return pd.DataFrame({ + "Open": close + np.random.randn(300) * 0.5, + "High": close + np.abs(np.random.randn(300)), + "Low": close - np.abs(np.random.randn(300)), + "Close": close, + "Volume": np.random.randint(100, 10000, 300).astype(float), + }, index=pd.DatetimeIndex(dates, name="Datetime")) + + def run(end, hours): + cfg = make_config(Timeframe.M15) + cfg.backtest.session_filter = False + # Neutralize the other SELL gates so 6g3 is the one under test. + cfg.xau_strategy.sell_min_quality = ["A+", "A", "B", "C"] + cfg.xau_strategy.sell_vol_regimes = ["low", "normal", "high", "extreme"] + cfg.xau_strategy.blocked_regimes = [] + cfg.xau_strategy.sell_blocked_hours_utc = hours + df = xau_15min_df(end) + dfs = {Timeframe.M15: df, Timeframe.M5: df, Timeframe.H1: df} + return SignalAggregator(cfg).analyze_from_dataframes( + symbol=XAUUSD, dfs=dfs, use_news=False) + + # Same synthetic downtrend; only the index end (and thus the bar-close + # hour) differs. Label 14:45 closes 15:00 (blocked); 19:45 closes + # 20:00 (allowed). The gate reads bar-CLOSE time, not the label hour. + control = run("2026-07-13 14:45", []) + assert control.signal_quality in ("A+", "A", "B", "C") and control.final_score < 0, \ + f"probe data must yield an actionable sell, got {control.signal_quality}" + blocked = run("2026-07-13 14:45", [15, 17]) + assert blocked.signal_quality == "D" and blocked.trade_setup is None, \ + f"15:00 UTC close must be gated to D, got {blocked.signal_quality}" + assert any("blocked for shorts" in w for w in blocked.warnings), \ + f"missing SELL hour warning: {blocked.warnings}" + open_hour = run("2026-07-13 19:45", [15, 17]) + assert open_hour.signal_quality == control.signal_quality, \ + f"20:00 UTC close must pass the gate, got {open_hour.signal_quality}" + # Factory scoping: M15 opts in, M5 does not (its book must not move). + assert make_config(Timeframe.M15).xau_strategy.sell_blocked_hours_utc == [15, 17] + assert make_config(Timeframe.M5).xau_strategy.sell_blocked_hours_utc == [] + + @test("NewsEngine._get_affected_pairs includes EUR/USD for EUR and USD events") def _(): from news_engine import NewsEngine