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:
+87
-1
@@ -90,7 +90,7 @@ _KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
||||
# config.json files still carry the block and must keep
|
||||
# loading; load_app_config logs one warning when it is there.
|
||||
"market_outlook",
|
||||
"violent_watch", "signal_log", "news_archive"}
|
||||
"violent_watch", "signal_log", "news_archive", "news_model"}
|
||||
_KNOWN_OANDA = {
|
||||
# "account_id" is TOLERATED but ignored: it belonged to the auto-
|
||||
# execution daemon removed 2026-09-03 (last commit 31002cb). The candle
|
||||
@@ -111,6 +111,8 @@ _KNOWN_VIOLENT_WATCH = {"enabled", "alert_line", "headsup",
|
||||
"max_headsup_per_day", "headsup_min_level"}
|
||||
_KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"}
|
||||
_KNOWN_NEWS_ARCHIVE = {"enabled", "path"}
|
||||
_KNOWN_NEWS_MODEL = {"path", "threads", "mode", "deepseek_audit_per_day"}
|
||||
_NEWS_MODEL_MODES = ("off", "fallback", "primary")
|
||||
_KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"}
|
||||
_KNOWN_SCANNING = {"pairs", "timeframes"}
|
||||
_KNOWN_PAIR_KEYS = {"blocked_hours_utc"}
|
||||
@@ -150,6 +152,7 @@ class AppConfig:
|
||||
default_factory=lambda: dict(_VIOLENT_WATCH_DEFAULTS))
|
||||
signal_log_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
news_archive_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
news_model_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
log_level: str = "INFO"
|
||||
|
||||
|
||||
@@ -405,6 +408,30 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
||||
"config.json: news_archive.path must be a non-empty string")
|
||||
app.news_archive_settings = dict(narch)
|
||||
|
||||
nm = raw.get("news_model", {})
|
||||
_check_keys(nm, _KNOWN_NEWS_MODEL, '"news_model"')
|
||||
if "mode" in nm and nm["mode"] not in _NEWS_MODEL_MODES:
|
||||
raise AppConfigError(
|
||||
'config.json: news_model.mode must be "off", "fallback" or "primary"')
|
||||
if "threads" in nm and (not isinstance(nm["threads"], int)
|
||||
or isinstance(nm["threads"], bool)
|
||||
or nm["threads"] < 1):
|
||||
raise AppConfigError(
|
||||
"config.json: news_model.threads must be a positive integer")
|
||||
if "deepseek_audit_per_day" in nm and (
|
||||
not isinstance(nm["deepseek_audit_per_day"], int)
|
||||
or isinstance(nm["deepseek_audit_per_day"], bool)
|
||||
or nm["deepseek_audit_per_day"] < 0):
|
||||
raise AppConfigError(
|
||||
"config.json: news_model.deepseek_audit_per_day must be a "
|
||||
"non-negative integer")
|
||||
if nm.get("mode", "off") != "off" and (
|
||||
not isinstance(nm.get("path"), str) or not nm.get("path")):
|
||||
raise AppConfigError(
|
||||
"config.json: news_model.path is required when news_model.mode "
|
||||
"is not off")
|
||||
app.news_model_settings = dict(nm)
|
||||
|
||||
level = raw.get("log_level", app.log_level)
|
||||
if level not in _VALID_LOG_LEVELS:
|
||||
raise AppConfigError(
|
||||
@@ -569,6 +596,65 @@ def signal_log_path(app: Optional[AppConfig] = None) -> str:
|
||||
return path
|
||||
|
||||
|
||||
# ── Local news model (env → config.json → default) ───────────────────
|
||||
#
|
||||
# The owner's ML-NEWS XAU transformer scores headlines locally
|
||||
# (news_model.py). "off" is today's DeepSeek/keyword path; "fallback" runs
|
||||
# the model only on rows DeepSeek left keyword-scored; "primary" scores
|
||||
# every headline with the model and never calls DeepSeek for headlines
|
||||
# (except the optional audit sample). NEWS_MODEL_MODE lets one process
|
||||
# (the dashboard daemon's systemd unit) pick its own mode: each process
|
||||
# that loads the model holds ~0.6 GB.
|
||||
|
||||
def news_model_mode(app: Optional[AppConfig] = None) -> str:
|
||||
env = os.environ.get("NEWS_MODEL_MODE")
|
||||
if env:
|
||||
mode = env.strip().lower()
|
||||
if mode in _NEWS_MODEL_MODES:
|
||||
return mode
|
||||
logger.warning(
|
||||
'NEWS_MODEL_MODE=%r is not one of %s; using config.json',
|
||||
env, _NEWS_MODEL_MODES)
|
||||
app = app or get_app_config()
|
||||
mode = app.news_model_settings.get("mode", "off")
|
||||
return mode if mode in _NEWS_MODEL_MODES else "off"
|
||||
|
||||
|
||||
def news_model_path(app: Optional[AppConfig] = None) -> Optional[str]:
|
||||
"""Absolute path to the bundle directory (model.onnx, tokenizer.json,
|
||||
meta.json), or None when not configured. A relative value resolves
|
||||
against the repo root like news_archive_path."""
|
||||
app = app or get_app_config()
|
||||
path = app.news_model_settings.get("path")
|
||||
if not isinstance(path, str) or not path:
|
||||
return None
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path)
|
||||
return path
|
||||
|
||||
|
||||
def news_model_threads(app: Optional[AppConfig] = None) -> int:
|
||||
"""ONNX intra-op threads (measured 2026-09-27: 1 → 19 ms per headline,
|
||||
4 → 9 ms, 16 → 13 ms)."""
|
||||
app = app or get_app_config()
|
||||
try:
|
||||
return max(1, int(app.news_model_settings.get("threads", 4)))
|
||||
except (TypeError, ValueError):
|
||||
return 4
|
||||
|
||||
|
||||
def news_model_audit_per_day(app: Optional[AppConfig] = None) -> int:
|
||||
"""primary mode only: headlines per UTC day also sent to DeepSeek so
|
||||
the archive keeps receiving fresh training labels. Archive-only, never
|
||||
displayed. 0 = no DeepSeek headline calls at all (owner decision
|
||||
2026-09-27)."""
|
||||
app = app or get_app_config()
|
||||
try:
|
||||
return max(0, int(app.news_model_settings.get("deepseek_audit_per_day", 0)))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
# ── News-archive resolution helpers (env → config.json → default) ────
|
||||
|
||||
def news_archive_enabled(app: Optional[AppConfig] = None) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user