diff --git a/CLAUDE.md b/CLAUDE.md index decfc88..479a6c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest ## Commands ```bash -python test_all.py # QA suite: 296 tests, 20 modules, no network (mocked) +python test_all.py # QA suite: 302 tests, 20 modules, no network (mocked) python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump) python telegram_bot.py # the bot (refuses to boot without credentials) python oanda_daemon.py # auto-execution daemon on Oanda practice account diff --git a/backtest/gpu_backtest.py b/backtest/gpu_backtest.py index cc8a6ed..284d48c 100644 --- a/backtest/gpu_backtest.py +++ b/backtest/gpu_backtest.py @@ -529,6 +529,11 @@ class GPUTechnicalEngine(TechnicalEngine): signal = SignalStrength.NEUTRAL conf = 40 + # Mirrors the live engine's ema_partial_neutral squash (parity). + if c.ema_partial_neutral and signal in (SignalStrength.BUY, SignalStrength.SELL): + signal = SignalStrength.NEUTRAL + conf = 40 + crossover_desc = "" # Live: `if len(ema_fast) >= 3:` then iloc[-2] — needs len ≥ 2 functionally, # but live's guard requires len ≥ 3 so we match that to stay byte-identical. diff --git a/config.py b/config.py index 35b2c77..ff49358 100755 --- a/config.py +++ b/config.py @@ -46,6 +46,12 @@ class TechnicalConfig: ema_medium: int = 21 ema_slow: int = 50 ema_trend: int = 200 + # 4-EMA mode only: score partial ribbon alignment (2-3 of 4 checks) + # as NEUTRAL instead of a weak BUY/SELL. Research knob (2026-08-04 + # indicator audit: partial-alignment trades averaged -0.008R vs + # +0.230R fully aligned on the M5 12mo Oanda book); default False = + # legacy behavior until a replay campaign validates flipping it. + ema_partial_neutral: bool = False rsi_period: int = 14 rsi_overbought: float = 70.0 diff --git a/docs/user_guide.html b/docs/user_guide.html index a4a321a..8cb4e74 100644 --- a/docs/user_guide.html +++ b/docs/user_guide.html @@ -71,7 +71,7 @@ Warnings ⚠️Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings. Market context 📰An AI-written one-liner summarizing today's macro backdrop (headlines + high-impact releases) and its likely pull on gold. Informational only — it never blocks or grades a signal. SL / TP1 / RRStop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. 1R = the entry-to-SL distance = the amount you risk. -Trail plan 🔄The real exit. Once price moves +0.6R in your favor (+0.8R for pullback setups), move the stop to trail 0.75R behind the best price and update it at each candle close. TP1 is advisory — let winners run. +Trail plan 🔄The real exit. Once price moves +0.6R in your favor, move the stop to trail 0.75R behind the best price and update it at each candle close. TP1 is advisory — let winners run.

3 · Acting on a signal — step by step

