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). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+42
-139
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user