Files
ForexBot2/llm_client.py
Harwinder Sekhon 2cc5915b00 Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit.

Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule,
written before the runs):
- SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto
  "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday
  stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier)
  is removed as the last step of the core. One definition
  (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's
  host side; D1 is a required frame of the M15 scan; mirrored in
  cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs
  1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs
  2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R.
- FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1
  (−9.7R), buy-only and sell-only on M15, the wide-stop cap
  (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob).
- New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins
  unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15
  pins byte for byte. Twin and reference script MD5-equal on every window;
  recorder and feature-export parity re-proved; base-rate table rebuilt.
- DISPLAY (books byte-identical): exposure.py — the lot size for
  trade_size.risk_per_trade (new config.json block, off by default, private to
  the owner) and the count of alerted same-direction plans still open
  (signals.alerted_at, read-only open_alerted_setups); the daily trend in words
  on Telegram, the CLI and the dashboard; signals.daily_trend logged.
- providers/oanda: a date window with no bars is an empty page, not an error
  (a D1 pad starting on a Saturday could not be fetched).
- Reports only, nothing shipped: seven context screens (no state nominated) and
  eight outside data sources (none adds a forecast to gold's own daily trend).
- Independent verification (report section 5.13): every figure reproduced on
  both scripts, scoring identical to the previous commit on all four
  timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not
  a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022;
  the neighbour clause is thin (a simple 100-day average misses the R line by
  1.2R); "never adds a trade" is an observation, not a guarantee.

Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects
elsewhere, plus 4 found by an independent review of the fixes; all fixed, each
with a test; no trade book changed:
- telegram_bot: one _deliver_alert for the candle scan and the level break. The
  row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and
  un-stamped when no chat received it or the task is cancelled mid-stamp, so
  two alerts at one candle close count each other and no SQLite write runs on
  the event loop. The 5-bar cooldown counts bars of the scan's own frame
  (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after
  the settlement pause) and is restored from signals.db at start-up. The
  lot-size line goes to the owner's own chat only (a group the owner ran
  /autoscan in used to receive it). /status gains a news-model line and escapes
  failure details.
- signal_log: mark_alerted returns the stamp it wrote; unmark_alerted;
  last_alerted_bars (read-only).
- advisory_outcome: candle continuity is judged bar by bar inside the walk; a
  hole after a trade's exit no longer voids it. The stored-candle replay
  resolves 108 of 111 logged setups instead of 99, all equal to the live
  resolver's R.
- providers/oanda: an empty date window is accepted only when it is 5 days or
  shorter and no malformed candle was skipped; a longer one is retried, then
  refused, instead of being cached as history with a hole.
- news_engine: blank RSS titles skipped; one unreadable headline no longer
  costs the local model its whole batch (checked on the real bundle); model
  health recorded; no DeepSeek audit call in a process that does not archive;
  event-direction cache keyed per release; peek_week_events never fetches.
- llm_client: chat_json never raises and never logs the API key (requests
  quotes the Authorization value in InvalidHeader; error bodies are redacted).
- signal_aggregator: the scan's news block is guarded; the replay path
  (use_news=False) assigns exactly the values it did before.
- agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input
  (event timing over the cached calendar week, headlines newest first).
  scripts/panel_replay.py replays the logged payload per persona;
  scripts/label_scan_outcomes.py counts with an ISO cutoff.
- test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34);
  tests that write Parquet print a notice and return without pyarrow.

Verification after the last code change: suite 632 passed, 0 failed (also with
both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the
five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37
green; static Python 3.9 / SQLite 3.34 check clean on every touched file.

Evidence: backtest/research/daily_trend_campaign_2026-09-30.md,
context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6,
8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
2026-10-01 10:28:28 -04:00

248 lines
10 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,
):
# Whitespace around a key is never part of it, and a trailing line
# break (a key pasted from a file) is not a legal header value:
# every call would fail before it left the process.
self._api_key = (api_key or "").strip()
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 _redact(self, text: Any) -> str:
"""Exception text that is safe to log: never contains the key."""
out = str(text)
return out.replace(self._api_key, "<api key>") if self._api_key else out
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.
Never raises: every caller reads None as "use the fallback", and
the news enrichment runs inside a live scan."""
if not self.available:
return None
try:
content = json.dumps(user_payload, sort_keys=True)
except (TypeError, ValueError) as e:
# Before the cap is counted: a payload that cannot be sent
# must not spend one of the day's requests.
self.last_error = (time.time(), "payload")
self._warn_once(
"payload", "DeepSeek payload is not JSON-serialisable "
"(%s) — call skipped", type(e).__name__)
return None
if not self._under_cap():
return None
body = {
"model": self._model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": content},
],
"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.exceptions.InvalidHeader:
# requests quotes the offending header VALUE in this error, and
# the only header built from configuration is Authorization:
# the text would put the API key in the log. Fixed wording.
self.last_error = (time.time(), "header")
self._warn_once(
"header", "DeepSeek call not sent: the API key holds a "
"character that is not allowed in an HTTP header (a line "
"break?) — check deepseek.api_key / DEEPSEEK_API_KEY")
return None
except requests.RequestException as e:
self.last_error = (time.time(), "network")
self._warn_once("network", "DeepSeek call failed: %s",
self._redact(e))
return None
except Exception as e:
# Anything else raised inside the HTTP stack (a header that
# cannot be encoded, a transport bug) must not reach a scan
# either. Type name only: the text may quote the request.
self.last_error = (time.time(), "client")
self._warn_once("client", "DeepSeek call failed inside the "
"HTTP client: %s", type(e).__name__)
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,
# Redact BEFORE cutting: a gateway error page can echo the
# request headers, and a key cut at the 200-character
# mark would be left partly visible.
self._redact(resp.text)[:200])
return None
try:
parsed = self._parse_response(resp.json())
except Exception:
# ValueError for a body that is not JSON; anything else (a
# pathological nesting depth) is just as unusable.
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