signal_log: outcomes for every setup, event dedup, interned news; fix over-scan, dead outlook, panel OPPOSE bias

- seed an advisory outcome on every setup row; same-setup re-logs within 60 min
  are DUPLICATE rows (advisory_event_id); resolver rebuilds bar streams across gaps
- headlines/events interned into news_snapshots (DB 667 MB -> 262 MB); readers
  use signals_with_news / read_news and mode=ro (immutable=1 missed WAL rows)
- post-gate truth columns: gate_reason, actionable, cleaned_warnings, scan_kind,
  config_fingerprint; SELL-hour gates fail closed
- telegram: breach rescan reuses this bar's report; grade dropped from alert
  fingerprint (upgrade-only re-alert)
- market_outlook: hour-bucket dedup (old rule skipped alternate hours), skip heartbeats
- agent_panel: macro-anchored OPPOSE guard (opposed trades were +30R over 3 weeks)
- analyze_signal_log --stored-candles/--parity; backfill + news migration scripts
- tests: Module 25 (analyze_signal_log) + 27 (observability); 429 total

Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
Harwinder Sekhon
2026-09-03 20:47:33 -04:00
parent 83ffd290ca
commit b1e880be4f
13 changed files with 3120 additions and 121 deletions
+68 -10
View File
@@ -5,6 +5,8 @@ Combines multi-timeframe technical analysis with optional news context
into a graded actionable signal.
"""
import hashlib
import json
import logging
import os
from dataclasses import dataclass, field
@@ -116,6 +118,9 @@ class SignalReport:
bullish_reasons: List[str] = field(default_factory=list)
bearish_reasons: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
# Warnings the XAU_CP acceptance removed from `warnings` (metadata
# only — logged so the gates a CP trade bypassed stay auditable).
cleaned_warnings: List[str] = field(default_factory=list)
current_price: float = 0.0
spread_estimate: float = 0.0
@@ -178,7 +183,7 @@ class SignalAggregator:
report.trade_setup = None
report.warnings.append("WEEKEND FILTER: XAU/USD market closed — no liquidity")
report.action_recommendation = "NO TRADE — Weekend (market closed)"
self._log_signal(report, log_source)
self._log_signal(report, log_source, blocked=True)
return report
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_cfg.allowed_weekdays:
@@ -196,7 +201,7 @@ class SignalAggregator:
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
)
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
self._log_signal(report, log_source)
self._log_signal(report, log_source, blocked=True)
return report
timeframes = [self.config.primary_timeframe] + list(
@@ -280,6 +285,7 @@ class SignalAggregator:
def _log_signal(
self, report: SignalReport, source: Optional[str],
dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None,
blocked: bool = False,
) -> None:
"""Persist the scan to the signal-history DB (signal_log.py).
@@ -287,12 +293,21 @@ class SignalAggregator:
live scan. The news context re-fetch rides the NewsEngine's
class-level caches, so it costs nothing within a scan and
matches the context the signal just used; if it fails, the row
is still written, just without the news JSON blobs."""
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)."""
context = None
if not blocked:
try:
context = self._get_news_engine().get_fundamental_context()
except Exception:
logger.debug("signal log: news context unavailable",
exc_info=True)
extra = {"scan_kind": "blocked" if blocked else "scan"}
try:
context = self._get_news_engine().get_fundamental_context()
extra["config_fingerprint"] = self.gate_config_fingerprint()
except Exception:
logger.debug("signal log: news context unavailable",
logger.debug("signal log: config fingerprint unavailable",
exc_info=True)
signal_log.log_signal(
report,
@@ -300,8 +315,38 @@ class SignalAggregator:
source=source or "live",
timeframe=self.config.primary_timeframe.value,
dataframes=dataframes,
extra=extra,
)
def gate_config_fingerprint(self) -> str:
"""Short hash of the gate configuration this process runs with.
Logged on every row so two surfaces disagreeing on the same bar
(2026-08-19: the dashboard process applied a different blocked-
hours list than the bot for a day) is diagnosable from the DB."""
xs = self.config.xau_strategy
bt = self.config.backtest
payload = {
"primary": self.config.primary_timeframe.value,
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
"blocked_hours": list(xs.blocked_hours_utc),
"sell_blocked_hours": list(xs.sell_blocked_hours_utc),
"allowed_weekdays": list(xs.allowed_weekdays),
"blocked_regimes": list(xs.blocked_regimes),
"sell_blocked_regimes": list(xs.sell_blocked_regimes),
"sell_min_quality": list(xs.sell_min_quality),
"sell_vol_regimes": list(xs.sell_vol_regimes),
"big_candle": xs.big_candle_atr_multiple,
"cp": bool(xs.cp_enabled),
"pb": bool(xs.m5_pullback_enabled),
"session": [bt.session_filter, bt.session_start_hour,
bt.session_end_hour],
"regime_filter": bool(bt.regime_filter),
}
return hashlib.sha1(
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
).hexdigest()[:12]
def _apply_regime_filter_live(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None:
@@ -744,9 +789,9 @@ class SignalAggregator:
close_h = None
logger.warning(
f"{report.symbol}: bar-close hour underivable — "
f"SELL hour gate skipped"
f"CP SELL hour gate FAILS CLOSED (short rejected)"
)
if close_h in cfg.sell_blocked_hours_utc:
if close_h is None or close_h in cfg.sell_blocked_hours_utc:
_reject("sell_hour")
return
if self.config.primary_timeframe == Timeframe.M5:
@@ -851,14 +896,18 @@ class SignalAggregator:
"Weak pullback",
)
filtered: List[str] = []
dropped: List[str] = []
for warning in report.warnings:
if warning.startswith(stale_prefixes):
dropped.append(warning)
continue
if (warning.startswith("REGIME FILTER: '")
and warning.endswith("blocked for XAU")):
dropped.append(warning)
continue
if (warning.startswith("M5 FILTER: '")
and "volatility" in warning):
dropped.append(warning)
continue
filtered.append(warning)
@@ -869,6 +918,7 @@ class SignalAggregator:
]
if not extension_items:
report.warnings[:] = filtered
report.cleaned_warnings.extend(dropped)
return
first_index = extension_items[0][0]
priority = {prefix: rank for rank, prefix in enumerate(extension_prefixes)}
@@ -883,7 +933,10 @@ class SignalAggregator:
if not warning.startswith(extension_prefixes)
]
kept.insert(first_index, strongest)
dropped.extend(
warning for _, warning in extension_items if warning != strongest)
report.warnings[:] = kept
report.cleaned_warnings.extend(dropped)
# ── Public: bar-replayable entry point ─────────────────────────
@@ -1116,11 +1169,16 @@ class SignalAggregator:
close_h = None
logger.warning(
f"{report.symbol}: bar-close hour underivable — "
f"SELL hour gate skipped"
f"SELL hour gate FAILS CLOSED (short blocked)"
)
if close_h in self.config.xau_strategy.sell_blocked_hours_utc:
# Fail CLOSED: an underivable close hour blocks the short
# (`None in [15, 17]` used to pass silently). Never fires
# on parseable timestamps, so pinned books are unchanged.
if (close_h is None
or close_h in self.config.xau_strategy.sell_blocked_hours_utc):
hour_txt = "??" if close_h is None else f"{close_h:02}"
report.warnings.append(
f"SELL FILTER: {close_h:02}:00 UTC entry blocked for shorts"
f"SELL FILTER: {hour_txt}:00 UTC entry blocked for shorts"
)
report.signal_quality = "D"
report.trade_setup = None