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>
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""
|
|
Daily English Wikipedia pageviews for every US research-panel ticker, as a
|
|
point-in-time attention proxy (the free source with the longest daily history;
|
|
X/Reddit history is not available for free, see research/lab/attention.py).
|
|
|
|
uv run python -m research.lab.fetch_wiki_attention # mapping + pageviews
|
|
uv run python -m research.lab.fetch_wiki_attention --map-only
|
|
|
|
Ticker -> article, first hit wins:
|
|
0. OVERRIDES below (hand-checked)
|
|
1. "List of S&P 500 companies" constituents table (current members)
|
|
2. "Historical components of the S&P 500" change log (added/removed names,
|
|
most recent entry first, so a reused ticker gets its latest S&P company)
|
|
3. Wikidata: SEC CIK (P5531) of data/research/sec_sic.csv
|
|
4. Wikidata: NYSE/Nasdaq ticker qualifier (P414 / P249)
|
|
Titles are resolved to the canonical article; views are summed over the
|
|
article and every redirect to it, so a rename (Facebook -> Meta Platforms)
|
|
does not break the series. Agent "user" (spiders and automated excluded).
|
|
|
|
Writes data/research/wiki_pageviews.pkl:
|
|
{"views": DataFrame dates x tickers (canonical + redirects), "complete": bool,
|
|
"views_main": same, canonical title only,
|
|
"map": DataFrame ticker/title/source, "fetched": timestamp}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pickle
|
|
import re
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
import pandas as pd
|
|
import requests
|
|
from bs4 import BeautifulSoup
|
|
|
|
UA = "quant-research-attention/0.1 (academic factor research; gahow@example.com)"
|
|
OUT = os.path.join("data", "research", "wiki_pageviews.pkl")
|
|
CACHE = os.path.join("data", "research", "wiki_cache")
|
|
START, END = "20150701", "20260929"
|
|
SPARQL = "https://query.wikidata.org/sparql"
|
|
API = "https://en.wikipedia.org/w/api.php"
|
|
PV = ("https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia/"
|
|
"all-access/user/{t}/daily/{a}/{b}")
|
|
# change-log links that point at the wrong article, or names missing from both tables
|
|
OVERRIDES = {"ITT": "ITT Inc.", "CNX": "CNX Resources", "PODD": "Insulet", "MBC": "MasterBrand"}
|
|
TICKER_RE = re.compile(r"^[A-Z][A-Z0-9.\-]{0,6}$")
|
|
|
|
_session = threading.local()
|
|
|
|
|
|
def _s() -> requests.Session:
|
|
if not hasattr(_session, "s"):
|
|
_session.s = requests.Session()
|
|
_session.s.headers["User-Agent"] = UA
|
|
return _session.s
|
|
|
|
|
|
def _get(url, **kw):
|
|
for i in range(20):
|
|
try:
|
|
r = _s().get(url, timeout=60, **kw)
|
|
except requests.RequestException:
|
|
time.sleep(2 ** i)
|
|
continue
|
|
if r.status_code == 429 or r.status_code >= 500:
|
|
ra = r.headers.get("Retry-After")
|
|
time.sleep(float(ra) + 1 if ra else 2 ** i)
|
|
continue
|
|
return r
|
|
raise RuntimeError(f"failed: {url}")
|
|
|
|
|
|
def _norm(t: str) -> str:
|
|
return t.strip().upper().replace(".", "-")
|
|
|
|
|
|
def _title(href: str) -> str | None:
|
|
if not href or not href.startswith("./"):
|
|
return None
|
|
t = urllib.parse.unquote(href[2:]).split("#")[0]
|
|
return t.replace("_", " ")
|
|
|
|
|
|
def _page(title: str) -> BeautifulSoup:
|
|
r = _get("https://en.wikipedia.org/api/rest_v1/page/html/" + urllib.parse.quote(title, safe=""))
|
|
return BeautifulSoup(r.text, "lxml")
|
|
|
|
|
|
def _from_wikipedia() -> tuple[dict, dict]:
|
|
cur = {}
|
|
s = _page("List_of_S&P_500_companies")
|
|
for tr in s.find("table", id="constituents").find_all("tr")[1:]:
|
|
td = tr.find_all("td")
|
|
if len(td) >= 2:
|
|
a = td[1].find("a")
|
|
t = _title(a.get("href")) if a else None
|
|
if t:
|
|
cur[_norm(td[0].get_text(strip=True))] = t
|
|
hist = {}
|
|
s = _page("Historical_components_of_the_S&P_500")
|
|
for tr in s.find("table", id="changes").find_all("tr")[2:]: # newest first
|
|
cells = tr.find_all("td")
|
|
for c, nxt in zip(cells, cells[1:]):
|
|
tk = c.get_text(strip=True)
|
|
a = nxt.find("a")
|
|
if TICKER_RE.match(tk) and a is not None:
|
|
t = _title(a.get("href"))
|
|
if t:
|
|
hist.setdefault(_norm(tk), t)
|
|
return cur, hist
|
|
|
|
|
|
def _sparql(q: str) -> pd.DataFrame:
|
|
r = _get(SPARQL, params={"query": q}, headers={"Accept": "text/csv"})
|
|
from io import StringIO
|
|
return pd.read_csv(StringIO(r.text), dtype=str)
|
|
|
|
|
|
def _from_wikidata() -> tuple[dict, dict]:
|
|
c = _sparql("""SELECT ?cik ?article WHERE { ?item wdt:P5531 ?cik .
|
|
?article schema:about ?item ; schema:isPartOf <https://en.wikipedia.org/> . }""")
|
|
by_cik = {}
|
|
for cik, art in zip(c["cik"].str.lstrip("0"), c["article"]):
|
|
by_cik.setdefault(cik, urllib.parse.unquote(art.rsplit("/", 1)[1]).replace("_", " "))
|
|
t = _sparql("""SELECT ?ticker ?article WHERE { VALUES ?exch { wd:Q13677 wd:Q82059 }
|
|
?item p:P414 ?st . ?st ps:P414 ?exch ; pq:P249 ?ticker .
|
|
?article schema:about ?item ; schema:isPartOf <https://en.wikipedia.org/> . }""")
|
|
by_tk = {}
|
|
for tk, art in zip(t["ticker"], t["article"]):
|
|
by_tk.setdefault(_norm(tk), urllib.parse.unquote(art.rsplit("/", 1)[1]).replace("_", " "))
|
|
return by_cik, by_tk
|
|
|
|
|
|
def _canonical(titles: list[str]) -> dict[str, str]:
|
|
out = {}
|
|
for i in range(0, len(titles), 50):
|
|
batch = titles[i:i + 50]
|
|
r = _get(API, params={"action": "query", "titles": "|".join(batch), "redirects": 1,
|
|
"format": "json", "formatversion": 2}).json()["query"]
|
|
norm = {n["from"]: n["to"] for n in r.get("normalized", [])}
|
|
red = {n["from"]: n["to"] for n in r.get("redirects", [])}
|
|
missing = {p["title"] for p in r["pages"] if p.get("missing")}
|
|
for t in batch:
|
|
x = norm.get(t, t)
|
|
x = red.get(x, x)
|
|
if x not in missing:
|
|
out[t] = x
|
|
return out
|
|
|
|
|
|
def _redirects(titles: list[str]) -> dict[str, list[str]]:
|
|
"""Redirects into each title, 50 titles per request, sequential (the action
|
|
API throttles parallel clients); cached in CACHE/redirects.json."""
|
|
path = os.path.join(CACHE, "redirects.json")
|
|
os.makedirs(CACHE, exist_ok=True)
|
|
out = json.load(open(path)) if os.path.exists(path) else {}
|
|
todo = [t for t in titles if t not in out]
|
|
for i in range(0, len(todo), 50):
|
|
batch, cont = todo[i:i + 50], {}
|
|
got = {t: [] for t in batch}
|
|
while True:
|
|
r = _get(API, params={"action": "query", "titles": "|".join(batch), "prop": "redirects",
|
|
"rdnamespace": 0, "rdlimit": "max", "format": "json",
|
|
"formatversion": 2, **cont}).json()
|
|
for p in r["query"]["pages"]:
|
|
got.setdefault(p["title"], []).extend(x["title"] for x in p.get("redirects", []))
|
|
if "continue" not in r:
|
|
break
|
|
cont = r["continue"]
|
|
out.update({t: got.get(t, []) for t in batch})
|
|
with open(path, "w") as fh:
|
|
json.dump(out, fh)
|
|
return {t: out[t] for t in titles}
|
|
|
|
|
|
def build_map(tickers: list[str]) -> pd.DataFrame:
|
|
cur, hist = _from_wikipedia()
|
|
by_cik, by_tk = _from_wikidata()
|
|
sic = pd.read_csv(os.path.join("data", "research", "sec_sic.csv"), dtype={"cik": str})
|
|
cik = dict(zip(sic["ticker"], sic["cik"].str.lstrip("0")))
|
|
rows = []
|
|
for t in tickers:
|
|
n = _norm(t)
|
|
for src, title in (("override", OVERRIDES.get(t)), ("sp500_list", cur.get(n)), ("sp500_changes", hist.get(n)),
|
|
("wikidata_cik", by_cik.get(cik.get(t, ""))),
|
|
("wikidata_ticker", by_tk.get(n))):
|
|
if title:
|
|
rows.append({"ticker": t, "title": title, "source": src})
|
|
break
|
|
else:
|
|
rows.append({"ticker": t, "title": None, "source": None})
|
|
m = pd.DataFrame(rows)
|
|
canon = _canonical(sorted(m["title"].dropna().unique()))
|
|
m["canonical"] = m["title"].map(canon)
|
|
return m
|
|
|
|
|
|
def _views(title: str) -> pd.Series:
|
|
os.makedirs(CACHE, exist_ok=True)
|
|
key = os.path.join(CACHE, re.sub(r"[^A-Za-z0-9]+", "_", title)[:120] + "_" + hashlib.md5(title.encode()).hexdigest()[:8] + ".json")
|
|
if os.path.exists(key):
|
|
with open(key) as fh:
|
|
d = json.load(fh)
|
|
else:
|
|
url = PV.format(t=urllib.parse.quote(title.replace(" ", "_"), safe=""), a=START, b=END)
|
|
r = _get(url)
|
|
d = r.json().get("items", []) if r.status_code == 200 else []
|
|
with open(key, "w") as fh:
|
|
json.dump(d, fh)
|
|
time.sleep(0.05)
|
|
if not d:
|
|
return pd.Series(dtype=float)
|
|
return pd.Series({pd.Timestamp(x["timestamp"][:8]): float(x["views"]) for x in d})
|
|
|
|
|
|
def _assemble(m, titles, reds, V, with_redirects: bool) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
idx = pd.date_range(pd.Timestamp(START), pd.Timestamp(END), freq="D")
|
|
main, tot = {}, {}
|
|
for t in titles:
|
|
s0 = V[t].reindex(idx)
|
|
parts = [s0.fillna(0.0)] + ([V[r].reindex(idx).fillna(0.0) for r in reds[t]] if with_redirects else [])
|
|
s = pd.concat(parts, axis=1).sum(axis=1)
|
|
main[t], tot[t] = s0, s.where(s0.notna() | (s > 0))
|
|
tick = m.dropna(subset=["canonical"])
|
|
views = pd.DataFrame({r.ticker: tot[r.canonical] for r in tick.itertuples()})
|
|
views_main = pd.DataFrame({r.ticker: main[r.canonical] for r in tick.itertuples()})
|
|
return views, views_main
|
|
|
|
|
|
def _save(m, views, views_main, complete: bool) -> None:
|
|
with open(OUT, "wb") as fh:
|
|
pickle.dump({"views": views, "views_main": views_main, "map": m, "complete": complete,
|
|
"fetched": pd.Timestamp.now()}, fh)
|
|
print(f"wrote {OUT} (redirects {'included' if complete else 'NOT included'}): {views.shape}", flush=True)
|
|
|
|
|
|
def fetch_views(m: pd.DataFrame, workers: int = 4) -> None:
|
|
"""Canonical articles first (saved without redirects), then every redirect."""
|
|
titles = sorted(m["canonical"].dropna().unique())
|
|
reds = _redirects(titles)
|
|
rest = sorted({r for v in reds.values() for r in v} - set(titles))
|
|
print(f"{len(titles)} articles, {len(rest)} redirects", flush=True)
|
|
done = [0]
|
|
|
|
def one(t):
|
|
s = _views(t)
|
|
done[0] += 1
|
|
if done[0] % 500 == 0:
|
|
print(f" {done[0]}/{len(titles) + len(rest)}", flush=True)
|
|
return t, s
|
|
|
|
with ThreadPoolExecutor(workers) as ex:
|
|
V = dict(ex.map(one, titles))
|
|
_save(m, *_assemble(m, titles, reds, V, False), complete=False)
|
|
with ThreadPoolExecutor(workers) as ex:
|
|
V.update(dict(ex.map(one, rest)))
|
|
_save(m, *_assemble(m, titles, reds, V, True), complete=True)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--map-only", action="store_true")
|
|
ap.add_argument("--workers", type=int, default=4)
|
|
args = ap.parse_args()
|
|
with open(os.path.join("data", "research", "us.panel.pkl"), "rb") as fh:
|
|
tickers = list(pickle.load(fh).close.columns)
|
|
m = build_map(tickers)
|
|
print(m["source"].value_counts(dropna=False).to_string())
|
|
print(f"mapped {m['canonical'].notna().sum()}/{len(m)}; shared articles:",
|
|
m.dropna(subset=["canonical"]).groupby("canonical")["ticker"].agg(list).loc[lambda s: s.str.len() > 1].to_dict())
|
|
m.to_csv(os.path.join("data", "research", "wiki_map.csv"), index=False)
|
|
if args.map_only:
|
|
return
|
|
fetch_views(m, args.workers)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|