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>
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
# 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`):
|
||||
|
||||
```python
|
||||
{"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
|
||||
```python
|
||||
_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)
|
||||
```python
|
||||
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)
|
||||
```json
|
||||
"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:
|
||||
```python
|
||||
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:
|
||||
```python
|
||||
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.
|
||||
|
||||
```python
|
||||
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
|
||||
```python
|
||||
scored = headlines + unmatched
|
||||
try:
|
||||
self._apply_llm_sentiment(scored)
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM sentiment failed, keyword scores stand: {e}")
|
||||
```
|
||||
with
|
||||
```python
|
||||
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)
|
||||
```python
|
||||
_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:
|
||||
```python
|
||||
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):
|
||||
```python
|
||||
"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 `NewsSentiment`s, 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`).
|
||||
|
||||
0. `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).
|
||||
1. `cd /home/Forex/ForexBot2 && git pull && venv/bin/pip install -r requirements.txt`.
|
||||
2. `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.
|
||||
3. Add the `news_model` section to `config.json` (§4.1) with `"mode": "primary"`. Leave the
|
||||
`deepseek` section unchanged.
|
||||
4. 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.
|
||||
5. `venv/bin/python test_all.py` — green (integration test runs because the dir exists).
|
||||
6. 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).
|
||||
7. Rollback: set `"mode": "fallback"` or `"off"` (or the env var), restart. No data migration needed.
|
||||
8. 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.
|
||||
Reference in New Issue
Block a user