Files
ForexBot2/news_archive.py
T
Harwinder Sekhon 0c56b4e155 Local news model: ML-NEWS XAU transformer scores headlines (news_model.py, mode primary)
The owner's DeBERTa-v3-small two-head ONNX model (golden set 72/78, ~72%
agreement with DeepSeek on unseen days) replaces DeepSeek for headline
scoring. DeepSeek keeps event direction, the summary and the dashboard
endpoint.

- news_model.py: vendored ML-NEWS runtime (lazy onnxruntime/tokenizers,
  so the bot starts without them) + load-once, never-raising singleton.
- news_engine: _enrich_headline_scores mode switch (off/fallback/primary),
  _apply_model_sentiment (gold head only: xau_score + XAU entry, never
  USD; not-relevant shows 0.0; never touches _llm_sent_cache),
  _audit_with_deepseek (archive-only labels, deepseek_audit_per_day).
- Owner decision: primary mode writes nothing to logs/newsdata.db except
  audit copies — the model's output is not training data.
- app_config: news_model section (path/threads/mode/deepseek_audit_per_day)
  + NEWS_MODEL_MODE env override; validated at boot.
- news_archive: model rows carry teacher_model xau_transformer@<trained_at>.
- deploy/README-news-model.md (ops for bot + dashboard daemon), unit gets a
  commented NEWS_MODEL_MODE=off line; requirements + onnxruntime, tokenizers.
- test_all.py Module 38 (15 tests, one against the real bundle). Suite 581.
- CLAUDE.md, spec status note, handoff marked implemented.

Books unaffected: news never enters signals; no signal file touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 21:02:42 -04:00

405 lines
17 KiB
Python

