Initial import: XAUUSD news sentiment models, evals, and data tooling

TF-IDF baseline (news_sentiment.py, models/xauusd_sentiment.joblib) and the
two-head DeBERTa-v3-small transformer (transformer/, news_transformer.py)
with its Google News crawler and DeepSeek labeller (data_collect/), the
golden-set and archive evals (eval/), and the 2026-09-22 and 2026-09-27
results and progress notes.

Transformer weights (model.pt, model.onnx) and the crawl database are not
tracked; rebuild with transformer/train.py and transformer/export_onnx.py.
The DeepSeek label cache in data/ is tracked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-09-27 19:22:30 -04:00
commit 1d72e4006a
98 changed files with 1719757 additions and 0 deletions
+641
View File
@@ -0,0 +1,641 @@
"""
Shared helpers for the ML-NEWS vs DeepSeek evaluation scripts.
RESEARCH ONLY. This folder lives outside the ForexBot2 git tree on
purpose (ML-NEWS is not a git repository). Nothing here is imported by
live code.
Runtime: the ForexBot2 venv (it has `requests`, `pandas` and the live
news code) with ML-NEWS on sys.path (for `news_sentiment`). The model
was pickled with scikit-learn 1.9.1; the ForexBot2 venv carries 1.9.0
and was verified on 2026-09-17 to return byte-identical probabilities
on all 1,577 archived headlines, so the version warning is silenced.
/home/harwinder/Development/ForexBot2/venv/bin/python eval_golden.py
Three scorers are wrapped so every script measures the same things:
* ModelScorer - the scikit-learn bundle (models/xauusd_sentiment.joblib)
* KeywordScorer - ForexBot2's built-in keyword scorer, XAU-inverted
(what the live system falls back to when DeepSeek
is unavailable)
* DeepSeekScorer - the LIVE DeepSeek path: the real system prompt, the
real client, the real batch cap of 25, through
NewsEngine._llm_headline_scores. Results are cached
on disk (results/deepseek_cache.json) keyed by
headline + prompt sha1 + model, so re-running a
report never re-spends.
"""
from __future__ import annotations
import csv
import hashlib
import json
import logging
import math
import os
import sqlite3
import sys
import time
import warnings
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
FOREXBOT2_DIR = Path("/home/harwinder/Development/ForexBot2")
ML_NEWS_DIR = Path("/home/harwinder/Development/ML-NEWS")
EVAL_DIR = Path(__file__).resolve().parent
DATA_DIR = EVAL_DIR / "data"
RESULTS_DIR = EVAL_DIR / "results"
GOLDEN_CSV = DATA_DIR / "llm_news_golden.csv"
MODEL_PATH = ML_NEWS_DIR / "models" / "xauusd_sentiment.joblib"
TRANSFORMER_DIR = ML_NEWS_DIR / "models" / "xau_transformer"
DEFAULT_DB = FOREXBOT2_DIR / "logs" / "newsdata.db"
DEEPSEEK_CACHE = RESULTS_DIR / "deepseek_cache.json"
#: |xau| below this = neutral. The 2026-08-06 golden-set grading used
#: 0.15 (DeepSeek 78/78, keywords 24/78 under this rule).
NEUTRAL_BAND_GOLDEN = 0.15
#: The ML-NEWS trainer maps a DeepSeek XAU score to a class with this
#: threshold (news_sentiment.train_model neutral_threshold=0.05).
LABEL_THRESHOLD_TRAIN = 0.05
#: Production batch cap in news_engine._apply_llm_sentiment.
BATCH_CAP = 25
CLASSES = (-1, 0, 1)
CLASS_NAMES = {-1: "bearish", 0: "neutral", 1: "bullish"}
NAME_TO_CLASS = {v: k for k, v in CLASS_NAMES.items()}
for _p in (str(FOREXBOT2_DIR), str(ML_NEWS_DIR)):
if _p not in sys.path:
sys.path.insert(0, _p)
warnings.filterwarnings("ignore", message=".*InconsistentVersionWarning.*")
try: # sklearn raises its own warning class for a version mismatch
from sklearn.exceptions import InconsistentVersionWarning
warnings.filterwarnings("ignore", category=InconsistentVersionWarning)
except Exception: # pragma: no cover - sklearn missing
pass
logging.basicConfig(level=logging.WARNING,
format="%(levelname)s %(name)s: %(message)s")
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
# ── small utilities ──────────────────────────────────────────────────
def ts_tag() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def to_class(x: Optional[float], band: float) -> Optional[int]:
"""-1 / 0 / +1 from a continuous score; None stays None."""
if x is None or (isinstance(x, float) and math.isnan(x)):
return None
if x >= band:
return 1
if x <= -band:
return -1
return 0
def headline_sha256(headline: str) -> str:
"""Same key news_archive uses (sha256 of the exact headline text)."""
return hashlib.sha256(headline.encode("utf-8")).hexdigest()
def fmt_pct(k: int, n: int) -> str:
return f"{k}/{n} = {100.0 * k / n:.1f}%" if n else "n/a"
def write_json(path: Path, obj: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(obj, indent=2, sort_keys=True, default=str)
+ "\n", encoding="utf-8")
def write_csv(path: Path, rows: Sequence[Dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not rows:
path.write_text("", encoding="utf-8")
return
keys: List[str] = []
for r in rows:
for k in r:
if k not in keys:
keys.append(k)
with path.open("w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=keys)
w.writeheader()
for r in rows:
w.writerow(r)
def hr(title: str = "") -> None:
line = "═" * 72
print("\n" + line)
if title:
print(title)
print(line)
# ── metrics (numpy/pandas only, mirrors the 2026-08-06 harness) ─────
def confusion(exp: Sequence[int], pred: Sequence[int],
classes: Sequence[int] = CLASSES) -> List[List[int]]:
idx = {c: i for i, c in enumerate(classes)}
m = [[0] * len(classes) for _ in classes]
for e, p in zip(exp, pred):
m[idx[e]][idx[p]] += 1
return m
def accuracy(exp: Sequence[int], pred: Sequence[int]) -> float:
pairs = list(zip(exp, pred))
return sum(1 for e, p in pairs if e == p) / len(pairs) if pairs else float("nan")
def macro_f1(exp: Sequence[int], pred: Sequence[int],
classes: Sequence[int] = CLASSES) -> float:
m = confusion(exp, pred, classes)
f1s = []
for i in range(len(classes)):
tp = m[i][i]
fp = sum(m[r][i] for r in range(len(classes))) - tp
fn = sum(m[i]) - tp
prec = tp / (tp + fp) if tp + fp else 0.0
rec = tp / (tp + fn) if tp + fn else 0.0
f1s.append(2 * prec * rec / (prec + rec) if prec + rec else 0.0)
return sum(f1s) / len(f1s)
def directional_accuracy(exp: Sequence[int], pred: Sequence[int]) -> Tuple[int, int]:
"""Among rows whose expected class is not neutral: hits where the
prediction equals it (a neutral prediction counts as a miss)."""
pairs = [(e, p) for e, p in zip(exp, pred) if e != 0]
return sum(1 for e, p in pairs if e == p), len(pairs)
def sign_agreement(exp: Sequence[int], pred: Sequence[int]) -> Tuple[int, int]:
"""Among rows where BOTH sides lean one way: same sign."""
pairs = [(e, p) for e, p in zip(exp, pred) if e != 0 and p != 0]
return sum(1 for e, p in pairs if e == p), len(pairs)
def spearman(a: Sequence[float], b: Sequence[float]) -> float:
import numpy as np
import pandas as pd
s = pd.DataFrame({"a": list(a), "b": list(b)}).dropna()
if len(s) < 3:
return float("nan")
ra, rb = s["a"].rank(), s["b"].rank()
if ra.std() == 0 or rb.std() == 0:
return float("nan")
return float(np.corrcoef(ra, rb)[0, 1])
def mae(a: Sequence[float], b: Sequence[float]) -> float:
pairs = [(x, y) for x, y in zip(a, b) if x is not None and y is not None]
return sum(abs(x - y) for x, y in pairs) / len(pairs) if pairs else float("nan")
def mcnemar(hit_a: Sequence[bool], hit_b: Sequence[bool]) -> Tuple[int, int, float]:
"""Exact two-sided McNemar on the discordant pairs: b = A right and
B wrong, c = A wrong and B right."""
b = sum(1 for x, y in zip(hit_a, hit_b) if x and not y)
c = sum(1 for x, y in zip(hit_a, hit_b) if not x and y)
n = b + c
if n == 0:
return b, c, 1.0
k = min(b, c)
p = 2.0 * sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n
return b, c, min(1.0, p)
def print_confusion(cm: List[List[int]], title: str) -> None:
print(f"\n{title} (rows = expected, cols = predicted)")
print(" " + "".join(f"{CLASS_NAMES[c]:>9}" for c in CLASSES))
for c, row in zip(CLASSES, cm):
print(f" {CLASS_NAMES[c]:<9} " + "".join(f"{v:>9}" for v in row))
def agreement_block(exp_cls: Sequence[int], pred_cls: Sequence[int],
exp_score: Optional[Sequence[float]] = None,
pred_score: Optional[Sequence[float]] = None) -> Dict[str, Any]:
"""Everything one comparison needs, as a dict (JSON-friendly)."""
n = len(exp_cls)
hits = sum(1 for e, p in zip(exp_cls, pred_cls) if e == p)
d_hit, d_n = directional_accuracy(exp_cls, pred_cls)
s_hit, s_n = sign_agreement(exp_cls, pred_cls)
out: Dict[str, Any] = {
"n": n,
"accuracy": hits / n if n else None,
"hits": hits,
"macro_f1": macro_f1(exp_cls, pred_cls) if n else None,
"directional_hits": d_hit, "directional_n": d_n,
"directional_accuracy": d_hit / d_n if d_n else None,
"sign_hits": s_hit, "sign_n": s_n,
"sign_agreement": s_hit / s_n if s_n else None,
"confusion": confusion(exp_cls, pred_cls) if n else None,
}
if exp_score is not None and pred_score is not None:
out["spearman"] = spearman(exp_score, pred_score)
out["mae"] = mae(exp_score, pred_score)
return out
def print_agreement(label: str, blk: Dict[str, Any]) -> None:
if not blk["n"]:
print(f" {label:<44} n=0")
return
extra = ""
if "spearman" in blk and blk["spearman"] == blk["spearman"]:
extra = f" rho {blk['spearman']:+.2f} MAE {blk['mae']:.2f}"
print(f" {label:<44} n={blk['n']:<4} 3-class {100 * blk['accuracy']:5.1f}% "
f"macro-F1 {blk['macro_f1']:.3f} dir {fmt_pct(blk['directional_hits'], blk['directional_n']):<18} "
f"sign {fmt_pct(blk['sign_hits'], blk['sign_n']):<18}{extra}")
# ── golden set ───────────────────────────────────────────────────────
def load_golden() -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
with GOLDEN_CSV.open(encoding="utf-8") as fh:
for r in csv.DictReader(fh):
r["expected_xau_sign"] = int(r["expected_xau_sign"])
rows.append(r)
need = {"headline", "category", "expected_xau_sign", "rationale"}
if rows and not need <= set(rows[0]):
raise SystemExit(f"golden CSV missing columns {need - set(rows[0])}")
if any(r["expected_xau_sign"] not in (-1, 0, 1) for r in rows):
raise SystemExit("expected_xau_sign must be -1/0/+1")
if len({r["headline"] for r in rows}) != len(rows):
raise SystemExit("duplicate headlines in golden CSV")
return rows
# ── archive corpus ───────────────────────────────────────────────────
def _finite(value: Any) -> Optional[float]:
try:
value = float(value) if value is not None else None
except (TypeError, ValueError):
return None
return value if value is not None and math.isfinite(value) else None
def load_extra_labels(path: Optional[Path]) -> Dict[str, float]:
"""hash -> xau from a JSONL such as results/backfill_labels.jsonl."""
if path is None:
return {}
out: Dict[str, float] = {}
with path.open(encoding="utf-8") as fh:
for line in fh:
if line.strip():
rec = json.loads(line)
xau = _finite(rec.get("xau"))
if rec.get("hash") and xau is not None:
out[rec["hash"]] = xau
return out
def load_archive_rows(db_path: Path = DEFAULT_DB,
extra_labels: Optional[Path] = None) -> List[Dict[str, Any]]:
"""Every row of the headlines table, read-only, with DeepSeek's XAU
score as `xau` (None when absent). Same priority as the trainer:
the xau_score column (every LLM row since 2026-09-18), then the XAU
key of score_by_currency_json, then `extra_labels`. `xau_src` says
which; `gold` is True when the JSON carries the XAU key (the rows
the pre-2026-09-22 trainer used)."""
if not db_path.is_file():
raise SystemExit(f"archive not found: {db_path}")
extra = load_extra_labels(extra_labels)
uri = f"{db_path.resolve().as_uri()}?mode=ro"
out: List[Dict[str, Any]] = []
with sqlite3.connect(uri, uri=True) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(headlines)")}
xau_col = "xau_score" if "xau_score" in cols else "NULL"
q = ("SELECT hash, headline, description, publisher, published_at, "
"first_seen, score, score_by_currency_json, currencies_json, "
f"score_source, teacher_model, teacher_prompt_sha1, {xau_col} "
"FROM headlines")
for (h, head, desc, pub, pat, fs, score, sbc, cur, src, tm,
tp, xau_score) in conn.execute(q):
by_cur: Dict[str, float] = {}
try:
by_cur = json.loads(sbc) if sbc else {}
if not isinstance(by_cur, dict):
by_cur = {}
except ValueError:
by_cur = {}
xau, xau_src = _finite(xau_score), "xau_score"
if xau is None:
xau, xau_src = _finite(by_cur.get("XAU")), "json"
if xau is None and h in extra:
xau, xau_src = extra[h], "extra_labels"
if xau is None:
xau_src = None
try:
currencies = json.loads(cur) if cur else []
except ValueError:
currencies = []
out.append({
"hash": h, "headline": head, "description": desc,
"publisher": pub, "published_at": pat, "first_seen": fs,
"day": str(pat or fs or "")[:10],
"score": score, "by_currency": by_cur, "xau": xau,
"xau_src": xau_src, "gold": "XAU" in by_cur,
"currencies": currencies, "score_source": src,
"teacher_model": tm, "teacher_prompt_sha1": tp,
})
return out
# ── scorers ──────────────────────────────────────────────────────────
class ModelScorer:
"""The scikit-learn bundle. `score()` is one headline at a time so
the measured latency is the live per-article cost."""
def __init__(self, model_path: Path = MODEL_PATH):
from news_sentiment import load_model, score_news # noqa: WPS433
self.bundle = load_model(model_path)
self._score_news = score_news
tr = self.bundle.get("training", {})
self.meta = {
"model_path": str(model_path),
"trained_at": self.bundle.get("trained_at"),
"sklearn_version": self.bundle.get("sklearn_version"),
"model_type": self.bundle.get("model_type"),
"training_examples": tr.get("examples"),
"label_distribution": tr.get("label_distribution"),
"neutral_threshold": tr.get("neutral_threshold"),
# rows published after this were never trained on
"oos_from": self.bundle.get("trained_at"),
}
self.total_ms = 0.0
self.calls = 0
def score(self, headline: str, description: Optional[str] = None) -> Dict[str, Any]:
t0 = time.perf_counter()
r = self._score_news(self.bundle, headline, description)
ms = (time.perf_counter() - t0) * 1000.0
self.total_ms += ms
self.calls += 1
probs = r["probabilities"]
ordered = sorted(probs.values(), reverse=True)
return {
"label": r["label"],
"cls": NAME_TO_CLASS[r["label"]],
"score": float(r["score"]),
"confidence": float(r["confidence"]),
"margin": float(ordered[0] - ordered[1]),
"p_bearish": probs["bearish"], "p_neutral": probs["neutral"],
"p_bullish": probs["bullish"],
"ms": ms,
}
@property
def mean_ms(self) -> float:
return self.total_ms / self.calls if self.calls else float("nan")
class TransformerScorer:
"""The two-head ONNX model (news_transformer.py), same interface as
ModelScorer plus p_relevant / p_bullish / relevant. Headline only:
`description` is accepted and ignored."""
def __init__(self, model_dir: Path = TRANSFORMER_DIR, threads: Optional[int] = None):
from news_transformer import load_model, score_news # noqa: WPS433
self.bundle = load_model(model_dir, threads)
self._score_news = score_news
meta = self.bundle["meta"]
data = meta.get("data", {})
n_train = sum(data.get(k, {}).get("n", 0)
for k in (("train", "dev", "val") if meta.get("final") else ("train",)))
self.meta = {
"model_path": str(model_dir),
"trained_at": meta.get("trained_at"),
"model_type": f"two_head_transformer:{meta.get('base_model')}",
"training_examples": n_train,
"relevance_threshold": meta.get("relevance_threshold"),
"final": meta.get("final"),
# a non-final model never saw newsdata rows from val_from on
"oos_from": (meta.get("trained_at") if meta.get("final")
else meta.get("hyperparameters", {}).get("val_from")),
}
self.total_ms = 0.0
self.calls = 0
def score(self, headline: str, description: Optional[str] = None) -> Dict[str, Any]:
t0 = time.perf_counter()
r = self._score_news(self.bundle, headline)
ms = (time.perf_counter() - t0) * 1000.0
self.total_ms += ms
self.calls += 1
pr, pb = r["p_relevant"], r["p_bullish"]
return {
"label": r["label"], "cls": NAME_TO_CLASS[r["label"]],
"score": float(r["score"]), "confidence": float(r["confidence"]),
"margin": float(abs(2 * pb - 1) if r["relevant"] else abs(2 * pr - 1)),
"p_relevant": pr, "p_bullish": pb, "relevant": r["relevant"],
"ms": ms,
}
@property
def mean_ms(self) -> float:
return self.total_ms / self.calls if self.calls else float("nan")
def make_scorer(kind: str, path: Optional[Path] = None):
"""`--scorer tfidf|transformer` with an optional `--model` path."""
if kind == "transformer":
return TransformerScorer(path or TRANSFORMER_DIR)
return ModelScorer(path or MODEL_PATH)
class KeywordScorer:
"""ForexBot2's keyword scorer with the XAU policy inversion — the
score the live system shows when DeepSeek is unavailable."""
def __init__(self):
from config import NewsConfig
from news_engine import NewsEngine
self._engine = NewsEngine(NewsConfig())
def score(self, headline: str) -> float:
return float(self._engine._analyze_headline_sentiment(
headline, for_currency="XAU"))
def currencies(self, headline: str) -> List[str]:
return list(self._engine._extract_currencies(headline))
def install_research_client(timeout: float, daily_cap: Optional[int] = None):
"""Replace the process-wide DeepSeek singleton with one that uses a
longer timeout (cold prompt-cache batches ran past the live 12 s in
the 2026-08-06 harness). Same key and model as live; the daily cap
is live's unless `daily_cap` is given. The cap is counted in this
process only, so it never consumes the live bot's allowance."""
import app_config
import llm_client
settings = app_config.get_app_config().deepseek_settings
client = llm_client.DeepSeekClient(
api_key=app_config.deepseek_api_key(),
model=str(settings.get("model", llm_client.DEFAULT_MODEL)),
timeout=timeout,
daily_cap=daily_cap or int(settings.get("daily_request_cap",
llm_client.DEFAULT_DAILY_CAP)),
enabled=True,
)
with llm_client._client_lock:
llm_client._client = client
return client
class DeepSeekScorer:
"""The live DeepSeek headline path with a disk cache.
Each uncached batch of <= 25 headlines is ONE real call through
NewsEngine._llm_headline_scores (real prompt constant, real JSON
contract, real clamping). A failed batch is retried `retries`
times; headlines still unscored come back as None.
"""
def __init__(self, cache_path: Path = DEEPSEEK_CACHE, timeout: float = 30.0,
retries: int = 3, no_network: bool = False,
daily_cap: Optional[int] = None):
from config import NewsConfig
import news_engine
self._engine = news_engine.NewsEngine(NewsConfig())
self.prompt_sha1 = news_engine._LLM_SENTIMENT_PROMPT_SHA1
self._client = install_research_client(timeout, daily_cap)
self.model = getattr(self._client, "_model", "?")
self.no_network = no_network
self.retries = retries
self.cache_path = cache_path
self._cache: Dict[str, Dict[str, Any]] = {}
if cache_path.is_file():
try:
self._cache = json.loads(cache_path.read_text(encoding="utf-8"))
except ValueError:
self._cache = {}
self.calls = 0
self.failed_calls = 0
self.headlines_sent = 0
self.cache_hits = 0
self.batch_ms: List[float] = []
@property
def available(self) -> bool:
return bool(getattr(self._client, "available", False)) and not self.no_network
def key(self, headline: str) -> str:
return (hashlib.sha1(headline.encode("utf-8")).hexdigest()
+ ":" + self.prompt_sha1[:8] + ":" + self.model)
def _save(self) -> None:
"""Merge-on-save: re-read the file first so two scripts running
at once (each writes after every batch) never drop each other's
entries. Same key = same answer, so last-writer-wins is fine."""
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
merged: Dict[str, Dict[str, Any]] = {}
if self.cache_path.is_file():
try:
merged = json.loads(self.cache_path.read_text(encoding="utf-8"))
except ValueError:
merged = {}
merged.update(self._cache)
self._cache = merged
tmp = self.cache_path.with_suffix(f".{os.getpid()}.tmp")
tmp.write_text(json.dumps(merged, indent=0, sort_keys=True),
encoding="utf-8")
os.replace(tmp, self.cache_path)
def _call(self, texts: List[str]) -> Optional[List[Tuple[float, float]]]:
for attempt in range(self.retries):
t0 = time.perf_counter()
scores = self._engine._llm_headline_scores(texts)
ms = (time.perf_counter() - t0) * 1000.0
self.calls += 1
if scores is not None:
self.batch_ms.append(ms)
self.headlines_sent += len(texts)
return scores
self.failed_calls += 1
err = getattr(self._client, "last_error", None)
print(f" DeepSeek batch of {len(texts)} failed (attempt "
f"{attempt + 1}/{self.retries}, {err}) — retrying")
time.sleep(2.0)
return None
def score_many(self, headlines: Sequence[str], force: bool = False
) -> List[Optional[Dict[str, Any]]]:
"""Aligned list of {"s", "xau", "cached", "batch_ms"} or None."""
out: List[Optional[Dict[str, Any]]] = [None] * len(headlines)
todo: List[int] = []
seen: Dict[str, int] = {}
for i, h in enumerate(headlines):
k = self.key(h)
if not force and k in self._cache:
c = self._cache[k]
out[i] = {"s": c["s"], "xau": c["xau"], "cached": True,
"batch_ms": c.get("batch_ms"), "ts": c.get("ts")}
self.cache_hits += 1
elif h in seen: # duplicate text inside this request
todo.append(i)
else:
seen[h] = i
todo.append(i)
uniq = []
for i in todo:
if headlines[i] not in [headlines[j] for j in uniq]:
uniq.append(i)
if uniq and not self.available:
why = "no network (--no-network)" if self.no_network else \
"DeepSeek client unavailable (no key / disabled)"
print(f" {len(uniq)} headline(s) not in cache and {why}; "
"they stay unscored")
return out
for start in range(0, len(uniq), BATCH_CAP):
idxs = uniq[start:start + BATCH_CAP]
texts = [headlines[i] for i in idxs]
scores = self._call(texts)
if scores is None:
continue
ms = self.batch_ms[-1]
now = utc_now_iso()
for i, (s, x) in zip(idxs, scores):
rec = {"headline": headlines[i], "s": float(s), "xau": float(x),
"ts": now, "batch_ms": ms, "batch_size": len(texts),
"model": self.model, "prompt_sha1": self.prompt_sha1}
self._cache[self.key(headlines[i])] = rec
self._save()
for i in todo:
if out[i] is None:
c = self._cache.get(self.key(headlines[i]))
if c is not None:
out[i] = {"s": c["s"], "xau": c["xau"], "cached": False,
"batch_ms": c.get("batch_ms"), "ts": c.get("ts")}
return out
def stats(self) -> Dict[str, Any]:
import statistics
return {
"model": self.model,
"prompt_sha1": self.prompt_sha1,
"calls": self.calls,
"failed_calls": self.failed_calls,
"headlines_sent": self.headlines_sent,
"cache_hits": self.cache_hits,
"batch_ms_mean": statistics.fmean(self.batch_ms) if self.batch_ms else None,
"batch_ms_max": max(self.batch_ms) if self.batch_ms else None,
"per_headline_ms": (sum(self.batch_ms) / self.headlines_sent
if self.headlines_sent else None),
}