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:
Adriano Dal Pastro
2026-07-01 23:21:59 +00:00
parent ccf5e38101
commit 491411ac77
17 changed files with 3538 additions and 14 deletions
+90 -14
View File
@@ -106,6 +106,94 @@ def rebalance_sim(daily_cols: dict[str, pd.Series], weights: dict,
n_rebalances=n_rebal, period_days=period_days, cost_rate=cost_rate)
def combine_outer(daily_cols: dict[str, pd.Series], weights: dict,
lo=None, hi=None) -> pd.Series:
"""Combina serie GIORNALIERE per peso con OUTER-join e rinormalizzazione per-giorno
(stessa semantica di StrategyPortfolio.combined_daily, ma su pesi arbitrari —
riusabile per studi di sensibilità/tilt senza istanziare il portafoglio)."""
J = pd.concat(daily_cols, axis=1, join="outer").sort_index()
wv = np.array([weights[c] for c in J.columns], float)
active = J.notna().values * wv # peso solo dove c'e' dato
rowsum = active.sum(axis=1, keepdims=True)
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
if lo is not None:
combo = combo[combo.index >= lo]
if hi is not None:
combo = combo[combo.index < hi]
return combo
def weights_tilt_null(daily_cols: dict[str, pd.Series], w_current: dict, w_proposed: dict,
*, caps: dict | None = None, floor: float = 0.05, n: int = 500,
seed: int = 20260701, holdout: pd.Timestamp = HOLDOUT,
k_seen: int | None = None) -> dict:
"""GATE per ogni proposta di CAMBIO PESI del portafoglio (lezione 2026-07-01, EW-STR refutato).
Un tilt che "batte i pesi correnti sull'hold-out" non è informativo di per sé: se metà dei
tilt casuali dentro i vincoli batte CURRENT sull'hold-out, il claim è generico; e se il tilt
proposto siede al percentile ~k/(k+1) fra i tilt casuali (k = configurazioni viste
sull'hold-out durante la ricerca), l'uplift è indistinguibile dal *best-of-k scelto
sull'hold-out* (selezione di 2° ordine). Vedi diario 2026-07-01-portfolio-weights-ddguard.md.
daily_cols: serie di rendimenti GIORNALIERI per sleeve (es. {s.name: s.daily()}).
caps: peso massimo per sleeve (es. {"VRP01": 0.15, "XS01": 0.25}); floor: peso minimo comune.
k_seen: quante configurazioni di pesi sono state guardate sull'hold-out durante la ricerca.
REGOLA (gate_pass): un cambio pesi si applica solo se
(1) delta_insample >= 0 — non deve PERDERE risk-adjusted pre-holdout;
(2) pctl_hold < 100*k/(k+1) (o < 80 se k_seen ignoto) — sotto la firma best-of-k.
Il gate è NECESSARIO, non sufficiente: restano richiesti finestre OOS disgiunte e realismo
(pesi effettivi post-rinormalizzazione, eseguibilità degli sleeve a peso aumentato)."""
names = list(daily_cols)
caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float)
def _sh(s: pd.Series, lo=None, hi=None) -> float:
v = s
if lo is not None:
v = v[v.index >= lo]
if hi is not None:
v = v[v.index < hi]
r = np.asarray(v.dropna().values, float)
return float(r.mean() / r.std() * np.sqrt(DAYS_PER_YEAR)) if len(r) > 1 and r.std() > 0 else 0.0
def _wvec(wd: dict) -> np.ndarray:
w = np.array([wd[nm] for nm in names], float)
return w / w.sum()
# campiona n pesi casuali uniformi sul simplesso, dentro floor/caps (rejection sampling)
rng = np.random.default_rng(seed)
samples, tries = [], 0
while len(samples) < n:
batch = rng.dirichlet(np.ones(len(names)), size=max(4 * n, 256))
ok = (batch >= floor).all(axis=1) & (batch <= caps_v).all(axis=1)
samples.extend(batch[ok])
tries += 1
if tries > 200:
raise RuntimeError("weights_tilt_null: vincoli floor/caps troppo stretti (acceptance ~0)")
S = np.array(samples[:n])
sh_cur_hold = _sh(combine_outer(daily_cols, dict(zip(names, _wvec(w_current)))), lo=holdout)
d_hold_rand = np.array([
_sh(combine_outer(daily_cols, dict(zip(names, w))), lo=holdout) - sh_cur_hold for w in S])
wp = dict(zip(names, _wvec(w_proposed)))
wc = dict(zip(names, _wvec(w_current)))
d_hold = _sh(combine_outer(daily_cols, wp), lo=holdout) - sh_cur_hold
d_full = _sh(combine_outer(daily_cols, wp)) - _sh(combine_outer(daily_cols, wc))
d_is = _sh(combine_outer(daily_cols, wp, hi=holdout)) - _sh(combine_outer(daily_cols, wc, hi=holdout))
pctl_hold = float((d_hold_rand < d_hold).mean() * 100.0)
bestofk = float(100.0 * k_seen / (k_seen + 1)) if k_seen else None
gate_pass = bool(d_is >= 0.0 and pctl_hold < (bestofk if bestofk is not None else 80.0))
return dict(delta_hold=round(d_hold, 4), delta_full=round(d_full, 4),
delta_insample=round(d_is, 4),
frac_random_beat_hold=round(float((d_hold_rand > 0).mean()), 3),
pctl_hold=round(pctl_hold, 1), bestofk_pctl=bestofk,
gate_pass=gate_pass, n_samples=int(len(S)), samples=S)
class StrategyPortfolio:
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
if not sleeves:
@@ -124,20 +212,8 @@ class StrategyPortfolio:
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI
fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua
storia). Cosi' non si tronca il portafoglio alla finestra comune."""
w = self.weights()
cols = {s.name: s.daily() for s in self.sleeves}
J = pd.concat(cols, axis=1, join="outer").sort_index()
wv = np.array([w[c] for c in J.columns], float)
active = J.notna().values * wv # peso solo dove c'e' dato
rowsum = active.sum(axis=1, keepdims=True)
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
if lo is not None:
combo = combo[combo.index >= lo]
if hi is not None:
combo = combo[combo.index < hi]
return combo
return combine_outer({s.name: s.daily() for s in self.sleeves}, self.weights(),
lo=lo, hi=hi)
def backtest(self) -> dict:
full = self.combined_daily()