M5 at real cost: disable XAU_PB, squash partial EMA alignment
Both validated at the measured $0.65 spread on Oanda bars (12mo halves + PF, OOS 2024-06->2025-03, 5yr; full evidence in pair_signals/xauusd.py M5 overrides 4-5): - m5_pullback_enabled False on M5: loss forensics identified XAU_PB as the largest wrong-direction loss source; 12mo PF 1.26 (failing the acceptance bar) -> 1.44, max DD 23.9R -> 13.9R. PB code stays for future regime-gated experiments. - New TechnicalConfig.ema_partial_neutral (default False), True on M5 only: partial 4-EMA ribbon alignment (2-3 of 4 checks) scores NEUTRAL instead of a weak BUY/SELL (-0.008R/84 vs +0.230R/341 fully aligned). Squash mirrored in the backtester's precomputed EMA scorer in this same commit (live==backtest parity). 12mo +97.6R/556 PF 1.48 DD 10.7R halves +47.8/+49.8; 5yr +127.4R PF 1.38; OOS one-trade tie. - Rejected and logged (memory + docstring): hour-19 block (OOS fail), hours-14/15, trail dist 0.75, confirm-TF swaps incl M15+H4 (OOS ordering), divergence/candlestick weight changes, bollinger removal. - Tests 296 -> 302: partial-alignment fixture test, factory-equivalence mirrors, per-TF isolation test, BC-gate fixture force-enables PB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1eKWZsAVapJ1C4winYLqX
This commit is contained in:
@@ -11,7 +11,7 @@ Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtest
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```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 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 telegram_bot.py # the bot (refuses to boot without credentials)
|
||||||
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
||||||
|
|||||||
@@ -529,6 +529,11 @@ class GPUTechnicalEngine(TechnicalEngine):
|
|||||||
signal = SignalStrength.NEUTRAL
|
signal = SignalStrength.NEUTRAL
|
||||||
conf = 40
|
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 = ""
|
crossover_desc = ""
|
||||||
# Live: `if len(ema_fast) >= 3:` then iloc[-2] — needs len ≥ 2 functionally,
|
# 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.
|
# but live's guard requires len ≥ 3 so we match that to stay byte-identical.
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ class TechnicalConfig:
|
|||||||
ema_medium: int = 21
|
ema_medium: int = 21
|
||||||
ema_slow: int = 50
|
ema_slow: int = 50
|
||||||
ema_trend: int = 200
|
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_period: int = 14
|
||||||
rsi_overbought: float = 70.0
|
rsi_overbought: float = 70.0
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
|
<tr><td><b>Warnings ⚠️</b></td><td>Context, not veto. Backtesting shows warned trades perform about the same — strong momentum often triggers warnings.</td></tr>
|
||||||
<tr><td><b>Market context 📰</b></td><td>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.</td></tr>
|
<tr><td><b>Market context 📰</b></td><td>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.</td></tr>
|
||||||
<tr><td><b>SL / TP1 / RR</b></td><td>Stop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. <b>1R</b> = the entry-to-SL distance = the amount you risk.</td></tr>
|
<tr><td><b>SL / TP1 / RR</b></td><td>Stop loss sits behind the nearest swing level with an ATR buffer. TP1 is the nearest opposing level. <b>1R</b> = the entry-to-SL distance = the amount you risk.</td></tr>
|
||||||
<tr><td><b>Trail plan 🔄</b></td><td>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. <b>TP1 is advisory — let winners run.</b></td></tr>
|
<tr><td><b>Trail plan 🔄</b></td><td>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. <b>TP1 is advisory — let winners run.</b></td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h2>3 · Acting on a signal — step by step</h2>
|
<h2>3 · Acting on a signal — step by step</h2>
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ class TechnicalEngine:
|
|||||||
signal = SignalStrength.NEUTRAL
|
signal = SignalStrength.NEUTRAL
|
||||||
conf = 40
|
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 = ""
|
crossover_desc = ""
|
||||||
if len(ema_fast) >= 3:
|
if len(ema_fast) >= 3:
|
||||||
prev_f, prev_m = ema_fast.iloc[-2], ema_medium.iloc[-2]
|
prev_f, prev_m = ema_fast.iloc[-2], ema_medium.iloc[-2]
|
||||||
|
|||||||
@@ -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
|
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
|
DD +34% on 5yr); every SELL-gate/vol/TP-size relaxation retested
|
||||||
negative (see memory notes for the full grid).
|
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
|
from typing import Optional, List
|
||||||
@@ -152,6 +200,12 @@ def make_config(
|
|||||||
# BUY-only chart patterns on M5 — see module docstring.
|
# BUY-only chart patterns on M5 — see module docstring.
|
||||||
config.xau_strategy.cp_enabled = True
|
config.xau_strategy.cp_enabled = True
|
||||||
config.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
|
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.
|
# Wider trailing distance on M5 only — see module docstring.
|
||||||
config.signals.trail_arm_r = 0.60
|
config.signals.trail_arm_r = 0.60
|
||||||
config.signals.trail_dist_r = 0.85
|
config.signals.trail_dist_r = 0.85
|
||||||
|
|||||||
+51
@@ -443,6 +443,36 @@ def _():
|
|||||||
assert "ema_slow" in result.key_levels
|
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<EMA200 -> 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")
|
@test("TechnicalEngine EMA-2 mode computes EMA50/200")
|
||||||
def _():
|
def _():
|
||||||
config = TechnicalConfig(ema_mode="2ema")
|
config = TechnicalConfig(ema_mode="2ema")
|
||||||
@@ -2077,12 +2107,30 @@ def _():
|
|||||||
] + [9]
|
] + [9]
|
||||||
inline_cfg.xau_strategy.cp_enabled = True
|
inline_cfg.xau_strategy.cp_enabled = True
|
||||||
inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
|
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_arm_r = 0.60
|
||||||
inline_cfg.signals.trail_dist_r = 0.85
|
inline_cfg.signals.trail_dist_r = 0.85
|
||||||
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
|
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
|
||||||
f"XAU factory config differs from inline construction for {tf}"
|
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")
|
@test("produce_signal raises for unregistered pair")
|
||||||
def _():
|
def _():
|
||||||
from pair_signals import produce_signal
|
from pair_signals import produce_signal
|
||||||
@@ -4634,6 +4682,9 @@ def _():
|
|||||||
def run(k):
|
def run(k):
|
||||||
agg = _bc_agg(k=k)
|
agg = _bc_agg(k=k)
|
||||||
agg.config.xau_strategy.cp_enabled = True
|
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 = []
|
calls = []
|
||||||
agg._try_m5_pullback = lambda *a, **kw: calls.append("pb")
|
agg._try_m5_pullback = lambda *a, **kw: calls.append("pb")
|
||||||
agg._try_chart_pattern = lambda *a, **kw: calls.append("cp")
|
agg._try_chart_pattern = lambda *a, **kw: calls.append("cp")
|
||||||
|
|||||||
Reference in New Issue
Block a user