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:
+124
-13
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user