news: DeepSeek validation, vs_forecast fix, /status source health

Validation (llm_news_eval.py harness + 78-headline golden CSV + verdict
doc): headline scoring 78/78 vs keyword baseline 24/78 — validated;
event gold-direction calls misread close K/M numeric comparisons ~11%
of the time (both claims cases in sample, JOLTS 8.79M vs 8.84M).

Fix: event payload now carries code-computed "vs_forecast" (from
EconomicEvent.surprise) and the prompt gains an explicit inverted-
metrics rule + worked claims example — iteration 1 (field alone) fixed
the arithmetic but exposed a masked inversion error. 18/18 on live
re-verification (4 misreads fixed, 11 regression cases clean, 3 fresh
both-side probes).

/status: new "News & AI" section — per-source health for the calendar
chain (which of FF/MyFXBook served), each RSS feed, and DeepSeek
(daily count, last error kind), written passively by the existing
fetch paths under _cache_lock. Failures WARN once on transition
(repeats DEBUG, recovery INFO) so the rotating log carries outage
stories without 10-min spam.

test_all.py: +5 Module 19 tests (351 total); also carries the Module 22
fixed-RR-TP tests from the prior research session (shared file).
Replay artifacts dir gitignored like commit_ladder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ
This commit is contained in:
Harwinder Sekhon
2026-08-06 22:21:02 -04:00
parent daaed8e6c7
commit b831001cfb
8 changed files with 1315 additions and 9 deletions
+84
View File
@@ -71,6 +71,7 @@ from app_config import (
telegram_channel_id,
)
from providers import BaseDataProvider, make_data_provider
from llm_client import get_llm_client
from news_engine import FundamentalContext, NewsEngine
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
@@ -1114,6 +1115,87 @@ async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
def _fmt_age(ts: Optional[float]) -> str:
"""Compact age like '45s', '12m', '3.2h' for status lines."""
if not ts:
return "?"
s = time.time() - ts
if s < 90:
return f"{s:.0f}s"
if s < 5400:
return f"{s / 60:.0f}m"
return f"{s / 3600:.1f}h"
def _news_health_lines() -> List[str]:
"""News / calendar / DeepSeek source health for /status.
Reads the class-level `NewsEngine._source_health` written by the
scan-path fetches plus the LLM client's health stamps — display
only, never triggers a fetch. ⚠️ marks a degraded dependency;
details live in the rotating log (transition-logged WARNINGs)."""
lines = ["\n*News & AI:*"]
# Snapshot: a scan thread may insert a source's FIRST entry while we
# iterate (dict-changed-size RuntimeError); entry mutation is safe.
for _ in range(3):
try:
sh = dict(NewsEngine._source_health)
break
except RuntimeError:
continue
else:
sh = {}
if not sh:
lines.append(" _no news cycle yet (fills after first scan)_")
else:
cal = sh.get("calendar")
ff = sh.get("calendar:forexfactory")
if cal:
if NewsEngine.source_is_failing(cal):
lines.append(f" ⚠️ Calendar: unavailable — {cal['detail']}"
f" ({_fmt_age(cal['err'])})")
elif NewsEngine.source_is_failing(ff):
lines.append(f" ⚠️ Calendar: {cal['extra']} — ForexFactory"
f" down: {ff['detail']} ({_fmt_age(ff['err'])})")
else:
lines.append(f" ✅ Calendar: {cal['extra']}"
f" ({_fmt_age(cal['ok'])} ago)")
heads = sh.get("headlines")
if heads:
failing = NewsEngine.source_is_failing(heads)
mark = "⚠️" if failing else "✅"
detail = f" — {heads['detail']}" if failing else ""
ts = heads["err"] if failing else heads["ok"]
lines.append(f" {mark} Headlines: {heads['extra']}{detail}"
f" ({_fmt_age(ts)} ago)")
for name, entry in sorted(sh.items()):
if name.startswith("rss:") and NewsEngine.source_is_failing(entry):
lines.append(f" ⚠️ {name[4:]}: {entry['detail']}"
f" ({_fmt_age(entry['err'])})")
client = get_llm_client()
if not client.available:
lines.append(" DeepSeek: off — keyword scoring only")
else:
# The client's counter only resets on the first call of a new
# UTC day — show 0 (not yesterday's total, or a phantom
# cap-exhausted warning) until that reset happens.
today = datetime.now(timezone.utc).date()
used = client._count if client._count_day == today else 0
cap = client._daily_cap
scored = len(NewsEngine._llm_sent_cache)
err = client.last_error
if used >= cap:
lines.append(f" ⚠️ DeepSeek: daily cap {cap} exhausted —"
" keyword fallback until UTC midnight")
elif err and err[0] > client.last_ok:
lines.append(f" ⚠️ DeepSeek: last call failed ({err[1]},"
f" {_fmt_age(err[0])}) — keyword fallback")
else:
lines.append(f" ✅ DeepSeek: {used}/{cap} calls today,"
f" {scored} headlines scored")
return lines
def _status_text() -> str:
lines = ["📊 *ForexBot2 Status*\n"]
@@ -1172,6 +1254,8 @@ def _status_text() -> str:
if active_breach:
lines.append(f"*Breach tasks:* {active_breach} running")
lines.extend(_news_health_lines())
lines.append(f"\n*Alert quality:* {_alert_min_quality}+ "
f"| re-arm {_ALERT_REARM_SECONDS // 60}m")
return "\n".join(lines)