b3082de9af
Domanda dell'operatore ("GTAA01 puo' essere in revolut?") che scopre un'assunzione mai
controllata in 5 settimane di lavoro sullo sleeve.
GTAA01 usa SPY, QQQ, IWM, TLT, GLD, HYG = 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, che e'
esattamente il venue che lo sleeve assume — li bloccano in acquisto per la clientela
retail.
COSA POGGIA SU QUESTA ASSUNZIONE: i 30 anni di storia, il fix dei costi IB reali del
25/07, la soglia GTAA_MIN_CAPITAL $3.000, il contributo al book, e il risultato del LOO del
26/07 che lo indica come l'UNICO sleeve positivo nel 100% delle estrazioni su tutte e tre
le metriche. Nessuno di questi numeri e' sbagliato come backtest; quello che non e' mai
stato verificato e' se lo sleeve sia ACQUISTABILE dal conto reale dell'operatore.
DA VERIFICARE PRIMA DEL DEPLOY, non dopo. Se il blocco c'e' servono gli equivalenti UCITS
(CSPX/SXR8, EQQQ/SXRV, IUSN/CSUSS, DTLA/IDTL, SGLN/IGLN, IHYU), che sono strumenti DIVERSI
per domicilio, valuta, TER e replica -> rifetch dei dati e rivalidazione, non una
sostituzione di ticker.
SU REVOLUT nello specifico la domanda resta piu' stretta: universo ETF limitato, prodotto
retail leggero, e un ribilanciamento settimanale a 6 gambe con banda $50 non e' cio' per
cui e' pensato. Il confronto va fatto su esistenza degli strumenti e costo per ordine.
Nota permanente nel sorgente sopra EQ_UNIVERSE + bullet in CLAUDE.md.
REGOLA: la negoziabilita' di uno strumento sul conto REALE va verificata quando lo sleeve
entra in RICERCA, non quando entra nel book. E' l'analogo azionario di cio' che il progetto
gia' fa sul crypto (min-order, haircut small-cap, eseguibilita' a $600): la stessa
disciplina non era stata applicata all'equity.
Book, pesi, cron, config INVARIATI (GTAA01 non e' nel book live).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
10 KiB
Python
202 lines
10 KiB
Python
"""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"))
|