research(funding-carry): FC01 cross-sectional su HL -> fragile, NON regge + infra IB paper

Onda "nuova ricerca mirata". Unico meccanismo non coperto dalle 2 ondate: carry da
funding (cashflow perp, delta-neutral). Scan dati: price-clock gia' FAIL (intraday),
Deribit ccxt 0 righe, Cerbero solo candele -> fonte = API pubblica Hyperliquid.

- fetch_hl_funding.py: 19 major, funding orario reale dal 2023-05, certificato
  (0 gap, cov 98-100%, ann +1.0% APT .. +21.6% NEAR). backoff anti-429.
- funding_carry_hl.py: book dollar-neutral short-alto-funding/long-basso, causale come
  XS01, vol-target 20%, fee 0.05%/lato. Giudizio: marginal_vs_tp01 indurito + overlap XS01.

VERDETTO: il premio esiste (carry >> anti) ma il book NON regge il gauntlet.
  FULL -0.12, HOLD -0.50, DILUTES vs TP01, in-sample edge <0.5, no multicut.
  Jackknife universo: FULL oscilla [-0.39,+0.30] togliendo UN asset -> FRAGILE/overfit.
  (preview a 17 asset era +0.62 ADDS: fortuna, mancavano NEAR/AAVE). corr XS01 -0.19
  (ortogonale, non re-skin). Meccanismo: carry-vs-momentum, gli alto-funding pompano.
  -> NON entra in portafoglio, fetcher NON in cron. Diario completo.

