Files
PythagorasGoal/src/portfolio/gtaa.py
T
Adriano Dal Pastro b3082de9af ⚠️ GTAA01: negoziabilita' degli strumenti MAI VERIFICATA (rischio aperto)
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>
2026-07-26 22:01:26 +00:00

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"))