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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
Harwinder Sekhon
2026-09-03 20:47:33 -04:00
parent 4dd0ca0b46
commit ae6a6658f6
13 changed files with 3120 additions and 121 deletions
+39 -7
View File
@@ -253,7 +253,9 @@ def _recent_signals() -> List[Dict[str, Any]]:
"""Read the latest signal history through SQLite's immutable URI."""
try:
path = os.path.abspath(signal_log_path())
uri = f"file:{path}?immutable=1"
# mode=ro (not immutable=1): immutable ignores the WAL, so the
# newest rows written by the live loggers were invisible here.
uri = f"file:{path}?mode=ro"
con = sqlite3.connect(uri, uri=True)
try:
rows = con.execute(
@@ -328,28 +330,44 @@ def _collect_payload(settings: Dict[str, Any]) -> Dict[str, Any]:
_state_lock = threading.Lock()
_last_run_ts: Optional[float] = None
_last_run_hour: Optional[int] = None # UTC hour bucket of the last attempt
_outlook_day: Optional[_dt.date] = None
_outlook_count = 0
_warned_budget = False
def _skip(reason: str) -> None:
"""Heartbeat for every silent early return — a dark outlook feature
(zero rows for 15 days, 2026-08/09) must be visible in the log."""
logger.info("market outlook: skipped (%s)", reason)
def reset_outlook_state() -> None:
"""Drop hourly and daily state (tests / deployment config reloads)."""
global _last_run_ts, _outlook_day, _outlook_count, _warned_budget
global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
global _warned_budget
with _state_lock:
_last_run_ts = None
_last_run_hour = None
_outlook_day = None
_outlook_count = 0
_warned_budget = False
def _begin_attempt(cap: int, force: bool) -> bool:
"""Atomically apply hourly dedup, then reserve a daily attempt."""
global _last_run_ts, _outlook_day, _outlook_count, _warned_budget
"""Atomically apply hourly dedup, then reserve a daily attempt.
Hourly dedup is by UTC hour BUCKET, not ``now - last < 3600``: the
H1 loop calls this at boundary+10 s+scan time, so consecutive hours
were routinely 3,590 s apart and every other hour was dropped."""
global _last_run_ts, _last_run_hour, _outlook_day, _outlook_count
global _warned_budget
now = time.time()
hour_bucket = int(now // 3600)
today = _dt.datetime.now(_dt.timezone.utc).date()
with _state_lock:
if not force and _last_run_ts is not None and now - _last_run_ts < 3600:
if not force and _last_run_hour == hour_bucket:
_skip(f"hour bucket {hour_bucket} already attempted")
return False
if _outlook_day != today:
_outlook_day = today
@@ -361,9 +379,12 @@ def _begin_attempt(cap: int, force: bool) -> bool:
logger.warning(
"market outlook: daily budget (%d) exhausted — no more "
"outlooks until UTC midnight", cap)
else:
_skip("daily budget exhausted")
return False
_outlook_count += 1
_last_run_ts = now
_last_run_hour = hour_bucket
return True
@@ -372,8 +393,13 @@ def run_outlook(settings: Dict[str, Any],
"""Build, ask, validate, and persist one advisory outlook."""
try:
payload = _collect_payload(settings)
result = _validate_outlook(get_llm_client().chat_json(SYSTEM_PROMPT, payload))
reply = get_llm_client().chat_json(SYSTEM_PROMPT, payload)
result = _validate_outlook(reply)
if result is None:
logger.warning(
"market outlook: no outlook produced (%s)",
"LLM returned nothing — cap/timeout/disabled" if reply is None
else "reply failed validation")
return None
try:
from signal_log import log_outlook
@@ -396,13 +422,19 @@ def maybe_run_outlook(surface: str, force: bool = False) -> Optional[OutlookResu
settings = get_app_config().market_outlook_settings
if not force:
if not market_outlook_enabled():
_skip("disabled")
return None
if surface == "telegram" and not settings.get("telegram", False):
_skip("telegram surface off")
return None
if _provider_base._is_forex_weekend():
_skip("weekend")
return None
cap = int(settings.get("max_per_day", DEFAULT_MAX_PER_DAY))
if cap < 1 or not _begin_attempt(cap, force):
if cap < 1:
_skip("max_per_day < 1")
return None
if not _begin_attempt(cap, force):
return None
return run_outlook(settings, surface=surface)
except Exception: