26f8d27a61
Goal: migliorare la strategia short-vol (famiglia Albimarini/VRP01) e proteggerla dai DD. Workflow 26 agenti (7 filoni + 2 lenti avversariali + scettico incrociato). Esito: NON migliorabile; la protezione DD si compra SOLO con la size. - Griglia 288 strutture: nessuna batte VRP01 (DSR 0.000; meta' griglia = 3a occorrenza "0-perdite/Sharpe implausibile" dopo CC01/ALB-A). - 4 overlay DD (exit-spike/SL-MTM/ala-coda/cooldown): 4/4 REFUTED dal null de-levering — la protezione crash vive gia' nel gate IV-rank. - Gate nuovi: 4o fallimento su 4 (l'alpha e' il binario IV-rank>0.30). - Sizing: 12% deploy ~ 0.27 Kelly onesto (anti-rovina); disambiguazione unita' obbligatoria vs 12% peso book (0.014 Kelly, fattore 19x). - Gate term-structure VIX/VXV su SPX (dSh +0.90, DSR 0.992) = confound di modello al 100% -> nuova regola: riprezzare term-structure-consistent prima di credere a un gate vol su strutture BS-flat. - ANCHOR-AUDIT VRP01 CHIUSO: primo sleeve SENZA luck (fase canonica = peggiore delle 7 -> numeri di ammissione conservativi). Audit anchor ora completo su 4/4 sleeve ancorati. Book/pesi INVARIATI. Nessun nuovo sleeve. 168 test verdi. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
485 lines
24 KiB
Python
485 lines
24 KiB
Python
#!/usr/bin/env python
|
|
"""r0703_vrpimp_anchor.py — FILONE 5: AUDIT ANCHOR-LUCK di VRP01 (le 7 fasi settimanali).
|
|
|
|
VRP01 apre un put credit spread ogni 7 GIORNI-INDICE a partire da i=60 del join px/DVOL
|
|
(gap-free dal 2021-03-24) e lo tiene a scadenza. L'ancora e' quindi il GIORNO DELLA SETTIMANA
|
|
di apertura: 7 fasi a priori (i0 = 60+phase, phase in 0..6). E' l'unico sleeve ancorato non
|
|
ancora auditato dopo TP01 (24 ancore, P=0.86), XS01 (10 fasi) e SKH01 (23 offset).
|
|
|
|
Domande (ricerca di PROTEZIONE, non di alpha):
|
|
0. REPLICA bit-exact della sleeve canonica (_vrp_combo_returns) prima di tutto.
|
|
1. Banda delle 7 fasi a parametri IDENTICI (gate IV-rank/VRP ricalcolati causali per fase,
|
|
automatico: i gate si valutano all'indice d'ingresso della fase): Sh FULL/HOLD, maxDD,
|
|
worst-week, per-anno. Percentile della fase canonica.
|
|
2. Banda f skew-caveat {0.6, 0.8, 1.0, 1.3} (pricing BS flat su DVOL ATM).
|
|
3. ENSEMBLE delle 7 fasi (1/7 ciascuna) + null del DE-LEVERING (lezione TP01xDVOL:
|
|
una riduzione DD replicabile scalando la size non vale nulla).
|
|
4. Block-bootstrap P(spike) alla maniera dello scettico TP01 (r0702_skeptic_offset.py):
|
|
lo spike della fase canonica e' speciale o e' il massimo atteso di 7 stime correlate?
|
|
5. Impatto sul book 5-sleeve (TP 33/XS 15/VRP 12/SKH 20/GTAA 20, combine_outer) alla fase
|
|
canonica / mediana / peggiore / ensemble.
|
|
6. Ri-verdetto dei numeri di ammissione (FULL 1.10 / HOLD 0.60 / DD 12%) alla fase mediana.
|
|
|
|
Macchineria RIUSATA, non riscritta: helper del pricing e config presi in sola lettura da
|
|
src/portfolio/sleeves (_bs_put, _strike_from_delta, VRP_CFG) — il motore replica quindi la
|
|
sleeve canonica per costruzione, e la replica e' VERIFICATA bit-exact in PART 0. (Il motore
|
|
r0702_alb_structure usa load_tf di research_lab: qui serve il path della sleeve, resample_1d
|
|
su load_data, per la bit-exactness.) Nessun file di produzione toccato. Nessuna selezione
|
|
sull'hold-out (nessuna grid: 7 fasi = famiglia a priori, parametri congelati).
|
|
|
|
Regole standing rispettate: niente short-vol da modello in deploy (esito = conoscenza);
|
|
niente '7D' con origin (qui il ciclo e' index-based come il canonico, niente resample
|
|
settimanale); pandas 2.x niente .view('int64').
|
|
|
|
uv run python scripts/research/r0703_vrpimp_anchor.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
ROOT = Path("/opt/docker/PythagorasGoal")
|
|
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
import altlib as al # noqa: E402 (harness di progetto: _sh/_dd_ret/HOLDOUT)
|
|
from src.data.downloader import load_data # noqa: E402
|
|
from src.strategies.trend_portfolio import resample_1d # noqa: E402
|
|
from src.portfolio.sleeves import ( # noqa: E402 (SOLA LETTURA)
|
|
VRP_CFG, _bs_put, _strike_from_delta, _vrp_weekly_asset, _vrp_combo_returns, _HL_DIR)
|
|
from src.portfolio.portfolio import combine_outer, to_daily # noqa: E402
|
|
|
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
|
WKY = 365.25 / 7.0
|
|
F_BAND = (0.6, 0.8, 1.0, 1.3)
|
|
PHASES = tuple(range(7))
|
|
ASSETS = ("BTC", "ETH")
|
|
B_BOOT = 4000
|
|
DAYNAME = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
ADMISSION = dict(full=1.10, hold=0.60, dd=0.12) # numeri dichiarati in CLAUDE.md
|
|
|
|
|
|
# ===========================================================================
|
|
# motore per-fase (specchio 1:1 di sleeves._vrp_weekly_asset, param i0 e f)
|
|
# ===========================================================================
|
|
@lru_cache(maxsize=4)
|
|
def joined(asset: str) -> pd.DataFrame:
|
|
"""Stesso join px/DVOL della sleeve canonica (resample_1d su load_data 1h)."""
|
|
df = resample_1d(load_data(asset, "1h"))
|
|
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
|
|
if s.index.tz is None:
|
|
s.index = s.index.tz_localize("UTC")
|
|
dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet")
|
|
d = pd.Series(dv["close"].values.astype(float),
|
|
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
|
|
return pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna()
|
|
|
|
|
|
def vrp_weekly_phase(asset: str, phase: int = 0, f: float = 1.0) -> pd.Series:
|
|
"""Replica ESATTA di _vrp_weekly_asset con i0 = 60+phase e fattore premio f.
|
|
I gate (VRP>0, IV-rank espandente, crash-skip) sono ricalcolati CAUSALI all'indice
|
|
d'ingresso di ciascuna fase (dvf[:i]) — nessun riuso della sequenza gate di fase 0."""
|
|
J = joined(asset)
|
|
px = J["px"].values; dvf = J["dvol"].values / 100.0; idx = J.index
|
|
n = len(px); cfg = VRP_CFG; tn = cfg["tenor_d"]; T = tn / 365.25
|
|
rets = {}
|
|
i = 60 + phase
|
|
while i + tn < n:
|
|
S0 = px[i]; sig = dvf[i]
|
|
skip = False
|
|
if cfg["gate_vrp"] and i >= 31: # VRP>0 causale
|
|
rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25)
|
|
if (sig - rv) <= 0:
|
|
skip = True
|
|
if not skip and (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60:
|
|
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente
|
|
if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]:
|
|
skip = True
|
|
if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]:
|
|
skip = True
|
|
if skip:
|
|
rets[idx[i + tn]] = 0.0; i += tn; continue
|
|
Ks = _strike_from_delta(S0, T, sig, cfg["short_delta"])
|
|
Kl = _strike_from_delta(S0, T, sig, cfg["long_delta"])
|
|
net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * f
|
|
S1 = px[i + tn]
|
|
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
|
pnl = net_prem - payoff - cfg["fee_frac"] * abs(net_prem)
|
|
rets[idx[i + tn]] = pnl / Ks
|
|
i += tn
|
|
return pd.Series(rets)
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def combo_weekly(phase: int, f: float = 1.0) -> pd.Series:
|
|
"""Book 50/50 BTC+ETH per fase (stessa inner-join su date di scadenza del canonico)."""
|
|
rB = vrp_weekly_phase("BTC", phase, f)
|
|
rE = vrp_weekly_phase("ETH", phase, f)
|
|
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
|
|
|
|
|
def to_daily_lumped(wk: pd.Series) -> pd.Series:
|
|
"""Convenzione _vrp_combo_returns: lump del rendimento settimanale sul giorno di scadenza."""
|
|
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(),
|
|
freq="1D", tz="UTC")
|
|
daily = pd.Series(0.0, index=days)
|
|
daily.loc[wk.index.normalize()] = wk.values
|
|
return daily
|
|
|
|
|
|
# ===========================================================================
|
|
# metriche
|
|
# ===========================================================================
|
|
def m_wk(r: pd.Series) -> dict:
|
|
"""Metriche sul ciclo settimanale naturale (convenzione dei numeri di ammissione)."""
|
|
r = r.dropna()
|
|
if len(r) < 3 or r.std() == 0:
|
|
return dict(sh=0.0, sh_h=0.0, cagr=0.0, dd=0.0, worst=0.0, act=0.0, n=len(r))
|
|
def _s(x):
|
|
return float(x.mean() / x.std() * np.sqrt(WKY)) if len(x) > 2 and x.std() > 0 else 0.0
|
|
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
|
yrs = len(r) / WKY
|
|
return dict(sh=_s(r), sh_h=_s(r[r.index >= HOLDOUT]),
|
|
cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else -1.0,
|
|
dd=float(np.max((pk - eq) / pk)), worst=float(r.min()),
|
|
act=float((r != 0.0).mean()), n=int(len(r)))
|
|
|
|
|
|
def sh_daily(s: pd.Series) -> float:
|
|
return al._sh(s)
|
|
|
|
|
|
def worst_7d(daily: pd.Series) -> float:
|
|
"""Peggior finestra di 7 giorni compounding, ancor-free (per l'ensemble)."""
|
|
lr = np.log1p(daily.values)
|
|
if len(lr) < 8:
|
|
return 0.0
|
|
cs = np.concatenate([[0.0], np.cumsum(lr)])
|
|
w = cs[7:] - cs[:-7]
|
|
return float(np.expm1(w.min()))
|
|
|
|
|
|
def pctl_of(vals: np.ndarray, v0: float) -> float:
|
|
return float(((vals < v0).sum() + 0.5 * (vals == v0).sum()) / len(vals) * 100.0)
|
|
|
|
|
|
def per_year(r: pd.Series) -> dict:
|
|
return {int(y): float(np.prod(1 + g.values) - 1) for y, g in r.groupby(r.index.year)}
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 0 — replica bit-exact
|
|
# ===========================================================================
|
|
def part0() -> None:
|
|
print("=" * 100)
|
|
print("PART 0 — REPLICA della sleeve canonica (sanity bit-exact vs sleeves._vrp_combo_returns)")
|
|
print("=" * 100)
|
|
for a in ASSETS:
|
|
mine = vrp_weekly_phase(a, phase=0, f=VRP_CFG["f"])
|
|
ref = _vrp_weekly_asset(a)
|
|
assert len(mine) == len(ref), f"P0 len mismatch {a}: {len(mine)} vs {len(ref)}"
|
|
assert (mine.index == ref.index).all(), f"P0 index mismatch {a}"
|
|
dmax = float(np.max(np.abs(mine.values - ref.values)))
|
|
assert dmax == 0.0, f"P0 valori non bit-exact {a}: max|diff|={dmax:.2e}"
|
|
print(f" [{a}] settimanale fase-0 == _vrp_weekly_asset: bit-exact "
|
|
f"(n={len(mine)}, max|diff|={dmax:.1e})")
|
|
mine_d = to_daily_lumped(combo_weekly(0, VRP_CFG["f"]))
|
|
ref_d = _vrp_combo_returns()
|
|
assert len(mine_d) == len(ref_d) and (mine_d.index == ref_d.index).all(), "P0 grid daily"
|
|
dmax = float(np.max(np.abs(mine_d.values - ref_d.values)))
|
|
assert dmax == 0.0, f"P0 combo daily non bit-exact: {dmax:.2e}"
|
|
print(f" [COMBO] daily-lumped fase-0 == _vrp_combo_returns: bit-exact (n={len(mine_d)})")
|
|
for a in ASSETS:
|
|
J = joined(a)
|
|
gaps = J.index.to_series().diff().dt.total_seconds().dropna() / 86400.0
|
|
print(f" [{a}] join px/DVOL: {len(J)} giorni {J.index.min().date()} -> "
|
|
f"{J.index.max().date()}, gap>1g: {int((gaps > 1).sum())} "
|
|
f"(gap-free -> ogni fase = giorno-della-settimana FISSO)")
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 1 — le 7 fasi a parametri identici (f=1.0)
|
|
# ===========================================================================
|
|
def part1() -> dict:
|
|
print("\n" + "=" * 100)
|
|
print("PART 1 — LE 7 FASI (parametri canonici, f=1.0). Ancora canonica = fase 0.")
|
|
print("=" * 100)
|
|
rows = {}
|
|
print(f" {'fase':<6}{'apre':>5}{'n_wk':>6}{'att%':>6}{'ShFULL':>8}{'ShHOLD':>8}"
|
|
f"{'CAGR':>8}{'maxDD':>8}{'worst-wk':>9}")
|
|
for p in PHASES:
|
|
wk = combo_weekly(p, 1.0)
|
|
entry_wd = int(pd.DatetimeIndex(wk.index - pd.Timedelta(days=VRP_CFG["tenor_d"]))
|
|
.dayofweek.min())
|
|
wd_n = pd.DatetimeIndex(wk.index).dayofweek.nunique()
|
|
mm = m_wk(wk)
|
|
rows[p] = dict(mm, entry_wd=entry_wd, wd_n=wd_n, wk=wk)
|
|
tag = " <- CANONICA" if p == 0 else ""
|
|
print(f" {p:<6}{DAYNAME[entry_wd]:>5}{mm['n']:>6}{mm['act']*100:>5.0f}%"
|
|
f"{mm['sh']:>8.2f}{mm['sh_h']:>8.2f}{mm['cagr']*100:>+7.1f}%"
|
|
f"{mm['dd']*100:>7.1f}%{mm['worst']*100:>+8.2f}%{tag}")
|
|
assert wd_n == 1, f"fase {p}: weekday non unico ({wd_n})"
|
|
|
|
for key, label in (("sh", "Sh FULL"), ("sh_h", "Sh HOLD"), ("dd", "maxDD"),
|
|
("worst", "worst-week"), ("cagr", "CAGR")):
|
|
v = np.array([rows[p][key] for p in PHASES])
|
|
print(f" banda {label:<11}: min {v.min():+.3f} / mediana {np.median(v):+.3f} / "
|
|
f"max {v.max():+.3f} | canonica {v[0]:+.3f} "
|
|
f"(pctl {pctl_of(v, v[0]):.0f}° su 7 fasi)")
|
|
|
|
print("\n per-anno (f=1.0) — dispersione di fase attraverso il 2022 (LUNA/FTX):")
|
|
yrs = sorted({y for p in PHASES for y in per_year(rows[p]["wk"])})
|
|
print(" fase " + "".join(f"{y:>9}" for y in yrs))
|
|
for p in PHASES:
|
|
py = per_year(rows[p]["wk"])
|
|
print(f" {p:<6}" + "".join(f"{py.get(y, float('nan'))*100:>+8.1f}%" for y in yrs))
|
|
return rows
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 2 — banda f (caveat skew: pricing BS flat su DVOL ATM)
|
|
# ===========================================================================
|
|
def part2() -> None:
|
|
print("\n" + "=" * 100)
|
|
print("PART 2 — BANDA f {0.6, 0.8, 1.0, 1.3} x 7 fasi (skew non esplicito -> banda, non punto)")
|
|
print("=" * 100)
|
|
print(f" {'f':>5} | {'ShFULL min/med/max':>22} {'(canon)':>8} | {'ShHOLD min/med/max':>22} "
|
|
f"{'(canon)':>8} | {'maxDD min/med/max':>20} {'(canon)':>8} | {'worst min':>9}")
|
|
for f in F_BAND:
|
|
mm = [m_wk(combo_weekly(p, f)) for p in PHASES]
|
|
sf = np.array([m["sh"] for m in mm]); shh = np.array([m["sh_h"] for m in mm])
|
|
dd = np.array([m["dd"] for m in mm]); wo = np.array([m["worst"] for m in mm])
|
|
print(f" {f:>5.1f} | {sf.min():>6.2f} {np.median(sf):>6.2f} {sf.max():>6.2f} "
|
|
f"{sf[0]:>7.2f} | {shh.min():>6.2f} {np.median(shh):>6.2f} {shh.max():>6.2f} "
|
|
f"{shh[0]:>7.2f} | {dd.min()*100:>5.1f} {np.median(dd)*100:>5.1f} "
|
|
f"{dd.max()*100:>5.1f}% {dd[0]*100:>6.1f}% | {wo.min()*100:>+8.2f}%")
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 3 — ensemble delle 7 fasi + null del de-levering
|
|
# ===========================================================================
|
|
def phase_matrix(f: float = 1.0) -> pd.DataFrame:
|
|
cols = {f"p{p}": to_daily_lumped(combo_weekly(p, f)) for p in PHASES}
|
|
M = pd.concat(cols, axis=1, join="outer").sort_index()
|
|
lo = max(c.index.min() for c in cols.values())
|
|
hi = min(c.index.max() for c in cols.values())
|
|
return M.loc[(M.index >= lo) & (M.index <= hi)].fillna(0.0)
|
|
|
|
|
|
def part3(rows: dict) -> dict:
|
|
print("\n" + "=" * 100)
|
|
print("PART 3 — ENSEMBLE delle 7 fasi (1/7 ciascuna) + NULL DEL DE-LEVERING")
|
|
print("=" * 100)
|
|
M = phase_matrix(1.0)
|
|
ens = M.mean(axis=1)
|
|
can = M["p0"]
|
|
def _stats(s, name):
|
|
dd = al._dd_ret(s)
|
|
out = dict(sh=sh_daily(s), sh_h=sh_daily(s[s.index >= HOLDOUT]), dd=dd,
|
|
w7=worst_7d(s),
|
|
cagr=float(np.prod(1 + s.values) ** (365.25 / len(s)) - 1))
|
|
print(f" {name:<28} ShFULL {out['sh']:>5.2f} ShHOLD {out['sh_h']:>5.2f} "
|
|
f"CAGR {out['cagr']*100:>+5.1f}% maxDD {out['dd']*100:>5.1f}% "
|
|
f"worst-7g {out['w7']*100:>+6.2f}%")
|
|
return out
|
|
print(" (griglia daily comune alle 7 fasi; Sharpe daily ~= Sharpe weekly del lump)")
|
|
s_can = _stats(can, "CANONICA fase-0")
|
|
s_ens = _stats(ens, "ENSEMBLE 7 fasi (1/7)")
|
|
|
|
# NULL DEL DE-LEVERING: lo Sharpe e' scale-free -> scalare la canonica non lo cambia.
|
|
# Cerco lambda t.c. maxDD(lam*canonica) ~= maxDD(ensemble) e confronto CAGR a pari DD.
|
|
lams = np.linspace(0.30, 1.00, 141)
|
|
dds = np.array([al._dd_ret(can * lam) for lam in lams])
|
|
k = int(np.argmin(np.abs(dds - s_ens["dd"])))
|
|
lam = float(lams[k]); scaled = can * lam
|
|
cagr_sc = float(np.prod(1 + scaled.values) ** (365.25 / len(scaled)) - 1)
|
|
print(f"\n NULL de-levering: canonica x lambda={lam:.2f} -> maxDD {dds[k]*100:.1f}% "
|
|
f"(~ ensemble {s_ens['dd']*100:.1f}%), Sharpe INVARIATO {sh_daily(scaled):.2f}, "
|
|
f"CAGR {cagr_sc*100:+.1f}% (ensemble {s_ens['cagr']*100:+.1f}%)")
|
|
# LENTE ONESTA sul numero dell'ensemble: lo Sharpe daily (sopra) e' GONFIATO dalla
|
|
# convenzione del lump — le 7 fasi lumpano su giorni DISGIUNTI (corr daily ~0 per
|
|
# costruzione) ma i 7 spread concorrenti condividono ~6/7 dell'esposizione economica.
|
|
# Ricompound a blocchi di 7 giorni (tutti gli offset) = P&L settimanale del libro
|
|
# sfalsato con la correlazione cross-fase RIPRISTINATA.
|
|
def sh_7d_blocks(s: pd.Series) -> np.ndarray:
|
|
lr = np.log1p(s.values)
|
|
shs = []
|
|
for o in range(7):
|
|
m = (len(lr) - o) // 7
|
|
if m < 30:
|
|
continue
|
|
w = np.expm1(lr[o:o + 7 * m].reshape(m, 7).sum(axis=1))
|
|
shs.append(float(w.mean() / w.std() * np.sqrt(WKY)) if w.std() > 0 else 0.0)
|
|
return np.array(shs)
|
|
print("\n LENTE ONESTA (Sharpe su blocchi 7g non-overlap, banda sui 7 offset di blocco):")
|
|
for name, s in (("CANONICA fase-0", can), ("ENSEMBLE 7 fasi", ens)):
|
|
vf = sh_7d_blocks(s); vh = sh_7d_blocks(s[s.index >= HOLDOUT])
|
|
print(f" {name:<20} FULL min/med/max {vf.min():.2f}/{np.median(vf):.2f}/{vf.max():.2f}"
|
|
+ (f" | HOLD {vh.min():.2f}/{np.median(vh):.2f}/{vh.max():.2f}" if len(vh) else ""))
|
|
print(" -> il numero citabile dell'ensemble e' quello a blocchi 7g, NON lo Sh daily del lump.")
|
|
print(" NB: anche il maxDD (canonica E ensemble) e' su equity a date di REGOLAMENTO —")
|
|
print(" il mark-to-market intra-settimana degli spread aperti non e' catturato (caveat noto).")
|
|
beats = (s_ens["sh"] > s_can["sh"] + 1e-9) and (s_ens["cagr"] > cagr_sc + 1e-9)
|
|
print(f"\n -> l'ensemble batte il de-levering? {'SI' if beats else 'NO'} "
|
|
f"(serve Sh_ens > Sh_can E CAGR_ens > CAGR de-levered a pari DD; il CAGR e' "
|
|
f"convention-free, lo Sh daily dell'ensemble NO — vedi lente onesta)")
|
|
print(" NB deploy: a $600-2k un solo spread min-size satura il libro -> 7 tranche da 1/7")
|
|
print(" NON sono eseguibili (stesso muro del tranching TP01); l'ensemble e' una LENTE di")
|
|
print(" misura (de-lucking della stima), non una modifica proponibile del book.")
|
|
return dict(ens=s_ens, can=s_can, lam=lam, cagr_scaled=cagr_sc, beats=beats)
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 4 — block-bootstrap P(spike) (metodo r0702_skeptic_offset)
|
|
# ===========================================================================
|
|
def _sh_mat(R: np.ndarray) -> np.ndarray:
|
|
mu = R.mean(axis=1); sd = R.std(axis=1)
|
|
with np.errstate(divide="ignore", invalid="ignore"):
|
|
out = np.where(sd > 0, mu / sd, 0.0)
|
|
return np.nan_to_num(out) * np.sqrt(365.25)
|
|
|
|
|
|
def block_boot(M: np.ndarray, B: int, block: int, rng) -> dict:
|
|
n, K = M.shape
|
|
nblocks = int(np.ceil(n / block))
|
|
g0s, gmaxs = [], []
|
|
done = 0
|
|
while done < B:
|
|
b = min(500, B - done)
|
|
starts = rng.integers(0, n, size=(b, nblocks))
|
|
idx = (starts[:, :, None] + np.arange(block)[None, None, :]) % n
|
|
idx = idx.reshape(b, -1)[:, :n]
|
|
R = M[idx]
|
|
Sh = np.stack([_sh_mat(R[:, :, k]) for k in range(K)], axis=1)
|
|
med_others = np.empty_like(Sh)
|
|
for h in range(K):
|
|
others = np.delete(Sh, h, axis=1)
|
|
med_others[:, h] = np.median(others, axis=1)
|
|
g = Sh - med_others
|
|
g0s.append(g[:, 0]); gmaxs.append(g.max(axis=1))
|
|
done += b
|
|
return dict(g0=np.concatenate(g0s), gmax=np.concatenate(gmaxs))
|
|
|
|
|
|
def part4() -> dict:
|
|
print("\n" + "=" * 100)
|
|
print("PART 4 — BLOCK-BOOTSTRAP: lo spike della fase canonica e' speciale o massimo atteso di 7?")
|
|
print("=" * 100)
|
|
M = phase_matrix(1.0)
|
|
out = {}
|
|
for wname, W in (("HOLD-OUT 2025+", M[M.index >= HOLDOUT]),
|
|
("FULL 2021-26", M)):
|
|
Mv = W.values
|
|
sh = _sh_mat(Mv.T)
|
|
g0 = float(sh[0] - np.median(sh[1:]))
|
|
corr = np.corrcoef(Mv.T); iu = np.triu_indices(len(PHASES), 1)
|
|
print(f"\n [{wname}] {Mv.shape[0]} giorni | Sh fase-0 {sh[0]:+.3f}, mediana altre "
|
|
f"{np.median(sh[1:]):+.3f}, spike g0 = {g0:+.3f} | corr daily fra fasi: "
|
|
f"mediana {np.median(corr[iu]):+.3f} (lump su giorni diversi -> quasi-ortogonali "
|
|
f"per costruzione)")
|
|
for blk in (14, 28, 56):
|
|
bs = block_boot(Mv, B_BOOT, blk, np.random.default_rng(73 + blk))
|
|
p_any = float(np.mean(bs["gmax"] >= g0))
|
|
p_le0 = float(np.mean(bs["g0"] <= 0.0))
|
|
ci = np.percentile(bs["g0"], [2.5, 97.5])
|
|
print(f" block={blk:>2}: P(spike di UNA QUALSIASI fase >= {g0:+.2f}) = {p_any:.3f}"
|
|
f" | P(g0<=0) = {p_le0:.3f} | CI95 g0 [{ci[0]:+.2f},{ci[1]:+.2f}]")
|
|
out[(wname, blk)] = p_any
|
|
# finestre annuali: la fase migliore e' stabile o gira?
|
|
print("\n finestre annuali — Sh per fase, pctl della canonica, best-fase:")
|
|
print(f" {'finestra':<9}" + "".join(f"{'p'+str(p):>8}" for p in PHASES)
|
|
+ f"{'pctl p0':>9}{'best':>6}")
|
|
idx_years = sorted(set(M.index.year))
|
|
for y in idx_years:
|
|
W = M[M.index.year == y]
|
|
if len(W) < 60:
|
|
continue
|
|
sh = _sh_mat(W.values.T)
|
|
print(f" {y:<9}" + "".join(f"{v:>8.2f}" for v in sh)
|
|
+ f"{pctl_of(sh, sh[0]):>8.0f}°{int(np.argmax(sh)):>6}")
|
|
return out
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 5 — impatto sul book 5-sleeve
|
|
# ===========================================================================
|
|
def part5(rows: dict) -> dict:
|
|
print("\n" + "=" * 100)
|
|
print("PART 5 — BOOK 5-SLEEVE (TP 33/XS 15/VRP 12/SKH 20/GTAA 20) con VRP01 per fase")
|
|
print("=" * 100)
|
|
from src.portfolio.sleeves import (_gtaa_daily_returns, _skyhook_returns,
|
|
_tp01_returns, _xsec_returns)
|
|
tp = to_daily(_tp01_returns())
|
|
fixed = dict(TP=tp, XS=to_daily(_xsec_returns()), SKH=to_daily(_skyhook_returns()),
|
|
GTAA=to_daily(_gtaa_daily_returns()))
|
|
W = dict(TP=0.33, XS=0.15, VRP=0.12, SKH=0.20, GTAA=0.20)
|
|
lo = tp.index.min()
|
|
|
|
def book(vrp_daily):
|
|
s = combine_outer(dict(fixed, VRP=vrp_daily), W, lo=lo)
|
|
return dict(sh=al._sh(s), sh_h=al._sh(s[s.index >= HOLDOUT]), dd=al._dd_ret(s),
|
|
dd_h=al._dd_ret(s[s.index >= HOLDOUT]))
|
|
|
|
res = {}
|
|
print(f" {'variante VRP':<22}{'ShFULL':>8}{'ShHOLD':>8}{'maxDD':>8}{'DD hold':>9}")
|
|
for p in PHASES:
|
|
res[p] = book(to_daily_lumped(rows[p]["wk"]))
|
|
tag = " <- CANONICA (book corrente)" if p == 0 else ""
|
|
print(f" fase {p} ({DAYNAME[rows[p]['entry_wd']]}){'':<10}{res[p]['sh']:>8.2f}"
|
|
f"{res[p]['sh_h']:>8.2f}{res[p]['dd']*100:>7.1f}%{res[p]['dd_h']*100:>8.1f}%{tag}")
|
|
M = phase_matrix(1.0)
|
|
res["ens"] = book(M.mean(axis=1))
|
|
print(f" {'ENSEMBLE 7 fasi':<22}{res['ens']['sh']:>8.2f}{res['ens']['sh_h']:>8.2f}"
|
|
f"{res['ens']['dd']*100:>7.1f}%{res['ens']['dd_h']*100:>8.1f}%")
|
|
|
|
for key, label in (("sh", "ShFULL"), ("sh_h", "ShHOLD"), ("dd", "maxDD")):
|
|
v = np.array([res[p][key] for p in PHASES])
|
|
print(f" banda book {label:<7}: min {v.min():.3f} / mediana {np.median(v):.3f} / "
|
|
f"max {v.max():.3f} | canonica {v[0]:.3f} (pctl {pctl_of(v, v[0]):.0f}°)")
|
|
print(" (il peso VRP e' 12%: la banda di fase si diluisce ~0.12 nel book — atteso stretto)")
|
|
return res
|
|
|
|
|
|
# ===========================================================================
|
|
# PART 6 — ri-verdetto dei numeri di ammissione alla fase mediana
|
|
# ===========================================================================
|
|
def part6(rows: dict) -> None:
|
|
print("\n" + "=" * 100)
|
|
print("PART 6 — RI-VERDETTO dei numeri di ammissione VRP01 (FULL 1.10 / HOLD 0.60 / DD 12%)")
|
|
print("=" * 100)
|
|
sf = np.array([rows[p]["sh"] for p in PHASES])
|
|
shh = np.array([rows[p]["sh_h"] for p in PHASES])
|
|
dd = np.array([rows[p]["dd"] for p in PHASES])
|
|
wo = np.array([rows[p]["worst"] for p in PHASES])
|
|
print(f" Sh FULL : dichiarato {ADMISSION['full']:.2f} | canonica {sf[0]:.2f} "
|
|
f"(pctl {pctl_of(sf, sf[0]):.0f}°) | mediana fasi {np.median(sf):.2f} | "
|
|
f"banda [{sf.min():.2f}, {sf.max():.2f}]")
|
|
print(f" Sh HOLD : dichiarato {ADMISSION['hold']:.2f} | canonica {shh[0]:.2f} "
|
|
f"(pctl {pctl_of(shh, shh[0]):.0f}°) | mediana fasi {np.median(shh):.2f} | "
|
|
f"banda [{shh.min():.2f}, {shh.max():.2f}]")
|
|
print(f" maxDD : dichiarato {ADMISSION['dd']*100:.0f}% | canonica {dd[0]*100:.1f}% "
|
|
f"(pctl {pctl_of(dd, dd[0]):.0f}°) | mediana fasi {np.median(dd)*100:.1f}% | "
|
|
f"banda [{dd.min()*100:.1f}%, {dd.max()*100:.1f}%]")
|
|
print(f" worst-wk: canonica {wo[0]*100:+.2f}% | mediana {np.median(wo)*100:+.2f}% | "
|
|
f"peggiore {wo.min()*100:+.2f}%")
|
|
print("\n Regola 2026-07-02: i numeri di VRP01 si citano d'ora in poi CON la banda di fase.")
|
|
|
|
|
|
def main() -> None:
|
|
part0()
|
|
rows = part1()
|
|
part2()
|
|
part3(rows)
|
|
part4()
|
|
part5(rows)
|
|
part6(rows)
|
|
print("\nFatto (r0703_vrpimp_anchor).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|