research(crypto): 4 filoni 2026-06-29 — ERM lead sub-daily (forward), 3 scartati/deboli
Ricerca onesta su BTC/ETH + universo HL, branch separato (nessun impatto live).
Harness condiviso altlib (causale, fee 0.10% RT, marginal vs TP01, day-boundary,
haircut $600). Test 19/19 verdi.
- A DVOL direzionale -> LEAD hedge/DD-dampener, NON sleeve (buy-the-fear; is_hedge).
- B Intraday ERM 8h -> LEAD forte / forward-monitor: earns_slot=True, ADDS oltre
SKH01 (TP01+SKH+ERM 60/25/15 FULL 1.88/HOLD 1.46/DD 8.9%).
Caveat: plateau hold-out single-row, multiple-testing non
deflazionato, exec 8h. Controllo TOD = FAIL atteso.
- C Cross-sectional non-mom (low-vol HL) -> DEBOLE/forward-monitor (deflated-Sh 0.13,
storia 2.5a, non eseguibile $600) STAT-MODE.
- D Macro regime-gate -> RIDONDANTE col trend (corr->TP01 0.989), SCARTATO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Test del filone DVOL-DIREZIONALE (scripts/research/dvol_directional.py).
|
||||
|
||||
Verifica le proprieta' che DEVONO valere per ogni segnale del progetto:
|
||||
* il percentile espandente del DVOL e' CAUSALE (rank su un prefisso == rank(full)[:cut]);
|
||||
* il leader (DVOL-fear long-flat) passa causality_ok di altlib (niente future-peeking);
|
||||
* a $600 (eval_weights_smallcap) l'haircut e' trascurabile (segnale eseguibile, low-turnover);
|
||||
* sign-falsification: la tesi (buy-the-fear) batte il suo flip (buy-the-calm) sull'era DVOL.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import altlib as al # noqa: E402
|
||||
from scripts.research import dvol_directional as dd # noqa: E402
|
||||
|
||||
|
||||
def test_expanding_rank_is_causal():
|
||||
"""rank calcolato su un prefisso deve coincidere con rank(full) ristretto al prefisso."""
|
||||
x = np.asarray(al.dvol(al.get("BTC", "1d"), "BTC"), float)
|
||||
full = dd._expanding_rank_arr(x)
|
||||
cut = int(len(x) * 0.7)
|
||||
pref = dd._expanding_rank_arr(x[:cut])
|
||||
a, b = full[:cut], pref
|
||||
m = np.isfinite(a) & np.isfinite(b)
|
||||
assert m.sum() > 100
|
||||
assert np.max(np.abs(a[m] - b[m])) < 1e-9
|
||||
|
||||
|
||||
def test_leader_causality_ok():
|
||||
"""Il leader (DVOL-fear q0.4 long-flat) non deve avere look-ahead (altlib causality_ok)."""
|
||||
fn = dd.make_dvol_level(0.4, "fear", True)
|
||||
co = al.causality_ok(fn, tf="1d")
|
||||
assert co["ok"], co
|
||||
assert co["max_tail_diff"] <= 1e-6
|
||||
|
||||
|
||||
@pytest.mark.parametrize("asset", ["BTC", "ETH"])
|
||||
def test_leader_executable_at_600(asset):
|
||||
"""A $600 il segnale e' a basso turnover: haircut Sharpe trascurabile, trade eseguiti reali."""
|
||||
fn = dd.make_dvol_level(0.4, "fear", True)
|
||||
df = al.get(asset, "1d")
|
||||
sc = al.eval_weights_smallcap(df, al._call_target(fn, df, asset), capital=600, min_order=5)
|
||||
assert sc["n_executed_trades"] > 10
|
||||
assert abs(sc["sharpe_haircut"]) < 0.10
|
||||
|
||||
|
||||
def test_sign_falsification():
|
||||
"""La tesi (buy-the-fear) deve battere il flip (buy-the-calm) sull'era DVOL."""
|
||||
thesis = dd.era_full_sharpe(dd.make_dvol_level(0.5, "fear", True))["sharpe"]
|
||||
flip = dd.era_full_sharpe(dd.make_dvol_level(0.5, "calm", True))["sharpe"]
|
||||
assert thesis > flip
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Test del filone B — INTRADAY REGIME (scripts/research/intraday_regime.py).
|
||||
|
||||
Verifica leggera e veloce (un solo asset) che:
|
||||
* i target factory producano array della lunghezza giusta e CAUSALI (no look-ahead);
|
||||
* il meccanismo ERM (efficiency-ratio regime momentum) sia long/short di natura;
|
||||
* la cella canonica ERM 8h L=2 thr=0.35 abbia Sharpe FULL positivo netto fee (sanity dell'edge).
|
||||
NB: NON costruisce la baseline TP01/SKH01 (lento) — quello e' nello script di ricerca.
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"intraday_regime", "/opt/docker/PythagorasGoal/scripts/research/intraday_regime.py")
|
||||
ir = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(ir)
|
||||
|
||||
|
||||
def test_erm_target_shape_and_ls():
|
||||
df = al.get("BTC", "8h")
|
||||
tgt = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False)(df)
|
||||
assert len(tgt) == len(df)
|
||||
assert np.isfinite(tgt).all()
|
||||
# L/S: deve esistere sia esposizione long sia short (non e' long-only)
|
||||
assert (tgt > 0).any() and (tgt < 0).any()
|
||||
# long_flat=True -> nessuno short
|
||||
tgt_lf = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=True)(df)
|
||||
assert (tgt_lf >= -1e-9).all()
|
||||
|
||||
|
||||
def test_erm_causal_no_leak():
|
||||
# causality_ok ricalcola il target su prefissi troncati e pretende che la coda combaci
|
||||
res = al.causality_ok(ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False),
|
||||
tf="8h", assets=("BTC",))
|
||||
assert res["ok"], f"look-ahead in ERM: {res}"
|
||||
|
||||
|
||||
def test_erm_winner_positive_full_sharpe():
|
||||
fn = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False)
|
||||
for a in ("BTC", "ETH"):
|
||||
df = al.get(a, "8h")
|
||||
ev = al.eval_weights(df, fn(df), fee_side=0.0005) # 0.10% RT
|
||||
assert ev["full"]["sharpe"] > 0.5, f"{a} full Sharpe {ev['full']['sharpe']}"
|
||||
|
||||
|
||||
def test_vbr_and_tod_causal():
|
||||
for fn in (ir.make_vbr(tf="8h", k=1.0, atr_win=14, long_flat=False),
|
||||
ir.make_tod(tf="1h", long_flat=True)):
|
||||
res = al.causality_ok(fn, tf=("8h" if fn is not None else "1h"),
|
||||
assets=("BTC",)) if False else None
|
||||
# VBR causale (8h) e TOD causale (1h), un asset per velocita'
|
||||
assert al.causality_ok(ir.make_vbr(tf="8h", k=1.0, atr_win=14, long_flat=False),
|
||||
tf="8h", assets=("BTC",))["ok"]
|
||||
assert al.causality_ok(ir.make_tod(tf="1h", long_flat=True),
|
||||
tf="1h", assets=("BTC",))["ok"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Test del filone D — MACRO REGIME-GATE (scripts/research/macro_regime_gate.py).
|
||||
|
||||
Verifica leggera che:
|
||||
* il frame macro (ETF daily) carichi e sia su un calendario monotono;
|
||||
* i gate builder producano un gate in [g_off, 1] e CAUSALE (SMA/ratio rolling, no future);
|
||||
* align_gate sia backward-only (la barra crypto i usa solo gate equity con label <= i);
|
||||
* il VERDETTO del filone regga: il gate macro e' RIDONDANTE col trend di TP01 — "lavora"
|
||||
(riduce una posizione TP01 NON gia' flat) solo in una piccola quota di giorni.
|
||||
NB: un solo gate per i test lenti (costruisce TP01) -> velocita'.
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"macro_regime_gate", "/opt/docker/PythagorasGoal/scripts/research/macro_regime_gate.py")
|
||||
mg = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(mg)
|
||||
|
||||
|
||||
def test_macro_frame_loads_and_monotone():
|
||||
mf = mg.macro_frame()
|
||||
for col in ("spy", "qqq", "hyg", "lqd", "gld", "tlt", "ief"):
|
||||
assert col in mf.columns, f"colonna macro mancante: {col}"
|
||||
ts = mf["timestamp"].values
|
||||
assert (np.diff(ts) > 0).all(), "calendario macro non strettamente crescente"
|
||||
# spy non-null dopo l'inizio (master del calendario)
|
||||
assert np.isfinite(mf["spy"].values[-1])
|
||||
|
||||
|
||||
def test_gate_in_range_and_binary():
|
||||
mf = mg.macro_frame()
|
||||
for gdf, g_off in ((mg.gate_trend(mf, "spy", 200, 0.0), 0.0),
|
||||
(mg.gate_combo(mf, 200, 0.5), 0.5)):
|
||||
g = gdf["gate"].values
|
||||
fin = g[np.isfinite(g)]
|
||||
assert len(fin) > 0
|
||||
assert (fin >= g_off - 1e-9).all() and (fin <= 1.0 + 1e-9).all(), \
|
||||
"gate fuori da [g_off, 1]"
|
||||
assert len(gdf) == len(mf)
|
||||
|
||||
|
||||
def test_gate_trend_causal_prefix():
|
||||
# gate_trend usa solo SMA rolling -> ricalcolato su un prefisso, la coda combacia
|
||||
mf = mg.macro_frame()
|
||||
full = mg.gate_trend(mf, "spy", 200, 0.0)["gate"].values
|
||||
k = len(mf) - 300
|
||||
pref = mg.gate_trend(mf.iloc[:k].reset_index(drop=True), "spy", 200, 0.0)["gate"].values
|
||||
a, b = full[:k], pref
|
||||
both = np.isfinite(a) & np.isfinite(b)
|
||||
assert both.any()
|
||||
assert np.allclose(a[both], b[both]), "look-ahead nel gate_trend (prefix != coda)"
|
||||
|
||||
|
||||
def test_align_gate_backward_no_future():
|
||||
# la barra crypto i deve mappare a un gate equity con timestamp <= timestamp crypto i
|
||||
mf = mg.macro_frame()
|
||||
gate_df = mg.gate_trend(mf, "spy", 200, 0.0)
|
||||
df = al.get("BTC", "1d")
|
||||
g = mg.align_gate(gate_df, df)
|
||||
assert len(g) == len(df)
|
||||
assert np.isfinite(g).all()
|
||||
# nessun valore di gate puo' provenire dal futuro: per una manciata di barre crypto,
|
||||
# il gate allineato deve coincidere con l'ultimo gate equity NON-NaN con ts <= ts_crypto
|
||||
gd = gate_df.dropna(subset=["gate"]).sort_values("timestamp")
|
||||
gt, gv = gd["timestamp"].values, gd["gate"].values
|
||||
cts = df["timestamp"].astype("int64").values
|
||||
for i in range(len(cts) - 1, max(len(cts) - 50, 0), -1):
|
||||
prior = np.searchsorted(gt, cts[i], side="right") - 1
|
||||
if prior < 0:
|
||||
continue
|
||||
assert abs(g[i] - gv[prior]) < 1e-9, f"align_gate usa gate futuro a i={i}"
|
||||
|
||||
|
||||
def test_verdict_gate_is_redundant_with_trend():
|
||||
# IL VERDETTO del filone D: TP01 e' gia' flat nei crash -> il gate macro "lavora"
|
||||
# (riduce una posizione NON gia' flat) solo in una piccola quota di giorni.
|
||||
mf = mg.macro_frame()
|
||||
gate_df = mg.gate_combo(mf, 200, 0.0) # combo SPY+HYG+HYG/LQD, de-risk a 0
|
||||
diag = mg.redundancy_diag(gate_df)
|
||||
for a in mg.ASSETS:
|
||||
r = diag[a]
|
||||
# esposizione TP01 nei giorni risk-off gia' bassa, e gate "lavora" raramente
|
||||
assert r["pct_days_gate_works"] < 0.20, \
|
||||
f"{a}: gate lavora {r['pct_days_gate_works']} (atteso piccolo = ridondante col trend)"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Test del filone C: cross-sectional NON-momentum su Hyperliquid (scripts/research/xsec_v2_nonmom).
|
||||
|
||||
Verifica i GATE strutturali, non i numeri esatti (storia corta, ricerca): l'engine e' CAUSALE
|
||||
(prefix-consistency, zero look-ahead), le fee MONOTONE (piu' fee -> Sharpe <=), il reversal grezzo
|
||||
e' MORTO (plateau negativo), e il low-vol factor sui 19 major e' positivo in-sample (il LEAD).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.portfolio.portfolio import to_daily, metrics
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"xsec_v2_nonmom", PROJECT_ROOT / "scripts" / "research" / "xsec_v2_nonmom.py")
|
||||
xv = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(xv)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def majors():
|
||||
return xv.load_matrix(XS_UNIVERSE)
|
||||
|
||||
|
||||
def test_universe_loads_clean(majors):
|
||||
PX, VOL = majors
|
||||
assert PX.shape[1] == len(XS_UNIVERSE)
|
||||
assert PX.shape[0] > 800 # ~2.5 anni a 1d
|
||||
assert PX.index.is_monotonic_increasing
|
||||
|
||||
|
||||
def test_engine_is_causal_no_lookahead(majors):
|
||||
"""L'engine NON deve guardare al futuro: ricostruito su un prefisso, la coda combacia
|
||||
bit-a-bit con la run completa (gate #1 della metodologia)."""
|
||||
PX, VOL = majors
|
||||
builder, _ = xv.mechanisms()["LOWVOL"]
|
||||
cfg = dict(B=30, H=10, k=5)
|
||||
res = xv.causality_prefix_check(PX, VOL, builder, cfg)
|
||||
assert res["ok"], f"look-ahead rilevato: max_tail_diff={res['max_tail_diff']}"
|
||||
assert res["max_tail_diff"] == 0.0
|
||||
|
||||
# anche un meccanismo residuo (usa beta rolling + mercato) deve essere causale
|
||||
builder_i, _ = xv.mechanisms()["IMOM"]
|
||||
res_i = xv.causality_prefix_check(PX, VOL, builder_i, dict(L=30, H=5, k=8, B=60))
|
||||
assert res_i["ok"], f"IMOM non causale: {res_i['max_tail_diff']}"
|
||||
|
||||
|
||||
def test_fee_is_monotone(majors):
|
||||
"""Piu' fee non puo' MAI alzare lo Sharpe (su una config con turnover non nullo)."""
|
||||
PX, VOL = majors
|
||||
builder, _ = xv.mechanisms()["LOWVOL"]
|
||||
score_at, warm = builder(PX, dict(B=30, H=10, k=5))
|
||||
s0, t0 = xv.xs_engine(PX, VOL, score_at, 10, 5, fee=0.0, warmup=warm)
|
||||
s2, t2 = xv.xs_engine(PX, VOL, score_at, 10, 5, fee=0.002, warmup=warm)
|
||||
assert t0 > 0
|
||||
assert metrics(to_daily(s0))["sharpe"] >= metrics(to_daily(s2))["sharpe"] - 1e-9
|
||||
|
||||
|
||||
def test_raw_reversal_is_dead(majors):
|
||||
"""Reversal cross-sectional grezzo = NESSUN plateau positivo (coerente con la lezione del
|
||||
progetto: la mean-reversion e' artefatto). Almeno meta' delle config dev'essere FULL<=0."""
|
||||
PX, VOL = majors
|
||||
builder, cfgs = xv.mechanisms()["REV"]
|
||||
neg = 0; tot = 0
|
||||
for p in cfgs:
|
||||
score_at, warm = builder(PX, p)
|
||||
d = to_daily(xv.xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm)[0])
|
||||
if d.std() == 0:
|
||||
continue
|
||||
tot += 1
|
||||
if metrics(d)["sharpe"] <= 0:
|
||||
neg += 1
|
||||
assert tot > 0
|
||||
assert neg >= tot / 2, f"reversal inatteso: solo {neg}/{tot} config FULL<=0"
|
||||
|
||||
|
||||
def test_lowvol_factor_positive_insample(majors):
|
||||
"""Il LEAD: low-vol factor sui 19 major (B30 H10 k5) ha FULL Sharpe positivo e robusto
|
||||
(plateau 100% positivo). Numero non vincolato (ricerca), solo il segno/robustezza."""
|
||||
PX, VOL = majors
|
||||
builder, cfgs = xv.mechanisms()["LOWVOL"]
|
||||
score_at, warm = builder(PX, dict(B=30, H=10, k=5))
|
||||
d = to_daily(xv.xs_engine(PX, VOL, score_at, 10, 5, warmup=warm)[0])
|
||||
assert metrics(d)["sharpe"] > 0.5
|
||||
# plateau: ogni config LOWVOL deve avere FULL>0 (factor robusto ai parametri in-sample)
|
||||
pos = 0; tot = 0
|
||||
for p in cfgs:
|
||||
sa, w = builder(PX, p)
|
||||
dd = to_daily(xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=w)[0])
|
||||
if dd.std() == 0:
|
||||
continue
|
||||
tot += 1; pos += metrics(dd)["sharpe"] > 0
|
||||
assert pos == tot, f"plateau low-vol non pieno: {pos}/{tot}"
|
||||
Reference in New Issue
Block a user