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:
+637
-3
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user