diff --git a/engine.py b/engine.py index 5372685..a78995d 100755 --- a/engine.py +++ b/engine.py @@ -245,6 +245,12 @@ class TechnicalEngine: signal = SignalStrength.NEUTRAL conf = 40 + # Partial alignment (plain BUY/SELL, 2-3 of 4 checks) carries no + # edge on M5 — squash to NEUTRAL when the research knob is on. + if c.ema_partial_neutral and signal in (SignalStrength.BUY, SignalStrength.SELL): + signal = SignalStrength.NEUTRAL + conf = 40 + crossover_desc = "" if len(ema_fast) >= 3: prev_f, prev_m = ema_fast.iloc[-2], ema_medium.iloc[-2] diff --git a/pair_signals/xauusd.py b/pair_signals/xauusd.py index 2a3902b..1eaaea7 100644 --- a/pair_signals/xauusd.py +++ b/pair_signals/xauusd.py @@ -117,6 +117,54 @@ campaign: an M5 hour-22 block (+4.8R on 12mo — the burst-heavy specific); M15 hour-16 unblock (+0.8R/+57 trades 12mo but -18.3R DD +34% on 5yr); every SELL-gate/vol/TP-size relaxation retested negative (see memory notes for the full grid). + +M5 override 4 — XAU_PB disabled (validated 2026-08-04, Oanda bars, +$0.65 REAL spread — first campaign validated at measured cost on the +live feed, per the 2026-08-04 re-pin baseline): m5_pullback_enabled +False on M5. Loss forensics on the Oanda books identified XAU_PB as +the largest wrong-direction loss source (12mo: -43.5R of the -156.6R +wrong-direction bucket; 5yr: -274.5R). Disabling: 12mo +96.6R/588 +PF 1.44 max DD 13.9R halves +51.2/+45.4 vs +82.3R/788 PF 1.26 DD +23.9R shipped (which FAILS the PF>=1.3 clause at real cost); OOS +2024-06-01 -> 2025-03-30 +9.5R PF 1.34; 5yr +123.9R/956 PF 1.35 vs +PF 1.16 with PB. PB's 5yr net contribution (+23.6R) was almost +entirely the 2022 chop year (+36.1R vs -2.2R without) — a regime +earner that has been a drag on trend-regime bars since; its tight +stops are also the most cost-punished at $0.65. PB code and the +pb_trail_* params STAY for future regime-gated experiments (same +pattern as cp_enabled). Rejected in the same campaign (4 candidates +total, N logged): hour-19 block (+3.7R 12mo but -3.3R OOS, -1.5R +5yr — OOS gate, the classic trap); hours-14/15 block (-6.9R 12mo +once PB is removed — their raw negativity WAS the PB losses); +trail_dist_r 0.75 (-2.6R 12mo; the 0.88R average give-back is the +validated price of riding trends, exits confirmed NOT the loss +driver: exit-management accounts for 1-2% of lost R vs 50% +wrong-direction / 49% entry-timing). + +M5 override 5 — EMA partial-alignment squash (validated 2026-08-04, +Oanda bars, $0.65 real spread): ema_partial_neutral True on M5 — +partial 4-EMA ribbon alignment (2-3 of 4 checks) scores NEUTRAL +instead of a weak BUY/SELL. Motivated by the indicator audit: partial- +alignment trades averaged -0.008R/84 vs +0.230R/341 fully aligned. +Replay (knob mirrored in the backtester's precomputed EMA scorer in +the same commit — live==backtest parity): 12mo +97.6R/556 PF 1.48 +DD 10.7R halves +47.8/+49.8 vs control +96.6R/588 PF 1.44 DD 13.9R; +5yr +127.4R/912 PF 1.38 DD 12.8R vs +123.9R/956 PF 1.35 DD 13.2R, +2022 improves -2.2 -> -1.1; OOS 2024-06-01 -> 2025-03-30 +9.4R +PF 1.33 vs +9.5R PF 1.34 — a one-trade tie, documented as such (the +OOS window holds up; it neither prefers nor punishes). Net effect: +32 fewer marginal trades, every risk-adjusted measure better. +Rejected in the same round (trial-logged): confirmation-TF swap +M5->[M15,H4] (12mo +104.0R PF 1.51 DD 10.3 but OOS +7.8R PF 1.27 +vs control +9.5R PF 1.34 and 2022 3x worse — the H4 confirm keeps +endorsing stale direction in chop; OOS-ordering rejection, same +precedent as trail dist 1.0); confirm [H1,H4] (-31.8R 12mo); +triple-confirm [M15,H1,H4] (-4.9R); divergence weight -1/3 +(-3.8R 12mo); candlestick weight +50% (-3.0R 12mo). The weight +vector keeps proving calibrated as a system: divergence/candlestick/ +bollinger all look mis-weighted in-book and all replay worse when +"fixed" (bollinger weight-0: +80.9R PF 1.29 DD 23.8 — its constant +oppose vote is a load-bearing quality bar, like ROC). """ from typing import Optional, List @@ -152,6 +200,12 @@ def make_config( # BUY-only chart patterns on M5 — see module docstring. config.xau_strategy.cp_enabled = True config.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"] + # XAU_PB pullback entries OFF — largest wrong-direction loss + # source at real cost on Oanda bars — see module docstring. + config.xau_strategy.m5_pullback_enabled = False + # Partial EMA-ribbon alignment scores NEUTRAL on M5 — its + # trades carried no edge — see module docstring. + config.technical.ema_partial_neutral = True # Wider trailing distance on M5 only — see module docstring. config.signals.trail_arm_r = 0.60 config.signals.trail_dist_r = 0.85 diff --git a/test_all.py b/test_all.py index 580eee0..cd5c963 100644 --- a/test_all.py +++ b/test_all.py @@ -443,6 +443,36 @@ def _(): assert "ema_slow" in result.key_levels +@test("EMA-4 partial alignment squashes to NEUTRAL with ema_partial_neutral") +def _(): + import numpy as np + # Long decline then a medium rally: price>EMA9>EMA21>EMA50 but + # EMA50 alignment 2/4 = partial BUY under legacy scoring. + closes = np.concatenate([np.linspace(3000, 2400, 400), + np.linspace(2400, 2560, 55)]) + idx = pd.date_range("2026-01-01", periods=len(closes), freq="5min", tz="UTC") + df = pd.DataFrame({"Open": closes, "High": closes + 1.0, + "Low": closes - 1.0, "Close": closes, + "Volume": 1000.0}, index=idx) + assert TechnicalConfig().ema_partial_neutral is False # legacy default + base = TechnicalEngine(TechnicalConfig()).analyze( + df, "XAU/USD", "5min").indicators["ema_trend"] + assert base.signal == SignalStrength.BUY and 0.2 < base.value <= 0.6, \ + f"fixture must yield a partial-alignment BUY, got {base.signal} {base.value}" + ind = TechnicalEngine(TechnicalConfig(ema_partial_neutral=True)).analyze( + df, "XAU/USD", "5min").indicators["ema_trend"] + assert ind.signal == SignalStrength.NEUTRAL and ind.confidence == 40 + # Full alignment is untouched by the knob. + up = np.linspace(2000, 3000, 380) + df2 = pd.DataFrame({"Open": up, "High": up + 1.0, "Low": up - 1.0, + "Close": up, "Volume": 1000.0}, + index=pd.date_range("2026-01-01", periods=380, + freq="5min", tz="UTC")) + strong = TechnicalEngine(TechnicalConfig(ema_partial_neutral=True)).analyze( + df2, "XAU/USD", "5min").indicators["ema_trend"] + assert strong.signal == SignalStrength.STRONG_BUY + + @test("TechnicalEngine EMA-2 mode computes EMA50/200") def _(): config = TechnicalConfig(ema_mode="2ema") @@ -2077,12 +2107,30 @@ def _(): ] + [9] inline_cfg.xau_strategy.cp_enabled = True inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"] + inline_cfg.xau_strategy.m5_pullback_enabled = False + inline_cfg.technical.ema_partial_neutral = True inline_cfg.signals.trail_arm_r = 0.60 inline_cfg.signals.trail_dist_r = 0.85 assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \ f"XAU factory config differs from inline construction for {tf}" +@test("M5 factory disables XAU_PB; other TFs keep the global default") +def _(): + # 2026-08-04 campaign (Oanda bars, $0.65 real spread): pullback + # entries are the largest wrong-direction loss source on M5 and + # fail at real cost; disabled in the M5 factory branch only. + from pair_signals import PAIR_PRODUCERS + m5 = PAIR_PRODUCERS[XAUUSD](Timeframe.M5, [Timeframe.M15, Timeframe.H1]) + assert m5.xau_strategy.m5_pullback_enabled is False + assert m5.technical.ema_partial_neutral is True # 2026-08-04 override 5 + m15 = PAIR_PRODUCERS[XAUUSD](Timeframe.M15, [Timeframe.M5, Timeframe.H1]) + assert m15.xau_strategy.m5_pullback_enabled is True, \ + "PB flag must stay at the global default off-M5 (inert there)" + assert m15.technical.ema_partial_neutral is False, \ + "partial-alignment squash is M5-validated only" + + @test("produce_signal raises for unregistered pair") def _(): from pair_signals import produce_signal @@ -4634,6 +4682,9 @@ def _(): def run(k): agg = _bc_agg(k=k) agg.config.xau_strategy.cp_enabled = True + # Force-enable PB: this test exercises gate mechanics, not the + # tuned M5 default (PB off since 2026-08-04, xauusd.py docstring). + agg.config.xau_strategy.m5_pullback_enabled = True calls = [] agg._try_m5_pullback = lambda *a, **kw: calls.append("pb") agg._try_chart_pattern = lambda *a, **kw: calls.append("cp")