diff --git a/scripts/surfer/xvenue_sim_oos.py b/scripts/surfer/xvenue_sim_oos.py new file mode 100644 index 000000000..997b2eb1b --- /dev/null +++ b/scripts/surfer/xvenue_sim_oos.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""CLEAN OOS test of the cross-venue funding arb (hysteresis). + +The hysteresis params (entry/exit/K) were chosen by looking at the data = in-sample. Honest test: +pick the best config on IN-SAMPLE (first 60% of days) by IS Sharpe, then apply that EXACT config +BLIND to OUT-OF-SAMPLE (last 40%). If OOS holds -> real; if it collapses -> in-sample fit. +Reports IS-best config + its OOS result, robustness across configs, and OOS per-month. +Taker cost 10bp (conservative). Sharpe is inflated by low vol + idealized fills (realistic ~ /2.5). +""" +import json +import math + +import numpy as np + +PANEL = "data/surfer/xvenue2/panel2.json" +COST = 0.0010 + + +def main(): + panel = json.load(open(PANEL)) + dates = sorted(set().union(*[set(v) for v in panel.values()])) + di = {d: i for i, d in enumerate(dates)} + coins = list(panel) + T, N = len(dates), len(coins) + spread = np.full((T, N), np.nan) + for j, c in enumerate(coins): + for d, v in panel[c].items(): + vals = list(v.values()) + if len(vals) >= 2: + spread[di[d], j] = max(vals) - min(vals) + split = int(0.60 * T) + print(f"cross-venue OOS: {N} coins, {T} days | IS {dates[0]}..{dates[split-1]} | OOS {dates[split]}..{dates[-1]}") + + def hyst(t0, t1, K, entry, exit_, cost): + held, rets, prev = {}, [], set() + for t in range(t0, t1 - 1): + s_t, s_n = spread[t], spread[t + 1] + held = {j: 1 for j in held if np.isfinite(s_t[j]) and s_t[j] > exit_} + if len(held) < K: + cand = [j for j in np.where(np.isfinite(s_t) & (s_t > entry))[0] if j not in held] + cand.sort(key=lambda j: -s_t[j]) + for j in cand[:K - len(held)]: + held[j] = 1 + cur = set(held) + turn = len(cur ^ prev) / max(len(cur), 1) if cur else 0 + realized = (sum(s_n[j] for j in held if np.isfinite(s_n[j])) / len(held)) if held else 0.0 + rets.append(realized - turn * (cost / 2)); prev = cur + r = np.array(rets) + ann = r.mean() * 365 if len(r) else float("nan") + vol = r.std() * math.sqrt(365) if len(r) else float("nan") + return ann, (ann / vol if vol > 0 else float("nan")), r + + grid = [(K, e, x) for K in (10, 20) for e in (0.0005, 0.0010, 0.0020) for x in (0.0003, 0.0005, 0.0010) if x <= e] + scored = [] + for cfg in grid: + _, is_sh, _ = hyst(0, split, *cfg, COST) + _, oos_sh, _ = hyst(split, T, *cfg, COST) + oa = hyst(split, T, *cfg, COST)[0] + scored.append((cfg, is_sh, oos_sh, oa)) + + best = max(scored, key=lambda s: s[1]) # pick by IS Sharpe ONLY + cfg, is_sh, oos_sh, oos_ann = best + print(f"\n IS-BEST config (chosen blind to OOS): K={cfg[0]} entry={cfg[1]*1e4:.0f}bp exit={cfg[2]*1e4:.0f}bp") + print(f" IS Sharpe {is_sh:+.1f} -> OOS Sharpe {oos_sh:+.1f} | OOS ann {100*oos_ann:+.1f}% (realistic ~/2.5 = {oos_sh/2.5:+.1f})") + good = [s for s in scored if s[1] > 2] + robust = [s for s in good if s[2] > 1] + print(f" robustness: of {len(good)} configs with IS Sharpe>2, {len(robust)} also OOS Sharpe>1") + + # OOS per-month on the IS-best config + _, _, r = hyst(split, T, *cfg, COST) + mo = [dates[t][:7] for t in range(split, T - 1)] + print(" OOS per-month Sharpe (IS-best config):") + for m in sorted(set(mo)): + seg = r[np.array(mo) == m] + if len(seg) > 6 and seg.std() > 0: + print(f" {m}: {seg.mean()/seg.std()*math.sqrt(365):+.1f} (n={len(seg)})") + print("\n VERDICT: IS-best OOS Sharpe>1 (realistic, after /2.5) + most months positive = REAL out-of-sample.") + print(" If OOS collapses to ~0/negative = in-sample fit, joins the dead list.") + + +if __name__ == "__main__": + main()