3 omnisearch researchers + clean PEAD test answer 'why does AI4Finance find what we cant': they dont. FinRL flagship (Sharpe 1.30) = single-split, slippage-free, no-deflation, bull-market, survivorship, hand-coded crash rule; their own people (Gort/Liu AAAI'23) published the overfitting rebuttal; zero live track record. ML-trading decays 73%+ backtest->live, faster for complexity. PEAD tested properly (real Nasdaq surprises x DBEQ, leak-free, 23bp cost, OOS): full-sample looked good (20d +0.29% t=2.9) but pure in-sample bull-beta -> OOS NEGATIVE every horizon; surprise-size signature fails. Strongest classic anomaly dies OOS. Untested real pulse left: prediction markets (uncorrelated). Tooling: fetch_earnings.py, pead_real.py, dbeq_symbology resolved (17605 tickers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
4.2 KiB
Python
99 lines
4.2 KiB
Python
#!/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()
|