Compare commits
7 Commits
25a22fc7c1
...
8d559c6f33
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d559c6f33 | |||
| 555977d987 | |||
| 8c3868cb31 | |||
| 9d586eeb58 | |||
| 69e3517f1b | |||
| cf72e395d3 | |||
| db738bce3b |
@@ -96,6 +96,42 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
|
|||||||
regime-luck), calendar/seasonality (buy&hold travestito), volume/vol e momentum-reversal (negativi).
|
regime-luck), calendar/seasonality (buy&hold travestito), volume/vol e momentum-reversal (negativi).
|
||||||
- **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero — la vecchia
|
- **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero — la vecchia
|
||||||
libreria +201%/+1238% era contaminazione); trend 5m/15m (fee).
|
libreria +201%/+1238% era contaminazione); trend 5m/15m (fee).
|
||||||
|
- **GAMMA SCALPING (long-vol) "scalping BTC/ETH con copertura in opzioni" — SCARTATO (2026-06-26)** —
|
||||||
|
`scripts/research/options_gamma_scalp.py`, test `tests/test_gamma_scalp.py`. È lo **specchio
|
||||||
|
esatto del VRP01** (long straddle ATM + delta-hedge: incassa **RV−IV**, dove VRP01 incassa IV−RV).
|
||||||
|
Perde **ogni anno, ogni variante, ogni frequenza** (Sharpe −3 a −6; nudo/cheap-gated/rich-skip;
|
||||||
|
rehedge 1d e 1h). Diagnostica strutturale: a 1d IV≈o>RV (BTC +4.9pp) → paghi il VRP; a 1h RV>IV
|
||||||
|
gross ma (a) gonfiata da microstruttura, (b) il rehedge orario paga **24× la fee di hedge** →
|
||||||
|
variante *peggiore* (−6). Marginale vs TP01 = **DILUTES**, non è nemmeno hedge (perde sia TP01-up
|
||||||
|
sia TP01-down). Muro eseguibilità: opzione BTC min $5.968 ≫ $600. Schiacciato tra due muri:
|
||||||
|
rehedge lento = premio, veloce = fee → **nessuna frequenza vince.** Regola gemella del VRP:
|
||||||
|
*niente long-vol scalp da modello in deploy*. Il VRP01 (lato short, gated) resta l'unico edge
|
||||||
|
opzioni — funziona perché sta sul lato *giusto* dello stesso premio. Diario `2026-06-26-gamma-scalp-options.md`.
|
||||||
|
- **CASH-AND-CARRY (basis trade) "CC01" — premio REALE, Sharpe ARTEFATTO, NON deployabile (2026-06-26)** —
|
||||||
|
`scripts/research/cash_carry_hl.py`, test `tests/test_cash_carry.py`. Diverso da FC01 (funding
|
||||||
|
cross-sectional, già scartato): qui delta-neutral long-spot/short-perp sullo *stesso* asset →
|
||||||
|
ritorno ≈ **+funding** (zero esposizione prezzo). Il premio di funding è **reale** (~+8-14%/anno
|
||||||
|
aggregato, positivo ogni anno in-sample, ortogonale a TP01 corr ~0.05). MA lo **Sharpe modellato
|
||||||
|
11-13 (DD 0.3%) è un ARTEFATTO**: il modello cattura solo il cashflow liscio del funding e i
|
||||||
|
rischi di coda sono **strutturalmente fuori dal dataset** — (1) storico funding dal 2023-05 →
|
||||||
|
**manca il 2022** (deleveraging, funding −, basis blow-out); (2) **procyclico** (carry +23% toro
|
||||||
|
2024 → +1.7% bear 2026, si comprime quando servirebbe); (3) liquidazione short/slippage non
|
||||||
|
modellati. Il mark-to-market della base (`premium` col → r=funding−Δpremium) sgonfia lo Sharpe
|
||||||
|
solo 13→11 → il basis-from-data NON è il rischio vero. Sharpe reale di un basis-trade ~1-3 con
|
||||||
|
code brusche. **NON eseguibile a $600** (spot+perp = 4-38 gambe, funding HL non Deribit) → STAT-MODE.
|
||||||
|
**LEAD da rivedere a scala (~20k+ e venue con funding eseguibile), non uno sleeve.** Sottoprodotto:
|
||||||
|
CC01 passa OGNI gate del marginal scorer → **punto cieco** (manca un gate "Sharpe implausibile →
|
||||||
|
rischio nascosto"; prossima indurita raccomandata). Diario `2026-06-26-cash-carry-hl.md`.
|
||||||
|
- **TP01 × DVOL vol-targeting — NON migliora (2026-06-26)** — `scripts/research/tp01_dvol_overlay.py`,
|
||||||
|
test `tests/test_tp01_dvol_overlay.py`. Angolo ESEGUIBILE (tocca il book live, non STAT-MODE):
|
||||||
|
usare il DVOL (vol implicita forward-looking) come denominatore del vol-target di TP01 invece
|
||||||
|
della vol realizzata. Su finestra comune 2021-2026: le varianti DVOL abbassano il DD (12.3%→9.2%)
|
||||||
|
ma anche Sharpe FULL (0.75→0.70) e CAGR (8%→6%). **Controllo decisivo:** il realized a target_vol
|
||||||
|
RIDOTTO (15%) eguaglia quel DD (9.4%) a Sharpe **più alto** (0.75) → il taglio di DD del DVOL è
|
||||||
|
solo **de-levering**, replicabile meglio con un semplice target_vol più basso. L'unico residuo
|
||||||
|
(hold-out +0.06) è single-window (storia DVOL <5 anni) → sotto la soglia multi-cut. Il gate
|
||||||
|
DVOL-spike de-risk è **ridondante col trend** (TP01 già flat nei crash, Δ 0.00). **Lezione: per meno
|
||||||
|
DD sul live la leva è `target_vol`, non un overlay DVOL** (20% resta canonico). Diario `2026-06-26-tp01-dvol-overlay.md`.
|
||||||
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
|
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
|
||||||
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
|
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
|
||||||
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
|
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_nota": "Config esecuzione LIVE di TP01. execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-20.",
|
"_nota": "Config esecuzione LIVE del BOOK DERIBIT (TP01+SKH01 nettati in software). execution_enabled=true + --execute -> ordini REALI. ARMATO 2026-06-23: esecutore scripts/live/book_execute.py via cron ORARIO scripts/cron_book.sh (SKH01 e' a 230m). cap/asset $300 split 75/25, disaster-SL on-book -30% sulla posizione netta. Tutto flat all'arming -> nessun ordine finche' un segnale non arma.",
|
||||||
"execution_enabled": true,
|
"execution_enabled": true,
|
||||||
"max_notional_per_asset_usd": 300,
|
"max_notional_per_asset_usd": 300,
|
||||||
"min_order_usd": 5,
|
"min_order_usd": 5,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026-06-26 — Cash-and-carry (basis trade) delta-neutral su HL (CC01): premio REALE, Sharpe ARTEFATTO, NON deployabile
|
||||||
|
|
||||||
|
**Goal utente:** dopo lo scarto del gamma scalping, "provare" gli altri due angoli proposti —
|
||||||
|
funding-carry cross-sectional e basis/cash-and-carry. Il **funding cross-sectional (FC01) è già
|
||||||
|
SCARTATO** (diario 2026-06-22: DILUTES, sign-flip su un nome). L'angolo non testato è il
|
||||||
|
**cash-and-carry delta-neutral (CC01)**. Branch `research/gamma-scalp-options`,
|
||||||
|
script `scripts/research/cash_carry_hl.py`.
|
||||||
|
|
||||||
|
## Meccanismo (diverso da FC01)
|
||||||
|
|
||||||
|
FC01 pickava cross-section (short alto-funding / long basso-funding) → scommessa relativa, fragile.
|
||||||
|
CC01 **non picka**: harvesta il premio di funding AGGREGATO restando delta-neutral sullo *stesso*
|
||||||
|
asset. Long spot + short perp (stessa size): le gambe lineari nel prezzo si cancellano → ritorno
|
||||||
|
≈ **+funding** (lo short incassa il funding quando f>0), zero esposizione direzionale. Dati: funding
|
||||||
|
orario reale HL dal 2023-05 (`hlfund_*_1h.parquet`), gli stessi 19 major di XS01.
|
||||||
|
|
||||||
|
## Risultato grezzo — e perché NON va creduto
|
||||||
|
|
||||||
|
| config | Sharpe | CAGR | maxDD | per-anno |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| BTC/ETH CC-static | **+12.9** | +13% | 0.8% | 2023:+10 2024:+23 2025:+8 2026:+1% |
|
||||||
|
| BTC/ETH CC-gated | +13.9 | +14% | 0.3% | tutti positivi |
|
||||||
|
| 19-major CC-gated | +11.6 | +16% | 0.4% | tutti positivi |
|
||||||
|
|
||||||
|
**Uno Sharpe di 13 con DD 0.3% è un ALLARME, non un edge.** È la firma di un modello che cattura
|
||||||
|
solo un cashflow liscio (il funding) e azzera la volatilità reale. Il marginal scorer dà **ADDS /
|
||||||
|
robust_oos / has_insample_edge=True / multicut_persistent** su OGNI config — ma questo **espone un
|
||||||
|
punto cieco dello scorer**: si fida della vol riportata dal candidato e non ha un gate "Sharpe
|
||||||
|
implausibile → rischio nascosto". Un Sharpe di 13 dovrebbe squalificare-e-indagare, non passare.
|
||||||
|
|
||||||
|
## Il rischio è NASCOSTO, non assente
|
||||||
|
|
||||||
|
- **Mark-to-market della base** (`premium` col → `r = funding − Δpremium`): sgonfia lo Sharpe solo
|
||||||
|
13→11. La colonna premium di HL è già lisciata/clampata → a risoluzione daily il basis-vol è
|
||||||
|
minimo. **Quindi il basis-from-data NON è il rischio vero.**
|
||||||
|
- **I rischi che giustificherebbero uno Sharpe realistico (~1-3) sono ASSENTI dal dataset:**
|
||||||
|
1. **2022 mancante.** Lo storico funding parte 2023-05 → niente deleveraging LUNA/3AC/FTX, il
|
||||||
|
regime di funding profondamente negativo + basis blow-out che farebbe il vero drawdown.
|
||||||
|
2. **Procyclicità.** Funding aggregato BTC/ETH: +23% nel toro 2024 → **+1.7% nel bear 2026**
|
||||||
|
(e 19-major CC-static 2026 = **−1%**). Solo 10% di giorni a funding negativo *in-sample* — ma
|
||||||
|
in un vero bear quella frazione esplode. Il carry **non è all-weather**: è un risk-on premium.
|
||||||
|
3. **Liquidazione dello short** in uno squeeze e **slippage** su spot+perp: non modellati.
|
||||||
|
- **Sharpe reale di un crypto basis-trade ≈ 1-3 con code brusche** (Mar-2020, Mag-2021, FTX), non 13.
|
||||||
|
|
||||||
|
## Eseguibilità a $600 — muro indipendente
|
||||||
|
|
||||||
|
Serve spot **+** perp per gamba: BTC/ETH = 4 posizioni, 19-major = 38. Su Deribit (dove operiamo) lo
|
||||||
|
storico funding è **bloccato** (404) e non tradiamo spot HL. **STAT-MODE**, come XS01/VRP01 ma peggio
|
||||||
|
(doppia gamba per asset). Non deployabile a questo capitale.
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**CC01 = premio di funding REALE (~+8-14%/anno aggregato, positivo ogni anno in-sample) e
|
||||||
|
genuinamente ortogonale a TP01 (corr ~0.05, NON direzionale).** È la "fonte di ritorno diversa" più
|
||||||
|
concreta trovata finora. MA:
|
||||||
|
- lo **Sharpe headline è fiction** (rischi di coda strutturalmente fuori dal dataset);
|
||||||
|
- è **procyclico** (si comprime/inverte nel bear — proprio quando servirebbe);
|
||||||
|
- **non eseguibile** a $600 / sul nostro venue.
|
||||||
|
|
||||||
|
→ **LEAD da rivedere a scala** (capitale ~20k+ e un venue con funding eseguibile), **non uno sleeve**.
|
||||||
|
Nessun impatto sul book live (branch separato).
|
||||||
|
|
||||||
|
## Sottoprodotto metodologico (raccomandazione)
|
||||||
|
|
||||||
|
CC01 ha passato ogni gate del marginal scorer pur essendo un'illusione di rischio. **Prossima
|
||||||
|
indurita dello scorer:** un gate di *plausibilità dello Sharpe* — uno standalone Sharpe ≫ 4 deve
|
||||||
|
declassare a "SUSPECT/INVESTIGATE" invece di alimentare ADDS, perché segnala vol sottostimata
|
||||||
|
(mark-to-cashflow, dati che escludono il regime di stress, leva nascosta). Documentato qui, non
|
||||||
|
implementato in questa passata per non toccare lo scorer condiviso su `main`.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 2026-06-26 — "Scalping BTC/ETH con copertura in opzioni" = gamma scalping → SCARTATO
|
||||||
|
|
||||||
|
**Domanda (goal utente):** analizzare lo scalping su BTC/ETH con copertura in opzioni come
|
||||||
|
possibile nuovo sleeve. **Esito: negativo, strutturale, su tutte le frequenze. Non è uno sleeve,
|
||||||
|
non è nemmeno un hedge.** Branch `research/gamma-scalp-options`, script
|
||||||
|
`scripts/research/options_gamma_scalp.py`.
|
||||||
|
|
||||||
|
## Cos'è davvero "scalping con copertura in opzioni"
|
||||||
|
|
||||||
|
L'interpretazione rigorosa è il **gamma scalping**: compri un'opzione (la *copertura* = long
|
||||||
|
gamma), delta-hedgi il sottostante a cadenza fissa (lo *scalp*), e il P&L netto è
|
||||||
|
≈ dollar-gamma × (vol realizzata² − vol implicita²). È lo **specchio esatto del VRP01** (short-vol):
|
||||||
|
VRP01 incassa IV−RV (positivo in media), il gamma scalping incassa **RV−IV** (negativo in media).
|
||||||
|
|
||||||
|
Modello (mirror della struttura VRP per comparabilità): long straddle ATM, tenor 7g, IV = DVOL
|
||||||
|
Deribit (la stessa fonte del VRP), delta-hedge sui prezzi certificati, fee opzioni cap 12.5% del
|
||||||
|
premio + fee perp 0.05%/lato sull'hedge turnover, return-on-notional vol-targeted 20%.
|
||||||
|
|
||||||
|
## La diagnostica strutturale (il cuore)
|
||||||
|
|
||||||
|
| asset | IV (DVOL) | RV 1d | IV−RV 1d | RV 1h | IV−RV 1h |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| BTC | 60.9% | 56.0% | **+4.9pp** | 64.5% | −3.6pp |
|
||||||
|
| ETH | 74.9% | 76.0% | −1.0pp | 81.0% | −6.1pp |
|
||||||
|
|
||||||
|
- A **rehedge giornaliero** l'IV ≈ o > la RV → il long gamma **paga il VRP** (sei sul lato sbagliato
|
||||||
|
del premio). Perde gross, prima di ogni fee.
|
||||||
|
- A **rehedge orario** la RV *supera* l'IV (BTC +3.6pp, ETH +6.1pp gross) — sembrerebbe un edge…
|
||||||
|
**ma è una trappola doppia:** (a) la RV_1h è gonfiata da microstruttura (bid-ask bounce), quindi
|
||||||
|
il "gross edge" è in parte fittizio; (b) il rehedge orario paga **24× la fee di hedge**, che
|
||||||
|
spazza via qualunque edge intraday reale.
|
||||||
|
|
||||||
|
## I numeri (ogni variante, ogni anno, ogni frequenza: PERDE)
|
||||||
|
|
||||||
|
| variante | rehedge | Sharpe | CAGR | per-anno |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| NAKED | 1d | **−3.99** | −56% | 2021..2026 tutti −25..−68% |
|
||||||
|
| CHEAP-GATED (IVr<0.30) | 1d | −3.05 | −47% | tutti negativi |
|
||||||
|
| RICH-SKIP (IVr>0.90) | 1d | −3.92 | −55% | tutti negativi |
|
||||||
|
| NAKED | 1h | **−6.06** | −71% | tutti −38..−81% (peggio: le fee) |
|
||||||
|
| CHEAP-GATED (IVr<0.30) | 1h | −4.96 | −64% | tutti negativi |
|
||||||
|
|
||||||
|
Il gate "compra vol solo quando è a sconto" (lo specchio del gate vincente del VRP, *vendi vol solo
|
||||||
|
quando è cara*) **migliora ma non salva**: −3.05 invece di −3.99. Il segno resta negativo perché
|
||||||
|
l'IV-rank basso non garantisce RV > IV su 7 giorni.
|
||||||
|
|
||||||
|
## Scoring marginale vs TP01
|
||||||
|
|
||||||
|
Tutte le varianti: **DILUTES**, `is_hedge=False`, `has_insample_edge=False` (IS Sharpe −3..−4.5),
|
||||||
|
blend-25 uplift hold −1.2/−1.5. Non aiuta nemmeno come hedge: perde sia quando TP01 è su
|
||||||
|
(uplift −1.2..−1.6) sia quando TP01 è giù (uplift −2.5..−3.2). Corr a TP01 ≈ 0 ma il drift negativo
|
||||||
|
domina la matematica di diversificazione.
|
||||||
|
|
||||||
|
## Eseguibilità a ~$600 (muro indipendente)
|
||||||
|
|
||||||
|
- **BTC**: contratto 1 BTC, min 0.1 BTC → notional minimo **$5.968** ≫ $600 → **NON eseguibile**.
|
||||||
|
- **ETH**: min 0.1 ETH → **$157** → marginalmente OK, ma un solo straddle = ~25% del conto in premio.
|
||||||
|
|
||||||
|
Anche se l'edge esistesse, BTC è fuori e ETH è troppo grosso per diversificare.
|
||||||
|
|
||||||
|
## Conclusione
|
||||||
|
|
||||||
|
Lo "scalping con copertura in opzioni" è **schiacciato tra due muri**: rehedge lento → paghi il
|
||||||
|
premio (RV < IV); rehedge veloce → paghi le fee (24×/day). **Nessuna frequenza vince.** Conferma
|
||||||
|
due lezioni di prim'ordine del progetto, ora quantificate sul long-vol:
|
||||||
|
1. **Le fee uccidono lo scalping** (il rehedge orario, vera frequenza di scalping, è la variante
|
||||||
|
*peggiore*: −6 Sharpe).
|
||||||
|
2. **Vol-da-modello è fragile in entrambe le direzioni.** Regola gemella del VRP: *niente long-vol
|
||||||
|
scalp da modello in deploy*.
|
||||||
|
|
||||||
|
**Il VRP01 (lato short, gated, rischio-definito) resta l'unico edge opzioni del progetto** —
|
||||||
|
e funziona proprio perché sta sul lato *giusto* dello stesso premio che il gamma scalp paga.
|
||||||
|
|
||||||
|
SCARTATO. Script preservato come riferimento. Nessun impatto sul book live (branch separato).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 2026-06-26 — TP01 × DVOL vol-targeting: NON migliora (il taglio di DD è solo de-levering)
|
||||||
|
|
||||||
|
**Goal utente:** "continua" ad analizzare strategie. Dopo i tre angoli di diversificazione tutti
|
||||||
|
STAT-MODE (gamma scalp scartato, funding cross-sectional già morto, cash-and-carry lead non
|
||||||
|
deployabile), ho puntato sull'angolo a **più alto valore perché ESEGUIBILE**: migliorare il
|
||||||
|
risk-sizing del book **live** TP01 (BTC/ETH perp Deribit, già armato). Branch
|
||||||
|
`research/gamma-scalp-options`, script `scripts/research/tp01_dvol_overlay.py`.
|
||||||
|
|
||||||
|
## Ipotesi
|
||||||
|
|
||||||
|
TP01 vol-targeta sulla vol **realizzata** 30g (backward-looking). Il **DVOL** (vol implicita 30g
|
||||||
|
Deribit, forward-looking, che spesso anticipa i salti di vol) come denominatore → de-risking più
|
||||||
|
tempestivo prima dei crash → DD più basso / hold-out migliore senza affondare il FULL.
|
||||||
|
|
||||||
|
Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla **finestra comune 2021-04 → 2026**
|
||||||
|
(1891g, perdo il toro 2019-2020 pre-DVOL). Causale, fee 0.10% RT, config canonica TP01.
|
||||||
|
|
||||||
|
## Risultati (finestra comune, hold-out 2025+)
|
||||||
|
|
||||||
|
| variante (denominatore vol) | FULL Sh | FULL DD | CAGR | HOLD Sh |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **realized** (baseline) | **+0.75** | 12.3% | +8% | +0.30 |
|
||||||
|
| dvol (implicita) | +0.70 | 9.3% | +6% | +0.33 |
|
||||||
|
| blend 50/50 | +0.72 | 10.4% | +7% | +0.32 |
|
||||||
|
| max(realiz, dvol) | +0.70 | 9.2% | +6% | +0.36 |
|
||||||
|
| derisk (gate DVOL>p90 ×0.5) | +0.75 | 12.3% | +8% | +0.30 |
|
||||||
|
|
||||||
|
Le varianti DVOL **abbassano il DD** (12.3% → ~9.2%) ma **anche** Sharpe FULL (−0.04/−0.05) e CAGR
|
||||||
|
(8% → 6%). Lo Sharpe è scale-invariant → sospetto: il taglio di DD è solo *posizioni più piccole*
|
||||||
|
(l'IV > RV per il VRP → denominatore più grande → leva minore), non vero timing.
|
||||||
|
|
||||||
|
## Il controllo decisivo
|
||||||
|
|
||||||
|
Confronto le varianti DVOL col **realized a target_vol RIDOTTO** (stesso de-levering, senza DVOL):
|
||||||
|
|
||||||
|
| | FULL Sh | DD | CAGR | HOLD Sh |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **max-DVOL** | +0.70 | 9.2% | +6% | +0.36 |
|
||||||
|
| **realized @ vol-tgt 15%** | **+0.75** | 9.4% | +6% | +0.30 |
|
||||||
|
| **realized @ vol-tgt 13%** | +0.75 | **8.1%** | +5% | +0.30 |
|
||||||
|
|
||||||
|
**Conclusivo:** il realized a 15% eguaglia il DD del max-DVOL (9.4% ≈ 9.2%) a Sharpe **più alto**
|
||||||
|
(0.75 > 0.70) e stesso CAGR; a 13% fa DD ancora più basso (8.1%), sempre a 0.75. **Il taglio di DD
|
||||||
|
del DVOL è interamente "leva minore", replicabile MEGLIO con un semplice target_vol più basso.**
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**DVOL vol-targeting NON migliora TP01.** Risk-adjusted è marginalmente *peggiore* (FULL 0.70 vs
|
||||||
|
0.75 a parità di DD). L'unico residuo a favore — hold-out +0.06 (max 0.36 vs 0.30) — è **single-window**
|
||||||
|
su 5 anni scarsi di storia DVOL: sotto la soglia di **persistenza multi-cut** del progetto (la lezione
|
||||||
|
che ha ucciso 13/14 falsi positivi nello sweep alt). Il gate **DVOL-spike de-risk è RIDONDANTE col
|
||||||
|
trend**: quando il DVOL esplode (crash) TP01 è già flat (momentum negativo → posizione 0) → il gate
|
||||||
|
non tocca nulla (Δ 0.00 su tutto). Niente da deployare.
|
||||||
|
|
||||||
|
## Lezioni
|
||||||
|
|
||||||
|
1. **Per meno DD sul book live, la leva giusta è `target_vol`, non un overlay DVOL.** Abbassare TP01
|
||||||
|
da vol-tgt 20% → 15% taglia il DD 12.3% → ~9.4% allo **stesso Sharpe** (0.75). Dial pulito e noto,
|
||||||
|
se mai si volesse un profilo più difensivo (oggi 20% è la scelta canonica — invariata).
|
||||||
|
2. **La vol implicita non batte la realizzata come denominatore di sizing per un trend long-flat**:
|
||||||
|
il segnale di trend gestisce già il de-risking direzionale; il denominatore fissa solo la
|
||||||
|
magnitudine, e la realizzata lo fa marginalmente meglio per unità di rischio.
|
||||||
|
3. Conferma indiretta del soffitto: anche un raffinamento "ovvio" e *eseguibile* su TP01 non produce
|
||||||
|
alpha; al massimo sposta il punto sulla stessa frontiera rischio/rendimento.
|
||||||
|
|
||||||
|
SCARTATO come miglioria. Nessun impatto sul book live (branch separato, config canonica invariata).
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-06-26 — Calendar-vol / term-structure DVOL: il DATO STORICO NON ESISTE (data-first gate)
|
||||||
|
|
||||||
|
**Goal utente:** "continua" — angolo scelto: scaricare e certificare la term-structure DVOL per
|
||||||
|
testare un calendar-vol (front IV vs back IV). **Esito: bloccato a monte dal gate "il dato prima
|
||||||
|
della strategia" (lezione v2.0.0).** Branch `research/gamma-scalp-options`.
|
||||||
|
Script: `scripts/research/probe_vol_termstructure.py` (scan) + `log_vol_termstructure.py` (forward).
|
||||||
|
|
||||||
|
## Scan di fattibilità (prima di costruire qualunque backtest)
|
||||||
|
|
||||||
|
Cosa l'API pubblica Deribit espone davvero per la vol per-scadenza:
|
||||||
|
|
||||||
|
| dato | disponibile? |
|
||||||
|
|---|---|
|
||||||
|
| Snapshot CORRENTE della term-structure ATM (mark_iv per scadenza) | **SÌ** (book_summary_by_currency, tokenless) |
|
||||||
|
| DVOL storico | solo **30g** fisso — nessun indice 7g/60g/90g pubblico |
|
||||||
|
| Trade-history IV per strumento | solo per strumenti **VIVI** (gli scaduti spariscono) |
|
||||||
|
| **Serie storica continua front-vs-back IV** | **NO** — il front-month rotola/espira → irricostruibile |
|
||||||
|
|
||||||
|
**Snapshot oggi (2026-06-26), per riferimento:**
|
||||||
|
- BTC spot $59.9k: 7g 45.6% · 30g 43.2% · 60g 42.7% · 90g 42.9% · 180g 44.5% — **contango** lieve.
|
||||||
|
- ETH spot $1.58k: 7g 57.6% · 30g 56.4% · 90g 57.5% · 180g 59.2% — contango (+1.6pp slope).
|
||||||
|
- (I valori sono interpolati a tenor fissi: il front a 1-giorno grezzo, 38%/54%, è rumore escluso.)
|
||||||
|
|
||||||
|
## Verdetto
|
||||||
|
|
||||||
|
**Un calendar-vol NON è backtestabile ora su dati certificati.** La storia per-scadenza non è
|
||||||
|
pubblica su Deribit (la nostra fonte di verità), e — coerentemente con la metodologia — **nessun
|
||||||
|
edge va creduto senza backtest OOS su dati certificati**. Costruire un calendar-vol su uno snapshot
|
||||||
|
singolo o su una storia ricostruita-a-pezzi sarebbe esattamente il tipo di scorciatoia che ha
|
||||||
|
prodotto la libreria fasulla v2.0.0. Quindi: **stop, niente backtest.**
|
||||||
|
|
||||||
|
## L'unica via legittima: costruire il dato in avanti
|
||||||
|
|
||||||
|
`scripts/research/log_vol_termstructure.py` — logger forward idempotente: ogni run prende lo snapshot
|
||||||
|
ATM, lo interpola ai tenor fissi {7,30,60,90,180}g e appende una riga/asset a
|
||||||
|
`data/raw/vol_term_<asset>.parquet`. Seminate le prime righe BTC/ETH oggi. Test offline
|
||||||
|
`tests/test_vol_termstructure.py` (interpolazione pura).
|
||||||
|
|
||||||
|
**Non auto-cablato in cron** (è ricerca forward, e non tocco il cron live senza ok). Per accumulare:
|
||||||
|
aggiungere una riga giornaliera al crontab. Dopo **~6-12 mesi** → certificare (monotonia, spike,
|
||||||
|
cross-venue) e SOLO ALLORA testare front-vs-back. Prima, nulla.
|
||||||
|
|
||||||
|
## Lezione
|
||||||
|
|
||||||
|
Il data-first gate ha funzionato: ha fermato un backtest su un dato che non esiste, invece di
|
||||||
|
produrre un numero non falsificabile. La term-structure è in **contango** oggi (regime calmo) —
|
||||||
|
osservazione da forward-monitor, non un edge. Nessun impatto sul book live (branch separato).
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# BOOK DERIBIT-ONLY (TP01+SKH01 nettati) — esecuzione LIVE a cadenza ORARIA. v2.0.0+.
|
||||||
|
# SKH01 decide su griglia 230m -> serve girare piu' spesso del daily; orario IDEMPOTENTE:
|
||||||
|
# riconcilia al target NETTO corrente (se non cambia nulla -> HOLD). Il feed 5m fresco per il
|
||||||
|
# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati
|
||||||
|
# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute.
|
||||||
|
export PATH="/home/adriano/.local/bin:$PATH"
|
||||||
|
cd /opt/docker/PythagorasGoal || exit 1
|
||||||
|
mkdir -p logs
|
||||||
|
{
|
||||||
|
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_book ====="
|
||||||
|
uv run python scripts/live/book_execute.py --execute
|
||||||
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
|
} >> logs/cron_book.log 2>&1
|
||||||
@@ -10,7 +10,9 @@ mkdir -p logs
|
|||||||
uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni)
|
uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni)
|
||||||
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
|
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
|
||||||
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
|
uv run python scripts/live/paper_prevday.py # forward-monitor lead prevday-breakout (PAPER, non deploy)
|
||||||
uv run python scripts/live/live_execute.py --execute # TP01 LIVE su Deribit (gated da config/live.json)
|
# NB: l'esecuzione Deribit e' passata al BOOK (TP01+SKH01 nettati) via scripts/cron_book.sh a
|
||||||
|
# cadenza ORARIA (SKH01 e' a 230m: il daily mancherebbe gli ingressi). live_execute.py
|
||||||
|
# (TP01-only) NON va piu' eseguito qui, sennò i due farebbero a pugni sullo stesso strumento.
|
||||||
# --- COMBO cross-venue (PAPER): refresh ETF IB (GTAA) + avanza paper TP01+GTAA ---
|
# --- COMBO cross-venue (PAPER): refresh ETF IB (GTAA) + avanza paper TP01+GTAA ---
|
||||||
docker compose up -d ib-gateway >/dev/null 2>&1 # gateway IB paper (idempotente)
|
docker compose up -d ib-gateway >/dev/null 2>&1 # gateway IB paper (idempotente)
|
||||||
for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done
|
for i in $(seq 1 25); do (echo > /dev/tcp/127.0.0.1/4002) >/dev/null 2>&1 && break; sleep 6; done
|
||||||
|
|||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# LOGGER FORWARD della vol term-structure Deribit — cadenza GIORNALIERA. v2.0.0+.
|
||||||
|
# Costruisce il dataset per un futuro calendar-vol (oggi NON backtestabile: storia per-scadenza non
|
||||||
|
# pubblica — vedi docs/diary/2026-06-26-vol-termstructure-feasibility.md). Append idempotente per
|
||||||
|
# giorno su data/raw/vol_term_<asset>.parquet. SOLO ricerca forward: NON tocca il book live ne' i
|
||||||
|
# dati certificati BTC/ETH; legge l'API pubblica Deribit (tokenless) e scrive un parquet dedicato.
|
||||||
|
export PATH="/home/adriano/.local/bin:$PATH"
|
||||||
|
cd /opt/docker/PythagorasGoal || exit 1
|
||||||
|
mkdir -p logs
|
||||||
|
{
|
||||||
|
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_vol_term ====="
|
||||||
|
uv run python scripts/research/log_vol_termstructure.py
|
||||||
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
|
} >> logs/cron_vol_term.log 2>&1
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""BOOK DERIBIT-ONLY LIVE EXECUTE — TP01 + SKH01 NETTATI in software su un solo conto Deribit mainnet.
|
||||||
|
|
||||||
|
Porta il conto reale al target NETTO per asset (vedi src/live/book.py): per ogni asset combina la
|
||||||
|
frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25), e manda UN ordine con
|
||||||
|
segno (long/short/flip) per raggiungerlo. Poi assicura un disaster-SL on-book sulla posizione NETTA.
|
||||||
|
|
||||||
|
DOPPIO GATE DI SICUREZZA (entrambi necessari per inviare ordini reali):
|
||||||
|
1. config/live.json -> "execution_enabled": true (master switch, default false)
|
||||||
|
2. flag CLI --execute
|
||||||
|
Senza entrambi e' un DRY-RUN (stampa il piano, NON invia). Reconciliation dopo ogni ordine; log in
|
||||||
|
data/live/book_executions.jsonl.
|
||||||
|
|
||||||
|
⚠️ CADENZA: SKH01 decide su griglia 230m -> questo script va lanciato ogni ~230 minuti con la feed
|
||||||
|
fresca all'ultima barra chiusa (NON il cron giornaliero, che mancherebbe gli ingressi). Gli exit di
|
||||||
|
SKH sono SOFTWARE (latenza fino a fine barra 230m); solo il disaster-SL (-30%) e' on-book.
|
||||||
|
|
||||||
|
uv run python scripts/live/book_execute.py # DRY-RUN (piano, nessun ordine)
|
||||||
|
uv run python scripts/live/book_execute.py --execute # esegue SOLO se execution_enabled=true
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.book import book_report
|
||||||
|
from src.live.execution import DeribitTrader
|
||||||
|
from src.live.notifier import notify
|
||||||
|
|
||||||
|
CONFIG = PROJECT_ROOT / "config" / "live.json"
|
||||||
|
LOG_DIR = PROJECT_ROOT / "data" / "live"
|
||||||
|
LOG = LOG_DIR / "book_executions.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||||
|
cfg.setdefault("execution_enabled", False)
|
||||||
|
cfg.setdefault("max_notional_per_asset_usd", 300.0)
|
||||||
|
cfg.setdefault("min_order_usd", 5.0)
|
||||||
|
cfg.setdefault("disaster_sl_pct", 0.30)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(rec: dict):
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write(json.dumps(rec) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
cfg = load_config()
|
||||||
|
want_execute = "--execute" in sys.argv[1:]
|
||||||
|
enabled = bool(cfg["execution_enabled"])
|
||||||
|
do_execute = want_execute and enabled
|
||||||
|
min_order = float(cfg["min_order_usd"])
|
||||||
|
sl_pct = float(cfg["disaster_sl_pct"])
|
||||||
|
|
||||||
|
r = book_report(live_feed=True) # target NETTO + conto/posizioni reali (feed SKH fresco)
|
||||||
|
equity = r["equity"]
|
||||||
|
|
||||||
|
print("=" * 88)
|
||||||
|
print(" BOOK DERIBIT LIVE EXECUTE — TP01(0.75)+SKH01(0.25) NETTATI — Deribit mainnet (USDC linear)")
|
||||||
|
print("=" * 88)
|
||||||
|
mode = ("ESECUZIONE REALE" if do_execute else
|
||||||
|
("ARMATO ma manca --execute" if enabled else "DRY-RUN (execution_enabled=false)"))
|
||||||
|
print(f" modo : {mode}")
|
||||||
|
print(f" gate : execution_enabled={enabled} | --execute={want_execute}")
|
||||||
|
print(f" conto reale : ${r['real_equity']:,.2f}" if r["real_equity"] else f" conto: {r['eq_basis']}")
|
||||||
|
print(f" sizing base : ${equity:,.2f} | cap/asset ${r['cap_per_asset']:.0f} | min ${min_order:.0f} | disaster-SL -{sl_pct*100:.0f}%")
|
||||||
|
print(f" ultima barra : {r['last_data']}\n")
|
||||||
|
|
||||||
|
if not r["online"]:
|
||||||
|
print(" conto non leggibile (offline) -> stop, non eseguo a cieco.")
|
||||||
|
if do_execute:
|
||||||
|
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco"})
|
||||||
|
return
|
||||||
|
|
||||||
|
trader = DeribitTrader() if do_execute else None
|
||||||
|
actions = []
|
||||||
|
for a in r["assets"]:
|
||||||
|
asset, inst = a["asset"], a["instrument"]
|
||||||
|
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
||||||
|
sk = a["skh_state"]
|
||||||
|
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||||
|
order = a["order"]
|
||||||
|
if order is None:
|
||||||
|
act = "HOLD (a target)"
|
||||||
|
elif order.get("is_close"):
|
||||||
|
act = f"CLOSE ${cur:,.0f}"
|
||||||
|
elif order.get("needs_flip"):
|
||||||
|
act = f"FLIP -> ${net:,.0f}"
|
||||||
|
else:
|
||||||
|
act = f"{order['side'].upper()} ${order['delta']:+,.0f}"
|
||||||
|
print(f" {asset:<3} TP {a['tp_frac']:+.3f} · SKH {a['skh_sign']:+d}({sk_txt}) -> net ${net:+,.0f} "
|
||||||
|
f"| pos ${cur:+,.0f} -> {act}")
|
||||||
|
|
||||||
|
if do_execute and order is not None:
|
||||||
|
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||||
|
newpos = trader.position_usd(inst)
|
||||||
|
for f in fills:
|
||||||
|
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||||
|
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||||
|
log_event(dict(ts_utc=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||||
|
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||||
|
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||||
|
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||||
|
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||||
|
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
|
||||||
|
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||||
|
det if f.verified else {**det, "notes": f.notes})
|
||||||
|
print(f" reconcile: pos ${newpos:,.0f}")
|
||||||
|
if do_execute:
|
||||||
|
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||||
|
print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
|
||||||
|
if ds.get("state") == "placed":
|
||||||
|
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
|
||||||
|
"amount": round(ds.get("amount") or 0, 4)})
|
||||||
|
elif ds.get("state") == "place-failed":
|
||||||
|
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
|
||||||
|
actions.append(act)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if not do_execute:
|
||||||
|
print(" => DRY-RUN: nessun ordine inviato." +
|
||||||
|
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
||||||
|
elif all(x.startswith("HOLD") for x in actions):
|
||||||
|
print(" => Nessuna azione: conto gia' al target netto del book.")
|
||||||
|
else:
|
||||||
|
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
_run()
|
||||||
|
except Exception as e:
|
||||||
|
notify("🛑 BOOK LIVE — ERRORE", {"error": f"{type(e).__name__}: {e}"})
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid. Backtest onesto, STAT-MODE.
|
||||||
|
|
||||||
|
DIVERSO da FC01 (funding cross-sectional, gia' SCARTATO 2026-06-22). Qui NON si pickano
|
||||||
|
vincitori/perdenti cross-section: si HARVESTA il premio di funding AGGREGATO restando
|
||||||
|
delta-neutral sullo STESSO asset.
|
||||||
|
|
||||||
|
MECCANISMO. Long spot + short perp (stesso asset, stessa size):
|
||||||
|
long spot -> +price_ret
|
||||||
|
short perp -> -price_ret + funding (lo short INCASSA il funding quando f>0)
|
||||||
|
netto -> +funding (il prezzo si cancella -> zero esposizione direzionale)
|
||||||
|
Il ritorno della gamba delta-neutral = il funding realizzato, meno fee. Entrambe le gambe sono
|
||||||
|
lineari nel prezzo => restano matchate in nozionale senza ribilanciare (delta ~neutro da solo);
|
||||||
|
i costi reali sono entry/exit + spread, modellati come drag annuo fisso.
|
||||||
|
|
||||||
|
VARIANTI:
|
||||||
|
CC-static -> sempre long-spot/short-perp (basis trade classico): r = funding. Perde se f<0.
|
||||||
|
CC-gated -> harvest solo quando il funding trailing CAUSALE > 0 (esci dai regimi a funding
|
||||||
|
negativo invece di pagarli). r = funding se trail>0 else 0.
|
||||||
|
UNIVERSI: BTC/ETH (sottoinsieme rilevante per l'esecuzione) e i 19 major (basket pieno).
|
||||||
|
|
||||||
|
GIUDIZIO: standalone (Sharpe/DD/anni) + marginal_vs_tp01. CAVEAT ONESTI (pre-risultato):
|
||||||
|
- NON eseguibile a $600: serve spot+perp per gamba (BTC/ETH = 4 posizioni; 19 = 38). Su Deribit
|
||||||
|
lo storico funding e' bloccato e non operiamo spot HL -> STAT-MODE.
|
||||||
|
- Il modello "r=funding" IGNORA il rischio di base (perp != spot), la liquidazione dello short in
|
||||||
|
uno squeeze, e l'inversione brusca del funding. La vol modellata SOTTOSTIMA la coda.
|
||||||
|
- Lo storico funding parte 2023-05 -> NON contiene il deleveraging 2022 (il regime peggiore per il
|
||||||
|
carry). Edge potenzialmente sovrastimato.
|
||||||
|
|
||||||
|
uv run python scripts/research/cash_carry_hl.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import 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, _HL_DIR
|
||||||
|
from altlib import marginal_vs_tp01 # type: ignore
|
||||||
|
|
||||||
|
SQ365 = np.sqrt(365.25)
|
||||||
|
ANNUAL_COST = 0.02 # drag annuo fisso: entry/exit + spread + borrow (generoso per un hold continuo)
|
||||||
|
|
||||||
|
|
||||||
|
def load_funding_panel(universe):
|
||||||
|
"""FUND, PREM [date x asset]: funding giornaliero (somma oraria) e premium/basis (ultimo del
|
||||||
|
giorno) per gli asset con dati. PREM serve a iniettare il mark-to-market della BASE (perp!=spot)."""
|
||||||
|
fund, prem = {}, {}
|
||||||
|
for sym in universe:
|
||||||
|
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
|
||||||
|
if not fp.exists():
|
||||||
|
continue
|
||||||
|
df = pd.read_parquet(fp)
|
||||||
|
fund[sym] = df["funding"].resample("1D").sum()
|
||||||
|
prem[sym] = df["premium"].resample("1D").last()
|
||||||
|
FUND = pd.concat(fund, axis=1).sort_index()
|
||||||
|
PREM = pd.concat(prem, axis=1).sort_index().reindex(FUND.index)
|
||||||
|
if FUND.index.tz is None:
|
||||||
|
FUND.index = FUND.index.tz_localize("UTC"); PREM.index = PREM.index.tz_localize("UTC")
|
||||||
|
return FUND, PREM
|
||||||
|
|
||||||
|
|
||||||
|
def cc_returns(universe, mode="static", trail=14, cost=ANNUAL_COST, with_basis=False) -> pd.Series:
|
||||||
|
"""Ritorno giornaliero del basket cash-and-carry equal-weight, netto drag annuo.
|
||||||
|
with_basis=True inietta il mark-to-market reale della base: r = funding - Δpremium (lo short
|
||||||
|
perp marca contro l'allargamento del basis). E' il rischio che 'r=funding' nasconde."""
|
||||||
|
FUND, PREM = load_funding_panel(universe)
|
||||||
|
daily_cost = cost / 365.25
|
||||||
|
leg_raw = FUND - PREM.diff() if with_basis else FUND # per-asset daily mark
|
||||||
|
if mode == "gated":
|
||||||
|
sig = FUND.rolling(trail, min_periods=trail // 2).mean().shift(1) # funding trailing causale
|
||||||
|
active = (sig > 0).astype(float)
|
||||||
|
n_active = active.sum(axis=1).replace(0, np.nan)
|
||||||
|
gross = ((leg_raw * active).sum(axis=1) / n_active).fillna(0.0)
|
||||||
|
drag = (active.sum(axis=1) > 0).astype(float) * daily_cost
|
||||||
|
else:
|
||||||
|
gross = leg_raw.mean(axis=1)
|
||||||
|
drag = daily_cost
|
||||||
|
return (gross - drag).dropna()
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(daily: pd.Series) -> dict:
|
||||||
|
r = daily.values
|
||||||
|
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
||||||
|
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||||
|
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||||
|
s = pd.Series(eq, index=daily.index)
|
||||||
|
yearly = {}
|
||||||
|
for y, g in s.groupby(s.index.year):
|
||||||
|
if len(g) > 1:
|
||||||
|
v = g.values; p = np.maximum.accumulate(v)
|
||||||
|
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
||||||
|
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly, gross_ann=float(np.mean(r) * 365.25))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 92)
|
||||||
|
print(" CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid")
|
||||||
|
print(" long spot + short perp -> r = funding (zero esposizione prezzo). Netto drag 2%/anno.")
|
||||||
|
print("=" * 92)
|
||||||
|
|
||||||
|
configs = [
|
||||||
|
("BTC/ETH CC-static", ["BTC", "ETH"], "static"),
|
||||||
|
("BTC/ETH CC-gated", ["BTC", "ETH"], "gated"),
|
||||||
|
("19-major CC-static", XS_UNIVERSE, "static"),
|
||||||
|
("19-major CC-gated", XS_UNIVERSE, "gated"),
|
||||||
|
]
|
||||||
|
print("\n [A] MODELLO INGENUO (r = funding) — IGNORA il rischio di base:")
|
||||||
|
series = {}
|
||||||
|
for label, uni, mode in configs:
|
||||||
|
r = cc_returns(uni, mode=mode)
|
||||||
|
series[label] = r
|
||||||
|
m = metrics(r)
|
||||||
|
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||||
|
print(f"\n --- {label} --- ({r.index[0].date()} -> {r.index[-1].date()}, {len(r)}g)")
|
||||||
|
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
||||||
|
f"carry lordo {m['gross_ann']*100:+.1f}%/anno | per-anno: {ys}")
|
||||||
|
|
||||||
|
print("\n [B] CON MARK-TO-MARKET DELLA BASE (r = funding - Δpremium) — il rischio nascosto:")
|
||||||
|
series_b = {}
|
||||||
|
for label, uni, mode in configs:
|
||||||
|
r = cc_returns(uni, mode=mode, with_basis=True)
|
||||||
|
series_b[label] = r
|
||||||
|
m = metrics(r)
|
||||||
|
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||||
|
print(f"\n --- {label} (basis) ---")
|
||||||
|
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
|
||||||
|
f"| per-anno: {ys}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(" REALITY CHECK — perche' uno Sharpe 11-13 e' un ARTEFATTO, non un edge")
|
||||||
|
print("=" * 92)
|
||||||
|
FUND, _ = load_funding_panel(["BTC", "ETH"])
|
||||||
|
agg = FUND.mean(axis=1)
|
||||||
|
neg = float((agg < 0).mean())
|
||||||
|
by_year = agg.groupby(agg.index.year).apply(lambda x: float(x.sum()))
|
||||||
|
print(f" funding aggregato BTC/ETH: giorni a funding NEGATIVO {neg*100:.0f}% | "
|
||||||
|
f"per-anno (somma): " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in by_year.items()))
|
||||||
|
print(" -> il carry e' PROCYCLICO: +23% nel toro 2024, ~+1% nel 2026 (si comprime nel bear).")
|
||||||
|
print(" -> lo storico funding parte 2023-05: ASSENTE il deleveraging 2022 (funding -, basis blow-out),")
|
||||||
|
print(" il regime che farebbe il vero drawdown. + assenti: liquidazione short in squeeze, slippage")
|
||||||
|
print(" su spot+perp a $600. Sharpe reale di un basis-trade ~1-3 con code brusche, NON 13.")
|
||||||
|
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(" SCORING MARGINALE vs TP01 (sul modello ONESTO con rischio di base [B])")
|
||||||
|
print(" NB: ADDS/robust_oos qui ESPONGONO un punto cieco dello scorer — si fida della vol")
|
||||||
|
print(" riportata e non ha un gate 'Sharpe implausibile -> rischio nascosto'.")
|
||||||
|
print("=" * 92)
|
||||||
|
for label, r in series_b.items():
|
||||||
|
if r.std() == 0:
|
||||||
|
print(f"\n[{label}] flat — skip"); continue
|
||||||
|
m = marginal_vs_tp01(r)
|
||||||
|
b = m.get("blends", {}).get("w25", {})
|
||||||
|
print(f"\n[{label}]")
|
||||||
|
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
||||||
|
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
||||||
|
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
||||||
|
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
||||||
|
f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) "
|
||||||
|
f"hold {b.get('hold')} (upl {b.get('uplift_hold')}) DD {b.get('dd')}")
|
||||||
|
print(f" multicut persistente={m.get('multicut_persistent')} robust_oos={m.get('robust_oos')} "
|
||||||
|
f"hedge-check up/down {m.get('uplift_tp01_up')}/{m.get('uplift_tp01_down')}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""LOGGER FORWARD della vol term-structure Deribit (l'UNICA via legittima per avere il dato).
|
||||||
|
|
||||||
|
Lo scan (probe_vol_termstructure.py) ha stabilito: la storia per-scadenza NON e' pubblica su Deribit
|
||||||
|
(DVOL solo 30g; trade-history IV solo per strumenti vivi; il front rotola). Quindi un calendar-vol
|
||||||
|
NON e' backtestabile oggi. Questo logger COSTRUISCE il dataset in avanti: ogni run prende lo snapshot
|
||||||
|
ATM mark_iv per scadenza, lo interpola a TENOR FISSI (7/30/60/90/180g) e appende una riga per asset a
|
||||||
|
data/raw/vol_term_<asset>.parquet. Idempotente per giorno (riscrive la riga del giorno).
|
||||||
|
|
||||||
|
Da cron giornaliero (NON auto-cablato: e' ricerca forward, va aggiunto a mano se si vuole):
|
||||||
|
uv run python scripts/research/log_vol_termstructure.py
|
||||||
|
|
||||||
|
Dopo ~6-12 mesi di accumulo -> certificare (cross-venue, monotonia, spike) e SOLO ALLORA testare un
|
||||||
|
calendar-vol (front vs back) su dati certificati. Nessun edge creduto prima.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE = "https://www.deribit.com/api/v2/public/"
|
||||||
|
RAW = PROJECT_ROOT / "data" / "raw"
|
||||||
|
TENORS = [7, 30, 60, 90, 180] # giorni-a-scadenza fissi su cui interpolare l'ATM IV
|
||||||
|
|
||||||
|
|
||||||
|
def _get(method, **params):
|
||||||
|
return requests.get(BASE + method, params=params, timeout=40).json().get("result", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _atm_curve(cur, now_ms):
|
||||||
|
"""[(dte_giorni, atm_iv%)] ordinato per scadenza, dallo strike piu' vicino allo spot."""
|
||||||
|
summ = _get("get_book_summary_by_currency", currency=cur, kind="option")
|
||||||
|
idx = _get("get_index_price", index_name=f"{cur.lower()}_usd")
|
||||||
|
if not summ or not idx:
|
||||||
|
return None, None
|
||||||
|
spot = float(idx["index_price"])
|
||||||
|
best = {} # exp_ms -> (dist_strike, iv)
|
||||||
|
for o in summ:
|
||||||
|
p = o["instrument_name"].split("-")
|
||||||
|
if len(p) != 4 or o.get("mark_iv") is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exp_ms = int(_dt.datetime.strptime(p[1], "%d%b%y").replace(
|
||||||
|
tzinfo=_dt.timezone.utc).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
d = abs(float(p[2]) - spot)
|
||||||
|
if exp_ms not in best or d < best[exp_ms][0]:
|
||||||
|
best[exp_ms] = (d, float(o["mark_iv"]))
|
||||||
|
curve = sorted(((e - now_ms) / 86400_000.0, iv) for e, (_, iv) in best.items() if e > now_ms)
|
||||||
|
return spot, curve
|
||||||
|
|
||||||
|
|
||||||
|
def build_row(spot, curve, now_ms):
|
||||||
|
"""Pura: da (spot, curve=[(dte,iv)], now_ms) -> riga con ATM IV interpolata ai TENOR fissi."""
|
||||||
|
if not curve or len(curve) < 2:
|
||||||
|
return None
|
||||||
|
dtes = np.array([c[0] for c in curve]); ivs = np.array([c[1] for c in curve])
|
||||||
|
row = {"date": pd.Timestamp(now_ms, unit="ms", tz="UTC").normalize(), "spot": spot}
|
||||||
|
for t in TENORS:
|
||||||
|
row[f"iv_{t}d"] = float(np.interp(t, dtes, ivs)) # interp lineare sui DTE disponibili
|
||||||
|
row["slope_7_180"] = row["iv_180d"] - row["iv_7d"]
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(cur, now_ms):
|
||||||
|
spot, curve = _atm_curve(cur, now_ms)
|
||||||
|
return build_row(spot, curve, now_ms) if curve else None
|
||||||
|
|
||||||
|
|
||||||
|
def append_row(cur, row):
|
||||||
|
fp = RAW / f"vol_term_{cur.lower()}.parquet"
|
||||||
|
df = pd.read_parquet(fp) if fp.exists() else pd.DataFrame()
|
||||||
|
df = df[df["date"] != row["date"]] if len(df) else df # idempotente per giorno
|
||||||
|
df = pd.concat([df, pd.DataFrame([row])], ignore_index=True).sort_values("date")
|
||||||
|
df.to_parquet(fp, index=False)
|
||||||
|
return fp, len(df)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
now_ms = int(pd.Timestamp.now("UTC").timestamp() * 1000)
|
||||||
|
print("=" * 78)
|
||||||
|
print(" LOG vol term-structure (forward dataset builder)")
|
||||||
|
print("=" * 78)
|
||||||
|
for cur in ("BTC", "ETH"):
|
||||||
|
row = snapshot(cur, now_ms)
|
||||||
|
if row is None:
|
||||||
|
print(f" {cur}: snapshot fallito (chain vuota?)"); continue
|
||||||
|
fp, n = append_row(cur, row)
|
||||||
|
ivs = " ".join(f"{t}g {row[f'iv_{t}d']:.1f}%" for t in TENORS)
|
||||||
|
print(f" {cur} {row['date'].date()} spot ${row['spot']:,.0f} | {ivs} | "
|
||||||
|
f"slope7-180 {row['slope_7_180']:+.1f}pp -> {n} righe in {fp.name}")
|
||||||
|
print("\n (forward-only: serve accumulo di mesi prima di poter certificare e testare un calendar-vol)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""GAMMA SCALPING (long-vol) — "scalping BTC/ETH con copertura in opzioni", valutato onestamente.
|
||||||
|
|
||||||
|
Interpretazione rigorosa di "fare scalping con copertura in opzioni" = GAMMA SCALPING:
|
||||||
|
compri un'opzione (la COPERTURA = long gamma), delta-hedgi il sottostante a cadenza fissa
|
||||||
|
(lo SCALP), e il P&L netto e' ~ dollar-gamma * (vol realizzata^2 - vol implicita^2). E' lo
|
||||||
|
SPECCHIO ESATTO del VRP01 (short-vol): VRP01 incassa IV-RV (positivo in media), il gamma
|
||||||
|
scalping incassa RV-IV (negativo in media, ma CONVESSO -> paga nei crash).
|
||||||
|
|
||||||
|
Modello (mirror della struttura VRP per comparabilita'):
|
||||||
|
- long STRADDLE ATM, tenor settimanale (7g), strike = spot all'ingresso.
|
||||||
|
- IV = DVOL Deribit (data/raw/dvol_*.parquet) — la stessa fonte IV del VRP.
|
||||||
|
- delta-hedge GIORNALIERO sui prezzi certificati 1d (BTC/ETH).
|
||||||
|
- P&L delta-hedged classico per step: DG_t * [(dS/S)^2 - sigma^2*dt],
|
||||||
|
DG_t = dollar-gamma dello straddle = phi(d1)*S/(sigma*sqrt(tau)).
|
||||||
|
- fee opzioni Deribit cap 12.5% del premio (come VRP) + fee perp sull'hedge turnover.
|
||||||
|
- return-on-notional (pnl/S0), poi VOL-TARGET 20% annuo per apples-to-apples con gli altri sleeve.
|
||||||
|
|
||||||
|
Varianti testate:
|
||||||
|
NAKED -> sempre long gamma (baseline: deve perdere il premio in media).
|
||||||
|
CHEAP-GATED -> entra solo quando IV-rank < gate (compri vol a sconto = specchio del gate VRP).
|
||||||
|
RICH-SKIP -> entra sempre tranne quando IV-rank > skip (evita di pagare vol carissima).
|
||||||
|
|
||||||
|
Output: metriche standalone + per-anno + SCORING MARGINALE vs TP01 (corr, blend uplift, is_hedge,
|
||||||
|
has_insample_edge) + il muro di ESEGUIBILITA' a $600 (min size opzioni Deribit).
|
||||||
|
|
||||||
|
uv run python scripts/research/options_gamma_scalp.py
|
||||||
|
|
||||||
|
ONESTA': premio MODELLATO su DVOL ATM (skew non esplicito) — stesso caveat del VRP. Long-vol da
|
||||||
|
modello: meno pericoloso dello short-vol (loss capped al premio), ma resta un LEAD da modello.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from src.strategies.trend_portfolio import resample_1d
|
||||||
|
from altlib import marginal_vs_tp01 # type: ignore
|
||||||
|
|
||||||
|
RAW = PROJECT_ROOT / "data" / "raw"
|
||||||
|
SQ365 = np.sqrt(365.25)
|
||||||
|
DT = 1.0 / 365.25
|
||||||
|
|
||||||
|
# fee model (mirror VRP): opzioni Deribit cap 12.5% del premio; perp taker 0.05%/lato sull'hedge.
|
||||||
|
OPT_FEE_FRAC = 0.125
|
||||||
|
HEDGE_FEE_SIDE = 0.0005
|
||||||
|
TENOR_D = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _bs_straddle(S, K, T, sig):
|
||||||
|
"""Premio BS di uno straddle ATM (call+put), r=0."""
|
||||||
|
if T <= 0 or sig <= 0:
|
||||||
|
return abs(S - K)
|
||||||
|
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||||
|
d2 = d1 - sig * np.sqrt(T)
|
||||||
|
call = S * norm.cdf(d1) - K * norm.cdf(d2)
|
||||||
|
put = K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||||
|
return call + put
|
||||||
|
|
||||||
|
|
||||||
|
def _dvol(asset: str) -> pd.Series:
|
||||||
|
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
|
||||||
|
return pd.Series(dv["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def _load(asset: str):
|
||||||
|
df = resample_1d(load_data(asset, "1h"))
|
||||||
|
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
|
||||||
|
if s.index.tz is None:
|
||||||
|
s.index = s.index.tz_localize("UTC")
|
||||||
|
J = pd.concat({"px": s, "dvol": _dvol(asset)}, axis=1, join="inner").sort_index().dropna()
|
||||||
|
return J["px"].values, J["dvol"].values / 1.0, J.index
|
||||||
|
|
||||||
|
|
||||||
|
def _load_hourly(asset: str):
|
||||||
|
df = load_data(asset, "1h")
|
||||||
|
s = pd.Series(df["close"].values.astype(float),
|
||||||
|
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def rv_iv_diagnostic(asset: str):
|
||||||
|
"""Il CUORE strutturale: vol realizzata (a vari campionamenti) vs vol implicita (DVOL).
|
||||||
|
Se RV < IV in media -> il long gamma PERDE gross, prima di ogni fee. E' il VRP, di segno opposto."""
|
||||||
|
px, dvf, idx = _load(asset)
|
||||||
|
iv = float(np.mean(dvf))
|
||||||
|
rdaily = np.diff(np.log(px))
|
||||||
|
rv_daily = float(np.std(rdaily) * SQ365)
|
||||||
|
h = _load_hourly(asset)
|
||||||
|
rhour = np.diff(np.log(h.values))
|
||||||
|
rv_hour = float(np.std(rhour) * np.sqrt(365.25 * 24))
|
||||||
|
return dict(asset=asset, iv=iv, rv_daily=rv_daily, rv_hour=rv_hour,
|
||||||
|
spread_daily=iv - rv_daily, spread_hour=iv - rv_hour)
|
||||||
|
|
||||||
|
|
||||||
|
def gamma_scalp_hourly(asset: str, mode: str = "naked",
|
||||||
|
gate_cheap: float = 0.30) -> pd.Series:
|
||||||
|
"""Gamma scalp a rehedge ORARIO (la 'vera' frequenza di scalping): cattura la RV intraday,
|
||||||
|
ma paga 24x la fee di hedge. Tenor 7g = 168 step orari. IV costante nel giorno (DVOL)."""
|
||||||
|
h = _load_hourly(asset)
|
||||||
|
dv = _dvol(asset).reindex(h.index.normalize(), method="ffill")
|
||||||
|
dv.index = h.index
|
||||||
|
J = pd.concat({"px": h, "dvol": dv}, axis=1).dropna()
|
||||||
|
px = J["px"].values; dvf = J["dvol"].values; idx = J.index
|
||||||
|
n = len(px); STEPS = TENOR_D * 24; dt = 1.0 / (365.25 * 24)
|
||||||
|
# IV-rank causale su DVOL giornaliero per il gate
|
||||||
|
daily_iv = _dvol(asset)
|
||||||
|
rets = {}
|
||||||
|
i = 24 * 60
|
||||||
|
while i + STEPS < n:
|
||||||
|
S0 = px[i]; sig = dvf[i]; K = S0
|
||||||
|
if mode == "cheap":
|
||||||
|
day = idx[i].normalize()
|
||||||
|
hist = daily_iv[daily_iv.index < day]
|
||||||
|
if len(hist) >= 60 and float((hist < sig).mean()) > gate_cheap:
|
||||||
|
rets[idx[i + STEPS]] = 0.0; i += STEPS; continue
|
||||||
|
T = TENOR_D / 365.25
|
||||||
|
premium = _bs_straddle(S0, K, T, sig)
|
||||||
|
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
|
||||||
|
for t in range(1, STEPS + 1):
|
||||||
|
tau = max((STEPS - (t - 1)) * dt, dt)
|
||||||
|
Sp = px[i + t - 1]; Sn = px[i + t]
|
||||||
|
r = Sn / Sp - 1.0
|
||||||
|
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
|
||||||
|
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau))
|
||||||
|
gamma_pnl += dollar_gamma * (r * r - sig * sig * dt)
|
||||||
|
delta = 2.0 * norm.cdf(d1) - 1.0
|
||||||
|
if prev_delta is not None:
|
||||||
|
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
|
||||||
|
prev_delta = delta
|
||||||
|
pnl = gamma_pnl - OPT_FEE_FRAC * premium - hedge_fee
|
||||||
|
rets[idx[i + STEPS]] = pnl / S0
|
||||||
|
i += STEPS
|
||||||
|
out = pd.Series(rets)
|
||||||
|
out.index = out.index.normalize()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def gamma_scalp_asset(asset: str, mode: str = "naked",
|
||||||
|
gate_cheap: float = 0.30, skip_rich: float = 0.90) -> pd.Series:
|
||||||
|
"""Rendimenti settimanali (return-on-notional) di una catena di long-straddle delta-hedged,
|
||||||
|
lumpati sul giorno di scadenza. Causale: IV/strike/gate da dati <= entry; payoff sul path."""
|
||||||
|
px, dvf, idx = _load(asset)
|
||||||
|
n = len(px)
|
||||||
|
rets = {}
|
||||||
|
i = 60
|
||||||
|
while i + TENOR_D < n:
|
||||||
|
S0 = px[i]; sig = dvf[i]; K = S0
|
||||||
|
skip = False
|
||||||
|
if i >= 60 and mode in ("cheap", "rich"):
|
||||||
|
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale
|
||||||
|
if mode == "cheap" and ivr > gate_cheap: # compra gamma solo se vol a SCONTO
|
||||||
|
skip = True
|
||||||
|
if mode == "rich" and ivr > skip_rich: # evita di pagare vol carissima
|
||||||
|
skip = True
|
||||||
|
if skip:
|
||||||
|
rets[idx[i + TENOR_D]] = 0.0; i += TENOR_D; continue
|
||||||
|
T = TENOR_D / 365.25
|
||||||
|
premium = _bs_straddle(S0, K, T, sig)
|
||||||
|
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
|
||||||
|
for t in range(1, TENOR_D + 1):
|
||||||
|
tau = (TENOR_D - (t - 1)) / 365.25 # tempo residuo a inizio step
|
||||||
|
Sp = px[i + t - 1]; Sn = px[i + t]
|
||||||
|
r = Sn / Sp - 1.0
|
||||||
|
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
|
||||||
|
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau)) # DG straddle = phi(d1)*S/(sig*sqrt(tau))
|
||||||
|
gamma_pnl += dollar_gamma * (r * r - sig * sig * DT)
|
||||||
|
delta = 2.0 * norm.cdf(d1) - 1.0 # delta straddle ATM
|
||||||
|
if prev_delta is not None:
|
||||||
|
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
|
||||||
|
prev_delta = delta
|
||||||
|
opt_fee = OPT_FEE_FRAC * premium
|
||||||
|
pnl = gamma_pnl - opt_fee - hedge_fee
|
||||||
|
rets[idx[i + TENOR_D]] = pnl / S0 # return-on-notional
|
||||||
|
i += TENOR_D
|
||||||
|
return pd.Series(rets)
|
||||||
|
|
||||||
|
|
||||||
|
def to_daily_voltgt(weekly_btc: pd.Series, weekly_eth: pd.Series, tgt_vol: float = 0.20) -> pd.Series:
|
||||||
|
"""Book 50/50 BTC+ETH su griglia giornaliera, poi scalato a tgt_vol annuo (apples-to-apples
|
||||||
|
con gli altri sleeve, tutti vol-targeted ~20%)."""
|
||||||
|
wk = pd.concat({"B": weekly_btc, "E": weekly_eth}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||||
|
if wk.empty:
|
||||||
|
return wk
|
||||||
|
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||||
|
daily = pd.Series(0.0, index=days)
|
||||||
|
daily.loc[wk.index.normalize()] = wk.values
|
||||||
|
sd = daily.std()
|
||||||
|
if sd > 0:
|
||||||
|
daily = daily * (tgt_vol / (sd * SQ365))
|
||||||
|
return daily
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(daily: pd.Series) -> dict:
|
||||||
|
r = daily.values
|
||||||
|
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
|
||||||
|
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||||
|
pk = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((pk - eq) / pk))
|
||||||
|
yrs = (daily.index[-1] - daily.index[0]).days / 365.25
|
||||||
|
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
|
||||||
|
s = pd.Series(eq, index=daily.index)
|
||||||
|
yearly = {}
|
||||||
|
for y, g in s.groupby(s.index.year):
|
||||||
|
if len(g) > 1:
|
||||||
|
v = g.values; p = np.maximum.accumulate(v)
|
||||||
|
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
|
||||||
|
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 92)
|
||||||
|
print(" GAMMA SCALPING (long-vol) — scalping BTC/ETH con copertura in opzioni")
|
||||||
|
print(" Modello: long straddle ATM 7g, delta-hedge 1d, P&L = DG*(RV^2 - IV^2). Mirror del VRP01.")
|
||||||
|
print("=" * 92)
|
||||||
|
|
||||||
|
print("\n DIAGNOSTICA STRUTTURALE — vol implicita (DVOL) vs realizzata (il segno dell'edge):")
|
||||||
|
for a in ("BTC", "ETH"):
|
||||||
|
d = rv_iv_diagnostic(a)
|
||||||
|
print(f" {a}: IV {d['iv']*100:5.1f}% | RV_1d {d['rv_daily']*100:5.1f}% "
|
||||||
|
f"(IV-RV {d['spread_daily']*100:+.1f}pp) RV_1h {d['rv_hour']*100:5.1f}% "
|
||||||
|
f"(IV-RV {d['spread_hour']*100:+.1f}pp)")
|
||||||
|
print(" -> IV-RV > 0 = il mercato PAGA per essere short-vol (VRP). Il long gamma e' su questo lato"
|
||||||
|
", al ROVESCIO: paga il premio. RV_1h>RV_1d -> rehedge orario cattura piu' RV (test sotto).")
|
||||||
|
|
||||||
|
variants = {
|
||||||
|
"NAKED (sempre long gamma)": dict(mode="naked"),
|
||||||
|
"CHEAP-GATED (IV-rank<0.30 = vol scont)": dict(mode="cheap", gate_cheap=0.30),
|
||||||
|
"CHEAP-GATED (IV-rank<0.50)": dict(mode="cheap", gate_cheap=0.50),
|
||||||
|
"RICH-SKIP (no entry se IV-rank>0.90)": dict(mode="rich", skip_rich=0.90),
|
||||||
|
}
|
||||||
|
|
||||||
|
series = {}
|
||||||
|
for label, kw in variants.items():
|
||||||
|
wB = gamma_scalp_asset("BTC", **kw)
|
||||||
|
wE = gamma_scalp_asset("ETH", **kw)
|
||||||
|
daily = to_daily_voltgt(wB, wE)
|
||||||
|
series[label] = daily
|
||||||
|
m = _metrics(daily)
|
||||||
|
ntr = int((wB != 0).sum() + (wE != 0).sum())
|
||||||
|
print(f"\n--- {label} ---")
|
||||||
|
print(f" standalone (vol-tgt 20%): Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% "
|
||||||
|
f"maxDD {m['dd']*100:.1f}% trade {ntr}")
|
||||||
|
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||||
|
print(f" per-anno PnL: {ys}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(" REHEDGE ORARIO (la 'vera' frequenza di scalping: cattura RV intraday, paga 24x hedge fee)")
|
||||||
|
print("=" * 92)
|
||||||
|
for label, kw in (("NAKED orario", dict(mode="naked")),
|
||||||
|
("CHEAP-GATED orario (IV-rank<0.30)", dict(mode="cheap", gate_cheap=0.30))):
|
||||||
|
wB = gamma_scalp_hourly("BTC", **kw); wE = gamma_scalp_hourly("ETH", **kw)
|
||||||
|
daily = to_daily_voltgt(wB, wE)
|
||||||
|
m = _metrics(daily)
|
||||||
|
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
|
||||||
|
print(f"\n--- {label} ---")
|
||||||
|
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}%")
|
||||||
|
print(f" per-anno PnL: {ys}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(" SCORING MARGINALE vs TP01 (lo standard del progetto: un nuovo sleeve si giudica QUI)")
|
||||||
|
print("=" * 92)
|
||||||
|
for label, daily in series.items():
|
||||||
|
if daily.std() == 0:
|
||||||
|
print(f"\n[{label}] flat — skip"); continue
|
||||||
|
m = marginal_vs_tp01(daily)
|
||||||
|
b25 = m.get("blends", {}).get("w25", {})
|
||||||
|
print(f"\n[{label}]")
|
||||||
|
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
|
||||||
|
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
|
||||||
|
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
|
||||||
|
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
|
||||||
|
f"blend25 full {b25.get('full')} (upl {b25.get('uplift_full')}) "
|
||||||
|
f"hold {b25.get('hold')} (upl {b25.get('uplift_hold')}) DD {b25.get('dd')}")
|
||||||
|
print(f" hedge-check: uplift TP01-up {m.get('uplift_tp01_up')} / TP01-down {m.get('uplift_tp01_down')} "
|
||||||
|
f"yearly-corr {m.get('hedge_yearly_corr')}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 92)
|
||||||
|
print(" ESEGUIBILITA' a ~$600 (Deribit options min size)")
|
||||||
|
print("=" * 92)
|
||||||
|
pxB = _load("BTC")[0][-1]; pxE = _load("ETH")[0][-1]
|
||||||
|
for a, p, csz, minc in (("BTC", pxB, 1.0, 0.1), ("ETH", pxE, 1.0, 0.1)):
|
||||||
|
notion = p * csz * minc
|
||||||
|
print(f" {a}: spot ${p:,.0f} | contratto {csz} {a}, min {minc} {a} -> notional minimo "
|
||||||
|
f"${notion:,.0f} ({'OK' if notion < 600 else 'OLTRE i $600 -> NON eseguibile'})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""SCAN DI FATTIBILITA' — la vol term-structure e' scaricabile/certificabile da Deribit pubblico?
|
||||||
|
|
||||||
|
Un calendar-vol (front vs back IV) richiede ATM IV per SCADENZA, STORICA. Il DVOL pubblico e' solo
|
||||||
|
30g. Questo script PROVA cosa l'API pubblica Deribit espone davvero, PRIMA di costruire backtest:
|
||||||
|
1. snapshot CORRENTE della term-structure (mark_iv ATM per scadenza) — book_summary_by_currency.
|
||||||
|
2. esiste STORIA per-scadenza? (DVOL e' 30g fisso; provo trade history con IV per instrument).
|
||||||
|
Verdetto: se la storia per-scadenza NON e' pubblica -> calendar-vol NON backtestabile su dati certi.
|
||||||
|
|
||||||
|
uv run python scripts/research/probe_vol_termstructure.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
import requests
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
BASE = "https://www.deribit.com/api/v2/public/"
|
||||||
|
|
||||||
|
|
||||||
|
def _get(method, **params):
|
||||||
|
r = requests.get(BASE + method, params=params, timeout=40)
|
||||||
|
return r.json().get("result", None)
|
||||||
|
|
||||||
|
|
||||||
|
def current_term_structure(cur="BTC"):
|
||||||
|
"""ATM mark_iv per scadenza ORA (snapshot pubblico, tokenless)."""
|
||||||
|
summ = _get("get_book_summary_by_currency", currency=cur, kind="option")
|
||||||
|
if not summ:
|
||||||
|
return None
|
||||||
|
idx = _get("get_index_price", index_name=f"{cur.lower()}_usd")
|
||||||
|
spot = float(idx["index_price"]) if idx else None
|
||||||
|
# raggruppa per scadenza, prendi lo strike piu' vicino allo spot (ATM)
|
||||||
|
by_exp = {}
|
||||||
|
for o in summ:
|
||||||
|
name = o["instrument_name"] # es. BTC-27JUN26-60000-C
|
||||||
|
parts = name.split("-")
|
||||||
|
if len(parts) != 4:
|
||||||
|
continue
|
||||||
|
exp, strike = parts[1], float(parts[2])
|
||||||
|
miv = o.get("mark_iv")
|
||||||
|
if miv is None or spot is None:
|
||||||
|
continue
|
||||||
|
d = abs(strike - spot)
|
||||||
|
if exp not in by_exp or d < by_exp[exp][0]:
|
||||||
|
by_exp[exp] = (d, float(miv), strike)
|
||||||
|
return spot, by_exp
|
||||||
|
|
||||||
|
|
||||||
|
def probe_history(cur="BTC"):
|
||||||
|
"""C'e' STORIA per-scadenza? Provo: (a) DVOL (sappiamo 30g), (b) trade history con IV su uno
|
||||||
|
strumento vivo, (c) se esistono indici vol a tenor diversi."""
|
||||||
|
findings = []
|
||||||
|
# (b) trade history con IV — solo strumenti NON scaduti
|
||||||
|
instr = _get("get_instruments", currency=cur, kind="option", expired="false")
|
||||||
|
live = instr[0]["instrument_name"] if instr else None
|
||||||
|
if live:
|
||||||
|
tr = _get("get_last_trades_by_instrument", instrument_name=live, count=5)
|
||||||
|
has_iv = bool(tr and tr.get("trades") and "iv" in tr["trades"][0])
|
||||||
|
findings.append(f"trade-history IV su {live}: {'SI (ma solo instrument VIVO, no scaduti)' if has_iv else 'NO'}")
|
||||||
|
# (c) altri indici vol a tenor != 30g?
|
||||||
|
findings.append("get_volatility_index_data: solo DVOL 30g (nessun indice 7g/60g/90g pubblico)")
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 88)
|
||||||
|
print(" SCAN FATTIBILITA' — vol term-structure Deribit pubblico")
|
||||||
|
print("=" * 88)
|
||||||
|
for cur in ("BTC", "ETH"):
|
||||||
|
ts = current_term_structure(cur)
|
||||||
|
if not ts:
|
||||||
|
print(f"\n{cur}: nessun dato chain"); continue
|
||||||
|
spot, by_exp = ts
|
||||||
|
print(f"\n{cur} spot ${spot:,.0f} — term-structure ATM mark_iv ORA ({len(by_exp)} scadenze):")
|
||||||
|
# ordina per scadenza (data nel nome)
|
||||||
|
def _key(e):
|
||||||
|
try:
|
||||||
|
return time.mktime(time.strptime(e, "%d%b%y"))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
ivs = []
|
||||||
|
for exp in sorted(by_exp, key=_key):
|
||||||
|
d, miv, strike = by_exp[exp]
|
||||||
|
ivs.append(miv)
|
||||||
|
print(f" {exp:>9} ATM~{strike:>9,.0f} IV {miv:5.1f}%")
|
||||||
|
if len(ivs) >= 2:
|
||||||
|
print(f" -> front {ivs[0]:.1f}% back {ivs[-1]:.1f}% slope {ivs[-1]-ivs[0]:+.1f}pp "
|
||||||
|
f"({'contango' if ivs[-1] > ivs[0] else 'backwardation'})")
|
||||||
|
print(f" STORIA per-scadenza:")
|
||||||
|
for f in probe_history(cur):
|
||||||
|
print(f" - {f}")
|
||||||
|
print("\n" + "=" * 88)
|
||||||
|
print(" VERDETTO sulla backtestabilita' sotto.")
|
||||||
|
print("=" * 88)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""TP01 × DVOL — la vol IMPLICITA (forward-looking) migliora il risk-sizing di TP01? (ESEGUIBILE)
|
||||||
|
|
||||||
|
A differenza degli sleeve diversificatori (XS01/VRP01/carry = STAT-MODE, non eseguibili a $600),
|
||||||
|
questo TOCCA il book live: TP01 è BTC/ETH perp su Deribit, già armato. Oggi vol-targeta sulla vol
|
||||||
|
REALIZZATA 30g (backward-looking). Ipotesi: il DVOL (vol implicita 30g Deribit, forward-looking,
|
||||||
|
che spesso ANTICIPA i salti di vol realizzata) come denominatore del vol-target → de-risking più
|
||||||
|
tempestivo prima dei crash → hold-out migliore e/o DD più basso, SENZA peggiorare il FULL.
|
||||||
|
|
||||||
|
Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla FINESTRA COMUNE 2021-2026 (perdo
|
||||||
|
2018-2021, incluso il toro 2021 pre-DVOL). Baseline ricalcolato sulla stessa finestra. Hold-out 2025+.
|
||||||
|
Tutto causale (vol/segnale ≤ close[i]), fee 0.10% RT, long-flat, leva cap 2x — config CANONICA TP01.
|
||||||
|
|
||||||
|
VARIANTI (denominatore del vol-target):
|
||||||
|
REALIZED -> 30g realizzata (baseline canonica)
|
||||||
|
DVOL -> DVOL/100 (implicita)
|
||||||
|
BLEND -> 0.5·realizzata + 0.5·DVOL
|
||||||
|
MAX -> max(realizzata, DVOL) (sizing più difensivo: la più alta delle due)
|
||||||
|
DERISK -> realizzata, ma posizione ×0.5 quando DVOL > pctl espandente causale (gate crash)
|
||||||
|
|
||||||
|
uv run python scripts/research/tp01_dvol_overlay.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
from src.strategies.trend_portfolio import (
|
||||||
|
resample_1d, simple_returns, realized_vol, tsmom_blend, CANONICAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
RAW = ROOT / "data" / "raw"
|
||||||
|
SQ = np.sqrt(365.25)
|
||||||
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||||
|
TGT_VOL = CANONICAL["target_vol"]; LEV = CANONICAL["leverage"]; FEE = CANONICAL["fee_side"]
|
||||||
|
HZ = CANONICAL["horizons_days"]; VW = CANONICAL["vol_win_days"]
|
||||||
|
|
||||||
|
|
||||||
|
def _components(asset: str):
|
||||||
|
df = resample_1d(load_data(asset, "1h"))
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
idx = pd.to_datetime(df["datetime"])
|
||||||
|
if idx.dt.tz is None:
|
||||||
|
idx = idx.dt.tz_localize("UTC")
|
||||||
|
idx = pd.DatetimeIndex(idx) # tz-aware (UTC)
|
||||||
|
r = simple_returns(c)
|
||||||
|
rv = realized_vol(r, VW, 365.25) # 30g realizzata annualizzata (bpd=1)
|
||||||
|
direction = np.clip(tsmom_blend(c, HZ), 0, None) # long-flat
|
||||||
|
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
|
||||||
|
dser = pd.Series(dv["close"].values.astype(float) / 100.0,
|
||||||
|
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index()
|
||||||
|
dvol = dser.reindex(idx, method="ffill").values
|
||||||
|
return c, r, idx, rv, direction, dvol
|
||||||
|
|
||||||
|
|
||||||
|
def _net_returns(asset: str, mode: str, tvol: float = TGT_VOL) -> pd.Series:
|
||||||
|
c, r, idx, rv, direction, dvol = _components(asset)
|
||||||
|
derisk = np.ones(len(c))
|
||||||
|
if mode == "realized":
|
||||||
|
vol = rv
|
||||||
|
elif mode == "dvol":
|
||||||
|
vol = dvol
|
||||||
|
elif mode == "blend":
|
||||||
|
vol = 0.5 * rv + 0.5 * dvol
|
||||||
|
elif mode == "max":
|
||||||
|
vol = np.fmax(rv, dvol)
|
||||||
|
elif mode == "derisk":
|
||||||
|
vol = rv
|
||||||
|
# gate crash causale: DVOL sopra il suo percentile espandente (90%) -> dimezza l'esposizione
|
||||||
|
dd = pd.Series(dvol, index=idx)
|
||||||
|
rank = dd.expanding(min_periods=60).apply(lambda x: (x[:-1] < x[-1]).mean() if len(x) > 1 else 0.5, raw=True)
|
||||||
|
derisk = np.where(rank.values > 0.90, 0.5, 1.0)
|
||||||
|
else:
|
||||||
|
raise ValueError(mode)
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
scal = np.where((vol > 0) & np.isfinite(vol), tvol / vol, 0.0)
|
||||||
|
tgt = np.clip(direction * scal * derisk, -LEV, LEV)
|
||||||
|
tgt[~np.isfinite(tgt)] = 0.0
|
||||||
|
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t
|
||||||
|
gross = pos * r
|
||||||
|
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
net = np.clip(gross - FEE * turn, -0.99, None); net[0] = 0.0
|
||||||
|
return pd.Series(net, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio(mode: str, tvol: float = TGT_VOL) -> pd.Series:
|
||||||
|
b = _net_returns("BTC", mode, tvol); e = _net_returns("ETH", mode, tvol)
|
||||||
|
J = pd.concat({"B": b, "E": e}, axis=1, join="inner").fillna(0.0)
|
||||||
|
return 0.5 * J["B"] + 0.5 * J["E"]
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(daily: pd.Series, lo=None) -> dict:
|
||||||
|
if lo is not None:
|
||||||
|
daily = daily[daily.index >= lo]
|
||||||
|
r = daily.values
|
||||||
|
sh = float(np.mean(r) / np.std(r) * SQ) if np.std(r) > 0 else 0.0
|
||||||
|
eq = np.cumprod(1.0 + r); pk = np.maximum.accumulate(eq)
|
||||||
|
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||||
|
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
|
||||||
|
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
|
||||||
|
s = pd.Series(eq, index=daily.index); yearly = {}
|
||||||
|
for y, g in s.groupby(s.index.year):
|
||||||
|
if len(g) > 1:
|
||||||
|
yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||||
|
return dict(sharpe=sh, dd=dd, cagr=cagr, tot=float(eq[-1] - 1) if len(eq) else 0.0, yearly=yearly)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
modes = ["realized", "dvol", "blend", "max", "derisk"]
|
||||||
|
series = {m: portfolio(m) for m in modes}
|
||||||
|
# vero inizio DVOL (dove TUTTE le varianti hanno dati validi) — non il primo indice del prezzo
|
||||||
|
dstart = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH"))
|
||||||
|
dstart = pd.Timestamp(dstart, unit="ms", tz="UTC") + pd.Timedelta(days=VW) # +warmup vol-win
|
||||||
|
series = {m: s[s.index >= dstart] for m, s in series.items()}
|
||||||
|
common = series["realized"].index
|
||||||
|
base = series["realized"]
|
||||||
|
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" TP01 × DVOL — vol-target con denominatore di vol diverso. Finestra COMUNE "
|
||||||
|
f"{common[0].date()} -> {common[-1].date()} ({len(base)}g). Hold-out 2025+.")
|
||||||
|
print("=" * 96)
|
||||||
|
print(f" {'variante':<10} {'FULL Sh':>8} {'FULL DD':>8} {'CAGR':>7} | "
|
||||||
|
f"{'HOLD Sh':>8} {'HOLD ret':>9} {'HOLD DD':>8} | per-anno PnL")
|
||||||
|
for m in modes:
|
||||||
|
s = series[m]; f = metrics(s); h = metrics(s, lo=HOLDOUT)
|
||||||
|
ys = " ".join(f"{y}:{p*100:+.0f}" for y, p in sorted(f['yearly'].items()))
|
||||||
|
tag = " (baseline)" if m == "realized" else ""
|
||||||
|
print(f" {m:<10} {f['sharpe']:>+8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+6.0f}% | "
|
||||||
|
f"{h['sharpe']:>+8.2f} {h['tot']*100:>+8.1f}% {h['dd']*100:>7.1f}% | {ys}{tag}")
|
||||||
|
|
||||||
|
print("\n DELTA vs baseline (realized) sulla stessa finestra:")
|
||||||
|
bf = metrics(base); bh = metrics(base, lo=HOLDOUT)
|
||||||
|
for m in modes:
|
||||||
|
if m == "realized":
|
||||||
|
continue
|
||||||
|
f = metrics(series[m]); h = metrics(series[m], lo=HOLDOUT)
|
||||||
|
print(f" {m:<8}: ΔFULL Sh {f['sharpe']-bf['sharpe']:+.2f} ΔFULL DD {(f['dd']-bf['dd'])*100:+.1f}pp "
|
||||||
|
f"ΔHOLD Sh {h['sharpe']-bh['sharpe']:+.2f} ΔHOLD ret {(h['tot']-bh['tot'])*100:+.1f}pp")
|
||||||
|
|
||||||
|
print("\n CONTROLLO DECISIVO — il taglio di DD del DVOL è 'posizioni più piccole' o vero timing?")
|
||||||
|
print(" Confronto le varianti DVOL con il realized a target_vol RIDOTTO (stesso de-levering, ma")
|
||||||
|
print(" senza DVOL). Se realized-ridotto eguaglia/batte il DVOL a parità di DD → DVOL non aggiunge.")
|
||||||
|
for tv in (0.15, 0.13):
|
||||||
|
s = portfolio("realized", tvol=tv); s = s[s.index >= dstart]
|
||||||
|
f = metrics(s); h = metrics(s, lo=HOLDOUT)
|
||||||
|
print(f" realized @ vol-tgt {tv*100:.0f}%: FULL Sh {f['sharpe']:+.2f} DD {f['dd']*100:.1f}% "
|
||||||
|
f"CAGR {f['cagr']*100:+.0f}% | HOLD Sh {h['sharpe']:+.2f}")
|
||||||
|
mx = metrics(series["max"]); mxh = metrics(series["max"], lo=HOLDOUT)
|
||||||
|
print(f" (vs max-DVOL: FULL Sh {mx['sharpe']:+.2f} DD {mx['dd']*100:.1f}% "
|
||||||
|
f"CAGR {mx['cagr']*100:+.0f}% | HOLD Sh {mxh['sharpe']:+.2f})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""BOOK DERIBIT-ONLY (TP01 + SKH01) — target NETTO per asset via NETTING SOFTWARE su un solo conto.
|
||||||
|
|
||||||
|
TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit esiste UNA sola
|
||||||
|
posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in
|
||||||
|
software in un unico target netto, e si manda UN ordine per asset per raggiungerlo.
|
||||||
|
|
||||||
|
Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni
|
||||||
|
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
||||||
|
|
||||||
|
net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP )
|
||||||
|
|
||||||
|
WEIGHT = 0.5 (book 50/50 BTC+ETH, come i due sleeve)
|
||||||
|
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
|
||||||
|
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
|
||||||
|
E = equity reale del conto (fallback paper se offline)
|
||||||
|
CAP = max_notional_per_asset_usd (config/live.json, $300)
|
||||||
|
|
||||||
|
GLI EXIT DI SKH01 SONO IMPLICITI: _skyhook_positions() replica il book (ingressi + SL/TP/max_bars +
|
||||||
|
non-overlap) sulla feed certificata fresca -> quando il trade va chiuso ritorna 'flat' -> skh_sign=0
|
||||||
|
-> il target netto si aggiorna -> il reconciler chiude la quota SKH. NB: gli exit sono SOFTWARE
|
||||||
|
(no bracket on-book per SKH, sennò chiuderebbero anche la quota TP01) -> latenza fino alla chiusura
|
||||||
|
della barra 230m corrente. Solo il disaster-SL (-30%) resta on-book, sulla posizione NETTA.
|
||||||
|
|
||||||
|
Questo modulo NON invia nulla: costruisce solo il report/ordine. L'invio è in scripts/live/book_execute.py
|
||||||
|
(doppio gate). Causale: usa solo barre chiuse (eredita la causalità di TP01 e di _skyhook_positions).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.live.deribit import INSTRUMENT, notional_to_amount
|
||||||
|
from src.live.shadow import ASSETS, WEIGHT, shadow_report
|
||||||
|
from src.portfolio.sleeves import _skyhook_positions
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CONFIG = PROJECT_ROOT / "config" / "live.json"
|
||||||
|
|
||||||
|
# Pesi del book Deribit-only (vedi src/portfolio/sleeves.deribit_book_sleeves).
|
||||||
|
W_TP01 = 0.75
|
||||||
|
W_SKH = 0.25
|
||||||
|
FLAT_USD = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _cap() -> float:
|
||||||
|
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||||
|
return float(cfg.get("max_notional_per_asset_usd", 300.0))
|
||||||
|
|
||||||
|
|
||||||
|
def book_net_target(tp_frac: float, skh_sign: int, equity: float, cap: float,
|
||||||
|
weight: float = WEIGHT) -> float:
|
||||||
|
"""Target NETTO (USD notional, segno = direzione) di un asset del book. PURA, testabile.
|
||||||
|
Combina la frazione long-flat di TP01 (peso 0.75) e il segno L/S di SKH01 (peso 0.25),
|
||||||
|
clampata al cap per-asset. Vedi formula nel docstring del modulo."""
|
||||||
|
raw = weight * equity * (W_TP01 * max(tp_frac, 0.0) + W_SKH * float(skh_sign))
|
||||||
|
return max(-cap, min(cap, raw))
|
||||||
|
|
||||||
|
|
||||||
|
def _skh_sign(state) -> int:
|
||||||
|
if state == "flat" or not isinstance(state, dict):
|
||||||
|
return 0
|
||||||
|
return 1 if state.get("dir") == "LONG" else -1
|
||||||
|
|
||||||
|
|
||||||
|
def build_book_order(instrument: str, net_target_usd: float, current_pos_usd: float,
|
||||||
|
mark: float | None, min_usd: float = 5.0) -> dict | None:
|
||||||
|
"""COSTRUISCE (non invia) l'ordine per portare la posizione al target NETTO. Ritorna dict-ordine
|
||||||
|
o None se sotto-soglia. Gestisce long/short e i flip:
|
||||||
|
- |delta| < min_usd -> None (già a target);
|
||||||
|
- target ~0 -> CLOSE (reduce_only, esce sempre);
|
||||||
|
- flip di segno (long<->short) -> needs_flip=True (close + open, gestito dall'executor);
|
||||||
|
- stesso segno, |target|<|cur| -> REDUCE (reduce_only);
|
||||||
|
- altrimenti -> OPEN/INCREASE (buy se delta>0, sell se delta<0)."""
|
||||||
|
delta = net_target_usd - current_pos_usd
|
||||||
|
if abs(delta) < min_usd:
|
||||||
|
return None
|
||||||
|
side = "buy" if delta > 0 else "sell"
|
||||||
|
is_close = abs(net_target_usd) < FLAT_USD and abs(current_pos_usd) > FLAT_USD
|
||||||
|
needs_flip = (current_pos_usd > FLAT_USD and net_target_usd < -FLAT_USD) or \
|
||||||
|
(current_pos_usd < -FLAT_USD and net_target_usd > FLAT_USD)
|
||||||
|
same_sign = (net_target_usd > 0) == (current_pos_usd > 0)
|
||||||
|
is_reduce = is_close or (same_sign and abs(net_target_usd) < abs(current_pos_usd) and not needs_flip)
|
||||||
|
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||||
|
if amount == 0.0 and not is_close:
|
||||||
|
return None
|
||||||
|
return dict(
|
||||||
|
instrument=instrument, side=side, amount=amount, type="market",
|
||||||
|
reduce_only=bool(is_reduce or is_close), needs_flip=bool(needs_flip), is_close=bool(is_close),
|
||||||
|
net_target=round(net_target_usd, 2), current=round(current_pos_usd, 2), delta=round(delta, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def book_report(offline: bool = False, equity_override: float | None = None,
|
||||||
|
live_feed: bool = False) -> dict:
|
||||||
|
"""Stato completo del book (TP01+SKH01) NETTO, per asset. NON invia nulla. Serializzabile.
|
||||||
|
Riusa shadow_report (target TP01 + conto/posizioni/mark reali) e ci somma il segno di SKH01.
|
||||||
|
|
||||||
|
live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) ->
|
||||||
|
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
|
||||||
|
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
|
||||||
|
sh = shadow_report(offline=offline, equity_override=equity_override)
|
||||||
|
cap = _cap()
|
||||||
|
equity = sh["equity"]
|
||||||
|
load5m = None
|
||||||
|
if live_feed:
|
||||||
|
from src.live.livefeed import fresh_5m
|
||||||
|
load5m = fresh_5m
|
||||||
|
try:
|
||||||
|
skh = _skyhook_positions(load5m=load5m)
|
||||||
|
except Exception as e: # non bloccare il report se il feed SKH fallisce
|
||||||
|
skh = {a: "flat" for a in ASSETS}
|
||||||
|
sh = {**sh, "skh_error": f"{type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
assets, orders = [], []
|
||||||
|
for a_rec in sh["assets"]:
|
||||||
|
a = a_rec["asset"]
|
||||||
|
inst = INSTRUMENT[a]
|
||||||
|
tp_frac = float(a_rec["target"])
|
||||||
|
st = skh.get(a, "flat")
|
||||||
|
sign = _skh_sign(st)
|
||||||
|
net = book_net_target(tp_frac, sign, equity, cap)
|
||||||
|
cur = float(a_rec["position_usd"])
|
||||||
|
mark = a_rec["mark"]
|
||||||
|
order = build_book_order(inst, net, cur, mark, min_usd=5.0)
|
||||||
|
if order:
|
||||||
|
orders.append(order)
|
||||||
|
assets.append(dict(
|
||||||
|
asset=a, instrument=inst,
|
||||||
|
tp_frac=round(tp_frac, 4), skh_sign=sign,
|
||||||
|
skh_state=(st if st == "flat" else {k: st[k] for k in ("dir", "entry", "sl", "tp", "bars_in", "max_bars") if k in st}),
|
||||||
|
net_target=round(net, 2), position_usd=round(cur, 2), mark=mark, mark_src=a_rec.get("mark_src"),
|
||||||
|
order=order,
|
||||||
|
))
|
||||||
|
return dict(
|
||||||
|
last_data=sh["last_data"], online=sh["online"],
|
||||||
|
real_equity=sh["real_equity"], equity=equity, eq_basis=sh["eq_basis"],
|
||||||
|
cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH),
|
||||||
|
assets=assets, orders=orders,
|
||||||
|
flat=all(abs(x["net_target"]) < FLAT_USD for x in assets),
|
||||||
|
)
|
||||||
@@ -140,6 +140,47 @@ class DeribitTrader(DeribitRead):
|
|||||||
return [self.open(instrument, "buy", amount, label="tp01-buy")]
|
return [self.open(instrument, "buy", amount, label="tp01-buy")]
|
||||||
return [self._submit(instrument, "sell", amount, reduce_only=True, label="tp01-reduce")]
|
return [self._submit(instrument, "sell", amount, reduce_only=True, label="tp01-reduce")]
|
||||||
|
|
||||||
|
# --- RIBILANCIO al target CON SEGNO (book TP01+SKH01: long / short / flip) ---
|
||||||
|
def rebalance_signed(self, instrument: str, target_notional_usd: float, mark: float,
|
||||||
|
min_usd: float = 5.0) -> list[Fill]:
|
||||||
|
"""Porta la posizione su `instrument` al target NETTO con SEGNO (long-short, a differenza di
|
||||||
|
rebalance_to che e' long-only). Gestisce i flip chiudendo prima e riaprendo dall'altro lato.
|
||||||
|
- |delta| < min_usd -> niente;
|
||||||
|
- flip di segno -> close() (sempre permessa) poi apre dall'altra parte;
|
||||||
|
- target ~0 -> close();
|
||||||
|
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
||||||
|
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
|
||||||
|
Ritorna i Fill eseguiti."""
|
||||||
|
cur = self.position_usd(instrument)
|
||||||
|
if abs(target_notional_usd - cur) < min_usd:
|
||||||
|
return []
|
||||||
|
fills: list[Fill] = []
|
||||||
|
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||||
|
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||||
|
if crossing: # flip: flatta, poi riparti da zero
|
||||||
|
f = self.close(instrument, label="book-flip-close")
|
||||||
|
if f:
|
||||||
|
fills.append(f)
|
||||||
|
cur = 0.0
|
||||||
|
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||||
|
if abs(cur) > FLAT_USD:
|
||||||
|
f = self.close(instrument, label="book-exit")
|
||||||
|
if f:
|
||||||
|
fills.append(f)
|
||||||
|
return fills
|
||||||
|
delta = target_notional_usd - cur
|
||||||
|
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||||
|
if amount <= 0:
|
||||||
|
return fills
|
||||||
|
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||||
|
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||||
|
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||||
|
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||||
|
else:
|
||||||
|
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||||
|
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||||
|
return fills
|
||||||
|
|
||||||
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
||||||
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
||||||
stop_price: float, label: str = "disaster-sl") -> Fill:
|
stop_price: float, label: str = "disaster-sl") -> Fill:
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""FEED LIVE EFFIMERO per il segnale SKH01 (book a 230m) — NON tocca i dati certificati su disco.
|
||||||
|
|
||||||
|
SKH01 decide su griglia 230m: per eseguirlo fedelmente il segnale serve fresco all'ultima barra
|
||||||
|
chiusa. Il rebuild certificato (rebuild_history.py) gira 1×/giorno e fa un rebuild COMPLETO (pesante):
|
||||||
|
girarlo ogni ora sarebbe sbagliato e violerebbe la regola "aggiornare lo storico SOLO con
|
||||||
|
rebuild_history + certificare". Quindi qui NON scriviamo su disco: carichiamo il 5m CERTIFICATO e gli
|
||||||
|
appendiamo IN MEMORIA una coda recente presa da Deribit PUBBLICO (ccxt, tokenless, STESSO simbolo
|
||||||
|
inverse del feed certificato -> prezzi entro ~3 bps). I dati certificati restano la verità su disco;
|
||||||
|
questa estensione vive solo nel processo live e per il calcolo del segnale.
|
||||||
|
|
||||||
|
Robusto ai fallimenti: qualunque errore di rete/fetch -> ritorna il feed certificato invariato (il
|
||||||
|
runner degrada a "fermo all'ultimo dato certificato", mai opera a cieco). Solo SKH01 ne ha bisogno:
|
||||||
|
TP01 è giornaliero e gira bene sul feed certificato.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
|
||||||
|
# STESSO simbolo del feed certificato (vedi scripts/analysis/rebuild_history.DERIBIT_INSTR):
|
||||||
|
# inverse USD perp, storia lunga, entro ~3 bps dal lineare USDC su cui eseguiamo.
|
||||||
|
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
|
||||||
|
SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
|
||||||
|
"""Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche."""
|
||||||
|
import ccxt
|
||||||
|
ex = ccxt.deribit({"enableRateLimit": True})
|
||||||
|
tf_ms = 5 * 60 * 1000
|
||||||
|
since = int((time.time() - lookback_days * 86400) * 1000)
|
||||||
|
rows: dict[int, list] = {}
|
||||||
|
guard = 0
|
||||||
|
while guard < 200:
|
||||||
|
guard += 1
|
||||||
|
try:
|
||||||
|
r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
r = [x for x in r if int(x[0]) >= since]
|
||||||
|
if not r:
|
||||||
|
break
|
||||||
|
for x in r:
|
||||||
|
t = int(x[0])
|
||||||
|
rows[t] = [t, float(x[1]), float(x[2]), float(x[3]), float(x[4]), float(x[5] or 0)]
|
||||||
|
nxt = int(r[-1][0]) + tf_ms
|
||||||
|
if nxt <= since:
|
||||||
|
break
|
||||||
|
since = nxt
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame(columns=SCHEMA)
|
||||||
|
return pd.DataFrame(rows.values(), columns=SCHEMA).sort_values("timestamp").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_tail(base: pd.DataFrame, tail: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Fonde la coda fresca sul feed certificato: concat, dedup per timestamp (la coda VINCE sui
|
||||||
|
duplicati, ma le barre certificate storiche restano), riordina. Mantiene lo schema di load_data
|
||||||
|
(inclusa 'datetime' se presente). PURA, testabile senza rete."""
|
||||||
|
if tail is None or tail.empty:
|
||||||
|
return base
|
||||||
|
cols = [c for c in SCHEMA if c in base.columns]
|
||||||
|
t = tail[[c for c in SCHEMA if c in tail.columns]].copy()
|
||||||
|
merged = pd.concat([base[cols], t], ignore_index=True)
|
||||||
|
merged = merged.drop_duplicates("timestamp", keep="last").sort_values("timestamp").reset_index(drop=True)
|
||||||
|
# ricostruisci 'datetime' coerente (build_frames non la usa, ma load_data la espone)
|
||||||
|
merged["datetime"] = pd.to_datetime(merged["timestamp"], unit="ms", utc=True)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_5m(asset: str, lookback_days: int = 12) -> pd.DataFrame:
|
||||||
|
"""Feed 5m certificato + coda recente effimera (in-memory). Fallback al certificato su errore."""
|
||||||
|
base = load_data(asset, "5m")
|
||||||
|
sym = DERIBIT_SYMBOL.get(asset)
|
||||||
|
if sym is None:
|
||||||
|
return base
|
||||||
|
try:
|
||||||
|
tail = _fetch_recent_5m(sym, lookback_days)
|
||||||
|
except Exception:
|
||||||
|
return base
|
||||||
|
return merge_tail(base, tail)
|
||||||
@@ -236,13 +236,17 @@ def _skyhook_returns() -> pd.Series:
|
|||||||
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||||
|
|
||||||
|
|
||||||
def _skyhook_positions() -> dict:
|
def _skyhook_positions(load5m=None) -> dict:
|
||||||
"""Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA
|
"""Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA
|
||||||
-> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di
|
-> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di
|
||||||
entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse."""
|
entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse.
|
||||||
|
|
||||||
|
load5m: callable(asset)->df5 opzionale (per il live: feed certificato + coda fresca effimera,
|
||||||
|
vedi src/live/livefeed.fresh_5m). Default = feed certificato su disco (load_data)."""
|
||||||
|
_load = load5m if load5m is not None else (lambda a: load_data(a, "5m"))
|
||||||
out = {}
|
out = {}
|
||||||
for a in ASSETS:
|
for a in ASSETS:
|
||||||
ltf, htf = build_frames(load_data(a, "5m"))
|
ltf, htf = build_frames(_load(a))
|
||||||
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
ent = skyhook_entries(ltf, htf, SKH01_V2_DD)
|
||||||
H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values
|
H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values
|
||||||
n = len(ltf); i = 0; open_pos = "flat"
|
n = len(ltf); i = 0; open_pos = "flat"
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Test del BOOK DERIBIT-ONLY live (TP01+SKH01 nettati in software, un solo conto).
|
||||||
|
|
||||||
|
Coprono: la formula di netting (sizing 75/25 + cap, long/short/flat/flip), la PARITA' coi pesi del
|
||||||
|
backtest (deribit_book_sleeves), la sicurezza del gate (disarmato -> nessun ordine), e il reconcile
|
||||||
|
CON SEGNO (close+open sui flip, reduce reduce_only) — senza toccare la rete (trader fittizio).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.live.book import W_SKH, W_TP01, book_net_target, build_book_order
|
||||||
|
from src.live.execution import Fill
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Formula di netting: 75/25, cap, combinazioni long/short/flat.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_net_target_sizing():
|
||||||
|
E, cap = 600.0, 300.0
|
||||||
|
assert book_net_target(0.0, 0, E, cap) == 0.0 # tutto flat
|
||||||
|
assert book_net_target(0.0, 1, E, cap) == 75.0 # solo SKH long = 0.25*0.5*600
|
||||||
|
assert book_net_target(0.0, -1, E, cap) == -75.0 # solo SKH short
|
||||||
|
assert book_net_target(1.0, 0, E, cap) == 225.0 # solo TP01 pieno = 0.75*0.5*600
|
||||||
|
assert book_net_target(1.0, -1, E, cap) == 150.0 # TP long + SKH short (hedge parziale)
|
||||||
|
assert book_net_target(1.0, 1, E, cap) == 300.0 # capped
|
||||||
|
assert book_net_target(2.0, 1, E, cap) == 300.0 # cap superiore
|
||||||
|
assert book_net_target(2.0, -1, E, cap) == 300.0 # 0.5*600*(1.5-0.25)=375 -> cap 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_target_clamps_negative():
|
||||||
|
# TP flat e SKH short forte non sfora il cap negativo
|
||||||
|
assert book_net_target(0.0, -1, 4000.0, 300.0) == -300.0
|
||||||
|
# tp_frac negativo trattato come 0 (TP01 e' long-flat)
|
||||||
|
assert book_net_target(-5.0, 1, 600.0, 300.0) == 75.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_weights_match_backtest_sleeves():
|
||||||
|
"""I pesi del book live DEVONO coincidere con quelli del backtest (deribit_book_sleeves)."""
|
||||||
|
from src.portfolio.sleeves import deribit_book_sleeves
|
||||||
|
w = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()}
|
||||||
|
assert abs(w["TP01"] - W_TP01) < 1e-12 and abs(w["SKH01"] - W_SKH) < 1e-12
|
||||||
|
assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Costruzione ordine: side, reduce_only, flip, close, soglia minima.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_build_order_open_long():
|
||||||
|
o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 0.0, 60000.0, min_usd=5.0)
|
||||||
|
assert o["side"] == "buy" and not o["reduce_only"] and not o["needs_flip"] and not o["is_close"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_order_reduce_same_sign():
|
||||||
|
o = build_book_order("BTC_USDC-PERPETUAL", 75.0, 150.0, 60000.0)
|
||||||
|
assert o["side"] == "sell" and o["reduce_only"] and not o["needs_flip"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_order_flip():
|
||||||
|
o = build_book_order("BTC_USDC-PERPETUAL", -75.0, 75.0, 60000.0)
|
||||||
|
assert o["needs_flip"] and o["side"] == "sell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_order_close_to_flat():
|
||||||
|
o = build_book_order("BTC_USDC-PERPETUAL", 0.0, 75.0, 60000.0)
|
||||||
|
assert o["is_close"] and o["reduce_only"] and o["side"] == "sell"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_order_below_min_is_none():
|
||||||
|
assert build_book_order("BTC_USDC-PERPETUAL", 75.0, 73.0, 60000.0, min_usd=5.0) is None
|
||||||
|
assert build_book_order("BTC_USDC-PERPETUAL", 0.0, 0.0, 60000.0) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sicurezza del GATE: disarmato (execution_enabled=false) -> do_execute False.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_gate_requires_both_switches():
|
||||||
|
def do_execute(enabled, want_execute):
|
||||||
|
return bool(want_execute) and bool(enabled)
|
||||||
|
assert not do_execute(False, False)
|
||||||
|
assert not do_execute(True, False) # armato ma senza --execute
|
||||||
|
assert not do_execute(False, True) # --execute ma disarmato
|
||||||
|
assert do_execute(True, True) # solo con entrambi
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_default_disarmed(tmp_path, monkeypatch):
|
||||||
|
"""load_config di book_execute mette execution_enabled=False di default (fail-safe)."""
|
||||||
|
import importlib
|
||||||
|
be = importlib.import_module("scripts.live.book_execute") if False else None
|
||||||
|
# carica il modulo via path (scripts/ non e' un package importabile per nome)
|
||||||
|
import importlib.util
|
||||||
|
spec = importlib.util.spec_from_file_location("book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
|
||||||
|
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||||
|
monkeypatch.setattr(mod, "CONFIG", tmp_path / "nope.json") # config assente
|
||||||
|
assert mod.load_config()["execution_enabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reconcile CON SEGNO (long/short/flip) senza rete: trader fittizio.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class FakeTrader:
|
||||||
|
"""Replica la logica di DeribitTrader.rebalance_signed registrando le chiamate, senza rete."""
|
||||||
|
def __init__(self, pos):
|
||||||
|
self.pos = float(pos)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def position_usd(self, instrument):
|
||||||
|
return self.pos
|
||||||
|
|
||||||
|
def _mk(self, side, amount, reduce_only, label):
|
||||||
|
self.calls.append((label, side, round(amount, 6), reduce_only))
|
||||||
|
return Fill(instrument="X", side=side, amount=amount, filled=amount, price=60000.0,
|
||||||
|
fee_usdc=0.0, order_id="1", state="filled", verified=True)
|
||||||
|
|
||||||
|
def close(self, instrument, label="x"):
|
||||||
|
if abs(self.pos) < 1.0:
|
||||||
|
return None
|
||||||
|
f = self._mk("sell" if self.pos > 0 else "buy", abs(self.pos) / 60000.0, True, label)
|
||||||
|
self.pos = 0.0
|
||||||
|
return f
|
||||||
|
|
||||||
|
def open(self, instrument, side, amount, label="x"):
|
||||||
|
f = self._mk(side, amount, False, label)
|
||||||
|
self.pos += (amount * 60000.0) * (1 if side == "buy" else -1)
|
||||||
|
return f
|
||||||
|
|
||||||
|
def _submit(self, instrument, side, amount, *, reduce_only, label, **k):
|
||||||
|
f = self._mk(side, amount, reduce_only, label)
|
||||||
|
self.pos += (amount * 60000.0) * (1 if side == "buy" else -1)
|
||||||
|
return f
|
||||||
|
|
||||||
|
# importa il metodo reale da DeribitTrader (testiamo proprio quella logica)
|
||||||
|
from src.live.execution import DeribitTrader as _DT
|
||||||
|
rebalance_signed = _DT.rebalance_signed
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_open_from_flat():
|
||||||
|
t = FakeTrader(0.0)
|
||||||
|
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
|
||||||
|
labels = [c[0] for c in t.calls]
|
||||||
|
assert labels == ["book-open"] and t.calls[0][1] == "buy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_flip_closes_then_opens():
|
||||||
|
t = FakeTrader(75.0) # long, target short -> flip
|
||||||
|
t.rebalance_signed("BTC_USDC-PERPETUAL", -75.0, 60000.0, min_usd=5.0)
|
||||||
|
labels = [c[0] for c in t.calls]
|
||||||
|
assert labels == ["book-flip-close", "book-open"]
|
||||||
|
assert t.calls[1][1] == "sell" # apre short dopo il close
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_reduce_same_sign_is_reduce_only():
|
||||||
|
t = FakeTrader(150.0)
|
||||||
|
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
|
||||||
|
assert [c[0] for c in t.calls] == ["book-reduce"]
|
||||||
|
assert t.calls[0][3] is True # reduce_only
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_target_flat_closes():
|
||||||
|
t = FakeTrader(75.0)
|
||||||
|
t.rebalance_signed("BTC_USDC-PERPETUAL", 0.0, 60000.0, min_usd=5.0)
|
||||||
|
assert [c[0] for c in t.calls] == ["book-exit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_below_min_noop():
|
||||||
|
t = FakeTrader(73.0)
|
||||||
|
t.rebalance_signed("BTC_USDC-PERPETUAL", 75.0, 60000.0, min_usd=5.0)
|
||||||
|
assert t.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PARITA' d'integrazione: il report reale (offline, niente rete) applica ESATTAMENTE
|
||||||
|
# la formula pura per ogni asset -> wiring corretto e riproducibile.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feed live effimero (src/live/livefeed): merge/dedup, fallback, loader onorato.
|
||||||
|
# Nessuna rete (la coda fresca è iniettata / il fetch è stubbato a errore).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_merge_tail_dedups_and_tail_wins():
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.livefeed import merge_tail
|
||||||
|
base = pd.DataFrame({"timestamp": [0, 300000, 600000], "open": [1, 2, 3], "high": [1, 2, 3],
|
||||||
|
"low": [1, 2, 3], "close": [1, 2, 3], "volume": [1, 1, 1],
|
||||||
|
"datetime": pd.to_datetime([0, 300000, 600000], unit="ms", utc=True)})
|
||||||
|
tail = pd.DataFrame({"timestamp": [600000, 900000], "open": [9, 4], "high": [9, 4],
|
||||||
|
"low": [9, 4], "close": [9, 4], "volume": [2, 2]})
|
||||||
|
m = merge_tail(base, tail)
|
||||||
|
assert list(m["timestamp"]) == [0, 300000, 600000, 900000] # esteso + ordinato
|
||||||
|
assert m.loc[m["timestamp"] == 600000, "close"].iloc[0] == 9 # la coda VINCE sul duplicato
|
||||||
|
assert "datetime" in m.columns and m["datetime"].is_monotonic_increasing
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_tail_empty_returns_base():
|
||||||
|
import pandas as pd
|
||||||
|
from src.live.livefeed import merge_tail
|
||||||
|
base = pd.DataFrame({"timestamp": [0], "open": [1], "high": [1], "low": [1], "close": [1], "volume": [1]})
|
||||||
|
assert merge_tail(base, pd.DataFrame()).equals(base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_5m_falls_back_to_certified_on_error(monkeypatch):
|
||||||
|
import src.live.livefeed as lf
|
||||||
|
from src.data.downloader import load_data
|
||||||
|
monkeypatch.setattr(lf, "_fetch_recent_5m", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("net")))
|
||||||
|
got = lf.fresh_5m("BTC")
|
||||||
|
base = load_data("BTC", "5m")
|
||||||
|
assert len(got) == len(base) and got["timestamp"].iloc[-1] == base["timestamp"].iloc[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skyhook_positions_honors_custom_loader():
|
||||||
|
from src.portfolio import sleeves
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def spy(a):
|
||||||
|
calls.append(a)
|
||||||
|
return sleeves.load_data(a, "5m")
|
||||||
|
pos = sleeves._skyhook_positions(load5m=spy)
|
||||||
|
assert set(pos.keys()) == {"BTC", "ETH"} and calls == ["BTC", "ETH"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_book_report_uses_pure_formula_offline():
|
||||||
|
from src.live.book import book_report
|
||||||
|
r = book_report(offline=True, equity_override=600.0)
|
||||||
|
assert r["equity"] == 600.0 and r["cap_per_asset"] > 0
|
||||||
|
for a in r["assets"]:
|
||||||
|
expect = book_net_target(a["tp_frac"], a["skh_sign"], 600.0, r["cap_per_asset"])
|
||||||
|
assert abs(a["net_target"] - expect) < 1e-6, f"{a['asset']}: net incoerente con la formula"
|
||||||
|
assert a["skh_sign"] in (-1, 0, 1)
|
||||||
|
# offline -> conto assunto flat -> nessuna posizione reale, report deterministico
|
||||||
|
assert all(a["position_usd"] == 0.0 for a in r["assets"])
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Lock della conclusione 2026-06-26 (CC01 cash-and-carry): il premio di funding e' REALE e
|
||||||
|
positivo, ma PROCYCLICO (si comprime nel bear) — NON un edge all-weather. Lo Sharpe headline ~13 e'
|
||||||
|
un artefatto (rischi di coda assenti dal dataset) -> non testiamo lo Sharpe, testiamo il FATTO
|
||||||
|
economico e la procyclicita'. Diario docs/diary/2026-06-26-cash-carry-hl.md."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
from cash_carry_hl import cc_returns, load_funding_panel, metrics # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def test_funding_premium_is_real_and_positive():
|
||||||
|
"""Il carry esiste: funding aggregato BTC/ETH positivo, CC-static con CAGR > 0."""
|
||||||
|
r = cc_returns(["BTC", "ETH"], mode="static")
|
||||||
|
m = metrics(r)
|
||||||
|
assert m["gross_ann"] > 0.0 # premio di funding netto positivo
|
||||||
|
assert m["cagr"] > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_basis_markup_does_not_explain_high_sharpe():
|
||||||
|
"""Aggiungere il mark-to-market della base (Δpremium) NON sgonfia lo Sharpe in modo materiale:
|
||||||
|
prova che il rischio vero e' FUORI dal dataset (2022/liquidazione), non nel basis dei dati."""
|
||||||
|
naive = metrics(cc_returns(["BTC", "ETH"], mode="static"))["sharpe"]
|
||||||
|
basis = metrics(cc_returns(["BTC", "ETH"], mode="static", with_basis=True))["sharpe"]
|
||||||
|
assert basis > 0.7 * naive # cala poco -> il basis-from-data non e' il rischio nascosto
|
||||||
|
|
||||||
|
|
||||||
|
def test_carry_is_procyclical_fades_in_bear():
|
||||||
|
"""Il carry e' procyclico: il funding aggregato del toro 2024 supera quello del bear 2026."""
|
||||||
|
FUND, _ = load_funding_panel(["BTC", "ETH"])
|
||||||
|
agg = FUND.mean(axis=1)
|
||||||
|
by_year = agg.groupby(agg.index.year).sum()
|
||||||
|
assert by_year.get(2024, 0.0) > by_year.get(2026, 0.0)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Lock della conclusione 2026-06-26: il gamma scalping (long-vol) su BTC/ETH PERDE
|
||||||
|
strutturalmente — è lo specchio del VRP01, dal lato sbagliato del premio. Guardia contro
|
||||||
|
ri-litigare l'idea "scalping con copertura in opzioni" come edge.
|
||||||
|
Diario docs/diary/2026-06-26-gamma-scalp-options.md."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
from options_gamma_scalp import ( # type: ignore # noqa: E402
|
||||||
|
_bs_straddle, gamma_scalp_asset, rv_iv_diagnostic, to_daily_voltgt, _metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bs_straddle_positive_and_increasing_in_vol():
|
||||||
|
"""Premio straddle ATM > 0 e cresce con la vol implicita (sanity del pricer)."""
|
||||||
|
p_lo = _bs_straddle(100.0, 100.0, 7 / 365.25, 0.30)
|
||||||
|
p_hi = _bs_straddle(100.0, 100.0, 7 / 365.25, 0.90)
|
||||||
|
assert p_lo > 0
|
||||||
|
assert p_hi > p_lo
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_gamma_loses_when_rv_below_iv_synthetic():
|
||||||
|
"""Sintetico: con prezzo PIATTO (RV=0) e IV>0, il long gamma deve perdere (solo theta)."""
|
||||||
|
# P&L per step = DG*(r^2 - sigma^2 dt); con r=0 ogni step e' -DG*sigma^2*dt < 0.
|
||||||
|
from options_gamma_scalp import OPT_FEE_FRAC # noqa: F401
|
||||||
|
sig = 0.60
|
||||||
|
# se RV=0 il gamma_pnl e' strettamente negativo -> ritorno negativo. Verifico via il segno
|
||||||
|
# del contributo per-step a r=0.
|
||||||
|
r = 0.0
|
||||||
|
contrib = (r * r - sig * sig * (1.0 / 365.25))
|
||||||
|
assert contrib < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_iv_exceeds_or_near_daily_rv_btc():
|
||||||
|
"""BTC: a campionamento giornaliero l'IV (DVOL) >= RV in media -> il long gamma paga il VRP."""
|
||||||
|
d = rv_iv_diagnostic("BTC")
|
||||||
|
assert d["iv"] > 0 and d["rv_daily"] > 0
|
||||||
|
assert d["spread_daily"] > 0.0 # IV - RV_1d > 0 su BTC (lato short paga)
|
||||||
|
|
||||||
|
|
||||||
|
def test_naked_gamma_scalp_standalone_sharpe_negative():
|
||||||
|
"""La conclusione: il book gamma-scalp nudo (BTC+ETH) ha Sharpe standalone NEGATIVO."""
|
||||||
|
wB = gamma_scalp_asset("BTC", mode="naked")
|
||||||
|
wE = gamma_scalp_asset("ETH", mode="naked")
|
||||||
|
daily = to_daily_voltgt(wB, wE)
|
||||||
|
assert daily.std() > 0
|
||||||
|
assert _metrics(daily)["sharpe"] < 0.0
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Lock della conclusione 2026-06-26: il DVOL come denominatore del vol-target NON migliora TP01
|
||||||
|
risk-adjusted. Il taglio di DD delle varianti DVOL è solo de-levering, replicabile MEGLIO con un
|
||||||
|
target_vol più basso sul realizzato. Diario docs/diary/2026-06-26-tp01-dvol-overlay.md."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
from tp01_dvol_overlay import portfolio, metrics, RAW, VW # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def _dstart():
|
||||||
|
s = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH"))
|
||||||
|
return pd.Timestamp(s, unit="ms", tz="UTC") + pd.Timedelta(days=VW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvol_does_not_beat_realized_risk_adjusted():
|
||||||
|
"""Sulla finestra comune, il realized batte (o eguaglia) le varianti DVOL sullo Sharpe FULL."""
|
||||||
|
lo = _dstart()
|
||||||
|
base = metrics(portfolio("realized")[portfolio("realized").index >= lo])
|
||||||
|
mx = metrics(portfolio("max")[portfolio("max").index >= lo])
|
||||||
|
dv = metrics(portfolio("dvol")[portfolio("dvol").index >= lo])
|
||||||
|
assert base["sharpe"] >= mx["sharpe"] - 1e-9
|
||||||
|
assert base["sharpe"] >= dv["sharpe"] - 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_lower_target_vol_replicates_dd_cut_at_better_sharpe():
|
||||||
|
"""Il taglio di DD del DVOL è solo de-levering: realized @ vol-tgt 15% eguaglia il DD del
|
||||||
|
max-DVOL a Sharpe FULL non inferiore (anzi superiore)."""
|
||||||
|
lo = _dstart()
|
||||||
|
mx = metrics(portfolio("max")[portfolio("max").index >= lo])
|
||||||
|
lo_tv = metrics(portfolio("realized", tvol=0.15)[portfolio("realized", tvol=0.15).index >= lo])
|
||||||
|
assert lo_tv["dd"] <= mx["dd"] + 0.01 # stesso DD (entro 1pp)
|
||||||
|
assert lo_tv["sharpe"] >= mx["sharpe"] # a Sharpe non inferiore
|
||||||
|
|
||||||
|
|
||||||
|
def test_dvol_spike_gate_is_redundant_with_trend():
|
||||||
|
"""Il gate DVOL-spike de-risk non cambia nulla: TP01 è già flat nei crash (momentum<0)."""
|
||||||
|
lo = _dstart()
|
||||||
|
base = portfolio("realized"); dr = portfolio("derisk")
|
||||||
|
base = base[base.index >= lo]; dr = dr[dr.index >= lo]
|
||||||
|
assert abs(metrics(base)["sharpe"] - metrics(dr)["sharpe"]) < 0.02
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Test offline del logger forward della vol term-structure (2026-06-26). La STORIA per-scadenza
|
||||||
|
non e' pubblica su Deribit -> calendar-vol non backtestabile ora; questo logger costruisce il dataset
|
||||||
|
in avanti. Si testa la pura interpolazione ai tenor fissi. Diario 2026-06-26-vol-termstructure-feasibility.md."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||||
|
|
||||||
|
from log_vol_termstructure import build_row, TENORS # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_row_interpolates_all_tenors():
|
||||||
|
"""Curva sintetica in contango -> riga con tutti i tenor + slope positivo."""
|
||||||
|
curve = [(7, 40.0), (30, 45.0), (90, 50.0), (180, 55.0)]
|
||||||
|
row = build_row(100000.0, curve, now_ms=1_700_000_000_000)
|
||||||
|
assert all(f"iv_{t}d" in row for t in TENORS)
|
||||||
|
assert abs(row["iv_30d"] - 45.0) < 1e-6 # nodo esatto
|
||||||
|
assert 45.0 < row["iv_60d"] < 50.0 # interpolato tra 30 e 90
|
||||||
|
assert row["slope_7_180"] > 0 # contango -> slope positivo
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_row_needs_two_points():
|
||||||
|
assert build_row(100.0, [(30, 50.0)], now_ms=1_700_000_000_000) is None
|
||||||
|
assert build_row(100.0, [], now_ms=1_700_000_000_000) is None
|
||||||
Reference in New Issue
Block a user