Files
quant-trading/research/lab/fetch_supply_links.py
Gahow Wang ab3acfe4cb feat(research): supply-chain links, theme propagation, cycles, attention, small caps
Tests of the "rank and buy top N misses the chain / cycle / sentiment" question
(report: https://claude.ai/artifact/HUnPtcy7D7Zp1yNQsDLRHY):
- review_live_us: Apr-Sep 2026 replay; book return vs AI-chain weight corr 0.71,
  overnight_recovery_top10 excess almost all allocation (memory stage +28pp).
- fetch_supply_links: 10-K mentions of S&P 1500 companies by role, PIT by filing
  date (12,117 S&P 500 10-Ks; --universe mid for 14,382 S&P 400/600 10-Ks).
- lead_lag: corr-peer and 10-K link momentum worked 2006-16 (FM t 3.3-4.3) and
  vanished 2017-26 (t -2.1..-0.6); Granger lead-lag network and earnings
  spillover drift null after 2016.
- theme_propagation: linked laggards of each era's hot industry underperform
  2017-26 (-2% to -12%/yr).
- lead_lag_small: the same signals on S&P 400/600, 2019-26, all |t| <= 1.8.
- sector_cycle: 21 cycle/rotation variants, none with holdout |t| > 2.
- fetch_wiki_attention + attention: Wikipedia pageview attention, |IC| <= 0.013.
- concentration: industry caps on momentum top 10 cut alpha, not drawdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:25:08 +08:00

351 lines
17 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Economic links between S&P 500 ever-members from their 10-K filings: which
other member companies each 10-K names, and in what role (customer, supplier,
competitor). The links become known on the filing date, so a signal built
from them is point-in-time.
PYTHONPATH=. uv run python -m research.lab.fetch_supply_links --workers 4
PYTHONPATH=. uv run python -m research.lab.fetch_supply_links --universe mid # S&P 400/600 10-Ks from 2015
Inputs: data/research/lazy_prices_index.csv (10-K accessions and primary
documents, from research/lab/fetch_lazy_prices.py) and sec_sic.csv (ticker,
CIK, current SEC name). Names: current and former SEC names (submissions JSON,
cached in data/research/sec_names.json), the Wikipedia security name of
current members, and a short manual list of brands (Google, Facebook, AWS).
Matching is case-sensitive on the proper-case or upper-case form of each
alias, with word boundaries. A one-word alias that is an English dictionary
word ("Target", "Ball") is dropped unless listed in ALLOW, and the filer's
own names are skipped.
A mention is counted in every role whose keywords appear in the same
sentence: customer ("customer", "sales to", "revenue(s) from", "accounted
for ... % of"), supplier ("supplier", "vendor", "supply", "purchase",
"sole source", "foundry", "manufactur-"), competitor ("compet-", "rival").
In filer i's 10-K, "sales to J accounted for 12% of revenue" marks J as a
customer of i.
Output: data/research/sec_10k_links.csv, one row per (10-K, named company):
filer, filed, accession, target, n_customer, n_supplier, n_competitor, n_all,
example (first customer sentence, else the first sentence, 300 chars).
data/research/sec_10k_links_done.txt lists processed accessions (resumable).
"""
from __future__ import annotations
import argparse
import csv
import json
import multiprocessing as mp
import os
import re
import time
import pandas as pd
from research.lab import fetch_lazy_prices as lp
DIR = os.path.join("data", "research")
OUT = os.path.join(DIR, "sec_10k_links.csv")
DONE = os.path.join(DIR, "sec_10k_links_done.txt")
NAMES = os.path.join(DIR, "sec_names.json")
COLS = ["filer", "filed", "accession", "target", "n_customer", "n_supplier", "n_competitor", "n_all", "example"]
EXTRA = {"GOOGL": ["Google", "Alphabet"], "GOOG": ["Google", "Alphabet"], "META": ["Facebook", "Meta Platforms"],
"AMZN": ["Amazon", "Amazon.com", "Amazon Web Services", "AWS"], "MSFT": ["Microsoft", "Azure"],
"AAPL": ["Apple"], "HPQ": ["Hewlett-Packard", "HP Inc"], "HPE": ["Hewlett Packard Enterprise"],
"WMT": ["Wal-Mart", "Walmart"], "IBM": ["IBM"], "AMD": ["AMD", "Advanced Micro Devices"],
"NVDA": ["NVIDIA", "Nvidia"], "INTC": ["Intel"], "ORCL": ["Oracle"], "CSCO": ["Cisco"],
"DELL": ["Dell", "Dell Technologies"], "GE": ["General Electric"], "TGT": ["Target Corporation"],
"BA": ["Boeing"], "F": ["Ford Motor"], "GM": ["General Motors"], "TSLA": ["Tesla"],
"CVS": ["CVS"], "UNH": ["UnitedHealth"], "MCK": ["McKesson"], "CAH": ["Cardinal Health"],
"COR": ["AmerisourceBergen", "Cencora"], "HD": ["Home Depot"], "LOW": ["Lowe's", "Lowe’s"],
"COST": ["Costco"], "KR": ["Kroger"], "T": ["AT&T"], "VZ": ["Verizon"], "TMUS": ["T-Mobile"],
"CMCSA": ["Comcast"], "DIS": ["Disney"], "QCOM": ["Qualcomm", "QUALCOMM"], "AVGO": ["Broadcom"],
"TXN": ["Texas Instruments"], "MU": ["Micron"], "WDC": ["Western Digital"], "STX": ["Seagate"],
"LRCX": ["Lam Research"], "AMAT": ["Applied Materials"], "KLAC": ["KLA-Tencor", "KLA Corporation"],
"SNDK": ["SanDisk", "Sandisk"], "GLW": ["Corning Incorporated"], "LMT": ["Lockheed Martin"],
"RTX": ["Raytheon", "United Technologies"], "NOC": ["Northrop Grumman"], "GD": ["General Dynamics"],
"UPS": ["UPS", "United Parcel Service"], "FDX": ["FedEx"], "BBY": ["Best Buy"], "EBAY": ["eBay"],
"MMM": ["3M"], "COHR": ["Coherent Corp", "II-VI"], "BALL": ["Ball Corporation"],
"PGR": ["Progressive Corporation"], "CRM": ["Salesforce", "salesforce.com"],
"NDAQ": ["Nasdaq, Inc", "NASDAQ OMX"], "PFG": ["Principal Financial Group"], "DOW": ["Dow Chemical", "Dow Inc"]}
# aliases that are mostly something else in a 10-K (the exchange, the officer title, the stock index)
DROP = {"NDAQ": {"Nasdaq", "NASDAQ", "Nasdaq Stock Market", "NASDAQ STOCK MARKET"},
"PFG": {"Principal Financial", "PRINCIPAL FINANCIAL"}, "DOW": {"Dow", "DOW"}}
# sentences about people and financing documents, not business relationships
NOISE = re.compile(r"served as\b|\bserves as\b|\bpositions? (?:with|at)\b|\broles? (?:with|at)\b|\bworked (?:at|for)\b|"
r"\bboard of directors\b|\bprior to joining\b|\bChief \w+ Officer\b|\bVice President\b|\bIndenture\b|"
r"\bCredit Agreement\b|\bRegistration Rights\b|\bunderwrit\w*\b|\btrustee\b|\bas (?:administrative )?agent\b|"
r"\bRule 13a\b|\bCertification\b|\bExhibit\b|\bpar value\b|\bStock Market\b|\bSelect Market\b|"
r"\bDow Jones\b|\bS&P\b", re.I)
ALLOW = {"Apple", "Oracle", "Amazon", "Intel", "Micron", "Boeing", "Tesla", "Google", "Facebook", "Seagate",
"Dell", "Cisco", "Comcast", "Disney", "Verizon", "Qualcomm", "Broadcom", "Microsoft", "Walmart",
"Kroger", "Costco", "Nvidia", "McKesson", "Lumentum", "Corning", "Chevron", "Exxon", "Pfizer",
"Merck", "Amgen", "Nike", "Starbucks", "Visa", "Mastercard", "Netflix", "Adobe", "Salesforce",
"Alphabet"}
SUFFIX = re.compile(r"(?:[\s,]+(?:inc|incorporated|corp|corporation|co|company|companies|ltd|limited|plc|llc|lp|l\.p|"
r"holdings?|group|the|n\.v|s\.a|ag|se|de|del|new|\&))+\.?$", re.I)
SLASH = re.compile(r"\s*/[A-Z]{2,}/?\s*$")
KW = {"customer": re.compile(r"\bcustomers?\b|\bsales to\b|\brevenues? from\b|\baccounted for\b|\bnet sales to\b", re.I),
"supplier": re.compile(r"\bsuppl(?:y|ier|iers|ied)\b|\bvendors?\b|\bpurchas\w*\b|\bsole[- ]source\b|\bfoundr(?:y|ies)\b|"
r"\bmanufactur\w*\b", re.I),
"competitor": re.compile(r"\bcompet\w*\b|\brivals?\b", re.I)}
_SENT = re.compile(r"(?<=[.;!?])\s+(?=[A-Z(\"“])")
def _clean_name(n: str) -> str:
n = SLASH.sub("", n.strip())
prev = None
while prev != n:
prev = n
n = SUFFIX.sub("", n).strip(" ,.")
return n
def _variants(n: str) -> set[str]:
out = {n}
if n.isupper() and len(n) > 4:
out.add(" ".join(w.capitalize() if len(w) > 3 or i == 0 else w.lower() if w in {"OF", "AND", "THE"} else w
for i, w in enumerate(n.split())))
if not n.isupper():
out.add(n.upper())
return {v for v in out if len(v) >= 3}
def former_names(sic: pd.DataFrame) -> dict[str, list[str]]:
if os.path.exists(NAMES):
return json.load(open(NAMES))
out = {}
for cik in sic["cik"].dropna().astype(int).unique():
r = lp._get(f"https://data.sec.gov/submissions/CIK{cik:010d}.json")
if r is None:
continue
d = r.json()
out[str(cik)] = [d.get("name", "")] + [f.get("name", "") for f in d.get("formerNames", [])]
json.dump(out, open(NAMES, "w"))
return out
def aliases(sic: pd.DataFrame | None = None, fn: dict | None = None) -> dict[str, set[str]]:
"""Name aliases per ticker. Defaults: the S&P 500 ever-members (sec_sic.csv,
sec_names.json); the mid/small run passes the S&P 1500 frames."""
if sic is None:
sic = pd.read_csv(os.path.join(DIR, "sec_sic.csv"))
if fn is None:
fn = former_names(sic)
wiki = pd.read_csv(os.path.join("data", "us_sectors.csv")).set_index("ticker")["Security"]
words = {w.strip() for w in open("/usr/share/dict/words") if w.strip().islower()}
out: dict[str, set[str]] = {}
for r in sic.itertuples():
names = list(fn.get(str(int(r.cik)), [])) + [r.name]
if r.ticker in wiki.index:
names.append(wiki[r.ticker])
al = set()
for n in names:
if isinstance(n, str) and n.strip():
al |= _variants(_clean_name(n))
al |= set(EXTRA.get(r.ticker, []))
al -= DROP.get(r.ticker, set())
keep = set()
for a in al:
ws = a.split()
if len(a) < 3:
continue
if len(ws) == 1 and (a.lower() in words or a.capitalize().lower() in words) and a not in ALLOW \
and a.capitalize() not in ALLOW:
continue
if len(ws) == 1 and a.isupper() and len(a) <= 3 and a not in {"IBM", "AMD", "UPS", "CVS", "AWS", "HP"}:
continue
keep.add(a)
out[r.ticker] = keep
# an alias shared by two tickers (share classes, reused names) stays only if they are one company
owner: dict[str, set[str]] = {}
for t, al in out.items():
for a in al:
owner.setdefault(a, set()).add(t)
same_co = [{"GOOGL", "GOOG"}, {"FOXA", "FOX"}, {"NWSA", "NWS"}, {"BRK-B", "BRK.B"}]
for a, ts in owner.items():
if len(ts) > 1 and not any(ts <= s for s in same_co):
for t in ts:
out[t].discard(a)
return out
_RX = None
_ALIAS2T: dict[str, str] = {}
def _compile(al: dict[str, set[str]]):
global _RX, _ALIAS2T
_ALIAS2T = {a: t for t, s in al.items() for a in s}
alts = sorted(_ALIAS2T, key=len, reverse=True)
_RX = re.compile(r"(?<![\w&-])(?:" + "|".join(re.escape(a) for a in alts) + r")(?![\w-])")
def links_in(text: str, own: set[str]) -> dict[str, dict]:
found: dict[str, dict] = {}
for line in text.split("\n"):
if len(line) < 20 or not _RX.search(line):
continue
for s in _SENT.split(line):
hits = {_ALIAS2T[m.group(0)] for m in _RX.finditer(s)} - own
if not hits or NOISE.search(s):
continue
roles = {k for k, rx in KW.items() if rx.search(s)}
if "competitor" in roles: # "our competitors include X ... customers" is not a customer link
roles = {"competitor"}
for t in hits:
d = found.setdefault(t, {"n_customer": 0, "n_supplier": 0, "n_competitor": 0, "n_all": 0, "example": ""})
d["n_all"] += 1
for k in roles:
d[f"n_{k}"] += 1
if (not d["example"]) or ("customer" in roles and d["n_customer"] == 1):
d["example"] = s[:300]
return found
def work(job) -> tuple[str, list[dict]] | None:
filer, own, cik, filed, acc, doc = job
a = acc.replace("-", "")
name = doc if isinstance(doc, str) and doc.strip() else f"{acc}.txt"
r = lp._get(f"https://www.sec.gov/Archives/edgar/data/{cik}/{a}/{name}")
if r is None:
return acc, []
if name.lower().endswith(".pdf"):
return acc, []
try:
text = lp.clean_text(r.content)
except Exception: # noqa: BLE001
return acc, []
rows = [{"filer": filer, "filed": filed, "accession": acc, "target": t, **d}
for t, d in links_in(text, set(own)).items()]
return acc, rows
def _init(lock, nxt, al):
lp._init_worker(lock, nxt)
_compile(al)
# ---------------------------------------------------------------------------
# S&P 400 + 600 (research/lab/panel_mid.py): 10-Ks filed from 2015 on, names
# matched against the S&P 1500 (the S&P 500 ever-members plus these)
# ---------------------------------------------------------------------------
MID_INDEX = os.path.join(DIR, "mid_10k_index.csv")
MID_SIC = os.path.join(DIR, "mid_sec_sic.csv")
MID_NAMES = os.path.join(DIR, "mid_sec_names.json")
MID_OUT = os.path.join(DIR, "mid_10k_links.csv")
MID_DONE = os.path.join(DIR, "mid_10k_links_done.txt")
MID_START = "2015-01-01"
def build_mid_index() -> None:
"""CIKs of the mid/small panel's tickers (company_tickers.json, i.e. names
still listed today, plus sec_sic.csv), their SEC names and 10-K filings."""
from research.lab.fetch_us_fundamentals import cik_map
from research.lab.panel_mid import load_us_mid
tickers = [t for t in load_us_mid("midsmall").close.columns if t not in lp.US_ETFS]
cm = cik_map()
old = pd.read_csv(os.path.join(DIR, "sec_sic.csv")).set_index("ticker")["cik"].to_dict()
tc = {t: int(old.get(t, cm.get(t, 0))) for t in tickers}
tc = {t: c for t, c in tc.items() if c}
print(f"{len(tc)}/{len(tickers)} mid/small tickers map to a CIK", flush=True)
by_cik: dict[int, list[str]] = {}
for t, c in tc.items():
by_cik.setdefault(c, []).append(t)
sic_rows, names, idx = [], {}, []
t0 = time.time()
for k, (cik, ts) in enumerate(by_cik.items(), 1):
r = lp._get(f"https://data.sec.gov/submissions/CIK{cik:010d}.json")
if r is None:
continue
js = r.json()
names[str(cik)] = [js.get("name", "")] + [x.get("name", "") for x in js.get("formerNames", [])]
parts = [lp._columns(js["filings"]["recent"])]
for fl in js["filings"].get("files", []):
if fl.get("filingTo", "9999") < MID_START:
continue
r2 = lp._get(f"https://data.sec.gov/submissions/{fl['name']}")
if r2 is not None:
parts.append(lp._columns(r2.json()))
df = pd.concat(parts, ignore_index=True).drop_duplicates("accessionNumber")
for t in sorted(ts):
sic_rows.append({"ticker": t, "cik": cik, "sic": js.get("sic", ""),
"sicDescription": js.get("sicDescription", ""), "name": js.get("name", "")})
fk = df[df["form"].isin(["10-K", "10-K405"]) & (df["filingDate"] >= MID_START)]
idx.append(pd.DataFrame({"cik": cik, "tickers": "|".join(sorted(ts)), "form": fk["form"],
"filed": fk["filingDate"], "period": fk["reportDate"],
"accession": fk["accessionNumber"], "doc": fk["primaryDocument"]}))
if k % 100 == 0:
print(f" submissions {k}/{len(by_cik)} [{time.time() - t0:.0f}s]", flush=True)
pd.DataFrame(sic_rows).sort_values("ticker").to_csv(MID_SIC, index=False)
json.dump(names, open(MID_NAMES, "w"))
ix = pd.concat(idx, ignore_index=True).sort_values(["cik", "filed"])
ix.to_csv(MID_INDEX, index=False)
print(f"mid index: {len(ix)} 10-Ks for {ix['cik'].nunique()} CIKs [{time.time() - t0:.0f}s]", flush=True)
def sp1500_frames() -> tuple[pd.DataFrame, dict]:
big = pd.read_csv(os.path.join(DIR, "sec_sic.csv"))
mid = pd.read_csv(MID_SIC)
sic = pd.concat([big, mid[~mid["ticker"].isin(big["ticker"])]], ignore_index=True)
fn = {**json.load(open(MID_NAMES)), **json.load(open(NAMES))}
return sic, fn
def main() -> None:
global OUT, DONE
ap = argparse.ArgumentParser()
ap.add_argument("--workers", type=int, default=4)
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--universe", default="sp500", choices=["sp500", "mid"])
args = ap.parse_args()
lock, nxt = mp.Lock(), mp.Value("d", 0.0)
lp._init_worker(lock, nxt)
if args.universe == "mid":
if not os.path.exists(MID_INDEX):
build_mid_index()
OUT, DONE = MID_OUT, MID_DONE
sic_all, fn = sp1500_frames()
al = aliases(sic_all, fn)
ix = pd.read_csv(MID_INDEX, dtype={"doc": str})
sic = pd.read_csv(MID_SIC)
else:
al = aliases()
ix = pd.read_csv(os.path.join(DIR, "lazy_prices_index.csv"), dtype={"doc": str})
sic = pd.read_csv(os.path.join(DIR, "sec_sic.csv"))
print(f"{len(al)} tickers, {sum(map(len, al.values()))} aliases", flush=True)
ix = ix[ix["form"].isin(["10-K", "10-K405"])]
cik2t = sic.groupby("cik")["ticker"].apply(list).to_dict()
done = set(open(DONE).read().split()) if os.path.exists(DONE) else set()
jobs = []
for r in ix.itertuples():
if r.accession in done:
continue
ts = cik2t.get(r.cik, str(r.tickers).split("|"))
jobs.append((ts[0], [t for t in ts], int(r.cik), r.filed, r.accession, r.doc))
if args.limit:
jobs = jobs[:args.limit]
print(f"{len(jobs)} 10-Ks to scan ({len(done)} done)", flush=True)
if not os.path.exists(OUT):
with open(OUT, "w", newline="") as fh:
csv.DictWriter(fh, fieldnames=COLS).writeheader()
t0 = time.time()
with mp.get_context("fork").Pool(args.workers, initializer=_init, initargs=(lock, nxt, al)) as pool, \
open(OUT, "a", newline="") as out, open(DONE, "a") as dn:
w = csv.DictWriter(out, fieldnames=COLS)
for k, (acc, rows) in enumerate(pool.imap_unordered(work, jobs, chunksize=4), 1):
for row in rows:
w.writerow(row)
dn.write(acc + "\n")
if k % 200 == 0 or k == len(jobs):
out.flush(); dn.flush()
el = time.time() - t0
print(f" {k}/{len(jobs)} [{el/60:.0f} min, ETA {(len(jobs)-k)*el/k/60:.0f} min]", flush=True)
print("done", flush=True)
if __name__ == "__main__":
main()