Files
quant-trading/research/lab/earnings.py
Gahow Wang ad569f1cea feat: us_momentum_qqq_trend; alpha-vs-QQQ research across new data sources
Register us_momentum_qqq_trend: half overnight_recovery_top10, half a QQQ
trend sleeve (strategies/qqq_trend.py: 200-day switch times a 30% vol target,
held as TQQQ + SHY). Mixed stock+ETF, US only (extra tickers QQQ, SHY, TQQQ).

Research (research/lab/): alpha against QQQ with a 2006-16 design / 2017-26
holdout split (alpha.py, qqq_scan.py, beat_qqq.py); leverage and drawdown
frontier (levered_book.py, us_target50.py); synthetic 3x ETFs from 1990 and
TrendRider/QQQ-trend stress tests (fetch_long_history.py, letf_history.py,
vol_trend.py); short-horizon sleeves, diversifiers, industry momentum,
earnings-announcement returns; new point-in-time data sources: SEC Form 4
insider trades (fetch_insider.py, insider.py), 10-K/10-Q text similarity,
8-K earnings dates and SIC codes (fetch_lazy_prices.py, lazy_prices.py), and
an S&P 400/600 universe from iShares holdings (fetch_us_mid.py, panel_mid.py).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:40:15 +08:00

125 lines
5.6 KiB
Python

"""
Earnings-announcement return (EAR) factor: the market's reaction to the
latest earnings release, which keeps drifting afterwards (Chan, Jegadeesh &
Lakonishok 1996; Brandt, Kishore, Santa-Clara & Venkatachalam 2008 find the
drift after the announcement *return* stronger and longer-lived than after
the earnings surprise).
PYTHONPATH=. uv run --with numba python -m research.lab.earnings
Announcement dates: 8-K filings with Item 2.02 (Results of Operations),
data/research/sec_8k_earnings.csv (written by research/lab/fetch_lazy_prices.py
from the SEC submissions JSON). A release after the close on day d is
reacted to on d+1, so the event window is the three sessions d-1..d+1:
ear = sum of (stock return - equal-weight member return) over d-1, d, d+1
It is known at the close of d+1, the score is dated then (the harness trades
at the next open) and held until the next release, at most 100 trading days.
Also: ``ear_z`` = ear divided by the stock's 60-day residual vol (a surprise in
units of normal noise), and ``ear_count`` = the sum of the last four signs.
"""
from __future__ import annotations
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab.factors import _mkt, _ret
warnings.filterwarnings("ignore")
SRC = os.path.join("data", "research", "sec_8k_earnings.csv")
CACHE = os.path.join("data", "research", "earnings_factors.pkl")
MAX_AGE = 100
def announcement_days(p) -> pd.DataFrame:
"""Bool panel: an Item 2.02 8-K was filed that day (mapped to the next
trading day when filed on a holiday or weekend)."""
ev = pd.read_csv(SRC, parse_dates=["filingDate"])
ev = ev[ev["ticker"].isin(p.close.columns)]
pos = p.dates.searchsorted(ev["filingDate"].values)
ev = ev.assign(pos=pos)[pos < len(p.dates)]
A = np.zeros(p.close.shape, bool)
col = {c: i for i, c in enumerate(p.close.columns)}
A[ev["pos"].to_numpy(), ev["ticker"].map(col).to_numpy()] = True
return pd.DataFrame(A, index=p.dates, columns=p.close.columns)
def _hold(values: pd.DataFrame, known: pd.DataFrame, max_age: int) -> pd.DataFrame:
"""Carry each event's value forward from the day it is known, for at most
``max_age`` trading days."""
idx = np.broadcast_to(np.arange(len(values))[:, None], values.shape)
last_pos = pd.DataFrame(np.where(known.values, idx, np.nan), index=values.index,
columns=values.columns).ffill()
return values.where(known).ffill().mask(idx - last_pos.values > max_age)
def build(p) -> dict[str, pd.DataFrame]:
A = announcement_days(p)
ab = _ret(p).sub(_mkt(p), axis=0) # abnormal daily return
car = ab.shift(1) + ab + ab.shift(-1) # d-1..d+1, indexed at d
known = A.shift(1, fill_value=False) # known at the close of d+1
vol = ab.rolling(60, min_periods=40).std().shift(2) # pre-event noise
ear = _hold(car.shift(1), known, MAX_AGE)
ear_z = _hold((car / vol).shift(1), known, MAX_AGE)
sign = np.sign(car.shift(1)).where(known)
count = pd.DataFrame(np.nan, index=p.dates, columns=p.close.columns)
for c in count.columns:
x = sign[c].dropna()
count.loc[x.index, c] = x.rolling(4, min_periods=2).sum()
ear_count = _hold(count, known & count.notna(), MAX_AGE)
return {"ear": ear, "ear_z": ear_z, "ear_count": ear_count, "announce": A}
def load(p, rebuild: bool = False) -> dict[str, pd.DataFrame]:
if os.path.exists(CACHE) and not rebuild:
with open(CACHE, "rb") as fh:
return pickle.load(fh)
out = build(p)
with open(CACHE, "wb") as fh:
pickle.dump(out, fh, protocol=4)
return out
def main() -> None:
from research.lab.alpha import alpha_stats, bench_returns, fmt_alpha
from research.lab.factor_scan import load as load_panel
from research.lab.report import forward_returns
from research.lab.rotation import factor_ranks
from research.lab.us_sweep import _blend, run
p, _ = load_panel("us")
E = load(p, rebuild=True)
A = E["announce"] & p.member
print("announcements among members per year:", A.sum(axis=1).groupby(p.dates.year).sum().to_dict())
print("members with a live ear value (median per day by year):",
E["ear"].where(p.member).notna().sum(axis=1).groupby(p.dates.year).median().to_dict())
fwd = forward_returns(p, 21).where(p.member)
bench = bench_returns(p.dates)
pr = factor_ranks(p, "us")
mom = _blend(pr["overnight_mom"], pr["rec_126"])
for name in ("ear", "ear_z", "ear_count"):
rk = E[name].where(p.member).rank(axis=1, pct=True)
ic = rk.corrwith(fwd.rank(axis=1, pct=True), axis=1).iloc[::21].dropna()
for a, b in (("2006", "2016"), ("2017", "2026")):
x = ic.loc[a:b]
print(f"{name} IC21 {a}-{b}: {x.mean():+.4f} t {x.mean() / x.std() * np.sqrt(len(x)):+.2f}")
for label, sc, kw in ((f"{name} top20 e21", rk, dict(top_n=20, every=21)),
(f"{name} top20 e10", rk, dict(top_n=20, every=10)),
(f"momentum + {name} top10 e10", _blend(mom, rk), dict(top_n=10, every=10))):
st = run(p, sc.where(p.member), [("2006-09-27", "2026-09-24")], buffer=2.0, **kw)
r = st["_rets"]
print(fmt_alpha(label, alpha_stats(r, bench)), "| holdout",
fmt_alpha("", alpha_stats(r.loc["2017":], bench)).split("alpha", 1)[1][:40], flush=True)
st = run(p, mom.where(p.member), [("2006-09-27", "2026-09-24")], top_n=10, buffer=2.0, every=10)
print(fmt_alpha("momentum alone top10 e10", alpha_stats(st["_rets"], bench)))
if __name__ == "__main__":
main()