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
+17 -6
View File
@@ -424,12 +424,23 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
(1) **GTAA01 non e' eseguibile come codificato.** `gtaa.py` modella 2bps proporzionali e lo sleeve e'
documentato "switch mensile/basso turnover": FALSO — il vol-target e' **continuo giornaliero su 6
gambe**, e IB ha un **pavimento FISSO** `min(max($0.35, $0.0035/az), 1% valore)` per ordine =
~$530/anno indipendenti dal capitale. A $600: **CAGR 3.5% / Sh 0.55** (vs modellato +5.5%/0.77);
negativo fino a ~$3k; a $50k arriva solo a 0.59. **La banda lo salva**: plateau robusto
**weekly x banda $25-100** → Sh 0.45-0.64 a ogni capitale (a $600: Sh 0.52 / CAGR 3.0%). Due
conseguenze: (a) se GTAA01 va live su IB **deve** avere banda+cadenza cablate; (b) il suo Sharpe
onesto e' **~0.55-0.64, non 0.77** → i numeri headline del book a 5 sleeve vanno citati con questo
haircut (in aggiunta all'anchor-luck). **REGOLA NUOVA: il costo di un venue va modellato nella sua
~$530/anno indipendenti dal capitale. A $600: **CAGR 3.5% / Sh 0.55**; negativo fino a ~$3k;
a $50k 0.59. **La banda lo salva**: plateau robusto **weekly x banda $25-100** → Sh 0.45-0.64 a
ogni capitale (a $600: Sh 0.52 / CAGR 3.0%). ⚠ **Correzione a un errore MIO:** la prima stesura
citava il modello vecchio a "0.77/5.5%" — era un **artefatto di annualizzazione** (metricavo la
serie GTAA grezza a ~252 barre/anno con `metrics()` che annualizza a 365 → Sharpe ×1.20, CAGR
×1.45). Valore vero del modello vecchio: **0.64/3.8%**. **Quindi il difetto NON e' "sovrastimava
di 0.13": alla taglia grande il modello vecchio era GIUSTO (0.64 vs 0.59-0.66 reali). Il difetto
e' che era CIECO AL CAPITALE** — dava 0.64 sia a $50k sia a $600, dove la realta' e' 0.55.
**Lezione: una serie su giorni di borsa (~252/anno) non si passa a `metrics()` (che annualizza a
365) senza `to_daily()` — il Sharpe esce ×1.20 e il CAGR ×1.45.**
**FIX CABLATO** (stessa sessione, `src/portfolio/gtaa.py`): costo IB reale (`ib_commission`),
esecuzione a **banda+cadenza** (settimanale, $50/gamba), `gtaa_returns(capital=...)` capital-aware,
soglia `GTAA_MIN_CAPITAL=$3.000` + `gtaa_is_deployable`, e **`gtaa_rebalance_plan(held, capital)`**
per l'esecutore (salta le gambe sotto banda). `sleeves.py` dichiara il capitale assunto
(`GTAA_DEFAULT_CAPITAL=$10.000` ≈ book $50k al peso 20%). **Impatto book misurato: FULL 2.22→2.22,
HOLD 2.36→2.38, DD 6.2→6.0%** = trascurabile alla taglia assunta; il fix conta per il **DEPLOY**
(a $600-2k: da 3.5%/anno a +3.0%/anno). Pesi INVARIATI. Test `tests/test_gtaa_sleeve.py` (+4). **REGOLA NUOVA: il costo di un venue va modellato nella sua
FORMA (fisso vs proporzionale), non solo nel livello** — due venue a pari "costo medio" danno esiti
OPPOSTI al variare del capitale (il pavimento fisso e' una tassa regressiva). Controprova: su Deribit
(proporzionale, senza pavimento) lo Sharpe realistico di TP01 = modellato da ~$500 in su → conferma
+37 -10
View File
@@ -25,11 +25,25 @@ Entrambe le cose sono false come codificate:
| capitale allocato a GTAA | CAGR reale | Sharpe reale | drag commissioni |
|---|---|---|---|
| $600 | **3.5%** | **0.55** | 9.0%/anno |
| $2.000 | 0.7% | 0.09 | 6.2%/anno |
| $5.000 | +1.5% | 0.28 | 4.0%/anno |
| $50.000 | +3.4% | 0.59 | 2.1%/anno |
| *modellato (2bps)* | *+5.5%* | *0.77* | — |
| $600 | **3.5%** | **0.55** | 7.2%/anno |
| $2.000 | 0.7% | 0.09 | 4.4%/anno |
| $5.000 | +1.5% | 0.28 | 2.2%/anno |
| $50.000 | +3.4% | 0.59 | 0.2%/anno |
| *modello vecchio (2bps propor.)* | *+3.8%* | *0.64* | — |
⚠️ **Correzione a un errore MIO, trovata implementando il fix.** La prima stesura di questa
sezione citava il modello vecchio a *Sharpe 0.77 / CAGR 5.5%*: era un **artefatto di
annualizzazione** del mio script di analisi — metricavo la serie GTAA grezza (solo giorni di
borsa, ~252/anno) con `metrics()`, che annualizza a 365 e calcola `years = n/365.25` → Sharpe
gonfiato di √(365/252) = 1.20× e CAGR di ~1.45×. Le righe per-capitale erano già su calendario
365 (reindex + weekend a zero), quindi **il confronto era falsato solo sulla riga di riferimento**.
Valore corretto del modello vecchio: **0.64 / 3.8%**.
**La formulazione giusta del difetto cambia di conseguenza.** Non è "il modello sovrastimava lo
Sharpe di 0.13": alla taglia grande il modello vecchio era sostanzialmente **giusto** (0.64 contro
0.59-0.66 reali). Il difetto è che era **CIECO AL CAPITALE** — dava 0.64 sia a $50.000 sia a $600,
mentre a $600 la realtà è **0.55**. Un modello di costo proporzionale non può descrivere un
pavimento fisso, e l'errore è tutto concentrato dove lo sleeve verrebbe realmente deployato.
**La banda lo salva.** Con costo fisso la banda ottimale non è il minimo eseguibile (come su
Deribit) ma un valore molto più alto. Plateau robusto = **cadenza settimanale × banda $25-100**
@@ -38,11 +52,24 @@ dà **Sh 0.52 / CAGR 3.0%** invece di 0.55/3.5%. (La cella argmax a $600 e
a 0.57, ma è isolata — crolla a 0.28 a banda 100 → scartata: il parametro è di costo, si sceglie
sul plateau, non sull'argmax in-sample.)
**Due conseguenze.**
1. Se GTAA01 va live su IB **deve** avere banda+cadenza cablate. Come sta ora, a $600-2k è un
distruttore di capitale.
2. Lo Sharpe **onesto** di GTAA01 è **~0.55-0.64, non 0.77** → i numeri headline del book a 5
sleeve vanno citati con questo haircut (in aggiunta all'anchor-luck già documentato).
**Conseguenza operativa: CABLATO** (stessa sessione, `src/portfolio/gtaa.py`). Il modulo ora
modella il costo IB reale (`ib_commission`), esegue a **banda + cadenza** (settimanale, $50/gamba),
prende il **capitale allocato** come parametro (`gtaa_returns(capital=...)`), dichiara la soglia
di deployabilità (`GTAA_MIN_CAPITAL = $3.000`, `gtaa_is_deployable`) ed espone
**`gtaa_rebalance_plan(held, capital)`** — il piano che l'esecutore deve usare per non ribilanciare
ogni giorno per pochi dollari. `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 prima/dopo** (non citato: ricalcolato sostituendo la sola gamba GTAA):
| | GTAA01 standalone | book 5 sleeve FULL | HOLD | maxDD |
|---|---|---|---|---|
| prima (2bps, continuo) | Sh 0.64 / CAGR 3.76% | 2.22 | 2.36 | 6.2% |
| dopo (IB reale + banda) | Sh 0.61 / CAGR 3.65% | 2.22 | **2.38** | **6.0%** |
Alla taglia assunta l'impatto sul book è **trascurabile** — il fix non è cosmetico per i numeri
storici, lo è per il **deploy**: è a $600-2.000 che cambia tutto (da 3.5%/anno a +3.0%/anno).
Pesi **invariati** (nessun cambio pesi → nessun `weights_tilt_null` richiesto).
**Regola nuova (generale): il costo di un venue va modellato nella sua FORMA — fisso vs
proporzionale — non solo nel suo livello.** Due venue con lo stesso "costo medio" danno
+11 -11
View File
@@ -13,7 +13,8 @@ Tre parti:
ALLOCATI a quello sleeve, con il modello di costo del SUO venue:
- Deribit (TP01, SKH01): fee proporzionale 0.05%/lato, min order $5 (skip, non trade).
- IB (GTAA01): commissione = min(max($0.35, $0.0035/azione), 1% del controvalore)
-> **pavimento FISSO per ordine**, mai modellato prima (gtaa.py usa 2bps proporzionali).
-> **pavimento FISSO per ordine**. gtaa.py usava 2bps proporzionali + ribilancio
continuo: difetto trovato QUI e poi corretto nel modulo (banda + costo IB reale).
- Hyperliquid (XSR01): fee 0.045%/lato, min order $5, 50 gambe.
Novita' vs tutto il lavoro precedente: la curva GTAA, cioe' a che capitale il 3o sleeve
smette di essere un costo.
@@ -45,7 +46,7 @@ sys.path.insert(0, str(ROOT))
from src.data.downloader import load_data # noqa: E402
from src.portfolio.gtaa import EQ_UNIVERSE, _close, _exposure # noqa: E402
from src.portfolio.portfolio import combine_outer, metrics # noqa: E402
from src.portfolio.portfolio import combine_outer, metrics, to_daily # noqa: E402
from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d, simple_returns # noqa: E402
@@ -103,7 +104,7 @@ def _tp01_realistic_cached(alloc: float) -> pd.Series:
def _gtaa_realistic_cached(alloc: float, universe=EQ_UNIVERSE) -> pd.Series:
"""GTAA con capitale ALLOCATO `alloc`, costo IB REALE (pavimento fisso per ordine).
gtaa.py modella 2bps proporzionali: a $600 su 6 gambe l'ordine tipico e' ~$10-30 e la
gtaa.py modellava 2bps proporzionali: a $600 su 6 gambe l'ordine tipico e' ~$10-30 e la
commissione IB minima ($0.35) e' l'**1-3%** del controvalore, cioe' 15-150x il modello.
Qui ogni gamba e' un conto separato: si ribilancia solo se il nozionale supera il minimo
frazionale, e si paga min(max(0.35, 0.0035*azioni), 1%*valore) A OGNI ordine.
@@ -187,10 +188,8 @@ def part_a2() -> dict:
print(" A2) GTAA01 — la BANDA di ribilanciamento puo' salvarlo? (costo fisso IB $0.35/ordine)")
print("=" * 100)
from src.portfolio.gtaa import gtaa_returns
g_model = gtaa_returns()
if g_model.index.tz is None:
g_model.index = g_model.index.tz_localize("UTC")
print(f" riferimento MODELLATO (2bps propor., ribil. continuo): "
g_model = to_daily(gtaa_returns()) # calendario 365: vedi nota in part_a()
print(f" riferimento MODELLATO (costo/banda correnti del modulo): "
f"Sh {metrics(g_model)['sharpe']:.2f} CAGR {metrics(g_model)['cagr']:.1%}")
best = {}
@@ -265,9 +264,10 @@ def part_a() -> dict:
print("\n GTAA01 (IB, 6 ETF, commissione min $0.35/ordine — PAVIMENTO FISSO)")
print(f" {'alloc':>10} {'Sharpe':>8} {'CAGR':>8} {'maxDD':>8} {'drag comm/anno':>15}")
from src.portfolio.gtaa import gtaa_returns
g_model = gtaa_returns()
if g_model.index.tz is None:
g_model.index = g_model.index.tz_localize("UTC")
# to_daily: la serie GTAA grezza ha solo i giorni di borsa (~252/anno) e metrics()
# annualizza a 365 -> senza questa conversione il riferimento e' gonfiato ~1.20x (Sharpe)
# e ~1.45x (CAGR). Le righe realistiche sono gia' su calendario 365.
g_model = to_daily(gtaa_returns())
mg_model = metrics(g_model)
for a in alloc_grid:
s = gtaa_realistic(a)
@@ -277,7 +277,7 @@ def part_a() -> dict:
print(f" {a:>10,.0f} {m['sharpe']:>8.2f} {m['cagr']:>8.1%} {m['maxdd']:>8.1%}"
f" {drag:>15.2%}")
print(f" {'MODELLATO':>10} {mg_model['sharpe']:>8.2f} {mg_model['cagr']:>8.1%} {mg_model['maxdd']:>8.1%}"
f" {'(2bps propor.)':>15}")
f" {'(modulo, banda)':>15}")
out["_models"] = dict(TP01=m_model, GTAA01=mg_model)
return out
+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")
+52
View File
@@ -40,3 +40,55 @@ def test_gtaa_weekend_zero_e_attivazione():
def test_book_live_deribit_non_include_gtaa():
names = [s.name for s in deribit_book_sleeves()]
assert names == ["TP01_trend_1d", "SKH01_skyhook"] # esecuzione live invariata
# ---------------------------------------------------------------------------
# COSTO IB REALE + BANDA (fix 2026-07-25, diario 2026-07-25-rendita-capcurve-prop.md §1)
# ---------------------------------------------------------------------------
from src.portfolio.gtaa import (GTAA_DEFAULT_CAPITAL, GTAA_MIN_CAPITAL, gtaa_is_deployable,
gtaa_rebalance_plan, gtaa_returns, ib_commission)
from src.portfolio.portfolio import metrics, to_daily
def test_commissione_ib_ha_un_PAVIMENTO_non_e_proporzionale():
"""Il difetto originale: modellare IB come fee proporzionale. Su un ordine piccolo la
commissione e' il MINIMO FISSO ($0.35), cioe' una percentuale enorme; su uno grande e'
per-azione. Se questo test cade, il modello e' tornato proporzionale."""
c_small = ib_commission(20.0, 100.0) # ordine da $20
c_big = ib_commission(20_000.0, 100.0) # ordine da $20.000
assert c_small == pytest.approx(0.20) # cap 1% del controvalore morde sui micro-ordini
assert c_small / 20.0 > 0.005 # >0.5% dell'ordine: il piccolo paga carissimo
assert c_big / 20_000.0 < 0.001 # il grande paga pochi bps
assert ib_commission(200.0, 100.0) == pytest.approx(0.35) # pavimento fisso
def test_lo_sleeve_dipende_dal_CAPITALE_e_non_e_deployabile_sotto_soglia():
"""Il modello vecchio era CIECO AL CAPITALE: dava lo stesso Sharpe a $600 e a $50k. Ora no.
E sotto GTAA_MIN_CAPITAL lo sleeve si dichiara non deployabile."""
assert not gtaa_is_deployable(600.0)
assert not gtaa_is_deployable(GTAA_MIN_CAPITAL - 1)
assert gtaa_is_deployable(GTAA_DEFAULT_CAPITAL)
piccolo = metrics(to_daily(gtaa_returns(capital=600.0)))
grande = metrics(to_daily(gtaa_returns(capital=50_000.0)))
assert grande["cagr"] > piccolo["cagr"], "il pavimento fisso deve penalizzare il capitale piccolo"
def test_senza_banda_a_basso_capitale_lo_sleeve_e_DISTRUTTIVO():
"""La ragione per cui la banda esiste: a $600, ribilanciando in continuo (banda 0, ogni
giorno), il pavimento di commissione rende lo sleeve NEGATIVO. E' il difetto trovato."""
nudo = metrics(to_daily(gtaa_returns(capital=600.0, band_usd=0.0, every=1)))
bandato = metrics(to_daily(gtaa_returns(capital=600.0)))
assert nudo["cagr"] < 0.0, "senza banda a $600 lo sleeve deve perdere (pavimento IB)"
assert bandato["cagr"] > 0.0, "con banda+cadenza a $600 deve tornare positivo"
def test_piano_di_ribilanciamento_salta_le_gambe_sotto_banda():
"""L'esecutore deve passare da qui: le gambe il cui nozionale non supera la banda NON si
toccano (un ordine da $12 costa $0.35 = 2.9%)."""
grande = gtaa_rebalance_plan({}, 10_000.0)
assert grande["deployable"] and grande["orders"], "a $10k da flat si deve poter operare"
assert all(o["notional"] >= 50.0 for o in grande["orders"].values())
piccolo = gtaa_rebalance_plan({}, 300.0)
assert not piccolo["deployable"]
# a $300 su 6 gambe gli ordini sono ~$50 o meno -> quasi tutto sotto banda
assert len(piccolo["orders"]) < len(grande["orders"])