Files
PythagorasGoal/scripts/research/r0701_portfolio_skeptic.py
T
Adriano Dal Pastro 491411ac77 research(wave-0701): 6 filoni multi-agente — 0 nuovi sleeve, pesi confermati, gate weights_tilt_null
Ondata onesta su angoli non coperti: funding-TS (chiude il filone funding su 3
lati), breadth alt (non-ridondante ma DSR 0.43, rivisitabile con storia),
XS-residmom (REDUNDANT), pesi+guardia-DD (EW-STR refutato dallo scettico come
selezione-sull'hold-out di 2° ordine, firma best-of-15), VRP-refine (filone
esaurito), stagionalità-XS (morta allo step statistico).

Lezione codificata: weights_tilt_null + combine_outer in src/portfolio
(ogni cambio-pesi vs null di tilt casuali cap-respecting + delta in-sample>=0);
5 test nuovi, suite 165/165.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:21:59 +00:00

305 lines
16 KiB
Python

"""r0701_portfolio_skeptic — VERIFICA AVVERSARIALE del tilt EW-STR (TP30/XS25/VRP15/SKH30).
Oggetto: la proposta di r0701_portfolio_opt (diario 2026-07-01-portfolio-weights-ddguard.md) di
spostare i pesi del portafoglio a 4 sleeve da CURRENT (41.25/18.75/15/25) a EW-STR (30/25/15/30).
Claim: HOLD Sh 2.21->2.35, HOLD CAGR +16.0->+19.7%, multi-cut +0.06/+0.13/+0.14, 7/8 anni vinti.
Linee di attacco (agente scettico):
1. RIPRODUZIONE indipendente via src.portfolio.StrategyPortfolio (path di PRODUZIONE, non lo
script dell'agente).
2. SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE via SKH01/XS01: SKH01 fu ammesso (2026-06-23) perche'
alzava l'hold-out 2025-26; XS01 fu affinato (blend+gate) conoscendo l'hold-out. Un tilt verso
di loro eredita quella selezione? Test: (a) per-anno SOLO 2019-2024; (b) tilt scomposti
(solo-XS con SKH pinned 25, solo-SKH con XS pinned); (c) finestre OOS DISGIUNTE (i 3 cut del
diario sono ANNIDATI); (d) contributo per-sleeve all'uplift hold-out.
3. "7/8 ANNI VINTI": quale metrica (ritorno, non Sharpe), margini per anno, Sharpe per-anno.
4. PLATEAU: griglia 2.5pp fra CURRENT e EW-STR (TP x SKH, VRP=15, XS=residuo cap 25).
5. REALISMO: pesi RINORMALIZZATI per era (outer-join: nel 2019-20 attivi solo TP+SKH!), haircut
d'esecuzione su SKH01/XS01 come DRAG (r' = r - h*mean(r), modello-costi, non de-levering).
6. FORKING PATHS: quante config viste sull'hold-out; null di tilt CASUALI cap-respecting — se
quasi ogni tilt anti-TP01 batte CURRENT sull'hold-out, il claim e' generico, non informativo.
Solo lettura/analisi: NON tocca src/ ne' scripts/live/.
uv run python scripts/research/r0701_portfolio_skeptic.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np
import pandas as pd
from src.portfolio.sleeves import active_sleeves
from src.portfolio.portfolio import StrategyPortfolio, HOLDOUT, DAYS_PER_YEAR, metrics
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
LO_W = 0.05
# pesi per PREFISSO sleeve (ordine risolto sui nomi del registry)
W_CURRENT = {"TP01": 0.4125, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.25}
W_EWSTR = {"TP01": 0.30, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.30}
W_ONLY_XS = {"TP01": 0.35, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.25} # SKH pinned al corrente
W_ONLY_SKH = {"TP01": 0.3625, "XS01": 0.1875, "VRP01": 0.15, "SKH01": 0.30} # XS pinned al corrente
CAPS = {"TP01": 0.60, "XS01": 0.25, "VRP01": 0.15, "SKH01": 0.60}
def prefix(name: str) -> str:
return name.split("_")[0]
def wvec(names: list[str], wd: dict) -> np.ndarray:
v = np.array([wd[prefix(n)] for n in names], float)
return v / v.sum()
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
"""Combine outer-join con pesi rinormalizzati per-riga (stessa semantica di combined_daily;
verificata sotto contro il path di produzione)."""
act = J.notna().values * w
rs = act.sum(axis=1, keepdims=True)
wn = np.divide(act, rs, out=np.zeros_like(act), where=rs > 0)
return pd.Series(np.nansum(np.nan_to_num(J.values) * wn, axis=1), index=J.index)
def met(s) -> dict:
return metrics(pd.Series(s).dropna()) # metriche di PRODUZIONE (src.portfolio)
def sh(s) -> float:
return met(s)["sharpe"]
def yr_ret(s) -> float:
v = pd.Series(s).dropna().values
return float(np.prod(1 + v) - 1)
def main():
print("=" * 110)
print(" r0701_portfolio_skeptic — verifica avversariale EW-STR (30/25/15/30) vs CURRENT (41.25/18.75/15/25)")
print("=" * 110)
sl = active_sleeves()
names = [s.name for s in sl]
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
J = J[J.notna().any(axis=1)]
w_cur = wvec(names, W_CURRENT)
w_ew = wvec(names, W_EWSTR)
print(f"\n finestra {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
# ------------------------------------------------ 1) RIPRODUZIONE (path di produzione)
print("\n" + "-" * 110)
print(" [1] RIPRODUZIONE INDIPENDENTE — StrategyPortfolio.combined_daily (produzione), non lo script agente")
print("-" * 110)
for lbl, wd in [("CURRENT", W_CURRENT), ("EW-STR", W_EWSTR)]:
for s, sleeve_w in zip(sl, [wd[prefix(n)] for n in names]):
s.weight = sleeve_w
port = StrategyPortfolio(sl)
full = port.combined_daily()
hold = port.combined_daily(lo=HOLDOUT)
# check che il mio combine coincida col path di produzione
mine = combo(J, wvec(names, wd))
dmax = float(np.max(np.abs(mine.values - full.values)))
f, h = met(full), met(hold)
print(f" {lbl:<8s} FULL Sh {f['sharpe']:.2f} DD {f['maxdd']*100:.1f}% CAGR {f['cagr']*100:+.1f}%"
f" | HOLD Sh {h['sharpe']:.2f} DD {h['maxdd']*100:.1f}% CAGR {h['cagr']*100:+.1f}%"
f" (|combine mio - produzione|max = {dmax:.2e})")
print(" multi-cut ΔSh OOS EW-STR vs CURRENT (finestre ANNIDATE, come nel diario):")
for cut in CUTS:
Jo = J[J.index >= cut]
d = sh(combo(Jo, w_ew)) - sh(combo(Jo, w_cur))
print(f" cut {cut.date()} ΔSh {d:+.2f}")
# ------------------------------------------------ 2) SELEZIONE-SULL'HOLD-OUT DI 2° ORDINE
print("\n" + "-" * 110)
print(" [2] SELEZIONE HOLD-OUT DI 2° ORDINE — SKH01 ammesso PERCHE' alzava l'hold-out; XS01 affinato idem")
print("-" * 110)
# (d prima: contesto) Sharpe standalone per-sleeve, pre-2025 vs hold-out
print(" Sharpe standalone per sleeve (pre-2025 | hold-out 2025+):")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} IS {sh(s[s.index < HOLDOUT]):>5.2f} | HOLD {sh(s[s.index >= HOLDOUT]):>5.2f}")
# (a) per-anno SOLO 2019-2024 (pre-selezione di SKH01/holdout)
print("\n (a) per-anno SOLO 2019-2024 (finestra NON toccata dalla selezione hold-out):")
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s}")
wins_ret_pre = wins_sh_pre = 0
years_pre = [y for y in sorted(set(J.index.year)) if y < 2025]
for y in years_pre:
Jy = J[J.index.year == y]
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
wins_ret_pre += dr > 0; wins_sh_pre += dsh > 0
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f}")
print(f" -> vittorie EW-STR 2019-2024: ritorno {wins_ret_pre}/{len(years_pre)}, Sharpe {wins_sh_pre}/{len(years_pre)}")
Jpre = J[J.index < HOLDOUT]
print(f" pre-2025 aggregato: Sh CUR {sh(combo(Jpre, w_cur)):.2f} vs EW-STR {sh(combo(Jpre, w_ew)):.2f}"
f" (ΔSh {sh(combo(Jpre, w_ew)) - sh(combo(Jpre, w_cur)):+.2f})")
# (b) tilt scomposti
print("\n (b) tilt SCOMPOSTI (chi porta l'uplift?): HOLD Sh + multi-cut ΔSh vs CURRENT")
for lbl, wd in [("solo-XS (SKH pinned 25): TP35 /XS25/VRP15/SKH25", W_ONLY_XS),
("solo-SKH (XS pinned 18.75): TP36.25/XS18.75/VRP15/SKH30", W_ONLY_SKH),
("EW-STR: TP30 /XS25/VRP15/SKH30", W_EWSTR)]:
w = wvec(names, wd)
hold_sh = sh(combo(J[J.index >= HOLDOUT], w))
cuts_d = [sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS]
print(f" {lbl:<58s} HOLD Sh {hold_sh:.2f} multi-cut " +
" ".join(f"{d:+.2f}" for d in cuts_d))
# (c) finestre OOS DISGIUNTE (i cut del diario sono annidati)
print("\n (c) finestre OOS DISGIUNTE (ΔSh e Δret EW-STR vs CURRENT):")
windows = [("2024-01 -> 2024-07", "2024-01-01", "2024-07-01"),
("2024-07 -> 2025-01", "2024-07-01", "2025-01-01"),
("2025-01 -> fine ", "2025-01-01", None)]
for lbl, lo, hi in windows:
m = (J.index >= pd.Timestamp(lo, tz="UTC"))
if hi:
m &= (J.index < pd.Timestamp(hi, tz="UTC"))
Jw = J[m]
rc, re = combo(Jw, w_cur), combo(Jw, w_ew)
print(f" {lbl} ΔSh {sh(re) - sh(rc):+.2f} Δret {(yr_ret(re) - yr_ret(rc))*100:+.1f}pp")
# (d) contributo per-sleeve all'uplift hold-out (pesi statici: sul hold-out tutti attivi)
print("\n (d) contributo per-sleeve al Δritorno annualizzato sull'hold-out (Δw_i x mean_i x 365):")
Jh = J[J.index >= HOLDOUT]
for n in names:
dw = W_EWSTR[prefix(n)] - W_CURRENT[prefix(n)]
mu = float(Jh[n].dropna().mean()) * DAYS_PER_YEAR
print(f" {n:<16s} Δw {dw*100:>+6.2f}pp x ret_ann {mu*100:>+6.1f}% = {dw*mu*100:>+5.2f}pp/anno")
# ------------------------------------------------ 3) "7/8 ANNI VINTI"
print("\n" + "-" * 110)
print(" [3] '7/8 ANNI VINTI' — metrica = RITORNO composto per anno (non Sharpe). Tabella completa + margini")
print("-" * 110)
print(f" {'anno':<6s} {'ret CUR':>9s} {'ret EW-STR':>10s} {'Δret':>8s} | {'Sh CUR':>7s} {'Sh EW-STR':>9s} {'ΔSh':>6s} |"
f" {'DD CUR':>7s} {'DD EW':>7s}")
wins_ret = wins_sh = 0
years = sorted(set(J.index.year))
for y in years:
Jy = J[J.index.year == y]
rc, re = combo(Jy, w_cur), combo(Jy, w_ew)
mc, me = met(rc), met(re)
dr = yr_ret(re) - yr_ret(rc); dsh = sh(re) - sh(rc)
wins_ret += dr > 0; wins_sh += dsh > 0
print(f" {y:<6d} {yr_ret(rc)*100:>+8.1f}% {yr_ret(re)*100:>+9.1f}% {dr*100:>+7.1f}p |"
f" {sh(rc):>7.2f} {sh(re):>9.2f} {dsh:>+6.2f} | {mc['maxdd']*100:>6.1f}% {me['maxdd']*100:>6.1f}%")
print(f" -> vittorie EW-STR: RITORNO {wins_ret}/{len(years)} | SHARPE {wins_sh}/{len(years)}"
f" (2026 = anno parziale)")
# ------------------------------------------------ 4) PLATEAU
print("\n" + "-" * 110)
print(" [4] PLATEAU — griglia 2.5pp: TP x SKH, VRP=15 fisso, XS = residuo (cap 25). HOLD Sh | min multi-cut ΔSh")
print("-" * 110)
tps = [0.30, 0.325, 0.35, 0.375, 0.40, 0.4125]
skhs = [0.25, 0.275, 0.30]
Jh = J[J.index >= HOLDOUT]
hold_cur = sh(combo(Jh, w_cur))
print(f" (HOLD Sh CURRENT = {hold_cur:.2f}; celle con XS>25% = viola cap, marcate *)")
hdr = " TP\\SKH " + "".join(f"{s*100:>18.1f}%" for s in skhs)
print(hdr)
for tp in tps:
row = f" {tp*100:>6.2f}%"
for skh in skhs:
xs = 1.0 - 0.15 - tp - skh
tag = "*" if xs > CAPS["XS01"] + 1e-9 else " "
if xs < LO_W - 1e-9:
row += f"{'—':>19s}"
continue
w = np.array([{"TP01": tp, "XS01": xs, "VRP01": 0.15, "SKH01": skh}[prefix(n)] for n in names])
hs = sh(combo(Jh, w))
dmin = min(sh(combo(J[J.index >= c], w)) - sh(combo(J[J.index >= c], w_cur)) for c in CUTS)
row += f" {hs:>5.2f}|{dmin:+.2f}{tag}"
print(row)
# ------------------------------------------------ 5) REALISMO
print("\n" + "-" * 110)
print(" [5] REALISMO — pesi EFFETTIVI per era (outer-join rinormalizza!) + haircut d'esecuzione")
print("-" * 110)
# pesi rinormalizzati per era
eras = [("2019-2020 (TP+SKH)", "2019-06-01"), ("2021-2023 (TP+VRP+SKH)", "2022-06-01"),
("2024+ (tutti)", "2025-06-01")]
print(" pesi EFFETTIVI (rinormalizzati sugli sleeve attivi) per era:")
for lbl, probe in eras:
t = pd.Timestamp(probe, tz="UTC")
i = J.index.get_indexer([t], method="nearest")[0]
act = J.iloc[i].notna().values
for wl, w in [("CURRENT", w_cur), ("EW-STR ", w_ew)]:
wn = w * act; wn = wn / wn.sum()
s = " ".join(f"{prefix(n)} {x*100:.0f}%" for n, x in zip(names, wn) if act[list(names).index(n)])
print(f" {lbl:<24s} {wl}: {s}")
print(" -> nel 2019-23 EW-STR tiene SKH01 al 40-50% effettivo (research-grade, book 230m).")
# haircut d'esecuzione come DRAG (r' = r - h*mean_full(r)): modello-costi, non de-levering
print("\n haircut esecuzione (drag costante = h x mean ritorno full dello sleeve):")
print(f" {'scenario':<34s} {'CUR HOLD Sh':>11s} {'EW HOLD Sh':>11s} {'ΔSh':>6s} | {'CUR HOLD CAGR':>13s} {'EW HOLD CAGR':>13s}")
scen = [("nessun haircut", {}, 0.0),
("SKH01 -20%", {"SKH01"}, 0.20), ("SKH01 -30%", {"SKH01"}, 0.30),
("SKH01+XS01 -20%", {"SKH01", "XS01"}, 0.20), ("SKH01+XS01 -30%", {"SKH01", "XS01"}, 0.30)]
for lbl, targets, h in scen:
Jx = J.copy()
for n in names:
if prefix(n) in targets:
mu = float(J[n].dropna().mean())
Jx[n] = J[n] - h * mu # drag solo dove lo sleeve e' attivo (NaN restano NaN)
Jxh = Jx[Jx.index >= HOLDOUT]
rc, re = combo(Jxh, w_cur), combo(Jxh, w_ew)
mc, me = met(rc), met(re)
print(f" {lbl:<34s} {mc['sharpe']:>11.2f} {me['sharpe']:>11.2f} {me['sharpe']-mc['sharpe']:>+6.2f} |"
f" {mc['cagr']*100:>+12.1f}% {me['cagr']*100:>+12.1f}%")
# ------------------------------------------------ 6) FORKING PATHS
print("\n" + "-" * 110)
print(" [6] FORKING PATHS — config viste sull'hold-out + null di tilt casuali cap-respecting")
print("-" * 110)
n_configs = 7 + 8 # 7 vettori pesi (MAXSH,RP,ERC,MINVAR-R,MAXSH-STR,EW,EW-STR) + 8 celle guardia
print(f" config valutate sull'hold-out nello script agente: >= {n_configs} (7 pesi + 8 celle guardia),")
print(" EW-STR costruito DOPO aver visto EW vincere (ammesso nel diario).")
rng = np.random.default_rng(42)
caps = np.array([CAPS[prefix(n)] for n in names])
N = 500
dsh_hold, dsh_full, all_cuts_pos = [], [], 0
Jh = J[J.index >= HOLDOUT]
hold_c = sh(combo(Jh, w_cur)); full_c = sh(combo(J, w_cur))
cuts_J = {c: J[J.index >= c] for c in CUTS}
cuts_base = {c: sh(combo(cuts_J[c], w_cur)) for c in CUTS}
for _ in range(N):
w = rng.dirichlet(np.ones(len(names)))
for _ in range(60): # proiezione su [LO_W, caps], somma 1
w = np.clip(w, LO_W, caps)
d = 1.0 - w.sum()
if abs(d) < 1e-9:
break
if d > 0:
room = caps - w
w += d * room / max(room.sum(), 1e-12)
else:
room = w - LO_W
w += d * room / max(room.sum(), 1e-12)
dh = sh(combo(Jh, w)) - hold_c
dsh_hold.append(dh)
dsh_full.append(sh(combo(J, w)) - full_c)
all_cuts_pos += all(sh(combo(cuts_J[c], w)) - cuts_base[c] > 0 for c in CUTS)
dsh_hold = np.array(dsh_hold); dsh_full = np.array(dsh_full)
dew = sh(combo(Jh, w_ew)) - hold_c
print(f" {N} tilt casuali dentro i cap (VRP<=15, XS<=25, w>=5): quota che batte CURRENT")
print(f" su HOLD-OUT: {float((dsh_hold > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_hold):+.2f})")
print(f" su FULL: {float((dsh_full > 0).mean())*100:.0f}% (mediana ΔSh {np.median(dsh_full):+.2f})")
print(f" su TUTTI e 3 cut: {all_cuts_pos/N*100:.0f}%")
print(f" percentile di EW-STR (ΔSh HOLD {dew:+.2f}) fra i tilt casuali: {float((dsh_hold < dew).mean())*100:.0f}°")
print(" lettura: se quasi ogni tilt cap-respecting batte CURRENT sull'hold-out, il 'vince OOS'")
print(" e' una proprieta' del PERIODO (hold-out pro-SKH/XS per costruzione), non del vettore EW-STR.")
print("\n" + "=" * 110)
print(" fine verifica — vedi addendum nel diario 2026-07-01-portfolio-weights-ddguard.md")
print("=" * 110)
if __name__ == "__main__":
main()