Audit fixes: mixed-regime bias, live==backtest session parity, parallel replay

Fix 1 — mixed-"trending" regime (ADX strong, EMAs unaligned) was
hard-coded bullish in 5 sites: RSI reinterpretation used the uptrend
playbook (RSI 38-55 -> STRONG_BUY conf 75), BUY-only confidence
bonuses, BUY-only extension penalties, no counter-trend penalty.
Now: extremes-only RSI (as transitioning), no directional bonuses,
symmetric extension penalties. 6yr validation: H1 +67.2->+61.9R
(mixed skew 16B/11S -> 4B/7S), H4 +31.6->+27.5R (the 4 bias BUYs),
M15 +152.6->+154.7R, M5 +207.1->+206.1R with maxDD 28.7->24.3R.
The ~8R aggregate give-back is bull-market subsidy from the bug.

Fix 2 — live session filter now applies the EXACT replay-loop
predicate (session window + blocked hours on the signal bar's CLOSE
time, from the bar timestamp not wall clock). Replaces a wall-clock
filter gated on a "XAU STRATEGY" marker that no code writes, which
made live diverge from every validated book.

Fix 3 — delete dead _check_xau_oversold_bounce and
_generate_xau_trade_setup (unreachable; the latter hardcoded BUY and
skipped the spread/RR gates).

Backtester: parallel bar replay via forked workers (--workers,
default auto = cores-2 capped 12). Per-bar evaluation is pure; the
entry cooldown is applied in an ordered phase-2 pass. Proven
byte-identical to sequential on the same window; ~300 -> 1030-1680
bars/s (6yr M5: ~26min -> 5-8min). --workers 1 = sequential path.

