Files
momentum devandClaude Opus 5.5 98805279d1 feat: real-time press-release and SEC filing recorder (prwire)
Premarket news timing could not be tested on history. Alpaca's Benzinga feed has
almost no PR-wire items for microcaps, and wire RSS feeds only keep the latest few
dozen items. So from now on, every item is recorded with our first-seen time.

- Polls PR Newswire, GlobeNewswire, Business Wire and SEC EDGAR's current 8-K, 6-K
  and 424B feeds every 20 s, in parallel. A failing feed backs off up to 10 min.
- Each new item goes to data/prwire/<date ET>.jsonl with seen_utc, feed, title,
  link, tickers (exchange-tagged, e.g. "(Nasdaq CM: ABCD)"; SEC filings via CIK)
  and form. Restarts do not re-log items already recorded.
- Runs on the workstation via Task Scheduler "Momentum\PrWireRecorder" (pythonw,
  no console window; re-fired every 10 min with IgnoreNew so it self-heals).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
2026-10-01 08:12:10 -05:00

162 lines
7.0 KiB
Python

"""Record every press release and SEC 8-K / 6-K / 424B filing the moment we first see it.
Why: premarket news timing could not be tested on history. The Benzinga feed carries almost no PR-wire items for
microcaps, and wire RSS feeds only show the latest few dozen items. From now on each item is logged with OUR
first-seen time (UTC) and the tickers it names, so later we can ask: "a fresh release hits the wire and the move
starts N minutes later; is that tradable?"
usage: python -m prwire.recorder (runs until killed; Task Scheduler re-fires it if it dies)
out: data/prwire/<YYYY-MM-DD ET>.jsonl, one JSON object per new item:
seen_utc, feed, title, link, guid, published, tickers, cik, form, snippet
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from research.ingestion.rss import parse_rss # noqa: E402
from research.ingestion.sec_dilution import USER_AGENT # noqa: E402
ET = ZoneInfo("America/New_York")
OUT = ROOT / "data" / "prwire"
POLL_S = float(os.environ.get("PRWIRE_POLL_S", "20"))
_SEC = ("https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type={t}&company=&dateb=&owner=include"
"&start=0&count=100&output=atom")
FEEDS = {
"prnewswire": "https://www.prnewswire.com/rss/news-releases-list.rss",
"globenewswire": "https://www.globenewswire.com/RssFeed/feedTitle/GlobeNewswire%20-%20All%20News",
"businesswire": "https://feed.businesswire.com/rss/home/?rss=G1QFDERJXkJeEFtRWA==",
"sec_8k": _SEC.format(t="8-K"),
"sec_6k": _SEC.format(t="6-K"),
"sec_424b": _SEC.format(t="424B"),
}
# "(Nasdaq: ABCD)", "NASDAQ CM: ABCD", "NYSE American: XYZ", "OTCQB: ABCD", "Nasdaq:ABCD" ...
TICKER_RE = re.compile(
r"\b(?:NASDAQ|Nasdaq|NYSE(?:\s+(?:American|MKT|Arca))?|AMEX|OTCQ[BX]|OTC(?:\s+Markets)?|OTC\s+Pink|Cboe)"
r"(?:\s*(?:CM|GM|GS|Capital\s+Market|Global\s+(?:Select\s+)?Market))?\s*[:\-]\s*\$?([A-Z]{1,5}(?:\.[A-Z]{1,2})?)\b")
CIK_RE = re.compile(r"\((\d{10})\)")
FORM_RE = re.compile(r"^\s*([0-9A-Z\-/]+)\s+-\s+")
def extract_tickers(text: str) -> list[str]:
return sorted(set(TICKER_RE.findall(text or "")))
def _cik_map() -> dict[int, str]:
f = ROOT / "data" / "ross" / "judge" / "edgar_cache" / "company_tickers.json"
if not f.exists() or time.time() - f.stat().st_mtime > 7 * 86400:
try:
req = urllib.request.Request("https://www.sec.gov/files/company_tickers.json",
headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as r:
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(r.read())
except Exception: # noqa: BLE001
if not f.exists():
return {}
return {int(v["cik_str"]): v["ticker"].upper() for v in json.loads(f.read_text()).values()}
def item_key(feed: str, it: dict) -> str:
return hashlib.sha1(f"{feed}|{it.get('url') or ''}|{it.get('title') or ''}".encode()).hexdigest()
def to_record(feed: str, it: dict, seen_utc: str, ciks: dict[int, str]) -> dict:
title, snippet = it.get("title") or "", it.get("snippet") or ""
rec = dict(seen_utc=seen_utc, feed=feed, title=title, link=it.get("url"), guid=item_key(feed, it),
published=it.get("published"), tickers=extract_tickers(f"{title} {snippet}"), cik=None, form=None,
snippet=snippet[:400])
if feed.startswith("sec_"):
m = CIK_RE.search(title)
if m:
rec["cik"] = int(m.group(1))
t = ciks.get(rec["cik"])
rec["tickers"] = [t] if t else []
f = FORM_RE.match(title)
rec["form"] = f.group(1) if f else None
return rec
def fetch(url: str) -> list[dict]:
ua = USER_AGENT if "sec.gov" in url else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) momentum-research/1.0"
req = urllib.request.Request(url, headers={"User-Agent": ua, "Accept": "application/rss+xml,application/xml,*/*"})
with urllib.request.urlopen(req, timeout=15) as r:
return parse_rss(r.read())
class Recorder:
def __init__(self):
OUT.mkdir(parents=True, exist_ok=True)
self.ciks = _cik_map()
self.seen: set[str] = set()
for f in sorted(OUT.glob("*.jsonl"))[-3:]: # don't re-log items already recorded before a restart
for line in f.read_text(encoding="utf-8").splitlines():
try:
g = json.loads(line).get("guid")
except ValueError:
continue
if g:
self.seen.add(g)
self.errors: dict[str, int] = {}
self.skip_until: dict[str, float] = {}
self.pool = ThreadPoolExecutor(max(1, len(FEEDS)))
def poll_once(self, fetcher=fetch, now: datetime | None = None) -> int:
"""Fetch every due feed in parallel (one slow feed never delays the rest) and log the new items.
A failing feed backs off 20 s, 40 s, 80 s... up to 10 min instead of being hit every poll."""
now = now or datetime.now(timezone.utc)
seen_utc = now.isoformat(timespec="seconds")
day = now.astimezone(ET).strftime("%Y-%m-%d")
mono = time.monotonic()
due = {f: u for f, u in FEEDS.items() if self.skip_until.get(f, 0.0) <= mono}
def get(url):
try:
return fetcher(url), None
except Exception as e: # noqa: BLE001
return None, e
results = dict(zip(due, self.pool.map(get, due.values())))
new = 0
with open(OUT / f"{day}.jsonl", "a", encoding="utf-8") as fh:
for feed, (items, err) in results.items():
if err is not None:
n = self.errors[feed] = self.errors.get(feed, 0) + 1
self.skip_until[feed] = mono + min(600.0, POLL_S * 2 ** (n - 1))
if n in (1, 10, 100): # log an outage, not every failed poll
fh.write(json.dumps(dict(seen_utc=seen_utc, feed=feed, error=str(err)[:200], guid=None)) + "\n")
continue
if self.errors.get(feed):
fh.write(json.dumps(dict(seen_utc=seen_utc, feed=feed, recovered_after=self.errors[feed],
guid=None)) + "\n")
self.errors[feed] = 0
for it in items:
k = item_key(feed, it)
if k in self.seen:
continue
self.seen.add(k)
fh.write(json.dumps(to_record(feed, it, seen_utc, self.ciks)) + "\n")
new += 1
return new
def run(self):
while True:
t0 = time.time()
self.poll_once()
time.sleep(max(1.0, POLL_S - (time.time() - t0)))
if __name__ == "__main__":
Recorder().run()