Files
ForexBot2/news_model.py
Harwinder Sekhon 42025da0b0 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.
2026-09-27 21:02:42 -04:00

204 lines
7.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Local XAU headline scorer: the owner's ML-NEWS transformer on CPU.
Vendored from ML-NEWS news_transformer.py at commit afa26dc; bundle
trained_at 2026-09-27T23:15:30.403442+00:00. Keep the scoring functions
identical to that file (the one difference: ``load_model`` has no default
directory here, the path always comes from config.json); ML-NEWS trains
and exports, ForexBot2 only scores.
A fine-tuned DeBERTa-v3-small encoder with two heads, exported to ONNX
with an int8 embedding table. Scoring needs only onnxruntime, tokenizers
and numpy. For each headline:
- ``p_relevant``: probability the news moves the gold price (trained on
DeepSeek |xau| >= 0.25 vs <= 0.10)
- ``p_bullish``: probability that, if it does, gold goes up
- ``relevant``: ``p_relevant >= relevance_threshold`` (from meta.json)
- ``label``: ``bullish`` / ``bearish``, or ``neutral`` when not relevant
- ``score``: ``p_relevant * (2 * p_bullish - 1)`` in [-1, 1], the same
scale as DeepSeek's gold score the archive stores in ``xau_score``
The model has a GOLD head only. news_engine writes its score into
``xau_score`` and the XAU per-currency entry, never into USD or any other
currency (get_pair_sentiment subtracts USD from XAU).
Import contract: ``onnxruntime`` and ``tokenizers`` are imported INSIDE
``load_model`` so ``import news_model`` (and therefore ``import
news_engine``) succeeds on a box without them; ``get_news_model`` then
returns None once, with one warning, and the DeepSeek path stays.
Configuration comes from app_config (``news_model`` section, env
``NEWS_MODEL_MODE``): ``news_model_path()``, ``news_model_threads()``.
"""
from __future__ import annotations
import html
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any, Iterable, Optional
import numpy as np
logger = logging.getLogger(__name__)
MAX_LENGTH = 64
#: DeepSeek |xau| at or above this is "moves gold"; at or below
#: RELEVANT_MAX_IRRELEVANT it is "does not"; the band between carries no
#: relevance label in training.
RELEVANT_MIN = 0.25
RELEVANT_MAX_IRRELEVANT = 0.10
def clean_headline(headline: str) -> str:
"""The exact text normalisation used in training and scoring."""
text = " ".join(html.unescape(str(headline or "")).split())
if not text:
raise ValueError("headline is empty")
return text
def _sigmoid(x: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-x))
def load_model(model_dir: str | Path,
threads: int | None = None) -> dict[str, Any]:
"""Load the ONNX model, tokenizer and metadata. Only load trusted files."""
import onnxruntime as ort
from tokenizers import Tokenizer
model_dir = Path(model_dir)
meta_path = model_dir / "meta.json"
if not meta_path.is_file():
raise FileNotFoundError(f"model not found: {meta_path}")
meta = json.loads(meta_path.read_text(encoding="utf-8"))
tokenizer = Tokenizer.from_file(str(model_dir / "tokenizer.json"))
tokenizer.enable_truncation(max_length=int(meta.get("max_length", MAX_LENGTH)))
tokenizer.enable_padding(pad_id=int(meta["pad_token_id"]), pad_token=meta["pad_token"])
options = ort.SessionOptions()
if threads:
options.intra_op_num_threads = max(1, min(int(threads), os.cpu_count() or 1))
options.inter_op_num_threads = 1
# ORT intra-op threads spin-wait after each run by default; in a mostly
# idle bot process that is 4 cores at 100% between cycles.
options.add_session_config_entry("session.intra_op.allow_spinning", "0")
session = ort.InferenceSession(str(model_dir / meta.get("onnx_file", "model.onnx")),
options, providers=["CPUExecutionProvider"])
return {"meta": meta, "tokenizer": tokenizer, "session": session}
def score_batch(bundle: dict[str, Any], headlines: Iterable[str]) -> list[dict[str, Any]]:
texts = [clean_headline(h) for h in headlines]
if not texts:
return []
encodings = bundle["tokenizer"].encode_batch(texts)
ids = np.array([e.ids for e in encodings], dtype=np.int64)
mask = np.array([e.attention_mask for e in encodings], dtype=np.int64)
rel_logit, dir_logit = bundle["session"].run(
["relevance_logit", "direction_logit"],
{"input_ids": ids, "attention_mask": mask})
p_rel = _sigmoid(rel_logit.astype(np.float64))
p_bull = _sigmoid(dir_logit.astype(np.float64))
threshold = float(bundle["meta"].get("relevance_threshold", 0.5))
out = []
for text, pr, pb in zip(texts, p_rel, p_bull):
relevant = bool(pr >= threshold)
direction = "bullish" if pb >= 0.5 else "bearish"
label = direction if relevant else "neutral"
confidence = pr * max(pb, 1 - pb) if relevant else 1 - pr
out.append({
"target": "XAUUSD",
"text": text,
"relevant": relevant,
"p_relevant": round(float(pr), 4),
"direction": direction,
"p_bullish": round(float(pb), 4),
"label": label,
"score": round(float(pr * (2 * pb - 1)), 4),
"confidence": round(float(confidence), 4),
})
return out
def score_news(bundle: dict[str, Any], headline: str) -> dict[str, Any]:
return score_batch(bundle, [headline])[0]
# ── Process-wide singleton (ForexBot2 addition, not in ML-NEWS) ──────────
#
# One ONNX session per process (~0.6 GB RSS, 3–5 s to create). A failed
# load is remembered: the bot logs one warning and keeps the DeepSeek /
# keyword path for the rest of its life instead of retrying every cycle.
_lock = threading.Lock()
_bundle: Optional[dict] = None
_failed: bool = False # tried and failed; don't retry, don't re-log
_warm_started: bool = False
def get_news_model() -> Optional[dict]:
"""The loaded bundle, or None. Loads once per process. Never raises."""
global _bundle, _failed
if _bundle is not None or _failed:
return _bundle
with _lock:
if _bundle is not None or _failed:
return _bundle
try:
import app_config
path = app_config.news_model_path()
if not path:
raise FileNotFoundError("news_model.path is not configured")
threads = app_config.news_model_threads()
b = load_model(path, threads=threads)
logger.info(
"news model loaded from %s (trained_at %s, threshold %s, "
"%d threads)", path, b["meta"].get("trained_at"),
b["meta"].get("relevance_threshold"), threads)
_bundle = b
except Exception as e: # ImportError, FileNotFoundError, bad JSON, ORT
logger.warning(
"news model unavailable, DeepSeek/keyword path stays: %s: %s",
type(e).__name__, e)
_failed = True
return _bundle
def teacher_tag() -> Optional[str]:
"""Provenance tag for archive rows scored by the loaded model, e.g.
``xau_transformer@2026-09-27T23:15:30…``. Reads the already-loaded
bundle only; never triggers a load. None when nothing is loaded."""
b = _bundle
if b is None:
return None
return f"xau_transformer@{b['meta'].get('trained_at', 'unknown')}"
def warm_up_in_background() -> None:
"""Create the ONNX session on a daemon thread, once per process, so
the first news cycle does not pay the 3–5 s load under
NewsEngine._cache_lock. Idempotent; a later get_news_model() call
simply waits on the lock if the thread is still loading."""
global _warm_started
if _warm_started or _bundle is not None or _failed:
return
with _lock:
if _warm_started:
return
_warm_started = True
threading.Thread(target=get_news_model, name="news-model-warmup",
daemon=True).start()
def reset_for_tests() -> None:
"""Forget any loaded or failed state (test_all.py only)."""
global _bundle, _failed, _warm_started
with _lock:
_bundle = None
_failed = False
_warm_started = False