fix(gtaa): costo IB reale (pavimento fisso) + esecuzione a banda — lo sleeve era cieco al capitale

DIFETTO. src/portfolio/gtaa.py modellava il costo come 2bps PROPORZIONALI e il modulo si
dichiarava "eseguibile a basso capitale, switch mensile/basso turnover". Entrambe false:
il vol-target e' CONTINUO (l'esposizione cambia ogni giorno, su 6 gambe) e IB non ha una
fee proporzionale ma un PAVIMENTO FISSO per ordine, min(max($0.35, $0.0035/az), 1% del
controvalore) = ~$530/anno indipendenti dal capitale.

Il modello vecchio era quindi CIECO AL CAPITALE: dava Sharpe 0.64 sia a $50.000 sia a $600.
Alla taglia grande era sostanzialmente giusto (0.64 vs 0.59-0.66 reali); a $600 la realta'
e' -0.55 di Sharpe e -3.5%/anno di CAGR. L'errore era tutto concentrato dove lo sleeve
verrebbe realmente deployato.

FIX. Il modulo ora:
  * modella la commissione IB reale (ib_commission);
  * esegue a BANDA + CADENZA (settimanale, $50/gamba) — config scelta sul PLATEAU del sweep
    (weekly/monthly x banda 25-100 -> Sh 0.45-0.64 a ogni capitale), non sull'argmax
    in-sample (banda $50 daily a $600 = cella isolata che crolla a banda 100);
  * prende il CAPITALE allocato come parametro: gtaa_returns(capital=...);
  * dichiara la soglia di deployabilita': GTAA_MIN_CAPITAL=$3.000 + gtaa_is_deployable();
  * espone gtaa_rebalance_plan(held, capital) per l'esecutore — salta le gambe il cui
    nozionale non supera la banda (un ordine da $12 costa $0.35 = 2.9%).
sleeves.py dichiara esplicitamente il capitale assunto (GTAA_DEFAULT_CAPITAL=$10.000 allocati
= book ~$50k al peso 20%) invece di nasconderlo.

IMPATTO SUL BOOK (misurato sostituendo la sola gamba GTAA, non citato):
  GTAA01 standalone  Sh 0.64 -> 0.61   CAGR 3.76% -> 3.65%
  book 5 sleeve      FULL 2.22 -> 2.22  HOLD 2.36 -> 2.38  maxDD 6.2% -> 6.0%
Trascurabile alla taglia assunta: il fix conta per il DEPLOY (a $600-2k passa da -3.5%/anno
a +3.0%/anno). PESI INVARIATI -> nessun weights_tilt_null richiesto.

CORREZIONE A UN ERRORE DI ANALISI DELLA SESSIONE. Il diario citava il modello vecchio a
"Sharpe 0.77 / CAGR 5.5%": artefatto di annualizzazione: la serie GTAA grezza ha ~252 barre
/anno (soli giorni di borsa) e metrics() annualizza a 365 con years=n/365.25 -> Sharpe x1.20
e CAGR x1.45. Le righe per-capitale erano gia' su calendario 365, quindi era falsata solo la
riga di riferimento. Corretti diario, CLAUDE.md e r0725_capcurve.py.
LEZIONE: una serie su giorni di borsa non si passa a metrics() senza to_daily().

