(a) All 6 momentum constructions collapse to ~0.2 under aggressive +/-20% winsor (momentum leans on large moves); volscaled/winsorinput degrade least, none robust to the cap. (b) LOWVOL is genuinely uncorrelated (corr -0.13) and lifts the stressed book +0.19->+0.34; TStrend correlated; carry/reversal/size don't help. Book=momentum+lowvol. Honest deployable bracket ~0.2 (harsh winsor) to ~0.6 (fair); +/-20% blanket winsor double-counts (death-spiral already excluded) so truth ~0.5. Deciding next: per-coin ADV slippage model, not blanket winsor. Corrected the script's over-claiming verdict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
7.0 KiB
Python
164 lines
7.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Proper research: (a) robust momentum construction + (b) diversifying second edge.
|
|
|
|
All point-in-time (universe = daily top-50 by trailing dollar-volume, dead coins included) and
|
|
under REALISM stress (Reff winsorized ±20%/day, death-spiral last-5d excluded, 20bp cost) — we
|
|
optimize for robust/realistic performance, not headline.
|
|
|
|
(a) 6 pre-registered momentum constructions judged by STRESSED Sharpe + degradation ratio + every-year.
|
|
(b) canonical diversifiers (carry, TS-trend, long/short reversal, low-vol, size) judged by correlation
|
|
to the momentum sleeve + COMBINED inverse-vol book Sharpe (a weak-but-uncorrelated sleeve can help).
|
|
"""
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import pit_sweep as ps # noqa: E402
|
|
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
|
import torch # noqa: E402
|
|
|
|
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
|
WINSOR, COST, TOPK = 0.20, 20.0, 50
|
|
|
|
|
|
def tr(lc, L, skip=0):
|
|
out = np.full_like(lc, np.nan)
|
|
if skip == 0:
|
|
out[L:] = lc[L:] - lc[:-L]
|
|
else: # return from t-L-skip to t-skip (classic 12-1 skip)
|
|
out[L + skip:] = lc[L:-skip] - lc[:-(L + skip)]
|
|
return out
|
|
|
|
|
|
def roll(fn, X, L):
|
|
out = np.full_like(X, np.nan)
|
|
for t in range(L, len(X)):
|
|
out[t] = fn(X[t - L:t], axis=0)
|
|
return out
|
|
|
|
|
|
def row_rank(x): # per-row percentile rank in [-0.5,0.5], nan-aware
|
|
out = np.full_like(x, np.nan)
|
|
for t in range(x.shape[0]):
|
|
m = np.isfinite(x[t])
|
|
if m.sum() > 1:
|
|
r = x[t, m].argsort().argsort().astype(float)
|
|
out[t, m] = r / (m.sum() - 1) - 0.5
|
|
return out
|
|
|
|
|
|
def main():
|
|
syms, days, close, qv, fund = ps.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)
|
|
fund = np.where(np.isfinite(fund), fund, 0.0)
|
|
qv = np.nan_to_num(qv)
|
|
dv30 = roll(np.mean, qv, 30)
|
|
year = (1970 + days / 365.25).astype(int)
|
|
yrs = list(range(2020, 2027))
|
|
|
|
# PIT universe + tradeable (death-spiral) mask
|
|
univ = np.zeros((T, N), bool)
|
|
for t in range(T):
|
|
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
|
if len(elig):
|
|
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
|
tradeable = np.ones((T, N), bool)
|
|
for j in range(N):
|
|
idx = np.where(np.isfinite(close[:, j]))[0]
|
|
if len(idx):
|
|
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
|
|
|
Reff_base = R - fund
|
|
Reff_str = np.where(tradeable, np.clip(Reff_base, -WINSOR, WINSOR), 0.0)
|
|
vol20 = roll(np.std, R, 20)
|
|
|
|
def pnl(sig, reff, cost):
|
|
s = sig.copy(); s[~univ] = np.nan
|
|
return pnl_w(xs_weights(s), reff, cost_bp=cost)
|
|
|
|
def peryear(p):
|
|
return [sharpe_t(torch.tensor(p[year[1:] == y], device=DEV, dtype=torch.float64))
|
|
if (year[1:] == y).sum() > 30 else float("nan") for y in yrs]
|
|
|
|
# ---------- (a) momentum constructions ----------
|
|
raw20 = tr(lc, 20)
|
|
cons = {
|
|
"raw_20": raw20,
|
|
"rank_20": row_rank(raw20),
|
|
"volscaled_20": raw20 / np.where(vol20 > 0, vol20, np.nan),
|
|
"pathconsist_20": roll(np.mean, np.sign(R), 20),
|
|
"skiprecent_20s3": tr(lc, 20, skip=3),
|
|
"winsorinput_20": roll(np.sum, np.clip(R, -0.05, 0.05), 20),
|
|
}
|
|
print(f"\n===== (a) ROBUST MOMENTUM CONSTRUCTION (PIT top-{TOPK}, stress=winsor±{int(WINSOR*100)}%+death+{int(COST)}bp) =====")
|
|
print(f"{'construction':>16} {'base_full':>9} {'str_full':>8} {'str_OOS':>7} {'str_CPCV':>8} {'ratio':>5} | per-year(stress) 2020..26")
|
|
abest, abest_pnl, abest_score = None, None, -9
|
|
for nm, sg in cons.items():
|
|
pb = pnl(sg, Reff_base, 10.0); ps_ = pnl(sg, Reff_str, COST)
|
|
sb = sharpe_t(torch.tensor(pb, device=DEV, dtype=torch.float64))
|
|
v = validate(ps_, days, 6)
|
|
ratio = v["full"] / sb if sb and not math.isnan(sb) and sb != 0 else float("nan")
|
|
py = peryear(ps_)
|
|
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
|
nneg = sum(1 for p in py if not math.isnan(p) and p < 0)
|
|
score = v["med"] - 0.3 * nneg # robust: high stressed CPCV-med, few negative years
|
|
print(f"{nm:>16} {sb:>+9.2f} {v['full']:>+8.2f} {v['oos']:>+7.2f} {v['med']:>+8.2f} {ratio:>5.2f} | {pys}")
|
|
if score > abest_score:
|
|
abest, abest_pnl, abest_score = nm, ps_, score
|
|
print(f" -> robust momentum sleeve = {abest}")
|
|
|
|
# ---------- (b) diversifying second edge ----------
|
|
sleeve = abest_pnl
|
|
cand = {
|
|
"carry_7": -roll(np.mean, fund, 7),
|
|
"TStrend_30": np.sign(tr(lc, 30)) * np.where(univ, 1.0, np.nan),
|
|
"LTreversal_180": -tr(lc, 180),
|
|
"STreversal_2": -tr(lc, 2),
|
|
"lowvol": -vol20,
|
|
"size_small": -np.log(np.where(dv30 > 0, dv30, np.nan)),
|
|
}
|
|
print(f"\n===== (b) DIVERSIFYING EDGE vs momentum sleeve ({abest}) — stress applied =====")
|
|
print(f"{'candidate':>16} {'standalone':>10} {'corr_to_mom':>11} {'combined':>9} | combined per-year 2020..26")
|
|
sl = torch.tensor(sleeve, device=DEV, dtype=torch.float64)
|
|
sl_sd = float(sl.std())
|
|
combos = []
|
|
for nm, sg in cand.items():
|
|
pc = pnl(sg, Reff_str, COST)
|
|
a, b = np.asarray(sleeve), np.asarray(pc)
|
|
m = np.isfinite(a) & np.isfinite(b)
|
|
corr = float(np.corrcoef(a[m], b[m])[0, 1]) if m.sum() > 50 else float("nan")
|
|
sc = float(torch.tensor(pc, device=DEV, dtype=torch.float64).std())
|
|
wm, wc = (1 / sl_sd), (1 / sc if sc > 0 else 0)
|
|
comb = (wm * a + wc * b) / (wm + wc)
|
|
vc = sharpe_t(torch.tensor(comb, device=DEV, dtype=torch.float64))
|
|
py = peryear(comb)
|
|
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
|
sa = sharpe_t(torch.tensor(pc, device=DEV, dtype=torch.float64))
|
|
print(f"{nm:>16} {sa:>+10.2f} {corr:>+11.2f} {vc:>+9.2f} | {pys}")
|
|
combos.append((nm, corr, vc, pc))
|
|
|
|
# ---------- final BOOK: momentum + diversifiers that lift combined Sharpe and are low-corr ----------
|
|
base_sh = sharpe_t(sl)
|
|
keep = [(nm, pc) for nm, corr, vc, pc in combos if vc > base_sh and (math.isnan(corr) or corr < 0.5)]
|
|
print(f"\n===== BOOK = momentum + {[k for k,_ in keep]} (inverse-vol weighted, stress) =====")
|
|
sleeves = [np.asarray(sleeve)] + [np.asarray(pc) for _, pc in keep]
|
|
sds = [float(np.nanstd(s)) for s in sleeves]
|
|
ws = np.array([1 / s if s > 0 else 0 for s in sds]); ws /= ws.sum()
|
|
book = sum(w * s for w, s in zip(ws, sleeves))
|
|
vb = validate(book, days, 6)
|
|
py = peryear(book)
|
|
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
|
|
print(f" momentum-only Sharpe (stress) = {base_sh:+.2f}")
|
|
print(f" BOOK: full {vb['full']:+.2f} IS {vb['is_']:+.2f} OOS {vb['oos']:+.2f} CPCVmed {vb['med']:+.2f} CPCV5% {vb['p5']:+.2f}")
|
|
print(f" BOOK per-year 2020..26: {pys}")
|
|
print("\nVERDICT: book Sharpe > momentum-only with every-year positive => diversification works (deployable book).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|