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:
+259
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user