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:
+90
-14
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user