audit 2026-09-13: eleven delivery/measurement fixes; books byte-identical
Signal path
- A shipped setup always names a direction: grade C needs confidence
>= 45 (gate 6c grants B at >= 45) while BUY/SELL needs >= 50, so C/B
reports reached the alert gate as NEUTRAL with a full setup. The
backtester traded them (the replay never reads final_signal) while
Telegram rejected NEUTRAL first — 5/91 trades, +3.7R of +22.2R, in
the H1 12-month book. _attach_core_setup stamps the setup's direction
and repairs the pre-gate action text; no-setup reports stay NEUTRAL.
- analyze_pair fails the scan when ANY required frame is missing
("DATA UNAVAILABLE:", scan_kind='failed'); a lost confirmation frame
used to run with an unearned -15 MTF penalty.
Outcomes and labels
- advisory_outcome.resolve_locked_floor: a gap the database cannot fill
resolves at the locked trailing stop (GAP_LOCKED, result.floor) when
the stop had crossed the entry; the rebuild fires on both gap reasons
(rows 915/920: +1.14R / +0.84R floors; resolved sum -2.46R -> -0.48R).
- label_scan_outcomes: SCAN_LABEL_MAX_AGE_H = 72 h, stale rows never
anchor the shared bar stream, labeled_at bumped on re-examination,
_stream_since warns at its row cap (the Labor-Day pile would have
blinded scan_outcomes around 2026-09-14).
Providers and scheduling
- The daily settlement pause (Mon-Thu 17:00 New York) is a scheduled
closure for _has_latest_closed_bar (whole-span label walk-back) and
_is_data_fresh (closed seconds are not age); bot and dashboard skip
scans whose just-closed label sits in the pause (61 false stale rows
in three nights).
- fetch_range retries a failed page in place (three failures on one
window raise) and logs an ERROR for interior gaps of >3 unscheduled
bars.
- pd.Timestamp.utcnow() replaced in providers/base.py and engine.py;
requirements pin pandas<4, numpy<3.
- telegram_bot: boundary loops share one scan per bar with the breach
handler (_remember_scan keys on the analysed bar; breach defers 20 s
after an M15 boundary); the rotating file handler attaches in main()
only (the test suite used to write into logs/telegram.log).
- dashboard_publisher: catch_up_timeframes re-scans boundaries missed
while a cycle overran or the process was down.
Backtester and engine guards
- _replay_span returns the per-span GPU cache-miss count; run_backtest
sums, logs it (must be 0) and writes cache_fallbacks to the manifest.
- regime_weight_table raises on a partial regime_weights_override.
Verification: 519 tests; M5/M15 2-yr, M15 OOS, H1/H4 3-yr books
byte-identical before and after; live signals.db untouched (checked on
a copy). CLAUDE.md carries the dated entry and the blocked-hours union
semantics that every book was validated under.
Claude-Session: https://claude.ai/code/session_0139xz1J6YccndA2be6yFnEo
This commit is contained in:
+62
-8
@@ -242,6 +242,36 @@ class SignalAggregator:
|
||||
except Exception as e:
|
||||
logger.warning(f"Fetch failed for {symbol} {tf.value}: {e}")
|
||||
|
||||
# A missing frame is a FAILED scan, never a degraded one. Losing a
|
||||
# CONFIRMATION frame used to let the scan continue with fewer
|
||||
# frames: _check_mtf_agreement needs >= 2 signals, so it returned
|
||||
# False and the composite took a 15-point mtf_disagreement penalty
|
||||
# the market never earned (A/A+ became impossible), while on
|
||||
# M15/H1/H4 a B/C setup could still ship on one confirmation with
|
||||
# no warning at all. The backtester never sees that state
|
||||
# (run_backtest raises on an empty frame), so it was a live-only
|
||||
# divergence. 2026-09-13 audit.
|
||||
missing = [tf for tf in timeframes if tf not in dfs]
|
||||
if missing:
|
||||
report = SignalReport(
|
||||
symbol=symbol,
|
||||
display_name=pair_info["display"],
|
||||
timestamp=str(now_utc),
|
||||
)
|
||||
report.final_signal = SignalStrength.NEUTRAL
|
||||
report.signal_quality = "F"
|
||||
report.trade_setup = None
|
||||
if self.config.primary_timeframe in missing:
|
||||
# Legacy text signal_log.gate_reason already recognises.
|
||||
report.warnings.append("Primary timeframe data unavailable")
|
||||
report.warnings.append(
|
||||
f"DATA UNAVAILABLE: {', '.join(tf.value for tf in missing)}"
|
||||
" — scan failed"
|
||||
)
|
||||
report.action_recommendation = "NO TRADE — market data unavailable"
|
||||
self._log_signal(report, log_source, dfs, failed=True)
|
||||
return report
|
||||
|
||||
report = self.analyze_from_dataframes(
|
||||
symbol=symbol,
|
||||
dfs=dfs,
|
||||
@@ -303,7 +333,7 @@ class SignalAggregator:
|
||||
def _log_signal(
|
||||
self, report: SignalReport, source: Optional[str],
|
||||
dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None,
|
||||
blocked: bool = False,
|
||||
blocked: bool = False, failed: bool = False,
|
||||
) -> None:
|
||||
"""Persist the scan to the signal-history DB (signal_log.py).
|
||||
|
||||
@@ -314,9 +344,11 @@ class SignalAggregator:
|
||||
is still written, just without the news JSON blobs. ``blocked``
|
||||
marks weekend/day early returns: no news fetch, no snapshots —
|
||||
a ~1 KB liveness row (or nothing, per signal_log.log_blocked_scans).
|
||||
A scan whose PRIMARY fetch failed is logged as ``scan_kind``
|
||||
``failed`` (price 0, no signal): liveness evidence that readers
|
||||
skip like ``blocked``, so it never pollutes NEUTRAL statistics."""
|
||||
A scan missing ANY required frame (primary or confirmation, or
|
||||
the regime-filter frame when that filter is on) is logged as
|
||||
``scan_kind`` ``failed`` (price 0, no signal): liveness evidence
|
||||
that readers skip like ``blocked``, so it never pollutes NEUTRAL
|
||||
statistics."""
|
||||
context = None
|
||||
if not blocked:
|
||||
try:
|
||||
@@ -325,10 +357,10 @@ class SignalAggregator:
|
||||
logger.debug("signal log: news context unavailable",
|
||||
exc_info=True)
|
||||
kind = "blocked" if blocked else "scan"
|
||||
if not blocked and dataframes is not None:
|
||||
primary = dataframes.get(self.config.primary_timeframe)
|
||||
if primary is None or primary.empty:
|
||||
kind = "failed"
|
||||
primary = (dataframes or {}).get(self.config.primary_timeframe)
|
||||
if failed or (not blocked and dataframes is not None
|
||||
and (primary is None or primary.empty)):
|
||||
kind = "failed"
|
||||
extra = {"scan_kind": kind}
|
||||
for key, fingerprint in (
|
||||
("config_fingerprint", self.gate_config_fingerprint),
|
||||
@@ -1297,6 +1329,28 @@ class SignalAggregator:
|
||||
report.trade_setup = self._generate_xau_atr_trade_setup(report, atr)
|
||||
if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
|
||||
report.trade_setup = self._generate_trade_setup(report, pip_value)
|
||||
# A shipped setup always names a direction. The signal thresholds
|
||||
# (BUY/SELL need confidence >= 50) sit above the C grade floor
|
||||
# (>= 45), and gate 6c grants B at >= 45 in "transitioning", so a
|
||||
# C/B report could leave here as NEUTRAL with a full setup: the
|
||||
# backtester traded it (the replay never reads final_signal) while
|
||||
# the Telegram alert gate rejects NEUTRAL first (2026-09-13 audit:
|
||||
# 5/91 H1 12-mo book trades, +3.7R of +22.2R, were never alerted).
|
||||
# The generators derived the direction from the score sign — the
|
||||
# same sign every direction gate above used — so this changes
|
||||
# display and alert eligibility only; the trade books are unchanged.
|
||||
# A report no generator served stays NEUTRAL, as before.
|
||||
if (report.trade_setup is not None
|
||||
and report.final_signal == SignalStrength.NEUTRAL):
|
||||
report.final_signal = (SignalStrength.BUY
|
||||
if report.trade_setup.direction == "BUY"
|
||||
else SignalStrength.SELL)
|
||||
# The action text was written before the gate chain with the
|
||||
# old signal name; keep it consistent with the header.
|
||||
if report.action_recommendation.startswith("NEUTRAL"):
|
||||
report.action_recommendation = (
|
||||
report.final_signal.name.replace("_", " ")
|
||||
+ report.action_recommendation[len("NEUTRAL"):])
|
||||
|
||||
@staticmethod
|
||||
def _drop_stale_spread_gate_vetoes(report: SignalReport) -> None:
|
||||
|
||||
Reference in New Issue
Block a user