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:
@@ -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.0–11.35) **con corr bassa**:
|
||||
|
||||
- **BTC** (15m/30m/1h, range o none): corr **0.15–0.27**, molto più ortogonale della
|
||||
griglia ETH bocciata (0.40). OOS di portafoglio 11.1–11.35 (il guadagno OOS più grosso).
|
||||
- **ETH** (15m/30m/1h): corr **0.21–0.57** (15m la peggiore, fino a 0.57; a 1h scende a
|
||||
0.22–0.39). OOS di portafoglio ~10.9–11.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 (4–7 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 **~54–67%** (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.1–6.6%** (+0.6/+4.0pp,
|
||||
**oltre la soglia tail** in quasi tutte le celle). **A HALF size invece rientra a 2.33–2.54%**
|
||||
(~baseline) preservando l'OOS ~11.0–11.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: **12–33%** (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.37–2.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.22–0.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.43–0.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.16–0.22 = la diversificazione migliore), MA la FULL DD standalone ~54% li rende
|
||||
**tail-risky a full size** (FULL DD portafoglio 3.1–6.6%, oltre soglia). **Deployabili SOLO a
|
||||
half-size** (FULL DD portafoglio 2.33–2.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** (2024–25). 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 ~47–57k. È 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.15–0.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** (|close−EMA200|/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 5–7) 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).
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user