merge: fix GTAA01 — costo IB a pavimento fisso + esecuzione a banda
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user