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

89 lines
4.0 KiB
Python

"""
Theme concentration in the momentum sleeve. In the 2026-04..09 replay the
live overnight_recovery_top10 held about 92% in AI-hardware names; this
checks whether capping names per industry changes its return or drawdown
over 2006-2026.
PYTHONPATH=. uv run --with numba python -m research.lab.concentration
A cap of k per group keeps a stock eligible only while it ranks in the top k
of its group by the same score; the usual top-10 / buffer-2 / every-10-days
selection then runs on the eligible names. Groups: GICS sector (current
classification, 11 groups) and 3-digit SIC (sec_sic.csv, ~150 groups).
Reported: alpha vs QQQ per half, MaxDD, worst 3-month return, and the mean
largest group weight.
"""
from __future__ import annotations
import os
import warnings
import numpy as np
import pandas as pd
from research.lab.alpha import alpha_stats, bench_returns
from research.lab.factor_scan import RESULTS, load
from research.lab.rotation import factor_ranks
from research.lab.us_sweep import _blend, run
warnings.filterwarnings("ignore")
LONG = [("2006-09-27", "2026-09-24")]
def capped(score: pd.DataFrame, groups: pd.Series, k: int) -> pd.DataFrame:
g = groups.reindex(score.columns)
out = score.copy()
for name in g.dropna().unique():
cols = list(g.index[g == name])
rk = score[cols].rank(axis=1, ascending=False)
out[cols] = score[cols].where(rk <= k)
return out
def top_group_weight(score: pd.DataFrame, groups: pd.Series, n: int = 10) -> float:
rk = score.rank(axis=1, ascending=False) <= n
g = groups.reindex(score.columns)
share = [rk.loc[:, g == x].sum(axis=1) for x in g.dropna().unique()]
m = pd.concat(share, axis=1).max(axis=1) / n
return float(m[rk.sum(axis=1) >= n].mean())
def main() -> None:
p, _ = load("us")
pr = factor_ranks(p, "us", ["overnight_mom", "rec_126"])
mom = _blend(pr["overnight_mom"], pr["rec_126"]).where(p.member)
sic = pd.read_csv(os.path.join("data", "research", "sec_sic.csv")).drop_duplicates("ticker").set_index("ticker")["sic"]
sic3 = (pd.to_numeric(sic, errors="coerce") // 10).dropna().astype(int).astype(str)
groups = {"gics": p.extra["sector"], "sic3": sic3}
b = bench_returns(p.dates)
variants = [("uncapped", None, None), ("gics <=4", "gics", 4), ("gics <=3", "gics", 3), ("gics <=2", "gics", 2),
("sic3 <=3", "sic3", 3), ("sic3 <=2", "sic3", 2), ("sic3 <=1", "sic3", 1)]
rows, R = [], {}
for name, gk, k in variants:
s = mom if gk is None else capped(mom, groups[gk], k)
r = run(p, s, LONG, top_n=10, buffer=2.0, every=10)["_rets"]
e = (1 + r).cumprod()
q = (1 + r).rolling(63).apply(np.prod, raw=True) - 1
row = {"variant": name, "mdd": float((e / e.cummax() - 1).min()), "worst_3m": float(q.min()),
"vol": float(r.std() * np.sqrt(252)),
"max_gics_w": top_group_weight(s.loc["2006-09-27":], groups["gics"]),
"max_sic3_w": top_group_weight(s.loc["2006-09-27":], groups["sic3"])}
for h, x in (("all", r), ("design", r.loc[:"2016"]), ("holdout", r.loc["2017":])):
a = alpha_stats(x, b)
row.update({f"{h}_alpha": a["alpha"], f"{h}_t": a["t"], f"{h}_cagr": a["cagr"]})
row["sharpe"] = float(r.mean() / r.std() * np.sqrt(252))
rows.append(row)
R[name] = r
print(f"{name:10s} alpha {row['all_alpha']*100:5.1f}% (t {row['all_t']:.2f}) | 06-16 {row['design_alpha']*100:5.1f}% "
f"t {row['design_t']:.2f} | 17-26 {row['holdout_alpha']*100:5.1f}% t {row['holdout_t']:.2f} | "
f"cagr {row['all_cagr']*100:5.1f}% sharpe {row['sharpe']:.2f} vol {row['vol']*100:4.1f}% "
f"mdd {row['mdd']*100:4.0f}% worst3m {row['worst_3m']*100:4.0f}% | max sector w {row['max_gics_w']*100:3.0f}% "
f"max sic3 w {row['max_sic3_w']*100:3.0f}%", flush=True)
pd.DataFrame(rows).to_csv(os.path.join(RESULTS, "concentration.csv"), index=False)
pd.DataFrame(R).to_pickle(os.path.join(RESULTS, "concentration_returns.pkl"))
if __name__ == "__main__":
main()