- 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>
164 lines
8.1 KiB
Python
164 lines
8.1 KiB
Python
#!/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:30–16: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()
|