SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
10.1 KB · 204 lines python
Raw Blame History
1#!/usr/bin/env python2"""SEC EDGAR enrichment for the seed registry (docs/SEEDS.md).34Matches registry companies to `company_tickers.json` (ticker first, then normalised name) and fills *missing* `sec_cik`, `ticker`,5`exchange`, `hq_city`/`hq_region` and an industry hint (SIC → taxonomy slug) from the submissions JSON. Existing values are never6overwritten. Polite: SEC-required User-Agent, ≤ 5 requests/s, responses cached under data/seed/edgar/.78Usage: .venv/bin/python scripts/seed_edgar.py [--max-submissions N] [--dry-run]9"""10from __future__ import annotations1112import argparse13import json14import logging15import sys16import time17from collections import Counter, defaultdict18from pathlib import Path19from typing import Any2021import httpx2223ROOT = Path(__file__).resolve().parents[1]24sys.path.insert(0, str(ROOT / "src"))2526from companyatlas.ids import normalize_alias27from companyatlas.registry.industries import map_sic2829log = logging.getLogger("seed_edgar")30USER_AGENT = "CompanyAtlasBot/0.1 contact@spboucher.ai"31TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"32SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik:010d}.json"33MIN_INTERVAL_S = 0.21               # ≤ 5 requests per second (SEC fair-access policy is 10/s)34CACHE_DIR = ROOT / "data" / "seed" / "edgar"35OUT_DIR = ROOT / "registry" / "companies"36US_STATES = {"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI",37             "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT",38             "VT", "VA", "WA", "WV", "WI", "WY", "DC", "PR"}394041class Edgar:42    def __init__(self) -> None:43        self.client = httpx.Client(headers={"User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate"}, timeout=30, follow_redirects=True)44        self.last = 0.045        self.requests = 046        CACHE_DIR.mkdir(parents=True, exist_ok=True)4748    def get_json(self, url: str, cache_name: str) -> Any | None:49        path = CACHE_DIR / cache_name50        if path.exists():51            return json.loads(path.read_text(encoding="utf-8"))52        for attempt in range(4):53            wait = MIN_INTERVAL_S - (time.monotonic() - self.last)54            if wait > 0:55                time.sleep(wait)56            try:57                r = self.client.get(url)58            except httpx.HTTPError as e:59                log.warning("edgar %s: %s", url, e)60                time.sleep(2 * (attempt + 1))61                continue62            finally:63                self.last = time.monotonic()64                self.requests += 165            if r.status_code == 200:66                path.write_text(r.text, encoding="utf-8")67                return r.json()68            if r.status_code == 404:69                path.write_text("null", encoding="utf-8")70                return None71            log.warning("edgar %s → HTTP %d, backing off", url, r.status_code)72            time.sleep(5 * (attempt + 1))73        return None747576def load_registry() -> dict[Path, list[dict[str, Any]]]:77    out: dict[Path, list[dict[str, Any]]] = {}78    for path in sorted(OUT_DIR.glob("wikidata-*.ndjson")):79        with path.open(encoding="utf-8") as f:80            out[path] = [json.loads(line) for line in f if line.strip()]81    return out828384def main() -> int:85    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)86    ap.add_argument("--max-submissions", type=int, default=8000, help="cap on submissions JSON fetches (≈ 30 min at 5 req/s when cold)")87    ap.add_argument("--dry-run", action="store_true")88    ap.add_argument("-v", "--verbose", action="store_true")89    args = ap.parse_args()90    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s")91    logging.getLogger("httpx").setLevel(logging.WARNING)9293    edgar = Edgar()94    tickers = edgar.get_json(TICKERS_URL, "company_tickers.json")95    if not tickers:96        log.error("could not fetch %s", TICKERS_URL)97        return 198    by_ticker: dict[str, list[dict[str, Any]]] = defaultdict(list)99    by_name: dict[str, set[int]] = defaultdict(set)100    for e in tickers.values():101        by_ticker[e["ticker"].upper()].append(e)102        by_name[normalize_alias(e["title"])].add(int(e["cik_str"]))103    log.info("edgar tickers: %d entries, %d names", len(tickers), len(by_name))104105    files = load_registry()106    stats: Counter[str] = Counter()107    submissions_fetched = 0108    for path, rows in files.items():109        for r in rows:110            cik: int | None = int(r["sec_cik"]) if r.get("sec_cik") and str(r["sec_cik"]).isdigit() else None111            if cik is None:112                cands: set[int] = set()113                if r.get("ticker") and r["ticker"].upper() in by_ticker:114                    for e in by_ticker[r["ticker"].upper()]:115                        if normalize_alias(e["title"])[:6] == normalize_alias(r["display_name"])[:6] or r.get("country") == "US":116                            cands.add(int(e["cik_str"]))117                if not cands:118                    names = [r["display_name"], r.get("legal_name") or ""] + list(r.get("aliases") or [])119                    for nm in names:120                        if nm and normalize_alias(nm) in by_name:121                            cands |= by_name[normalize_alias(nm)]122                    if len(cands) > 1 and r.get("country") != "US":123                        cands = set()      # ambiguous non-US name: do not guess124                if len(cands) == 1:125                    cik = cands.pop()126                    stats["matched_name_or_ticker"] += 1127                elif len(cands) > 1:128                    stats["ambiguous"] += 1129                    continue130                else:131                    continue132            else:133                stats["had_cik"] += 1134            r.setdefault("industry_labels", [])135            changed = False136            if not r.get("sec_cik"):137                r["sec_cik"] = str(cik)138                changed = True139            needs_sub = not r.get("exchange") or not r.get("industries") or not r.get("hq_city") or not r.get("ticker")140            if needs_sub and submissions_fetched < args.max_submissions:141                sub = edgar.get_json(SUBMISSIONS_URL.format(cik=cik), f"CIK{cik:010d}.json")142                submissions_fetched += 1143                if sub:144                    if not r.get("ticker") and sub.get("tickers"):145                        r["ticker"] = sub["tickers"][0]146                        changed = True147                    if not r.get("exchange") and sub.get("exchanges"):148                        r["exchange"] = next((x for x in sub["exchanges"] if x), None)149                        changed = changed or bool(r["exchange"])150                    if r.get("ticker"):151                        r["public_company"] = True152                    sic = sub.get("sic")153                    slug = map_sic(sic)154                    if sic and sub.get("sicDescription") and f"SIC {sic} {sub['sicDescription']}" not in r["industry_labels"]:155                        r["industry_labels"].append(f"SIC {sic} {sub['sicDescription']}")156                        changed = True157                    if slug and not r.get("industries"):158                        r["industries"] = [slug]159                        stats["industry_from_sic"] += 1160                        changed = True161                    biz = (sub.get("addresses") or {}).get("business") or {}162                    if not r.get("hq_city") and biz.get("city"):163                        r["hq_city"] = biz["city"].title()164                        changed = True165                    if not r.get("hq_region") and biz.get("stateOrCountry") in US_STATES:166                        r["hq_region"] = biz["stateOrCountry"]167                        changed = True168            if changed:169                r["source_edgar"] = {"cik": str(cik), "enriched_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())}170                stats["rows_enriched"] += 1171        if not args.dry_run:172            with path.open("w", encoding="utf-8") as f:173                for r in rows:174                    f.write(json.dumps(r, ensure_ascii=False) + "\n")175    log.info("edgar done: %s (%d HTTP requests, %d submissions)", dict(stats), edgar.requests, submissions_fetched)176    (ROOT / "data" / "seed" / "edgar-report.json").write_text(json.dumps({"stats": stats, "submissions": submissions_fetched}, indent=1))177    if not args.dry_run:178        total = sum(len(rows) for rows in files.values())179        with_cik = sum(1 for rows in files.values() for r in rows if r.get("sec_cik"))180        no_ind = sum(1 for rows in files.values() for r in rows if not r.get("industries"))181        update_readme(f"* EDGAR ({time.strftime('%Y-%m-%d', time.gmtime())}): {stats['matched_name_or_ticker']:,} companies newly matched to a CIK "182                      f"(ticker, then exact normalised name), {stats['had_cik']:,} already had one → {with_cik:,} of {total:,} with a SEC CIK; "183                      f"{stats['rows_enriched']:,} rows enriched, {stats['industry_from_sic']:,} industries from SIC ({no_ind:,} still without an "184                      f"industry mapping), {submissions_fetched:,} submissions JSON read.", stats, submissions_fetched)185    return 0186187188def update_readme(line: str, stats: Counter[str], submissions: int) -> None:189    """Replace (or append) the EDGAR line of the README harvest report and record the counters in data/seed/harvest-stats.json."""190    readme = OUT_DIR / "README.md"191    if readme.exists():192        lines = [ln for ln in readme.read_text(encoding="utf-8").splitlines() if not ln.startswith("* EDGAR (")]193        while lines and not lines[-1].strip():194            lines.pop()195        readme.write_text("\n".join([*lines, line]) + "\n", encoding="utf-8")196    stats_path = ROOT / "data" / "seed" / "harvest-stats.json"197    data = json.loads(stats_path.read_text(encoding="utf-8")) if stats_path.exists() else {}198    data["edgar"] = {**stats, "submissions": submissions, "at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())}199    stats_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")200201202if __name__ == "__main__":203    sys.exit(main())204