"""GTAA — sleeve EQUITY/ETF: trend difensivo multi-asset (analogo di TP01, su IB). Validato sul branch research/equities-ib (diari 2026-06-22-equities-*). Trend long-flat TSMOM multi-orizzonte su un paniere di classi (azioni US/tech/small + bond + oro + credito), equal-weight 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). 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 from pathlib import Path import numpy as np, pandas as pd from src.data.eq_splits import repair_splits RAW = Path(__file__).resolve().parents[2] / "data" / "raw" # ⚠️ NEGOZIABILITA' NON VERIFICATA (sollevato 2026-07-26, mai controllato prima). # Questi sono ETF **domiciliati negli USA**. Sotto il regolamento PRIIPs, un investitore # retail residente nell'UE tipicamente NON puo' acquistarli, perche' gli emittenti USA non # pubblicano il KID: i broker UE (Interactive Brokers incluso) li bloccano in acquisto per la # clientela retail. Tutti i numeri di questo sleeve — 30 anni di storia, costi IB, soglia # $3.000, contributo al book — assumono che siano comprabili, e quella assunzione non e' mai # stata verificata contro il conto REALE dell'operatore. # DA VERIFICARE PRIMA DI QUALSIASI DEPLOY: (a) il broker consente l'acquisto di questi ISIN al # tuo profilo? (b) altrimenti servono gli equivalenti UCITS (CSPX/SXR8, EQQQ/SXRV, IUSN/CSUSS, # DTLA/IDTL, SGLN/IGLN, IHYU) -> strumenti DIVERSI (domicilio, valuta, TER, replica) che # richiedono di rifare fetch e validazione, non una sostituzione di ticker. EQ_UNIVERSE = ("SPY", "QQQ", "IWM", "TLT", "GLD", "HYG") HORIZONS = (21, 63, 126, 252) TARGET_VOL = 0.12 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: p = RAW / f"eq_{sym.lower()}_1d.parquet" if not p.exists(): raise FileNotFoundError(f"{p} assente — gira scripts/research/fetch_ib_equities.py") d = pd.read_parquet(p) d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True) d = d.sort_index() # IB ADJUSTED_LAST non aggiusta SEMPRE gli split: IWM (gamba di questo sleeve) e EFA hanno # uno split non aggiustato il 2005-06-09. Vedi src/data/eq_splits.py. Riparato in lettura. d, _ = repair_splits(d) return pd.Series(d["close"].astype(float).values, index=d.index) def _exposure(close: pd.Series) -> pd.Series: """Esposizione long-flat [0,1] su un asset: frazione di orizzonti in trend-up, vol-targeted, cap 1. Causale (solo dati <= i).""" px = close.values; n = len(px); tgt = np.zeros(n); mh = max(HORIZONS) for i in range(mh, n): tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in HORIZONS]) s = pd.Series(tgt, index=close.index) rv = close.pct_change().rolling(63, min_periods=20).std().shift(1) * ANN scale = np.clip(np.nan_to_num(TARGET_VOL / rv.replace(0, np.nan).values, nan=0.0), 0, 10.0) return (s * scale).clip(0, 1.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, 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. 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: ex = _exposure(_close(a)) out[a] = round(float(ex.iloc[-1]) / n, 4) except FileNotFoundError: continue 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"))