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
+58 -5
View File
@@ -549,9 +549,50 @@ async def run_signal(
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
report = await loop.run_in_executor(
_executor, _run_signal_sync, symbol, timeframe, confirmation_tfs
)
_remember_scan(symbol, timeframe, report)
return report
# Most recent report per (symbol, timeframe) keyed by the closed-bar epoch
# it was computed for. Closed-bar inputs are identical within a bar, so a
# rescan inside the same bar (the WS level-breach handler fired up to
# ~5 min apart all day, logging 3.5 M15 rows per candle — 2026-09 audit)
# can reuse it instead of re-fetching, re-scoring and re-logging.
_last_scan: Dict[str, tuple] = {}
_TF_SECONDS_CACHE = {
Timeframe.M1: 60, Timeframe.M5: 300, Timeframe.M15: 900,
Timeframe.M30: 1800, Timeframe.H1: 3600, Timeframe.H4: 14400,
Timeframe.D1: 86400,
}
def _bar_epoch(tf: Timeframe, now: Optional[float] = None) -> int:
"""Epoch of the most recent candle boundary for ``tf`` (UTC-anchored,
the same grid _seconds_until_boundary sleeps to)."""
secs = _TF_SECONDS_CACHE.get(tf, 900)
t = int(time.time() if now is None else now)
return t - t % secs
def _remember_scan(symbol: str, tf: Timeframe, report: SignalReport) -> None:
_last_scan[f"{symbol}_{tf.value}"] = (_bar_epoch(tf), report)
async def _cached_or_scan(
symbol: str,
timeframe: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
"""run_signal, unless this bar already has a report for the key."""
cached = _last_scan.get(f"{symbol}_{timeframe.value}")
if cached is not None and cached[0] == _bar_epoch(timeframe):
logger.info(
f"{symbol} {timeframe.value}: reusing this bar's scan — no rescan")
return cached[1]
return await run_signal(symbol, timeframe, confirmation_tfs)
# ── Alert gating (quality threshold + dedup) ─────────────────────────
@@ -572,17 +613,22 @@ def _passes_quality(quality: str) -> bool:
return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1)
_last_alert_quality: Dict[str, str] = {} # key → grade of last ALERTED signal
def _alert_fingerprint(report: SignalReport) -> tuple:
"""Alert identity for dedup: signal, quality, setup direction.
"""Alert identity for dedup: signal and setup direction.
Entry movement is checked separately in _should_alert against the
last ALERTED entry/risk (a stable reference). Deriving a price bucket
from the CURRENT SL distance here made the fingerprint churn on
normal ATR/anchor drift — near-duplicate alerts every candle."""
normal ATR/anchor drift — near-duplicate alerts every candle. The
grade is deliberately NOT part of the identity (2026-09 audit: an
A↔B flicker on an unchanged idea re-sent the full alert ~4× in 45
min); a grade UPGRADE re-alerts via _should_alert instead."""
setup = report.trade_setup
return (
report.final_signal.name,
report.signal_quality,
setup.direction if setup else None,
)
@@ -602,6 +648,12 @@ def _should_alert(key: str, report: SignalReport) -> bool:
return True
if time.time() - _last_alert_ts.get(key, 0) >= _ALERT_REARM_SECONDS:
return True
# Same idea at a HIGHER grade than last alerted → worth re-sending;
# a downgrade or flicker back is not.
last_q = _last_alert_quality.get(key)
if last_q is not None and (_QUALITY_RANK.get(report.signal_quality, 0)
> _QUALITY_RANK.get(last_q, 0)):
return True
setup = report.trade_setup
last = _last_alert_setup.get(key)
if setup is not None and last is not None:
@@ -614,6 +666,7 @@ def _should_alert(key: str, report: SignalReport) -> bool:
def _commit_alert(key: str, report: SignalReport) -> None:
_last_alert_fp[key] = _alert_fingerprint(report)
_last_alert_ts[key] = time.time()
_last_alert_quality[key] = report.signal_quality
setup = report.trade_setup
if setup is not None:
_last_alert_setup[key] = (
@@ -871,7 +924,7 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
try:
report = await run_signal(pair_symbol, Timeframe.M15)
report = await _cached_or_scan(pair_symbol, Timeframe.M15)
_update_level_cache([report])
except Exception:
logger.exception(f"Breach scan failed for {display}")