28 KiB
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:
primarymode does not archive the cycle's rows. Owner decision during implementation: once the local model scores,logs/newsdata.dbstops growing, because the model's own output is not training data. Only the DeepSeek audit copies (deepseek_audit_per_day, shipped 0) are written.offandfallbackarchive as before (afallbackmodel row carriesmodel_teacher). §5.4 and §9 read accordingly._audit_with_deepseekreturns thellmcopies; there is no_pending_audit_rowsattribute. The mode switch of §5.2 lives inNewsEngine._enrich_headline_scores(scored, mode), which returns those copies, so the call site and the tests share one function.config.json.exampleships"mode": "off"; the live server setsprimary(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=offstays as the memory fallback. warm_up_in_background()is implemented (module-level once flag; aNewsEngineis built per scan).news_model.get_news_model()also treats a missingpathas a load failure (one warning).requirements.txtranges: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.pyreceived the §3.4 edits in the same session; the vendored copy differs only inload_modelhaving no default directory.
Repos:
- ML-NEWS
~/Development/ML-NEWS(gitssh://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 bygit 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):
- RSS items become
NewsSentimentobjects (:891-910) with the keyword scorer fillingsentiment_scoreandsentiment_by_currency[cur]for each currency incurrencies_mentioned,score_source="keyword",xau_score=None. scored = headlines + unmatched(:944);self._apply_llm_sentiment(scored)(:945, in try/except)._apply_llm_sentiment(:1032-1065) sends headlines not inNewsEngine._llm_sent_cacheto DeepSeek in batches of_LLM_BATCH_CAP = 25viaself._llm_headline_scores(texts)(:1009) and, on success, overwritessentiment_score,sentiment_by_currency(XAU gets the gold score, every other currency the overall score), setsxau_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.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) storesteacher_model/teacher_prompt_sha1only whensource == "llm".- Downstream consumers of the scores:
get_fundamental_context(drops |score| <NEUTRAL_HEADLINE_THRESHOLD = 0.1, :256),get_pair_sentiment(:553-557, computesXAU_mean − USD_mean),signal_aggregator.py:1677/1723(acts on that whensentiment_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_directionfor calendar surprises, the news summary, the dashboard's on-demandnews_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)
_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._KNOWN_NEWS_MODEL = {"path", "threads", "mode", "deepseek_audit_per_day"}next to_KNOWN_NEWS_ARCHIVE(:113).AppConfigdataclass (~:147):news_model_settings: Dict[str, Any] = field(default_factory=dict).- In
load_app_config, after thenews_archiveblock: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) - 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) formsXAU_mean − USD_mean;signal_aggregator.py:1677/1723acts 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_scorefrom the model only when the headline names no other currency. The archive'sscorecolumn issentiment_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_scoreis always the raw signed score. That column exists for training data (:229-237); keep it unfiltered. - D. Display fields get 0.0 when
relevantis 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 bandRELEVANT_MAX_IRRELEVANT, so the two notions agree.) - E. Do not touch
NewsEngine._llm_sent_cache. That cache decides what is re-sent to DeepSeek. Infallbackmode a headline that failed DeepSeek must stay uncached so it is retried next window and its archive row upgradesmodel→llmthrough the rank rule (news_archive.py:203-260). - F. Only
score_source == "keyword"rows are touched. Never overwrite anllmrow 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 tobuild_row.build_row(:323-354):For a model row"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),teacher_prompt_sha1is 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_modelchanges), and the UPSERT'sCOALESCE(excluded.teacher_model, headlines.teacher_model)(:253-260) only applies when the new rank ≥ stored rank, so anllmrow'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):
- model scores keyword rows only. Two
NewsSentiments, onescore_source="llm"; after_apply_model_sentiment, the llm one is byte-identical, the keyword one hasscore_source="model",xau_scoreset,sentiment_by_currency["XAU"]= score,["USD"]unchanged (rule A). - overall score rule (B).
currencies_mentioned=["XAU"]→sentiment_score= model score;["USD","XAU"]→sentiment_scoreunchanged;[]→ model score. - relevance zeroing (D). stub returns
relevant=False, score=-0.18→sentiment_by_currency["XAU"] == 0.0,xau_score == -0.18. - cache untouched (E).
_llm_sent_cacheempty before and after. - failure isolation. stub raises → all rows still
keyword, method returns 0, no exception.news_model.get_news_model()returning None → returns 0. - primary degrades to DeepSeek.
get_news_model→ None,_llm_headline_scoresstubbed → rows end upllm. - audit sample.
deepseek_audit_per_day=3, 10 model rows → exactly 3 sent, displayed objects stillmodel,_pending_audit_rowshas 3llmcopies; second call same day sends 0. - config. unknown key in
news_model→AppConfigError;mode="banana"→ error; section absent →news_model_mode() == "off";NEWS_MODEL_MODE=offenv beats"mode": "primary". - archive.
build_rowof amodelheadline withmodel_teacher="xau_transformer@T"stores it; anllmrow stores the DeepSeek teacher; existing rank tests still pass. - import hygiene. update the docstring of the test at :11437-11462 (it says numpy-only); add
an assertion that
requirements.txtlistsonnxruntimeandtokenizers; keep its "no torch / transformers" checks. - 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).
free -m. Each model-loading process needs ~0.7 GB headroom. If only the bot should score with the model, addEnvironment=NEWS_MODEL_MODE=offtodeploy/gold-dashboard.service(the dashboard then keeps using DeepSeek for its headlines; thosellmrows land in the shared archive and act as free audit labels).cd /home/Forex/ForexBot2 && git pull && venv/bin/pip install -r requirements.txt.mkdir -p /home/Forex/models/xau_transformerand copymodel.onnx,tokenizer.json,meta.jsonfrom the ML-NEWS machine (rsync, ~280 MB). Verifysha256sum model.onnxmatches the source. Owner: the account the bot runs as; read-only is enough.- Add the
news_modelsection toconfig.json(§4.1) with"mode": "primary". Leave thedeepseeksection unchanged. - Smoke test without starting the bot:
Expect
venv/bin/python -c "import news_model as m; b=m.get_news_model(); print(m.score_news(b,'Gold jumps as dollar slides'))"label: bullish, and the "news model loaded" log line. venv/bin/python test_all.py— green (integration test runs because the dir exists).- 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_scoreset,teacher_modelxau_transformer@…; DeepSeek daily counter for headlines stays at 0 (audit off).
- Rollback: set
"mode": "fallback"or"off"(or the env var), restart. No data migration needed. - Retrain cadence: when ML-NEWS ships a new bundle, copy the three files over the old ones and
restart both processes.
meta.json.trained_atchanges, so the archive shows which model scored which rows. Ifdeepseek_audit_per_dayis set, ML-NEWS'seval/eval_archive.py --scorer transformeron 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 onlymodelrows (plus ≤deepseek_audit_per_dayllmrows), 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), notkeyword. - With
onnxruntimeuninstalled 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.pyexits 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.