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>
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user