feat(stability): sweep stabilità — fix TR01 mean(rets), XS01 phase-tranching K=3, z-stop pairs bocciato
Audit anti-overfit su tutte le 19 sleeve (diario 2026-06-11-stability-sweep.md): - FIX BasketTrendWorker: mean(rets) sui soli asset in posizione sovrappesava N/k a paniere parziale (1 long = 0.45 del capitale invece di 0.09) -> replay -44% vs ref +42%. Ora sum(rets)/N (convenzione canonica 1/N): replay +32% vs +42% (residuo = convenzione dichiarata). Solo statistica PAPER. - XS01 PHASE-TRANCHING (gate xs01_tranche_gate: plateau K=2 E K=3 promossi, PORT06 OOS Sh 10.07->10.15 DD 1.48->1.38, FULL pari): la fase del roll e' timing-luck (Sharpe daily 1.52-2.33, DD 13.8-33% sulle 12 fasi). Worker con param tranches (default 1), 3 sub-book sfasati hold/3 su capitale comune, migrazione status legacy, last_bar_ts solo-avanti; runner forward del param; _defs tranches=3; hourly_report aggrega i sub-book; validatore esteso e PASSATO (K=1 == xsec_sim esatto, K=3 == unione fasi esatto). - Disaster-cap z sui pairs: pre-registrato e BOCCIATO su tutti i criteri (coda OOS peggiora 4/6 coppie, Sharpe -10..-49%, plateau solo del danno; 5a conferma stop-su-MR). Record pairs_zstop_research.py; pairs restano senza stop. - Audit drift: regression-lock trendmax OK (parita' 1.00000, plateau 2.5/3.0/3.5 confermato), correlazioni cross-famiglia ~0 invariate; PORT06 rolling al 19-28mo pct (normale) ma FADE 120g al 2o percentile storico -> monitor in TODO (nessun ritocco parametri). - TODO: forming-bar ROT02/TSM01 era gia' fixato (v1.1.10), item chiuso. Test: pytest 99 passed; validate_honest_workers OK; validate_xsec_worker OK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""Ricerca PRE-REGISTRATA: disaster-cap z-score (z_stop) per la famiglia PAIRS.
|
||||
|
||||
Ipotesi pre-registrata: uscita immediata al close della barra se |z| >= z_stop
|
||||
dopo l'ingresso taglia la coda da structural-break senza toccare i trade normali
|
||||
(che vivono fra z_exit e z_in).
|
||||
|
||||
Griglia PRE-REGISTRATA (unica, completa — NIENTE varianti a posteriori):
|
||||
- 5 coppie 1h (config universale n=50 z_in=2.0 z_exit=0.75 max_bars=72):
|
||||
z_stop in {3.0, 3.5, 4.0, 5.0}
|
||||
- ETH/BTC 15m flat_skip (n=66 z_in=1.674 z_exit=1.0 max_bars=35):
|
||||
z_stop in {2.5, 3.0, 3.5, 4.0}
|
||||
Split: TRAIN = entry prima del 2023-11-01, OOS = dopo (convenzione progetto).
|
||||
|
||||
Engine: copia FEDELE di pairs_research.pairs_sim / pairs_sim_flat (stessa
|
||||
matematica, fee 2 gambe = 2*fee_rt*lev) + parametro z_stop. Causalita': lo z
|
||||
usato per l'exit alla barra j e' lo stesso z[j] causale (rolling su r[<=j])
|
||||
gia' usato dall'exit |z|<=z_exit.
|
||||
|
||||
REGRESSION-LOCK obbligatorio (eseguito in main, si ferma se fallisce):
|
||||
z_stop=None deve riprodurre ESATTAMENTE pairs_sim (ETH/BTC 1h) e
|
||||
pairs_sim_flat (ETH/BTC 15m flat_skip): stesso n trade, stesso ret.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.pairs_research import ( # noqa: E402
|
||||
FEE_RT, LEV, POS, aligned_ohlc, is_flat_ohlc, pairs_sim, pairs_sim_flat,
|
||||
)
|
||||
|
||||
SPLIT_DT = pd.Timestamp("2023-11-01", tz="UTC")
|
||||
|
||||
|
||||
def pairs_sim_zstop(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72,
|
||||
jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS,
|
||||
z_stop=None, t0=None, t1=None,
|
||||
flat_skip=False, scan_buffer=192):
|
||||
"""Copia fedele dell'engine pairs (pairs_sim_flat, che con flat_skip=False
|
||||
e' identico a pairs_sim — regression-lock in main) + disaster-cap z_stop.
|
||||
|
||||
z_stop: se non None, l'exit si arma anche quando |z[jj]| >= z_stop
|
||||
(structural break: lo spread diverge oltre l'ingresso). Stessa convenzione
|
||||
causale e stesso fill (close della barra) dell'exit |z|<=z_exit.
|
||||
t0/t1: finestra sul timestamp della barra di ENTRY (train/OOS split).
|
||||
"""
|
||||
m = aligned_ohlc(a, b, tf)
|
||||
ca, cb = m["close_a"].values, m["close_b"].values
|
||||
N = len(ca)
|
||||
if flat_skip:
|
||||
flat = (is_flat_ohlc(m["open_a"].values, m["high_a"].values, m["low_a"].values, ca)
|
||||
| is_flat_ohlc(m["open_b"].values, m["high_b"].values, m["low_b"].values, cb))
|
||||
else:
|
||||
flat = np.zeros(N, dtype=bool)
|
||||
r = np.log(ca / cb)
|
||||
dr = np.abs(np.diff(r, prepend=r[0]))
|
||||
ma = pd.Series(r).rolling(n).mean().values
|
||||
sd = pd.Series(r).rolling(n).std().values
|
||||
z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i]
|
||||
ts = m["dt"]
|
||||
tsv = ts.values # datetime64 per filtro finestra
|
||||
t0v = np.datetime64(t0.tz_convert(None)) if t0 is not None else None
|
||||
t1v = np.datetime64(t1.tz_convert(None)) if t1 is not None else None
|
||||
fee = 2 * fee_rt * lev # 2 gambe
|
||||
cap = peak = 1000.0; dd = 0.0; last = -1
|
||||
trades = wins = n_stop = 0
|
||||
rets = []; rets_raw = []
|
||||
eq_ts, eq_v = [], []
|
||||
kmax = max_bars + (scan_buffer if flat_skip else 0)
|
||||
for i in range(n + 1, N - 1):
|
||||
if np.isnan(z[i]) or dr[i] > jump_max or i <= last:
|
||||
continue
|
||||
if t0v is not None and tsv[i] < t0v:
|
||||
continue
|
||||
if t1v is not None and tsv[i] >= t1v:
|
||||
continue
|
||||
if z[i] <= -z_in:
|
||||
d = 1
|
||||
elif z[i] >= z_in:
|
||||
d = -1
|
||||
else:
|
||||
continue
|
||||
if flat[i]:
|
||||
continue # niente ingresso su barra stale
|
||||
# exit: |z|<=z_exit, max_bars, o DISASTER-CAP |z|>=z_stop; con flat_skip
|
||||
# l'exit si arma e si esce alla prima barra pulita (live-realizable)
|
||||
exit_ready = False; stopped = False; j = i
|
||||
for k in range(1, kmax + 1):
|
||||
jj = i + k
|
||||
if jj >= N:
|
||||
j = N - 1; break
|
||||
if not exit_ready:
|
||||
if z_stop is not None and abs(z[jj]) >= z_stop:
|
||||
exit_ready = True; stopped = True
|
||||
elif abs(z[jj]) <= z_exit or k >= max_bars:
|
||||
exit_ready = True
|
||||
if exit_ready and not flat[jj]:
|
||||
j = jj; break
|
||||
j = jj
|
||||
retA = (ca[j] - ca[i]) / ca[i]
|
||||
retB = (cb[j] - cb[i]) / cb[i]
|
||||
ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa)
|
||||
cap = max(cap + cap * pos * ret, 10.0)
|
||||
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
|
||||
trades += 1; wins += ret > 0; n_stop += stopped
|
||||
rets.append(ret * pos); rets_raw.append(ret); last = j
|
||||
eq_ts.append(ts.iloc[j]); eq_v.append(cap)
|
||||
# span temporale della finestra effettiva (per annualizzare lo Sharpe)
|
||||
lo = ts.iloc[0] if t0 is None else max(ts.iloc[0], t0)
|
||||
hi = ts.iloc[-1] if t1 is None else min(ts.iloc[-1], t1)
|
||||
yrs_span = (hi - lo).days / 365.25 or 1
|
||||
sharpe = 0.0
|
||||
if len(rets) > 1 and np.std(rets) > 0:
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades / yrs_span))
|
||||
ret_tot = (cap / 1000 - 1) * 100
|
||||
worst = min(rets_raw) * 100 if rets_raw else 0.0
|
||||
return dict(trades=trades, n_stop=n_stop, win=wins / trades * 100 if trades else 0,
|
||||
ret=ret_tot, dd=dd * 100, sharpe=sharpe, worst=worst)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- lock
|
||||
|
||||
def regression_lock():
|
||||
"""z_stop=None deve riprodurre ESATTAVENTE l'engine canonico."""
|
||||
ok = True
|
||||
# 1h plain vs pairs_sim (config universale live z_exit=0.75)
|
||||
ref = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.75, max_bars=72)
|
||||
new = pairs_sim_zstop("ETH", "BTC", n=50, z_in=2.0, z_exit=0.75, max_bars=72,
|
||||
z_stop=None, flat_skip=False)
|
||||
m1 = (ref["trades"] == new["trades"]) and abs(ref["ret"] - new["ret"]) < 1e-9
|
||||
print(f" LOCK 1h ETH/BTC vs pairs_sim: trades {ref['trades']} vs {new['trades']}, "
|
||||
f"ret {ref['ret']:+.6f} vs {new['ret']:+.6f} -> {'OK' if m1 else 'FAIL'}")
|
||||
ok &= m1
|
||||
# 15m flat_skip vs pairs_sim_flat
|
||||
ref = pairs_sim_flat("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
|
||||
max_bars=35, flat_skip=True)
|
||||
new = pairs_sim_zstop("ETH", "BTC", tf="15m", n=66, z_in=1.674, z_exit=1.0,
|
||||
max_bars=35, z_stop=None, flat_skip=True)
|
||||
m2 = (ref["trades"] == new["trades"]) and abs(ref["ret"] - new["ret"]) < 1e-9
|
||||
print(f" LOCK 15m ETH/BTC vs pairs_sim_flat: trades {ref['trades']} vs {new['trades']}, "
|
||||
f"ret {ref['ret']:+.6f} vs {new['ret']:+.6f} -> {'OK' if m2 else 'FAIL'}")
|
||||
ok &= m2
|
||||
return ok
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- main
|
||||
|
||||
PAIRS_1H = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("BTC", "LTC"), ("ETH", "SOL")]
|
||||
GRID_1H = [None, 3.0, 3.5, 4.0, 5.0]
|
||||
GRID_15M = [None, 2.5, 3.0, 3.5, 4.0]
|
||||
|
||||
|
||||
def run_cell(a, b, win, z_stop, **kw):
|
||||
t0, t1 = (None, SPLIT_DT) if win == "TRAIN" else (SPLIT_DT, None)
|
||||
return pairs_sim_zstop(a, b, z_stop=z_stop, t0=t0, t1=t1, **kw)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" PAIRS disaster-cap z_stop — ricerca PRE-REGISTRATA (griglia fissa, tutti i risultati)")
|
||||
print(f" split TRAIN < {SPLIT_DT.date()} <= OOS | fee 2 gambe {2*FEE_RT*LEV*100:.2f}% | lev {LEV:.0f}x pos {POS}")
|
||||
print("=" * 100)
|
||||
print("\nREGRESSION-LOCK (z_stop=None == engine canonico):")
|
||||
if not regression_lock():
|
||||
print("\n LOCK FALLITO — STOP."); sys.exit(1)
|
||||
|
||||
hdr = (f" {'z_stop':>7s} | {'trd':>5s} {'stop':>5s} {'ret%':>9s} {'Shrp':>6s} "
|
||||
f"{'DD%':>6s} {'worst%':>8s}")
|
||||
for a, b in PAIRS_1H:
|
||||
kw = dict(tf="1h", n=50, z_in=2.0, z_exit=0.75, max_bars=72, flat_skip=False)
|
||||
print(f"\n{'-'*100}\n {a}/{b} 1h (n=50 z_in=2.0 z_exit=0.75 max_bars=72)")
|
||||
for win in ("TRAIN", "OOS"):
|
||||
print(f" [{win}]\n{hdr}")
|
||||
for zs in GRID_1H:
|
||||
r = run_cell(a, b, win, zs, **kw)
|
||||
lab = "None" if zs is None else f"{zs:.1f}"
|
||||
print(f" {lab:>7s} | {r['trades']:>5d} {r['n_stop']:>5d} {r['ret']:>+9.1f} "
|
||||
f"{r['sharpe']:>6.2f} {r['dd']:>6.2f} {r['worst']:>+8.2f}")
|
||||
|
||||
a, b = "ETH", "BTC"
|
||||
kw = dict(tf="15m", n=66, z_in=1.674, z_exit=1.0, max_bars=35, flat_skip=True)
|
||||
print(f"\n{'-'*100}\n {a}/{b} 15m flat_skip (n=66 z_in=1.674 z_exit=1.0 max_bars=35)")
|
||||
for win in ("TRAIN", "OOS"):
|
||||
print(f" [{win}]\n{hdr}")
|
||||
for zs in GRID_15M:
|
||||
r = run_cell(a, b, win, zs, **kw)
|
||||
lab = "None" if zs is None else f"{zs:.1f}"
|
||||
print(f" {lab:>7s} | {r['trades']:>5d} {r['n_stop']:>5d} {r['ret']:>+9.1f} "
|
||||
f"{r['sharpe']:>6.2f} {r['dd']:>6.2f} {r['worst']:>+8.2f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,35 +20,64 @@ from src.live.xsec_worker import CrossSectionalWorker
|
||||
from scripts.strategies.XS01_cross_sectional import aligned_panel, xsec_sim, UNIVERSE, LB, HOLD
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print(" VALIDAZIONE CrossSectionalWorker — replay live vs backtest xsec_sim (fee 0.10% RT/book)")
|
||||
print("=" * 88)
|
||||
M = aligned_panel(UNIVERSE)
|
||||
dfs = {a: pd.DataFrame({"timestamp": M.index.values, "close": M[a].values}) for a in UNIVERSE}
|
||||
def _replay(M, dfs, params):
|
||||
"""Alimenta il worker bar-per-bar su finestre trailing; ritorna il worker."""
|
||||
n = len(M)
|
||||
tmp = Path(tempfile.mkdtemp(prefix="xsec_val_"))
|
||||
try:
|
||||
w = CrossSectionalWorker(UNIVERSE, tf="1h", params={"lb": LB, "hold": HOLD},
|
||||
w = CrossSectionalWorker(UNIVERSE, tf="1h", params=params,
|
||||
fee_rt=0.0005, data_dir=tmp)
|
||||
w._save = lambda: None; w._log = lambda *a, **k: None; w._notify = lambda *a, **k: None
|
||||
window = LB + 6
|
||||
for k in range(LB + 1, n + 1): # prima finestra = lb+1 barre -> ingresso al bar lb
|
||||
lo = max(0, k - window)
|
||||
w.tick({a: dfs[a].iloc[lo:k] for a in UNIVERSE})
|
||||
bt = xsec_sim(UNIVERSE)
|
||||
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
||||
cap_ok = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
|
||||
trd_ok = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
|
||||
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
||||
print(f"\n {'':<6}{'cap':>14}{'trades':>8}{'win%':>7}")
|
||||
print(f" WORKER{w.capital:>14.0f}{w.total_trades:>8d}{ww:>7.1f}")
|
||||
print(f" BCKTST{bt_cap:>14.0f}{bt['trades']:>8d}{bt['win']:>7.1f}")
|
||||
print(f"\n ESITO: {'OK (replay == backtest)' if (cap_ok and trd_ok) else 'DIFF -> INDAGARE'}")
|
||||
print(" (diff minime attese da bar finale aperta / troncamento)")
|
||||
return w
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print(" VALIDAZIONE CrossSectionalWorker — replay live vs backtest xsec_sim (fee 0.10% RT/book)")
|
||||
print("=" * 88)
|
||||
M = aligned_panel(UNIVERSE)
|
||||
dfs = {a: pd.DataFrame({"timestamp": M.index.values, "close": M[a].values}) for a in UNIVERSE}
|
||||
|
||||
# [1] K=1: parita' storica col backtest canonico
|
||||
w = _replay(M, dfs, {"lb": LB, "hold": HOLD})
|
||||
bt = xsec_sim(UNIVERSE)
|
||||
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
|
||||
cap_ok = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
|
||||
trd_ok = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
|
||||
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
|
||||
print(f"\n [K=1] {'':<6}{'cap':>14}{'trades':>8}{'win%':>7}")
|
||||
print(f" WORKER{w.capital:>14.0f}{w.total_trades:>8d}{ww:>7.1f}")
|
||||
print(f" BCKTST{bt_cap:>14.0f}{bt['trades']:>8d}{bt['win']:>7.1f}")
|
||||
ok1 = cap_ok and trd_ok
|
||||
print(f" ESITO: {'OK (replay == backtest)' if ok1 else 'DIFF -> INDAGARE'}")
|
||||
|
||||
# [2] K=3 (tranching live): parita' con l'unione delle fasi 0,4,8 (gate
|
||||
# xs01_tranche_gate) — capitale comune, PnL/K per trade in ordine di exit
|
||||
from scripts.analysis.xs01_tranche_research import xsec_trades
|
||||
K = 3
|
||||
w3 = _replay(M, dfs, {"lb": LB, "hold": HOLD, "tranches": K})
|
||||
step = HOLD // K
|
||||
allt = sorted([t for j in range(K) for t in xsec_trades(phase=j * step, M=M)],
|
||||
key=lambda t: t[1])
|
||||
cap = 1000.0
|
||||
for _, _, net in allt:
|
||||
cap = max(cap + cap * 0.15 * 3.0 * net / K, 10.0)
|
||||
cap_ok3 = abs(w3.capital - cap) / cap < 0.02
|
||||
trd_ok3 = abs(w3.total_trades - len(allt)) <= max(2, len(allt) * 0.02)
|
||||
print(f"\n [K=3] {'':<6}{'cap':>14}{'trades':>8}")
|
||||
print(f" WORKER{w3.capital:>14.0f}{w3.total_trades:>8d}")
|
||||
print(f" BCKTST{cap:>14.0f}{len(allt):>8d}")
|
||||
ok3 = cap_ok3 and trd_ok3
|
||||
print(f" ESITO: {'OK (replay == unione fasi)' if ok3 else 'DIFF -> INDAGARE'}")
|
||||
print(f"\n ESITO COMPLESSIVO: {'OK' if (ok1 and ok3) else 'DIFF -> INDAGARE'}")
|
||||
print(" (diff minime attese da bar finale aperta / troncamento)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""GATE — XS01 phase-tranching (K sub-book sfasati di hold/K, capitale comune).
|
||||
|
||||
`xs01_tranche_research.py` ha misurato timing-luck di fase reale (FULL Sharpe
|
||||
1.87-2.87, DD 13.8-33.1% a seconda della fase di partenza, che live e' arbitraria).
|
||||
Qui il confronto ONESTO su equity GIORNALIERA (stessa convenzione dei gate del
|
||||
progetto: combine_portfolio.metrics) + gate PORT06 swap-sleeve:
|
||||
[1] standalone: base (fase canonica) vs K=2 vs K=3, FULL e OOS, daily Sharpe/DD;
|
||||
in piu' il range delle 12 fasi singole (il rischio che il tranching elimina).
|
||||
[2] PORT06: members canonici con XS01 sostituito dalla variante tranched.
|
||||
Criterio (tutti): OOS Sharpe portafoglio non peggiora (>-0.02) E DD non sale;
|
||||
K=2 e K=3 devono essere ENTRAMBI >= base (plateau, non best-pick di K).
|
||||
|
||||
uv run python scripts/analysis/xs01_tranche_gate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
|
||||
from scripts.analysis.report_families import daily_from
|
||||
from scripts.analysis.xs01_tranche_research import xsec_trades
|
||||
from scripts.strategies.XS01_cross_sectional import aligned_panel, HOLD, POS, LEV
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from src.portfolio.sleeves import all_sleeve_equities
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
|
||||
def daily_equity_for(phases, M, ts):
|
||||
"""Trade di tutte le fasi su capitale comune (peso 1/K) -> equity daily."""
|
||||
K = len(phases)
|
||||
allt = sorted([t for ph in phases for t in xsec_trades(phase=ph, M=M)],
|
||||
key=lambda t: t[1])
|
||||
cap = 1000.0
|
||||
eq_ts, eq_v = [], []
|
||||
for i, j, net in allt:
|
||||
cap = max(cap + cap * POS * LEV * net / K, 10.0)
|
||||
eq_ts.append(ts[j])
|
||||
eq_v.append(cap)
|
||||
return daily_from(eq_ts, eq_v)
|
||||
|
||||
|
||||
def port_metrics(members, ids, clusters, caps):
|
||||
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
|
||||
w = W.weight_vector("cap", ids, dr, caps=caps, clusters=clusters)
|
||||
drp = port_returns({i: members[i] for i in ids}, w)
|
||||
return metrics(drp), metrics(drp, lo=SPLIT)
|
||||
|
||||
|
||||
def main():
|
||||
M = aligned_panel()
|
||||
ts = pd.to_datetime(M.index, unit="ms", utc=True)
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
print("=" * 92)
|
||||
print(" GATE — XS01 phase-tranching | equity daily, OOS da", OOS_DATE)
|
||||
print("=" * 92)
|
||||
|
||||
variants = {"base (fase 0)": [0],
|
||||
"K=2 (fasi 0,6)": [0, 6],
|
||||
"K=3 (fasi 0,4,8)": [0, 4, 8]}
|
||||
eqs = {k: daily_equity_for(v, M, ts) for k, v in variants.items()}
|
||||
|
||||
print(f"\n [1] STANDALONE daily — {'config':<18}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
|
||||
for k, e in eqs.items():
|
||||
r = e.pct_change().fillna(0.0)
|
||||
f, o = metrics(r), metrics(r, lo=SPLIT)
|
||||
print(f" {k:<22}{f['sharpe']:>8.2f}{f['dd']:>9.2f}{o['sharpe']:>8.2f}{o['dd']:>8.2f}")
|
||||
# range delle 12 fasi singole (daily): il rischio di fase che il tranching elimina
|
||||
fs, os_ = [], []
|
||||
for ph in range(HOLD):
|
||||
r = daily_equity_for([ph], M, ts).pct_change().fillna(0.0)
|
||||
fs.append(metrics(r)["sharpe"])
|
||||
os_.append(metrics(r, lo=SPLIT)["sharpe"])
|
||||
print(f" 12 fasi singole: FULL Sh {min(fs):.2f}-{max(fs):.2f} | OOS Sh {min(os_):.2f}-{max(os_):.2f}")
|
||||
|
||||
eq_base = dict(all_sleeve_equities())
|
||||
ids, cl, caps = list(p.sleeve_ids), p.clusters, p.caps
|
||||
print(f"\n [2] PORT06 swap-sleeve — {'config':<18}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
|
||||
f0, o0 = port_metrics(eq_base, ids, cl, caps)
|
||||
print(f" {'ATTUALE (base)':<22}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
|
||||
verdicts = []
|
||||
for k in ("K=2 (fasi 0,6)", "K=3 (fasi 0,4,8)"):
|
||||
mem = dict(eq_base)
|
||||
mem["XS01"] = eqs[k]
|
||||
f1, o1 = port_metrics(mem, ids, cl, caps)
|
||||
ok = (o1["sharpe"] >= o0["sharpe"] - 0.02 and o1["dd"] <= o0["dd"] + 1e-9
|
||||
and f1["sharpe"] >= f0["sharpe"] - 0.02 and f1["dd"] <= f0["dd"] + 1e-9)
|
||||
verdicts.append(ok)
|
||||
print(f" {k:<22}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}"
|
||||
f" {'OK' if ok else 'NO'}")
|
||||
print(f"\n => {'PROMOSSO (plateau K=2 e K=3)' if all(verdicts) else 'NON promosso (serve plateau su entrambi i K)'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""XS01 phase-tranching — PRE-REGISTRATO: il roll non-sovrapposto di XS01 (entry a i,
|
||||
exit a i+hold, re-entry) ha una FASE arbitraria (dipende dal primo indice valido).
|
||||
Se l'esito dipende dalla fase, c'e' timing-luck: dividere il book in K sub-book
|
||||
sfasati di hold/K barre (capitale 1/K ciascuno) e' un ensemble di fase che riduce
|
||||
la varianza SENZA parametri fittati (K=2 e K=3 riportati entrambi, nessun best-pick).
|
||||
|
||||
Test (griglia fissata qui, completa):
|
||||
[1] SENSIBILITA' DI FASE: xsec_sim base con offset di partenza 0..hold-1
|
||||
-> dispersione di Sharpe/ret/DD (FULL e OOS). Se la dispersione e' piccola,
|
||||
il tranching non serve (verdetto onesto, fine).
|
||||
[2] TRANCHING: K in {2, 3} sub-book sfasati equal-capital -> Sharpe/DD/ret
|
||||
FULL e OOS vs la MEDIA e il WORST delle fasi singole.
|
||||
Criterio (tutti necessari): il tranched riduce la dispersione (per costruzione) e
|
||||
il suo Sharpe OOS >= worst-fase OOS con DD <= mediana fasi; fee identiche (il
|
||||
tranching NON cambia il turnover per unita' di capitale).
|
||||
|
||||
uv run python scripts/analysis/xs01_tranche_research.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from scripts.strategies.XS01_cross_sectional import (
|
||||
aligned_panel, UNIVERSE, LB, HOLD, FEE_RT, LEV, POS, OOS_FRAC)
|
||||
|
||||
|
||||
def xsec_trades(phase: int = 0, lb: int = LB, hold: int = HOLD, fee_rt: float = FEE_RT,
|
||||
split_frac: float = 0.0, M=None):
|
||||
"""Replica ESATTA della logica di xsec_sim ma parte da max(lb, split)+phase e
|
||||
ritorna la lista trade (i, j, net) — stessa matematica, fee = 2*fee_rt."""
|
||||
C = M[UNIVERSE].values
|
||||
n = len(C)
|
||||
logC = np.log(C)
|
||||
split = int(n * split_frac)
|
||||
fee = 2 * fee_rt
|
||||
out = []
|
||||
last = -1
|
||||
i = max(lb, split) + phase
|
||||
while i < n - hold:
|
||||
if i <= last:
|
||||
i += 1
|
||||
continue
|
||||
dm = logC[i] - logC[i - lb]
|
||||
dm = dm - dm.mean()
|
||||
gw = np.sum(np.abs(dm))
|
||||
if gw < 1e-9:
|
||||
i += 1
|
||||
continue
|
||||
w = -dm / gw
|
||||
book = float(np.sum(w * (logC[i + hold] - logC[i])))
|
||||
out.append((i, i + hold, book - fee))
|
||||
last = i + hold
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def equity_from(trades, ts, pos=POS, lev=LEV, weight=1.0):
|
||||
"""Equity compounding a punti-exit (convenzione xsec_sim), peso per tranche."""
|
||||
cap = 1000.0
|
||||
eq_ts, eq_v = [], []
|
||||
for i, j, net in sorted(trades, key=lambda t: t[1]):
|
||||
cap = max(cap + cap * pos * lev * net * weight, 10.0)
|
||||
eq_ts.append(ts[j])
|
||||
eq_v.append(cap)
|
||||
return pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
|
||||
|
||||
|
||||
def metrics(trades, ts, yrs_span):
|
||||
rets = [t[2] * POS for t in trades]
|
||||
n = len(rets)
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
|
||||
eq = equity_from(trades, ts)
|
||||
pk = eq.cummax()
|
||||
dd = float(((pk - eq) / pk).max() * 100) if len(eq) else 0.0
|
||||
ret = float((eq.iloc[-1] / 1000 - 1) * 100) if len(eq) else 0.0
|
||||
return dict(n=n, ret=ret, sharpe=sharpe, dd=dd)
|
||||
|
||||
|
||||
def combined_metrics(branches, ts, yrs_span):
|
||||
"""K tranche -> un'unica equity: pesa ogni trade 1/K sul capitale comune."""
|
||||
K = len(branches)
|
||||
allt = sorted([t for b in branches for t in b], key=lambda t: t[1])
|
||||
cap = 1000.0
|
||||
eq_ts, eq_v, rets = [], [], []
|
||||
for i, j, net in allt:
|
||||
rets.append(net * POS / K)
|
||||
cap = max(cap + cap * POS * LEV * net / K, 10.0)
|
||||
eq_ts.append(ts[j])
|
||||
eq_v.append(cap)
|
||||
eq = pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
|
||||
pk = eq.cummax()
|
||||
n = len(rets)
|
||||
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
|
||||
return dict(n=n, ret=float((eq.iloc[-1] / 1000 - 1) * 100),
|
||||
sharpe=sharpe, dd=float(((pk - eq) / pk).max() * 100))
|
||||
|
||||
|
||||
def run():
|
||||
M = aligned_panel()
|
||||
ts = pd.to_datetime(M.index, unit="ms", utc=True)
|
||||
n = len(M)
|
||||
print("=" * 96)
|
||||
print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata")
|
||||
print("=" * 96)
|
||||
for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)):
|
||||
yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1
|
||||
rows = []
|
||||
for ph in range(HOLD):
|
||||
tr = xsec_trades(phase=ph, split_frac=split, M=M)
|
||||
rows.append(metrics(tr, ts, yrs_span))
|
||||
sh = np.array([r["sharpe"] for r in rows])
|
||||
dd = np.array([r["dd"] for r in rows])
|
||||
rt = np.array([r["ret"] for r in rows])
|
||||
print(f"\n [{tag}] sensibilita' di fase (12 fasi):")
|
||||
print(f" Sharpe: min {sh.min():.2f} | med {np.median(sh):.2f} | max {sh.max():.2f} | std {sh.std():.2f}")
|
||||
print(f" ret%: min {rt.min():+.0f} | med {np.median(rt):+.0f} | max {rt.max():+.0f}")
|
||||
print(f" DD%: min {dd.min():.1f} | med {np.median(dd):.1f} | max {dd.max():.1f}")
|
||||
for K in (2, 3):
|
||||
branches = [xsec_trades(phase=int(p), split_frac=split, M=M)
|
||||
for p in np.linspace(0, HOLD, K, endpoint=False)]
|
||||
m = combined_metrics(branches, ts, yrs_span)
|
||||
print(f" K={K} tranched: Sharpe {m['sharpe']:.2f} | ret {m['ret']:+.0f}% | "
|
||||
f"DD {m['dd']:.1f}% | trade {m['n']}")
|
||||
print("\n criterio: tranched-Sharpe OOS >= worst-fase OOS e DD <= mediana fasi; "
|
||||
"se la dispersione di fase e' gia' piccola -> NON SERVE (verdetto onesto).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user