Test: 221 pass (+4 in tests/test_gtaa_sleeve.py: pavimento non proporzionale, dipendenza dal
capitale + soglia, senza-banda-e-distruttivo, piano che salta le gambe sotto banda).
Smoke: scripts/live/paper_combo.py gira invariato.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 17:25:06 +00:00
parent 3266f3a09a
commit 607cd94ecd
6 changed files with 248 additions and 42 deletions
+124 -13
View File
@@ -5,9 +5,24 @@ multi-orizzonte su un paniere di classi (azioni US/tech/small + bond + oro + cre
sugli asset in trend-up, vol-target 12%. Difensivo: Sharpe ~0.64 / maxDD ~15% standalone, e — il punto
— corr ~0.21 col crypto (TP01) -> diversificatore reale (blend Sharpe ~1.5, DD dimezzato).
Eseguibile su IB a basso capitale (ETF frazionabili, switch mensile/basso turnover). Legge la CACHE
su disco data/raw/eq_*.parquet (ADJUSTED_LAST, scritta da scripts/research/fetch_ib_equities.py);
in produzione va rinfrescata giornalmente (gateway IB). Espone rendimenti + PESI CORRENTI (posizioni).
Legge la CACHE su disco data/raw/eq_*.parquet (ADJUSTED_LAST, scritta da
scripts/research/fetch_ib_equities.py); in produzione va rinfrescata giornalmente (gateway IB).
Espone rendimenti + PESI CORRENTI (posizioni) + il PIANO di ribilanciamento bandato.
⚠️ CORREZIONE 2026-07-25 (diario 2026-07-25-rendita-capcurve-prop.md, §1).
Questo modulo dichiarava "eseguibile a basso capitale, switch mensile/basso turnover" e modellava
il costo come 2bps PROPORZIONALI. Erano entrambe FALSE:
* il vol-target e' CONTINUO: l'esposizione cambia OGNI GIORNO, su 6 gambe;
* IB non ha una fee proporzionale ma un PAVIMENTO FISSO per ordine
(min(max($0.35, $0.0035/azione), 1% del controvalore)) = ~$530/anno INDIPENDENTI dal capitale.
A $600 allocati questo faceva CAGR -3.5% / Sharpe -0.55 (vs +5.5%/0.77 modellato): lo sleeve era
un distruttore di capitale, non un diversificatore. Restava negativo fino a ~$3k.
Ora il modulo modella il costo REALE e ribilancia a BANDA + CADENZA (plateau: settimanale,
banda $50/gamba) -> a $600 Sh 0.52 / CAGR 3.0%, e ~0.6 di Sharpe onesto a ogni capitale.
REGOLA GENERALE che ne esce: il costo di un venue va modellato nella sua FORMA (fisso vs
proporzionale), non solo nel livello. Con un pavimento fisso la banda ottimale NON e' il minimo
eseguibile (come su Deribit, dove la fee e' proporzionale) ma un valore molto piu' alto.
"""
from __future__ import annotations
from functools import lru_cache
@@ -20,9 +35,40 @@ RAW = Path(__file__).resolve().parents[2] / "data" / "raw"
EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG")
HORIZONS = (21, 63, 126, 252)
TARGET_VOL = 0.12
FEE_SIDE = 0.0002
ANN = np.sqrt(252.0)
# --- COSTO IB REALE (IBKR Pro tiered, US stocks/ETF) --------------------------
IB_MIN_COMM = 0.35 # USD minimi per ordine — il pavimento che rovina il capitale piccolo
IB_PER_SHARE = 0.0035 # USD per azione
IB_MAX_FRAC = 0.01 # cap: 1% del controvalore dell'ordine
# --- ESECUZIONE (scelta sul PLATEAU, non sull'argmax in-sample) ---------------
# Sweep banda x cadenza a 4 livelli di capitale (diario §1): weekly/monthly x banda $25-100
# danno Sharpe 0.45-0.64 a OGNI capitale; l'argmax a $600 (banda $50 daily, 0.57) e' una cella
# isolata che crolla a 0.28 a banda $100 -> scartata. La banda e' un parametro di COSTO: si
# sceglie sul plateau e si dichiara, non si ottimizza sul rendimento.
REBAL_EVERY = 5 # barre di borsa fra due controlli (5 = settimanale)
REBAL_BAND_USD = 50.0 # nozionale minimo per gamba perche' un ordine valga la commissione
IB_MIN_TRADE_USD = 1.0 # ordine frazionale minimo sensato
# Capitale allocato allo sleeve. NON e' un dettaglio: sotto la soglia lo sleeve NON e' deployabile.
GTAA_DEFAULT_CAPITAL = 10_000.0
GTAA_MIN_CAPITAL = 3_000.0 # sotto: il pavimento di commissione domina anche con la banda
def gtaa_is_deployable(capital: float) -> bool:
"""Lo sleeve regge il costo IB al capitale dato? Sotto ~$3k il pavimento fisso per ordine
si mangia il rendimento anche con la banda (diario 2026-07-25, §1)."""
return float(capital) >= GTAA_MIN_CAPITAL
def ib_commission(notional: float, price: float) -> float:
"""Commissione IB per UN ordine: min(max($0.35, $0.0035/az), 1% del controvalore)."""
if notional <= 0:
return 0.0
shares = notional / price
return min(max(IB_MIN_COMM, IB_PER_SHARE * shares), IB_MAX_FRAC * notional)
@lru_cache(maxsize=16)
def _close(sym: str) -> pd.Series:
@@ -50,23 +96,56 @@ def _exposure(close: pd.Series) -> pd.Series:
return (s * scale).clip(0, 1.0)
def _gated_returns(sym: str) -> pd.Series:
close = _close(sym); ex = _exposure(close)
ret = close.pct_change().fillna(0.0).values
held = np.zeros(len(ex)); held[1:] = ex.values[:-1] # causale: esposizione decisa a i-1, tenuta in i
net = held * ret - FEE_SIDE * np.abs(np.diff(held, prepend=0.0))
def _gated_returns(sym: str, cap_leg: float, band_usd: float, every: int) -> pd.Series:
"""Rendimenti netti di UNA gamba, con esecuzione BANDATA e commissione IB REALE.
Il target si guarda ogni `every` barre; si esegue solo se il nozionale da muovere supera la
banda (sotto, l'ordine non ripaga il pavimento di $0.35 e si tiene la posizione: tracking
error, non trade). La commissione e' addebitata PER ORDINE, non in proporzione al turnover.
Causale: esposizione decisa a i-1 e tenuta in i, come nella versione precedente.
"""
close = _close(sym)
ex = np.nan_to_num(np.asarray(_exposure(close).values, float))
px = close.values.astype(float)
ret = np.zeros(len(px))
ret[1:] = px[1:] / px[:-1] - 1.0
held = np.empty(len(ex))
comm = np.zeros(len(ex))
cur = 0.0
for i in range(len(ex)):
if i % every == 0:
notional = abs(ex[i] - cur) * cap_leg
if notional >= max(band_usd, IB_MIN_TRADE_USD):
comm[i] = ib_commission(notional, px[i]) / cap_leg
cur = ex[i]
held[i] = cur
pos = np.zeros(len(held))
pos[1:] = held[:-1]
net = pos * ret - comm
net[0] = 0.0
return pd.Series(net, index=close.index)
def gtaa_returns(universe=EQ_UNIVERSE) -> pd.Series:
"""Rendimenti netti daily del GTAA: EW dei rendimenti trend-gated sugli asset disponibili."""
cols = {a: _gated_returns(a) for a in universe}
def gtaa_returns(universe=EQ_UNIVERSE, capital: float = GTAA_DEFAULT_CAPITAL,
band_usd: float = REBAL_BAND_USD, every: int = REBAL_EVERY) -> pd.Series:
"""Rendimenti netti daily del GTAA: EW delle gambe trend-gated, con COSTO IB REALE.
`capital` = dollari ALLOCATI a questo sleeve (non l'equity totale del book): il costo per
ordine e' fisso, quindi il rendimento netto DIPENDE dalla taglia. Sotto GTAA_MIN_CAPITAL
lo sleeve non e' deployabile (`gtaa_is_deployable`).
"""
cap_leg = float(capital) / len(universe)
cols = {a: _gated_returns(a, cap_leg, band_usd, every) for a in universe}
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
def gtaa_weights(universe=EQ_UNIVERSE) -> dict:
"""Pesi target CORRENTI (ultima barra): quanto allocare a ciascun ETF (e quanto in cash).
weight_i = esposizione_i / N_disponibili. Azionabile su IB."""
weight_i = esposizione_i / N_disponibili. Azionabile su IB.
NB: e' il TARGET nudo. Chi esegue deve passarlo da `gtaa_rebalance_plan`, altrimenti
ribilancia ogni giorno per pochi dollari e paga il pavimento di commissione su ogni gamba.
"""
out = {}; n = len(universe)
for a in universe:
try:
@@ -77,3 +156,35 @@ def gtaa_weights(universe=EQ_UNIVERSE) -> dict:
out["_cash"] = round(1.0 - sum(out.values()), 4)
out["_asof"] = str(_close("SPY").index[-1].date())
return out
def gtaa_rebalance_plan(held: dict, capital: float,
universe=EQ_UNIVERSE, band_usd: float = REBAL_BAND_USD) -> dict:
"""PIANO di ribilanciamento per l'esecutore: quali gambe muovere davvero.
`held` = pesi attualmente detenuti {sym: peso}; `capital` = dollari allocati allo sleeve.
Ritorna, per ogni gamba, il nozionale da muovere, se supera la banda, e la commissione
stimata. Le gambe sotto banda NON si toccano: un ordine da $12 costa $0.35 = il 2.9%.
Serve a garantire che il LIVE esegua la stessa strategia che il backtest modella.
"""
tgt = gtaa_weights(universe)
orders, skipped, comm_tot = {}, {}, 0.0
for a in universe:
if a not in tgt:
continue
delta = float(tgt[a]) - float(held.get(a, 0.0))
notional = abs(delta) * float(capital)
if notional >= max(band_usd, IB_MIN_TRADE_USD):
try:
px = float(_close(a).iloc[-1])
except FileNotFoundError:
continue
c = ib_commission(notional, px)
comm_tot += c
orders[a] = dict(delta=round(delta, 4), notional=round(notional, 2),
commission=round(c, 2), comm_pct=round(c / notional, 5))
elif notional > 0:
skipped[a] = dict(delta=round(delta, 4), notional=round(notional, 2))
return dict(orders=orders, skipped=skipped, commission_total=round(comm_tot, 2),
deployable=gtaa_is_deployable(capital), capital=float(capital),
band_usd=band_usd, asof=tgt.get("_asof"))
+7 -2
View File
@@ -292,7 +292,7 @@ def skyhook_sleeve(weight: float = 0.25) -> Sleeve:
# travasano nel weekend. ESEGUIBILE a basso capitale (ETF frazionali, switch mensile, gia' in
# paper_combo dal 2026-06-23). CAVEAT: cross-venue = due conti (Deribit+IB); il book live Deribit
# (deribit_book_sleeves) NON lo include.
from src.portfolio.gtaa import gtaa_returns, gtaa_weights
from src.portfolio.gtaa import GTAA_DEFAULT_CAPITAL, GTAA_MIN_CAPITAL, gtaa_returns, gtaa_weights
# GTAA nel BOOK si attiva con l'era del book (inizio storia certificata BTC/ETH): senza troncatura
# l'outer-join farebbe partire il portafoglio nel 1996 con 23 anni di solo-GTAA rinormalizzato al
@@ -301,7 +301,12 @@ GTAA_BOOK_ACTIVATION = pd.Timestamp("2019-03-14", tz="UTC")
def _gtaa_daily_returns() -> pd.Series:
r = gtaa_returns().dropna().sort_index()
# CAPITALE ASSUNTO (2026-07-25): il costo IB e' un PAVIMENTO FISSO per ordine, quindi il
# rendimento netto di questo sleeve DIPENDE dalla taglia. Si dichiara l'ipotesi invece di
# nasconderla: GTAA_DEFAULT_CAPITAL ($10k allocati = book ~$50k al peso 20%). Sotto
# GTAA_MIN_CAPITAL ($3k allocati) lo sleeve NON e' deployabile e i numeri del book che lo
# includono non valgono. Diario 2026-07-25-rendita-capcurve-prop.md §1.
r = gtaa_returns(capital=GTAA_DEFAULT_CAPITAL).dropna().sort_index()
if r.index.tz is None:
r.index = r.index.tz_localize("UTC")
days = pd.date_range(r.index.min().normalize(), r.index.max().normalize(), freq="1D", tz="UTC")