DeepSeek V4-Flash news enrichment: LLM sentiment, surprise direction, summary

Three display/warning-only upgrades to the news engine, all behind the
existing keyword path as fallback — signals are never gated on LLM
output (event_risk_caps_signals / sentiment_affects_signals stay False
per the 2026-07 audit; backtester use_news=False untouched):

- Headline sentiment: one batched call per 600s cache window (capped at
  25 new headlines, sha1-cached so repeats are never re-sent) overrides
  the keyword scores per currency with the gold-inverse doctrine encoded
  in the prompt.
- Release surprises: get_event_risk_level's description now says which
  way the print pulls GOLD ("— gold bearish: strong jobs lift yields"),
  fixing the documented numeric-only gap; risk level and windows
  unchanged.
- Market summary: 1-2 sentence macro blurb on SignalReport.news_summary,
  rendered in Telegram alerts (📰) and the CLI (🧠).

New llm_client.py: requests-only OpenAI-compatible client (no SDK), one
attempt per call, UTC-day request cap, singleton so the cap survives
per-scan NewsEngine reconstruction. Config: deepseek block in
config.json + DEEPSEEK_API_KEY env; absent key = fully inert.

Live-measured API behavior baked in: V4-Flash is a hybrid thinking model
— requests send thinking:disabled (reasoning otherwise blew the timeout
and could exhaust max_tokens before the answer); prompts are static
module constants so DeepSeek's prefix cache prices repeat calls at the
cache-hit rate; scores use compact [s, xau] pairs; timeout 12s.

