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).

Claude-Session: https://claude.ai/code/session_01Nsd8HcrV67GbCZXxZEn1Ma
This commit is contained in:
Harwinder Sekhon
2026-08-03 13:25:02 -04:00
parent f7d8f1113c
commit 6fff06ff90
10 changed files with 759 additions and 7 deletions
+208 -1
View File
@@ -8,6 +8,7 @@ Key principle: High-impact news events override technical signals.
A perfect technical setup can be destroyed by an unexpected NFP print.
"""
import hashlib
import logging
import re
import threading
@@ -21,10 +22,62 @@ from typing import Dict, List, Optional, Tuple
import requests
from config import NewsConfig
from llm_client import get_llm_client
logger = logging.getLogger(__name__)
# ── DeepSeek prompts ─────────────────────────────────────────────────
# STATIC constants on purpose: DeepSeek prices a repeated byte-identical
# prompt prefix at the cache-hit rate (~50x cheaper), so nothing here may
# be interpolated or timestamped. All variable content travels in the
# user payload. LLM output is display/warning-only — it never gates
# signals (see NewsConfig.sentiment_affects_signals).
_LLM_SENTIMENT_SYSTEM = """\
You are a forex/gold news analyst. You receive JSON:
{"headlines": ["...", ...]}
For EACH headline, in the same order, score:
- "s": overall market sentiment of the headline, -1.0 (very bearish)
to 1.0 (very bullish), 0.0 if irrelevant or neutral.
- "xau": the effect on the GOLD (XAU/USD) price, -1.0 (gold down)
to 1.0 (gold up), 0.0 if irrelevant.
Gold doctrine: hawkish Fed / rate hikes / rising real yields / strong
dollar are gold-bearish; easing / rate cuts / safe-haven flows /
geopolitical stress / weak dollar are gold-bullish.
Handle negation correctly ("no rate hike" is not hawkish).
Reply with ONLY JSON: {"scores": [[s, xau], ...]} — exactly one
[s, xau] pair per input headline, in the same order, nothing else.
Examples:
"Fed signals faster rate hikes as inflation stays hot" -> [0.4, -0.7]
"Gold surges to record high on safe-haven demand" -> [0.8, 0.9]
"Dollar slips after soft jobs report; Treasury yields fall" -> [-0.4, 0.6]
"""
_LLM_EVENT_SYSTEM = """\
You are a forex/gold analyst. You receive one released economic event as
JSON: {"event", "currency", "actual", "forecast", "previous"}.
Decide the likely effect of this RELEASE (actual vs forecast) on the
GOLD (XAU/USD) price. A stronger-than-forecast USD economy or a hawkish
implication is usually gold-bearish; weaker data or a dovish implication
is usually gold-bullish. Mind inverted metrics: higher unemployment or
jobless claims = weaker economy.
Reply with ONLY JSON:
{"gold_direction": "bullish"|"bearish"|"neutral", "reason": "<max 10 words>"}
"""
_LLM_SUMMARY_SYSTEM = """\
You are a forex/gold market-context writer. You receive JSON with recent
"headlines" and today's high-impact "events" (released: true means the
number is out). Write a 1-2 sentence plain-English summary of the
current macro backdrop for a GOLD (XAU/USD) trader: the dominant theme
and its likely pull on gold. No advice, no hedging boilerplate, max 45
words.
Reply with ONLY JSON: {"summary": "..."}
"""
_RELEASE_VALUE_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*([kmbt])?", re.IGNORECASE)
_RELEASE_MULTIPLIERS = {"k": 1e3, "m": 1e6, "b": 1e9, "t": 1e12}
@@ -105,6 +158,7 @@ class FundamentalContext:
news_headlines: List[NewsSentiment] = field(default_factory=list)
event_impact_on_pairs: Dict[str, str] = field(default_factory=dict)
calendar_available: bool = False # True if live calendar data was fetched
llm_summary: str = "" # DeepSeek market-context blurb (display-only)
class NewsEngine:
@@ -185,6 +239,17 @@ class NewsEngine:
_headlines_cache: Tuple[float, List[NewsSentiment]] = (0.0, [])
_EMPTY_RESULT_TTL = 60.0 # retry sooner after a failed/empty fetch
# DeepSeek result caches. Headlines and releases are immutable, so a
# scored one never needs re-scoring; bounded by dropping the oldest
# insertion. _llm_sent_cache is only touched under _cache_lock (its
# writer runs inside _cached_fetch); the other two take the lock
# around cache access but keep HTTP calls outside it.
_llm_sent_cache: Dict[str, Tuple[float, float]] = {} # sha1 → (s, xau)
_llm_event_cache: Dict[Tuple[str, str, str], str] = {}
_llm_summary_cache: Tuple[float, str] = (0.0, "")
_LLM_SENT_CACHE_MAX = 400
_LLM_EVENT_CACHE_MAX = 100
def __init__(self, config: NewsConfig):
self.config = config
self._session = requests.Session()
@@ -264,6 +329,12 @@ class NewsEngine:
except Exception as e:
logger.warning(f"News sentiment fetch failed: {e}")
# 3. LLM market-context summary (display-only)
try:
context.llm_summary = self._llm_market_summary(context)
except Exception as e:
logger.debug(f"LLM market summary failed: {e}")
return context
@staticmethod
@@ -311,10 +382,17 @@ class NewsEngine:
)
if event.minutes_until > -window:
if surprised:
return "caution", (
desc = (
f"{event.currency} {event.event} released — {surprise} surprise "
f"(actual {event.actual} vs forecast {event.forecast})"
)
# LLM enrichment touches the DESCRIPTION only —
# `surprise` is numeric-direction and does not know
# whether a beat is gold-bullish; the LLM does.
gold_dir = self._llm_event_direction(event)
if gold_dir:
desc += f" — gold {gold_dir}"
return "caution", desc
return "caution", f"{event.currency} {event.event} just released"
return "clear", "No significant events"
@@ -596,6 +674,11 @@ class NewsEngine:
except Exception as e:
logger.debug(f"Failed to fetch RSS from {feed_url}: {e}")
try:
self._apply_llm_sentiment(headlines)
except Exception as e:
logger.debug(f"LLM sentiment failed, keyword scores stand: {e}")
return headlines
def _analyze_headline_sentiment(
@@ -637,6 +720,130 @@ class NewsEngine:
return (bullish_score - bearish_score) / total
# ── DeepSeek enrichment (display/warning-only, keyword fallback) ──
def _llm_headline_scores(
self, texts: List[str]
) -> Optional[List[Tuple[float, float]]]:
"""One batched DeepSeek call → [(overall, xau), ...] aligned with
`texts`, or None on any failure/mismatch (caller keeps keyword
scores). Tests stub this method directly."""
result = get_llm_client().chat_json(
_LLM_SENTIMENT_SYSTEM, {"headlines": texts})
if not result:
return None
scores = result.get("scores")
if not isinstance(scores, list) or len(scores) != len(texts):
return None
out = []
for item in scores:
try:
s = max(-1.0, min(1.0, float(item[0])))
x = max(-1.0, min(1.0, float(item[1])))
except (TypeError, KeyError, ValueError, IndexError):
return None
out.append((s, x))
return out
def _apply_llm_sentiment(self, headlines: List[NewsSentiment]) -> None:
"""Overwrite keyword sentiment on `headlines` with LLM scores.
Runs inside _cached_fetch, i.e. UNDER _cache_lock — cache access
is direct and the (rare) HTTP call blocks sibling scans at most
once per cache window, matching the existing shared-fetch design.
Only headlines missing from the cache are sent; a failed batch
changes nothing.
"""
if not headlines or not get_llm_client().available:
return
cache = NewsEngine._llm_sent_cache
keyed = [(hashlib.sha1(h.headline.encode()).hexdigest(), h)
for h in headlines]
# Cap the batch so the reply fits the client's max_tokens — the
# overflow stays keyword-scored this cycle and, still uncached,
# goes out with the next window's batch.
new = [(k, h) for k, h in keyed if k not in cache][:25]
if new:
scores = self._llm_headline_scores([h.headline for _, h in new])
if scores is not None:
for (k, _), sc in zip(new, scores):
cache[k] = sc
while len(cache) > self._LLM_SENT_CACHE_MAX:
cache.pop(next(iter(cache)))
for k, h in keyed:
sc = cache.get(k)
if sc is None:
continue
overall, xau = sc
h.sentiment_score = overall
h.sentiment_by_currency = {
cur: (xau if cur == "XAU" else overall)
for cur in h.currencies_mentioned
}
def _llm_event_direction(self, event: EconomicEvent) -> Optional[str]:
"""Gold direction for a released surprise, e.g. "bearish: strong
jobs lift yields". None → caller keeps the numeric description.
Cached per release; failures are NOT cached (retry next scan)."""
key = (event.event, event.currency, str(event.actual or ""))
with NewsEngine._cache_lock:
if key in NewsEngine._llm_event_cache:
return NewsEngine._llm_event_cache[key] or None
client = get_llm_client()
if not client.available:
return None
result = client.chat_json(_LLM_EVENT_SYSTEM, {
"event": event.event,
"currency": event.currency,
"actual": event.actual,
"forecast": event.forecast,
"previous": event.previous,
})
if not result:
return None
direction = result.get("gold_direction")
if direction not in ("bullish", "bearish", "neutral"):
return None
reason = str(result.get("reason", "")).strip()[:80]
text = "" if direction == "neutral" else (
f"{direction}: {reason}" if reason else direction)
with NewsEngine._cache_lock:
NewsEngine._llm_event_cache[key] = text
while len(NewsEngine._llm_event_cache) > self._LLM_EVENT_CACHE_MAX:
NewsEngine._llm_event_cache.pop(
next(iter(NewsEngine._llm_event_cache)))
return text or None
def _llm_market_summary(self, context: FundamentalContext) -> str:
"""1-2 sentence macro blurb for alerts; "" when unavailable.
Cached (success OR failure) for the context-cache TTL so at most
one call is made per window."""
with NewsEngine._cache_lock:
ts, cached = NewsEngine._llm_summary_cache
if time.time() - ts < self.config.context_cache_ttl_seconds:
return cached
high_impact = [e for e in context.events_today if e.is_high_impact]
if not context.news_headlines and not high_impact:
return ""
client = get_llm_client()
if not client.available:
return ""
result = client.chat_json(_LLM_SUMMARY_SYSTEM, {
"headlines": [h.headline for h in context.news_headlines[:15]],
"events": [
{"event": e.event, "currency": e.currency,
"actual": e.actual, "forecast": e.forecast,
"released": not e.is_upcoming}
for e in high_impact[:10]
],
})
summary = ""
if result:
summary = str(result.get("summary", "")).strip()[:300]
with NewsEngine._cache_lock:
NewsEngine._llm_summary_cache = (time.time(), summary)
return summary
def _extract_currencies(self, headline: str) -> List[str]:
"""Extract mentioned currencies from a headline."""
headline_lower = headline.lower()