"""
ForexBot2 — News archive (durable headline corpus)
==================================================
Every headline the news engine fetches is written here, exactly as it
arrived, together with the score that was attached to it and **which
scorer produced that score**. One row per distinct headline, kept
forever.
Why this exists
---------------
Headlines used to survive only as a side effect of signal logging, inside
``logs/signals.db``. That database was rotated/rebuilt around 2026-09-04
and three weeks of news — 4,054 distinct headlines, the corpus the
``news_sentiment_value_2026-09-04.md`` study was written on — was lost.
``logs/`` is gitignored, so git could not bring it back and nobody
noticed until 2026-09-08.
This module writes to its own database (``logs/newsdata.db`` by default)
so signal logging can be wiped, rotated or rebuilt without touching the
news corpus.
What the corpus is for
----------------------
Training a small local scorer that reproduces DeepSeek's headline
labelling without calling it. On the 2026-08-06 golden set DeepSeek
scored 78/78 and the built-in keyword scorer it overrides scored 24/78
(31%) — and the keyword score is what stands whenever DeepSeek is
unavailable (no key, disabled, daily cap, timeout, HTTP/parse failure,
or the >25-new-headlines-per-cycle batch overflow). ``score_source``
records which one produced each row; training filters on ``'llm'``.
Contract
--------
* **Never raises.** ``archive_headlines`` swallows every exception. A
logging failure must not break a scan — the same contract
``signal_log.log_signal`` holds.
* **Upgrade-only scores.** A row already marked ``llm`` is never
overwritten by a later ``keyword`` score. A headline first seen under
the batch-overflow cap is keyword-scored, then gets its DeepSeek score
on a later fetch window; the better label wins and is never demoted.
* **Dedup by headline text.** ``hash`` = sha256 of the headline. A
headline seen 50 times is one row with ``last_seen`` bumped.
* **stdlib only** (``sqlite3``), like ``signal_log``.
Two columns exist for training only (2026-09-17):
* ``xau_score`` — DeepSeek's gold-specific score for the headline,
whatever currencies it names. ``score_by_currency_json["XAU"]``
carries it only for gold-keyword headlines; 930 of the first 1,546
DeepSeek rows had no gold label without this column. NULL for a
keyword-scored row and for rows written before the column existed
(an existing database is migrated in place, values are not
recoverable).
* ``currencies_json`` ``[]`` marks a headline naming no tracked currency.
The news engine used to drop those before scoring; now it scores and
archives them but never returns them to the platform. About 23% of a
day's feed; DeepSeek gave 10 of 24 a gold direction.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, Optional
import app_config
logger = logging.getLogger(__name__)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS headlines (
hash TEXT PRIMARY KEY,
headline TEXT NOT NULL,
description TEXT,
link TEXT,
source TEXT,
publisher TEXT,
published_at TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
seen_count INTEGER NOT NULL DEFAULT 1,
score REAL,
score_by_currency_json TEXT,
currencies_json TEXT,
keywords_json TEXT,
score_source TEXT NOT NULL DEFAULT 'keyword',
teacher_model TEXT,
teacher_prompt_sha1 TEXT,
origin TEXT NOT NULL DEFAULT 'live',
xau_score REAL
);
CREATE INDEX IF NOT EXISTS idx_headlines_source ON headlines (score_source);
CREATE INDEX IF NOT EXISTS idx_headlines_first_seen ON headlines (first_seen);
"""
#: Rank of a score's provenance. A write only lands when its rank is >=
#: the rank already stored, so a keyword score can never overwrite an LLM
#: one (the batch-overflow case) but an LLM score always upgrades.
_SOURCE_RANK = {"unknown": 0, "keyword": 1, "model": 2, "llm": 3}
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def headline_hash(headline: str) -> str:
"""Primary key for a headline: sha256 of its exact text."""
return hashlib.sha256(headline.encode("utf-8")).hexdigest()
def _json_or_none(value: Any) -> Optional[str]:
if value is None:
return None
try:
return json.dumps(value, default=str)
except Exception:
return None
def _iso_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _finite_score(value: Any) -> Optional[float]:
try:
out = float(value)
except (TypeError, ValueError):
return None
if out != out or out in (float("inf"), float("-inf")):
return None
return out
class NewsArchive:
"""Thread-safe SQLite writer for the headline corpus. One instance
per database path; safe to share across the bot's executor threads
and the dashboard daemon's pool."""
def __init__(self, path: str):
parent = os.path.dirname(path)
if parent:
os.makedirs(parent, exist_ok=True)
self._conn = sqlite3.connect(path, check_same_thread=False)
# busy_timeout BEFORE the journal-mode switch: the bot, the
# dashboard publisher and the tests open this file at once and
# the WAL switch needs a brief exclusive lock.
self._conn.execute("PRAGMA busy_timeout=5000")
self._lock = threading.Lock()
with self._lock:
# busy_timeout does not cover the WAL switch, which needs a
# brief exclusive lock: three processes creating this file at
# once will race it. Same retry signal_log uses.
self._with_lock_retry(
lambda: self._conn.execute("PRAGMA journal_mode=WAL"))
self._with_lock_retry(lambda: self._conn.executescript(_SCHEMA))
self._with_lock_retry(self._migrate)
self._conn.commit()
#: Columns added after the first databases were created, in order.
#: CREATE TABLE IF NOT EXISTS leaves an existing table untouched, so
#: each one is added in place on open; existing rows read NULL.
_ADDED_COLUMNS = (("xau_score", "REAL"),)
def _migrate(self) -> None:
present = {row[1] for row in
self._conn.execute("PRAGMA table_info(headlines)")}
for name, decl in self._ADDED_COLUMNS:
if name not in present:
self._conn.execute(
f"ALTER TABLE headlines ADD COLUMN {name} {decl}")
@staticmethod
def _with_lock_retry(fn, attempts: int = 8, delay: float = 0.05):
"""Run ``fn`` again on 'database is locked' (concurrent openers
racing the same schema step); other errors propagate at once."""
for attempt in range(attempts):
try:
return fn()
except sqlite3.OperationalError as exc:
if "locked" not in str(exc).lower() or attempt == attempts - 1:
raise
time.sleep(delay * (attempt + 1))
def close(self) -> None:
with self._lock:
try:
self._conn.close()
except Exception:
pass
#: Rank of the score already stored, computed in SQL so the compare
#: happens inside the same atomic statement as the write.
_STORED_RANK = ("CASE headlines.score_source WHEN 'llm' THEN 3"
" WHEN 'model' THEN 2 WHEN 'keyword' THEN 1 ELSE 0 END")
def store(self, rows: Iterable[Dict[str, Any]]) -> int:
"""Insert-or-upgrade a batch of prepared rows. Returns the number
of rows written. Raises on a database failure — the module-level
wrapper is what guarantees a scan cannot break.
One atomic UPSERT per row. A read-then-write pair would lose an
increment whenever two processes (bot, dashboard publisher, tests)
first see the same headline at the same moment: both would find it
absent, one INSERT would win and the other's count would vanish.
"""
rows = list(rows)
if not rows:
return 0
now = _utc_now_iso()
wins = f"? >= {self._STORED_RANK}" # incoming rank vs stored rank
sql = (
"INSERT INTO headlines ("
" hash, headline, description, link, source, publisher,"
" published_at, first_seen, last_seen, seen_count,"
" score, score_by_currency_json, currencies_json,"
" keywords_json, score_source, teacher_model,"
" teacher_prompt_sha1, origin, xau_score"
") VALUES (?,?,?,?,?,?,?,?,?,1,?,?,?,?,?,?,?,?,?)"
" ON CONFLICT(hash) DO UPDATE SET"
# The backfill stamps historical timestamps, so clamp rather
# than assign: first_seen only ever moves earlier, last_seen
# only ever moves later.
" first_seen = MIN(headlines.first_seen, excluded.first_seen),"
" last_seen = MAX(headlines.last_seen, excluded.last_seen),"
" seen_count = headlines.seen_count + 1,"
# Upgrade-only: a weaker scorer never overwrites a stronger
# one's label, but re-seeing a headline always counts.
f" score = CASE WHEN {wins} THEN excluded.score"
" ELSE headlines.score END,"
f" score_by_currency_json = CASE WHEN {wins}"
" THEN excluded.score_by_currency_json"
" ELSE headlines.score_by_currency_json END,"
f" currencies_json = CASE WHEN {wins} THEN excluded.currencies_json"
" ELSE headlines.currencies_json END,"
f" keywords_json = CASE WHEN {wins} THEN excluded.keywords_json"
" ELSE headlines.keywords_json END,"
# COALESCE, not a bare overwrite: the backfill re-ingests
# llm-scored headlines with NO teacher identity (neither source
# DB recorded one), and rank >= rank is true, so a plain
# assignment blanks what the live hook already stored.
f" teacher_model = CASE WHEN {wins}"
" THEN COALESCE(excluded.teacher_model,"
" headlines.teacher_model)"
" ELSE headlines.teacher_model END,"
f" teacher_prompt_sha1 = CASE WHEN {wins}"
" THEN COALESCE(excluded.teacher_prompt_sha1,"
" headlines.teacher_prompt_sha1)"
" ELSE headlines.teacher_prompt_sha1 END,"
f" score_source = CASE WHEN {wins} THEN excluded.score_source"
" ELSE headlines.score_source END,"
# Same upgrade-only rule as score, and COALESCE on top: a
# keyword re-sighting carries no gold score and loses on
# rank, and even a same-rank write without one (a future
# backfill shape) must never blank a stored value.
f" xau_score = CASE WHEN {wins}"
" THEN COALESCE(excluded.xau_score, headlines.xau_score)"
" ELSE headlines.xau_score END,"
# Enrichment fills gaps but never blanks what is already there
# (the legacy backfill shape carries no description or link).
" description = COALESCE(excluded.description, headlines.description),"
" link = COALESCE(excluded.link, headlines.link),"
" publisher = COALESCE(excluded.publisher, headlines.publisher),"
" published_at = COALESCE(excluded.published_at,"
" headlines.published_at)")
# The rank is bound once per CASE arm. Positional binding means a
# miscount silently shifts every parameter, so assert it instead of
# trusting the arithmetic: 18 INSERT values + one rank per arm.
n_rank = sql.count("? >= ")
n_values = 18
assert sql.count("?") == n_values + n_rank, (
f"placeholder count drifted: {sql.count('?')} "
f"!= {n_values} + {n_rank}")
params = []
for row in rows:
rank = _SOURCE_RANK.get(row["score_source"], 0)
params.append((
row["hash"], row["headline"], row["description"], row["link"],
row["source"], row["publisher"], row["published_at"],
row.get("first_seen") or now, row.get("last_seen") or now,
row["score"], row["score_by_currency_json"],
row["currencies_json"], row["keywords_json"],
row["score_source"], row["teacher_model"],
row["teacher_prompt_sha1"], row.get("origin", "live"),
row.get("xau_score"),
) + (rank,) * n_rank)
def _write() -> None:
# BEGIN IMMEDIATE takes the write lock up front, so contention
# fails here — before any row is applied — and the rollback
# makes the batch atomic if it fails later. Without this a
# partial executemany would be re-applied by the retry and
# double-count seen_count on the rows that already landed.
self._conn.execute("BEGIN IMMEDIATE")
try:
self._conn.executemany(sql, params)
self._conn.commit()
except Exception:
try:
self._conn.rollback()
except Exception:
pass
raise
with self._lock:
self._with_lock_retry(_write)
return len(rows)
def build_row(headline: Any, *, teacher_model: Optional[str] = None,
teacher_prompt_sha1: Optional[str] = None,
origin: str = "live",
first_seen: Optional[str] = None,
last_seen: Optional[str] = None,
model_teacher: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Flatten one ``NewsSentiment`` (or an equivalent object) into a row.
Provenance: an ``llm`` row stores ``teacher_model`` (the DeepSeek
model name); a ``model`` row stores ``model_teacher`` (the local
scorer's tag, ``xau_transformer@<trained_at>``, news_model.teacher_tag).
Both carry ``teacher_prompt_sha1``: for a model row it is the DeepSeek
prompt the model was distilled from, which is the truthful lineage.
Keyword rows carry neither.
Returns None when the object carries no usable headline text."""
text = getattr(headline, "headline", None)
if not isinstance(text, str) or not text.strip():
return None
source = getattr(headline, "score_source", None) or "keyword"
return {
"hash": headline_hash(text),
"headline": text,
"description": getattr(headline, "description", None),
"link": getattr(headline, "link", None),
"source": getattr(headline, "source", None),
"publisher": getattr(headline, "publisher", None),
"published_at": _iso_or_none(getattr(headline, "timestamp", None)),
"first_seen": first_seen,
"last_seen": last_seen,
"score": _finite_score(getattr(headline, "sentiment_score", None)),
"score_by_currency_json": _json_or_none(
getattr(headline, "sentiment_by_currency", None)),
"currencies_json": _json_or_none(
getattr(headline, "currencies_mentioned", None)),
"keywords_json": _json_or_none(getattr(headline, "keywords", None)),
"score_source": source,
"teacher_model": (teacher_model if source == "llm"
else model_teacher if source == "model" else None),
"teacher_prompt_sha1": (
teacher_prompt_sha1 if source in ("llm", "model") else None),
"origin": origin,
"xau_score": _finite_score(getattr(headline, "xau_score", None)),
}
#: path -> NewsArchive
_archives: Dict[str, NewsArchive] = {}
_archives_lock = threading.Lock()
def archive_headlines(headlines: Any, *, teacher_model: Optional[str] = None,
teacher_prompt_sha1: Optional[str] = None,
origin: str = "live",
model_teacher: Optional[str] = None) -> None:
"""Persist a batch of scored headlines if the news_archive config
block enables it. NEVER raises — a logging failure must not break a
scan."""
try:
if not headlines:
return
if not app_config.news_archive_enabled():
return
rows = []
for headline in headlines:
row = build_row(headline, teacher_model=teacher_model,
teacher_prompt_sha1=teacher_prompt_sha1,
origin=origin, model_teacher=model_teacher)
if row is not None:
rows.append(row)
if not rows:
return
path = app_config.news_archive_path()
with _archives_lock:
archive = _archives.get(path)
if archive is None:
archive = NewsArchive(path)
_archives[path] = archive
archive.store(rows)
except Exception:
logger.warning("news_archive: failed to persist headlines",
exc_info=True)