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
+259
View File
@@ -4829,6 +4829,8 @@ def _():
print("\n═══ Module 19: DeepSeek news enrichment ═══")
import datetime as _dt_m19
import logging
import requests
import llm_client as lc
from llm_client import DeepSeekClient
from news_engine import (EconomicEvent, FundamentalContext, NewsEngine,
@@ -4906,10 +4908,12 @@ class _StubLLM:
def __init__(self, reply):
self.reply = reply
self.calls = 0
self.last_payload = None
available = True
def chat_json(self, system_prompt, user_payload):
self.calls += 1
self.last_payload = user_payload
return self.reply
@@ -5029,6 +5033,10 @@ def _():
risk, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert risk == "caution"
assert desc.endswith("— gold bearish: strong jobs lift yields"), desc
# payload carries the precomputed comparison (300K > 200K) so the
# model never re-compares the raw numbers (2026-08-06 validation)
assert lc._client.last_payload["vs_forecast"] == "above forecast", \
lc._client.last_payload
# bogus direction → numeric fallback
NewsEngine._llm_event_cache = {}
lc._client = _StubLLM({"gold_direction": "sideways"})
@@ -5094,6 +5102,171 @@ def _():
assert cfg.sentiment_affects_signals is False
@test("llm_client: health stamps track last success and failure")
def _():
client = DeepSeekClient(api_key="k", daily_cap=10)
assert client.last_ok == 0.0 and client.last_error is None
client._session = _counting_session(_chat_body({"a": 1}))
assert client.chat_json("sys", {}) == {"a": 1}
assert client.last_ok > 0 and client.last_error is None
client._session = _counting_session({"detail": "quota"}, status=429)
assert client.chat_json("sys", {}) is None
assert client.last_error is not None
ts, kind = client.last_error
assert kind == "http_429" and ts >= client.last_ok
class _LogCapture(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
@test("NewsEngine: _record_source logs transitions only, tracks state")
def _():
import news_engine as ne_mod
cap = _LogCapture()
ne_mod.logger.addHandler(cap)
old_level = ne_mod.logger.level
ne_mod.logger.setLevel(logging.INFO)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
try:
NewsEngine._record_source("t:src", False, "HTTP 503")
warns = [r for r in cap.records if r.levelno == logging.WARNING]
assert len(warns) == 1 and "HTTP 503" in warns[0].getMessage()
assert NewsEngine.source_is_failing(
NewsEngine._source_health["t:src"])
# repeat failure must NOT warn again (DEBUG only)
NewsEngine._record_source("t:src", False, "HTTP 503")
warns = [r for r in cap.records if r.levelno == logging.WARNING]
assert len(warns) == 1
# recovery logs INFO and clears the failing state
NewsEngine._record_source("t:src", True, extra="42 kept")
infos = [r for r in cap.records if r.levelno == logging.INFO]
assert any("recovered" in r.getMessage() for r in infos)
entry = NewsEngine._source_health["t:src"]
assert not NewsEngine.source_is_failing(entry)
assert entry["extra"] == "42 kept"
finally:
ne_mod.logger.removeHandler(cap)
ne_mod.logger.setLevel(old_level)
NewsEngine._source_health = saved
@test("NewsEngine: calendar fallback source is visible in health state")
def _():
ne = NewsEngine(NewsConfig())
saved = NewsEngine._source_health
NewsEngine._source_health = {}
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ev = EconomicEvent(time=now, currency="USD", impact="high", event="X")
try:
ne._try_fetch_calendar_api = lambda: []
ne._try_fetch_myfxbook_rss = lambda: [ev]
assert ne._fetch_calendar_raw() == [ev]
sh = NewsEngine._source_health
assert NewsEngine.source_is_failing(sh["calendar:forexfactory"])
assert not NewsEngine.source_is_failing(sh["calendar"])
assert "MyFXBook" in sh["calendar"]["extra"]
# both dead → calendar itself failing
ne._try_fetch_myfxbook_rss = lambda: []
assert ne._fetch_calendar_raw() == []
assert NewsEngine.source_is_failing(sh["calendar"])
finally:
NewsEngine._source_health = saved
@test("NewsEngine: per-feed RSS health recorded, aggregate survives")
def _():
cfg = NewsConfig()
cfg.rss_feeds = ["https://good.example/rss", "https://bad.example/rss"]
ne = NewsEngine(cfg)
saved = NewsEngine._source_health
NewsEngine._source_health = {}
rss = (b"<rss><channel><item>"
b"<title>Fed signals rate cuts as dollar slides</title>"
b"</item></channel></rss>")
class _Sess:
def get(self, url, timeout=None):
if "bad.example" in url:
raise requests.ConnectionError("refused")
return type("R", (), {"status_code": 200, "content": rss})()
ne._session = _Sess()
try:
heads = ne._fetch_news_sentiment_raw()
assert len(heads) == 1, heads
sh = NewsEngine._source_health
assert not NewsEngine.source_is_failing(sh["rss:good.example"])
assert NewsEngine.source_is_failing(sh["rss:bad.example"])
assert "refused" in sh["rss:bad.example"]["detail"]
agg = sh["headlines"]
assert not NewsEngine.source_is_failing(agg)
assert "1/2 feeds" in agg["extra"], agg
finally:
NewsEngine._source_health = saved
@test("/status renders news/calendar/AI health with ⚠️ degradations")
def _():
import telegram_bot as tb
saved = NewsEngine._source_health
t0 = time.time()
NewsEngine._source_health = {
"calendar": {"ok": t0, "err": None, "detail": "",
"extra": "42 events via MyFXBook"},
"calendar:forexfactory": {"ok": None, "err": t0,
"detail": "HTTP 503", "extra": ""},
"headlines": {"ok": t0, "err": None, "detail": "",
"extra": "38 headlines from 3/4 feeds"},
"rss:fxstreet.com": {"ok": None, "err": t0,
"detail": "timeout", "extra": ""},
}
stub = _StubLLM(None)
stub.last_ok = 0.0
stub.last_error = (t0, "http_429")
stub._count = 87
stub._daily_cap = 500
stub._count_day = _dt_m19.datetime.now(_dt_m19.timezone.utc).date()
lc._client = stub
try:
text = tb._status_text()
assert "⚠️ Calendar: 42 events via MyFXBook — ForexFactory" in text
assert "HTTP 503" in text
assert "38 headlines from 3/4 feeds" in text
assert "⚠️ fxstreet.com: timeout" in text
assert "⚠️ DeepSeek: last call failed (http_429" in text
# healthy path: FF serving, DeepSeek clean → no warnings
NewsEngine._source_health = {
"calendar": {"ok": t0, "err": None, "detail": "",
"extra": "42 events via ForexFactory"},
"headlines": {"ok": t0, "err": None, "detail": "",
"extra": "38 headlines from 4/4 feeds"},
}
stub.last_ok = t0
text = tb._status_text()
assert "✅ Calendar: 42 events via ForexFactory" in text
assert "✅ DeepSeek: 87/500 calls today" in text
assert "⚠️" not in text.split("*News & AI:*")[1].split("*Alert")[0]
# empty state (pre-first-scan) renders the neutral line
NewsEngine._source_health = {}
assert "no news cycle yet" in tb._status_text()
# yesterday's exhausted counter must NOT show as cap-exhausted
# after the UTC date roll (counter resets lazily on next call)
stub._count = 500
stub._count_day = stub._count_day - _dt_m19.timedelta(days=1)
text = tb._status_text()
assert "cap" not in text and "0/500" in text, text
finally:
NewsEngine._source_health = saved
lc._client = None
# ══════════════════════════════════════════════════════════════════════
# MODULE 20: dashboard_publisher.py (static web dashboard)
# ══════════════════════════════════════════════════════════════════════
@@ -6456,6 +6629,92 @@ def _():
os.environ.pop("FB2_EMA_LO", None)
# ══════════════════════════════════════════════════════════════════════
# MODULE 22: fixed-RR TP research flag (--fixed-rr-tp)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 22: fixed-RR TP research flag ═══")
@test("fixed-RR TP: tp1 = entry ± mult×risk (BUY+SELL), tp2=tp1, trail zeroed")
def _():
import gpu_backtest as _gb
buy = _m21_sim_trade() # 2400 / sl 2390
buy.trail_arm_r, buy.trail_dist_r = 0.8, 0.75 # PB-style stamp
sell = _m21_sim_trade(direction="SELL", entry=2400.0, sl=2410.0, tp1=2390.0)
_gb._apply_fixed_rr_tp([buy, sell], 2.0)
assert abs(buy.tp1 - 2420.0) < 1e-9, f"BUY tp1 must be 2420, got {buy.tp1}"
assert abs(sell.tp1 - 2380.0) < 1e-9, f"SELL tp1 must be 2380, got {sell.tp1}"
for t in (buy, sell):
assert t.tp2 == t.tp1, "tp2 must mirror the hard target"
assert t.trail_arm_r == 0.0 and t.trail_dist_r == 0.0, \
"per-trade trail params must be zeroed"
assert t.rr_planned == 2.0
assert abs(t.reward_pips - 200.0) < 1e-9 # 2 × $10 risk / 0.1 pip
@test("fixed-RR TP: 2R target fills on the static path, spread charged")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
_gb._apply_fixed_rr_tp([t], 2.0)
sim = _m21_sim_df([(2400, 2405, 2395, 2404), (2404, 2421, 2402, 2418)])
_gb.simulate_trades([t], sim, max_bars=5, spread=0.30, trail_arm_r=0.0)
assert t.exit_reason == "TP1", f"got {t.exit_reason}"
assert abs(t.exit_price - 2420.0) < 1e-9
assert abs(t.r_multiple - (20.0 - 0.30) / 10.0) < 1e-9, \
"R must be (2R target - round-trip spread) / risk"
@test("fixed-RR TP: same-bar SL+TP tie still resolves SL-first (rule 6)")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
_gb._apply_fixed_rr_tp([t], 2.0)
sim = _m21_sim_df([(2400, 2421, 2389, 2400)]) # both barriers inside bar 0
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0, trail_arm_r=0.0)
assert t.exit_reason == "SL", f"tie must stay SL-first, got {t.exit_reason}"
assert abs(t.r_multiple - (-1.0)) < 1e-9
@test("fixed-RR TP: per-trade PB trail params cannot resurrect the trail")
def _():
import gpu_backtest as _gb
t = _m21_sim_trade()
t.trail_arm_r, t.trail_dist_r = 0.8, 0.75 # XAU_PB per-trade override
_gb._apply_fixed_rr_tp([t], 2.0)
# bar0 runs to +1.2R (a live 0.8R-arm trail would ratchet to 2404.5),
# bar1 retraces through that would-be trail stop, bar2 reaches 2R.
sim = _m21_sim_df([(2400, 2412, 2398, 2410),
(2410, 2411, 2402, 2403),
(2403, 2421, 2402, 2420)])
_gb.simulate_trades([t], sim, max_bars=5, spread=0.0, trail_arm_r=0.0)
assert t.exit_reason == "TP1", \
f"trail must be fully disabled under the flag, got {t.exit_reason}"
assert abs(t.exit_price - 2420.0) < 1e-9 and t.bars_held == 3
assert abs(t.r_multiple - 2.0) < 1e-9
@test("fixed-RR TP: CLI parses the flag; trail-override combinations refused")
def _():
import gpu_backtest as _gb
assert _gb._parse_args(
["--pair", "XAUUSD", "--fixed-rr-tp", "2.0"]).fixed_rr_tp == 2.0
assert _gb._parse_args(["--pair", "XAUUSD"]).fixed_rr_tp is None
for bad in (["--trail-arm", "0.5"], ["--trail-dist", "0.8"],
["--set", "signals.trail_arm_r=0.5"],
["--set", "signals.pb_trail_dist_r=0.5"]):
try:
_gb.main(["--pair", "XAUUSD", "--fixed-rr-tp", "2.0"] + bad)
assert False, f"conflict {bad} must raise SystemExit"
except SystemExit as e:
assert "conflicts" in str(e)
try:
_gb.main(["--pair", "XAUUSD", "--fixed-rr-tp", "-1"])
assert False, "non-positive multiple must raise SystemExit"
except SystemExit as e:
assert "must be > 0" in str(e)
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════