From 61180637eb56f784c38b55836320379125a66a57 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 22 Jun 2026 20:56:18 +0000 Subject: [PATCH] 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) --- .env.ibgw.example | 7 + .gitignore | 2 + docker-compose.yml | 17 +++ docs/diary/2026-06-22-funding-carry-hl.md | 96 ++++++++++++ scripts/research/fetch_hl_funding.py | 137 +++++++++++++++++ scripts/research/funding_carry_hl.py | 170 ++++++++++++++++++++++ scripts/research/ib_probe.py | 108 ++++++++++++++ 7 files changed, 537 insertions(+) create mode 100644 .env.ibgw.example create mode 100644 docs/diary/2026-06-22-funding-carry-hl.md create mode 100644 scripts/research/fetch_hl_funding.py create mode 100644 scripts/research/funding_carry_hl.py create mode 100644 scripts/research/ib_probe.py diff --git a/.env.ibgw.example b/.env.ibgw.example new file mode 100644 index 0000000..dd6e703 --- /dev/null +++ b/.env.ibgw.example @@ -0,0 +1,7 @@ +# Credenziali IB Gateway PAPER per la ricerca dati (account paper, es. DUQ513966). +# COPIA questo file in .env.ibgw (gitignored) e riempi i valori REALI. +# cp .env.ibgw.example .env.ibgw && chmod 600 .env.ibgw && nano .env.ibgw +# NON committare mai .env.ibgw. Sono credenziali del CONTO PAPER (nessun denaro reale), +# l'API e' comunque READ_ONLY (solo dati storici, nessun ordine). +TWS_USERID=il_tuo_username_paper +TWS_PASSWORD=la_tua_password_paper diff --git a/.gitignore b/.gitignore index d6e1726..61156b6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ build/ .venv/ .env !.env.example +.env.ibgw +!.env.ibgw.example .vscode/ .idea/ .DS_Store diff --git a/docker-compose.yml b/docker-compose.yml index 0f82682..ce2a3e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,3 +13,20 @@ services: # token mainnet (sola lettura) per lo "Shadow live": conto/posizioni reali sulla dashboard. # Montato a runtime (NON nell'immagine: .env.mainnet e' dockerignored). Solo letture, nessun ordine. - ./.env.mainnet:/app/.env.mainnet:ro + + # IB Gateway (PAPER) per la RICERCA DATI Interactive Brokers — replica il setup provato di BuzWay + # (scout). IBC fa login automatico headless; nessuna GUI desktop. API READ-ONLY (solo dati storici, + # MAI ordini). Bind SOLO su 127.0.0.1 -> non esposto in rete. Credenziali in .env.ibgw (gitignored). + # host 4002 -> container 4004 (socat paper), esattamente come nel connect("127.0.0.1", 4002). + ib-gateway: + image: ghcr.io/gnzsnz/ib-gateway:stable + container_name: pythagoras-ibgw + restart: unless-stopped + env_file: .env.ibgw + environment: + TRADING_MODE: paper + READ_ONLY_API: "yes" # SOLO dati: nessun ordine possibile via API + TWOFA_TIMEOUT_ACTION: restart + TIME_ZONE: Europe/Rome + ports: + - "127.0.0.1:4002:4004" # gateway paper (socat) raggiungibile solo da localhost dell'host diff --git a/docs/diary/2026-06-22-funding-carry-hl.md b/docs/diary/2026-06-22-funding-carry-hl.md new file mode 100644 index 0000000..8790211 --- /dev/null +++ b/docs/diary/2026-06-22-funding-carry-hl.md @@ -0,0 +1,96 @@ +# 2026-06-22 — Funding-CARRY cross-sectional su Hyperliquid (FC01): LEAD fragile, NON regge + +## Contesto + +Onda "nuova ricerca mirata" (l'utente ha chiesto di cercare un angolo non coperto dalle due grandi +ondate — sweep 104-ipotesi e ortho relative-value, entrambe esaurite sul *prezzo* BTC/ETH). L'unico +meccanismo con una **fonte di ritorno diversa** non ancora testato su dati certi è il **carry da +funding**: incassare il cashflow dei perp stando delta-neutral. + +### Scan di fattibilità dati (prima di tutto, lezione v2.0.0) +- **Funding price-clock** (drift attorno agli stamp 00/08/16) sul feed Deribit certificato → + già testato nell'onda intraday (`agent_03_funding_clock_15m`) = **FAIL** ("il funding è un cashflow + perp-vs-spot; il prezzo index non ha drift tradabile attorno allo stamp al netto del trend"). +- **Funding carry su Deribit** (dove eseguiamo) → ccxt `fetch_funding_rate_history` = **0 righe** + (bloccato), Cerbero MCP espone solo `get_historical` (candele), endpoint funding = 404. +- **Funding carry su Hyperliquid** → API pubblica `/info {"type":"fundingHistory"}` = **disponibile**, + oraria, tokenless, serie native dal 2023-05. HL è già l'universo certificato di XS01. + +### Dato scaricato e certificato +`scripts/research/fetch_hl_funding.py` (backoff anti-429) → **19 major** (gli stessi di XS01), +`data/raw/hlfund__1h.parquet`. Certificazione: cadenza ~1h, **0 gap**, copertura 98-100%, +funding annualizzato per asset da **APT +1.0%** a **NEAR +21.6%** (mediana ~+11.7%). Pochi `cap_hit` +(ore con |funding|>0.06%/h) su INJ/TIA/SEI, plausibili in alt ad alta vol. Dato pulito. + +## Ipotesi e costruzione (FC01) + +Book dollar-neutral che **SHORTA i k perp ad alto funding** e **COMPRA i k a basso** → incassa il +premio (chi è long paga il funding). Ritorno perp per un long = `price_ret − funding`. Causale come +XS01: ogni H=10 giorni, segnale = media causale del funding giornaliero realizzato sugli ultimi L +giorni (shift 1), rank cross-section, vol-target 20%, fee 0.05%/lato sul turnover. +`scripts/research/funding_carry_hl.py`. Domanda chiave: **edge reale e ORTOGONALE a XS01**, o XS01 +travestito? (gli alt ad alto funding sono spesso i pompati = quelli che XS01 *compra*; qui li +*shortiamo* → potenziale anti-correlazione, oppure il carry domina). + +## Risultati + +### Premio reale ma direzione-dipendente +`carry` (short alto-funding) batte sistematicamente `anti` (long alto-funding, sempre molto negativo) +→ **il premio di funding esiste**: shortare i perp ad alto funding paga, in aggregato. + +### Ma il book NON regge il gauntlet (19 asset, 2024-2026, 904g) +- **Standalone base (L=7 k=5): FULL Sharpe −0.12, in-sample 0.44, HOLD −0.50, DD 28.6%**, −2.8%/anno. + Decadimento netto: 2024 **+0.44** → 2025 −0.06 → 2026 **−1.42**. +- Correlazioni: TP01 −0.02, **XS01 −0.19** (ortogonale, come da ipotesi — NON è XS01 travestito), + VRP01 +0.05. +- **`marginal_vs_tp01` = DILUTES**: `has_insample_edge=False` (in-sample 0.44 < 0.5), + `multicut_persistent=False`, blend w25 uplift FULL −0.21 / HOLD −0.39. +- **Non aggiunge a XS01**: uplift w25 FULL −0.04 / HOLD −0.19. + +### Il colpo di grazia: FRAGILITÀ all'universo +Un preview su 17 asset (mancavano NEAR e AAVE) dava FULL **+0.62**, ADDS, +0.22 uplift — un PASS +tentatore. Sui 19 completi: **DILUTES**. Jackknife lascia-fuori-uno (base L=7 k=5): + +``` +19 asset: FULL -0.12 HOLD -0.50 +-SUI FULL -0.39 ... -BTC FULL +0.17 +-SEI FULL -0.31 -AAVE FULL +0.26 +-BNB FULL -0.29 -NEAR FULL +0.30 +=> FULL oscilla in [-0.39, +0.30] togliendo UN solo asset (range 0.70), attraversa lo zero. +``` + +Togliere **NEAR o AAVE** (i due assenti nel preview) **recupera il segno** → il preview era fortunato +*proprio* perché quei due non c'erano ancora. **Un edge robusto non cambia segno per un singolo nome.** +Le poche celle "buone" del plateau (es. L=7 k=3: HOLD 0.91) hanno **in-sample debole + hold-out forte** += la firma del hold-out-luck che la metodologia indurita uccide. + +## Perché fallisce (meccanismo) + +Tensione fondamentale **carry vs momentum**: il funding-carry shorta i forti (alto funding = domanda +long aggressiva), ma in un mercato alt toro i forti **continuano a correre** (NEAR/AAVE: alto funding +*e* prezzo su → shortarli perde più del premio incassato). Il premio di funding è reale in aggregato, +ma il book cross-sectional equal-weight top-k è dominato da pochi nomi a funding estremo che *anche* +trendano, e su 2.5 anni / 19 nomi questo basta a ribaltare il segno. + +## Verdetto + +**FC01 NON è uno sleeve.** Né deploy (è STAT-MODE: 10 gambe market-neutral, non eseguibile a $600), +né lead affidabile: fragile all'universo (sign-flip su un nome), DILUTES vs TP01, non aggiunge a XS01, +in-sample edge < 0.5, niente persistenza multi-cut, decadimento 2026. Conferma — di nuovo — il +soffitto del progetto: promettente su un sottoinsieme fortunato, collassa sotto il gauntlet onesto. +**Win metodologico:** lo scorer indurito + il jackknife d'universo hanno intercettato un falso +positivo che il preview a 17 asset avrebbe promosso. + +## Lascito / lavoro futuro (NON inseguire ora) +- I 19 parquet funding (`hlfund_*`) restano certificati per ricerca futura. Il fetcher NON va in cron + (FC01 fallito → niente da monitorare in forward). +- Idee se mai si tornasse sul carry (NON ora): (a) **gate sul LIVELLO** di funding (short solo quando + estremo, regime-filter alla VRP01 IV-rank) invece dello short-top-k incondizionato; (b) cap sul peso + per-nome / neutralizzazione momentum per togliere il dominio NEAR/AAVE. Entrambe rischiano + overfitting su storia corta — soglia di prova alta. + +## Nota IB (thread parallelo, stessa sessione) +Esplorato come fonte per il **basis CME crypto** (cugino eseguibile del carry). Gateway paper +`gnzsnz/ib-gateway` su `127.0.0.1:4002` (read-only, `docker-compose.yml`), sonda `ib_probe.py`. +Esito dati: **backtest del basis NON fattibile** (ContFuture back-adjusted; contratti scaduti = 1 +barra). IB resta valido per esecuzione/forward, non per scoprire l'edge. Dettagli nel corpo sessione. diff --git a/scripts/research/fetch_hl_funding.py b/scripts/research/fetch_hl_funding.py new file mode 100644 index 0000000..7172b73 --- /dev/null +++ b/scripts/research/fetch_hl_funding.py @@ -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__1h.parquet (namespace dedicato 'hlfund', NON tocca hl__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__1h.parquet (funding ORARIO, serie nativa).") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/funding_carry_hl.py b/scripts/research/funding_carry_hl.py new file mode 100644 index 0000000..bbbc04c --- /dev/null +++ b/scripts/research/funding_carry_hl.py @@ -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() diff --git a/scripts/research/ib_probe.py b/scripts/research/ib_probe.py new file mode 100644 index 0000000..5ca28fa --- /dev/null +++ b/scripts/research/ib_probe.py @@ -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()