bed8b549ac
None of these touch the signal path. The 12-month M15 book (2024-03-31..2025-03-30, $0.65, cached Oanda bars) is byte-identical before and after (md5 c59d3e7e685c3c880b614640adfea74b, 375 trades), and signal/indicator logging to logs/signals.db is unchanged — verified end to end: per-TF indicators for all 3 TFs, 14 candles each, MTF and reasons all still persisted. agent_panel: the >=2-take quorum guards the CHAIR's synthesizer spend, but ran BEFORE the macro branch — so the shipped default (verdict_source="macro", which never calls the synthesizer) silently dropped a good macro verdict whenever the other three personas were slow. Quorum moved below the macro path. analyze_signal_log: _forward_return clamped to the last available bar when the horizon had not elapsed, reporting a near-zero "forward move" for every row logged within `hours` of the end of the data — on every run, blended into the hit-rate/mean-move aggregate. Now returns None. analyze_signal_log: persona_scorecard iterated raw rows through an index that maps EVERY member row of an event, so a re-logged scan appended the same event R twice. One vote per event, matching news_outcome_buckets. Measured inflation on the live DB: 1.03x. signal_log: dedup required a non-negative logged_at gap, but logged_at is stamped before the row wins the write lock (busy_timeout 5 s), so two processes logging one setup at a boundary can invert timestamp order against id order and seed a second event. Now abs(gap); same in backfill_seeds. Zero occurrences in the current 366 setup rows. advisory_outcome: the catch-all turned real evaluator faults into the same UNRESOLVABLE used for legitimate weekend gaps, with nothing in the log. Still fail-safe, now logged. llm_client: only the "cap" warn-once flag was ever reset, so in a daemon the second and every later network/http/parse outage logged at DEBUG only. Cleared on UTC-day roll and re-armed by a recovered call. dashboard.js: renderCards() redraws the selector for every timeframe but was gated on the SELECTED one changing, leaving the other buttons stale (up to 4 h on H4) while renderHero's rail already showed the new value. news_summary.php: atomic_write treated a short write (disk full) as success, publishing a truncated body the cache-first branch then served forever; and the daily cap was charged before the no-key check and before the stampede lock re-checked the cache, so a click burst could exhaust the 150/day budget with zero LLM calls. migrate_signal_log_news: logged_at is ISO with 'T' while SQLite's datetime() uses a space, so string-comparing them put the whole cutoff calendar day on the wrong side and it never pruned. NOTE: this now deletes one day more than before — it only runs when invoked by hand. Tests 414 -> 422 (new Module 28, one per fix). The existing "<2 valid takes" test was retargeted at the chair — the case it actually protects — rather than deleted, with new coverage for macro-alone. Not fixed, recorded for the owner: - Gate 6c's boost condition is textually identical to the C-grade definition, so every C in a "transitioning" regime is promoted to B before gate 6e reads it, making sell_min_quality's exclusion of "C" a no-op on M15/H4 (H1 and M5 are immune by other means). The backtester runs the same code, so every pinned book already includes this — changing it is a rule-1 revalidation event. - Safe-haven/geopolitical headlines score exactly 0.00 in the keyword fallback and are now dropped entirely by the ±0.1 neutral filter, contradicting the platform's own gold doctrine. Bites only when DeepSeek is capped/disabled/failing. Claude-Session: https://claude.ai/code/session_01Ah5EB1goi5inQmSd37Ne6a
208 lines
8.1 KiB
Python
208 lines
8.1 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
|
|
import time
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
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()
|
|
# Health stamps for status displays: last successful call and
|
|
# last failure (ts, kind). Read-only for consumers.
|
|
self.last_ok: float = 0.0
|
|
self.last_error: Optional[Tuple[float, str]] = None
|
|
|
|
@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
|
|
# Clear ALL warn-once kinds, not just "cap": the network/
|
|
# http_*/parse flags used to be set once and never reset,
|
|
# so in a daemon that runs for weeks the second and every
|
|
# later outage logged at DEBUG only.
|
|
self._warned.clear()
|
|
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.last_error = (time.time(), "network")
|
|
self._warn_once("network", "DeepSeek call failed: %s", e)
|
|
return None
|
|
if resp.status_code != 200:
|
|
self.last_error = (time.time(), f"http_{resp.status_code}")
|
|
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.last_error = (time.time(), "parse")
|
|
self._warn_once("parse", "DeepSeek response was not the "
|
|
"expected JSON object — ignoring")
|
|
else:
|
|
self.last_ok = time.time()
|
|
# A recovered call re-arms the failure warnings so the NEXT
|
|
# outage is visible at WARNING again. "cap" keeps its own
|
|
# UTC-day reset in _under_cap. (Taken under the lock; safe
|
|
# here because chat_json holds no lock — _under_cap has
|
|
# already returned.)
|
|
if self._warned - {"cap"}:
|
|
with self._lock:
|
|
self._warned = {k for k in self._warned if k == "cap"}
|
|
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
|