Tests 273 → 286 (new Module 19 + deepseek config tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nsd8HcrV67GbCZXxZEn1Ma
This commit is contained in:
Harwinder Sekhon
2026-08-03 13:25:02 -04:00
parent f0d76021d1
commit 8521ab909a
10 changed files with 759 additions and 7 deletions
+309 -1
View File
@@ -26,7 +26,7 @@ sys.path.insert(0, os.path.dirname(__file__))
import json
import app_config
for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID",
"TWELVE_DATA_API_KEY"):
"TWELVE_DATA_API_KEY", "DEEPSEEK_API_KEY"):
os.environ.pop(_k, None)
_TEST_APP = app_config.AppConfig(
provider_settings={"twelve_data": {"api_key": "test-key"}}
@@ -3044,6 +3044,42 @@ def _():
pass
@test("app_config: deepseek block parses, validates, defaults empty")
def _():
app = load_app_config(_write_json({"deepseek": {
"api_key": "sk-x", "model": "deepseek-v4-flash", "enabled": False,
"timeout_seconds": 5, "daily_request_cap": 100}}))
assert app.deepseek_settings["api_key"] == "sk-x"
assert app.deepseek_settings["enabled"] is False
assert load_app_config(_write_json({})).deepseek_settings == {}
for payload in (
{"deepseek": {"api_keyy": "typo"}}, # unknown key
{"deepseek": {"enabled": "yes"}}, # not a bool
{"deepseek": {"timeout_seconds": 0}}, # not positive
{"deepseek": {"timeout_seconds": True}}, # bool is not a number
{"deepseek": {"daily_request_cap": 2.5}}, # not an int
{"deepseek": {"model": ""}}, # empty model
{"deepseek": "oops"}, # not an object
):
try:
load_app_config(_write_json(payload))
assert False, f"should have raised for {payload}"
except AppConfigError:
pass
@test("app_config: deepseek_api_key resolves env → config.json → empty")
def _():
from app_config import deepseek_api_key
app = load_app_config(_write_json({"deepseek": {"api_key": "json-ds"}}))
with patch.dict(os.environ, {}, clear=True):
assert deepseek_api_key(app) == "json-ds"
with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "env-ds"}):
assert deepseek_api_key(app) == "env-ds"
with patch.dict(os.environ, {}, clear=True):
assert deepseek_api_key(AppConfig()) == ""
@test("config.py: hardcoded twelve_data_api_key field is gone")
def _():
assert not hasattr(PlatformConfig(), "twelve_data_api_key"), \
@@ -4657,6 +4693,278 @@ def _():
assert hits == ["M5_SELL_QUALITY", "SELL_HOUR", "TREND_EXHAUSTION"], hits
# ══════════════════════════════════════════════════════════════════════
# MODULE 19: DeepSeek news enrichment (llm_client + news_engine seams)
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 19: DeepSeek news enrichment ═══")
import datetime as _dt_m19
import llm_client as lc
from llm_client import DeepSeekClient
from news_engine import (EconomicEvent, FundamentalContext, NewsEngine,
NewsSentiment)
def _stub_resp(payload, status=200):
return type("R", (), {
"status_code": status,
"text": json.dumps(payload),
"json": lambda self: payload,
})()
def _chat_body(content_obj):
return {"choices": [{"message": {"content": json.dumps(content_obj)}}]}
def _counting_session(payload, status=200):
class S:
calls = 0
def post(self, url, json=None, timeout=None):
S.calls += 1
return _stub_resp(payload, status)
return S()
@test("llm_client: response parsing and availability gating")
def _():
parse = DeepSeekClient._parse_response
assert parse(_chat_body({"a": 1})) == {"a": 1}
assert parse({"choices": []}) is None
assert parse({"error": "x"}) is None
assert parse({"choices": [{"message": {"content": "not json"}}]}) is None
assert parse({"choices": [{"message": {"content": "[1,2]"}}]}) is None
# keyless / disabled clients never touch the network
for client in (DeepSeekClient(api_key=""),
DeepSeekClient(api_key="k", enabled=False)):
assert client.available is False
s = _counting_session(_chat_body({"a": 1}))
client._session = s
assert client.chat_json("sys", {"q": 1}) is None
assert type(s).calls == 0
@test("llm_client: daily cap blocks requests and resets on UTC date roll")
def _():
client = DeepSeekClient(api_key="k", daily_cap=2)
s = _counting_session(_chat_body({"ok": True}))
client._session = s
assert client.chat_json("sys", {}) == {"ok": True}
assert client.chat_json("sys", {}) == {"ok": True}
assert client.chat_json("sys", {}) is None, "third call must hit the cap"
assert type(s).calls == 2, "capped call must make no HTTP request"
# UTC date roll resets the counter
client._count_day = (_dt_m19.datetime.now(_dt_m19.timezone.utc).date()
- _dt_m19.timedelta(days=1))
assert client.chat_json("sys", {}) == {"ok": True}
assert type(s).calls == 3
@test("llm_client: HTTP error and malformed body return None")
def _():
client = DeepSeekClient(api_key="k")
client._session = _counting_session({"detail": "quota"}, status=429)
assert client.chat_json("sys", {}) is None
client._session = _counting_session({"unexpected": "shape"})
assert client.chat_json("sys", {}) is None
class _StubLLM:
"""Drop-in for the llm_client singleton."""
def __init__(self, reply):
self.reply = reply
self.calls = 0
available = True
def chat_json(self, system_prompt, user_payload):
self.calls += 1
return self.reply
@test("NewsEngine: _llm_headline_scores validates, clamps, rejects mismatch")
def _():
ne = NewsEngine(NewsConfig())
try:
lc._client = _StubLLM({"scores": [[5, -3], [0.2, 0.1]]})
assert ne._llm_headline_scores(["a", "b"]) == [(1.0, -1.0), (0.2, 0.1)]
lc._client = _StubLLM({"scores": [[0.1, 0.1]]})
assert ne._llm_headline_scores(["a", "b"]) is None, "length mismatch"
lc._client = _StubLLM({"scores": [["hot", 0], [0.3]]})
assert ne._llm_headline_scores(["a", "b"]) is None, "non-numeric/short"
lc._client = _StubLLM(None)
assert ne._llm_headline_scores(["a"]) is None, "failed call"
finally:
lc._client = None
@test("NewsEngine: LLM sentiment overrides keyword scores per currency")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_sent_cache = {}
try:
lc._client = _StubLLM({"ok": True}) # available, unused (stubbed below)
h = NewsSentiment(
headline="Fed signals rate hike", source="s", timestamp=now,
currencies_mentioned=["USD", "XAU"], sentiment_score=1.0,
keywords=["rate hike"],
sentiment_by_currency={"USD": 1.0, "XAU": -1.0})
ne._llm_headline_scores = lambda texts: [(0.4, -0.7)] * len(texts)
ne._apply_llm_sentiment([h])
assert h.sentiment_score == 0.4
assert h.sentiment_by_currency == {"USD": 0.4, "XAU": -0.7}
assert h.keywords == ["rate hike"], "keywords stay keyword-derived"
finally:
NewsEngine._llm_sent_cache = {}
lc._client = None
@test("NewsEngine: LLM failure keeps keyword sentiment untouched")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_sent_cache = {}
try:
lc._client = _StubLLM({"ok": True})
h = NewsSentiment(
headline="Fed signals rate hike", source="s", timestamp=now,
currencies_mentioned=["USD"], sentiment_score=0.6,
keywords=[], sentiment_by_currency={"USD": 0.6})
ne._llm_headline_scores = lambda texts: None
ne._apply_llm_sentiment([h])
assert h.sentiment_score == 0.6
assert h.sentiment_by_currency == {"USD": 0.6}
# unavailable client: not even the scores call happens
lc._client = None
ne._llm_headline_scores = lambda texts: (_ for _ in ()).throw(
AssertionError("must not be called when unavailable"))
ne._apply_llm_sentiment([h])
finally:
NewsEngine._llm_sent_cache = {}
lc._client = None
@test("NewsEngine: LLM sentiment cache only sends new headlines")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_sent_cache = {}
sent_batches = []
def make(h):
return NewsSentiment(
headline=h, source="s", timestamp=now,
currencies_mentioned=["XAU"], sentiment_score=0.0,
keywords=[], sentiment_by_currency={"XAU": 0.0})
try:
lc._client = _StubLLM({"ok": True})
def scores(texts):
sent_batches.append(list(texts))
return [(0.5, 0.5)] * len(texts)
ne._llm_headline_scores = scores
ne._apply_llm_sentiment([make("headline one"), make("headline two")])
ne._apply_llm_sentiment([make("headline one"), make("headline two"),
make("headline three")])
assert sent_batches == [["headline one", "headline two"],
["headline three"]], sent_batches
finally:
NewsEngine._llm_sent_cache = {}
lc._client = None
@test("NewsEngine: LLM enriches surprise description, numeric fallback intact")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
ne = NewsEngine(NewsConfig())
NewsEngine._llm_event_cache = {}
released = EconomicEvent(
time=now - _dt_m19.timedelta(minutes=10), currency="USD",
impact="high", event="Nonfarm Payrolls",
forecast="200K", actual="300K")
ctx = FundamentalContext(events_today=[released])
try:
# failure first: description must equal today's numeric form
lc._client = _StubLLM(None)
risk, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert risk == "caution"
assert desc.endswith("(actual 300K vs forecast 200K)"), desc
# then success: LLM direction appended to the description only
lc._client = _StubLLM({"gold_direction": "bearish",
"reason": "strong jobs lift yields"})
risk, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert risk == "caution"
assert desc.endswith("— gold bearish: strong jobs lift yields"), desc
# bogus direction → numeric fallback
NewsEngine._llm_event_cache = {}
lc._client = _StubLLM({"gold_direction": "sideways"})
_, desc = ne.get_event_risk_level("XAU/USD", ctx)
assert desc.endswith("(actual 300K vs forecast 200K)"), desc
finally:
NewsEngine._llm_event_cache = {}
lc._client = None
@test("NewsEngine: market summary cached across engine instances")
def _():
now = _dt_m19.datetime.now(_dt_m19.timezone.utc)
NewsEngine._llm_summary_cache = (0.0, "")
ctx = FundamentalContext(news_headlines=[NewsSentiment(
headline="Gold steady", source="s", timestamp=now,
currencies_mentioned=["XAU"], sentiment_score=0.0, keywords=[])])
try:
stub = _StubLLM({"summary": "Soft dollar supports gold."})
lc._client = stub
assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == \
"Soft dollar supports gold."
# second engine instance within the TTL: served from cache, no call
assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == \
"Soft dollar supports gold."
assert stub.calls == 1
# failure caches "" so a broken API is retried next window, not per scan
NewsEngine._llm_summary_cache = (0.0, "")
lc._client = _StubLLM(None)
assert NewsEngine(NewsConfig())._llm_market_summary(ctx) == ""
assert NewsEngine._llm_summary_cache[1] == ""
finally:
NewsEngine._llm_summary_cache = (0.0, "")
lc._client = None
@test("SignalReport.news_summary flows to Telegram, resets when news off")
def _():
import telegram_bot as tb
config = PlatformConfig()
config.primary_timeframe = Timeframe.H1
config.confirmation_timeframes = [Timeframe.M15, Timeframe.H4]
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
ctx = FundamentalContext(llm_summary="Test macro blurb.")
report = agg.analyze_from_dataframes(
"XAU/USD", dfs, context=ctx, use_news=True)
assert report.news_summary == "Test macro blurb."
assert "Test macro blurb." in tb.format_signal_message(report)
report = agg.analyze_from_dataframes(
"XAU/USD", dfs, context=ctx, use_news=False)
assert report.news_summary == ""
@test("news defaults: LLM never gates signals (2026-07 audit lock)")
def _():
cfg = NewsConfig()
assert cfg.event_risk_caps_signals is False
assert cfg.sentiment_affects_signals is False
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════