diff --git a/scripts/surfer/fetch_earnings.py b/scripts/surfer/fetch_earnings.py new file mode 100644 index 000000000..a846f487a --- /dev/null +++ b/scripts/surfer/fetch_earnings.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Fetch Nasdaq earnings calendar (date + symbol + surprise%) for all weekdays in the DBEQ range, +for a proper PEAD test. Free, no key. Cached per date; gentle pacing + backoff. +""" +import datetime +import json +import os +import time +import urllib.request + +OUT = "data/surfer/earnings" +START = datetime.date(2023, 3, 28) +END = datetime.date(2026, 5, 31) + + +def get(u): + for a in range(4): + try: + req = urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0", "Accept": "application/json"}) + return json.loads(urllib.request.urlopen(req, timeout=25).read()) + except Exception: + if a == 3: + return None + time.sleep(4 * (a + 1)) + return None + + +def main(): + os.makedirs(OUT, exist_ok=True) + d = START + n = fail = 0 + while d <= END: + if d.weekday() < 5: + f = f"{OUT}/{d}.json" + if not os.path.exists(f): + r = get(f"https://api.nasdaq.com/api/calendar/earnings?date={d}") + if r is None: + fail += 1 + else: + rows = (r.get("data") or {}).get("rows") or [] + json.dump([(x.get("symbol"), x.get("surprise")) for x in rows], open(f, "w")) + n += 1 + time.sleep(1.3) + d += datetime.timedelta(days=1) + print(f"DONE: fetched {n} new days, {fail} failed; cache dir {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/surfer/pead_gate.py b/scripts/surfer/pead_gate.py new file mode 100644 index 000000000..3aeb7d517 --- /dev/null +++ b/scripts/surfer/pead_gate.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""PEAD gate: does post-earnings-announcement drift survive in small/neglected names, net of cost? + +The strongest classic anomaly that persists BECAUSE it's too small/illiquid for funds to arb. +Proxy (no earnings-date data needed): an "earnings-like surprise" = a large abnormal-volume single +-day move (|ret| > K_SIG x trailing-vol AND volume > K_VOL x trailing-avg). PEAD predicts +CONTINUATION: enter at the event-day CLOSE (leak-free, after the full move), hold N days, signed by +the surprise direction. Measure net-of-cost drift, t-stat, long vs short, IS/OOS, by liquidity band. +""" +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from equity_factor_gate import load, roll # noqa: E402 + +K_SIG, K_VOL = 2.0, 2.0 # surprise = |ret|>2sigma AND volume>2x average +LO, HI = 1e6, 30e6 # small-but-tradeable $/day band (where PEAD survives) +HORIZONS = [1, 3, 5, 10, 20] + + +def tstat(x): + x = x[np.isfinite(x)] + return float(x.mean() / (x.std() / math.sqrt(len(x)))) if len(x) > 30 and x.std() > 0 else float("nan") + + +def main(): + insts, days, close, dvol = load() + T, N = close.shape + lc = np.log(close) + R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0) + vol20 = roll(np.std, R, 20) # trailing std (excludes t -> causal) + advol = roll(np.mean, np.nan_to_num(dvol), 20) + year = (1970 + days / 365.25).astype(int) + rt_cost = np.clip(40.0 / np.sqrt(np.maximum(advol, 1.0) / 1e6), 5.0, 80.0) / 1e4 # round-trip, illiquidity-scaled + + valid = (np.isfinite(close) & (vol20 > 0) & np.isfinite(advol) & (advol > LO) & (advol < HI)) + surprise = valid & (np.abs(R) > K_SIG * vol20) & (dvol > K_VOL * advol) + surprise[:21] = False; surprise[T - max(HORIZONS):] = False + sgn = np.sign(R) + print(f"PEAD gate (DBEQ, band ${LO/1e6:.0f}-{HI/1e6:.0f}M/day, surprise=|ret|>{K_SIG}sig & vol>{K_VOL}x)") + print(f" total surprise events: {int(surprise.sum())} (avg cost {1e4*rt_cost[surprise].mean():.0f}bp round-trip)") + + oos = np.repeat((year >= 2025)[:, None], N, 1) + up = surprise & (sgn > 0); dn = surprise & (sgn < 0) + print(f"\n{'horizon':>8} {'n':>7} {'gross%':>7} {'net%':>7} {'t(net)':>7} {'fracpos':>8} | {'LONG net%':>9} {'SHORT net%':>10} | {'OOS net%':>9}") + for h in HORIZONS: + dr = np.full((T, N), np.nan); dr[:T - h] = lc[h:] - lc[:T - h] # dr[t] = lc[t+h]-lc[t] + signed = sgn * dr + net = signed - rt_cost + g = signed[surprise]; nt = net[surprise] + ln = net[up]; sh = net[dn] + oo = net[surprise & oos] + print(f"{h:>8} {int(np.isfinite(nt).sum()):>7} {100*np.nanmean(g):>+7.2f} {100*np.nanmean(nt):>+7.2f} " + f"{tstat(nt):>+7.1f} {np.nanmean(nt>0):>8.2f} | {100*np.nanmean(ln):>+9.2f} {100*np.nanmean(sh):>+10.2f} | {100*np.nanmean(oo):>+9.2f}") + + # by surprise strength at the 10d horizon + h = 10 + dr = np.full((T, N), np.nan); dr[:T - h] = lc[h:] - lc[:T - h] + net = sgn * dr - rt_cost + strong = surprise & (np.abs(R) > 3 * vol20) + print(f"\n 10d net by surprise strength: 2-3sig {100*np.nanmean(net[surprise & ~strong]):+.2f}% " + f">3sig {100*np.nanmean(net[strong]):+.2f}% (PEAD: bigger surprise -> bigger drift)") + print("\nVERDICT: net drift > 0 with t(net) > 3 (deflated) AND positive OOS AND long-side works = real PEAD edge") + print("worth a proper book. If net ~0 or t<2 or OOS collapses = eaten by cost / arbed even in small names.") + + +if __name__ == "__main__": + main() diff --git a/scripts/surfer/pead_real.py b/scripts/surfer/pead_real.py new file mode 100644 index 000000000..e4b19b123 --- /dev/null +++ b/scripts/surfer/pead_real.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Real PEAD test: actual earnings surprises (Nasdaq) x DBEQ prices, leak-free, net of cost. + +For each earnings event (ticker, date, surprise%): direction = sign(surprise). Enter at the CLOSE +of the trading day AFTER the announcement (leak-free — announcement + initial reaction already +public), hold N days, signed by surprise. Measure net-of-cost drift, t-stat, long vs short, IS/OOS, +by surprise magnitude, in the small/neglected liquidity band where PEAD survives. +""" +import datetime +import glob +import json +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from equity_factor_gate import load, roll # noqa: E402 + +LO, HI = 1e6, 50e6 +HORIZONS = [1, 3, 5, 10, 20, 40] +EPOCH = datetime.date(1970, 1, 1) + + +def tstat(x): + x = np.asarray(x); x = x[np.isfinite(x)] + return float(x.mean() / (x.std() / math.sqrt(len(x)))) if len(x) > 30 and x.std() > 0 else float("nan") + + +def main(): + insts, days, close, dvol = load() + T, N = close.shape + lc = np.log(close) + R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1] + advol = roll(np.mean, np.nan_to_num(dvol), 20) + rt_cost = np.clip(40.0 / np.sqrt(np.maximum(advol, 1.0) / 1e6), 5.0, 80.0) / 1e4 + col = {int(i): k for k, i in enumerate(insts)} + sym = json.load(open("data/surfer/dbeq_symbology.json")) + mp = sym.get("result", sym) + tick2col = {} + for t, ranges in mp.items(): + if ranges: + iid = int(ranges[0]["s"]) + if iid in col: + tick2col[t] = col[iid] + + # load earnings events + files = glob.glob("data/surfer/earnings/*.json") + ev_day, ev_col, ev_sgn, ev_mag = [], [], [], [] + matched = 0 + for f in files: + d = datetime.date.fromisoformat(os.path.basename(f)[:-5]) + epoch = (d - EPOCH).days + idx = int(np.searchsorted(days, epoch, "left")) + t_enter = idx + 1 # close of day after announcement (leak-free) + if t_enter >= T - max(HORIZONS): + continue + for tk, surp in json.load(open(f)): + c = tick2col.get(tk) + if c is None or surp in (None, "", "N/A"): + continue + try: + s = float(str(surp).replace("%", "").replace(",", "")) + except ValueError: + continue + if not np.isfinite(close[t_enter, c]) or not (LO < advol[t_enter, c] < HI): + continue + ev_day.append(t_enter); ev_col.append(c); ev_sgn.append(np.sign(s)); ev_mag.append(abs(s)); matched += 1 + ev_day = np.array(ev_day); ev_col = np.array(ev_col); ev_sgn = np.array(ev_sgn); ev_mag = np.array(ev_mag) + yr = (1970 + days[ev_day] / 365.25).astype(int) + print(f"PEAD (real surprises) — {len(files)} earnings days loaded, {matched} events matched to DBEQ " + f"(band ${LO/1e6:.0f}-{HI/1e6:.0f}M, avg cost {1e4*rt_cost[ev_day, ev_col].mean():.0f}bp)") + if matched < 200: + print(" (few events — let the fetch finish, then re-run)"); return + + oosm = yr >= 2025 + cost = rt_cost[ev_day, ev_col] + print(f"\n{'horizon':>8} {'gross%':>7} {'net%':>7} {'t(net)':>7} {'fracpos':>8} | {'LONG net%':>9} {'SHORT net%':>10} | {'OOS net%':>9}") + for h in HORIZONS: + fut = lc[ev_day + h, ev_col] - lc[ev_day, ev_col] + signed = ev_sgn * fut + net = signed - cost + up = ev_sgn > 0; dn = ev_sgn < 0 + print(f"{h:>8} {100*np.nanmean(signed):>+7.2f} {100*np.nanmean(net):>+7.2f} {tstat(net):>+7.1f} " + f"{np.nanmean(net > 0):>8.2f} | {100*np.nanmean(net[up]):>+9.2f} {100*np.nanmean(net[dn]):>+10.2f} | {100*np.nanmean(net[oosm]):>+9.2f}") + + # by surprise magnitude at 20d (PEAD: bigger surprise -> bigger drift) + h = 20 + fut = lc[ev_day + h, ev_col] - lc[ev_day, ev_col]; net = ev_sgn * fut - cost + q = np.nanpercentile(ev_mag, [33, 66]) + for lab, m in [("small surprise", ev_mag <= q[0]), ("mid", (ev_mag > q[0]) & (ev_mag <= q[1])), ("large surprise", ev_mag > q[1])]: + print(f" 20d net, {lab:>14}: {100*np.nanmean(net[m]):+.2f}% (n={int(m.sum())})") + print("\nVERDICT: net>0, t(net)>3, positive OOS, long-side works, AND bigger-surprise->bigger-drift = real PEAD.") + + +if __name__ == "__main__": + main()