Files
ForexBot2/docs/news_model_integration_spec_2026-09-27.md
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

28 KiB
Raw Blame History

Specification: run the ML-NEWS XAU transformer inside ForexBot2

Status: IMPLEMENTED 2026-09-27 (ForexBot2 branch sql-integration, on top of 6af1237; suite Module 38). The text below is the specification as written before the implementation; file:line numbers refer to 6af1237 and have drifted. Where the implementation differs, this list wins:

  • primary mode does not archive the cycle's rows. Owner decision during implementation: once the local model scores, logs/newsdata.db stops growing, because the model's own output is not training data. Only the DeepSeek audit copies (deepseek_audit_per_day, shipped 0) are written. off and fallback archive as before (a fallback model row carries model_teacher). §5.4 and §9 read accordingly.
  • _audit_with_deepseek returns the llm copies; there is no _pending_audit_rows attribute. The mode switch of §5.2 lives in NewsEngine._enrich_headline_scores(scored, mode), which returns those copies, so the call site and the tests share one function.
  • config.json.example ships "mode": "off"; the live server sets primary (deploy/README-news-model.md).
  • §8 step 0 was wrong: the dashboard daemon never writes the archive in any mode (set_db_writes_for_process(False)), so its DeepSeek rows are not "free audit labels". Both processes load the model (owner decision); NEWS_MODEL_MODE=off stays as the memory fallback.
  • warm_up_in_background() is implemented (module-level once flag; a NewsEngine is built per scan).
  • news_model.get_news_model() also treats a missing path as a load failure (one warning).
  • requirements.txt ranges: onnxruntime>=1.19,<2, tokenizers>=0.22,<1. The live box runs Python 3.9, where only 1.19.2 / 0.22.2 install; verified on 2026-09-28 to score the bundle identically to the dev box's 1.30.0 / 0.23.2 (golden pair, four probe headlines).
  • ML-NEWS news_transformer.py received the §3.4 edits in the same session; the vendored copy differs only in load_model having no default directory.

