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:
Harwinder Sekhon
2026-07-30 11:30:20 -04:00
parent dc7beb2877
commit 042b079861
9 changed files with 621 additions and 36 deletions
+67 -2
View File
@@ -306,6 +306,44 @@ class SignalAggregator:
return None
return float(adx.iloc[-1])
def _detect_big_candle(self, primary_df: pd.DataFrame) -> Optional[tuple]:
"""Largest abnormal candle in the recent primary-TF window.
Returns (timestamp, ratio) of the biggest bar whose range is
>= big_candle_atr_multiple x the PRIOR bar's ATR within the last
big_candle_lookback_hours of primary-TF bars, else None. The
prior-bar ATR (shift 1) matters: a huge candle inflates the
Wilder ATR immediately, so comparing against the concurrent ATR
under-detects the very bars this gate exists to catch.
Pure pandas on the caller-sliced df, so the live path and the
backtest replay (which calls analyze_from_dataframes on prefix
slices) compute bit-identical answers. The tail slice keeps the
per-call cost bounded (~n + 42 rows); min_periods NaNs and short
histories degrade to "no trip".
"""
cfg = self.config.xau_strategy
bar_secs = BaseDataProvider.CANDLE_SECONDS[self.config.primary_timeframe]
n = max(1, int(cfg.big_candle_lookback_hours * 3600 // bar_secs))
period = self.config.technical.atr_period
tail = primary_df.iloc[-(n + 3 * period):]
high, low, close = tail["High"], tail["Low"], tail["Close"]
tr = pd.concat(
[high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs()],
axis=1,
).max(axis=1)
atr = tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
ratio = (high - low) / atr.shift(1)
window = ratio.iloc[-n:]
if window.empty:
return None
max_ratio = window.max()
if pd.isna(max_ratio) or max_ratio < cfg.big_candle_atr_multiple:
return None
return window.idxmax(), float(max_ratio)
def _apply_m5_filters(self, report: SignalReport) -> None:
"""Extra selectivity for M5 — noisy TF needs stronger confirmation."""
if report.signal_quality not in ("A+", "A", "B", "C"):
@@ -957,6 +995,30 @@ class SignalAggregator:
report.signal_quality = "D"
report.trade_setup = None
# ── 6h. Big-candle entry gate (opt-in via per-TF factories) ──
# An abnormally large candle in the recent window = institutional
# burst; price may reverse violently, so NO entry of any kind may
# fire. Detection runs for ANY grade: the warning is the
# suppression channel for the XAU_PB path (which accepts D) and
# the XAU_CP path (which accepts even F). The D-downgrade itself
# touches actionable grades only — F stays F (same convention as
# 6e).
if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and self.config.xau_strategy.big_candle_atr_multiple > 0):
big_candle = self._detect_big_candle(primary_df)
if big_candle is not None:
bc_ts, bc_ratio = big_candle
report.warnings.append(
f"BIG CANDLE FILTER: {bc_ratio:.1f}x ATR bar at {bc_ts} "
f"within "
f"{self.config.xau_strategy.big_candle_lookback_hours:g}h "
f"— no entries"
)
if report.signal_quality in ("A+", "A", "B", "C"):
report.signal_quality = "D"
report.trade_setup = None
# ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ──
if (self.config.primary_timeframe == Timeframe.M5
and report.display_name == "XAU/USD"):
@@ -985,7 +1047,9 @@ class SignalAggregator:
and report.display_name == "XAU/USD"
and self.config.xau_strategy.m5_pullback_enabled
and report.trade_setup is None
and report.signal_quality != "F"):
and report.signal_quality != "F"
and not any(w.startswith("BIG CANDLE FILTER")
for w in report.warnings)):
self._try_m5_pullback(report, primary_df, pip_value)
# ── 10. Chart-pattern entry (XAU_CP) ────────────────────────
@@ -1002,7 +1066,8 @@ class SignalAggregator:
and self.config.xau_strategy.cp_enabled
and report.trade_setup is None
and not any(w.startswith(("SESSION FILTER", "DAY FILTER",
"WEEKEND FILTER"))
"WEEKEND FILTER",
"BIG CANDLE FILTER"))
for w in report.warnings)):
self._try_chart_pattern(report, primary_df, pip_value)