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