Repos:

  • ML-NEWS ~/Development/ML-NEWS (git ssh://git@192.168.0.11:222/harry/News-ML-Decoder.git). Produces the model. Nothing in ForexBot2 imports from it.
  • ForexBot2 ~/Development/ForexBot2, live at /home/Forex/ForexBot2, deployed by git pull. Consumes the model.

1. Context and goal

1.1 What the model is

ML-NEWS/models/xau_transformer/ holds a fine-tuned microsoft/deberta-v3-small with two heads, exported to ONNX:

File Size Purpose
model.onnx 271 MB encoder + heads; int8 embedding table, fp32 compute
tokenizer.json 8 MB HF tokenizers fast tokenizer
meta.json 4 KB thresholds, trained_at, relevance_threshold, export_checks, training stats

Inputs: one headline, ≤ 64 tokens. Outputs per headline (from news_transformer.score_batch):

{"headline": str, "p_relevant": float, "p_bullish": float,
 "relevant": bool,            # p_relevant >= meta["relevance_threshold"]  (0.2 for the shipped bundle)
 "direction": "bullish"|"bearish",
 "label": "bullish"|"bearish"|"neutral",   # neutral when not relevant
 "score": float}              # p_relevant * (2*p_bullish - 1), in [-1, 1]

score is on the same scale as DeepSeek's xau score that ForexBot2 already stores in xau_score. The model was trained to copy DeepSeek (deepseek-v4-flash, prompt sha1 d32e04c6…, the same prompt as news_engine._LLM_SENTIMENT_PROMPT_SHA1) on 17,245 headlines. Quality on 2026-09-27: golden set 72/78 (DeepSeek 78, keyword scorer 24), agreement with DeepSeek on unseen live days ~72%, CPU latency 19 ms/headline on 1 thread, 9 ms on 4. Full numbers: ML-NEWS/eval/RESULTS_2026-09-27.md.

The model scores gold only. It has no head for USD, EUR or anything else. This drives most of the rules in §5.

1.2 How ForexBot2 scores headlines today

news_engine.py, inside NewsEngine._fetch_news_sentiment_raw (called via _cached_fetch("_headlines_cache", …) at :822, which holds NewsEngine._cache_lock, :629-647):

  1. RSS items become NewsSentiment objects (:891-910) with the keyword scorer filling sentiment_score and sentiment_by_currency[cur] for each currency in currencies_mentioned, score_source="keyword", xau_score=None.
  2. scored = headlines + unmatched (:944); self._apply_llm_sentiment(scored) (:945, in try/except). _apply_llm_sentiment (:1032-1065) sends headlines not in NewsEngine._llm_sent_cache to DeepSeek in batches of _LLM_BATCH_CAP = 25 via self._llm_headline_scores(texts) (:1009) and, on success, overwrites sentiment_score, sentiment_by_currency (XAU gets the gold score, every other currency the overall score), sets xau_score, score_source="llm". A failed batch stops sending; those headlines stay keyword-scored and stay out of the cache, so they are re-sent next window.
  3. news_archive.archive_headlines(scored, teacher_model=…, teacher_prompt_sha1=…) (:958-964). The archive UPSERT (news_archive.py:203-260) keeps the higher-ranked source: _SOURCE_RANK = {"unknown": 0, "keyword": 1, "model": 2, "llm": 3} (:107). "model" is already reserved for "a trained local artifact" (news_engine.py:223-227). build_row (:323-354) stores teacher_model/teacher_prompt_sha1 only when source == "llm".
  4. Downstream consumers of the scores: get_fundamental_context (drops |score| < NEUTRAL_HEADLINE_THRESHOLD = 0.1, :256), get_pair_sentiment (:553-557, computes XAU_mean − USD_mean), signal_aggregator.py:1677/1723 (acts on that when sentiment_affects_signals), agent_panel.py, the dashboard.

Two processes run this code: the bot (venv/bin/python telegram_bot.py, CLAUDE.md:30) and the dashboard daemon (dashboard_publisher.py:401-403 builds its own NewsEngine, :503 calls get_fundamental_context(); unit deploy/gold-dashboard.service). They share config.json and the archive DB.

1.3 Decisions taken by the owner (2026-09-27)

  • Mode primary, no DeepSeek for headline scoring. The model scores every headline. DeepSeek stays for everything else it does (_llm_event_direction for calendar surprises, the news summary, the dashboard's on-demand news_summary.php).
  • Model files live outside the ForexBot2 tree, path in config.json.
  • Accepted consequence: the archive stops receiving fresh DeepSeek labels, so the ML-NEWS weekly retrain and the "replay and compare" check lose their data source. Mitigation kept optional: deepseek_audit_per_day (§4) labels a few headlines a day for the archive only. Default 0.

1.4 Non-goals

  • Removing the DeepSeek client or its non-headline uses.
  • Scoring non-gold currencies with the model.
  • Changing ML-NEWS training. (The one ML-NEWS change is §3.4, a runtime option.)
  • Training from price moves.

2. Target layout on the live server

/home/Forex/ForexBot2/                       # git pull as today
    news_model.py                            # NEW, vendored runtime (§3)
    requirements.txt                         # + onnxruntime, tokenizers (§3.1)
    app_config.py, news_engine.py, news_archive.py, test_all.py   # edited (§4, §5, §6, §7)
    config.json                              # + "news_model" section (§4)
    deploy/README-news-model.md              # NEW ops checklist (§8)
    deploy/gold-dashboard.service            # maybe + Environment=NEWS_MODEL_MODE=off (§8)
/home/Forex/models/xau_transformer/          # NEW, outside git, copied by hand
    model.onnx  tokenizer.json  meta.json

Memory: each process that loads the model holds ~0.5–0.7 GB RSS for the ONNX session. Two processes → ~1.5 GB. free -m before deciding whether the dashboard daemon also runs it (§8 step 0).


3. news_model.py (new, ForexBot2)

3.1 Dependencies

Add to requirements.txt:

onnxruntime>=1.30,<2
tokenizers>=0.23,<1

Pin to the versions that verified the bundle in ML-NEWS .venv (check pip show there before writing). tokenizer.json compatibility across older tokenizers releases is not guaranteed. Do not write the words "torch" or "transformers" anywhere in requirements.txt, not even in a comment: test_all.py:11437-11462 greps the file for them and fails.

3.2 Source

Copy ML-NEWS/news_transformer.py → ForexBot2/news_model.py. Keep verbatim: clean_headline, _sigmoid, load_model, score_batch, score_news, constants MAX_LENGTH = 64, RELEVANT_MIN = 0.25, RELEVANT_MAX_IRRELEVANT = 0.10. Drop the CLI main or keep it, either is fine. Add a docstring line: Vendored from ML-NEWS news_transformer.py at commit <sha>; bundle trained_at <meta.json trained_at>.

Imports. onnxruntime and tokenizers must stay imported inside load_model (as in news_transformer.py:58-59). Module top imports only numpy, json, logging, os, pathlib, threading. Reason: news_engine.py will import news_model at its top; if onnxruntime is missing on a box, the bot must still start.

3.3 Singleton

_lock = threading.Lock()
_bundle: Optional[dict] = None
_failed: bool = False          # tried and failed; don't retry, don't re-log

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()
            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 errors
            logger.warning("news model unavailable, DeepSeek/keyword path stays: %s: %s",
                           type(e).__name__, e)
            _failed = True
        return _bundle

def reset_for_tests() -> None:  # clears _bundle/_failed; used by test_all.py

Optional: warm_up_in_background() that calls get_news_model() in a daemon thread at NewsEngine.__init__, so the first news cycle isn't blocked by the ~3–5 s session creation under _cache_lock. Nice to have, not required.

3.4 Two changes inside load_model (also apply to ML-NEWS news_transformer.py so the copies stay identical)

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")

3.5 Contract score_batch(bundle, headlines: list[str]) -> list[dict]

Unchanged from ML-NEWS. Empty or whitespace-only headline raises ValueError from clean_headline; callers pass already-non-empty NewsSentiment.headline, but §5 still wraps per-batch in try/except. Batch size: _apply_model_sentiment sends all of a cycle's headlines in one call (typically 30–150); score_batch pads to the longest in the batch, ≤ 64 tokens, so one call of 150 is ~1 s on 4 threads.


4. Configuration

4.1 config.json section (add to config.json.example too)

"news_model": {
  "path": "/home/Forex/models/xau_transformer",
  "threads": 4,
  "mode": "primary",
  "deepseek_audit_per_day": 0
}
Key Type Default if absent Meaning
path str required when mode ≠ off directory with the three bundle files
threads int ≥ 1 4 ORT intra-op threads (measured: 1→19 ms, 4→9 ms, 16→13 ms)
mode "off"/"fallback"/"primary" "off" see §5.2
deepseek_audit_per_day int ≥ 0 0 primary mode only: headlines/day to also send to DeepSeek, archive-only

Whole section absent → mode="off" → today's behaviour exactly.

Environment override: NEWS_MODEL_MODE=off|fallback|primary wins over config.json. Purpose: the dashboard daemon's systemd unit can turn the model off for that process only. Pattern to copy: NEWS_ARCHIVE_ENABLED in app_config.py:574-583.

4.2 app_config.py edits (follow the news_archive section, :397-406, accessors :574/:588)

  1. _KNOWN_TOP (:86-92): add "news_model". Required; _check_keys(raw, _KNOWN_TOP, "top level") at :181 makes boot fail on an unknown top-level key.
  2. _KNOWN_NEWS_MODEL = {"path", "threads", "mode", "deepseek_audit_per_day"} next to _KNOWN_NEWS_ARCHIVE (:113).
  3. AppConfig dataclass (~:147): news_model_settings: Dict[str, Any] = field(default_factory=dict).
  4. In load_app_config, after the news_archive block:
    nm = raw.get("news_model", {})
    _check_keys(nm, _KNOWN_NEWS_MODEL, '"news_model"')
    if "mode" in nm and nm["mode"] not in ("off", "fallback", "primary"):
        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 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):
        raise AppConfigError("config.json: news_model.path is required when mode is not off")
    app.news_model_settings = dict(nm)
    
  5. Accessors:
    def news_model_mode() -> str:          # env NEWS_MODEL_MODE overrides; default "off"
    def news_model_path() -> Optional[str]
    def news_model_threads() -> int        # default 4
    def news_model_audit_per_day() -> int  # default 0
    

NewsConfig in config.py:169 is not touched: tests construct it bare as NewsConfig(), and it holds feed/threshold settings, not paths. news_engine.py reads the new settings through app_config the same way it reaches DeepSeek through get_llm_client().


5. news_engine.py changes

5.1 New method _apply_model_sentiment

Place next to _apply_llm_sentiment (:1032). Runs under _cache_lock like it.

def _apply_model_sentiment(self, headlines: List[NewsSentiment]) -> int:
    """Score with the local XAU transformer every headline that is still
    keyword-scored. Returns the number scored. Never raises.

    The model has a gold head only. It writes xau_score always, XAU's
    per-currency score when XAU is mentioned, and the overall score only
    when the headline names no other currency. USD/EUR/... entries keep
    their keyword score: get_pair_sentiment subtracts USD from XAU and
    must not see the same number on both sides.
    """
    bundle = news_model.get_news_model()
    if bundle is None or not headlines:
        return 0
    todo = [h for h in headlines if h.score_source == "keyword"]
    if not todo:
        return 0
    try:
        results = news_model.score_batch(bundle, [h.headline for h in todo])
    except Exception as e:
        logger.debug(f"news model scoring failed, keyword scores stand: {e}")
        return 0
    for h, r in zip(todo, results):
        raw = float(r["score"])                       # archive value, unmodified
        shown = raw if r["relevant"] else 0.0         # display value, see rule D
        h.xau_score = raw
        if "XAU" in h.currencies_mentioned:
            h.sentiment_by_currency["XAU"] = shown
        if set(h.currencies_mentioned) <= {"XAU"}:    # [] or ["XAU"]
            h.sentiment_score = shown
        h.score_source = "model"
    return len(todo)

Rules, with the reason each exists:

  • A. Never write the model score into a non-XAU currency. get_pair_sentiment (:553-557) forms XAU_mean − USD_mean; signal_aggregator.py:1677/1723 acts on it. The DeepSeek path puts its overall score into USD; the model has no overall score, so USD keeps the keyword number.
  • B. sentiment_score from the model only when the headline names no other currency. The archive's score column is sentiment_score, and the ML-NEWS replay eval reads it; for a gold-only headline it should be the model's number, for "Fed hikes, dollar jumps" it stays keyword so the row is honest about what the model did and didn't score. score_source="model" therefore means "XAU score is the model's" — amend the dataclass comment at :223-227 to say so.
  • C. xau_score is always the raw signed score. That column exists for training data (:229-237); keep it unfiltered.
  • D. Display fields get 0.0 when relevant is False. A headline the model calls not-relevant can still carry |score| up to ~0.2 from a confident direction head; NEUTRAL_HEADLINE_THRESHOLD = 0.1 (:256) would let 0.15 through as "leaning bearish". Zeroing the shown value makes "neutral" mean neutral. (0.1 happens to equal the model's irrelevant band RELEVANT_MAX_IRRELEVANT, so the two notions agree.)
  • E. Do not touch NewsEngine._llm_sent_cache. That cache decides what is re-sent to DeepSeek. In fallback mode a headline that failed DeepSeek must stay uncached so it is retried next window and its archive row upgrades model → llm through the rank rule (news_archive.py:203-260).
  • F. Only score_source == "keyword" rows are touched. Never overwrite an llm row in memory.

5.2 Call site (:944-947)

Replace

scored = headlines + unmatched
try:
    self._apply_llm_sentiment(scored)
except Exception as e:
    logger.debug(f"LLM sentiment failed, keyword scores stand: {e}")

with

scored = headlines + unmatched
mode = app_config.news_model_mode()
try:
    if mode == "primary":
        n = self._apply_model_sentiment(scored)
        if n == 0 and news_model.get_news_model() is None:
            # model unavailable this process: degrade to DeepSeek, not to keywords
            self._apply_llm_sentiment(scored)
        else:
            self._audit_with_deepseek(scored)         # no-op when audit_per_day == 0
    elif mode == "fallback":
        self._apply_llm_sentiment(scored)
        self._apply_model_sentiment(scored)           # only what DeepSeek left keyword-scored
    else:
        self._apply_llm_sentiment(scored)
except Exception as e:
    logger.debug(f"headline sentiment enrichment failed, keyword scores stand: {e}")

import news_model and import app_config at the module top of news_engine.py (news_model's top imports only numpy, §3.2).

Mode semantics:

mode DeepSeek for headlines model when a step fails
off as today never keyword scores stand (today)
fallback first, as today on rows DeepSeek left keyword-scored keyword scores stand
primary never (except audit) on all rows model missing → DeepSeek path for the cycle; model raises mid-batch → keyword

5.3 Audit sample (primary mode, optional feature, default off)

_audit_day: str = ""          # class attrs, like the caches
_audit_sent_today: int = 0

def _audit_with_deepseek(self, headlines):
    cap = app_config.news_model_audit_per_day()
    if cap <= 0 or not get_llm_client().available:
        return
    today = datetime.now(timezone.utc).date().isoformat()
    if NewsEngine._audit_day != today:
        NewsEngine._audit_day, NewsEngine._audit_sent_today = today, 0
    room = cap - NewsEngine._audit_sent_today
    if room <= 0:
        return
    cand = [h for h in headlines if h.score_source == "model"
            and hashlib.sha1(h.headline.encode()).hexdigest() not in NewsEngine._llm_sent_cache]
    batch = cand[:min(room, self._LLM_BATCH_CAP)]
    if not batch:
        return
    scores = self._llm_headline_scores([h.headline for h in batch])
    if scores is None:
        return
    NewsEngine._audit_sent_today += len(batch)
    copies = []
    for h, (overall, xau) in zip(batch, scores):
        NewsEngine._llm_sent_cache[hashlib.sha1(h.headline.encode()).hexdigest()] = (overall, xau)
        c = copy.copy(h)                     # the displayed object keeps the model score
        c.sentiment_score = overall
        c.sentiment_by_currency = {cur: (xau if cur == "XAU" else overall) for cur in h.currencies_mentioned}
        c.xau_score = xau
        c.score_source = "llm"
        copies.append(c)
    self._pending_audit_rows = copies        # archived by the call site right after `scored`

The call site then archives scored first and self._pending_audit_rows second (same teacher_model/teacher_prompt_sha1 as today's DeepSeek rows); the rank rule upgrades those rows to llm, the in-memory objects the bot displays keep the model's numbers. One batch per cycle at most, ≤ cap per UTC day. This is the only place DeepSeek is called for headlines in primary mode.

5.4 Archive call (:958-964)

Pass, in addition to today's kwargs:

model_teacher=news_model.teacher_tag()   # "xau_transformer@<meta.trained_at>" or None

where teacher_tag() reads the loaded bundle's meta (None if not loaded). See §6.

5.5 Dataclass comment

NewsSentiment.score_source comment (:223-227): add "model" = the local XAU transformer scored xau_score (and XAU's per-currency score); other currencies remain keyword-scored.


6. news_archive.py changes

No schema change. _SOURCE_RANK (:107) and _STORED_RANK (:203-206) already rank model = 2.

  • archive_headlines(headlines, *, teacher_model=None, teacher_prompt_sha1=None, origin="live", model_teacher=None) — new kwarg, passed through to build_row.
  • build_row (:323-354):
    "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),
    
    For a model row teacher_prompt_sha1 is the DeepSeek prompt the model was distilled from (the same constant the bot already passes), which is the truthful provenance.
  • Effect: a retrain is visible in the data (teacher_model changes), and the UPSERT's COALESCE(excluded.teacher_model, headlines.teacher_model) (:253-260) only applies when the new rank ≥ stored rank, so an llm row's teacher is never replaced by a model tag.

7. Tests (test_all.py)

The suite is a plain script: @test(name) (:51-64) runs each function at definition time and tallies; the file ends with sys.exit(1 if FAIL > 0 else 0). Run: venv/bin/python test_all.py. Stubbing patterns in use: instance-level ne._llm_headline_scores = lambda texts: … with lc._client = _StubLLM({...}) and finally cleanup (:5196-5215); module-level swap saved = _ne_mod.get_llm_client; _ne_mod.get_llm_client = stub; … finally restore (:11391-11424).

Add, using the same patterns and a fake bundle (news_model.score_batch swapped on the module):

  1. model scores keyword rows only. Two NewsSentiments, one score_source="llm"; after _apply_model_sentiment, the llm one is byte-identical, the keyword one has score_source="model", xau_score set, sentiment_by_currency["XAU"] = score, ["USD"] unchanged (rule A).
  2. overall score rule (B). currencies_mentioned=["XAU"] → sentiment_score = model score; ["USD","XAU"] → sentiment_score unchanged; [] → model score.
  3. relevance zeroing (D). stub returns relevant=False, score=-0.18 → sentiment_by_currency["XAU"] == 0.0, xau_score == -0.18.
  4. cache untouched (E). _llm_sent_cache empty before and after.
  5. failure isolation. stub raises → all rows still keyword, method returns 0, no exception. news_model.get_news_model() returning None → returns 0.
  6. primary degrades to DeepSeek. get_news_model → None, _llm_headline_scores stubbed → rows end up llm.
  7. audit sample. deepseek_audit_per_day=3, 10 model rows → exactly 3 sent, displayed objects still model, _pending_audit_rows has 3 llm copies; second call same day sends 0.
  8. config. unknown key in news_model → AppConfigError; mode="banana" → error; section absent → news_model_mode() == "off"; NEWS_MODEL_MODE=off env beats "mode": "primary".
  9. archive. build_row of a model headline with model_teacher="xau_transformer@T" stores it; an llm row stores the DeepSeek teacher; existing rank tests still pass.
  10. import hygiene. update the docstring of the test at :11437-11462 (it says numpy-only); add an assertion that requirements.txt lists onnxruntime and tokenizers; keep its "no torch / transformers" checks.
  11. integration, skipped when the bundle is absent (os.environ.get("NEWS_MODEL_DIR") or the configured path missing → print SKIP): load, score_news(b, "Fed cuts rates, signals more easing")["label"] == "bullish", score_news(b, "Red Lobster endless shrimp losses")["label"] == "neutral", latency of 20 calls < 1 s.

test_all.py:3675-3690 loads config.json.example for pair output only; adding the section there is safe.


8. Ops checklist → deploy/README-news-model.md (new)

Covers both processes: the bot (telegram_bot.py) and the dashboard daemon (deploy/gold-dashboard.service).

  1. free -m. Each model-loading process needs ~0.7 GB headroom. If only the bot should score with the model, add Environment=NEWS_MODEL_MODE=off to deploy/gold-dashboard.service (the dashboard then keeps using DeepSeek for its headlines; those llm rows land in the shared archive and act as free audit labels).
  2. cd /home/Forex/ForexBot2 && git pull && venv/bin/pip install -r requirements.txt.
  3. mkdir -p /home/Forex/models/xau_transformer and copy model.onnx, tokenizer.json, meta.json from the ML-NEWS machine (rsync, ~280 MB). Verify sha256sum model.onnx matches the source. Owner: the account the bot runs as; read-only is enough.
  4. Add the news_model section to config.json (§4.1) with "mode": "primary". Leave the deepseek section unchanged.
  5. Smoke test without starting the bot:
    venv/bin/python -c "import news_model as m; b=m.get_news_model(); print(m.score_news(b,'Gold jumps as dollar slides'))"
    
    Expect label: bullish, and the "news model loaded" log line.
  6. venv/bin/python test_all.py — green (integration test runs because the dir exists).
  7. Restart the bot; restart the dashboard unit. Check:
    • log: one "news model loaded" line per process, no "news model unavailable";
    • top: RSS ~+0.6 GB per process, idle CPU ~0% between scans (spinning disabled);
    • archive: new rows have score_source='model', xau_score set, teacher_model xau_transformer@…; DeepSeek daily counter for headlines stays at 0 (audit off).
  8. Rollback: set "mode": "fallback" or "off" (or the env var), restart. No data migration needed.
  9. Retrain cadence: when ML-NEWS ships a new bundle, copy the three files over the old ones and restart both processes. meta.json.trained_at changes, so the archive shows which model scored which rows. If deepseek_audit_per_day is set, ML-NEWS's eval/eval_archive.py --scorer transformer on a copy of the live archive gives the model-vs-DeepSeek agreement on those rows.

9. Acceptance criteria (end-to-end)

  • With mode="primary" and DeepSeek enabled, one live news cycle archives only model rows (plus ≤ deepseek_audit_per_day llm rows), and the cycle's added latency is < 1 s for ~100 headlines.
  • With the model directory renamed away, both processes start, log one warning each, and the cycle's rows are llm (DeepSeek path), not keyword.
  • With onnxruntime uninstalled in a scratch venv, python -c "import news_engine" succeeds.
  • A headline naming USD and XAU ends with sentiment_by_currency["USD"] equal to the keyword value and ["XAU"] equal to the model value.
  • mode="off" reproduces today's archive rows exactly (llm/keyword, same teacher fields).
  • venv/bin/python test_all.py exits 0.

10. Known limits to state in the README

  • The model copies DeepSeek's opinion (72% agreement on unseen live days, 72/78 golden); it does not learn from price. DeepSeek itself is ~90% self-consistent.
  • Gold only. Every other currency's sentiment remains the ~31%-accurate keyword scorer in primary mode, which is what those currencies get today whenever DeepSeek is unavailable.
  • With audit off, no new DeepSeek labels accrue; the ML-NEWS retrain loop needs either deepseek_audit_per_day > 0, the dashboard left on DeepSeek, or a separate labelling run.