diff --git a/CLAUDE.md b/CLAUDE.md index af7e5e4..9874695 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/diary/2026-07-25-rendita-capcurve-prop.md b/docs/diary/2026-07-25-rendita-capcurve-prop.md index aaf0b68..509d3d9 100644 --- a/docs/diary/2026-07-25-rendita-capcurve-prop.md +++ b/docs/diary/2026-07-25-rendita-capcurve-prop.md @@ -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 diff --git a/scripts/research/r0725_capcurve.py b/scripts/research/r0725_capcurve.py index 2e2c79c..7f10c9d 100644 --- a/scripts/research/r0725_capcurve.py +++ b/scripts/research/r0725_capcurve.py @@ -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 diff --git a/src/portfolio/gtaa.py b/src/portfolio/gtaa.py index 67dc7f1..8546f12 100644 --- a/src/portfolio/gtaa.py +++ b/src/portfolio/gtaa.py @@ -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")) diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index 79d7b9a..b86ee93 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -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") diff --git a/tests/test_gtaa_sleeve.py b/tests/test_gtaa_sleeve.py index 58de15a..82e09bf 100644 --- a/tests/test_gtaa_sleeve.py +++ b/tests/test_gtaa_sleeve.py @@ -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"])