Initial import: XAUUSD news sentiment models, evals, and data tooling

TF-IDF baseline (news_sentiment.py, models/xauusd_sentiment.joblib) and the
two-head DeBERTa-v3-small transformer (transformer/, news_transformer.py)
with its Google News crawler and DeepSeek labeller (data_collect/), the
golden-set and archive evals (eval/), and the 2026-09-22 and 2026-09-27
results and progress notes.

Transformer weights (model.pt, model.onnx) and the crawl database are not
tracked; rebuild with transformer/train.py and transformer/export_onnx.py.
The DeepSeek label cache in data/ is tracked.
This commit is contained in:
Harwinder Sekhon
2026-09-27 19:22:30 -04:00
commit 01baef48ff
98 changed files with 1719757 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Export a trained two-head model to int8 ONNX for CPU scoring.
Reads a train.py output directory (model.pt, tokenizer, meta.json),
exports ONNX (inputs input_ids / attention_mask, outputs
relevance_logit / direction_logit), quantises the embedding table to
int8 (weight-only; compute stays fp32), and writes the runtime bundle
news_transformer.py loads:
models/xau_transformer/model.onnx
models/xau_transformer/tokenizer.json
models/xau_transformer/meta.json
Why only the embeddings: on DeBERTa-v3 any dynamic int8 quantisation
of the transformer MatMuls (all, per-channel, reduce-range, FFN only)
moved probabilities by up to 0.55-0.72 and flipped 13-16% of decisions
(activation outliers). The 128k x 768 embedding table is 70% of the
weights; quantising it alone halves the file (566 -> 271 MB) with mean
|diff| 0.004 and ~1% flips, all near 0.5. Latency is set by the fp32
encoder: ~18 ms per headline on 1 thread, ~9 ms on 4.
Then checks, on up to 500 headlines, that the runtime tokenizer produces
the same ids as the training tokenizer, that probabilities stay within
--tolerance of the PyTorch fp32 ones and flip at most --max-flip-rate of
decisions, and times single-headline CPU latency.
Usage:
.venv-train/bin/python transformer/export_onnx.py # torch_final if present
.venv-train/bin/python transformer/export_onnx.py --src models/xau_transformer/torch
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import time
from pathlib import Path
import numpy as np
import torch
ML_NEWS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ML_NEWS_DIR))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import news_transformer # noqa: E402
from train import TwoHeadModel, load_rows, split # noqa: E402
BUNDLE_DIR = ML_NEWS_DIR / "models" / "xau_transformer"
class Exportable(torch.nn.Module):
def __init__(self, model: TwoHeadModel):
super().__init__()
self.model = model
def forward(self, input_ids, attention_mask):
return self.model(input_ids, attention_mask)
def export(model: TwoHeadModel, tokenizer, path: Path) -> None:
sample = tokenizer(["Gold rises as the dollar weakens", "Fed hikes"], padding=True,
return_tensors="pt")
args = (sample["input_ids"], sample["attention_mask"])
wrapper = Exportable(model).eval()
# TorchScript exporter: the dynamo exporter's value_info shapes fail
# onnxruntime's shape inference during quantisation (768 vs 1).
torch.onnx.export(
wrapper, args, str(path),
input_names=["input_ids", "attention_mask"],
output_names=["relevance_logit", "direction_logit"],
dynamic_axes={"input_ids": {0: "batch", 1: "sequence"},
"attention_mask": {0: "batch", 1: "sequence"},
"relevance_logit": {0: "batch"}, "direction_logit": {0: "batch"}},
opset_version=17, dynamo=False)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--src", type=Path, default=None,
help="train.py output dir (default: torch_final, else torch)")
ap.add_argument("--out", type=Path, default=BUNDLE_DIR)
ap.add_argument("--tolerance", type=float, default=0.05,
help="max |probability difference| vs PyTorch fp32")
ap.add_argument("--max-flip-rate", type=float, default=0.02,
help="max share of relevance/direction decisions that may flip")
ap.add_argument("--check-rows", type=int, default=500)
args = ap.parse_args()
src = args.src or next(p for p in (BUNDLE_DIR / "torch_final", BUNDLE_DIR / "torch")
if (p / "model.pt").is_file())
meta = json.loads((src / "meta.json").read_text())
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(src)
model = TwoHeadModel(meta["base_model"])
model.load_state_dict(torch.load(src / "model.pt", map_location="cpu"))
model.eval()
args.out.mkdir(parents=True, exist_ok=True)
fp32 = args.out / "model.fp32.onnx"
int8 = args.out / "model.onnx"
print(f"exporting {src} -> {fp32}")
export(model, tokenizer, fp32)
from onnxruntime.quantization import QuantType, quantize_dynamic
quantize_dynamic(str(fp32), str(int8), weight_type=QuantType.QInt8,
op_types_to_quantize=["Gather"])
print(f"embeddings int8 -> {int8} ({int8.stat().st_size / 1e6:.0f} MB, "
f"fp32 {fp32.stat().st_size / 1e6:.0f} MB)")
shutil.copy(src / "tokenizer.json", args.out / "tokenizer.json")
runtime_meta = {
**meta,
"onnx_file": int8.name,
"quantization": "int8 weight-only embedding table; fp32 compute",
"pad_token": tokenizer.pad_token,
"pad_token_id": tokenizer.pad_token_id,
"exported_from": os.path.relpath(src.resolve(), ML_NEWS_DIR),
}
(args.out / "meta.json").write_text(json.dumps(runtime_meta, indent=2, default=str) + "\n")
# ── parity: tokenizer ids and probabilities on held-out headlines ──
_, _, val = split(load_rows(), meta["hyperparameters"]["val_from"],
meta["hyperparameters"]["seed"])
texts = [r["text"] for r in val[: args.check_rows]]
bundle = news_transformer.load_model(args.out)
tok_mismatch = sum(
bundle["tokenizer"].encode(t).ids
!= tokenizer(t, truncation=True, max_length=news_transformer.MAX_LENGTH)["input_ids"]
for t in texts)
with torch.no_grad():
ref_rel, ref_dir = [], []
for start in range(0, len(texts), 64):
enc = tokenizer(texts[start:start + 64], padding=True, truncation=True,
max_length=news_transformer.MAX_LENGTH, return_tensors="pt")
r, d = model(enc["input_ids"], enc["attention_mask"])
ref_rel.append(torch.sigmoid(r))
ref_dir.append(torch.sigmoid(d))
ref_rel = torch.cat(ref_rel).numpy()
ref_dir = torch.cat(ref_dir).numpy()
got = []
for start in range(0, len(texts), 64):
got += news_transformer.score_batch(bundle, texts[start:start + 64])
diff_rel = np.abs(ref_rel - np.array([g["p_relevant"] for g in got]))
diff_dir = np.abs(ref_dir - np.array([g["p_bullish"] for g in got]))
thr = runtime_meta["relevance_threshold"]
flips = int(sum((a >= thr) != g["relevant"] for a, g in zip(ref_rel, got))
+ sum((b >= 0.5) != (g["direction"] == "bullish") for b, g in zip(ref_dir, got)))
print(f"tokenizer mismatches: {tok_mismatch}/{len(texts)}")
print(f"int8 vs fp32 over {len(texts)}: p_relevant |diff| mean {diff_rel.mean():.4f} max "
f"{diff_rel.max():.4f}; p_bullish mean {diff_dir.mean():.4f} max {diff_dir.max():.4f}; "
f"decision flips {flips}")
# ── latency: one headline at a time, as the live feed would call it ──
latency = {}
for threads in (1, os.cpu_count()):
b = news_transformer.load_model(args.out, threads=threads)
for t in texts[:20]:
news_transformer.score_news(b, t)
t0 = time.perf_counter()
for t in texts[:200]:
news_transformer.score_news(b, t)
latency[threads] = (time.perf_counter() - t0) * 1000 / min(200, len(texts))
print(f"CPU latency, {threads} thread(s): {latency[threads]:.1f} ms per headline")
runtime_meta["export_checks"] = {
"rows": len(texts), "tokenizer_mismatches": tok_mismatch,
"p_relevant_max_abs_diff": float(diff_rel.max()),
"p_bullish_max_abs_diff": float(diff_dir.max()),
"decision_flips": flips,
"latency_ms_per_headline": {str(k): round(v, 2) for k, v in latency.items()},
}
(args.out / "meta.json").write_text(json.dumps(runtime_meta, indent=2, default=str) + "\n")
fp32.unlink()
flip_rate = flips / (2 * len(texts))
ok = (tok_mismatch == 0 and max(diff_rel.max(), diff_dir.max()) <= args.tolerance
and flip_rate <= args.max_flip_rate)
print("PARITY OK" if ok else
f"PARITY FAILED (tolerance {args.tolerance}, flip rate {flip_rate:.1%} "
f"> {args.max_flip_rate:.0%}?)")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Fine-tune a two-head transformer: gold-relevant? and bullish/bearish.
Data (all labels are DeepSeek's live-prompt XAU score):
* data/history.db crawled headlines 2019-2026 + labels table
* newsdata.db the live archive (xau_score -> JSON XAU -> backfill)
Headlines are deduplicated on case-folded text (median xau). Anything in
the golden set is removed from every split.
Targets per headline, from xau:
relevance 1 if |xau| >= 0.25, 0 if |xau| <= 0.10, ignored in between
direction sign(xau), only where relevance = 1
Splits:
val newsdata rows published on or after --val-from (2026-09-18):
the unseen days of the 2026-09-22 report, never used to choose
anything
dev a seeded random 5% of the rest: picks the best epoch and the
relevance threshold
train everything else
``--final`` trains on train + dev + val for the epoch count chosen by
the last non-final run, for deployment.
Outputs models/xau_transformer/torch/ (state dict, tokenizer, meta.json);
``--final`` writes models/xau_transformer/torch_final/.
Export for CPU with transformer/export_onnx.py.
Usage:
.venv-train/bin/python transformer/train.py
.venv-train/bin/python transformer/train.py --final
"""
from __future__ import annotations
import argparse
import json
import math
import random
import sqlite3
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from statistics import median
import numpy as np
import torch
from torch import nn
from transformers import AutoModel, AutoTokenizer, get_linear_schedule_with_warmup
ML_NEWS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ML_NEWS_DIR))
sys.path.insert(0, str(ML_NEWS_DIR / "eval"))
from news_transformer import (MAX_LENGTH, RELEVANT_MAX_IRRELEVANT, # noqa: E402
RELEVANT_MIN, clean_headline)
HISTORY_DB = ML_NEWS_DIR / "data" / "history.db"
NEWSDATA_DB = ML_NEWS_DIR / "newsdata.db"
BACKFILL = ML_NEWS_DIR / "eval" / "results" / "backfill_labels.jsonl"
OUT_DIR = ML_NEWS_DIR / "models" / "xau_transformer" / "torch"
IGNORE = -1.0
class TwoHeadModel(nn.Module):
"""Mean-pooled encoder with a relevance logit and a direction logit."""
def __init__(self, base: str, dropout: float = 0.1):
super().__init__()
# transformers 5 loads the checkpoint's stored dtype (fp16 for
# deberta-v3-small) by default; fp16 master weights overflow under
# AdamW. Keep fp32 weights; autocast does bf16 compute.
self.encoder = AutoModel.from_pretrained(base, dtype=torch.float32)
hidden = self.encoder.config.hidden_size
self.dropout = nn.Dropout(dropout)
self.relevance = nn.Linear(hidden, 1)
self.direction = nn.Linear(hidden, 1)
def forward(self, input_ids, attention_mask):
states = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
mask = attention_mask.unsqueeze(-1).to(states.dtype)
pooled = (states * mask).sum(1) / mask.sum(1).clamp(min=1.0)
pooled = self.dropout(pooled)
return self.relevance(pooled).squeeze(-1), self.direction(pooled).squeeze(-1)
# ── data ─────────────────────────────────────────────────────────────
def golden_texts() -> set[str]:
from eval_common import load_golden
return {clean_headline(g["headline"]).casefold() for g in load_golden()}
def load_rows() -> list[dict]:
raw: list[dict] = []
if HISTORY_DB.is_file():
uri = f"{HISTORY_DB.resolve().as_uri()}?mode=ro"
with sqlite3.connect(uri, uri=True) as conn:
has_labels = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table'"
" AND name = 'labels'").fetchone()
for headline, published_at, xau in [] if not has_labels else conn.execute(
"SELECT h.headline, h.published_at, l.xau FROM headlines h"
" JOIN labels l ON l.hash = h.hash"):
raw.append({"headline": headline, "published_at": published_at or "",
"xau": float(xau), "source": "history"})
from eval_common import load_archive_rows
for r in load_archive_rows(NEWSDATA_DB, BACKFILL if BACKFILL.is_file() else None):
if r["score_source"] == "llm" and r["xau"] is not None and r["headline"]:
raw.append({"headline": r["headline"],
"published_at": r["published_at"] or r["first_seen"] or "",
"xau": float(r["xau"]), "source": "newsdata"})
golden = golden_texts()
grouped: dict[str, dict] = {}
dropped_golden = 0
for r in raw:
try:
text = clean_headline(r["headline"])
except ValueError:
continue
key = text.casefold()
if key in golden:
dropped_golden += 1
continue
g = grouped.setdefault(key, {"text": text, "xaus": [], "dates": [], "sources": set()})
g["xaus"].append(r["xau"])
g["dates"].append(r["published_at"])
g["sources"].add(r["source"])
rows = []
for g in grouped.values():
xau = float(median(g["xaus"]))
a = abs(xau)
rel = 1.0 if a >= RELEVANT_MIN else 0.0 if a <= RELEVANT_MAX_IRRELEVANT else IGNORE
rows.append({
"text": g["text"], "xau": xau, "published_at": max(g["dates"]),
"source": "newsdata" if "newsdata" in g["sources"] else "history",
"rel": rel, "dir": (1.0 if xau > 0 else 0.0) if rel == 1.0 else IGNORE,
})
print(f"{len(raw)} labelled rows -> {len(rows)} unique headlines "
f"({dropped_golden} golden-set duplicates dropped)")
return rows
def split(rows: list[dict], val_from: str, seed: int):
val = [r for r in rows if r["source"] == "newsdata" and r["published_at"] >= val_from]
rest = [r for r in rows if not (r["source"] == "newsdata" and r["published_at"] >= val_from)]
rng = random.Random(seed)
rng.shuffle(rest)
n_dev = max(200, len(rest) // 20)
return rest[n_dev:], rest[:n_dev], val
def describe(name: str, rows: list[dict]) -> dict:
rel = Counter(r["rel"] for r in rows)
d = Counter(r["dir"] for r in rows if r["dir"] != IGNORE)
years = Counter(r["published_at"][:4] for r in rows)
info = {"n": len(rows), "relevant": rel[1.0], "irrelevant": rel[0.0], "grey": rel[IGNORE],
"bullish": d[1.0], "bearish": d[0.0],
"sources": dict(Counter(r["source"] for r in rows)),
"years": dict(sorted(years.items()))}
print(f" {name:<6} n={info['n']:>6} relevant {info['relevant']:>6} irrelevant "
f"{info['irrelevant']:>6} grey {info['grey']:>5} bull {info['bullish']:>5} "
f"bear {info['bearish']:>5} {info['sources']}")
return info
# ── training / evaluation ────────────────────────────────────────────
def batches(rows, tokenizer, batch_size, shuffle, rng=None):
order = list(range(len(rows)))
if shuffle:
rng.shuffle(order)
for start in range(0, len(order), batch_size):
chunk = [rows[i] for i in order[start:start + batch_size]]
enc = tokenizer([r["text"] for r in chunk], padding=True, truncation=True,
max_length=MAX_LENGTH, return_tensors="pt")
rel = torch.tensor([r["rel"] for r in chunk])
dirs = torch.tensor([r["dir"] for r in chunk])
yield enc["input_ids"], enc["attention_mask"], rel, dirs
def masked_bce(logits, targets, pos_weight):
mask = targets != IGNORE
if not mask.any():
return logits.sum() * 0.0
return nn.functional.binary_cross_entropy_with_logits(
logits[mask].float(), targets[mask].float(), pos_weight=pos_weight)
@torch.no_grad()
def predict(model, rows, tokenizer, device, batch_size=256):
model.eval()
p_rel, p_bull = [], []
for ids, mask, _, _ in batches(rows, tokenizer, batch_size, False):
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device.type == "cuda"):
r, d = model(ids.to(device), mask.to(device))
p_rel.append(torch.sigmoid(r.float()).cpu())
p_bull.append(torch.sigmoid(d.float()).cpu())
return torch.cat(p_rel).numpy(), torch.cat(p_bull).numpy()
def deepseek_class(xau: float, band: float = 0.05) -> int:
return 1 if xau > band else -1 if xau < -band else 0
def metrics(rows, p_rel, p_bull, threshold: float) -> dict:
rel_t = np.array([r["rel"] for r in rows])
dir_t = np.array([r["dir"] for r in rows])
m = rel_t != IGNORE
pred_rel = p_rel >= threshold
tp = int(((pred_rel == 1) & (rel_t == 1)).sum())
fp = int(((pred_rel == 1) & (rel_t == 0)).sum())
fn = int(((pred_rel == 0) & (rel_t == 1)).sum())
prec = tp / (tp + fp) if tp + fp else 0.0
rec = tp / (tp + fn) if tp + fn else 0.0
dm = dir_t != IGNORE
dir_acc = float(((p_bull[dm] >= 0.5) == (dir_t[dm] == 1)).mean()) if dm.any() else float("nan")
pred_cls = np.where(pred_rel, np.where(p_bull >= 0.5, 1, -1), 0)
ds = np.array([deepseek_class(r["xau"]) for r in rows])
cls3 = float((pred_cls == ds).mean())
from sklearn.metrics import f1_score
return {
"relevance_threshold": threshold,
"relevance_precision": prec, "relevance_recall": rec,
"relevance_f1": 2 * prec * rec / (prec + rec) if prec + rec else 0.0,
"relevance_accuracy": float((pred_rel[m] == (rel_t[m] == 1)).mean()),
"direction_accuracy_on_relevant": dir_acc,
"three_class_vs_deepseek_0.05": cls3,
"three_class_macro_f1": float(f1_score(ds, pred_cls, average="macro")),
"n": len(rows),
}
def best_threshold(rows, p_rel, p_bull) -> float:
"""Relevance cut that maximises 3-class agreement on dev."""
grid = [round(x, 2) for x in np.arange(0.2, 0.81, 0.05)]
return max(grid, key=lambda t: metrics(rows, p_rel, p_bull, t)["three_class_vs_deepseek_0.05"])
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--base", default="microsoft/deberta-v3-small")
ap.add_argument("--epochs", type=int, default=4)
ap.add_argument("--batch-size", type=int, default=64)
ap.add_argument("--lr", type=float, default=5e-5)
ap.add_argument("--warmup", type=float, default=0.06)
ap.add_argument("--weight-decay", type=float, default=0.01)
ap.add_argument("--val-from", default="2026-09-18")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--out", type=Path, default=OUT_DIR)
ap.add_argument("--final", action="store_true",
help="train on every split for the epoch count chosen by the last run")
args = ap.parse_args()
random.seed(args.seed)
np.random.seed(args.seed)
torch.manual_seed(args.seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
rows = load_rows()
train, dev, val = split(rows, args.val_from, args.seed)
print("splits:")
data_info = {"train": describe("train", train), "dev": describe("dev", dev),
"val": describe("val", val)}
previous = {}
if args.final:
prev_path = args.out / "meta.json"
if not prev_path.is_file():
raise SystemExit("--final needs a previous non-final run (its meta.json)")
previous = json.loads(prev_path.read_text())
args.epochs = int(previous["best_epoch"])
train = train + dev + val
args.out = args.out.with_name("torch_final")
print(f"final: training on all {len(train)} rows for {args.epochs} epochs -> {args.out}")
tokenizer = AutoTokenizer.from_pretrained(args.base)
model = TwoHeadModel(args.base).to(device)
rel_counts = Counter(r["rel"] for r in train)
dir_counts = Counter(r["dir"] for r in train)
rel_pw = torch.tensor(rel_counts[0.0] / max(1, rel_counts[1.0]), device=device)
dir_pw = torch.tensor(dir_counts[0.0] / max(1, dir_counts[1.0]), device=device)
no_decay = ("bias", "LayerNorm.weight", "layernorm", "norm")
params = [
{"params": [p for n, p in model.named_parameters() if not any(k in n for k in no_decay)],
"weight_decay": args.weight_decay},
{"params": [p for n, p in model.named_parameters() if any(k in n for k in no_decay)],
"weight_decay": 0.0},
]
optimizer = torch.optim.AdamW(params, lr=args.lr)
steps = math.ceil(len(train) / args.batch_size) * args.epochs
scheduler = get_linear_schedule_with_warmup(optimizer, int(steps * args.warmup), steps)
args.out.mkdir(parents=True, exist_ok=True)
rng = random.Random(args.seed)
history = []
best = {"score": -1.0}
started = time.time()
for epoch in range(1, args.epochs + 1):
model.train()
total = n = 0
for ids, mask, rel, dirs in batches(train, tokenizer, args.batch_size, True, rng):
ids, mask, rel, dirs = ids.to(device), mask.to(device), rel.to(device), dirs.to(device)
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=device.type == "cuda"):
r_logit, d_logit = model(ids, mask)
loss = masked_bce(r_logit, rel, rel_pw) + masked_bce(d_logit, dirs, dir_pw)
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
scheduler.step()
total += loss.item() * len(ids)
n += len(ids)
entry = {"epoch": epoch, "train_loss": total / n, "seconds": round(time.time() - started)}
if not args.final:
pr, pb = predict(model, dev, tokenizer, device)
thr = best_threshold(dev, pr, pb)
entry["dev"] = metrics(dev, pr, pb, thr)
score = entry["dev"]["three_class_vs_deepseek_0.05"]
if score > best["score"]:
best = {"score": score, "epoch": epoch, "threshold": thr}
torch.save(model.state_dict(), args.out / "model.pt")
d = entry["dev"]
print(f"epoch {epoch}: loss {entry['train_loss']:.4f} dev 3-class "
f"{d['three_class_vs_deepseek_0.05']:.3f} rel F1 {d['relevance_f1']:.3f} "
f"dir acc {d['direction_accuracy_on_relevant']:.3f} thr {thr} "
f"({entry['seconds']}s)", flush=True)
else:
print(f"epoch {epoch}: loss {entry['train_loss']:.4f} ({entry['seconds']}s)", flush=True)
history.append(entry)
if args.final:
torch.save(model.state_dict(), args.out / "model.pt")
best = {"epoch": args.epochs, "threshold": previous["relevance_threshold"]}
evaluation = previous.get("evaluation", {})
else:
model.load_state_dict(torch.load(args.out / "model.pt", map_location=device))
evaluation = {}
for name, part in (("dev", dev), ("val", val)):
pr, pb = predict(model, part, tokenizer, device)
evaluation[name] = metrics(part, pr, pb, best["threshold"])
v = evaluation["val"]
print(f"best epoch {best['epoch']}, relevance threshold {best['threshold']}")
print(f"VAL (unseen {args.val_from}+, n={v['n']}): 3-class {v['three_class_vs_deepseek_0.05']:.3f}"
f" macro-F1 {v['three_class_macro_f1']:.3f} rel P/R/F1 {v['relevance_precision']:.3f}/"
f"{v['relevance_recall']:.3f}/{v['relevance_f1']:.3f} dir acc "
f"{v['direction_accuracy_on_relevant']:.3f}")
tokenizer.save_pretrained(args.out)
meta = {
"base_model": args.base,
"trained_at": datetime.now(timezone.utc).isoformat(),
"final": args.final,
"max_length": MAX_LENGTH,
"relevant_min_abs_xau": RELEVANT_MIN,
"irrelevant_max_abs_xau": RELEVANT_MAX_IRRELEVANT,
"relevance_threshold": best["threshold"],
"best_epoch": best["epoch"],
"hyperparameters": {k: v for k, v in vars(args).items() if k not in ("out",)},
"data": data_info,
"history": history,
"evaluation": evaluation,
"evaluation_note": ("final model trained on val too; evaluation is from the "
"non-final run" if args.final else "val never used for selection"),
"labels": "DeepSeek deepseek-v4-flash live prompt XAU score",
}
(args.out / "meta.json").write_text(json.dumps(meta, indent=2, default=str) + "\n")
print(f"wrote {args.out}/model.pt and meta.json")
return 0
if __name__ == "__main__":
sys.exit(main())