Files
foxhunt/scripts/surfer/mft_4h_harden.py
jgrusewski f4bd1e2432 research(surfer): crypto edge-falsification probes — intraday/cascade dead, stablecoin peg-reversion validated
- crypto_mft_xsec / mft_*: intraday/MFT XS price edge FALSIFIED (fee-trap)
- crypto_cascade_reversion: liquidation-cascade reversion FALSIFIED (continuation)
- crypto_trend_sizing: TS-trend return-engine reconfirmed (Sharpe ~1.25)
- crypto_stablecoin_dislocation/harden/intraday: short-rich peg-reversion VALIDATED (bounded, uncorrelated); long-cheap = death-spiral trap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 10:40:34 +02:00

164 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Harden the 4h MFT trend edge — kill the inflation sources before trusting it.
The mft_horizon_sweep found a 4h selective trend edge (OOS net +5.9t, t+7.4). But that t is
OVERSTATED: overlapping 4h holds across 5-min entries are not independent. This re-tests honestly:
1. ONE TRADE PER SESSION — the single highest-conviction morning setup, ~4h hold, flat-by-close.
Non-overlapping ⇒ trades are ~independent across sessions ⇒ honest t-stat. Also the realistic strat.
2. IS-DERIVED conviction cutoff — set on the in-sample sessions, applied to OOS (no pooled-threshold leak).
3. REAL RTH session — ET 09:3016:00 (not UTC-midnight); entry window = first 2.5h so 4h fits before close.
4. PER-YEAR breakdown — is the edge in every year, or one regime?
5. Cost robustness (1 and 2 ticks) + multiple-testing context.
Pre-registered: dir=sign(ret over H bars), conviction=|t-stat|=|ret_H|/(vol·√H), vol=trailing 12-bar.
Entry = highest-conviction valid bar in 09:30-12:00, exit = +H bars (same session), cost 1 (and 2) ticks.
Trade the session only if its best conviction ≥ IS cutoff (selectivity sweep). H=48 (4h) primary; 36/60 robustness.
"""
import glob
import math
import numpy as np
import pandas as pd
TICK = 0.25
BAR_NS = 5 * 60 * 10**9
HOLDS = [36, 48, 60] # 3h, 4h (primary), 5h
ENTRY_CUTOFF_MIN = 12 * 60 # latest entry 12:00 ET so a 4h hold fits before 16:00
RTH_LO, RTH_HI = 9 * 60 + 30, 16 * 60
def load_es_5min():
import databento as db
ts_all, c_all = [], []
for p in sorted(glob.glob("data/surfer/es1m/*.dbn")):
try:
arr = db.DBNStore.from_file(p).to_ndarray()
except Exception:
continue
if len(arr) == 0 or "close" not in arr.dtype.names:
continue
ts = arr["ts_event"].astype(np.int64)
c = arr["close"].astype(np.float64) / 1e9
m = c > 0
ts_all.append(ts[m]); c_all.append(c[m])
ts = np.concatenate(ts_all); c = np.concatenate(c_all)
_u, ui = np.unique(ts, return_index=True); ts, c = ts[ui], c[ui]
o = np.argsort(ts, kind="stable"); ts, c = ts[o], c[o]
b5 = ts // BAR_NS
_, first = np.unique(b5, return_index=True)
last = np.append(first[1:] - 1, len(b5) - 1)
return ts[last], c[last]
def rth_sessions(ts5, close5):
"""Restrict to ET RTH 09:30-16:00, return (close, session_id, minute_of_day_ET, year) RTH-only, sorted."""
idx = pd.to_datetime(ts5, utc=True).tz_convert("America/New_York")
mins = (idx.hour * 60 + idx.minute).to_numpy()
rth = (mins >= RTH_LO) & (mins < RTH_HI)
et_date = idx.strftime("%Y-%m-%d").to_numpy()
year = idx.year.to_numpy()
close = close5[rth]; sess_str = et_date[rth]; mod = mins[rth]; yr = year[rth]
sid = pd.factorize(sess_str)[0] # contiguous session ids (data already time-sorted)
return close, sid, mod, yr
def per_session_trades(close, sid, mod, yr, H, cost):
"""For each RTH session pick the single highest-conviction entry in 09:30-12:00, hold H bars to a
same-session exit. Returns arrays over sessions that HAVE a valid candidate:
(best_conv, net_ticks, year, sess_order)."""
lc = np.log(close)
n = len(lc)
# trailing per-bar vol (12-bar) and H-bar trend, only valid within the same session
r1 = np.zeros(n); r1[1:] = lc[1:] - lc[:-1]
same1 = np.zeros(n, bool); same1[1:] = sid[1:] == sid[:-1]
r1 = np.where(same1, r1, 0.0)
# rolling 12-bar std via cumsum
w = 12
cs = np.concatenate([[0.0], np.cumsum(r1)]); cs2 = np.concatenate([[0.0], np.cumsum(r1 * r1)])
vol = np.full(n, np.nan)
s = cs[w:] - cs[:-w]; s2 = cs2[w:] - cs2[:-w]
vol[w - 1:] = np.sqrt(np.maximum(s2 / w - (s / w) ** 2, 0.0))
# trailing H-bar trend over the RTH-contiguous series (overnight gap collapsed — conservative:
# the overnight move, where much drift lives, is excluded from the signal). No same-session
# requirement on the lookback (a 4h trailing trend can't fit in the morning entry window otherwise).
retH = np.full(n, np.nan); retH[H:] = lc[H:] - lc[:-H]
tstat = retH / np.maximum(vol * math.sqrt(H), 1e-9)
# forward H-bar move (ticks), exit MUST be same session (flat-by-close)
fwd = np.full(n, np.nan); fwd[:-H] = (close[H:] - close[:-H]) / TICK
sameH_fwd = np.zeros(n, bool); sameH_fwd[:-H] = sid[:-H] == sid[H:]
entry_ok = (mod <= ENTRY_CUTOFF_MIN) & sameH_fwd & np.isfinite(tstat) & np.isfinite(fwd) & (vol > 0)
direction = np.sign(retH)
net = direction * fwd - cost
conv = np.abs(tstat)
out_conv, out_net, out_yr, out_ord = [], [], [], []
# group by session, pick max-conviction valid entry
order = np.argsort(sid, kind="stable")
sid_s = sid[order]
bounds = np.append(np.append(0, np.nonzero(np.diff(sid_s))[0] + 1), len(sid_s))
for b in range(len(bounds) - 1):
rows = order[bounds[b]:bounds[b + 1]]
rows = rows[entry_ok[rows] & (direction[rows] != 0)]
if len(rows) == 0:
continue
best = rows[np.argmax(conv[rows])]
out_conv.append(conv[best]); out_net.append(net[best])
out_yr.append(yr[best]); out_ord.append(sid[best])
return (np.array(out_conv), np.array(out_net), np.array(out_yr), np.array(out_ord))
def stats(net):
n = len(net)
if n < 20:
return n, float("nan"), float("nan"), float("nan"), float("nan")
m = float(net.mean()); sd = float(net.std()) + 1e-12
t = m / (sd / math.sqrt(n))
hit = 100.0 * float((net > 0).mean())
ann_sharpe = (m / sd) * math.sqrt(252.0) # ~1 trade/session ⇒ ~252 trades/yr
return n, m, hit, t, ann_sharpe
def run():
ts5, close5 = load_es_5min()
close, sid, mod, yr = rth_sessions(ts5, close5)
n_sess = int(sid.max()) + 1
print("\n================ 4h MFT HARDENING (RTH, 1 trade/session, IS-cutoff, per-year) ================")
print(f"RTH 5-min bars={len(close)} sessions={n_sess} years={int(yr.min())}-{int(yr.max())}")
print("honest non-overlapping t-stat (1 trade/session); multiple-testing bar ≈ |t|>2.3 for ~10 configs\n")
for cost in (1.0, 2.0):
print(f"================= COST = {cost:.0f} tick round-trip =================")
for H in HOLDS:
conv, net, tyr, sord = per_session_trades(close, sid, mod, yr, H, cost)
if len(conv) < 50:
print(f" H={H}: too few sessions with a candidate ({len(conv)})"); continue
split = np.quantile(sord, 0.70) # first 70% of sessions = IS
is_m = sord < split; oos_m = ~is_m
hh = H * 5 / 60.0
print(f" --- H={H} ({hh:.0f}h hold) --- (IS sessions {int(is_m.sum())} / OOS {int(oos_m.sum())})")
print(f" {'select':>8} {'cutoff':>7} {'n_OOS':>6} {'net_t':>7} {'hit%':>6} {'t':>6} {'annSR':>6} {'trd/yr':>7}")
for sel in (1.00, 0.50, 0.25, 0.10): # trade top-sel% of IS-conviction days
cutoff = np.quantile(conv[is_m], 1.0 - sel) if sel < 1.0 else -np.inf
take = oos_m & (conv >= cutoff)
n, m, hit, t, sr = stats(net[take])
oos_years = (sord[oos_m].max() - split) / n_sess * (int(yr.max()) - int(yr.min()) + 1)
trd_yr = n / max(oos_years, 1e-9)
cuts = f"{cutoff:.2f}" if np.isfinite(cutoff) else "all"
print(f" {sel:>8.2f} {cuts:>7} {n:>6} {m:>+7.2f} {hit:>6.1f} {t:>+6.2f} {sr:>+6.2f} {trd_yr:>7.1f}")
# per-year at the pre-registered selectivity = top 25% of IS-conviction days
cutoff = np.quantile(conv[is_m], 0.75)
print(f" per-year (sel=0.25, cost={cost:.0f}, OOS+IS shown; IS years are not OOS):")
for y in range(int(yr.min()), int(yr.max()) + 1):
ym = (tyr == y) & (conv >= cutoff)
if int(ym.sum()) < 10:
continue
n, m, hit, t, sr = stats(net[ym])
flag = "OOS" if (np.median(sord[tyr == y]) >= split) else "is "
print(f" {y} [{flag}] n={n:>4} net={m:>+6.2f}t hit={hit:>5.1f}% t={t:>+5.2f}")
print()
if __name__ == "__main__":
run()