You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""Research probe for Wikimedia pageviews as a low-attention axis.
|
|
|
|
This is a standalone analysis tool. It does not modify the backtest engine.
|
|
|
|
The probe answers one narrow question:
|
|
Does filtering for low Wikimedia attention improve the quality of idle-alpha
|
|
events, relative to the same candidate set without the filter?
|
|
|
|
It reports:
|
|
- pageview spike distribution
|
|
- forward continuation statistics for low-attention vs high-attention buckets
|
|
- a simple threshold sweep over low-attention cutoffs
|
|
|
|
The script is intentionally lightweight and uses only Wikimedia pageviews plus
|
|
existing snapshot outcome columns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import datetime as dt
|
|
import json
|
|
import re
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pandas as pd
|
|
import requests
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from libs.common.config import get_settings
|
|
|
|
USER_AGENT = "codex-fithia2-wikimedia-low-attention-probe/1.0"
|
|
WIKI_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
|
|
WIKI_PAGEVIEWS_URL = (
|
|
"https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/"
|
|
"en.wikipedia.org/all-access/all-agents/{article}/daily/{start}/{end}"
|
|
)
|
|
|
|
STOPWORDS = {
|
|
"inc",
|
|
"incorporated",
|
|
"corp",
|
|
"corporation",
|
|
"ltd",
|
|
"holdings",
|
|
"group",
|
|
"co",
|
|
"company",
|
|
"plc",
|
|
"nv",
|
|
}
|
|
|
|
MANUAL_WIKI_TITLE = {
|
|
"AMERICAN AIRLINES GROUP INC.": "American Airlines Group",
|
|
"ASTRONICS CORPORATION": "Astronics",
|
|
"CENTURY ALUMINUM COMPANY": "Century Aluminum",
|
|
"CLEANSPARK, INC.": "CleanSpark",
|
|
"CCC INTELLIGENT SOLUTIONS HOLDINGS INC.": "CCC Intelligent Solutions",
|
|
"FLUENCE ENERGY, INC.": "Fluence Energy",
|
|
"IMMUNITYBIO, INC.": "ImmunityBio",
|
|
"LYFT, INC.": "Lyft",
|
|
"MIRION TECHNOLOGIES, INC.": "Mirion Technologies",
|
|
"MOSAIC CO": "The Mosaic Company",
|
|
"NORWEGIAN CRUISE LINE HOLDINGS LTD.": "Norwegian Cruise Line Holdings",
|
|
"PAR PACIFIC HOLDINGS, INC.": "Par Pacific Holdings",
|
|
"PATTERSON-UTI ENERGY, INC.": "Patterson-UTI Energy",
|
|
"RITHM CAPITAL CORP.": "Rithm Capital",
|
|
"SOUNDHOUND AI, INC.": "SoundHound AI",
|
|
"TANGO THERAPEUTICS, INC.": "Tango Therapeutics",
|
|
"TERNS PHARMACEUTICALS, INC.": "Terns Pharmaceuticals",
|
|
"UNITY SOFTWARE INC.": "Unity Technologies",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EventRow:
|
|
event_id: str
|
|
ticker: str
|
|
issuer_name: str
|
|
event_date: dt.date
|
|
reaction_day_return: float
|
|
fwd_return_3d: float
|
|
fwd_return_5d: float
|
|
|
|
|
|
def _session() -> requests.Session:
|
|
session = requests.Session()
|
|
session.headers.update({"User-Agent": USER_AGENT})
|
|
return session
|
|
|
|
|
|
def _clean_tokens(text_value: str) -> list[str]:
|
|
tokens = re.findall(r"[A-Za-z0-9]+", text_value.lower().replace("\xa0", " "))
|
|
return [token for token in tokens if token not in STOPWORDS]
|
|
|
|
|
|
def _candidate_names(name: str) -> list[str]:
|
|
clean = name.replace("\xa0", " ").strip()
|
|
manual = MANUAL_WIKI_TITLE.get(clean.upper())
|
|
candidates = [candidate for candidate in [manual, clean] if candidate]
|
|
token_name = " ".join(_clean_tokens(clean))
|
|
if token_name:
|
|
candidates.append(token_name)
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for candidate in candidates:
|
|
stripped = candidate.strip(" ,.")
|
|
if stripped and stripped not in seen:
|
|
result.append(stripped)
|
|
seen.add(stripped)
|
|
return result
|
|
|
|
|
|
def _title_match_score(name: str, title: str) -> float:
|
|
name_tokens = set(_clean_tokens(name))
|
|
title_tokens = set(_clean_tokens(title))
|
|
if not name_tokens or not title_tokens:
|
|
return 0.0
|
|
overlap = len(name_tokens & title_tokens)
|
|
score = overlap / max(1, len(name_tokens))
|
|
first_word = name.split()[0].lower() if name.split() else ""
|
|
if first_word and title.lower().startswith(first_word):
|
|
score += 0.1
|
|
return score
|
|
|
|
|
|
def resolve_wikipedia_title(session: requests.Session, issuer_name: str) -> tuple[str | None, float]:
|
|
best_score = 0.0
|
|
best_title: str | None = None
|
|
for candidate in _candidate_names(issuer_name):
|
|
response = None
|
|
for attempt in range(4):
|
|
response = session.get(
|
|
WIKI_SEARCH_URL,
|
|
params={
|
|
"action": "query",
|
|
"list": "search",
|
|
"srsearch": candidate,
|
|
"format": "json",
|
|
"srlimit": 5,
|
|
},
|
|
timeout=20,
|
|
)
|
|
if response.status_code != 429:
|
|
break
|
|
time.sleep(1.5 * (attempt + 1))
|
|
if response is None or response.status_code == 429:
|
|
continue
|
|
response.raise_for_status()
|
|
hits = response.json().get("query", {}).get("search", [])
|
|
for hit in hits:
|
|
title = hit["title"]
|
|
score = _title_match_score(candidate, title)
|
|
if score > best_score:
|
|
best_score = score
|
|
best_title = title
|
|
if best_score >= 0.55:
|
|
return best_title, best_score
|
|
return None, best_score
|
|
|
|
|
|
def fetch_pageview_spike(
|
|
session: requests.Session,
|
|
article_title: str,
|
|
event_date: dt.date,
|
|
) -> dict[str, float] | None:
|
|
start = (event_date - dt.timedelta(days=20)).strftime("%Y%m%d")
|
|
end = (event_date + dt.timedelta(days=2)).strftime("%Y%m%d")
|
|
response = None
|
|
for attempt in range(4):
|
|
response = session.get(
|
|
WIKI_PAGEVIEWS_URL.format(
|
|
article=article_title.replace(" ", "_"),
|
|
start=start,
|
|
end=end,
|
|
),
|
|
timeout=20,
|
|
)
|
|
if response.status_code != 429:
|
|
break
|
|
time.sleep(1.5 * (attempt + 1))
|
|
if response is None or response.status_code == 429:
|
|
return None
|
|
if response.status_code != 200:
|
|
return None
|
|
items = response.json().get("items", [])
|
|
if len(items) < 8:
|
|
return None
|
|
views = pd.DataFrame(
|
|
[(pd.to_datetime(item["timestamp"][:8]), item["views"]) for item in items],
|
|
columns=["date", "views"],
|
|
).sort_values("date")
|
|
event_ts = pd.Timestamp(event_date)
|
|
pre_event = views.loc[views["date"] < event_ts, "views"]
|
|
event_views = views.loc[views["date"] == event_ts, "views"]
|
|
if pre_event.empty or event_views.empty:
|
|
return None
|
|
baseline = float(pre_event.tail(10).median())
|
|
if baseline <= 0:
|
|
return None
|
|
event_value = float(event_views.iloc[0])
|
|
return {
|
|
"event_views": event_value,
|
|
"baseline_views": baseline,
|
|
"pageview_spike": event_value / baseline,
|
|
}
|
|
|
|
|
|
async def load_event_rows(snapshot_path: Path, limit: int) -> list[EventRow]:
|
|
snapshot = pd.read_parquet(snapshot_path)
|
|
snapshot = snapshot[
|
|
["event_id", "event_date", "reaction_day_return", "fwd_return_3d", "fwd_return_5d"]
|
|
].copy()
|
|
snapshot["event_date"] = pd.to_datetime(snapshot["event_date"]).dt.date
|
|
|
|
engine = create_async_engine(get_settings().postgres_dsn)
|
|
try:
|
|
async with engine.connect() as conn:
|
|
result = await conn.execute(
|
|
text(
|
|
"""
|
|
select e.event_id, e.event_type, sm.ticker, i.issuer_name
|
|
from events e
|
|
left join issuer_master i on i.issuer_id = e.issuer_id
|
|
left join symbol_master sm on sm.symbol_id = e.symbol_id
|
|
where e.event_id = any(:ids)
|
|
"""
|
|
),
|
|
{"ids": snapshot["event_id"].tolist()},
|
|
)
|
|
meta = pd.DataFrame(result.fetchall(), columns=result.keys())
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
merged = snapshot.merge(meta, on="event_id", how="left")
|
|
merged = merged[merged["event_type"] == "earnings_release"].copy()
|
|
generic = (
|
|
(merged["issuer_name"].fillna("") == merged["ticker"].fillna("") + " Corporation")
|
|
| (merged["issuer_name"].fillna("") == merged["ticker"].fillna("") + " Inc.")
|
|
| (merged["issuer_name"].fillna("") == merged["ticker"].fillna("") + " Ltd.")
|
|
)
|
|
filtered = merged.loc[~generic & merged["issuer_name"].notna()].sort_values("event_date")
|
|
if limit > 0:
|
|
filtered = filtered.head(limit)
|
|
rows: list[EventRow] = []
|
|
for _, row in filtered.iterrows():
|
|
rows.append(
|
|
EventRow(
|
|
event_id=str(row["event_id"]),
|
|
ticker=str(row["ticker"]),
|
|
issuer_name=str(row["issuer_name"]),
|
|
event_date=row["event_date"],
|
|
reaction_day_return=float(row["reaction_day_return"]),
|
|
fwd_return_3d=float(row["fwd_return_3d"]),
|
|
fwd_return_5d=float(row["fwd_return_5d"]),
|
|
)
|
|
)
|
|
return rows
|
|
|
|
|
|
def _summarize(df: pd.DataFrame) -> dict[str, Any]:
|
|
if df.empty:
|
|
return {"count": 0}
|
|
low = df[df["pageview_spike"] <= df["pageview_spike"].median()]
|
|
high = df[df["pageview_spike"] > df["pageview_spike"].median()]
|
|
return {
|
|
"count": int(len(df)),
|
|
"median_spike": round(float(df["pageview_spike"].median()), 4),
|
|
"mean_3d_low": round(float(low["signed_cont_3d"].mean()), 4) if not low.empty else None,
|
|
"mean_3d_high": round(float(high["signed_cont_3d"].mean()), 4) if not high.empty else None,
|
|
"mean_5d_low": round(float(low["signed_cont_5d"].mean()), 4) if not low.empty else None,
|
|
"mean_5d_high": round(float(high["signed_cont_5d"].mean()), 4) if not high.empty else None,
|
|
"corr_3d": round(float(df["pageview_spike"].corr(df["signed_cont_3d"])), 4),
|
|
"corr_5d": round(float(df["pageview_spike"].corr(df["signed_cont_5d"])), 4),
|
|
}
|
|
|
|
|
|
def _threshold_sweep(df: pd.DataFrame, thresholds: list[float]) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for thr in thresholds:
|
|
subset = df[df["pageview_spike"] <= thr]
|
|
if subset.empty:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"threshold": thr,
|
|
"count": int(len(subset)),
|
|
"mean_3d": round(float(subset["signed_cont_3d"].mean()), 4),
|
|
"mean_5d": round(float(subset["signed_cont_5d"].mean()), 4),
|
|
"median_3d": round(float(subset["signed_cont_3d"].median()), 4),
|
|
"median_5d": round(float(subset["signed_cont_5d"].median()), 4),
|
|
"mean_spike": round(float(subset["pageview_spike"].mean()), 4),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
async def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Probe Wikimedia low-attention axis for idle-alpha candidates.")
|
|
parser.add_argument("--snapshot", default="data/datasets/snapshots/midcap-filtered/test.parquet")
|
|
parser.add_argument("--limit", type=int, default=120, help="Number of events to sample before joining pageviews.")
|
|
parser.add_argument("--thresholds", default="1.0,1.2,1.5,2.0,3.0", help="Comma-separated low-attention thresholds.")
|
|
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
|
|
args = parser.parse_args()
|
|
|
|
rows = await load_event_rows(Path(args.snapshot), args.limit)
|
|
session = _session()
|
|
title_cache: dict[str, tuple[str | None, float]] = {}
|
|
pageview_cache: dict[tuple[str, dt.date], dict[str, float] | None] = {}
|
|
resolved: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
if row.issuer_name not in title_cache:
|
|
title_cache[row.issuer_name] = resolve_wikipedia_title(session, row.issuer_name)
|
|
title, match_score = title_cache[row.issuer_name]
|
|
if not title:
|
|
continue
|
|
cache_key = (title, row.event_date)
|
|
if cache_key not in pageview_cache:
|
|
pageview_cache[cache_key] = fetch_pageview_spike(session, title, row.event_date)
|
|
pageviews = pageview_cache[cache_key]
|
|
if not pageviews:
|
|
continue
|
|
signed_cont_3d = (1.0 if row.reaction_day_return >= 0 else -1.0) * row.fwd_return_3d
|
|
signed_cont_5d = (1.0 if row.reaction_day_return >= 0 else -1.0) * row.fwd_return_5d
|
|
resolved.append(
|
|
{
|
|
"ticker": row.ticker,
|
|
"issuer_name": row.issuer_name,
|
|
"article_title": title,
|
|
"match_score": round(match_score, 3),
|
|
"event_date": row.event_date.isoformat(),
|
|
"reaction_day_return": row.reaction_day_return,
|
|
"fwd_return_3d": row.fwd_return_3d,
|
|
"fwd_return_5d": row.fwd_return_5d,
|
|
"signed_cont_3d": signed_cont_3d,
|
|
"signed_cont_5d": signed_cont_5d,
|
|
**pageviews,
|
|
}
|
|
)
|
|
|
|
df = pd.DataFrame(resolved)
|
|
if df.empty:
|
|
payload = {"resolved_rows": 0}
|
|
if args.json:
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
else:
|
|
print("resolved_rows=0")
|
|
return
|
|
|
|
summary = _summarize(df)
|
|
thresholds = [float(item) for item in args.thresholds.split(",") if item.strip()]
|
|
sweep = _threshold_sweep(df, thresholds)
|
|
result = {
|
|
"resolved_rows": int(len(df)),
|
|
"summary": summary,
|
|
"threshold_sweep": sweep,
|
|
"sample_rows": resolved[:10],
|
|
}
|
|
|
|
if args.json:
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return
|
|
|
|
print(f"resolved_rows={result['resolved_rows']}")
|
|
print(f"median_pageview_spike={summary['median_spike']:.3f}")
|
|
print(f"corr(pageview_spike,signed_cont_3d)={summary['corr_3d']:.4f}")
|
|
print(f"corr(pageview_spike,signed_cont_5d)={summary['corr_5d']:.4f}")
|
|
print("threshold_sweep:")
|
|
for row in sweep:
|
|
print(
|
|
f" <= {row['threshold']:.2f}: n={row['count']} mean3d={row['mean_3d']:.4f} "
|
|
f"mean5d={row['mean_5d']:.4f} median3d={row['median_3d']:.4f} "
|
|
f"median5d={row['median_5d']:.4f}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|