Big-candle gate (default-OFF) + M5 relaxation campaign + --set tooling
Gate 6h (big-candle entry gate): blocks all XAU entries (core, XAU_PB, XAU_CP) when the primary TF's last 24h contains a bar with range >= K x prior-bar ATR. Ships default-OFF: validation rejected every threshold (K 3-6 on M5/M15 12mo) — removed trades were net-POSITIVE at every cell; M5 quiet-window trades are the only losing bucket. Config knobs big_candle_atr_multiple/lookback_hours, --big-candle backtest flag, Module 17 tests. M5 relaxation SHIPPED (validated 2026-07-30, Oanda bars, $0.30 spread): m5_min_abs_score 30->25, m5_min_confirmation_score 10->5, hour-21 UTC unblocked M5-only. Beats control in ALL 6 calendar years of the 5yr replay (+230.9R/2319 PF 1.26 DD 21.3 vs +168.0R/2048 PF 1.21 DD 27.0), 12mo +107.4R PF 1.36 vs +84.6R PF 1.33, OOS +26.1R vs +17.2R, $0.70 spread +86.9R vs +66.4R. M15: every relaxation retested negative — config unchanged. Declined-but-open: M5 hour-22 block, M15 hour-16 unblock (both fail 5yr/OOS). Evidence in the factory docstring. Tooling: generic --set PATH=JSON research override in gpu_backtest (typo-proof, logged); diagnose_filters filter_tags refreshed for gates 6d-6i with a staleness-guard test (Module 18); research scripts hour_analysis/burst_profile/book_diff. Suite: 268 tests. Claude-Session: https://claude.ai/code/session_014jqHpmNYmwYMowF6bPZyeD
This commit is contained in:
+248
-4
@@ -2000,10 +2000,11 @@ def _():
|
||||
inline_cfg.signals.trail_dist_r = 0.75
|
||||
if tf == Timeframe.M5:
|
||||
# Documented per-TF overrides (see xauusd.py docstring):
|
||||
# hour 9 UTC blocked + BUY-only chart patterns on M5 only.
|
||||
inline_cfg.xau_strategy.blocked_hours_utc = (
|
||||
inline_cfg.xau_strategy.blocked_hours_utc + [9]
|
||||
)
|
||||
# hour 9 UTC blocked, hour 21 UNBLOCKED (2026-07-30
|
||||
# relaxation campaign) + BUY-only chart patterns on M5 only.
|
||||
inline_cfg.xau_strategy.blocked_hours_utc = [
|
||||
h for h in inline_cfg.xau_strategy.blocked_hours_utc if h != 21
|
||||
] + [9]
|
||||
inline_cfg.xau_strategy.cp_enabled = True
|
||||
inline_cfg.xau_strategy.cp_patterns = ["inv_hs", "double_bottom", "bull_flag"]
|
||||
inline_cfg.signals.trail_arm_r = 0.60
|
||||
@@ -4309,6 +4310,249 @@ def _():
|
||||
assert td != oa, "providers must not share cache files"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 17: Big-candle entry gate (gate 6h)
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print("\n═══ Module 17: big-candle entry gate ═══")
|
||||
|
||||
|
||||
def _inject_big_bar(df, pos=-250, mult=8.0):
|
||||
"""Copy df and widen one bar to ~mult x the median range (a burst
|
||||
candle) at position `pos` — far enough back not to disturb the
|
||||
near-window indicators (RSI/BB/ATR 14-20 bars, swing S/R 120)."""
|
||||
out = df.copy()
|
||||
atr_est = (out["High"] - out["Low"]).median()
|
||||
ts = out.index[pos]
|
||||
out.loc[ts, "High"] = out.loc[ts, "Open"] + mult * atr_est
|
||||
out.loc[ts, "Low"] = out.loc[ts, "Open"] - 0.5 * atr_est
|
||||
out.loc[ts, "Close"] = out.loc[ts, "Open"] + mult * 0.9 * atr_est
|
||||
return out
|
||||
|
||||
|
||||
def _bc_agg(k=3.0, tf=Timeframe.M5, confirms=(Timeframe.M15, Timeframe.H1)):
|
||||
from pair_signals.xauusd import make_config as xau_make_config
|
||||
cfg = xau_make_config(tf, list(confirms))
|
||||
cfg.xau_strategy.big_candle_atr_multiple = k
|
||||
return SignalAggregator(cfg)
|
||||
|
||||
|
||||
# Deterministic ACTIONABLE fixture: grade A BUY with a GENERIC setup
|
||||
# through the factory M5 config at an unblocked hour (Wed 14:55 UTC),
|
||||
# huge bar pre-injected 250 bars back (verified by seed search).
|
||||
def _bc_actionable_dfs():
|
||||
end = "2026-07-08 14:55"
|
||||
primary = _inject_big_bar(make_xau_bars(end=end, drift=0.8, seed=0))
|
||||
m15 = make_xau_bars(end="2026-07-08 14:45", freq="15min", drift=0.8, seed=100)
|
||||
h1 = make_xau_bars(end="2026-07-08 14:00", freq="60min", drift=0.8, seed=200)
|
||||
return {Timeframe.M5: primary, Timeframe.M15: m15, Timeframe.H1: h1}
|
||||
|
||||
|
||||
@test("BC detect: quiet fixture → None; injected 8x bar → (its ts, ratio ≥ 3)")
|
||||
def _():
|
||||
agg = _bc_agg(k=3.0)
|
||||
quiet = make_xau_bars(end="2026-07-08 14:55")
|
||||
assert agg._detect_big_candle(quiet) is None, \
|
||||
"quiet bars must not trip the detector"
|
||||
spiked = _inject_big_bar(quiet, pos=-250)
|
||||
hit = agg._detect_big_candle(spiked)
|
||||
assert hit is not None, "injected burst bar not detected"
|
||||
ts, ratio = hit
|
||||
assert ts == spiked.index[-250], f"wrong offender bar: {ts}"
|
||||
assert ratio >= 3.0, f"ratio {ratio} below threshold"
|
||||
|
||||
|
||||
@test("BC detect: burst bar as LAST row still trips (prior-ATR shift)")
|
||||
def _():
|
||||
# The huge bar inflates the concurrent Wilder ATR; the shift(1)
|
||||
# denominator keeps the very newest burst detectable.
|
||||
agg = _bc_agg(k=3.0)
|
||||
spiked = _inject_big_bar(make_xau_bars(end="2026-07-08 14:55"), pos=-1)
|
||||
hit = agg._detect_big_candle(spiked)
|
||||
assert hit is not None and hit[0] == spiked.index[-1], f"last-row burst missed: {hit}"
|
||||
|
||||
|
||||
@test("BC detect: bar older than the lookback window ignored; short df safe")
|
||||
def _():
|
||||
agg = _bc_agg(k=3.0)
|
||||
# M5 window = 288 bars (+42 ewm warm-up tail). A burst at -350 is
|
||||
# outside even the warm-up slice of a 400-bar df.
|
||||
old_spike = _inject_big_bar(make_xau_bars(n=400, end="2026-07-08 14:55"), pos=-350)
|
||||
assert agg._detect_big_candle(old_spike) is None, \
|
||||
"bar outside the lookback window must not block"
|
||||
# Short history: no raise, graceful no-trip on quiet bars.
|
||||
assert agg._detect_big_candle(make_xau_bars(n=60, end="2026-07-08 14:55")) is None
|
||||
|
||||
|
||||
@test("BC gate 6h: actionable A-grade → warning + D + setup killed")
|
||||
def _():
|
||||
dfs = _bc_actionable_dfs()
|
||||
control = _bc_agg(k=0.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
|
||||
assert control.signal_quality == "A" and control.trade_setup is not None, \
|
||||
f"fixture drifted: expected actionable A, got {control.signal_quality}"
|
||||
assert not any("BIG CANDLE" in w for w in control.warnings), \
|
||||
"disabled gate must stay silent"
|
||||
gated = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
|
||||
assert any(w.startswith("BIG CANDLE FILTER") for w in gated.warnings), \
|
||||
f"gate did not fire: {gated.warnings}"
|
||||
assert gated.signal_quality == "D", f"expected D, got {gated.signal_quality}"
|
||||
assert gated.trade_setup is None, "setup must be killed by the gate"
|
||||
|
||||
|
||||
@test("BC gate 6h: session-blocked F stays F (warning only, no F→D)")
|
||||
def _():
|
||||
end = "2026-07-08 16:55" # blocked hour → gate 6b sets F
|
||||
dfs = {
|
||||
Timeframe.M5: _inject_big_bar(make_xau_bars(end=end)),
|
||||
Timeframe.M15: make_xau_bars(end="2026-07-08 16:45", freq="15min"),
|
||||
Timeframe.H1: make_xau_bars(end="2026-07-08 16:00", freq="60min"),
|
||||
}
|
||||
report = _bc_agg(k=3.0).analyze_from_dataframes(XAUUSD, dfs, use_news=False)
|
||||
assert any("SESSION FILTER" in w for w in report.warnings)
|
||||
assert any(w.startswith("BIG CANDLE FILTER") for w in report.warnings), \
|
||||
"detection must run regardless of grade (PB/CP suppression channel)"
|
||||
assert report.signal_quality == "F", \
|
||||
f"blocked bar must stay F, got {report.signal_quality}"
|
||||
|
||||
|
||||
@test("BC gate 6h: XAU_PB and XAU_CP entry paths suppressed")
|
||||
def _():
|
||||
# The D-downgrade alone would NOT stop them: PB accepts D (check is
|
||||
# != 'F') and CP even F — the warning is the suppression channel.
|
||||
dfs = _bc_actionable_dfs()
|
||||
|
||||
def run(k):
|
||||
agg = _bc_agg(k=k)
|
||||
agg.config.xau_strategy.cp_enabled = True
|
||||
calls = []
|
||||
agg._try_m5_pullback = lambda *a, **kw: calls.append("pb")
|
||||
agg._try_chart_pattern = lambda *a, **kw: calls.append("cp")
|
||||
# Keep the setup slot empty so steps 9/10 are actually reached.
|
||||
agg._generate_trade_setup = lambda *a, **kw: None
|
||||
agg._generate_xau_atr_trade_setup = lambda *a, **kw: None
|
||||
agg.analyze_from_dataframes(XAUUSD, dfs, use_news=False)
|
||||
return calls
|
||||
|
||||
assert run(k=0.0) == ["pb", "cp"], "control must reach both entry paths"
|
||||
assert run(k=3.0) == [], "tripped gate must suppress both entry paths"
|
||||
|
||||
|
||||
@test("BC gate: disabled by default in every factory TF config")
|
||||
def _():
|
||||
from pair_signals.xauusd import make_config as xau_make_config
|
||||
for tf in (Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4):
|
||||
cfg = xau_make_config(tf)
|
||||
assert cfg.xau_strategy.big_candle_atr_multiple == 0.0, \
|
||||
f"{tf}: gate must ship default-OFF (validate-first rollout)"
|
||||
assert cfg.xau_strategy.big_candle_lookback_hours == 24.0
|
||||
|
||||
|
||||
@test("BC backtester: --big-candle / --big-candle-hours parse, default None")
|
||||
def _():
|
||||
from gpu_backtest import _parse_args
|
||||
args = _parse_args(["--pair", "XAUUSD",
|
||||
"--big-candle", "3.0", "--big-candle-hours", "12"])
|
||||
assert args.big_candle == 3.0 and args.big_candle_hours == 12.0
|
||||
args2 = _parse_args(["--pair", "XAUUSD"])
|
||||
assert args2.big_candle is None and args2.big_candle_hours is None
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 18: --set research override + diagnose_filters tag freshness
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print("\n═══ Module 18: --set override + diagnose tags ═══")
|
||||
|
||||
|
||||
@test("--set: nested float/bool/JSON-list overrides apply with typing")
|
||||
def _():
|
||||
from gpu_backtest import _apply_config_overrides
|
||||
cfg = PlatformConfig()
|
||||
_apply_config_overrides(cfg, [
|
||||
"xau_strategy.m5_min_abs_score=25",
|
||||
"xau_strategy.m5_require_mtf=false",
|
||||
'xau_strategy.sell_blocked_hours_utc=[15,17]',
|
||||
"signals.min_tp_cost_multiple=20.5",
|
||||
])
|
||||
assert cfg.xau_strategy.m5_min_abs_score == 25
|
||||
assert cfg.xau_strategy.m5_require_mtf is False
|
||||
assert cfg.xau_strategy.sell_blocked_hours_utc == [15, 17]
|
||||
assert cfg.signals.min_tp_cost_multiple == 20.5
|
||||
|
||||
|
||||
@test("--set: unknown path / section leaf / missing '=' all fail loudly")
|
||||
def _():
|
||||
# A typo'd path MUST NOT become a silent no-op run: dataclass
|
||||
# instances happily accept setattr of any name.
|
||||
from gpu_backtest import _apply_config_overrides
|
||||
cfg = PlatformConfig()
|
||||
for bad in ("xau_strategy.m5_min_abs_scroe=25", # typo leaf
|
||||
"xau_stragety.m5_min_abs_score=25", # typo section
|
||||
"xau_strategy=5", # section, not leaf
|
||||
"no_equals_sign"):
|
||||
try:
|
||||
_apply_config_overrides(cfg, [bad])
|
||||
except SystemExit:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"'{bad}' must raise SystemExit")
|
||||
|
||||
|
||||
@test("--set: bare-string fallback for non-JSON values")
|
||||
def _():
|
||||
from gpu_backtest import _apply_config_overrides
|
||||
cfg = PlatformConfig()
|
||||
_apply_config_overrides(cfg, ["technical.rsi_mode=classic"])
|
||||
assert cfg.technical.rsi_mode == "classic"
|
||||
|
||||
|
||||
@test("--set: CLI flag parses as repeatable list, default empty")
|
||||
def _():
|
||||
from gpu_backtest import _parse_args
|
||||
args = _parse_args(["--pair", "XAUUSD",
|
||||
"--set", "xau_strategy.m5_min_abs_score=25",
|
||||
"--set", "signals.tp_floor_rr=1.0"])
|
||||
assert args.set == ["xau_strategy.m5_min_abs_score=25",
|
||||
"signals.tp_floor_rr=1.0"]
|
||||
assert _parse_args(["--pair", "XAUUSD"]).set == []
|
||||
|
||||
|
||||
@test("diagnose_filters: every filter tag has a live emitter (staleness guard)")
|
||||
def _():
|
||||
import inspect
|
||||
import diagnose_filters
|
||||
import signal_aggregator as sa
|
||||
src = inspect.getsource(sa)
|
||||
tag_src = inspect.getsource(diagnose_filters.classify_bar)
|
||||
# Extract the tag strings from the ordered list in classify_bar.
|
||||
import re
|
||||
tags = re.findall(r'\("([^"]+)",\s+"[A-Z0-9_]+"\)', tag_src)
|
||||
assert len(tags) >= 25, f"tag extraction broke: found {len(tags)}"
|
||||
for tag in tags:
|
||||
assert tag in src, f"filter tag '{tag}' has no emitter in signal_aggregator.py"
|
||||
|
||||
|
||||
@test("diagnose_filters: one tag per warning, specific prefix wins")
|
||||
def _():
|
||||
import diagnose_filters as d
|
||||
from signal_aggregator import SignalReport
|
||||
|
||||
class _R:
|
||||
pass
|
||||
r = _R()
|
||||
r.warnings = [
|
||||
"M5 FILTER: SELL quality A capped — high-conviction M5 shorts enter at extremes",
|
||||
"SELL FILTER: 15:00 UTC entry blocked for shorts",
|
||||
"TREND EXHAUSTION — price extended >3.5 ATR past swing high",
|
||||
]
|
||||
r.final_signal = SignalStrength.SELL
|
||||
r.signal_quality = "D"
|
||||
r.trade_setup = None
|
||||
outcome, hits = d.classify_bar(r, True)
|
||||
assert outcome == "LOW_QUALITY"
|
||||
assert hits == ["M5_SELL_QUALITY", "SELL_HOUR", "TREND_EXHAUSTION"], hits
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user