Infra IB (thread parallelo): gateway paper gnzsnz/ib-gateway (127.0.0.1:4002, READ_ONLY)
in docker-compose + ib_probe.py. Esito dati basis CME micro: backtest NON fattibile
(ContFuture back-adjusted, scaduti=1 barra). IB ok per esecuzione/forward, non ricerca.
.env.ibgw gitignored (credenziali paper), template in .env.ibgw.example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-22 20:56:18 +00:00
parent 745ba7d066
commit 61180637eb
7 changed files with 537 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
"""FETCH + CERTIFY funding rate Hyperliquid (API pubblica, tokenless) — per ricerca CARRY cross-sectional.
CONTESTO (2026-06-22, onda "nuova ricerca mirata"). Le due grandi ondate (sweep 104-ipotesi +
ortho relative-value) hanno esaurito gli angoli DIREZIONALI e RELATIVE-VALUE sul *prezzo* BTC/ETH.
L'unico meccanismo con una fonte di ritorno DIVERSA non ancora testato su dati certi e' il CARRY da
funding (incassare il cashflow perp, delta-neutral). Scan di fattibilita':
* funding price-clock sul feed Deribit certificato -> gia' testato (agent_03 intraday) = FAIL.
* funding carry su Deribit (dove eseguiamo) -> ccxt fetch_funding_rate_history = 0 righe (bloccato).
* funding carry su Hyperliquid -> API pubblica /info {"type":"fundingHistory"} = DISPONIBILE,
cadenza ORARIA, tokenless, serie native dal 2023-05-12. HL e' gia' l'universo certificato di XS01.
DISCIPLINA (lezione v2.0.0): nessuna fiducia nel dato finche' non e' certificato. Qui certifichiamo:
(1) cadenza ~1h coerente, (2) gap interni, (3) copertura (giorni nativi reali per coin),
(4) plausibilita' magnitudine (|funding orario| tipico < ~0.06%/h = cap HL; outlier flaggati),
(5) il funding e' un CASHFLOW, non un prezzo -> niente cross-venue OHLC; il sanity check e'
che il funding medio sia ~positivo e piccolo (premio long-pays-short tipico dei perp crypto).
Universo = i 19 major di XS01 (quelli che la strategia live userebbe). Output:
data/raw/hlfund_<sym>_1h.parquet (namespace dedicato 'hlfund', NON tocca hl_<sym>_1d di XS01).
"""
import sys, time, datetime as dt
from pathlib import Path
import numpy as np, pandas as pd, requests
ROOT = Path(__file__).resolve().parents[2]
RAW = ROOT / "data" / "raw"
RAW.mkdir(parents=True, exist_ok=True)
# 19 major di XS01 (CLAUDE.md)
UNIVERSE = ["BTC","ETH","SOL","BNB","XRP","DOGE","AVAX","LINK","LTC","ADA",
"ARB","OP","SUI","APT","INJ","TIA","SEI","NEAR","AAVE"]
HL_INFO = "https://api.hyperliquid.xyz/info"
START = int(dt.datetime(2023, 1, 1, tzinfo=dt.timezone.utc).timestamp() * 1000)
HOUR_MS = 3600 * 1000
def _post(payload, max_retry=6):
"""POST con backoff esponenziale su 429/5xx (l'API pubblica HL throttla)."""
delay = 1.0
for attempt in range(max_retry):
r = requests.post(HL_INFO, json=payload, timeout=30)
if r.status_code == 429 or r.status_code >= 500:
time.sleep(delay)
delay = min(delay * 2, 20) # 1,2,4,8,16,20
continue
r.raise_for_status()
return r.json()
r.raise_for_status()
return r.json()
def fetch_funding(coin: str) -> pd.DataFrame:
"""Pagina fundingHistory (max 500/req, orario) avanzando startTime fino a oggi."""
rows, start = [], START
seen = set()
while True:
d = _post({"type": "fundingHistory", "coin": coin, "startTime": start})
if not d:
break
new = [x for x in d if x["time"] not in seen]
for x in new:
seen.add(x["time"])
rows.append((x["time"], float(x["fundingRate"]), float(x.get("premium", "nan"))))
last = d[-1]["time"]
if len(d) < 500: # ultima pagina
break
nxt = last + 1
if nxt <= start: # niente progresso -> stop
break
start = nxt
time.sleep(0.35) # gentile con l'API pubblica
if not rows:
return pd.DataFrame(columns=["ts", "funding", "premium"]).set_index("ts")
df = pd.DataFrame(rows, columns=["ts", "funding", "premium"]).drop_duplicates("ts").sort_values("ts")
df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
return df.set_index("ts")
def certify(coin: str, df: pd.DataFrame) -> dict:
if df.empty:
return {"coin": coin, "n": 0, "status": "VUOTO"}
idx = df.index
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
# cadenza: differenze in ore
deltas_h = np.diff(idx.view("int64")) / 1e9 / 3600
median_dt = float(np.median(deltas_h)) if len(deltas_h) else float("nan")
gaps = int((deltas_h > 1.5).sum()) # buchi > 1.5h
expected = int(round(span_days * 24)) + 1
coverage = len(df) / expected if expected else float("nan")
f = df["funding"].values
# statistiche funding (orario)
ann = float(np.nanmean(f)) * 24 * 365 # funding annualizzato (carry teorico per chi paga)
cap_hits = int((np.abs(f) > 0.0006).sum()) # cap HL ~0.06%/h (4%/8h clamp); fuori = sospetto
status = "OK"
if coverage < 0.97 or gaps > 50:
status = "GAP"
if span_days < 365:
status = "corto<365g"
return {"coin": coin, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
"giorni": round(span_days), "cad_h": round(median_dt, 3), "gap>1.5h": gaps,
"cover%": round(coverage * 100, 1), "fund_med_bps": round(float(np.nanmedian(f)) * 1e4, 4),
"fund_ann%": round(ann * 100, 1), "cap_hit": cap_hits, "status": status}
def main():
print("=" * 100)
print(" FETCH + CERTIFY funding Hyperliquid (orario, tokenless) — 19 major XS01 -> data/raw/hlfund_*")
print("=" * 100)
rep = []
for sym in UNIVERSE:
try:
df = fetch_funding(sym)
except Exception as e:
print(f" {sym:5} ERR {repr(e)[:80]}")
rep.append({"coin": sym, "n": 0, "status": "ERR"})
continue
c = certify(sym, df)
rep.append(c)
if c.get("n", 0) > 0:
out = RAW / f"hlfund_{sym.lower()}_1h.parquet"
df.to_parquet(out)
print(f" {sym:5} n={c.get('n',0):>6} {str(c.get('primo','')):>10}->{str(c.get('ultimo','')):>10} "
f"cad={c.get('cad_h','?')}h gap={c.get('gap>1.5h','?')} cov={c.get('cover%','?')}% "
f"med={c.get('fund_med_bps','?')}bps ann={c.get('fund_ann%','?')}% cap_hit={c.get('cap_hit','?')} "
f"[{c['status']}]")
ok = [r["coin"] for r in rep if r.get("status") in ("OK",)]
short = [r["coin"] for r in rep if r.get("status") == "corto<365g"]
print("-" * 100)
print(f" CERTIFICATI OK ({len(ok)}): {ok}")
if short:
print(f" CORTI <365g ({len(short)}): {short}")
print(f" Scritti in data/raw/hlfund_<sym>_1h.parquet (funding ORARIO, serie nativa).")
if __name__ == "__main__":
main()
+170
View File
@@ -0,0 +1,170 @@
"""FC01 — Funding-CARRY cross-sectional su Hyperliquid (backtest onesto, STAT-MODE).
IPOTESI. Il funding dei perp e' un CASHFLOW (long pagano short quando f>0). Un book
dollar-neutral che VENDE i perp ad alto funding e COMPRA quelli a basso funding INCASSA
il premio di funding (carry). E' una fonte di ritorno DIVERSA dal trend (TP01) e — forse —
dal momentum cross-sectional (XS01). Domanda chiave: e' un edge reale o solo XS01 travestito?
(gli asset ad alto funding sono spesso quelli pompati = forti = quelli che XS01 COMPRA; qui li
SHORTIAMO -> potenziale ANTI-correlazione con XS01, oppure il carry domina).
DATI (certificati). funding orario reale HL (scripts/research/fetch_hl_funding.py, dal 2023-05) +
prezzi HL 1d (data/raw/hl_*_1d.parquet, gli stessi di XS01). Universo = i 19 major di XS01.
COSTRUZIONE (causale, come XS01). Ogni H giorni: segnale = media causale del funding giornaliero
realizzato sugli ultimi L giorni (solo dati <= i-1). Rank cross-section; SHORT i k ad alto funding,
LONG i k a basso (dollar-neutral). Ritorno del perp per un LONG = price_ret - funding (chi e' long
paga il funding); quindi r_book[i] = sum_a w[i-1,a] * (price_ret[i,a] - funding_realizzato[i,a]),
meno fee sul turnover (0.05%/lato, come XS01), poi vol-target 20%.
GIUDIZIO (metodologia indurita). Standalone (FULL/in-sample/hold-out, DD, anni) + correlazione a
TP01/XS01/VRP01 + marginal_vs_tp01 (gate: edge in-sample>=0.5, persistenza multi-cut, hedge-vs-alpha,
noise-null) + UPLIFT vs XS01 (la domanda di overlap) + plateau su L/k/direzione.
CAVEAT ONESTO PRE-RISULTATO: e' market-neutral a 10 gambe -> NON eseguibile a $600 (STAT-MODE come
XS01/VRP01). Il deliverable e' "esiste un carry reale e ORTOGONALE a XS01?", non un deploy.
"""
import sys
from pathlib import Path
import numpy as np, pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
from src.portfolio.sleeves import XS_UNIVERSE, _xsec_returns, _vrp_combo_returns, _HL_DIR
import altlib as L
from altlib import _sh, _dd_ret, _to_daily, _uplift_series, HOLDOUT, marginal_vs_tp01
FEE_SIDE = 0.0005 # 0.10% RT / 2, come XS01
def _load_panel():
"""Ritorna (PX, FUND): due DataFrame daily allineati [date x asset] con prezzo e funding
giornaliero realizzato (somma oraria). Solo asset con ENTRAMBI i dati."""
px, fund = {}, {}
for sym in XS_UNIVERSE:
pp = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
if not (pp.exists() and fp.exists()):
continue
dp = pd.read_parquet(pp)
px[sym] = pd.Series(dp["close"].values.astype(float),
index=pd.to_datetime(dp["timestamp"], unit="ms", utc=True)).resample("1D").last()
df = pd.read_parquet(fp) # index ts (orario, utc), col funding
fund[sym] = df["funding"].resample("1D").sum()
PX = pd.concat(px, axis=1).sort_index()
FUND = pd.concat(fund, axis=1).sort_index().reindex(PX.index)
# tieni solo le date con prezzi per tutti + funding noto (0 dove manca un giorno isolato)
common = PX.dropna(how="any").index.intersection(FUND.index)
return PX.loc[common], FUND.loc[common].fillna(0.0)
def carry_returns(L_lb=7, H=10, k=5, direction="carry", target_vol=0.20,
fee_side=FEE_SIDE) -> pd.Series:
"""Serie daily netta del book funding-carry cross-sectional. direction='carry' shorta l'alto
funding (incassa il premio); 'anti' lo compra."""
PX, FUND = _load_panel()
idx = PX.index
px = PX.values; fnd = FUND.values
n, A = px.shape
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
# segnale = media causale del funding giornaliero sugli ultimi L giorni (shiftato di 1)
sig = pd.DataFrame(fnd, index=idx).rolling(L_lb, min_periods=max(3, L_lb // 2)).mean().shift(1).values
W = np.zeros((n, A)); w = np.zeros(A)
for i in range(n):
if i >= L_lb + 1 and i % H == 0 and np.isfinite(sig[i]).sum() >= 2 * k:
s = sig[i].copy()
order = np.argsort(np.where(np.isfinite(s), s, np.nan))
order = order[np.isfinite(s[order])]
if len(order) >= 2 * k:
w = np.zeros(A)
lo, hi = order[:k], order[-k:] # lo=basso funding, hi=alto funding
if direction == "carry":
w[hi] = -0.5 / k; w[lo] = +0.5 / k # SHORT alto funding (incassa), LONG basso
else:
w[hi] = +0.5 / k; w[lo] = -0.5 / k
W[i] = w
# ritorno del perp per un LONG = price_ret - funding realizzato
perp_ret = dret - fnd
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * perp_ret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
net = gross - turn * fee_side
s = pd.Series(net, index=idx)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return pd.Series(s.values * scale, index=idx)
def _stats(s: pd.Series) -> dict:
s = _to_daily(s)
h = s[s.index >= HOLDOUT]; isamp = s[s.index < HOLDOUT]
yrs = {y: round(_sh(s[s.index.year == y]), 2) for y in sorted(set(s.index.year))}
return dict(n=len(s), full=round(_sh(s), 2), insample=round(_sh(isamp), 2) if len(isamp) > 30 else None,
hold=round(_sh(h), 2) if len(h) > 30 else None, dd=round(_dd_ret(s), 3),
ann_ret=round(float(s.mean() * 365.25), 3), yearly=yrs)
def main():
print("=" * 96)
print(" FC01 — FUNDING-CARRY cross-sectional su Hyperliquid (STAT-MODE)")
print("=" * 96)
PX, FUND = _load_panel()
print(f" panel: {PX.shape[1]} asset x {len(PX)} giorni [{PX.index[0].date()} .. {PX.index[-1].date()}]")
print(f" asset: {list(PX.columns)}")
# funding medio annualizzato per asset (dispersione = materia prima del carry)
fann = (FUND.mean() * 365.25 * 100).round(1).sort_values(ascending=False)
print(f" funding annualizzato% (carry teorico long-pays): "
f"max {fann.index[0]} {fann.iloc[0]:+.1f} min {fann.index[-1]} {fann.iloc[-1]:+.1f} "
f"mediana {fann.median():+.1f} spread {fann.iloc[0]-fann.iloc[-1]:.1f}")
base = carry_returns()
print("\n --- STANDALONE (config base L=7 H=10 k=5, direction=carry) ---")
st = _stats(base)
print(f" FULL Sh {st['full']} in-sample {st['insample']} HOLD {st['hold']} DD {st['dd']*100:.1f}% "
f"ann.ret {st['ann_ret']*100:+.1f}% ({st['n']}g)")
print(f" per anno: {st['yearly']}")
# correlazioni vs gli sleeve attivi
print("\n --- CORRELAZIONE vs sleeve attivi (daily, overlap comune) ---")
refs = {"TP01": L.tp01_baseline_daily(), "XS01": _to_daily(_xsec_returns()),
"VRP01": _to_daily(_vrp_combo_returns())}
bd = _to_daily(base)
for nm, r in refs.items():
J = pd.concat({"c": bd, "r": r}, axis=1, join="inner").dropna()
c = round(float(J["c"].corr(J["r"])), 3) if len(J) > 30 else None
print(f" corr(FC01, {nm}) = {c} (overlap {len(J)}g)")
# marginal vs TP01 (verdetto indurito completo)
print("\n --- MARGINAL vs TP01 (scorer indurito) ---")
m = marginal_vs_tp01(bd)
for key in ("marginal_verdict", "corr_full", "cand_full_sharpe", "cand_insample_sharpe",
"has_insample_edge", "multicut_persistent", "is_hedge", "null_pctl_insample"):
print(f" {key:22} = {m.get(key)}")
print(f" blend w25: {m.get('blends', {}).get('w25')}")
print(f" multicut_uplift: {m.get('multicut_uplift')}")
# UPLIFT vs XS01 (la domanda di overlap): XS01 da solo vs blend(XS01, FC01)
print("\n --- UPLIFT vs XS01 (aggiunge a XS01 o e' ridondante?) ---")
xs = refs["XS01"]
J = pd.concat({"xs": xs, "fc": bd}, axis=1, join="inner").dropna()
JH = J[J.index >= HOLDOUT]
for w in (0.25, 0.5):
bf = _sh((1 - w) * J["xs"] + w * J["fc"]) - _sh(J["xs"])
bh = (_sh((1 - w) * JH["xs"] + w * JH["fc"]) - _sh(JH["xs"])) if len(JH) > 30 else None
print(f" w={w}: uplift FULL {bf:+.3f} HOLD {bh:+.3f}" if bh is not None
else f" w={w}: uplift FULL {bf:+.3f}")
print(f" XS01 standalone: FULL {_sh(J['xs']):.2f} | FC01 standalone su overlap: {_sh(J['fc']):.2f}")
# PLATEAU su L, k, direzione
print("\n --- PLATEAU (FULL / in-sample / HOLD Sharpe) ---")
print(f" {'cfg':22} {'FULL':>6} {'in-s':>6} {'HOLD':>6} {'DD%':>6}")
for direction in ("carry", "anti"):
for Llb in (3, 7, 14, 30):
for k in (3, 5):
s = _stats(carry_returns(L_lb=Llb, k=k, direction=direction))
tag = f"{direction} L={Llb} k={k}"
print(f" {tag:22} {str(s['full']):>6} {str(s['insample']):>6} {str(s['hold']):>6} {s['dd']*100:>5.1f}")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
"""IB DATA PROBE — enumera cosa un conto paper Interactive Brokers espone (dati storici).
NON e' una strategia: e' lo scan di FATTIBILITA' DATI, gemello di certify_feed.py per il mondo IB.
Disciplina v2.0.0: prima il dato (cosa c'e', quanto indietro, che qualita', cosa costa), poi la
strategia. "Solo dati, decido dopo".
PREREQUISITO: una sessione IB Gateway/TWS PAPER loggata e raggiungibile (default 127.0.0.1:4002).
Tipico: avvii IB Gateway (Paper) sul tuo PC con API abilitata su 4002, poi reverse-tunnel
SSH verso questo server: ssh -R 4002:localhost:4002 utente@server
ESECUZIONE (senza sporcare le dipendenze del progetto):
uv run --with ib_async python scripts/research/ib_probe.py
uv run --with ib_async python scripts/research/ib_probe.py --port 7497 # TWS paper
Cosa fa, in ordine, e si ferma con diagnosi chiara al primo errore:
(1) connette e stampa server version + account paper;
(2) risolve un set di contratti rilevanti per QUESTO progetto:
- CME crypto: BTC (5 BTC), MBT (micro 0.1 BTC), ETH, MET (micro); -> per il BASIS/carry
- spot crypto Paxos (se abilitato): BTC, ETH;
- 2 azioni/ETF di riferimento (SPY, AAPL) per tarare durate/qualita';
(3) per ogni contratto risolto: chiede un piccolo storico (durata breve, 1 day bars) e riporta
n barre, range, e se scatta un errore di SUBSCRIPTION mancante (codice 354/10089/10090...);
(4) sintesi: cosa e' scaricabile GRATIS su paper vs cosa richiede market-data a pagamento.
"""
import argparse, sys
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=7497, help="7497=TWS paper (default), 4002=GW paper, 4001/7496=live")
ap.add_argument("--client-id", type=int, default=77)
args = ap.parse_args()
try:
from ib_async import IB, Future, Stock, Crypto, util
except Exception:
print("ib_async non importabile. Esegui con: uv run --with ib_async python scripts/research/ib_probe.py")
sys.exit(2)
ib = IB()
try:
ib.connect(args.host, args.port, clientId=args.client_id, timeout=15)
except Exception as e:
print(f"[CONNESSIONE FALLITA] {args.host}:{args.port} -> {repr(e)[:160]}")
print(" Verifica: IB Gateway/TWS Paper acceso, API abilitata, porta giusta, tunnel attivo.")
sys.exit(1)
print("=" * 90)
print(f" IB DATA PROBE — connesso {args.host}:{args.port} | serverVersion={ib.client.serverVersion()}")
try:
accts = ib.managedAccounts()
print(f" account: {accts} (paper se inizia per 'D' tipicamente)")
except Exception as e:
print(f" account: ? ({repr(e)[:60]})")
print("=" * 90)
# (2) contratti rilevanti
candidates = []
# CME crypto futures: lasciamo che IB scelga il front-month (no expiry -> reqContractDetails)
candidates += [("CME BTC fut", Future("BTC", exchange="CME")),
("CME MBT micro", Future("MBT", exchange="CME")),
("CME ETH fut", Future("ETH", exchange="CME")),
("CME MET micro", Future("MET", exchange="CME"))]
# spot crypto Paxos (puo' non essere abilitato su paper)
candidates += [("Paxos BTC", Crypto("BTC", exchange="PAXOS", currency="USD")),
("Paxos ETH", Crypto("ETH", exchange="PAXOS", currency="USD"))]
# riferimenti equity
candidates += [("SPY ETF", Stock("SPY", "SMART", "USD")),
("AAPL", Stock("AAPL", "SMART", "USD"))]
resolved = []
print("\n (A) RISOLUZIONE CONTRATTI")
for label, c in candidates:
try:
cds = ib.reqContractDetails(c)
if not cds:
print(f" {label:16} -> NESSUN match")
continue
# per i futures prendi la scadenza piu' vicina disponibile
cd = sorted(cds, key=lambda d: getattr(d.contract, "lastTradeDateOrContractMonth", "") or "")[0]
con = cd.contract
extra = f" exp={con.lastTradeDateOrContractMonth}" if getattr(con, "lastTradeDateOrContractMonth", "") else ""
print(f" {label:16} -> OK {con.localSymbol or con.symbol} {con.exchange}{extra} (n match={len(cds)})")
resolved.append((label, con))
except Exception as e:
print(f" {label:16} -> ERR {repr(e)[:70]}")
# (3) prova storico breve
print("\n (B) STORICO DI PROVA (durata 10 D, barre 1 day)")
for label, con in resolved:
try:
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="10 D",
barSizeSetting="1 day", whatToShow="TRADES",
useRTH=False, formatDate=1, timeout=30)
if not bars:
print(f" {label:16} -> 0 barre (forse serve subscription o whatToShow diverso)")
else:
print(f" {label:16} -> {len(bars)} barre {bars[0].date} .. {bars[-1].date} close={bars[-1].close}")
except Exception as e:
print(f" {label:16} -> ERR {repr(e)[:90]}")
print("\n (C) NOTE")
print(" - errori 354/10089/10090/10168 = market-data subscription mancante (paper la eredita dal live).")
print(" - per il BASIS/carry servono i MULTIPLI futures (front+next) -> poi reqContractDetails senza filtro expiry.")
ib.disconnect()
if __name__ == "__main__":
main()