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:
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect historical headlines from Google News RSS into data/history.db.
|
||||
|
||||
One request per (query, date window), e.g.
|
||||
``gold site:reuters.com after:2019-07-22 before:2019-07-29``. Google
|
||||
returns at most 100 items per request. The crawl is
|
||||
resumable: finished (query, window) pairs are recorded in ``crawl_log``
|
||||
and skipped on the next run. Requests are sequential with a fixed
|
||||
delay; on HTTP 429/503 the crawler backs off and stops after repeated
|
||||
refusals rather than hammering the service.
|
||||
|
||||
Headlines are stored once per normalised text (``hash``); the queries
|
||||
that found a headline are kept in ``queries``. Nothing here labels or
|
||||
filters against the golden set; label_history.py and the trainer do.
|
||||
|
||||
Usage:
|
||||
python data_collect/crawl_google_news.py # precise set
|
||||
python data_collect/crawl_google_news.py --query-set broad # off-topic side
|
||||
python data_collect/crawl_google_news.py --query-set growth --window-days 30 # growth data + equities
|
||||
python data_collect/crawl_google_news.py --limit 20 # smoke test
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import email.utils
|
||||
import hashlib
|
||||
import html
|
||||
import random
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ML_NEWS_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_DB = ML_NEWS_DIR / "data" / "history.db"
|
||||
FEED_URL = "https://news.google.com/rss/search?q={q}&hl=en-US&gl=US&ceid=US:en"
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) ML-NEWS research crawler"
|
||||
|
||||
#: Scoped to financial publishers: unscoped queries return mostly
|
||||
#: general news ("gold price" -> phone prices, "safe haven" -> baby-box
|
||||
#: laws). Reuters answers each weekly window with up to 100 market
|
||||
#: headlines. Topics cover gold doctrine (Fed, dollar, yields,
|
||||
#: inflation, jobs, central banks, geopolitics, risk appetite) plus
|
||||
#: off-topic controls (oil, crypto, single stocks) so the relevance head
|
||||
#: sees news that does not move gold.
|
||||
BROAD_QUERIES = (
|
||||
"gold site:reuters.com",
|
||||
"Fed site:reuters.com",
|
||||
"dollar site:reuters.com",
|
||||
"yields site:reuters.com",
|
||||
"inflation site:reuters.com",
|
||||
"payrolls OR jobless site:reuters.com",
|
||||
"\"central bank\" site:reuters.com",
|
||||
"stocks site:reuters.com",
|
||||
"war OR sanctions OR tensions site:reuters.com",
|
||||
"euro OR yen OR sterling site:reuters.com",
|
||||
"oil site:reuters.com",
|
||||
"bitcoin OR crypto site:reuters.com",
|
||||
"gold site:cnbc.com",
|
||||
"markets site:cnbc.com",
|
||||
"site:marketwatch.com",
|
||||
)
|
||||
|
||||
#: A keyword in the query matches anywhere in the article, so the broad
|
||||
#: set is ~93% headlines that do not move gold (2026-09-23 pilot: 70 of
|
||||
#: 1,000 DeepSeek-relevant). ``intitle:`` restricts the match to the
|
||||
#: headline: 3-15 items per week per query, mostly gold doctrine. The
|
||||
#: broad set supplies the not-relevant side, this one the relevant side.
|
||||
PRECISE_QUERIES = tuple(
|
||||
f"intitle:{term} site:{site}"
|
||||
for site in ("reuters.com", "cnbc.com", "marketwatch.com")
|
||||
for term in ("gold", "bullion", "Fed", "Powell", "dollar", "yields",
|
||||
"inflation", "payrolls", '"rate cut"', '"rate hike"', '"safe haven"')
|
||||
if site == "reuters.com" or term in ("gold", "Fed", "dollar", "yields", "inflation")
|
||||
)
|
||||
|
||||
#: The two golden-set categories the 2026-09-27 model still fails are
|
||||
#: strong-US-data (3/7) and risk-on (1/4): "GDP beats", "stocks at record
|
||||
#: highs", "ceasefire lifts markets" should all be bearish for gold, but
|
||||
#: no precise query covers growth data or equities. ``intitle:`` again,
|
||||
#: so label_history.py puts these in the precise pool.
|
||||
GROWTH_TERMS = ("GDP", '"retail sales"', "ISM", "PMI", '"consumer confidence"',
|
||||
'"jobless claims"', '"consumer spending"', "manufacturing")
|
||||
RISK_TERMS = ('"record high"', '"risk appetite"', '"Wall Street"', '"S&P 500"',
|
||||
"ceasefire", '"trade deal"', "equities")
|
||||
GROWTH_QUERIES = tuple(
|
||||
f"intitle:{term} site:{site}"
|
||||
for site in ("reuters.com", "cnbc.com")
|
||||
for term in GROWTH_TERMS + RISK_TERMS
|
||||
if site == "reuters.com" or term in ("GDP", '"retail sales"', '"jobless claims"',
|
||||
'"record high"', '"S&P 500"')
|
||||
)
|
||||
|
||||
QUERY_SETS = {"broad": BROAD_QUERIES, "precise": PRECISE_QUERIES, "growth": GROWTH_QUERIES}
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS headlines (
|
||||
hash TEXT PRIMARY KEY,
|
||||
headline TEXT NOT NULL,
|
||||
raw_title TEXT NOT NULL,
|
||||
publisher TEXT,
|
||||
published_at TEXT,
|
||||
first_query TEXT NOT NULL,
|
||||
queries TEXT NOT NULL,
|
||||
link TEXT,
|
||||
crawled_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_headlines_published ON headlines (published_at);
|
||||
CREATE TABLE IF NOT EXISTS crawl_log (
|
||||
query TEXT NOT NULL,
|
||||
window_start TEXT NOT NULL,
|
||||
items INTEGER NOT NULL,
|
||||
new_headlines INTEGER NOT NULL,
|
||||
crawled_at TEXT NOT NULL,
|
||||
PRIMARY KEY (query, window_start)
|
||||
);
|
||||
"""
|
||||
|
||||
_SPACE = re.compile(r"\s+")
|
||||
|
||||
|
||||
def normalise(text: str) -> str:
|
||||
return _SPACE.sub(" ", html.unescape(text)).strip()
|
||||
|
||||
|
||||
def headline_key(headline: str) -> str:
|
||||
"""Hash of the case-folded, whitespace-collapsed headline."""
|
||||
return hashlib.sha256(normalise(headline).casefold().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def strip_publisher(title: str, publisher: str | None) -> str:
|
||||
"""Google appends ' - Publisher' to every title; drop it."""
|
||||
title = normalise(title)
|
||||
if publisher:
|
||||
suffix = f" - {normalise(publisher)}"
|
||||
if title.endswith(suffix):
|
||||
return title[: -len(suffix)].strip()
|
||||
return title
|
||||
|
||||
|
||||
def windows_of(start: date, end: date, days: int):
|
||||
day = start
|
||||
while day < end:
|
||||
yield day, min(day + timedelta(days=days), end)
|
||||
day += timedelta(days=days)
|
||||
|
||||
|
||||
def fetch(url: str, timeout: float) -> bytes:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def parse_items(payload: bytes):
|
||||
root = ET.fromstring(payload)
|
||||
for item in root.findall("./channel/item"):
|
||||
title = item.findtext("title") or ""
|
||||
publisher = item.findtext("source")
|
||||
published = item.findtext("pubDate")
|
||||
published_at = None
|
||||
if published:
|
||||
try:
|
||||
published_at = email.utils.parsedate_to_datetime(published).astimezone(
|
||||
timezone.utc).isoformat()
|
||||
except (TypeError, ValueError):
|
||||
published_at = None
|
||||
headline = strip_publisher(title, publisher)
|
||||
if headline:
|
||||
yield {
|
||||
"headline": headline,
|
||||
"raw_title": normalise(title),
|
||||
"publisher": publisher,
|
||||
"published_at": published_at,
|
||||
"link": item.findtext("link"),
|
||||
}
|
||||
|
||||
|
||||
def store(conn: sqlite3.Connection, query: str, items) -> tuple[int, int]:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
seen = new = 0
|
||||
for it in items:
|
||||
seen += 1
|
||||
key = headline_key(it["headline"])
|
||||
row = conn.execute("SELECT queries FROM headlines WHERE hash = ?", (key,)).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO headlines (hash, headline, raw_title, publisher, published_at,"
|
||||
" first_query, queries, link, crawled_at) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(key, it["headline"], it["raw_title"], it["publisher"], it["published_at"],
|
||||
query, query, it["link"], now))
|
||||
new += 1
|
||||
elif query not in row[0].split("|"):
|
||||
conn.execute("UPDATE headlines SET queries = ? WHERE hash = ?",
|
||||
(row[0] + "|" + query, key))
|
||||
return seen, new
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--db", type=Path, default=DEFAULT_DB)
|
||||
ap.add_argument("--start", type=date.fromisoformat, default=date(2019, 1, 7))
|
||||
ap.add_argument("--end", type=date.fromisoformat, default=date(2026, 9, 1))
|
||||
ap.add_argument("--delay", type=float, default=2.0, help="seconds between requests")
|
||||
ap.add_argument("--timeout", type=float, default=20.0)
|
||||
ap.add_argument("--limit", type=int, default=0, help="stop after N requests (0 = all)")
|
||||
ap.add_argument("--query-set", choices=sorted(QUERY_SETS), default="precise")
|
||||
ap.add_argument("--window-days", type=int, default=0,
|
||||
help="search window length (default: 7 broad, 14 precise; precise "
|
||||
"queries return 3-15 items a week, far under the 100 cap)")
|
||||
ap.add_argument("--max-refusals", type=int, default=5,
|
||||
help="stop after this many consecutive 429/503 responses")
|
||||
args = ap.parse_args()
|
||||
|
||||
args.db.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(args.db)
|
||||
conn.executescript(SCHEMA)
|
||||
done = {(q, w) for q, w in conn.execute("SELECT query, window_start FROM crawl_log")}
|
||||
queries = QUERY_SETS[args.query_set]
|
||||
days = args.window_days or (14 if args.query_set == "precise" else 7)
|
||||
windows = list(windows_of(args.start, args.end, days))
|
||||
# Seeded shuffle, window-major: an interrupted crawl still covers
|
||||
# every year and every query evenly, so a pilot on a partial crawl
|
||||
# is not all 2019.
|
||||
random.Random(0).shuffle(windows)
|
||||
todo = [(q, a, b) for a, b in windows for q in queries if (q, a.isoformat()) not in done]
|
||||
print(f"{args.query_set}: {len(windows)} windows of {days} days x {len(queries)} queries; "
|
||||
f"{len(done)} done, {len(todo)} to go", flush=True)
|
||||
|
||||
requests = refusals = 0
|
||||
started = time.monotonic()
|
||||
for query, a, b in todo:
|
||||
if args.limit and requests >= args.limit:
|
||||
break
|
||||
q = f"{query} after:{a.isoformat()} before:{b.isoformat()}"
|
||||
url = FEED_URL.format(q=urllib.parse.quote_plus(q))
|
||||
try:
|
||||
payload = fetch(url, args.timeout)
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in (429, 503):
|
||||
refusals += 1
|
||||
wait = min(600, 30 * 2 ** (refusals - 1))
|
||||
print(f"HTTP {error.code} on {q!r}; refusal {refusals}, sleeping {wait}s",
|
||||
flush=True)
|
||||
if refusals >= args.max_refusals:
|
||||
print("too many refusals, stopping; rerun later to resume", flush=True)
|
||||
break
|
||||
time.sleep(wait)
|
||||
continue
|
||||
print(f"HTTP {error.code} on {q!r}; skipped", flush=True)
|
||||
time.sleep(args.delay)
|
||||
continue
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
print(f"network error on {q!r}: {error}; skipped", flush=True)
|
||||
time.sleep(args.delay * 5)
|
||||
continue
|
||||
refusals = 0
|
||||
requests += 1
|
||||
try:
|
||||
seen, new = store(conn, query, parse_items(payload))
|
||||
except ET.ParseError as error:
|
||||
print(f"bad XML for {q!r}: {error}; skipped", flush=True)
|
||||
time.sleep(args.delay)
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO crawl_log VALUES (?,?,?,?,?)",
|
||||
(query, a.isoformat(), seen, new, datetime.now(timezone.utc).isoformat()))
|
||||
conn.commit()
|
||||
if requests % 100 == 0:
|
||||
total = conn.execute("SELECT count(*) FROM headlines").fetchone()[0]
|
||||
rate = requests / (time.monotonic() - started)
|
||||
print(f"{requests} requests, window {a}, {total} unique headlines, "
|
||||
f"{rate * 60:.0f} req/min", flush=True)
|
||||
time.sleep(args.delay)
|
||||
|
||||
total = conn.execute("SELECT count(*) FROM headlines").fetchone()[0]
|
||||
print(f"done: {requests} requests this run, {total} unique headlines in {args.db}",
|
||||
flush=True)
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user