8521ab909a
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
186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
"""
|
|
ForexBot2 — DeepSeek LLM client
|
|
================================
|
|
Thin requests-based client for DeepSeek's OpenAI-compatible chat API,
|
|
used by `news_engine` for display/warning-only enrichment (headline
|
|
sentiment, event surprise direction, market summary). Signals are NEVER
|
|
gated on LLM output — every caller has a non-LLM fallback and treats a
|
|
`None` return as "use the fallback".
|
|
|
|
Design constraints:
|
|
- One attempt per call, no retries: news runs inside scan cycles; the
|
|
news engine's 600 s context cache is the natural retry cadence.
|
|
- A UTC-day request cap bounds spend even if a bug causes call storms.
|
|
- Cost: DeepSeek caches identical prompt PREFIXES server-side (cache-hit
|
|
input is ~50x cheaper), so callers pass a STATIC system prompt (no
|
|
timestamps or interpolation) and put all variable content in the user
|
|
payload.
|
|
|
|
This is a module-level singleton (`get_llm_client`) because the daily
|
|
cap counter must survive the per-scan reconstruction of `NewsEngine`.
|
|
"""
|
|
|
|
import datetime as _dt
|
|
import json
|
|
import logging
|
|
import threading
|
|
from typing import Any, Dict, Optional
|
|
|
|
import requests
|
|
|
|
from app_config import deepseek_api_key, get_app_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEEPSEEK_URL = "https://api.deepseek.com/chat/completions"
|
|
DEFAULT_MODEL = "deepseek-v4-flash"
|
|
# 8s timed out on a measured cold-prefix-cache headline batch (2026-08-03);
|
|
# warm-cache calls come back well under that. 12s buys cold-start headroom
|
|
# while still bounding worst-case scan latency (≤3 calls per cache window).
|
|
DEFAULT_TIMEOUT = 12.0
|
|
DEFAULT_DAILY_CAP = 500
|
|
|
|
|
|
class DeepSeekClient:
|
|
"""JSON-in/JSON-out chat wrapper. All failures return None."""
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
model: str = DEFAULT_MODEL,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
daily_cap: int = DEFAULT_DAILY_CAP,
|
|
enabled: bool = True,
|
|
):
|
|
self._api_key = api_key or ""
|
|
self._model = model
|
|
self._timeout = timeout
|
|
self._daily_cap = daily_cap
|
|
self._enabled = enabled
|
|
self._session = requests.Session()
|
|
self._session.headers.update({
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {self._api_key}",
|
|
})
|
|
self._lock = threading.Lock()
|
|
self._count_day: Optional[_dt.date] = None
|
|
self._count = 0
|
|
self._warned: set = set()
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
return self._enabled and bool(self._api_key)
|
|
|
|
def _warn_once(self, kind: str, msg: str, *args: Any) -> None:
|
|
if kind in self._warned:
|
|
logger.debug(msg, *args)
|
|
else:
|
|
self._warned.add(kind)
|
|
logger.warning(msg, *args)
|
|
|
|
def _under_cap(self) -> bool:
|
|
"""Count a request against the UTC-day cap; False = don't call."""
|
|
today = _dt.datetime.now(_dt.timezone.utc).date()
|
|
with self._lock:
|
|
if self._count_day != today:
|
|
self._count_day = today
|
|
self._count = 0
|
|
self._warned.discard("cap")
|
|
if self._count >= self._daily_cap:
|
|
self._warn_once(
|
|
"cap", "DeepSeek daily request cap (%d) exhausted — "
|
|
"falling back to keyword analysis until UTC midnight",
|
|
self._daily_cap)
|
|
return False
|
|
self._count += 1
|
|
return True
|
|
|
|
def chat_json(self, system_prompt: str,
|
|
user_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
"""One chat call; returns the model's JSON object or None."""
|
|
if not self.available or not self._under_cap():
|
|
return None
|
|
body = {
|
|
"model": self._model,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user",
|
|
"content": json.dumps(user_payload, sort_keys=True)},
|
|
],
|
|
"response_format": {"type": "json_object"},
|
|
"temperature": 0,
|
|
# V4-Flash is a hybrid thinking model; without this flag it
|
|
# burns hundreds of reasoning tokens per call — measured
|
|
# 2026-08-03: a 25-headline batch blew straight through the
|
|
# timeout AND could exhaust max_tokens before the answer,
|
|
# returning empty content. The doctrine + few-shot examples
|
|
# in the system prompts carry the quality instead.
|
|
"thinking": {"type": "disabled"},
|
|
# Must fit the largest reply: a full sentiment batch is
|
|
# ~10 tokens per headline; 1000 truncated mid-JSON on a real
|
|
# RSS cycle (2026-08-03).
|
|
"max_tokens": 2000,
|
|
}
|
|
try:
|
|
resp = self._session.post(
|
|
DEEPSEEK_URL, json=body, timeout=self._timeout)
|
|
except requests.RequestException as e:
|
|
self._warn_once("network", "DeepSeek call failed: %s", e)
|
|
return None
|
|
if resp.status_code != 200:
|
|
self._warn_once(
|
|
f"http_{resp.status_code}",
|
|
"DeepSeek HTTP %s: %s", resp.status_code, resp.text[:200])
|
|
return None
|
|
try:
|
|
parsed = self._parse_response(resp.json())
|
|
except ValueError:
|
|
parsed = None
|
|
if parsed is None:
|
|
self._warn_once("parse", "DeepSeek response was not the "
|
|
"expected JSON object — ignoring")
|
|
return parsed
|
|
|
|
@staticmethod
|
|
def _parse_response(body: Any) -> Optional[Dict[str, Any]]:
|
|
"""Extract the assistant's JSON object from a chat completion."""
|
|
try:
|
|
content = body["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, TypeError):
|
|
return None
|
|
try:
|
|
obj = json.loads(content)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return obj if isinstance(obj, dict) else None
|
|
|
|
|
|
# ── Process-wide singleton ────────────────────────────────────────────
|
|
|
|
_client: Optional[DeepSeekClient] = None
|
|
_client_lock = threading.Lock()
|
|
|
|
|
|
def get_llm_client() -> DeepSeekClient:
|
|
global _client
|
|
with _client_lock:
|
|
if _client is None:
|
|
settings = get_app_config().deepseek_settings
|
|
_client = DeepSeekClient(
|
|
api_key=deepseek_api_key(),
|
|
model=str(settings.get("model", DEFAULT_MODEL)),
|
|
timeout=float(settings.get("timeout_seconds",
|
|
DEFAULT_TIMEOUT)),
|
|
daily_cap=int(settings.get("daily_request_cap",
|
|
DEFAULT_DAILY_CAP)),
|
|
enabled=settings.get("enabled", True) is not False,
|
|
)
|
|
return _client
|
|
|
|
|
|
def reset_llm_client() -> None:
|
|
"""Drop the singleton (tests / config reloads)."""
|
|
global _client
|
|
with _client_lock:
|
|
_client = None
|