merge: Price Ladder research + GRID_BTC paper sleeve (shadow stage 1)

Porta su main: pulizia feed (clean_feed, 254 spike-print Binance), ricerca Price Ladder
(ladder_search/regate/sltp), GridWorker paper + validazione, wiring runner kind=grid +
paper_extra (PORT06 canonico INTATTO: 19 sleeve), GRID_BTC paper in portfolios.yml.
Tutto additive + validato; nessun ordine reale (grid sim-only).
This commit is contained in:
Adriano Dal Pastro
2026-06-18 16:19:48 +00:00
10 changed files with 1099 additions and 5 deletions
@@ -0,0 +1,265 @@
# 2026-06-18 — Price Ladder (griglia) vs gate PORT06
**Branch:** `price_ladder_research` · **Harness:** `scripts/analysis/ladder_search.py`
(engine `grid_mtm`: mark-to-market onesto, SL gap-aware, flat-skip, fee 0.10% RT taker).
**Baseline PORT06:** FULL Sharpe 8.13 / DD 2.47% — OOS Sharpe 10.86 / DD 2.09%.
**Metodo:** caccia multi-agente su asset × TF (15m/30m/1h) × regime-gate (none / range
trend_max 1.5/2.0/2.5), poi `verify` avversariale sui finalisti (plateau, fee 2x, corr,
FULL DD di portafoglio). **Goal di progetto:** trovare un Price Ladder che *diversifichi*
(PASSI il gate) senza accendere la coda di trend.
---
## 1. Cosa era già noto
Il gioco-griglia precedente aveva trovato una **griglia ETH fortissima standalone**
(Sharpe ~5.61) ma **BOCCIATA al gate PORT06**: corr ~0.40 con le fade ETH già deployate
**ridondante**, non un diversificatore. La lezione: lo standalone Sharpe non basta, conta
quanto il ladder *aggiunge ortogonalmente* al portafoglio. Questa ricerca cerca proprio un
ladder che PASSI il gate = che abbia corr bassa con gli sleeve esistenti.
---
## 2. Risultato NUOVO — i ladder PASSANO il gate
Sì. Su **tutti gli asset liquidi** (BTC e ETH) e su quasi tutte le combinazioni TF×regime,
i ladder migliorano l'OOS Sharpe di portafoglio (10.86 → ~11.011.35) **con corr bassa**:
- **BTC** (15m/30m/1h, range o none): corr **0.150.27**, molto più ortogonale della
griglia ETH bocciata (0.40). OOS di portafoglio 11.111.35 (il guadagno OOS più grosso).
- **ETH** (15m/30m/1h): corr **0.210.57** (15m la peggiore, fino a 0.57; a 1h scende a
0.220.39). OOS di portafoglio ~10.911.1.
- **SOL/LTC**: SOL passa solo 2 celle, half-only, edge standalone quasi nullo
(full_sh ~0.2); **LTC è negativo ovunque** (full_sh 0.4…−0.8, bocciato) → solo BTC/ETH
liquidi danno un ladder vivo, come atteso.
Il gate **misura corr + OOS**, e su questi due assi i ladder BTC vincono nettamente.
### Migliori candidati (dai `verify` avversariali)
| Asset/TF/regime/struttura | OOS base→new | FULL DD standalone | FULL DD portafoglio (full / half) | corr | fee2x OOS | robust? |
|---|---|---|---|---|---|---|
| **ETH 1h range2 rd0.16 ru0.04 L4** | 10.86→**11.00** | 11.79% | **2.70** / 2.52 | 0.305 | 4.89 | ✅ sì |
| **ETH 1h range2 rd0.16 ru0.06 L6** | 10.86→11.01 | 12.09% | 2.74 / 2.54 | 0.328 (al filo) | 5.05 | ✅ sì |
| **ETH 1h range2 rd0.16 ru0.06 L4** | 10.86→10.98 | 12.64% | **2.68** / 2.51 | 0.249 | 4.44 | ✅ sì |
| **ETH 30m range2 rd0.16 ru0.04 L3** | 10.86→10.93 | 12.56% | **2.48** / 2.41 | 0.223 | 4.21 | ✅ sì |
| **ETH 1h none rd0.20 ru0.06 L6** | 10.86→11.03 | 17.89% | 2.59 / 2.46 | 0.271 | 5.43 | ✅ sì |
| **ETH 30m none rd0.16 ru0.06 L4** | 10.86→10.92 | 18.68% | **2.37** / 2.35 | 0.319 | 5.42 | ✅ sì |
| **ETH 1h none rd0.16 ru0.06 L4** | 10.86→10.96 | 23.13% | **2.40** / 2.37 | 0.224 | 4.59 | ✅ sì |
| **BTC 1h none rd0.08 ru0.06 L3** | 10.86→**11.34** | **53.69%** | 3.11 / **2.48** | 0.216 | 5.78 | ⚠ half-only |
| **BTC 30m range2.5 rd0.08 ru0.06 L3** | 10.86→11.22 | 55.33% | 3.27 / **2.50** | 0.265 | 5.19 | ⚠ half-only |
| **BTC 30m none rd0.08 ru0.06 L3** | 10.86→11.32 | 55.33% | 3.27 / **2.44** | 0.220 | 5.37 | ⚠ half-only (tail) |
| **BTC 1h range1.5 rd0.20 ru0.06 L6** | 10.86→11.13 | 54.43% | 3.19 / **2.33** | **0.161** | 5.46 | ⚠ half-only (tail) |
`rd` = spacing down, `ru` = spacing up, `L` = livelli. Tutti i finalisti hanno plateau
robusto (47 vicini di struttura restano PROMOSSO) e fee2x ~= OOS standalone.
---
## 3. IL NODO — la FULL DD (che il gate IGNORA)
**Il gate verdict NON guarda la FULL DD.** Misura solo corr + OOS Sharpe/DD. Una griglia
long-only ha una coda di trend 2021/22 (viene comprata fino in fondo mentre il prezzo crolla
in un trend persistente) → la **FULL DD standalone** è il vero rischio nascosto. Il filtro
onesto è la **FULL DD di PORTAFOGLIO** (`full_full_dd` / `half_full_dd`), con soglia di
tail-risk **~+0.8pp sopra il baseline 2.47%** (cioè restare sotto ~3.3%).
Due famiglie nette emergono:
- **BTC** — FULL DD standalone **~5467%** (la coda long-only è devastante, come temuto).
Il regime-gate range *taglia gli ingressi nei trend* ma **non immunizza** dalle posizioni
già aperte → a FULL size la FULL DD di portafoglio sale a **3.16.6%** (+0.6/+4.0pp,
**oltre la soglia tail** in quasi tutte le celle). **A HALF size invece rientra a 2.332.54%**
(~baseline) preservando l'OOS ~11.011.3. → **BTC deployabile SOLO a half-size**, e il
guadagno OOS più grosso (11.3+) viene proprio da queste celle a coda alta.
- **ETH** — FULL DD standalone **molto più bassa** del previsto: **1233%** (1h range scende
a **~12%**), NON il ~60% temuto. Lo SL-buffer + flat-skip + il regime-gate range tengono la
griglia fuori dai trend estesi. Conseguenza: la FULL DD di portafoglio resta **2.372.74%**
(+0.0/+0.27pp, **sotto soglia anche a FULL size**) — in diversi casi *scende* sotto il
baseline (es. ETH 30m none L4 → 2.37%). → **ETH è la famiglia tail-safe.** Il prezzo è una
corr più alta (0.220.33 sui finalisti 1h/30m, fino a 0.57 sul 15m da scartare).
**Lettura chiave:** il regime-gate *abbassa* la FULL DD ma su BTC **non a sufficienza** a
full size (la coda 54% è troppo grande); è l'**half-size** a fare il lavoro. Su ETH la coda
è già piccola e il portafoglio l'assorbe. Quindi il discriminante reale è **asset (ETH vs
BTC) + size**, non solo il regime-gate.
---
## 4. Vantaggio Deribit — fill MAKER
Un Price Ladder è una griglia di **ordini LIMIT ai livelli** → su Deribit i fill sono
**MAKER (~0% fee)**, non taker. L'harness assume **0.10% RT taker** ovunque → è
**CONSERVATIVA**: la stessa griglia live pagherebbe meno fee e potrebbe rendere PIÙ del
backtest. Lo confermano i `verify`: il **fee2x** (raddoppio fee) lascia l'OOS Sharpe quasi
intatto (~98% trattenuto su quasi tutti i finalisti) → nessun fee-cliff, e con i fill maker
reali c'è upside, non downside, sul lato costi. (Il rischio resta la coda di trend e lo
slippage/fill in esecuzione reale, non le fee.)
---
## 5. Verdetto operativo
**C'è un Price Ladder deployabile.** Il candidato più pulito su tutti gli assi
(PROMOSSO + FULL DD di portafoglio controllata a full size + plateau robusto + corr genuina):
> **ETH 1h, regime=range trend_max 2.0, rd=0.16 / ru=0.04 / 4 livelli, sl_buf 0.12 / tp_buf 0.05, max_bars 720.**
> OOS 10.86→11.00, FULL DD portafoglio **2.70% a full / 2.52% a half** (standalone 11.79%),
> corr 0.305, fee2x OOS 4.89, plateau 4 vicini PROMOSSO. **Deploy raccomandato a HALF size**
> (FULL DD +0.05pp, marginale; il caveat slippage live giustifica la prudenza).
**Alternative ETH equivalenti** (stessa banda di sicurezza, scegliere per corr/coda):
`ETH 1h range2 rd0.16 ru0.06 L4` (corr 0.249, la più bassa) e `ETH 30m range2 rd0.16 ru0.04 L3`
(FULL DD portafoglio 2.48, ~baseline). Tutte ETH 1h/30m **range-gated rd≥0.16** → NON scendere
sotto rd 0.16 (rd 0.12 fa risalire la corr verso 0.38 = ricomincia a somigliare alle fade ETH;
il 15m ha corr 0.430.57 = troppo ridondante, scartare).
**I BTC NON a full size.** Danno il guadagno OOS più grande (fino a 11.34) e la corr più bassa
(0.160.22 = la diversificazione migliore), MA la FULL DD standalone ~54% li rende
**tail-risky a full size** (FULL DD portafoglio 3.16.6%, oltre soglia). **Deployabili SOLO a
half-size** (FULL DD portafoglio 2.332.50), dove restano un diversificatore eccellente. Il
migliore BTC half-size è `BTC 1h range1.5 rd0.20 ru0.06 L6` (corr **0.161**, FULL DD portafoglio
half **2.33**).
**BLOCCO prima di qualsiasi deploy:** il gate PORT06 (corr + OOS) è **necessario ma non
sufficiente** — non controlla la FULL DD, e l'OOS held-out è un **regime calmo** (202425). La
coda di trend 2021/22 è reale e standalone è enorme su BTC. **Nessun deploy senza un gate
stressato sul 2022** (verificare la FULL DD di portafoglio attraverso il trend duro, non solo
l'OOS calmo) e una conferma su **ledger reale shadow** per slippage/fill maker. Il verdetto del
gate va sempre incrociato con la FULL DD di portafoglio (filtro vero) + fattibilità feed live +
regime recente — la stessa lezione del timing-sweep pairs/honest (2026-06-14).
**Sintesi:** ETH 1h/30m range-gated rd≥0.16 a half-size è il **deploy-candidate sano e
tail-safe**; BTC è il diversificatore migliore ma **solo a half-size** per la coda di trend.
Prossimo passo obbligatorio: stress-test 2022 + shadow ledger reale (i fill maker possono solo
migliorare il risultato sul lato fee).
---
## 6. ⚠️ RETTIFICA CRITICA — il verdetto §5 è ROVESCIATO (critico + verifica dati)
Il critico di completezza + una verifica diretta dei dati **ribaltano la raccomandazione di
deploy della §5**. Messo a verbale: la §5 è SUPERATA da quanto segue.
**(1) I risultati BTC poggiano su DATI CORROTTI (spike-print), verificato.** La "coda di trend
BTC ~54%" su cui ruota la §3 NON è un crollo di mercato: è **feed sporco**. Misurato su `btc_1h`:
**37 barre con range intrabar >20%**, concentrate a **feb-2024** (es. 2024-02-13: low=38.580 con
close~49.968 = wick fasullo 23% che rimbalza nella stessa barra), con min 22.706 / max 104.688 in
un trimestre dove BTC stava ~4757k. È lo **stesso spike-print testnet documentato ovunque in
CLAUDE.md** (TP_PHANTOM / feed congelato). L'engine SL-gap-aware filla su questi wick fantasma e la
griglia "compra" i dip fasulli → la coda BTC ~54% e parte dell'edge OOS sono **artefatto, non
mercato**. Conseguenza: i candidati BTC NON sono affidabili, e lo "stress 2022" proposto NON
toccherebbe la coda vera (che è a feb-2024, da dati sporchi). Serve `close_only` + pulizia feed.
**(2) Il guadagno OOS è RUMORE al peso deployabile.** +0.14 OOS Sharpe su base 10.86, con il ladder
che pesa **~5.3% full / ~2.7% half** in un portafoglio già a Sharpe ~11, su **592 giorni di un
solo regime calmo** (2024-10→2026-05, niente 2022). Nessun test di significatività
(block-bootstrap). A half-size il contributo è quasi inerte (a volte l'OOS DD *migliora*): è dentro
l'errore di campionamento, non un edge dimostrato.
**(3) La diversificazione ETH è più sottile di quanto venduto.** Il finalista ETH "raccomandato" è
**corr 0.305** (non 0.150.20: quella è BTC half-only non deployabile), già vicino allo **0.40 che
fece bocciare la griglia ETH originale**. Peggio: il `regime_mask` usa **lo stesso filtro
trend-distance** (|closeEMA200|/ATR) delle fade ETH già live → la "diversificazione" è in parte
illusoria (gate e sleeve condividono il filtro). La corr misurata è **daily in regime calmo**, NON
co-tail: una griglia long-only e una fade reversion possono co-crollare in un trend (entrambe
"comprano il calo") — la metrica che conterebbe (co-movimento nella coda) non è calcolata.
**(4) La FULL DD standalone è mascherata dal compounding.** Il finalista ETH compone a **~31x
(+3.1M%) in 5.4 anni**: una "FULL DD 11.79%" accanto a un ritorno di milioni-% è artefatto di
notional reinvestito, e il daily-resample **nasconde l'inventory-risk intrabar** che il docstring
di `grid_game_gate.py` (righe 57) avverte esplicitamente di non vedere.
**(5) Stress disponibile NON usato.** `ladder_search`/`grid_mtm` hanno `close_only=True` (filla solo
sul close, neutralizza gli spike-print): NON è stato girato sui finalisti — proprio il test che
smonterebbe la coda BTC fasulla.
### Verdetto rettificato
**NON c'è un Price Ladder deployabile dimostrato.** La ricerca ha prodotto (a) una **harness onesta
e validata** (`ladder_search.py` + regime-gate, riproduce esatto il gate noto), (b) un **risultato
negativo/guardingo** robusto: ogni candidato o è ridondante (ETH, corr→0.40), o poggia su dati
sporchi (BTC spike-print), o è rumore al peso deployabile. **Il multi-agente + critico ha evitato
un deploy ingiustificato** — esattamente la lezione anti-squeeze del progetto.
**Prima di riconsiderare QUALSIASI ladder servono, in ordine:** (1) **pulire il feed BTC** dagli
spike-print + girare `close_only` sui finalisti; (2) **test di significatività** del delta OOS
(block-bootstrap) al peso reale; (3) **co-tail / corr nei crash** (2022), non corr daily calma;
(4) isolare e spiegare l'**evento feb-2024**. Config live **invariata**.
---
## 7. RE-GATE su DATI PULITI (post clean_feed.py) — quadro onesto finale
Dopo `clean_feed.py` (254 spike-print riparati con Binance) ho ri-gateato i top candidati con
le verifiche che il critico chiedeva (`ladder_regate_clean.py`): gate PORT06 + stress
`close_only` + **DD per anno** sulla storia completa.
| Candidato | DD (gate, 2021+) | OOS Sh | corr | fee2x | close_only OOS | gate½ | **DD 2018 (tail vero)** |
|---|---:|---:|---:|---:|---:|---|---:|
| ETH 1h rd0.16 ru0.04 L4 range2 | 12.4% | 4.70 | 0.29 | 4.62 | 0.24 | PROMOSSO | 52.4% |
| ETH 1h rd0.16 ru0.06 L4 range2 | 12.6% | 4.25 | 0.23 | 4.17 | 0.20 | PROMOSSO | 49.9% |
| BTC 1h rd0.08 ru0.06 L3 none | 10.8% | 5.90 | 0.24 | 5.78 | 0.60 | PROMOSSO | 50.3% |
| **BTC 1h rd0.20 ru0.06 L6 range1.5** | 14.8% | 5.58 | 0.23 | 5.46 | 0.89 | PROMOSSO | **27.7%** |
| BTC 30m rd0.08 ru0.06 L3 none | 14.8% | 5.47 | 0.25 | 5.37 | 1.07 | PROMOSSO | 36.6% |
**Cosa cambia coi dati puliti:**
1.**L'obiezione "coda artefatto" CADE.** Il 54% del BTC era spike-print 2024: ora la DD del
gate (2021+) è **~11-15%** e tutti i candidati restano PROMOSSO a half-size, corr 0.22-0.29,
reggono fee2x. Sono candidati *veri*, non artefatti.
2. 🔴 **Emerge il tail VERO: il 2018** (44/52% standalone), che **il gate NON VEDE** perché
l'IDX del progetto parte dal 2021-01-01. Una griglia long-only sarebbe stata sventrata nel
bear 2018 (BTC 84% sull'anno). È il nuovo rischio portante — e una **lacuna metodologica
generale**: tutte le metriche "FULL" del progetto escludono il 2018. Mitigante reale: il
**regime-gate stretto** (BTC rd0.20 L6 **range1.5**) dimezza il tail 2018 a **27.7%** (vs
50% senza gate) → il gate-di-regime fa il suo lavoro contro i bear sostenuti.
3. 🟡 **L'edge dipende dai fill INTRABAR.** `close_only` (fill solo sul close) fa crollare l'OOS
(4.7→0.24 ETH, 5.9→0.6 BTC). Per ordini LIMIT i fill intrabar sono legittimi (close_only è
troppo severo come stress), ma il gap segnala forte **sensibilità all'ipotesi di fill**
il ledger shadow reale è indispensabile prima di fidarsi.
4. 🟡 Restano: corr 0.22-0.29 (moderata), guadagno OOS ai limiti del rumore al peso deployabile
(~2.7% half), OOS singolo regime calmo.
**Verdetto finale (clean data):** la pulizia ha rimosso l'obiezione FALSA (coda artefatto) e
promosso i candidati a *genuini*, ma ha anche scoperto il tail REALE (2018, gate-cieco) e
confermato due cautele (fill intrabar, OOS rumoroso). **Il candidato più sensato è ora
`BTC 1h rd0.20 ru0.06 L6 regime=range trend_max1.5`**: PROMOSSO, corr 0.23, e la **miglior coda
2018 (27.7%)** grazie al regime-gate. **Prima di QUALSIASI deploy**: (1) backtest che INCLUDA il
2018 nel gate (non solo IDX 2021+), (2) ledger shadow reale per i fill intrabar/maker, (3) half-size.
La pulizia del feed resta il guadagno trasversale più grande (irrobustisce ogni backtest del progetto).
---
## 8. I 3 PASSI PRE-DEPLOY + STUDIO SL/TP (ladder_sltp_study.py)
**[1] Valutazione 2018-INCLUSIVE** (standalone, tutta la storia; il gate IDX2021+ è cieco al 2018):
| Candidato (sl0.12/tp0.05) | full_sh | full_dd | oos_sh | DD 2018 | DD 2021 |
|---|---:|---:|---:|---:|---:|
| BTC 1h L6 **range1.5** | 3.18 | 27.7% | 4.95 | **27.7%** | 14.8% |
| BTC 1h L3 none | 2.93 | 60.2% | 5.51 | **50.3%** | 10.8% |
Il regime-gate già dimezza il tail vero (2018: 50%→−27.7%). Senza gate la griglia long-only è insostenibile (60%).
**Studio SL/TP** (sweep sl_buf × tp_buf sul candidato regime-gated, full 2018+):
- **Sweet spot: `sl_buf=0.10, tp_buf=0.03` → DD 2018 23.5%** (da 27.7%), oos_sh 5.06, full_sh 2.92. Migliora il tail SENZA intaccare l'edge.
- **Lezione (conferma il prior del progetto):** un SL **troppo stretto PEGGIORA** (sl 0.06 → 33/35%): stoppa e rideploya più in basso nel coltello che cade = la trappola del falso-negativo mean-reversion. Un SL **troppo largo** (0.20) idem (29/31%, oos più basso). Il bene sta in mezzo (0.10).
- **Il SL da solo (senza regime-gate) è ERRATICO** (tabella L3 none non-monotona: 20% → 52% → 43% al variare di pochi punti): è il **regime-gate** il controllo primario della coda; il SL moderato fine-tuna. Catastrophe-control = "non deployare nel bear" (regime) + "cappa l'episodio" (SL 0.10), NON uno stop stretto.
**[2] Fill maker vs taker** (Deribit: i livelli sono LIMIT → maker):
| fee | oos_sh | DD 2018 | gate½ |
|---|---:|---:|---|
| taker 0.10% RT (harness) | 5.06 | 23.5% | PROMOSSO (OOS 10.86→11.0, corr 0.195) |
| **maker 0%** (Deribit reale) | 5.18 | 22.7% | PROMOSSO (OOS→11.0, corr 0.194) |
Il maker è leggermente MIGLIORE → la harness taker è conservativa, nessun fee-cliff. (La parte live = shadow ledger reale resta il passo operativo finale.)
**[3] Half-size:** il gate "half" è già a metà size e PROMOSSO; la coda 2018 standalone 23.5% si dimezza sul book (~12% in un 2018-repeat a half-size) = reale ma gestibile.
### CONFIG FINALE raccomandata (con SL/TP aggiunti)
> **BTC 1h · regime=range trend_max 1.5 · rd 0.20 / ru 0.06 / 6 livelli · sl_buf 0.10 / tp_buf 0.03 · HALF-size.**
> - Tail 2018-inclusive: 23.5% standalone (~12% sul book a half) — reale ma controllato dal regime-gate + SL moderato.
> - Gate PORT06: PROMOSSO (OOS 10.86→11.0, corr 0.195); maker Deribit lo migliora; nessun fee-cliff.
> - **Unico passo residuo: shadow ledger reale** (fill intrabar/maker su Deribit) — operativo, non backtestabile.
Caveat onesti residui: l'OOS gain al peso deployabile è modesto (10.86→11.0); il tail 2018 non è zero (12% a half); l'edge dipende dai fill intrabar (per i LIMIT è legittimo, ma lo shadow lo deve confermare).
+17
View File
@@ -35,6 +35,23 @@ overrides:
# Override per-famiglia: irrilevante per il conto reale (i pairs sono PAPER), tenuto
# solo perche' i worker pairs in sola-statistica dimensionino come da gate storico.
position_size_family: {PAIRS: 0.13}
# PAPER_EXTRA (2026-06-18): sleeve paper definiti SOLO qui (NON in _defs.py/PORT06) ->
# NON entrano nel backtest canonico/regression-lock. Shadow STAGE 1 del Price Ladder:
# GridWorker SIM-only su feed Deribit BTC 1h (NESSUN ordine reale; kind=grid non e' mai
# eseguito reale per costruzione). Config = re-gate su dati puliti (branch
# price_ladder_research): regime-gate range trend_max 1.5, rd0.20/ru0.06, 6 livelli,
# sl0.10/tp0.03. position_size 0.15 PINNATO (canonico validato; senza, erediterebbe il
# 0.5 globale del micro-test). Gira in data/portfolio_paper_stats/GRID_BTC/.
paper_extra:
- sid: GRID_BTC
kind: grid
name: GRID
asset: BTC
tf: "1h"
cluster: BTC-rev
params: {tf: "1h", range_down: 0.20, range_up: 0.06, levels: 6,
sl_buf: 0.10, tp_buf: 0.03, max_bars: 720,
regime: range, trend_max: 1.5, position_size: 0.15}
# Esecuzione REALE su Deribit MAINNET. Solo i 7 single-leg con TP/SL in metadata:
# 6 fade (MR01/MR02/MR07 x BTC/ETH 15m) + DIP01 (BTC 1h). Ordini sui LINEARI USDC
# (payoff lineare = matematica del backtest; fee/PnL in USDC).
+140
View File
@@ -0,0 +1,140 @@
"""CLEAN FEED — ripara gli spike-print del feed Deribit/Cerbero coi dati reali di Binance.
Motivo (2026-06-18): la ricerca Price Ladder ha rivelato che data/raw/btc_1h.parquet (e gli
altri TF/asset) contengono barre con WICK FASULLI (es. BTC 2024-02-13: low 38.580 con
close ~49.968, BTC reale ~50k) — lo stesso spike-print testnet documentato in CLAUDE.md
(TP_PHANTOM / feed congelato). Sono pochi (decine per file) ma avvelenano i backtest
(stop/entry su prezzi mai avvenuti) e gonfiano le code (la "FULL DD BTC ~54%" del ladder era
in gran parte questo).
Metodo (conservativo, fonte di verita' = Binance spot via ccxt, gia' cablato nel progetto):
1. DETECT: barra sospetta = high/low che sfora >15% il cluster di close locale [i-1,i,i+1]
(close sano + wick fasullo). Soglia larga: tanto e' Binance ad arbitrare.
2. ARBITRA: per ogni sospetta, scarica la barra Binance reale (BTC/USDT, ETH/USDT) allo
stesso tf/timestamp. Sostituisce O/H/L/C SOLO se Binance dissente materialmente (>2% su
high o low) -> un wick VERO confermato da Binance resta intatto. Volume/timestamp invariati.
3. BACKUP (data/_feed_backup/) + scrittura atomica + VALIDAZIONE (re-scan = 0 sospette,
n righe invariato). Log dettagliato di ogni barra riparata (old OHLC -> new).
uv run python scripts/analysis/clean_feed.py [ASSET_TF ...] # default: tutti BTC/ETH x TF
uv run python scripts/analysis/clean_feed.py BTC_1h # un solo file
"""
from __future__ import annotations
import shutil
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import _parquet_path, DATA_DIR
BACKUP = PROJECT_ROOT / "data" / "_feed_backup"
SYMBOL = {"BTC": "BTC/USDT", "ETH": "ETH/USDT"}
WICK_THR = 0.15 # detect: wick oltre 15% il cluster di close locale
REPLACE_THR = 0.02 # arbitra: sostituisci solo se Binance dissente >2% su high/low
_EX = None
def _binance():
global _EX
if _EX is None:
import ccxt
_EX = ccxt.binance({"enableRateLimit": True})
return _EX
def suspect_mask(df: pd.DataFrame) -> np.ndarray:
c = df["close"].to_numpy(float); h = df["high"].to_numpy(float); l = df["low"].to_numpy(float)
cp = np.roll(c, 1); cp[0] = c[0]; cn = np.roll(c, -1); cn[-1] = c[-1]
locmax = np.maximum.reduce([c, cp, cn]); locmin = np.minimum.reduce([c, cp, cn])
return (h > locmax * (1 + WICK_THR)) | (l < locmin * (1 - WICK_THR))
def _binance_bar(symbol: str, tf: str, ts_ms: int):
"""OHLC reale Binance alla barra ts_ms (None se assente)."""
try:
rows = _binance().fetch_ohlcv(symbol, tf, since=ts_ms - 1, limit=3)
except Exception as e:
print(f" ! binance err: {type(e).__name__}: {str(e)[:80]}")
return None
for r in rows:
if int(r[0]) == ts_ms:
return float(r[1]), float(r[2]), float(r[3]), float(r[4])
return None
def clean_file(asset: str, tf: str) -> dict:
path = _parquet_path(asset, tf)
if not path.exists():
return {"file": f"{asset}_{tf}", "skip": "no-file"}
df = pd.read_parquet(path)
mask = suspect_mask(df)
idx = np.where(mask)[0]
n0 = len(df)
if len(idx) == 0:
return {"file": f"{asset}_{tf}", "suspect": 0, "repaired": 0, "kept_real": 0,
"missing_binance": 0, "rows_before": n0, "rows_after": n0,
"still_suspect": 0, "log": []}
repaired, kept, missing = 0, 0, 0
log = []
for i in idx:
ts = int(df.iloc[i]["timestamp"])
b = _binance_bar(SYMBOL[asset], tf, ts)
oh, ol = float(df.iloc[i]["high"]), float(df.iloc[i]["low"])
if b is None:
missing += 1
continue
bo, bh, bl, bc = b
if abs(oh - bh) / bh > REPLACE_THR or abs(ol - bl) / max(bl, 1e-9) > REPLACE_THR:
df.iat[i, df.columns.get_loc("open")] = bo
df.iat[i, df.columns.get_loc("high")] = bh
df.iat[i, df.columns.get_loc("low")] = bl
df.iat[i, df.columns.get_loc("close")] = bc
repaired += 1
ts_s = pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
log.append(f" {ts_s} H {oh:,.0f}->{bh:,.0f} L {ol:,.0f}->{bl:,.0f}")
else:
kept += 1 # Binance conferma il wick: barra reale, intatta
if repaired:
BACKUP.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, BACKUP / f"{asset.lower()}_{tf}.parquet.bak")
tmp = path.with_suffix(".parquet.tmp")
df.to_parquet(tmp, index=False)
tmp.replace(path)
# validazione
df2 = pd.read_parquet(path)
still = int(suspect_mask(df2).sum())
return {"file": f"{asset}_{tf}", "suspect": len(idx), "repaired": repaired,
"kept_real": kept, "missing_binance": missing, "rows_before": n0,
"rows_after": len(df2), "still_suspect": still, "log": log}
def main():
targets = sys.argv[1:] or [f"{a}_{tf}" for a in ("BTC", "ETH")
for tf in ("5m", "15m", "30m", "1h")]
print(f"CLEAN FEED — backup in {BACKUP}\n")
grand = 0
for t in targets:
asset, tf = t.split("_", 1)
r = clean_file(asset, tf)
if r.get("skip"):
print(f" {t:<9} SKIP ({r['skip']})"); continue
grand += r.get("repaired", 0)
print(f" {r['file']:<9} sospette={r['suspect']:>3} riparate={r['repaired']:>3} "
f"reali-tenute={r.get('kept_real',0):>3} no-binance={r.get('missing_binance',0):>2} "
f"| righe {r['rows_before']}=={r['rows_after']} residue-sospette={r['still_suspect']}")
for line in r.get("log", [])[:8]:
print(line)
if len(r.get("log", [])) > 8:
print(f" ... (+{len(r['log'])-8} altre)")
print(f"\n TOTALE barre riparate: {grand}")
if __name__ == "__main__":
main()
+13 -3
View File
@@ -68,18 +68,25 @@ def _load(asset, tf):
def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf,
max_bars, pos=POS, lev=LEV, fee_side=FEE_SIDE, flat_skip=True,
close_only=False):
close_only=False, deploy_mask=None, df=None):
"""Griglia STRATEGIA_GRIGLIA.md con contabilita' mark-to-market.
Ritorna (equity daily Series base 1.0, stats dict). Causale: deploy sul
close, fill dalle barre successive lungo il percorso O->L->H->C / O->H->L->C.
`deploy_mask` (opzionale, np.bool array lungo come la serie, causale): se
fornito, una NUOVA griglia si deploya SOLO dove mask[j]=True (regime-gate);
None = comportamento storico (deploy sempre). Una griglia gia' attiva non
viene interrotta dal mask (gestisce il suo episodio fino a SL/TP/timeout).
`df` (opzionale): OHLCV gia' caricato (per il feed LIVE del GridWorker); None
= carica da _load(asset, tf) (comportamento storico, parita' col gate).
"""
df = _load(asset, tf)
df = _load(asset, tf) if df is None else df
op = df["open"].to_numpy(float)
hi = df["high"].to_numpy(float)
lo = df["low"].to_numpy(float)
cl = df["close"].to_numpy(float)
dt = pd.to_datetime(df["datetime"]).to_numpy()
dt = (pd.to_datetime(df["datetime"]) if "datetime" in df.columns
else pd.to_datetime(df["timestamp"], unit="ms", utc=True)).to_numpy()
n = len(cl)
ratio = ((1 + range_up) / (1 - range_down)) ** (1.0 / levels)
if ratio - 1 <= 1.5 * 2 * fee_side: # vincolo break-even §4
@@ -106,6 +113,9 @@ def grid_mtm(asset="ETH", *, tf, range_down, range_up, levels, sl_buf, tp_buf,
i = 20
for j in range(20, n):
if not active:
if deploy_mask is not None and not deploy_mask[j]:
eq[j] = capital # regime-gate: niente deploy, resta in cash
continue
# deploy sul close di j (fill da j+1)
px = cl[j]
rl_ = px * (1 - range_down)
+70
View File
@@ -0,0 +1,70 @@
"""LADDER RE-GATE su DATI PULITI (2026-06-18) — ri-valuta i top candidati Price Ladder dopo
clean_feed.py, con le verifiche che il critico aveva chiesto:
- gate PORT06 + corr + fee2x (su feed corretto);
- stress close_only (fill solo sul close: su dati puliti deve ~combaciare col base -> niente
dipendenza residua da spike-print);
- DD PER ANNO della griglia standalone (dov'e' la coda VERA, non l'artefatto feb-2024).
uv run python scripts/analysis/ladder_regate_clean.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.ladder_search import evaluate, regime_mask
from scripts.analysis.grid_game_gate import grid_mtm
# top candidati dal workflow (asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, regime, trend_max)
CANDS = [
("ETH", "1h", 0.16, 0.04, 4, 0.12, 0.05, 720, "range", 2.0), # raccomandato dalla sintesi
("ETH", "1h", 0.16, 0.06, 4, 0.12, 0.05, 720, "range", 2.0), # corr piu' bassa (0.249)
("ETH", "30m", 0.16, 0.04, 3, 0.12, 0.05, 1440, "range", 2.0),
("BTC", "1h", 0.08, 0.06, 3, 0.12, 0.05, 720, "none", 2.0), # best OOS; DD era 53.69% (artefatto)
("BTC", "1h", 0.20, 0.06, 6, 0.12, 0.05, 720, "range", 1.5), # corr piu' bassa (0.161)
("BTC", "30m", 0.08, 0.06, 3, 0.12, 0.05, 1440, "none", 2.0),
]
def peryear_dd(asset, tf, rd, ru, lv, sl, tp, mb, regime, tmax):
mask = regime_mask(asset, tf, trend_max=tmax) if regime == "range" else None
eqd, _ = grid_mtm(asset, tf=tf, range_down=rd, range_up=ru, levels=lv,
sl_buf=sl, tp_buf=tp, max_bars=mb, deploy_mask=mask)
out = {}
for y, g in eqd.groupby(eqd.index.year):
peak = g.cummax()
out[int(y)] = round(float(((g - peak) / peak).min() * 100), 1)
return out
def main():
print("RE-GATE PRICE LADDER su dati PULITI — gate PORT06 + close_only stress + DD/anno\n")
print(f"{'candidato':<34}{'fullDD':>7}{'oos_sh':>7}{'corr':>6}{'fee2x':>6}"
f"{'co_oos':>7}{'verdHALF':>10} DD per anno")
for c in CANDS:
asset, tf, rd, ru, lv, sl, tp, mb, regime, tmax = c
r = evaluate(asset, tf, rd, ru, lv, sl, tp, mb, regime=regime, trend_max=tmax)
rc = evaluate(asset, tf, rd, ru, lv, sl, tp, mb, regime=regime, trend_max=tmax,
close_only=True, do_gate=False, do_fee2x=False)
pyd = peryear_dd(*c)
tag = f"{asset} {tf} rd{rd} ru{ru} L{lv} {regime}"
py = " ".join(f"{y}:{v}" for y, v in pyd.items())
print(f"{tag:<34}{r['full_dd']:>7.1f}{r['oos_sh']:>7.2f}{r['max_corr_existing']:>6.2f}"
f"{r['fee2x_oos_sh']:>6.2f}{rc['oos_sh']:>7.2f}{r['verdict_half']:>10} {py}")
print("\n fullDD = DD standalone sulla finestra del GATE (IDX 2021+); era ~54% su BTC")
print(" PRIMA della pulizia (artefatto spike-print 2024, ora sparito).")
print(" ATTENZIONE — DD per anno: il TAIL VERO e' il 2018 (-44/-52%), che il gate (IDX")
print(" 2021-01-01+) NON VEDE. Una griglia long-only sarebbe stata sventrata nel")
print(" bear 2018. Il regime-gate piu' stretto (BTC rd0.2 L6 range1.5) lo dimezza (-27.7%).")
print(" co_oos = OOS Sharpe con fill SOLO sul close (no wick). CROLLA (4.7->0.2): l'edge della")
print(" griglia viene dai fill INTRABAR ai livelli. Per ordini LIMIT i fill intrabar")
print(" sono LEGITTIMI (close_only e' troppo severo), ma il gap segnala sensibilita'")
print(" all'ipotesi di fill -> serve il ledger shadow reale prima di fidarsi.")
print(" verdHALF = gate PORT06 a half-size | corr = max corr coi 19 sleeve (ridondanza)")
if __name__ == "__main__":
main()
+191
View File
@@ -0,0 +1,191 @@
"""LADDER SEARCH — harness per la caccia multi-agente a strategie Price Ladder (griglia).
Goal 2026-06-18 (branch price_ladder_research): "decine di agenti a cercare strategie
Price Ladder". CONTESTO: il gioco "Grid Traders" trovo' gia' una griglia ETH asimmetrica
fortissima standalone (FULL Sharpe 5.61, OOS 4.21, plateau 16/16) ma BOCCIATA al gate
PORT06 -- ridondante con le fade ETH (corr +0.40 con MR02_ETH): full-size alza FULL ma
abbassa OOS 10.86->10.47. Quindi la ricerca NON e' "trovare un edge griglia" (gia' fatto)
ma trovarne uno che PASSI IL GATE = aggiunga DIVERSIFICAZIONE. Leve nuove:
- ASSET diverso da ETH (BTC: meno ridondante con la reversione ETH);
- REGIME-GATE: deployare la griglia SOLO in regime di range (non trend) -- il doc
STRATEGIA_GRIGLIA.md dice che la griglia muore in trend; gateare i deploy concentra
l'edge dove funziona E riduce la correlazione coi trend-follower;
- STRUTTURA: livelli, range asimmetrico, sl/tp buffer, max_bars, tf.
Motore = grid_mtm (mark-to-market ONESTO: SL gap-aware, flat-skip, fee 0.10% RT) di
grid_game_gate.py, esteso con deploy_mask per il regime-gate (retro-compatibile).
Tutto NETTO, OOS held-out, leva 3x. Il giudizio che CONTA e' il gate PORT06.
CLI (JSON):
uv run python scripts/analysis/ladder_search.py eval ETH 15m 0.171 0.046 4 0.124 0.048 2143
uv run python scripts/analysis/ladder_search.py eval BTC 1h 0.13 0.05 4 0.12 0.05 1500 range 2.0
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.grid_game_gate import grid_mtm, std, _load
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, IDX
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio import weighting as W
_BASE = None
def _baseline():
global _BASE
if _BASE is None:
from src.portfolio.sleeves import all_sleeve_equities
_BASE = dict(all_sleeve_equities())
return _BASE
def _atr(df, n=14):
h, l, c = df["high"].to_numpy(float), df["low"].to_numpy(float), df["close"].to_numpy(float)
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
return pd.Series(tr).rolling(n).mean().to_numpy()
def regime_mask(asset, tf, ema_n=200, trend_max=2.0):
"""Mask CAUSALE 'range-bound' allineata alle righe di _load(asset,tf):
True dove |close - EMA(ema_n)| / ATR(14) < trend_max (prezzo vicino al trend =
regime di range -> la griglia puo' deployare). Decisione a close[j] con dati <= j."""
df = _load(asset, tf)
c = df["close"].to_numpy(float)
ema = pd.Series(c).ewm(span=ema_n, adjust=False).mean().to_numpy()
a = _atr(df, 14)
with np.errstate(invalid="ignore", divide="ignore"):
dist = np.abs(c - ema) / np.where(a == 0, np.nan, a)
m = dist < trend_max
m[~np.isfinite(dist)] = False # warmup / ATR nan -> niente deploy
return m
def _gate(grid_eq):
"""Gate PORT06 (stesso metodo di grid_game_gate): baseline vs +LADDER full/half.
Ritorna metriche base/full/half + max corr coi 19 sleeve (segnale ridondanza)."""
p = PORTFOLIOS["PORT06"]
base = _baseline()
def port_m(extra):
members = dict(base); ids = list(p.sleeve_ids)
if extra is not None:
members["LADDER"] = extra; ids = ids + ["LADDER"]
dr = pd.DataFrame({i: members[i].reindex(IDX).ffill().bfill()
.pct_change().fillna(0.0) for i in ids})
w = W.weight_vector(p.weighting, ids, dr, weights=p.weights,
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
drp = port_returns({i: members[i].reindex(IDX).ffill().bfill() for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT)
fb, ob = port_m(None)
gr = grid_eq.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)
maxcorr = max(abs(gr.corr(e.reindex(IDX).ffill().bfill().pct_change().fillna(0.0)))
for e in base.values())
half = (1 + 0.5 * gr).cumprod()
ff, of = port_m(grid_eq)
fh, oh = port_m(half)
def ok(f, o):
return bool(o["sharpe"] >= ob["sharpe"] - 0.02 and o["dd"] <= ob["dd"] + 1e-9
and f["sharpe"] >= fb["sharpe"] - 0.02)
return {
"base_full_sh": round(fb["sharpe"], 2), "base_full_dd": round(fb["dd"], 2),
"base_oos_sh": round(ob["sharpe"], 2), "base_oos_dd": round(ob["dd"], 2),
"full_oos_sh": round(of["sharpe"], 2), "full_oos_dd": round(of["dd"], 2),
"full_full_sh": round(ff["sharpe"], 2), "full_full_dd": round(ff["dd"], 2),
"half_oos_sh": round(oh["sharpe"], 2), "half_oos_dd": round(oh["dd"], 2),
"half_full_sh": round(fh["sharpe"], 2), "half_full_dd": round(fh["dd"], 2),
"max_corr_existing": round(float(maxcorr), 3),
"verdict_full": "PROMOSSO" if ok(ff, of) else "bocciato",
"verdict_half": "PROMOSSO" if ok(fh, oh) else "bocciato",
}
def evaluate(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars,
regime="none", trend_max=2.0, ema_n=200, do_gate=True, do_fee2x=True,
flat_skip=True, close_only=False) -> dict:
mask = regime_mask(asset, tf, ema_n=ema_n, trend_max=trend_max) if regime == "range" else None
cfg = dict(tf=tf, range_down=rd, range_up=ru, levels=levels,
sl_buf=sl_buf, tp_buf=tp_buf, max_bars=max_bars)
try:
eqd, st = grid_mtm(asset, **cfg, deploy_mask=mask, flat_skip=flat_skip, close_only=close_only)
except ValueError as e:
return {"asset": asset, "tf": tf, "regime": regime, "error": str(e)}
f, o = std(eqd)
row = {
"asset": asset, "tf": tf, "regime": regime, "trend_max": trend_max,
"rd": rd, "ru": ru, "levels": levels, "sl_buf": sl_buf, "tp_buf": tp_buf, "max_bars": max_bars,
"trades": st["trades"], "win": round(st["win"], 1), "stops": st["stops"],
"full_sh": round(f["sharpe"], 2), "full_dd": round(f["dd"], 2), "full_ret": round(f["ret"], 0),
"oos_sh": round(o["sharpe"], 2), "oos_dd": round(o["dd"], 2),
}
if do_fee2x:
eq2, _ = grid_mtm(asset, **cfg, fee_side=0.001, deploy_mask=mask, flat_skip=flat_skip)
row["fee2x_oos_sh"] = round(std(eq2)[1]["sharpe"], 2)
if do_gate:
row.update(_gate(eqd))
return row
# griglia di struttura coarse per lo scan (range asimmetrico favorito, come il vincitore)
SCAN_RD = [0.08, 0.12, 0.16, 0.20]
SCAN_RU = [0.04, 0.06]
SCAN_LEVELS = [3, 4, 6]
SCAN_SLB = [0.12]
SCAN_TP = 0.05
MAXBARS_TF = {"15m": 2880, "30m": 1440, "1h": 720} # ~30 giorni di episodio
def scan(asset, tf, regime="none", trend_max=2.0, top=10) -> list:
"""Sweep coarse della struttura (rd x ru x levels) col gate PORT06, baseline
cachata (una load per processo). Ritorna le top-`top` celle per qualita' di gate."""
mb = MAXBARS_TF.get(tf, 720)
rows = []
for rd in SCAN_RD:
for ru in SCAN_RU:
for lv in SCAN_LEVELS:
for slb in SCAN_SLB:
r = evaluate(asset, tf, rd, ru, lv, slb, SCAN_TP, mb,
regime=regime, trend_max=trend_max,
do_gate=True, do_fee2x=False)
if "error" not in r:
rows.append(r)
# score: PROMOSSO half/full premiati; poi OOS migliorato col candidato; penalita' FULL DD del portafoglio
def score(r):
promo = (r.get("verdict_half") == "PROMOSSO") + (r.get("verdict_full") == "PROMOSSO")
return (promo, r.get("half_oos_sh", 0) - 0.1 * r.get("full_full_dd", 99))
rows.sort(key=score, reverse=True)
return rows[:top]
def main():
a = sys.argv
if len(a) >= 2 and a[1] == "scan":
asset, tf = a[2], a[3]
regime = a[4] if len(a) > 4 else "none"
trend_max = float(a[5]) if len(a) > 5 else 2.0
print(json.dumps(scan(asset, tf, regime=regime, trend_max=trend_max)))
return
if len(a) < 2 or a[1] != "eval":
print(__doc__); return
asset, tf = a[2], a[3]
rd, ru = float(a[4]), float(a[5])
levels = int(a[6]); sl_buf, tp_buf = float(a[7]), float(a[8]); max_bars = int(a[9])
regime = a[10] if len(a) > 10 else "none"
trend_max = float(a[11]) if len(a) > 11 else 2.0
print(json.dumps(evaluate(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars,
regime=regime, trend_max=trend_max)))
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
"""LADDER SL/TP STUDY (2026-06-18) — i 3 passi pre-deploy + studio di SL e TP da aggiungere.
Contesto: dopo clean_feed.py i Price Ladder BTC/ETH sono candidati VERI (PROMOSSO, DD gate
2021+ ~11-15%), MA il tail REALE e' il 2018 (-44/-52%) che il gate (IDX 2021+) NON vede. SL/TP
sono la leva per domarlo. Prior del progetto: gli stop su mean-reversion sono falsi negativi
(EXIT-16/SH01/pairs z-stop) -> ma un grid in un BEAR sostenuto (2018, niente rimbalzo) e' il
caso in cui un catastrophe-SL genuinamente aiuta. Questo studio distingue i due regimi.
Fa i 3 passi:
1. VALUTAZIONE 2018-INCLUSIVE: metriche standalone su TUTTA la storia (2018+) + DD per anno
(il gate del progetto e' cieco al 2018; qui no).
2. FILL maker vs taker: il grid e' LIMIT -> su Deribit fill MAKER ~0%; confronto 0% vs 0.10%
RT (la harness e' conservativa). E' la preparazione backtest dello shadow ledger (la parte
live = deploy operativo a parte).
3. HALF-SIZE: il candidato finale a meta' size (prudenza coda).
E studia SL (sl_buf, = catastrophe stop sotto il range) x TP (tp_buf) sul tail 2018 vs edge 2021+.
uv run python scripts/analysis/ladder_sltp_study.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.analysis.ladder_search import regime_mask, _gate
from scripts.analysis.grid_game_gate import grid_mtm
OOS_DATE = pd.Timestamp("2024-10-12", tz="UTC")
def fm(eqd: pd.Series) -> dict:
"""Metriche su TUTTA la storia (2018+), niente reindex a IDX 2021+."""
def sh(s):
r = s.pct_change().fillna(0.0)
return float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
def dd(s):
c = s / s.iloc[0]
return float(((c - c.cummax()) / c.cummax()).min() * 100)
oos = eqd[eqd.index >= OOS_DATE]
peryear = {int(y): round(dd(g), 1) for y, g in eqd.groupby(eqd.index.year)}
return {"full_sh": round(sh(eqd), 2), "full_dd": round(dd(eqd), 1),
"oos_sh": round(sh(oos), 2) if len(oos) > 5 else 0.0,
"dd2018": peryear.get(2018, 0.0), "dd2022": peryear.get(2022, 0.0),
"peryear": peryear}
def run(asset, tf, rd, ru, levels, sl_buf, tp_buf, max_bars, regime, tmax, fee_side=0.0005):
mask = regime_mask(asset, tf, trend_max=tmax) if regime == "range" else None
eqd, st = grid_mtm(asset, tf=tf, range_down=rd, range_up=ru, levels=levels,
sl_buf=sl_buf, tp_buf=tp_buf, max_bars=max_bars,
deploy_mask=mask, fee_side=fee_side)
m = fm(eqd); m["trades"] = st["trades"]; m["eqd"] = eqd
return m
# candidati base (i migliori del re-gate pulito)
BASES = {
"BTC 1h L6 range1.5": dict(asset="BTC", tf="1h", rd=0.20, ru=0.06, levels=6,
max_bars=720, regime="range", tmax=1.5),
"BTC 1h L3 none": dict(asset="BTC", tf="1h", rd=0.08, ru=0.06, levels=3,
max_bars=720, regime="none", tmax=2.0),
}
def sltp_sweep(name, base):
print(f"\n{'='*104}\n SL/TP SWEEP — {name} (full=2018+, dd2018=tail vero, oos=2024-10+; fee 0.10% RT)\n{'='*104}")
print(f" {'sl_buf':>7}{'tp_buf':>7}{'trades':>8}{'full_sh':>9}{'full_dd':>9}{'dd2018':>8}{'dd2022':>8}{'oos_sh':>8}")
best = None
for slb in (0.06, 0.08, 0.10, 0.12, 0.15, 0.20):
for tpb in (0.03, 0.05, 0.08):
m = run(**base, sl_buf=slb, tp_buf=tpb)
star = ""
# criterio: tail 2018 contenuto (>-25%) E oos edge preservato (sh>3) E full edge ok
if m["dd2018"] > -25 and m["oos_sh"] > 3 and m["full_sh"] > 1.5:
star = " <-- tail-capped + edge"
if best is None or m["dd2018"] > best[1]["dd2018"]:
best = ((slb, tpb), m)
print(f" {slb:>7.2f}{tpb:>7.2f}{m['trades']:>8}{m['full_sh']:>9.2f}"
f"{m['full_dd']:>9.1f}{m['dd2018']:>8.1f}{m['dd2022']:>8.1f}{m['oos_sh']:>8.2f}{star}")
return best
def main():
print("LADDER SL/TP STUDY — 3 passi pre-deploy + SL/TP da aggiungere\n")
# passo 1: valutazione 2018-inclusive dei due base (sl/tp correnti)
print("[1] VALUTAZIONE 2018-INCLUSIVE (sl/tp correnti) — DD per anno (il gate IDX2021+ e' cieco al 2018):")
for name, base in BASES.items():
m = run(**base, sl_buf=0.12, tp_buf=0.05)
print(f" {name:<22} full_sh {m['full_sh']:>5.2f} full_dd {m['full_dd']:>6.1f} "
f"oos_sh {m['oos_sh']:>5.2f} | DD/anno {m['peryear']}")
# passo SL/TP: sweep su entrambi
winners = {}
for name, base in BASES.items():
winners[name] = sltp_sweep(name, base)
# passo 2+3: maker vs taker + half-size + gate 2021+, sul miglior (sl,tp) del candidato regime-gated
name = "BTC 1h L6 range1.5"
w = winners.get(name)
print(f"\n{'='*104}\n [2+3] FILL maker/taker + HALF-SIZE + GATE 2021+ — {name}\n{'='*104}")
if w is None:
print(" nessun (sl,tp) ha cappato il tail 2018 sotto -25% mantenendo l'edge: vedi sweep sopra.")
# usa comunque lo sl piu' stretto per il confronto fill
(slb, tpb) = (0.06, 0.05)
else:
(slb, tpb), _ = w
print(f" miglior (sl_buf,tp_buf) per tail 2018 + edge = ({slb}, {tpb})")
base = BASES[name]
for fee, lab in ((0.0005, "taker 0.10% RT"), (0.0, "maker 0% (Deribit limit)")):
m = run(**base, sl_buf=slb, tp_buf=tpb, fee_side=fee)
g = _gate(m["eqd"])
print(f" fee={lab:<26} oos_sh {m['oos_sh']:>5.2f} dd2018 {m['dd2018']:>6.1f} "
f"gate½ {g['verdict_half']} (OOS {g['base_oos_sh']}->{g['half_oos_sh']}, corr {g['max_corr_existing']})")
print("\n NB half-size: il gate 'half' E' gia' a meta' size (vedi grid_game_gate). La coda 2018")
print(" standalone va dimezzata sul book a half. Lo shadow ledger reale (fill intrabar/maker)")
print(" resta il passo OPERATIVO finale, non backtestabile qui.")
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
"""VALIDA GridWorker — forward-tracking == backtest grid_mtm (gate obbligatorio del progetto).
Il GridWorker (sim/paper) bootstrappa la storia FULL (start fisso) e mappa il capitale forward
dal deploy: capital = initial * eq[-1]/base_norm. Proprieta' da validare:
[A] LOGICA: la griglia sul feed full == backtest (stesso n_trades/win).
[B] FORWARD: deployato a T0 (bootstrap up to T0), dopo aver ticcato fino a T1 il capitale
e' initial * eq_full(T1)/eq_full(T0) — cioe' il ritorno della griglia DA T0 (causale).
[C] RESUME: reistanziato (rilegge base_norm da status.json) -> stesso capitale.
Config = la finale del re-gate pulito: BTC 1h range1.5 rd0.20 ru0.06 L6 sl0.10 tp0.03.
uv run python scripts/analysis/validate_grid_worker.py
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.grid_game_gate import grid_mtm
from scripts.analysis.ladder_search import regime_mask
from src.live.grid_worker import GridWorker
CFG = dict(tf="1h", range_down=0.20, range_up=0.06, levels=6,
sl_buf=0.10, tp_buf=0.03, max_bars=720, regime="range", trend_max=1.5)
ASSET, INIT, POS, LEV, FEE = "BTC", 1000.0, 0.15, 3.0, 0.0005
def _eq_last(df, mask):
cfg_bt = {k: v for k, v in CFG.items() if k not in ("regime", "trend_max")}
eqd, st = grid_mtm(ASSET, **cfg_bt, pos=POS, lev=LEV, fee_side=FEE, deploy_mask=mask, df=df)
return float(eqd.iloc[-1]), st
def main():
df = load_data(ASSET, "1h")
n = len(df)
nboot = n - 400 # deploy a T0 = nboot, poi 400 barre forward
mask_full = regime_mask(ASSET, "1h", trend_max=CFG["trend_max"])
F, st_full = _eq_last(df, mask_full) # eq full @ T1 (== backtest)
B, _ = _eq_last(df.iloc[:nboot].reset_index(drop=True), mask_full[:nboot]) # eq @ T0 (causale)
expected = INIT * F / B
print(f"[backtest] eq@T0={B:.4f} eq@T1={F:.4f} -> capitale forward atteso {expected:,.2f} "
f"(trades full {st_full['trades']}, win {st_full['win']:.1f}%)")
wd = Path(tempfile.mkdtemp(prefix="gridval_"))
try:
boot = df.iloc[:nboot].reset_index(drop=True)
w = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd, leverage=LEV,
position_size=POS, fee_side=FEE, hist=boot)
# [A] logica: tick col feed full -> n_trades come backtest
w.tick(df)
dA = abs(w.n_trades - st_full["trades"])
print(f"[A] logica n_trades worker {w.n_trades} vs backtest {st_full['trades']} "
f"{'OK' if dA == 0 else 'MISMATCH'}")
# [B] forward: deploy a T0 (base), poi fino a T1
wd2 = Path(tempfile.mkdtemp(prefix="gridval_fwd_"))
wf = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd2, leverage=LEV,
position_size=POS, fee_side=FEE, hist=boot)
wf.tick(boot) # deploy: base_norm=B, capital=initial
cap0 = wf.capital
wf.tick(df.iloc[:n - 200].reset_index(drop=True))
wf.tick(df) # fino a T1
dB = abs(wf.capital - expected)
print(f"[B] forward: cap@deploy {cap0:,.2f} (atteso {INIT:,.0f}) cap@T1 {wf.capital:,.2f} "
f"(atteso {expected:,.2f}) delta {dB:.4f} {'OK' if dB < 0.05 else 'MISMATCH'}")
# [C] resume
wr = GridWorker("GRID_BTC", ASSET, CFG, INIT, wd2, leverage=LEV,
position_size=POS, fee_side=FEE, hist=boot)
wr.tick(df)
dC = abs(wr.capital - wf.capital)
print(f"[C] resume cap {wr.capital:,.2f} delta {dC:.4f} "
f"{'OK' if dC < 0.05 else 'MISMATCH'} (base_norm persistito)")
ok = dA == 0 and dB < 0.05 and dC < 0.05
print(f"\n{'VALIDAZIONE OK: GridWorker forward-tracking == backtest' if ok else 'VALIDAZIONE FALLITA'}")
shutil.rmtree(wd2, ignore_errors=True)
finally:
shutil.rmtree(wd, ignore_errors=True)
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""GridWorker — Price Ladder (griglia) live SIM/PAPER, shadow-stage 1.
Worker live per la strategia Price Ladder (griglia geometrica con regime-gate + SL/TP,
config vincente del branch price_ladder_research). STAGE 1 = SIM/PAPER: gira sul feed LIVE
Deribit (stessi dati di decisione degli altri worker) e contabilizza l'equity mark-to-market
col MOTORE CANONICO `grid_mtm` (parita' col backtest per costruzione), MA non piazza ordini
reali. Accumula un track record paper per validare live-vs-backtest prima dello shadow reale.
NON esegue ordini: l'esecuzione reale (griglia di LIMIT resting su Deribit, gestione fill
parziali/episodi) e' lo STAGE 2, dietro testnet + autorizzazione esplicita (soldi veri,
siamo su mainnet). Per costruzione il runner avvia ordini reali solo per kind in
('single','ml'); kind='grid' resta sim.
Stato persistente (status.json): capital, peak, max_dd, n_trades, last_ts -> resume al restart.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
from scripts.analysis.grid_game_gate import grid_mtm
def _regime_mask(df: pd.DataFrame, ema_n: int, trend_max: float) -> np.ndarray:
"""Mask CAUSALE 'range-bound' allineata a df (== ladder_search.regime_mask, ma su df live)."""
c = df["close"].to_numpy(float)
h = df["high"].to_numpy(float); l = df["low"].to_numpy(float)
ema = pd.Series(c).ewm(span=ema_n, adjust=False).mean().to_numpy()
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
atr = pd.Series(tr).rolling(14).mean().to_numpy()
with np.errstate(invalid="ignore", divide="ignore"):
dist = np.abs(c - ema) / np.where(atr == 0, np.nan, atr)
m = dist < trend_max
m[~np.isfinite(dist)] = False
return m
class GridWorker:
KIND = "grid"
def __init__(self, sid: str, asset: str, params: dict, capital: float,
work_dir: Path, leverage: float = 3.0, position_size: float = 0.15,
fee_side: float = 0.0005, notifier=None, hist: pd.DataFrame | None = None):
self.sid = sid
self.asset = asset
self.p = dict(params) # tf,range_down,range_up,levels,sl_buf,tp_buf,max_bars,regime,trend_max
self.leverage = leverage
self.position_size = position_size
self.fee_side = fee_side
self.notifier = notifier
self.initial_capital = capital
self.capital = capital
self.peak = capital
self.max_dd = 0.0
self.n_trades = 0
self.last_ts = ""
# base_norm = valore dell'equity-norm (cumulata da inizio storia) al DEPLOY: la
# capital forward = initial * eq[-1]/base_norm -> parte da `initial` e segue il
# ritorno della griglia DA QUEL MOMENTO (start FISSO: niente salti da finestra mobile).
self.base_norm = None
# bootstrap STORIA FULL (start fisso, come SH01): il feed live e' una finestra mobile,
# ma normalizzando su una serie a start fisso l'equity forward e' stabile.
if hist is None:
try:
from src.data.downloader import load_data
hist = load_data(asset, self.p.get("tf", "1h"))
except Exception:
hist = None
self.hist = hist
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self.status_path = self.work_dir / "status.json"
self.trades_path = self.work_dir / "trades.jsonl"
self.in_position = False # compat dashboard (la griglia non ha una posizione singola)
self._load_state()
def _merge(self, live_df: pd.DataFrame) -> pd.DataFrame:
"""Storia bootstrap + feed live, dedup su timestamp (il live prevale), start FISSO."""
if self.hist is None or len(self.hist) == 0:
return live_df
cols = ["timestamp", "open", "high", "low", "close", "volume"]
h = self.hist[[c for c in cols if c in self.hist.columns]]
l = live_df[[c for c in cols if c in live_df.columns]]
m = pd.concat([h, l], ignore_index=True)
m = m.drop_duplicates(subset="timestamp", keep="last").sort_values("timestamp")
return m.reset_index(drop=True)
def _load_state(self):
if not self.status_path.exists():
self._log("INIT", {"capital": round(self.capital, 2), "sid": self.sid})
return
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.initial_capital)
self.peak = s.get("peak", self.capital)
self.max_dd = s.get("max_dd", 0.0)
self.n_trades = s.get("n_trades", 0)
self.last_ts = s.get("last_ts", "")
self.base_norm = s.get("base_norm")
self._log("RESUME", {"capital": round(self.capital, 2), "n_trades": self.n_trades,
"base_norm": self.base_norm})
def _save_state(self):
self.status_path.write_text(json.dumps({
"sid": self.sid, "kind": self.KIND, "asset": self.asset,
"capital": round(self.capital, 4), "peak": round(self.peak, 4),
"max_dd": round(self.max_dd, 4), "n_trades": self.n_trades,
"base_norm": self.base_norm, "in_position": self.in_position, "params": self.p,
"last_ts": self.last_ts, "ts": datetime.now(timezone.utc).isoformat(),
}, indent=2))
def _log(self, event: str, extra: dict):
row = {"ts": datetime.now(timezone.utc).isoformat(), "sid": getattr(self, "sid", "?"),
"event": event, **extra}
try:
with open(self.work_dir / "trades.jsonl", "a") as f:
f.write(json.dumps(row) + "\n")
except Exception:
pass
def tick(self, df: pd.DataFrame):
"""df = OHLCV live (finestra mobile) fino ad ora. Merge con la storia bootstrap
(start FISSO), ricomputa la griglia col motore canonico, e mappa il capitale forward:
capital = initial * eq[-1]/base_norm (parte da `initial` al deploy, segue la griglia
da li' in poi). SIM only (nessun ordine reale)."""
if df is None or len(df) < 40:
return
full = self._merge(df)
p = self.p
regime = p.get("regime", "none")
mask = (_regime_mask(full, p.get("ema_n", 200), p.get("trend_max", 2.0))
if regime == "range" else None)
eqd, st = grid_mtm(
self.asset, tf=p["tf"], range_down=p["range_down"], range_up=p["range_up"],
levels=p["levels"], sl_buf=p["sl_buf"], tp_buf=p["tp_buf"], max_bars=p["max_bars"],
pos=self.position_size, lev=self.leverage, fee_side=self.fee_side,
flat_skip=True, deploy_mask=mask, df=full)
if eqd is None or len(eqd) == 0:
return
cur = float(eqd.iloc[-1])
if self.base_norm is None or self.base_norm <= 0:
self.base_norm = cur # baseline al primo tick (deploy)
self.capital = max(self.initial_capital * cur / self.base_norm, 0.0)
self.peak = max(self.peak, self.capital)
if self.peak > 0:
self.max_dd = max(self.max_dd, (self.peak - self.capital) / self.peak)
self.n_trades = int(st.get("trades", self.n_trades))
self.last_ts = str(full.iloc[-1].get("timestamp", ""))
self._save_state()
self._log("GRID_MTM", {"capital": round(self.capital, 2), "n_trades": self.n_trades,
"win": st.get("win"), "stops": st.get("stops"),
"pnl_source": "sim"})
return self.capital
+29 -2
View File
@@ -23,6 +23,7 @@ from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker
from src.live.xsec_worker import CrossSectionalWorker
from src.live.grid_worker import GridWorker
from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
@@ -102,6 +103,13 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
capital=alloc_capital, position_size=position_size, leverage=leverage,
data_dir=data_dir,
)
if spec.kind == "grid":
# Price Ladder (griglia) — SIM/PAPER (shadow stage 1): nessun ordine reale.
return GridWorker(
sid=spec.sid, asset=spec.asset, params=spec.params, capital=alloc_capital,
work_dir=Path(data_dir) / spec.sid, leverage=leverage,
position_size=position_size, fee_side=0.0005,
)
module = _STRAT_MODULE.get(spec.name)
if module is None:
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
@@ -191,6 +199,8 @@ def _spec_assets_tf(spec: SleeveSpec):
return [spec.a, spec.b], spec.tf
if spec.kind in _MULTI_KINDS:
return list(spec.params["universe"]), spec.params.get("tf", "1d" if spec.kind != "basket" else "4h")
if spec.kind == "grid":
return [spec.asset], spec.params.get("tf", spec.tf)
return [spec.asset], spec.tf
@@ -375,6 +385,20 @@ def run(config_path: str = "portfolios.yml"):
paper_codes = {str(c).upper() for c in (_ov.get("paper_sleeves") or [])}
live_specs = [s for s in supported if s.name.upper() not in paper_codes]
paper_specs = [s for s in supported if s.name.upper() in paper_codes]
# PAPER_EXTRA: sleeve paper definiti SOLO in config (NON in p.sleeves) -> NON entrano
# nel backtest canonico/regression-lock (all_sleeve_equities non sa costruirli). Nato
# per il Price Ladder (kind=grid, shadow stage 1 sim). Parsing DIFENSIVO: un errore qui
# non deve crashare il runner mainnet.
for _ex in (_ov.get("paper_extra") or []):
try:
paper_specs.append(SleeveSpec(
kind=str(_ex["kind"]), name=str(_ex.get("name", _ex["sid"])),
sid=str(_ex["sid"]), asset=_ex.get("asset"),
tf=str(_ex.get("tf", "1h")), params=dict(_ex.get("params", {})),
cluster=str(_ex.get("cluster", "")),
))
except Exception as e:
print(f"[runner] WARN paper_extra ignorato ({_ex}): {e}")
if paper_specs:
print(f"[runner] sleeve PAPER (solo statistica, fuori dal pool): "
f"{[s.sid for s in paper_specs]}")
@@ -467,7 +491,7 @@ def run(config_path: str = "portfolios.yml"):
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
asset_days: dict[str, int] = {}
for s in supported: # live + PAPER (anche XS01/TR01/ROT02/TSM01)
for s in live_specs + paper_specs: # live + PAPER (incl. paper_extra grid)
assets, tf = _spec_assets_tf(s)
days = _LOOKBACK_DAYS.get(tf, 90)
if s.kind == "ml": # SH01 ha bisogno di molta storia 1h
@@ -478,7 +502,7 @@ def run(config_path: str = "portfolios.yml"):
# timeframe SUB-orari (es. pairs 15m, flat-skip): non resamplabili dal 1h ->
# fetch DIRETTO da Cerbero per (asset, tf). Inerte se nessuno sleeve e' sub-orario.
subhourly_needs: dict[tuple[str, str], int] = {}
for s in supported: # live + paper
for s in live_specs + paper_specs: # live + paper (incl. paper_extra grid)
assets, tf = _spec_assets_tf(s)
if tf in _SUBHOURLY:
for a in assets:
@@ -603,6 +627,9 @@ def run(config_path: str = "portfolios.yml"):
# interno fitta solo l'ultimo blocco (last_block_only nei params).
w.tick(_with_history(ml_hist.get(s.asset), res[s.asset],
ml_gap_warned, s.asset))
elif s.kind == "grid":
# Price Ladder SIM/PAPER: ricomputa la griglia sul feed live (BTC 1h).
w.tick(res[s.asset])
else:
# single (fade/dip): StrategyWorker su feed live.
w.tick(res[s.asset])