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.
This commit is contained in:
Harwinder Sekhon
2026-09-27 21:02:42 -04:00
parent d81cdf845b
commit 42025da0b0
13 changed files with 1966 additions and 34 deletions
+637 -3
View File
@@ -26,7 +26,7 @@ sys.path.insert(0, os.path.dirname(__file__))
import json
import app_config
for _k in ("TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_CHANNEL_ID",
"TWELVE_DATA_API_KEY", "DEEPSEEK_API_KEY"):
"TWELVE_DATA_API_KEY", "DEEPSEEK_API_KEY", "NEWS_MODEL_MODE"):
os.environ.pop(_k, None)
# The suite must never write the developer's (or the live box's) real
# databases either: the RSS fixture tests run the real archive hook and
@@ -11436,8 +11436,10 @@ def _():
@test("news archive: shipped code imports no scikit-learn/scipy/lightgbm")
def _():
"""The corpus is trained on offline in venv/; anything the live signal
path can reach must stay numpy-only (requirements.txt is the contract)."""
"""The corpus is trained on offline (ML-NEWS); anything the live signal
path can reach stays numpy-only plus the ONNX runtime that scores
headlines (news_model.py: onnxruntime + tokenizers, imported lazily,
Module 38). requirements.txt is the contract: no training stack."""
banned = {"sklearn", "scipy", "lightgbm", "torch", "transformers", "cupy"}
roots = [Path(".")] + [Path(p) for p in ("providers", "pair_signals")]
offenders = []
@@ -11460,6 +11462,8 @@ def _():
req = open("requirements.txt").read()
for name in banned:
assert name not in req, f"{name} must not enter requirements.txt"
for name in ("onnxruntime", "tokenizers"):
assert name in req, f"{name} is the news model's CPU runtime"
@test("news archive: DeepSeek's gold score is kept for every headline, gold keyword or not")
@@ -15379,6 +15383,636 @@ if _GPU37:
_gk37.BACKEND.release_device_memory()
# ══════════════════════════════════════════════════════════════════════
# MODULE 38: local news model (news_model.py, 2026-09-27)
# The owner's ML-NEWS XAU transformer scores headlines locally. The
# model has a gold head only, so the rules pinned here are about what
# it may and may not write: xau_score and XAU's per-currency entry, never
# USD; the DeepSeek cache is never touched; primary mode never archives
# the model's own output (owner decision: newsdata.db stops growing once
# the model scores, except the optional DeepSeek audit sample); mode off
# is byte-for-byte today's path. The real bundle is exercised only when
# NEWS_MODEL_DIR or the configured path exists (SKIP otherwise).
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 38: local news model ═══")
import ast as _ast38
import dataclasses as _dc38
import logging as _log38
import sqlite3 as _sqlite38
from datetime import datetime as _dt38, timezone as _tz38
from pathlib import Path as _Path38
import app_config as _ac38
import llm_client as _lc38
import news_archive as _na38
import news_engine as _ne38
import news_model as _nm38
from config import NewsConfig as _NC38
from news_engine import NewsEngine as _NE38, NewsSentiment as _NS38
_FAKE_BUNDLE38 = {"meta": {"trained_at": "2026-09-27T00:00:00+00:00",
"relevance_threshold": 0.2}}
def _h38(headline, currencies=("XAU", "USD"), score=0.3, by=None,
source="keyword"):
h = _NS38(headline=headline, source="https://feed.example/rss",
timestamp=_dt38.now(_tz38.utc),
currencies_mentioned=list(currencies), sentiment_score=score,
keywords=["k"],
sentiment_by_currency=dict(
by if by is not None else {c: score for c in currencies}))
h.score_source = source
return h
class _Model38:
"""Swap news_model's bundle + scorer for a scripted fake; restore after.
`results` is a dict applied to every headline or a callable(text)."""
def __init__(self, results=None, raise_exc=None, bundle=_FAKE_BUNDLE38):
self.results = results if results is not None else \
{"relevant": True, "score": 0.8}
self.raise_exc = raise_exc
self.bundle = bundle
self.calls = []
def __enter__(self):
self._saved = (_nm38.get_news_model, _nm38.score_batch,
_nm38.warm_up_in_background)
_nm38.get_news_model = lambda: self.bundle
_nm38._bundle = self.bundle # teacher_tag() reads it directly
_nm38.warm_up_in_background = lambda: None
def sb(bundle, texts):
texts = list(texts)
self.calls.append(texts)
if self.raise_exc is not None:
raise self.raise_exc
if callable(self.results):
return [self.results(t) for t in texts]
return [dict(self.results) for _ in texts]
_nm38.score_batch = sb
return self
def __exit__(self, *exc):
(_nm38.get_news_model, _nm38.score_batch,
_nm38.warm_up_in_background) = self._saved
_nm38.reset_for_tests()
return False
def _app38(mode="off", audit=0, path="/nonexistent/xau_transformer"):
app = _dc38.replace(_TEST_APP)
app.news_model_settings = {"mode": mode, "path": path,
"deepseek_audit_per_day": audit}
return app
@test("news model: scores keyword rows only; USD keeps its keyword value")
def _():
"""Rule A + F. The model has a gold head: it may write xau_score and
XAU's entry, never USD's (get_pair_sentiment forms XAU − USD), and it
never overwrites a DeepSeek-scored row."""
llm = _h38("Fed hikes, dollar jumps", score=0.4,
by={"XAU": -0.7, "USD": 0.4}, source="llm")
llm.xau_score = -0.7
kw = _h38("Gold climbs as yields slip", by={"XAU": 0.3, "USD": -0.2})
before = _dc38.asdict(llm)
with _Model38({"relevant": True, "score": 0.81}):
n = _NE38(_NC38())._apply_model_sentiment([llm, kw])
assert n == 1, n
assert _dc38.asdict(llm) == before, "an llm row is never touched"
assert kw.score_source == "model", kw.score_source
assert kw.xau_score == 0.81, kw.xau_score
assert kw.sentiment_by_currency["XAU"] == 0.81, kw.sentiment_by_currency
assert kw.sentiment_by_currency["USD"] == -0.2, \
"USD must keep the keyword value"
assert kw.sentiment_score == 0.3, \
"a headline naming USD keeps its keyword overall score"
@test("news model: overall score comes from the model only for [] or ['XAU']")
def _():
"""Rule B: the archive's `score` column is sentiment_score; for a
gold-only headline it is the model's number, for a mixed headline it
stays keyword so the row is honest about what the model scored."""
gold = _h38("Gold rallies", currencies=("XAU",), score=0.2)
mixed = _h38("Fed hikes, gold falls", currencies=("USD", "XAU"), score=0.1)
none = _h38("Stocks crater worldwide", currencies=(), score=0.0, by={})
with _Model38({"relevant": True, "score": -0.55}):
_NE38(_NC38())._apply_model_sentiment([gold, mixed, none])
assert gold.sentiment_score == -0.55 and gold.sentiment_by_currency["XAU"] == -0.55
assert mixed.sentiment_score == 0.1, mixed.sentiment_score
assert mixed.sentiment_by_currency["XAU"] == -0.55, mixed.sentiment_by_currency
assert mixed.sentiment_by_currency["USD"] == 0.1, mixed.sentiment_by_currency
assert none.sentiment_score == -0.55 and none.sentiment_by_currency == {}, \
"no per-currency entry is invented for a headline naming none"
assert all(h.xau_score == -0.55 for h in (gold, mixed, none))
assert all(h.score_source == "model" for h in (gold, mixed, none))
@test("news model: a not-relevant headline shows 0.0 but archives its raw score")
def _():
"""Rule C + D. relevant=False can still carry |score| ~0.2 from the
direction head; NEUTRAL_HEADLINE_THRESHOLD (0.1) would show 0.15 as
"leaning bearish". The displayed value is zeroed; xau_score is not."""
h = _h38("Copper slides on China data", currencies=("XAU",), score=0.05)
with _Model38({"relevant": False, "score": -0.18}):
_NE38(_NC38())._apply_model_sentiment([h])
assert h.sentiment_by_currency["XAU"] == 0.0, h.sentiment_by_currency
assert h.sentiment_score == 0.0, h.sentiment_score
assert h.xau_score == -0.18, "training data stays raw"
assert _ne38._is_neutral_headline(h), "neutral means neutral"
@test("news model: never touches NewsEngine._llm_sent_cache")
def _():
"""Rule E: that cache decides what is re-sent to DeepSeek. A headline
the model scored must still be eligible for DeepSeek in fallback mode
and for the audit sample."""
saved = _NE38._llm_sent_cache
_NE38._llm_sent_cache = {"deadbeef": (0.1, 0.2)}
try:
with _Model38():
_NE38(_NC38())._apply_model_sentiment(
[_h38("Gold up"), _h38("Gold down")])
assert _NE38._llm_sent_cache == {"deadbeef": (0.1, 0.2)}, \
_NE38._llm_sent_cache
finally:
_NE38._llm_sent_cache = saved
@test("news model: a scoring failure or a missing model leaves keyword scores standing")
def _():
rows = [_h38("Gold up"), _h38("Gold down")]
with _Model38(raise_exc=RuntimeError("ORT exploded")):
n = _NE38(_NC38())._apply_model_sentiment(rows)
assert n == 0 and all(h.score_source == "keyword" for h in rows)
assert all(h.xau_score is None for h in rows)
with _Model38(bundle=None) as m:
n = _NE38(_NC38())._apply_model_sentiment(rows)
assert n == 0 and m.calls == [], "no scoring call without a bundle"
assert all(h.score_source == "keyword" for h in rows)
# a length mismatch is a failure too, not a silent misalignment
saved = _nm38.score_batch
with _Model38() as m:
_nm38.score_batch = lambda b, texts: [{"relevant": True, "score": 0.5}]
n = _NE38(_NC38())._apply_model_sentiment(rows)
assert n == 0 and all(h.score_source == "keyword" for h in rows)
assert _nm38.score_batch is saved
@test("news model: primary mode with no model degrades to DeepSeek, not to keywords")
def _():
saved_cache = _NE38._llm_sent_cache
_NE38._llm_sent_cache = {}
_lc38._client = _StubLLM({})
try:
rows = [_h38("Gold up"), _h38("Stocks fall", currencies=())]
ne = _NE38(_NC38())
ne._llm_headline_scores = lambda texts: [(0.4, 0.6)] * len(texts)
with _Model38(bundle=None):
out = ne._enrich_headline_scores(rows, "primary")
assert out == [], "no audit copies on the DeepSeek path"
assert all(h.score_source == "llm" for h in rows), \
[h.score_source for h in rows]
assert rows[0].xau_score == 0.6
finally:
_NE38._llm_sent_cache = saved_cache
_lc38._client = None
@test("news model: primary mode never calls DeepSeek for headlines; off mode never calls the model")
def _():
saved_cache = _NE38._llm_sent_cache
saved_app = _ac38.get_app_config()
_NE38._llm_sent_cache = {}
_lc38._client = _StubLLM({})
llm_calls = []
try:
ne = _NE38(_NC38())
def scores(texts):
llm_calls.append(list(texts))
return [(0.4, 0.6)] * len(texts)
ne._llm_headline_scores = scores
_ac38.set_app_config(_app38("primary", audit=0))
rows = [_h38("Gold up"), _h38("Gold down")]
with _Model38({"relevant": True, "score": 0.9}) as m:
out = ne._enrich_headline_scores(rows, "primary")
assert out == [] and llm_calls == [], llm_calls
assert m.calls == [["Gold up", "Gold down"]], m.calls
assert all(h.score_source == "model" for h in rows)
_ac38.set_app_config(saved_app)
rows = [_h38("Gold up"), _h38("Gold down")]
with _Model38() as m:
ne._enrich_headline_scores(rows, "off")
assert m.calls == [], "off mode is today's path exactly"
assert llm_calls == [["Gold up", "Gold down"]], llm_calls
assert all(h.score_source == "llm" for h in rows)
finally:
_ac38.set_app_config(saved_app)
_NE38._llm_sent_cache = saved_cache
_lc38._client = None
@test("news model: fallback mode scores only what DeepSeek left keyword-scored")
def _():
saved_cache = _NE38._llm_sent_cache
_NE38._llm_sent_cache = {}
_lc38._client = _StubLLM({})
try:
ne = _NE38(_NC38())
ne._llm_headline_scores = lambda texts: [(0.4, 0.6)] * len(texts)
rows = [_h38("Gold up"), _h38("Gold down")]
with _Model38() as m:
ne._enrich_headline_scores(rows, "fallback")
assert m.calls == [], "DeepSeek scored everything; nothing left"
assert all(h.score_source == "llm" for h in rows)
# DeepSeek fails → the model fills in, and the headlines stay OUT
# of the DeepSeek cache so the next window retries them
ne._llm_headline_scores = lambda texts: None
rows = [_h38("Silver up"), _h38("Silver down")]
with _Model38({"relevant": True, "score": -0.3}) as m:
ne._enrich_headline_scores(rows, "fallback")
assert m.calls == [["Silver up", "Silver down"]], m.calls
assert all(h.score_source == "model" for h in rows)
assert len(_NE38._llm_sent_cache) == 2, "only the two DeepSeek scored"
finally:
_NE38._llm_sent_cache = saved_cache
_lc38._client = None
@test("news model: the DeepSeek audit sample is capped per UTC day and archive-only")
def _():
saved_cache = _NE38._llm_sent_cache
saved_app = _ac38.get_app_config()
saved_day = (_NE38._audit_day, _NE38._audit_sent_today)
_NE38._llm_sent_cache = {}
_NE38._audit_day, _NE38._audit_sent_today = "", 0
_lc38._client = _StubLLM({})
sent = []
try:
ne = _NE38(_NC38())
def scores(texts):
sent.append(list(texts))
return [(0.4, -0.6)] * len(texts)
ne._llm_headline_scores = scores
rows = [_h38(f"Gold headline {i}") for i in range(10)]
_ac38.set_app_config(_app38("primary", audit=3))
with _Model38({"relevant": True, "score": 0.7}):
copies = ne._enrich_headline_scores(rows, "primary")
assert [len(b) for b in sent] == [3], sent
assert len(copies) == 3, len(copies)
assert all(c.score_source == "llm" and c.xau_score == -0.6
and c.sentiment_by_currency["XAU"] == -0.6
and c.sentiment_by_currency["USD"] == 0.4 for c in copies)
assert all(h.score_source == "model" and h.xau_score == 0.7
for h in rows), "the displayed objects keep the model's numbers"
assert copies[0] is not rows[0]
assert len(_NE38._llm_sent_cache) == 3, "audited once, never twice"
# same day, second cycle: the cap is spent
more = [_h38(f"Gold headline {i}") for i in range(10, 20)]
with _Model38({"relevant": True, "score": 0.7}):
copies2 = ne._enrich_headline_scores(more, "primary")
assert copies2 == [] and len(sent) == 1, (copies2, sent)
# cap 0 (the shipped value): no call at all
_NE38._audit_day, _NE38._audit_sent_today = "", 0
_ac38.set_app_config(_app38("primary", audit=0))
with _Model38({"relevant": True, "score": 0.7}):
copies3 = ne._enrich_headline_scores(
[_h38("Gold headline 99")], "primary")
assert copies3 == [] and len(sent) == 1
finally:
_ac38.set_app_config(saved_app)
_NE38._llm_sent_cache = saved_cache
_NE38._audit_day, _NE38._audit_sent_today = saved_day
_lc38._client = None
def _cycle38(mode, audit, model_results, llm_scores):
"""Run one fake RSS cycle under `mode` and return (returned headlines,
archived rows by headline). Restores every piece of shared state."""
gold = "Gold surges as Fed signals rate cuts"
stocks = "Stocks crater worldwide as banking crisis fears spread"
saved_health = _NE38._source_health
saved_cache = _NE38._llm_sent_cache
saved_app = _ac38.get_app_config()
saved_env = os.environ.get("NEWS_ARCHIVE_PATH")
saved_archives = dict(_na38._archives)
saved_day = (_NE38._audit_day, _NE38._audit_sent_today)
_na38._archives.clear()
_NE38._source_health = {}
_NE38._llm_sent_cache = {}
_NE38._audit_day, _NE38._audit_sent_today = "", 0
_lc38._client = _StubLLM({})
_lc38._client._model = "deepseek-v4-flash" # the archive's teacher_model
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "newsdata.db")
os.environ["NEWS_ARCHIVE_PATH"] = path
stamp = b"<pubDate>" + _m19_pubdate(1) + b"</pubDate>"
rss = (b"<rss><channel>"
b"<item><title>" + gold.encode() + b"</title>" + stamp + b"</item>"
b"<item><title>" + stocks.encode() + b"</title>" + stamp + b"</item>"
b"</channel></rss>")
class _Sess:
def get(self, url, timeout=None):
return type("R", (), {"status_code": 200, "content": rss})()
try:
cfg = _NC38()
cfg.rss_feeds = ["https://feed.example/rss"]
ne = _NE38(cfg)
ne._session = _Sess()
ne._llm_headline_scores = llm_scores
_ac38.set_app_config(_app38(mode, audit=audit))
with _Model38(model_results):
heads = ne._fetch_news_sentiment_raw()
rows = {}
if os.path.exists(path):
con = _sqlite38.connect(f"file:{path}?mode=ro", uri=True)
try:
rows = {r[0]: r for r in con.execute(
"SELECT headline, score_source, score, xau_score,"
" teacher_model, teacher_prompt_sha1 FROM headlines")}
finally:
con.close()
return heads, rows
finally:
_ac38.set_app_config(saved_app)
_NE38._source_health = saved_health
_NE38._llm_sent_cache = saved_cache
_NE38._audit_day, _NE38._audit_sent_today = saved_day
_lc38._client = None
for arch in _na38._archives.values():
arch.close()
_na38._archives.clear()
_na38._archives.update(saved_archives)
if saved_env is None:
os.environ.pop("NEWS_ARCHIVE_PATH", None)
else:
os.environ["NEWS_ARCHIVE_PATH"] = saved_env
@test("news model: primary mode archives nothing but the audit rows; off and fallback archive as before")
def _():
"""Owner decision 2026-09-27: newsdata.db stops growing once the
local model scores. Its output is not training data; only DeepSeek
audit copies (llm) are stored. The platform still receives the same
currency-matched headlines in every mode."""
gold = "Gold surges as Fed signals rate cuts"
stocks = "Stocks crater worldwide as banking crisis fears spread"
model = {"relevant": True, "score": 0.9}
llm = lambda texts: [(0.5, 0.6)] * len(texts)
heads, rows = _cycle38("primary", 0, model, llm)
assert [h.headline for h in heads] == [gold], heads
assert heads[0].score_source == "model" and heads[0].xau_score == 0.9
assert rows == {}, f"primary mode must not archive: {rows}"
heads, rows = _cycle38("primary", 25, model, llm)
assert set(rows) == {gold, stocks}, rows
assert all(r[1] == "llm" and r[3] == 0.6 and r[4] == "deepseek-v4-flash"
and r[5] == _ne38._LLM_SENTIMENT_PROMPT_SHA1
for r in rows.values()), rows
assert heads[0].score_source == "model" and heads[0].xau_score == 0.9, \
"the audit never changes what is displayed"
heads, rows = _cycle38("off", 0, model, llm)
assert set(rows) == {gold, stocks} and all(r[1] == "llm" for r in rows.values())
assert heads[0].score_source == "llm"
heads, rows = _cycle38("fallback", 0, model, lambda texts: None)
assert set(rows) == {gold, stocks}, rows
assert all(r[1] == "model" and r[3] == 0.9 for r in rows.values()), rows
assert all(r[4] == "xau_transformer@2026-09-27T00:00:00+00:00"
for r in rows.values()), "model rows carry the bundle tag"
assert all(r[5] == _ne38._LLM_SENTIMENT_PROMPT_SHA1 for r in rows.values()), \
"and the DeepSeek prompt the model was distilled from"
@test("news model: config block is validated; env NEWS_MODEL_MODE wins; absent = off")
def _():
saved_app = _ac38.get_app_config()
try:
base = copy.deepcopy(_FULL_CFG)
base["news_model"] = {"path": "/models/xau", "threads": 4,
"mode": "primary", "deepseek_audit_per_day": 0}
app = load_app_config(_write_json(base))
assert _ac38.news_model_mode(app) == "primary"
assert _ac38.news_model_path(app) == "/models/xau"
assert _ac38.news_model_threads(app) == 4
assert _ac38.news_model_audit_per_day(app) == 0
for bad in ({"mode": "banana", "path": "/m"},
{"path": "/m", "threds": 4},
{"mode": "primary"}, # no path
{"mode": "primary", "path": ""},
{"mode": "off", "threads": 0},
{"mode": "off", "threads": True},
{"mode": "off", "deepseek_audit_per_day": -1},
{"mode": "off", "deepseek_audit_per_day": 2.5}):
payload = copy.deepcopy(_FULL_CFG)
payload["news_model"] = bad
try:
load_app_config(_write_json(payload))
except AppConfigError:
continue
raise AssertionError(f"accepted {bad}")
# absent section → off, defaults
plain = load_app_config(_write_json(copy.deepcopy(_FULL_CFG)))
assert plain.news_model_settings == {}
assert _ac38.news_model_mode(plain) == "off"
assert _ac38.news_model_path(plain) is None
assert _ac38.news_model_threads(plain) == 4
assert _ac38.news_model_audit_per_day(plain) == 0
# a relative path resolves against the repo root
rel = _dc38.replace(_TEST_APP)
rel.news_model_settings = {"path": "models/x", "mode": "off"}
assert _ac38.news_model_path(rel) == os.path.join(
os.path.dirname(os.path.abspath(_ac38.__file__)), "models/x")
# env override beats config.json; an invalid value falls back
with patch.dict(os.environ, {"NEWS_MODEL_MODE": "off"}):
assert _ac38.news_model_mode(app) == "off"
with patch.dict(os.environ, {"NEWS_MODEL_MODE": "Fallback"}):
assert _ac38.news_model_mode(app) == "fallback"
with patch.dict(os.environ, {"NEWS_MODEL_MODE": "banana"}):
assert _ac38.news_model_mode(app) == "primary"
# the suite's hermetic config never loads the model
assert _ac38.news_model_mode() == "off"
assert "news_model" in _ac38._KNOWN_TOP
finally:
_ac38.set_app_config(saved_app)
@test("news model: archive rows carry the model tag; an llm row is never downgraded by one")
def _():
row = _na38.build_row(_m32_head(score=0.5, source="model"),
teacher_model="deepseek-v4-flash",
teacher_prompt_sha1="p1",
model_teacher="xau_transformer@T")
assert row["teacher_model"] == "xau_transformer@T", row
assert row["teacher_prompt_sha1"] == "p1", "the distillation prompt"
row = _na38.build_row(_m32_head(score=0.5, source="llm"),
teacher_model="deepseek-v4-flash",
teacher_prompt_sha1="p1",
model_teacher="xau_transformer@T")
assert row["teacher_model"] == "deepseek-v4-flash", row
row = _na38.build_row(_m32_head(score=0.5, source="keyword"),
teacher_model="deepseek-v4-flash",
teacher_prompt_sha1="p1",
model_teacher="xau_transformer@T")
assert row["teacher_model"] is None and row["teacher_prompt_sha1"] is None
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "newsdata.db")
arch = _na38.NewsArchive(path)
try:
arch.store([_na38.build_row(_m32_head(score=0.3, source="model"),
teacher_prompt_sha1="p1",
model_teacher="xau_transformer@T")])
r = _m32_rows(path)["Gold surges as Fed signals rate cuts"]
assert r[2] == "model" and r[5] == "xau_transformer@T", r
arch.store([_na38.build_row(_m32_head(score=-0.7, source="llm"),
teacher_model="deepseek-v4-flash",
teacher_prompt_sha1="p1")])
r = _m32_rows(path)["Gold surges as Fed signals rate cuts"]
assert r[1] == -0.7 and r[2] == "llm" and r[5] == "deepseek-v4-flash", \
"llm upgrades model"
arch.store([_na38.build_row(_m32_head(score=0.3, source="model"),
model_teacher="xau_transformer@T2")])
r = _m32_rows(path)["Gold surges as Fed signals rate cuts"]
assert r[1] == -0.7 and r[2] == "llm" and r[5] == "deepseek-v4-flash", \
"model never downgrades llm"
assert r[3] == 3, "but re-seeing it still counts"
finally:
arch.close()
@test("news model: news_model.py imports onnxruntime/tokenizers lazily; requirements list them")
def _():
"""`import news_engine` must succeed on a box without the runtime;
the bot then logs one warning and keeps the DeepSeek/keyword path."""
tree = _ast38.parse(_Path38("news_model.py").read_text(encoding="utf-8"))
top = set()
for node in tree.body:
if isinstance(node, _ast38.Import):
top.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, _ast38.ImportFrom):
top.add((node.module or "").split(".")[0])
assert not top & {"onnxruntime", "tokenizers"}, top
assert "numpy" in top
lazy = set()
for node in _ast38.walk(tree):
if isinstance(node, (_ast38.Import, _ast38.ImportFrom)):
names = ([a.name for a in node.names] if isinstance(node, _ast38.Import)
else [node.module or ""])
lazy.update(n.split(".")[0] for n in names)
assert {"onnxruntime", "tokenizers"} <= lazy, "load_model imports them"
req = open("requirements.txt").read()
assert "onnxruntime" in req and "tokenizers" in req
for word in ("torch", "transformers"):
assert word not in req
@test("news model: get_news_model never raises, fails once and remembers it")
def _():
saved_app = _ac38.get_app_config()
_nm38.reset_for_tests()
log = _log38.getLogger("news_model")
records = []
class _H(_log38.Handler):
def emit(self, r):
records.append(r)
h = _H()
log.addHandler(h)
try:
_ac38.set_app_config(_app38("primary", path="/nonexistent/xau_transformer"))
assert _nm38.get_news_model() is None
assert _nm38.get_news_model() is None
assert _nm38.teacher_tag() is None
warns = [r for r in records if r.levelno == _log38.WARNING
and "news model unavailable" in r.getMessage()]
assert len(warns) == 1, "one warning, no retry spam"
# path not configured is the same outcome
_nm38.reset_for_tests()
_ac38.set_app_config(_app38("off", path=""))
_ac38.get_app_config().news_model_settings = {"mode": "primary"}
assert _nm38.get_news_model() is None
# warm-up is idempotent and never raises
_nm38.reset_for_tests()
_nm38.warm_up_in_background()
_nm38.warm_up_in_background()
assert _nm38._warm_started
# a NewsEngine built in mode off never starts it
_nm38.reset_for_tests()
_ac38.set_app_config(saved_app)
_NE38(_NC38())
assert not _nm38._warm_started
finally:
log.removeHandler(h)
_nm38.reset_for_tests()
_ac38.set_app_config(saved_app)
def _bundle_dir38():
d = os.environ.get("NEWS_MODEL_DIR")
if not d:
try:
d = _ac38.news_model_path(_ac38.load_app_config())
except Exception:
d = None
if d and os.path.isfile(os.path.join(d, "meta.json")):
return d
return None
@test("news model: the real bundle loads, labels the golden pair and stays under 50 ms/headline")
def _():
d = _bundle_dir38()
if d is None:
print(" SKIP: no bundle (set NEWS_MODEL_DIR or news_model.path)")
return
b = _nm38.load_model(d, threads=4)
up = _nm38.score_news(b, "Fed cuts rates, signals more easing")
off = _nm38.score_news(b, "Red Lobster endless shrimp losses")
assert up["label"] == "bullish" and up["score"] > 0.5, up
assert off["label"] == "neutral" and not off["relevant"], off
assert abs(off["score"]) < _nm38.RELEVANT_MAX_IRRELEVANT, off
batch = _nm38.score_batch(b, ["Gold jumps as dollar slides",
"Fed hikes 50bp, dollar surges"])
assert batch[0]["label"] == "bullish" and batch[1]["label"] == "bearish", batch
t = time.time()
for _ in range(20):
_nm38.score_news(b, "Gold jumps as dollar slides")
assert time.time() - t < 1.0, "20 single calls must take under a second"
# the singleton path through app_config
saved_app = _ac38.get_app_config()
_nm38.reset_for_tests()
try:
_ac38.set_app_config(_app38("primary", path=d))
loaded = _nm38.get_news_model()
assert loaded is not None and loaded["meta"]["relevance_threshold"] == 0.2
assert _nm38.get_news_model() is loaded, "loaded once"
tag = _nm38.teacher_tag()
assert tag and tag.startswith("xau_transformer@2026-"), tag
# end to end through the engine: a real headline, real numbers
h = _h38("Gold jumps as dollar slides", currencies=("XAU", "USD"),
by={"XAU": 0.1, "USD": -0.1})
n = _NE38(_NC38())._apply_model_sentiment([h])
assert n == 1 and h.score_source == "model"
assert h.sentiment_by_currency["XAU"] > 0.5 and h.xau_score > 0.5, h
assert h.sentiment_by_currency["USD"] == -0.1
finally:
_nm38.reset_for_tests()
_ac38.set_app_config(saved_app)
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════