Tests: 179 passed (new mixed-regime RSI regression test).
This commit is contained in:
Harwinder Sekhon
2026-07-11 22:57:37 -04:00
parent 6f69b9dd90
commit dde41fbeb8
5 changed files with 282 additions and 287 deletions
+42 -139
View File
@@ -227,38 +227,41 @@ class SignalAggregator:
if bt.regime_filter:
self._apply_regime_filter_live(report, dfs)
if bt.session_filter:
xau_active = (
self.config.xau_strategy.enabled
and any("XAU STRATEGY" in r for r in report.bullish_reasons)
)
if not xau_active:
self._apply_session_filter_live(report)
# Session window + blocked hours on the signal bar's CLOSE time —
# the exact predicate the backtester's replay loop applies before
# each bar, so live and backtest cannot diverge. Bar-close time
# (not wall clock) makes manual mid-candle scans behave like
# boundary auto-scans. This replaces an older wall-clock filter
# that was guarded by a marker string no code wrote.
if bt.session_filter and report.primary_analysis is not None:
try:
bar_close = (
pd.Timestamp(report.timestamp)
+ pd.Timedelta(
seconds=ForexDataProvider.CANDLE_SECONDS[
self.config.primary_timeframe
]
)
)
close_h = bar_close.hour
_is_xau = pair_info["display"] == "XAU/USD"
_blocked = (self.config.xau_strategy.blocked_hours_utc
if _is_xau else bt.blocked_hours_utc)
outside = not (bt.session_start_hour <= close_h < bt.session_end_hour)
if outside or close_h in _blocked:
report.warnings.append(
f"SESSION FILTER: bar closes {close_h:02}:00 UTC — "
+ ("outside window" if outside else "blocked hour")
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.action_recommendation = "NO TRADE — Outside tradeable session"
report.trade_setup = None
except Exception:
pass
return report
def _apply_session_filter_live(self, report: SignalReport) -> None:
"""Skip signals outside the configured UTC trading window."""
bt = self.config.backtest
now_h = datetime.now(timezone.utc).hour
if not (bt.session_start_hour <= now_h < bt.session_end_hour):
report.warnings.append(
f"SESSION FILTER: {now_h:02}:00 UTC outside "
f"{bt.session_start_hour:02}:00-{bt.session_end_hour:02}:00 — skipping"
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.action_recommendation = (
f"NO TRADE — Outside session "
f"({bt.session_start_hour:02}-{bt.session_end_hour:02} UTC; now {now_h:02})"
)
report.trade_setup = None
else:
report.warnings.append(
f"Session: {now_h:02}:00 UTC inside trading window "
f"{bt.session_start_hour:02}-{bt.session_end_hour:02}"
)
def _apply_regime_filter_live(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None:
@@ -931,112 +934,6 @@ class SignalAggregator:
# ── Internals ──────────────────────────────────────────────────
def _check_xau_oversold_bounce(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> bool:
"""Return True when XAU/USD oversold-bounce conditions are met."""
cfg = self.config.xau_strategy
try:
bar_hour = pd.Timestamp(report.timestamp).hour
if bar_hour in cfg.blocked_hours_utc:
return False
except Exception:
return False
m15_analysis = None
h4_analysis = None
for ca in report.confirmation_analyses:
if ca.timeframe == Timeframe.M15.value:
m15_analysis = ca
elif ca.timeframe == Timeframe.H4.value:
h4_analysis = ca
h1_analysis = report.primary_analysis
if not m15_analysis or not h1_analysis:
return False
m15_rsi = m15_analysis.indicators.get("rsi")
if not m15_rsi or m15_rsi.value >= cfg.rsi_entry_threshold:
return False
ema200_count = 0
m15_ema200 = m15_analysis.key_levels.get("ema_trend")
m15_df = dfs.get(Timeframe.M15)
if m15_df is not None and m15_ema200 is not None:
m15_price = float(m15_df["Close"].iloc[-1])
if m15_price > m15_ema200:
ema200_count += 1
h1_ema200 = h1_analysis.key_levels.get("ema_trend")
if h1_ema200 is not None and report.current_price > h1_ema200:
ema200_count += 1
if h4_analysis:
h4_ema200 = h4_analysis.key_levels.get("ema_trend")
h4_df = dfs.get(Timeframe.H4)
if h4_df is not None and h4_ema200 is not None:
h4_price = float(h4_df["Close"].iloc[-1])
if h4_price > h4_ema200:
ema200_count += 1
if ema200_count < cfg.min_ema200_tfs:
return False
return True
def _generate_xau_trade_setup(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> Optional[TradeSetup]:
"""Generate XAU-specific trade setup with backtested TP/SL."""
cfg = self.config.xau_strategy
m15_analysis = None
for ca in report.confirmation_analyses:
if ca.timeframe == Timeframe.M15.value:
m15_analysis = ca
break
if not m15_analysis:
return None
atr = m15_analysis.key_levels.get("atr", 0)
if atr <= 0:
return None
price = report.current_price
pip_value = 0.10
quality_scale = self._TP_QUALITY_SCALE.get(report.signal_quality, 0.75)
tp_mult = cfg.tp_atr_multiplier * quality_scale
tp1 = price + tp_mult * atr
sl = price - cfg.sl_atr_multiplier * atr
tp2 = price + tp_mult * 1.5 * atr
tf = self.config.signals.tp_tightening_factor
tp1 = price + (tp1 - price) * tf
tp2 = price + (tp2 - price) * tf
sl_distance = price - sl
tp_distance = tp1 - price
rr = tp_distance / sl_distance if sl_distance > 0 else 0
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction="BUY",
entry_price=price,
stop_loss=round(sl, 2),
take_profit_1=round(tp1, 2),
take_profit_2=round(tp2, 2),
risk_reward=round(rr, 2),
risk_pips=round(sl_distance / pip_value, 1),
reward_pips=round(tp_distance / pip_value, 1),
pip_value=pip_value,
)
_TP_QUALITY_SCALE = {"A+": 2.0, "A": 1.5, "B": 1.0, "C": 1.0}
def _generate_xau_atr_trade_setup(
@@ -1143,15 +1040,17 @@ class SignalAggregator:
if primary and report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger")
# Pullback bonuses require a KNOWN trend direction — the mixed
# "trending" regime (EMAs unaligned) gets no directional bonus.
if rsi_ind:
rsi_val = rsi_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and 38 <= rsi_val <= 55:
if report.market_regime == "trending_up" and score > 0 and 38 <= rsi_val <= 55:
confidence += 5
elif report.market_regime == "trending_down" and score < 0 and 45 <= rsi_val <= 62:
confidence += 5
if bb_ind:
bb_pos = bb_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos < 0.4:
if report.market_regime == "trending_up" and score > 0 and bb_pos < 0.4:
confidence += 5
elif report.market_regime == "trending_down" and score < 0 and bb_pos > 0.6:
confidence += 5
@@ -1246,19 +1145,23 @@ class SignalAggregator:
if report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger")
# Extension penalties apply in ANY strong-ADX regime; the
# mixed "trending" regime penalizes BOTH directions (it
# previously penalized only BUYs — the SELL side of the
# same bullish-bias bug fixed in the RSI reinterpretation).
if rsi_ind:
rsi_val = rsi_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and rsi_val > 65:
report.warnings.append(f"Extended — RSI {rsi_val:.0f} in uptrend")
penalties.append(("rsi_extended", 5))
elif report.market_regime == "trending_down" and score < 0 and rsi_val < 35:
elif report.market_regime in ("trending_down", "trending") and score < 0 and rsi_val < 35:
report.warnings.append(f"Extended — RSI {rsi_val:.0f} in downtrend")
penalties.append(("rsi_extended", 5))
if bb_ind:
bb_pos = bb_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos > 0.85:
penalties.append(("bb_overextended", 8))
elif report.market_regime == "trending_down" and score < 0 and bb_pos < 0.15:
elif report.market_regime in ("trending_down", "trending") and score < 0 and bb_pos < 0.15:
penalties.append(("bb_overextended", 8))
# Hard block: static exhaustion + fading momentum → kill the trade