Files
quant-trading/research/lab/theme_propagation.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

269 lines
13 KiB
Python

"""
Does a main uptrend propagate along linked industries with a lag of months?
The AI build-out moved stage by stage (hyperscalers and power in H1 2025,
optics / equipment / memory in H2 2025, chips and servers in Q2 2026). This
asks whether that pattern is systematic: when an industry is in a strong
run, do the industries linked to it that have NOT moved yet outperform over
the next 1-6 months? Every era gets its own theme; the rule never names one.
PYTHONPATH=. uv run python -m research.lab.theme_propagation
Units: 3-digit SIC industries of PIT S&P 500 members (sec_sic.csv), equal
weight, with at least 3 members that month. Monthly, at each month end t:
hot industries whose 6-month return over the equal-weight market is
in the top 10% of industries and above +15%
links econ_up supplier industries of the hot one: 10-K customer
sentences filed in the last 3 years (filer = supplier,
named company = customer; research/lab/fetch_supply_links.py)
econ_any any 10-K mention in either direction, last 3 years
corr the 5 industries whose monthly excess returns correlated
most with it over the last 36 months
lagged the 5 industries whose excess return in months m+1..m+3
correlated most with its return in month m, last 60 months
followers linked industries that are not hot themselves and whose own
6-month excess return is below the cross-industry median
("the linked industry has not moved yet"); variant: all linked
control below-median industries not linked to any hot industry
Portfolio: equal weight over the member stocks of the follower industries,
bought at the first open after t and held 1, 3 or 6 months (overlapping
cohorts averaged, i.e. monthly tranches), against the equal-weight market.
20 bp per unit of turnover is charged at each rebalance. Reported per era
(2006-10, 2011-15, 2016-20, 2021-26) and design (<=2016) / holdout.
A stock-level check follows: the lead_lag link signals over 63- and 126-day
windows predicting the next 63 days (Fama-MacBeth with the usual controls).
"""
from __future__ import annotations
import json
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS, load
warnings.filterwarnings("ignore")
ERAS = [("2006", "2010"), ("2011", "2015"), ("2016", "2020"), ("2021", "2026")]
HOT_Q, HOT_MIN, LOOK = 0.90, 0.15, 6
def industry_map(p) -> pd.Series:
sic = pd.read_csv(os.path.join("data", "research", "sec_sic.csv")).drop_duplicates("ticker").set_index("ticker")["sic"]
s = (pd.to_numeric(sic, errors="coerce") // 10).dropna().astype(int)
return s.reindex(p.close.columns)
def monthly_open_returns(p) -> tuple[pd.DataFrame, pd.DatetimeIndex]:
"""Stock return from the first open of month m+1 to the first open of
month m+2, indexed by the signal month end m."""
o = p.open.where(p.open.notna(), p.close).ffill()
first = pd.Series(p.dates, index=p.dates).groupby(p.dates.to_period("M")).min()
ends = pd.Series(p.dates, index=p.dates).groupby(p.dates.to_period("M")).max()
fo = o.loc[first.values]
r = fo.shift(-1) / fo - 1 # first open of month k -> first open of month k+1
r.index = ends.values
r = r.shift(-1) # signal at end of m -> return over month m+1
return r, pd.DatetimeIndex(ends.values)
def group_panels(p, ind: pd.Series):
"""Monthly member masks and equal-weight monthly close-to-close excess
returns per industry (for the signals)."""
ends = pd.Series(p.dates, index=p.dates).groupby(p.dates.to_period("M")).max().values
c = p.close.ffill()
mc = c.loc[ends]
mr = (mc / mc.shift(1) - 1).where(p.member.loc[ends].shift(1).fillna(False).astype(bool))
mem = p.member.loc[ends]
mkt = mr.mean(axis=1)
groups = sorted(ind.dropna().unique())
G = pd.DataFrame(index=mr.index, columns=groups, dtype=float)
N = pd.DataFrame(index=mr.index, columns=groups, dtype=float)
for g in groups:
cols = list(ind.index[ind == g])
G[g] = mr[cols].mean(axis=1)
N[g] = mem[cols].sum(axis=1)
X = G.sub(mkt, axis=0)
return X.where(N >= 3), N, mem
def econ_links(ind: pd.Series, ends, role: str) -> dict:
"""{month end: set of (hot industry, linked industry)} from 10-Ks filed
in the last 3 years. role "up": customer industry -> supplier industry."""
d = pd.read_csv(os.path.join("data", "research", "sec_10k_links.csv"), parse_dates=["filed"])
if role == "up":
d = d[d["n_customer"] > 0]
d = d.assign(fi=d["filer"].map(ind), ti=d["target"].map(ind)).dropna(subset=["fi", "ti"])
d = d[d["fi"] != d["ti"]]
out = {}
for t in ends:
live = d[(d["filed"] <= t) & (d["filed"] > t - pd.Timedelta(days=3 * 365))]
if role == "up": # filer supplies the named customer: customer industry leads
pairs = set(zip(live["ti"].astype(int), live["fi"].astype(int)))
else:
pairs = set(zip(live["ti"].astype(int), live["fi"].astype(int))) | set(zip(live["fi"].astype(int), live["ti"].astype(int)))
out[t] = pairs
return out
def stat_links(X: pd.DataFrame, kind: str, k: int = 5) -> dict:
out = {}
for i, t in enumerate(X.index):
win = 36 if kind == "corr" else 60
if i < win:
out[t] = set()
continue
W = X.iloc[i - win + 1:i + 1]
W = W.loc[:, W.notna().sum() >= int(win * 0.8)]
if W.shape[1] < 10:
out[t] = set()
continue
Z = (W - W.mean()) / W.std()
Z = Z.fillna(0.0)
if kind == "corr":
C = Z.T @ Z / len(Z)
else: # C[a, b] = mean over lags 1-3 of corr(a in month m, b in month m+lag)
C = sum(Z.iloc[:-lag].T.to_numpy() @ Z.iloc[lag:].to_numpy() / (len(Z) - lag) for lag in (1, 2, 3)) / 3
C = pd.DataFrame(C, index=Z.columns, columns=Z.columns)
C = C.to_numpy(copy=True)
np.fill_diagonal(C, -np.inf)
cols = list(Z.columns)
pairs = set()
for a in range(len(cols)):
for b in np.argsort(-C[a])[:k]:
pairs.add((cols[a], cols[b]))
out[t] = pairs
return out
def followers(X: pd.DataFrame, links: dict, laggard_only: bool = True) -> tuple[dict, dict, dict]:
"""Per month end: hot industries, follower industries, control industries."""
cum = np.log1p(X.fillna(0.0)).rolling(LOOK, min_periods=LOOK).sum().where(X.notna())
hot_d, fol_d, ctl_d = {}, {}, {}
for t in X.index:
row = cum.loc[t].dropna()
if len(row) < 20:
continue
thr = max(row.quantile(HOT_Q), np.log1p(HOT_MIN))
hot = set(row.index[row >= thr])
med = row.median()
lag = set(row.index[row < med])
pairs = links.get(t, set())
linked = {b for a, b in pairs if a in hot} - hot
linked &= set(row.index)
fol = linked & lag if laggard_only else linked
hot_d[t], fol_d[t] = hot, fol
ctl_d[t] = lag - linked - hot
return hot_d, fol_d, ctl_d
def portfolio(sel: dict, ind: pd.Series, mem: pd.DataFrame, R: pd.DataFrame, hold: int) -> pd.Series:
"""Monthly return of equal-weight member stocks in the selected industries,
held ``hold`` months as overlapping cohorts, net of 20 bp x turnover."""
months = R.index
W = pd.DataFrame(0.0, index=months, columns=R.columns)
for t, gs in sel.items():
if t not in W.index or not gs:
continue
cols = [c for c in ind.index[ind.isin(gs)] if mem.at[t, c]]
if cols:
W.loc[t, cols] = 1.0 / len(cols)
Wh = sum(W.shift(k).fillna(0.0) for k in range(hold)) / hold # tranches formed at t, t-1, ...
invested = Wh.sum(axis=1)
gross = (Wh * R.fillna(0.0)).sum(axis=1) / invested.replace(0, np.nan)
turn = (Wh.div(invested.replace(0, np.nan), axis=0).fillna(0.0).diff().abs().sum(axis=1)) / 2
return (gross - 0.002 * 2 * turn).where(invested > 0)
def mstats(r: pd.Series, b: pd.Series) -> dict:
"""Excess over the benchmark: mean per month, annualized, Newey-West t (3 lags)."""
x = (r - b).dropna()
if len(x) < 12:
return {"ann": np.nan, "t": np.nan, "n": len(x)}
n, m = len(x), x.mean()
e = x - m
s = (e ** 2).sum() / n
for l in (1, 2, 3):
s += 2 * (1 - l / 4) * (e.iloc[l:].to_numpy() * e.iloc[:-l].to_numpy()).sum() / n
return {"ann": float(m * 12), "t": float(m / np.sqrt(s / n)), "n": n}
def stock_level_long(p) -> dict:
"""lead_lag link signals over long windows, next-63-day Fama-MacBeth."""
from research.lab import lead_lag as ll
from research.lab.rotation import factor_ranks
pr = factor_ranks(p, "us", ["overnight_mom", "mom_12_1"])
L = pickle.load(open(ll.CACHE, "rb"))
c = p.close.ffill()
out = {}
for win in (63, 126):
own = (c / c.shift(win) - 1).where(p.member)
for key in ("corr_peer", "10k_any", "10k_customer"):
for kind in ("mom", "gap"):
sig = ll.link_signal(p, L[key], win, kind)
fm = ll.fama_macbeth(p, sig, {"own": own, "mom_12_1": pr["mom_12_1"], "overnight": pr["overnight_mom"]}, 63)
out[f"{key}_{kind}_{win}d"] = fm
print(f" {key}_{kind} window {win}d -> next 63d: FM t all {fm['all']['t']:5.2f} | design {fm['design']['t']:5.2f} "
f"| holdout {fm['holdout']['t']:5.2f} | IC {fm['all']['ic']:+.3f}", flush=True)
return out
def main() -> None:
p, _ = load("us")
ind = industry_map(p)
X, N, mem = group_panels(p, ind)
R, ends = monthly_open_returns(p) # unmasked: a stock bought at t keeps its return after leaving the index
mkt = R.where(mem).mean(axis=1)
from research.lab.alpha import bench_returns
q = bench_returns(p.dates)
qm = (1 + q.fillna(0.0)).groupby(p.dates.to_period("M")).prod() - 1
qm.index = ends
qm = qm.shift(-1) # align with R (return over month m+1)
print(f"{X.notna().any().sum()} industries with >=3 members at some point; median per month "
f"{int(X.notna().sum(axis=1).median())}", flush=True)
link_sets = {"econ_up": econ_links(ind, X.index, "up"), "econ_any": econ_links(ind, X.index, "any"),
"corr": stat_links(X, "corr"), "lagged": stat_links(X, "lagged")}
res, rets = {}, {}
for name, L in link_sets.items():
for lag_only in (True, False):
hot, fol, ctl = followers(X, L, lag_only)
nf = np.mean([len(v) for v in fol.values()])
tag = f"{name}{'' if lag_only else ' (all linked)'}"
for hold in (1, 3, 6):
r = portfolio(fol, ind, mem, R, hold)
rc = portfolio(ctl, ind, mem, R, hold)
row = {"followers_per_month": float(nf)}
for lab, a, b in [("all", "2006-09", "2026-08"), ("design", "2006-09", "2016-12"), ("holdout", "2017-01", "2026-08")] + \
[(f"{e0}-{e1}", e0, e1) for e0, e1 in ERAS]:
row[lab] = mstats(r.loc[a:b], mkt.loc[a:b])
row[lab + "_vs_control"] = mstats(r.loc[a:b], rc.loc[a:b])
row["vs_qqq_holdout"] = mstats(r.loc["2017":"2026-08"], qm.loc["2017":"2026-08"])
res[f"{tag} hold{hold}"] = row
rets[f"{tag} hold{hold}"] = r
eras = " ".join(f"{k}: {row[k]['ann']*100:+5.1f}% (t {row[k]['t']:+.2f})" for k in [f"{a}-{b}" for a, b in ERAS])
print(f"{tag:22s} hold {hold}m n {nf:4.1f} | vs mkt design {row['design']['ann']*100:+5.1f}% t {row['design']['t']:+.2f} "
f"holdout {row['holdout']['ann']*100:+5.1f}% t {row['holdout']['t']:+.2f} | vs control holdout "
f"{row['holdout_vs_control']['ann']*100:+5.1f}% t {row['holdout_vs_control']['t']:+.2f} | {eras}", flush=True)
# which themes fired: most frequent hot industries per era
hot, _, _ = followers(X, link_sets["corr"], True)
sic = pd.read_csv(os.path.join("data", "research", "sec_sic.csv"))
desc = sic.assign(g=pd.to_numeric(sic["sic"], errors="coerce") // 10).groupby("g")["sicDescription"].first()
themes = {}
for e0, e1 in ERAS:
cnt = pd.Series([g for t, hs in hot.items() if e0 <= str(t.year) <= e1 for g in hs]).value_counts().head(6)
themes[f"{e0}-{e1}"] = {f"{int(g)} {desc.get(g, '')}": int(n) for g, n in cnt.items()}
print(f"hot industries {e0}-{e1}: " + ", ".join(f"{k} ({v}m)" for k, v in themes[f"{e0}-{e1}"].items()), flush=True)
print("\nstock level, long windows:", flush=True)
res["_stock_level"] = stock_level_long(p)
res["_themes"] = themes
json.dump(res, open(os.path.join(RESULTS, "theme_propagation.json"), "w"), indent=1, default=float)
pd.DataFrame(rets).to_pickle(os.path.join(RESULTS, "theme_propagation_returns.pkl"))
if __name__ == "__main__":
main()