#!/usr/bin/env python3 """Label crawled historical headlines with DeepSeek's live XAU prompt. Reads data/history.db (crawl_google_news.py), picks unlabelled headlines by round-robin over (quarter, query) strata so any budget covers every year and topic evenly — ``--broad-share`` of them from the broad crawl (mostly not gold-relevant), the rest from the precise ``intitle:`` crawl — and sends them through the live ForexBot2 DeepSeek path (eval_common.DeepSeekScorer: same prompt sha1, batches of 25, disk cache). Answers go to the ``labels`` table. Excluded before any spend: headlines already labelled, anything whose normalised text is in the golden set (it must never reach training) or in newsdata.db (already labelled there), junk titles shorter than 20 or longer than 200 characters, and non-Latin (CJK) wire headlines. Budget: ``--max-calls`` is a hard stop on DeepSeek requests for this run. The research client gets its own UTC-day cap (``--daily-cap``), counted in this process only, so the live bot's cap is untouched. Usage (ForexBot2 venv, it has the DeepSeek client): PY=/home/harwinder/Development/ForexBot2/venv/bin/python $PY data_collect/label_history.py --max-calls 40 # pilot $PY data_collect/label_history.py --report-only # label mix per year """ from __future__ import annotations import argparse import random import sqlite3 import sys from collections import defaultdict from datetime import datetime, timezone from pathlib import Path ML_NEWS_DIR = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ML_NEWS_DIR / "eval")) sys.path.insert(0, str(Path(__file__).resolve().parent)) from crawl_google_news import DEFAULT_DB, QUERY_SETS, normalise # noqa: E402 from eval_common import BATCH_CAP, load_golden # noqa: E402 LABEL_CACHE = ML_NEWS_DIR / "data" / "history_deepseek_cache.json" NEWSDATA_DB = ML_NEWS_DIR / "newsdata.db" CHUNK = BATCH_CAP * 10 LABELS_SCHEMA = """ CREATE TABLE IF NOT EXISTS labels ( hash TEXT PRIMARY KEY, s REAL NOT NULL, xau REAL NOT NULL, model TEXT NOT NULL, prompt_sha1 TEXT NOT NULL, labelled_at TEXT NOT NULL ); """ def text_key(headline: str) -> str: return normalise(headline).casefold() def is_latin(headline: str) -> bool: """Reuters' Chinese and Japanese wires match ``intitle:GDP`` and ``intitle:PMI`` too; the live feed is English, so don't pay to label them. Allows accents and symbols, rejects CJK-heavy text.""" non_ascii = sum(ord(ch) > 0x24F for ch in headline) return non_ascii <= max(2, len(headline) // 10) def excluded_texts() -> set[str]: keys = {text_key(g["headline"]) for g in load_golden()} if NEWSDATA_DB.is_file(): uri = f"{NEWSDATA_DB.resolve().as_uri()}?mode=ro" with sqlite3.connect(uri, uri=True) as conn: keys |= {text_key(h) for (h,) in conn.execute("SELECT headline FROM headlines")} return keys def quarter(published_at: str | None) -> str: if not published_at or len(published_at) < 7: return "unknown" return f"{published_at[:4]}Q{(int(published_at[5:7]) - 1) // 3 + 1}" def _round_robin(strata: dict, n: int, rng: random.Random) -> list[tuple[str, str]]: for items in strata.values(): rng.shuffle(items) order = sorted(strata) rng.shuffle(order) chosen: list[tuple[str, str]] = [] depth = 0 while len(chosen) < n and any(len(strata[s]) > depth for s in order): for s in order: if depth < len(strata[s]): chosen.append(strata[s][depth]) if len(chosen) >= n: break depth += 1 return chosen def pick(conn: sqlite3.Connection, n: int, seed: int, broad_share: float, only_queries: set[str] | None = None) -> list[tuple[str, str]]: """Round-robin over (quarter, query) strata, random within each, from two pools: headlines any ``intitle:`` query found (mostly gold doctrine) and broad-only ones (mostly news that does not move gold). ``broad_share`` of the picks come from the broad pool. With ``only_queries`` (a crawler query set), only headlines one of those queries found are eligible, and the stratum is that query.""" skip = excluded_texts() pools: dict[str, dict] = {"precise": defaultdict(list), "broad": defaultdict(list)} rows = conn.execute( "SELECT h.hash, h.headline, h.published_at, h.first_query, h.queries FROM headlines h" " LEFT JOIN labels l ON l.hash = h.hash WHERE l.hash IS NULL") for key, headline, published_at, first_query, queries in rows: if not 20 <= len(headline) <= 200 or text_key(headline) in skip \ or not is_latin(headline): continue found = queries.split("|") if only_queries is not None: found = [q for q in found if q in only_queries] if not found: continue precise = [q for q in found if q.startswith("intitle:")] pool = "precise" if precise else "broad" pools[pool][(quarter(published_at), precise[0] if precise else first_query)].append( (key, headline)) rng = random.Random(seed) n_broad = round(n * broad_share) chosen = _round_robin(pools["precise"], n - n_broad, rng) chosen += _round_robin(pools["broad"], n - len(chosen), rng) rng.shuffle(chosen) print(f"pools: precise {sum(map(len, pools['precise'].values()))}, broad " f"{sum(map(len, pools['broad'].values()))} unlabelled; picked {len(chosen)}") return chosen def report(conn: sqlite3.Connection) -> None: rows = conn.execute( "SELECT substr(h.published_at, 1, 4), l.xau FROM labels l" " JOIN headlines h ON h.hash = l.hash").fetchall() by_year: dict[str, list[float]] = defaultdict(list) for year, xau in rows: by_year[year or "????"].append(xau) print(f"{len(rows)} labelled headlines") print(f" {'year':<5} {'n':>6} {'bear':>6} {'irrel':>6} {'bull':>6} " f"{'relevant':>9} {'mean xau':>9}") for year in sorted(by_year): xs = by_year[year] bear = sum(x <= -0.25 for x in xs) bull = sum(x >= 0.25 for x in xs) irrel = sum(abs(x) <= 0.10 for x in xs) print(f" {year:<5} {len(xs):>6} {bear:>6} {irrel:>6} {bull:>6} " f"{(bear + bull) / len(xs):>8.1%} {sum(xs) / len(xs):>+9.3f}") print(" (relevant = |xau| >= 0.25, irrel = |xau| <= 0.10, the rest is the grey zone)") def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--db", type=Path, default=DEFAULT_DB) ap.add_argument("--max-calls", type=int, default=40, help="hard stop on DeepSeek requests this run (25 headlines each)") ap.add_argument("--daily-cap", type=int, default=0, help="research client UTC-day cap (default: max-calls + 20)") ap.add_argument("--broad-share", type=float, default=0.3, help="share of picks from broad-only headlines (not-relevant side)") ap.add_argument("--query-set", choices=sorted(QUERY_SETS), default=None, help="only label headlines found by this crawler query set") ap.add_argument("--seed", type=int, default=42) ap.add_argument("--timeout", type=float, default=60.0) ap.add_argument("--no-network", action="store_true") ap.add_argument("--report-only", action="store_true") args = ap.parse_args() conn = sqlite3.connect(args.db, timeout=60) conn.executescript(LABELS_SCHEMA) if args.report_only: report(conn) return 0 from eval_common import DeepSeekScorer ds = DeepSeekScorer(cache_path=LABEL_CACHE, timeout=args.timeout, no_network=args.no_network, daily_cap=args.daily_cap or args.max_calls + 20) print(f"DeepSeek {ds.model}, prompt {ds.prompt_sha1[:8]}; budget {args.max_calls} calls") chosen = pick(conn, args.max_calls * BATCH_CAP, args.seed, args.broad_share, set(QUERY_SETS[args.query_set]) if args.query_set else None) print(f"selected {len(chosen)} unlabelled headlines") labelled = 0 for start in range(0, len(chosen), CHUNK): if ds.calls >= args.max_calls: break room = (args.max_calls - ds.calls) * BATCH_CAP part = chosen[start:start + min(CHUNK, room)] answers = ds.score_many([h for _, h in part]) now = datetime.now(timezone.utc).isoformat() rows = [(key, a["s"], a["xau"], ds.model, ds.prompt_sha1, now) for (key, _), a in zip(part, answers) if a is not None] conn.executemany("INSERT OR REPLACE INTO labels VALUES (?,?,?,?,?,?)", rows) conn.commit() labelled += len(rows) print(f" {labelled} labelled, {ds.calls} calls " f"({ds.failed_calls} failed), {ds.cache_hits} from cache", flush=True) if not ds.available and not rows: break stats = ds.stats() print(f"this run: {stats['calls']} calls, {stats['failed_calls']} failed, " f"{labelled} headlines labelled") report(conn) conn.close() return 0 if __name__ == "__main__": sys.exit(main())