Compare commits
54 Commits
79afe41ec2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bebfa63213 | |||
| ae887a3d98 | |||
| d34b0a81d8 | |||
| cfb0e1e57f | |||
| 39adfbf267 | |||
| 81b495eb10 | |||
| 28a6d79e4f | |||
| ac6ad7f116 | |||
| ddcd3773a7 | |||
| 213dd12bab | |||
| 3af6e1f0c3 | |||
| 829ebc7f98 | |||
| 52a905d497 | |||
| 12291709ed | |||
| 8e75e9c9a7 | |||
| 13c136cf4b | |||
| e82f2ee163 | |||
| d80323ff59 | |||
| d59aea7a0a | |||
| 31ec767ff5 | |||
| 95cfb396e7 | |||
| 604fde689e | |||
| 841d4c78cd | |||
| 0e6cf17081 | |||
| dab8a88e1c | |||
| ed4f8fd39b | |||
| 8405fe2a7d | |||
| 7234625863 | |||
| 9ce924d97c | |||
| 7c72308852 | |||
| 65a7f0d8fc | |||
| f79fb54221 | |||
| 4725839041 | |||
| 5acc31e390 | |||
| dda5d41f38 | |||
| f0193f013d | |||
| 0c19c804d1 | |||
| 2bf4f9cceb | |||
| b8c2bba320 | |||
| ac21e1c97d | |||
| cfc42653cc | |||
| 1a7f670966 | |||
| e4efdaaa53 | |||
| b46041d11d | |||
| 4b04831352 | |||
| 8007158eb5 | |||
| 31f5915a41 | |||
| e42ab5369f | |||
| a6a49d9c65 | |||
| a4e942c8bb | |||
| 5b72a4b188 | |||
| a2b03aa2b6 | |||
| 37d1565a1e | |||
| f82f685528 |
@@ -79,6 +79,12 @@ data/fee_watch/
|
||||
# battuta di cuore del collettore catena (stato runtime, come gli altri monitor)
|
||||
data/chain_collect/
|
||||
data/vrp_f_watch/
|
||||
# giri misurati del trigger di regime CC01 (letti da monitor_health, #12)
|
||||
data/cc01_regime/
|
||||
# giri misurati dei sorveglianti generalizzati da cc01 (#16), letti da monitor_health
|
||||
data/edge_watch/
|
||||
data/scale_watch/
|
||||
data/venue_news/
|
||||
|
||||
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
||||
data/live/
|
||||
|
||||
@@ -38,7 +38,7 @@ Documento di fondazione: `docs/diary/2026-06-19-deribit-history.md`.
|
||||
gia' dichiarati artefatti. Riscritto contro lo stato vero. **La lezione non e' "aggiornare il
|
||||
README": e' che un reset invalida anche i documenti che nessuno rilegge** — l'inventario di cosa
|
||||
cita numeri morti va fatto il giorno del reset, non quando qualcuno ci inciampa.
|
||||
- **L'esecuzione e' ARMATA e LIVE** su Deribit mainnet dal 2026-06-20. Capitale reale **~$4.495** dal 04/09 (era ~$635 fino al 25/08, ~$2.060 fino al 04/09)
|
||||
- **L'esecuzione e' ARMATA e LIVE** su Deribit mainnet: **TP01 da solo dal 2026-06-20** (commit `4650aa7`), **il BOOK TP01+SKH01 dal 2026-06-23** (commit `db738bc`, prima lettura di equity 23/06 22:00Z — e' la data d'arming che il TWR e `config/live.json` usano). Capitale reale **~$4.495** dal 04/09 (era ~$635 fino al 25/08, ~$2.060 fino al 04/09)
|
||||
(NON i €2.000 nominali del paper trader).
|
||||
- Si riparte dalla ricerca di strategie NUOVE, su dati certi, con la metodologia della §8.
|
||||
|
||||
@@ -59,6 +59,32 @@ dal 2026-08-25; era `:07`). Due vincoli, entrambi misurati: fuori dai ~26s del m
|
||||
annunciati), che il `:07` beccava — 4 volte in 63 giorni. **GTAA01, XS01, VRP01, XSR01 NON sono
|
||||
nel book live.**
|
||||
|
||||
**OMBRA (non e' questo repo, ma gira sullo stesso conto).** Dal 2026-09-13 la crontab ha anche
|
||||
`44 * * * * /opt/AI-OS/automations/TradingBook/scripts/cron_ombra.sh`: il **book traslocato**
|
||||
(`Adriano/TradingBook`, stesso codice di questo repo al 13/09) calcola ogni ora il target e lo
|
||||
scrive in un registro, **senza inviare**. Finestra dichiarata fino al **30/09/2026**; confronto col
|
||||
`:47` di qui con `TradingBook/scripts/confronta_giornata.py`. Verificato il 16/09 (issue #11):
|
||||
- **cosa legge**: il conto Deribit tramite il gateway interno di TradingBook, con
|
||||
`TradingBook/secrets/deribit.json`, il cui `client_id` **coincide** (confronto per hash) con
|
||||
`DERIBIT_CLIENT_ID_LIVE` di cerbero-mcp, cioe' **la chiave con cui questo libro manda gli ordini**;
|
||||
il feed tramite `TradingBook/data/raw`, un **collegamento** a `data/raw` di qui (sola lettura).
|
||||
- **dove scrive**: solo nel suo albero, `logs/cron_ombra.log`, `data/live/ombra.jsonl` e
|
||||
`data/live/equity_seen.json`. Sui giri delle 18:17, 19:17 e 20:44 del 16/09 **nessun file** di
|
||||
`data/` o `logs/` di questo progetto e' cambiato: il watermark di qui non lo tocca (P16).
|
||||
- **perche' non invia**: `book_ombra.py` non contiene il percorso d'invio, e
|
||||
`TradingBook/tests/test_ombra_non_invia.py` lo verifica sul sorgente. ✅ **Dal 21/09 anche il
|
||||
PERMESSO** (TradingBook#19): il gateway chiede a Deribit un token con scope
|
||||
`account:read trade:read wallet:none block_trade:none block_rfq:none custody:none` e rifiuta il token
|
||||
se lo scope concesso contiene un `:read_write`. Misurato il 21/09: senza scope la chiave dava
|
||||
**tutto in read_write, anche `wallet` e `custody`**; con lo scope ridotto Deribit concede
|
||||
`trade:read account:read` e risponde `13021 forbidden` a un `private/cancel`. 🚨 **Il segreto resta
|
||||
quello pieno**: chi legge `TradingBook/secrets/deribit.json` puo' chiedere un token senza scope. Una
|
||||
chiave Deribit di **sola lettura** per l'ombra la crea solo l'operatore.
|
||||
- **orario**: `:44` con `timeout -k 10 150`, quindi finisce entro le `:46:40`, prima del giro di qui.
|
||||
Era `:17` fino al 16/09 e cadeva nello slot di release del martedi' (15/09 09:17: manutenzione
|
||||
11051, due BUY calcolati sul paper capital nel registro d'ombra); da TradingBook#16 un conto o una
|
||||
posizione illeggibile danno un giro **saltato**, come qui danno lo stop.
|
||||
|
||||
**RICERCA (paper).** Portafoglio a 5 sleeve `src/portfolio/sleeves.active_sleeves`:
|
||||
TP01 33 / XS01 15 / VRP01 12 / SKH01 20 / GTAA01 20. **Non e' il book live** — quasi tutti i numeri
|
||||
di portafoglio del progetto sono su questa serie, non su quella che gira.
|
||||
@@ -76,7 +102,7 @@ lorda massima 1,0x**, e nessun livello di capitale cambia il profilo di rischio
|
||||
📌 **Modello di margine: `Cross: Standard Margin` (X:SM) dal 2026-08-31**, cambiato dall'operatore. Prima era **Segregated** (S:SM), e sotto quello l'USDE **non faceva margine**: e' il motivo per cui la misura dell'haircut trovava $0,0000 accantonati — si cercava un parametro non in vigore. Il passaggio e' verificabile dal gateway: `available_funds` USDC e' passato da ~$1.400 a **$2.011,09**, contro $2.010,90 attesi con haircut 5% (scarto **$0,19** — seconda conferma indipendente del 5%). ⚠️ **Cosa ha comprato, misurato**: **$611** di margine utilizzabile — inutile a 0,28x di leva, serve solo sopra ~1,4x che non e' autorizzata — e **+11 USDE (~$11)** di capienza sul tetto. **Cosa e' costato**: sotto segregato l'USDE era **ring-fenced** dalle perdite del libro (solo il silo USDC rispondeva delle posizioni); sotto cross **l'intero conto risponde**. Non morde oggi (perdita massima plausibile ~$617 col disaster-SL, dentro il solo USDC), ma il rischio strutturale e' cambiato verso: **se un giorno il cross non servisse piu', tornare a S:SM ri-recinta l'USDE**. 📌 **X:PM valutato e SCARTATO il 31/08 senza provarlo** — la risposta era gia' nella pagina margini: **Available Balance $1.935,14 contro $2.011,73** di X:SM (−$76,59) e margini piu' alti su entrambe le misure, **MM 3,43% contro 0,37% (~9×)**. Il PM e' basato su scenari e premia il rischio che si **compensa**: due long direzionali nudi sono il suo caso peggiore, e paga lo scenario di stress invece dell'aliquota piatta. **Cosa lo riapre**: il deploy di VRP01 o di un altro sleeve di opzioni — allora il rischio inizia a compensarsi e il confronto cambia di segno. ✅ **Confermato il 31/08 a X:SM attivo** e su misure pulite: la KB da' l'USDe al 5% sotto entrambi i modelli, quindi l'haircut si cancella e l'inversa della riga CROSS da' il margine di POSIZIONE — **$88,23 sotto PM contro $11,64 sotto SM, 7,6×**, e 9,3× la MM. La riga X:SM era una **proiezione di un modello inattivo** e oggi si riproduce al centesimo ⇒ anche la riga X:PM era affidabile: decidere senza provare era corretto. 🚨 **L'«IM %» della pagina margini NON e' il margine delle posizioni**: e' `(margin_balance − available)/margin_balance`, e da noi e' **74% haircut** (e' salita da 2,13% a 2,15% *perche' abbiamo comprato USDE*, non perche' sia salito il rischio). La **MM** e' pulita e ha base diversa. 📌 **La pagina margini non serve piu' come schermata**: la riga CROSS **e'** `available_funds` del gateway (scarto $0,01) e `r0831_margini_conto.live()` la ricostruisce, haircut compreso. 📌 **L'equity che il book somma e' la RICCHEZZA, non la capacita' di margine** — giusta per accorgersi di un'uscita di fondi (il difetto del 26/08), sbagliata se la si usasse per dire quanto margine c'e'.
|
||||
|
||||
📌 **Dal 2026-08-26 l'equity del book somma USDC + USDE** (valutato
|
||||
all'indice pubblico `usde_usdc`): il conto tiene **3.088,7 USDE, quota 68,7%** (**IDONEO** dal 28/08;
|
||||
all'indice pubblico `usde_usdc`): il conto tiene **2.851,9 USDE, quota 64,0%** dal 10/09 14:16Z (allineato al bersaglio di `cuscino_watch` su ordine dell'operatore: 238 USDE venduti in 3 ordini a 1,0000, slack +$266 = OK; era 3.088,7 / 68,7% dal 06/09) (**IDONEO** dal 28/08;
|
||||
500 → 644 il 30/08 → **3.088,7 il 06/09** su autorizzazione dell'operatore *«usa piu' USDE»* e *«cerca la %
|
||||
massima raggiungibile»*, dopo che il versamento di **$2.410,14 del 04/09** aveva dimezzato la quota a 14,6%).
|
||||
🚨 **Il TETTO del venue del 30-31/08 (~31,2-31,9%) il 06/09 NON C'ERA**: sonda a saldo crescente
|
||||
@@ -87,11 +113,11 @@ e' raggiungibile ne' ora ne' mai» (31/08) e' falsificato**; il tetto e' **spari
|
||||
20:58 dello stesso giorno avevo scritto qui «terza conferma della frazione» sulla base di *nessun rifiuto
|
||||
fino al 31,8%*, che dimostra ≥, non =. Non spiegato (D5): puo' tornare. `venue_cap_frac` → **null**,
|
||||
`quota_max_frac` → **0,85** (il valore che l'operatore aveva scelto il 30/08 per questo scenario).
|
||||
⚠️ **Nessuno sorveglia il cuscino USDC**: `usde_watch` allerta solo sulla quota; una perdita del libro
|
||||
(che si regola in USDC) fa salire la quota da sola — a −$58 di USDC il cuscino e' gia' scoperto, a −30%
|
||||
la quota passa il 98%. GATE USDE-01 in §4). ⚠️ **Ogni versamento abbassa la quota**: dopo un bonifico si
|
||||
rilancia `usde_convert --quota 0.70` (che si ferma al cuscino), non si aspetta che qualcuno se ne accorga. Autorita' dei parametri: sezione `usde` di `config/live.json`
|
||||
(haircut **5%** — ✅ divergenza chiusa il 31/08, la config diceva 10% ed era sbagliato (`r0831_margini_conto.py`); oggi comunque **non si applica**, vedi sopra — tetto ALLERTA quota 50%, soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta
|
||||
✅ **Il cuscino USDC e' sorvegliato dal 10/09** (`cuscino_watch`, §5.17): una perdita del libro (che si regola in USDC)
|
||||
fa salire la quota da sola, e sotto zero di slack il sorvegliante vende USDE; prima nessuno lo guardava — a −$58 di USDC
|
||||
il cuscino era gia' scoperto. GATE USDE-01 in §4). ⚠️ **Ogni versamento abbassa la quota**: dopo un bonifico va riportata al bersaglio, e
|
||||
**dal 10/09 lo fa `cuscino_watch` da solo** (stato ECCEDENTE: slack > 40% del cuscino ⇒ ricompra USDE fino a quota **0,64**, issue #7); prima si rilanciava `usde_convert --quota 0.70` a mano, e il 04/09 nessuno l'aveva fatto. Autorita' dei parametri: sezione `usde` di `config/live.json`
|
||||
(haircut **5%** — ✅ divergenza chiusa il 31/08, la config diceva 10% ed era sbagliato (`r0831_margini_conto.py`); oggi comunque **non si applica**, vedi sopra — tetto ALLERTA `quota_max_frac` **0,85** (era 50% fino al 06/09; 0,50 resta solo come default di codice in `src/live/usde.py`, la config lo sovrascrive), soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta
|
||||
da `src/live/usde.py` — shadow (sizing orario) e `usde_watch` (sorveglianza 12:35 UTC, cron
|
||||
proprio, sotto `monitor_health`) derivano entrambi da li'. ⚠️ Il difetto d'origine: `shadow._equity`
|
||||
leggeva SOLO il conto USDC — la prima conversione avrebbe causato un falso "USCITA DI FONDI −24%"
|
||||
@@ -108,8 +134,11 @@ le quattro gia' chiuse il 31/08. L'USDC sul conto **non e' fermo** (base di sizi
|
||||
vero sta fuori (~€6k in XEON = lo split-cassa): le opzioni sicure valgono **+€52-62/anno** (BOT 12,5% /
|
||||
conto deposito 26%), sUSDe rende 2x ma e' **lo stesso emittente dell'USDE** e distrugge lo split.
|
||||
✅ **L'operatore ha scelto di VERSARE** (piano 27/07: **€5.000 dentro, ~€1.000 fuori** — la protezione
|
||||
venue satura a qualunque quota > 0). Nessuna azione di config. **In attesa di importo e data**; al
|
||||
deposito va **una riga di giornale**, non ricostruita dopo. `r0901d_soldi_fermi.py`.
|
||||
venue satura a qualunque quota > 0). ✅ **VERSATO il 04/09: $2.414,68 di USDC** (dal balance, fra 13:42Z e
|
||||
14:42Z), **dichiarato il 10/09** in `movimenti_dichiarati.jsonl` — e **l'operatore ha detto che e' l'INTERO
|
||||
versamento previsto: il piano €5.000 e' chiuso qui**, non resta nulla «in attesa». Per sei giorni era stato solo
|
||||
*rilevato* (+$2.410,14 dal salto di equity, col mercato dell'ora dentro): la regola di §2 dice il giorno stesso.
|
||||
`r0901d_soldi_fermi.py`.
|
||||
|
||||
**Candidati in forward-monitor** (nessuno nel book): XSR01 · DVOLSPREAD · STATARB · PREVDAY.
|
||||
|
||||
@@ -143,7 +172,7 @@ sono all'ancora fortunata; le stime oneste sono la **mediana della banda d'ancor
|
||||
| P(≥50 €/g), canale funded, 36 mesi | 42% | **2,6%** [1,5 – 4,7], P(zero) 40,3% |
|
||||
| XSR01 | Sharpe 1,82 (e' una **terza** lente, divisore fisso 50) | **1,79** alla scoperta / **1,56-1,63** a oggi — lente dei gate, sole barre chiuse. Citare sempre la coppia (lente, ultima barra chiusa) |
|
||||
| soffitto direzionale BTC/ETH | ~1,3 | **~1,15** col funding dentro |
|
||||
| performance del libro LIVE | «+243%» di equity (e' **96,3% un bonifico**) · e **+11,98%** (lettura 06/09 20:47Z, PRIMA della dichiarazione del versamento di prova) | **TWR +7,83%** spezzato sui TRE movimenti — +8,14% fino al 25/08 08:00, −0,62% / −0,12% / +0,46% dopo (lettura 06/09 21:14Z); trading da arming **+$62,45**. Dal 02/09 lo stampa `trades_db.py --report` (debito 14 chiuso). 🚨 **Il 06/09 l'operatore ha dichiarato un versamento di PROVA di €25 il 25/08 alle 08:00Z**, ~$24,9: +3,3% su $647, **sotto la soglia del 10%** del rilevatore, quindi per 12 giorni e' stato contato come trading (un terzo del «trading da arming» e 4 punti di TWR). Ora sta in `data/live/movimenti_dichiarati.jsonl`, letto da `journal.movimenti_capitale` (fonte `dichiarato`, importo dell'operatore, il mercato dell'ora resta nel rendimento). **Regola: ogni versamento, anche di prova, va dichiarato li' il giorno stesso** — il rilevatore vede solo i salti ≥10% |
|
||||
| performance del libro LIVE | «+243%» di equity (e' **96,3% un bonifico**) · e **+11,98%** (lettura 06/09 20:47Z, PRIMA della dichiarazione del versamento di prova) | **TWR +6,66%** spezzato sui TRE movimenti in QUATTRO tratti — +8,14% fino al 25/08 08:00, −0,62% / −0,12% / **−0,63%** dopo (lettura 10/09 13:04Z, dopo la dichiarazione del versamento del 04/09 a **$2.414,68** dal balance); trading da arming **+$13,82**. Era +7,83% / +$62,45 il 06/09: la coda e' la marcatura delle due long dal picco del 06/09, non trade chiusi. Dal 02/09 lo stampa `trades_db.py --report` (debito 14 chiuso). 🚨 **Il 06/09 l'operatore ha dichiarato un versamento di PROVA di €25 il 25/08 alle 08:00Z**, ~$24,9: +3,3% su $647, **sotto la soglia del 10%** del rilevatore, quindi per 12 giorni e' stato contato come trading (un terzo del «trading da arming» e 4 punti di TWR). Ora sta in `data/live/movimenti_dichiarati.jsonl`, letto da `journal.movimenti_capitale` (fonte `dichiarato`, importo dell'operatore, il mercato dell'ora resta nel rendimento). **Regola: ogni versamento, anche di prova, va dichiarato li' il giorno stesso** — il rilevatore vede solo i salti ≥10% |
|
||||
|
||||
📌 **Il libro a k=1 rende MENO dell'S&P 500** (15,19% contro 17,40%, stessa finestra): il vantaggio
|
||||
sta nello **Sharpe** (1,35 vs 0,89), e **senza leva non si converte in rendimento**. A iso-rischio
|
||||
@@ -214,6 +243,7 @@ CLAUDE.md", la risposta e' la colonna *cosa la riapre*.
|
||||
| **Fee Deribit: ≤5 bps/lato → non si tocca nulla; >10 bps → rivedere il peso SKH01** (regola decisa *prima* di vedere il numero) | 26/07 | — | il sorvegliante `fee_watch` allerta da solo |
|
||||
| **GTAA01: decisione tenere/bloccare RINVIATA a $15k di book** — sotto quella soglia lo sleeve **non e' deployabile** (`GTAA_MIN_CAPITAL` $3.000 allocati al peso 20%), quindi finora e' manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio **+0,095 / +0,124** di Sharpe secondo la fase: **reale e sempre positivo**, e delle stesse dimensioni dello spread fra le baseline (0,12) | 28/08 | «GTAA01 non contribuisce» — la misura dice il contrario | **il book a $15k**, che il giornale sorveglia da solo (`journal.SOGLIE_CAPITALE`) |
|
||||
| **Gradino di leva NON autorizzato oggi.** 1,25x autorizzabile *a condizione*; **1,50x BOCCIATO** (peggior giorno strutturale 21,48% > 20%); **k max difendibile 1,40** | 23/08 | il 1,50x | ✅ la **chiave di scala e' costruita** (01/09, inerte a 1,00): restano **GATE SCALA-01** (§4) e il rifacimento di `r0726_fee_sensitivity` al nuovo lordo — la sua conclusione *"liquidation fee 1% irrilevante"* era condizionata a lordo ≤1x, a 1,25x costerebbe **1,25%** |
|
||||
| **Quota USDE = 0,64, DERIVATA dal cuscino, con isteresi automatica** — bersaglio unico `1 − frazione_cuscino × (1 + cuscino_margine_frac 0,20)`; vende sotto zero di slack, ricompra sopra il 40% del cuscino (`cuscino_watch`, cron :53). Sostituisce il **0,70 del 30/08**, che lasciava slack zero per costruzione. Costa ~$10/anno di resa rispetto a 0,70; accettato | 10/09 | «portare la quota al massimo» — il massimo e' 0,70 e vende alla prima ora in perdita | un cambio di `cuscino_margine_frac` in config (0,10 ⇒ 0,67, al prezzo di un ⚠️ dopo ogni vendita), o un cambio di `disaster_sl_pct`/`WEIGHT` che sposta il cuscino stesso |
|
||||
|
||||
✅ **La chiave di scala ESISTE dal 2026-09-01** (era il debito che rendeva la regola *"ogni cambio di
|
||||
scala passa dal cap di config"* non implementabile). `book_scale_k`, letta da `src/live/book._scala`
|
||||
@@ -248,15 +278,15 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure.
|
||||
|
||||
| gate | data | criterio | stato |
|
||||
|---|---|---|---|
|
||||
| **USDE-01 (eligibilita' reward)** | 2026-08-29 | ≥1 reward entro il 29/08 → IDONEO e si apre la decisione di QUOTA (dell'operatore; tetto allerta 50%, mai 100% — R1 emittente non recuperabile); zero → si riconverte e la pista si chiude | ✅ **CHIUSO IDONEO** il 2026-08-28 12:35Z: primo reward **+0,052055 USDE** rilevato per delta al netto dei trade, coi criteri pre-registrati il 26/08 PRIMA dell'esito. **QUOTA DECISA il 30/08** (l'operatore ha anticipato il rinvio: *"porta in usde tutto il capitale che non viene usato"*): bersaglio **70%**, `usde.quota_target` in config. Tasso a 4 finestre (30/08): **3,26% annuo, banda bootstrap [1,13% – 4,51%]** — 3/4 hanno pagato (0,00 · 3,80 · 4,51 · 4,51%), e la banda larga 3,4 punti **e' il risultato**: a n=4 il tasso non e' misurato. `usde_watch.rendimento()` lo stampa **col suo n**; `quota_da_decidere()` lo chiede ogni giorno da lunedi'. 🚨 **L'EV non puo' scegliere la quota** — e' lineare in q, quindi il suo argmax e' sempre un angolo (0% o 100%): ogni quota interna nasce da un criterio sulla **coda**, dichiarato non ottimizzato (M8). Il resto e' misurato (`r0830_usde_quota.py`, diario 30/08): **hazard di pareggio = l'APR** (EV-positiva sse Ethena sta sotto il 3,3%/anno, numero **assunto** come il `p` di Deribit) · l'haircut **non morde a nessuna quota** fino al 90%, nemmeno a leva 1,50x con USDE marcato a 0,95 (29× di cuscino): **il vincolo non e' il margine** · la resa di oggi vale **$16/anno**, meno di UN mese di versamento ⇒ la leva binding resta il **bonifico** · gia' a 24,2% l'evento emittente vale **3,1× il maxDD dell'intero libro**. 📌 **Aspettare costa $1,33**: alzare a 50% fra 4 settimane invece che subito lascia sul tavolo $1,33 di resa (conversione reversibile a ~6 bps) e porta il campione da **4 a 32 finestre**, stringendo la banda ~2,8×. 🚨 **DUE FATTI DEL 30/08 CHE CAMBIANO IL GATE** (diario `2026-08-30-usde-quota-tetto-venue`). **(a) TETTO DEL VENUE ≈31-32% dell'equity — e' una FRAZIONE, non un livello.** 🚨 **SUPERATO IL 06/09: il
|
||||
| **USDE-01 (eligibilita' reward)** | 2026-08-29 | ≥1 reward entro il 29/08 → IDONEO e si apre la decisione di QUOTA (dell'operatore; tetto allerta 50%, mai 100% — R1 emittente non recuperabile); zero → si riconverte e la pista si chiude | ✅ **CHIUSO IDONEO** il 2026-08-28 12:35Z: primo reward **+0,052055 USDE** rilevato per delta al netto dei trade, coi criteri pre-registrati il 26/08 PRIMA dell'esito. **QUOTA DECISA il 30/08** (l'operatore ha anticipato il rinvio: *"porta in usde tutto il capitale che non viene usato"*): bersaglio **70%**, `usde.quota_target` in config (📌 **dal 10/09 la quota decisa e' 0,64, derivata dalle bande del cuscino: §3 e §5.17**). Tasso a 4 finestre (30/08): **3,26% annuo, banda bootstrap [1,13% – 4,51%]** — 3/4 hanno pagato (0,00 · 3,80 · 4,51 · 4,51%), e la banda larga 3,4 punti **e' il risultato**: a n=4 il tasso non e' misurato. `usde_watch.rendimento()` lo stampa **col suo n**; `quota_da_decidere()` lo chiede ogni giorno da lunedi'. 🚨 **L'EV non puo' scegliere la quota** — e' lineare in q, quindi il suo argmax e' sempre un angolo (0% o 100%): ogni quota interna nasce da un criterio sulla **coda**, dichiarato non ottimizzato (M8). Il resto e' misurato (`r0830_usde_quota.py`, diario 30/08): **hazard di pareggio = l'APR** (EV-positiva sse Ethena sta sotto il 3,3%/anno, numero **assunto** come il `p` di Deribit) · l'haircut **non morde a nessuna quota** fino al 90%, nemmeno a leva 1,50x con USDE marcato a 0,95 (29× di cuscino): **il vincolo non e' il margine** · la resa di oggi vale **$16/anno**, meno di UN mese di versamento ⇒ la leva binding resta il **bonifico** · gia' a 24,2% l'evento emittente vale **3,1× il maxDD dell'intero libro**. 📌 **Aspettare costa $1,33**: alzare a 50% fra 4 settimane invece che subito lascia sul tavolo $1,33 di resa (conversione reversibile a ~6 bps) e porta il campione da **4 a 32 finestre**, stringendo la banda ~2,8×. 🚨 **DUE FATTI DEL 30/08 CHE CAMBIANO IL GATE** (diario `2026-08-30-usde-quota-tetto-venue`). **(a) TETTO DEL VENUE ≈31-32% dell'equity — e' una FRAZIONE, non un livello.** 🚨 **SUPERATO IL 06/09: il
|
||||
tetto non c'era piu'** — quota portata al **68,7%** con 39 ordini senza rifiuti, fermata dal cuscino di
|
||||
regolamento (§1). Quanto segue e' la misura del 30-31/08, vera allora, tenuta perche' il tetto puo' tornare. Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo **$1.400 disponibili**: messaggio **fuorviante**, che ha fatto inseguire tre ipotesi sbagliate (taglia, cadenza, prezzo). Che sia un tetto e non un problema d'ordine e' provato a saldo neutro (BUY rifiutato → SELL OK → BUY riaccettato → BUY rifiutato); che sia una **frazione** e' provato il 31/08 lasciando scendere l'equity di $8: **il tetto e' sceso con lei** — 30/08 in [644,18·645,18) con equity $2.063,79 = **31,21-31,26%**, 31/08 in [643,18·644,18) con equity $2.055,56 = **31,29-31,34%** (0,05pp di scarto, dentro il rumore dell'equity che oscilla ±$2-8/ora). ⚠️ **Ri-sondato il 31/08 dopo il passaggio a X:SM** (l'ipotesi era che il tetto dipendesse dal modello segregato): **[654,18-655,18) con equity $2.054,90 = 31,84-31,88%**. Il modello ENTRA nel tetto ma **non lo spiega** — ha comprato **+0,55pp = +11 USDE (~$11)**, non le centinaia che servirebbero. ⇒ **il tetto SCALA col conto**: la quota resta ~31% e il valore in dollari cresce col capitale, ma **il 70% non e' raggiungibile ne' ora ne' mai**. 🚨 **Deribit non lo documenta**: l'articolo ufficiale *Yield/reward bearing coins* descrive un **Cap ETHENA** che diluisce il **TASSO** a livello di exchange (`User APR = min(bal_exchange, cap)/bal_exchange × Ethena APR`) e **nessun limite sulle quantita' detenibili** da un conto. Ora e' in config (`usde.venue_cap_frac` 0.312, bordo basso dei bracket) e `usde_convert` **rifiuta il piano prima di sparare** invece di scoprirlo dal venue. **(b) L'USDC NON frutta SUL NOSTRO CONTO — misurato, e la premessa del gate e' RESTAURATA.** `public/get_currencies` dice USDC 3,4000% (USDE 4,1071%) e per qualche ora quel numero e' stato scritto qui come se fosse nostro: **era un listino del venue, non un accredito**. Deribit paga i reward USDC **una volta al MESE** («paid out as a single monthly payment early in the following month», accrual 00:00 UTC sulla **minima equity**) — non ogni giorno come l'USDE, ed e' per questo che una sorveglianza giornaliera non poteva vederli. Verificato sui **due confini di mese a libro FLAT**, dove `equity == balance == USDC` e ogni scalino sarebbe un accredito: **29/06→08/07 equity 598,06 costante, 237/237 ore ferme, 0 scalini** (attesi $0,45) e **31/07→03/08 equity 596,92 costante per 4 giorni** (attesi **$1,72 contro una risoluzione di $0,01, 172×**). **Zero misurato, non zero sotto soglia.** ⇒ il guadagno di tenere USDE e' il **tasso pieno ~4,1%**, non uno spread: **~$26/anno** sui $644. 🚨 **Il motivo ora e' DOCUMENTATO, e non e' MiCA come avevo ipotizzato**: l'articolo ufficiale elenca le giurisdizioni escluse dai reward USDC e **l'ITALIA c'e'** (con Canada e Giappone: non e' una lista MiCA, e' policy di giurisdizione Deribit). ⚠️ **E la finestra di pagamento e' piu' larga di quella che avevo controllato**: «within the **first two weeks** of the following month». Rifatta la verifica sulle due settimane vere: **luglio e' conclusivo** (atteso $1,72 contro un'escursione TOTALE dell'equity di **$0,48** in 1-16/08, zero scalini compatibili), **giugno NO** (il libro ha iniziato a operare a meta' mese e il rumore e' della taglia del segnale). Quindi: **una finestra conclusiva piu' la lista ufficiale**, non due finestre — il «172× la risoluzione» del 30/08 era sovra-affermato. 📌 **Un tasso pubblicato non e' un tasso incassato: si verifica sul CONTO** — ed e' costato una query su una serie che avevamo gia'. Strumento: **`scripts/live/balance_watch.py`** + `cron_balance.sh` (orario :42, sola lettura) registra il **balance a 8 decimali**, la serie che mancava. 📌 **Il vincolo che l'analisi del margine non aveva guardato**: P&L e funding dei perp USDC-lineari **si regolano in USDC**, quindi la quota massima difendibile non e' data dall'haircut ma dal **cuscino di regolamento** = disaster-SL sulla massima esposizione lorda (`n_asset × frac × disaster_sl_pct` = **30% dell'equity**), che lascia esattamente il **70%** — coincidenza per costruzione, zero slack (M8: non e' un argmax). 📌 **Il costo del NON averlo e' ora documentato e vale il criterio**: un'equity negativa in una valuta paga una *collateral fee* dello **0,05% AL GIORNO** — **18,25%/anno**, addebitata al secondo, **4,3× la resa USDE che si starebbe comprando** (fonte: KB *Cross collateral specifications*). Il ribilanciamento automatico NON salva: scatta solo a **$1M** assoluti o al **100% della cross equity**, soglie che a $2k non si toccano mai — si sanguina la fee e basta. Il 30/08 avevo scritto «Deribit lo finanzia a interesse» **senza il numero**: il numero e' questo, e rende il cuscino piu' giustificato, non meno. Attrezzo: **`scripts/live/usde_convert.py`** (dry-run di default, guardie proprie; NON passa da `execution.ALLOWED`, che resta ai soli due perp) |
|
||||
| **STATARB** | 2026-09-27 | soglie 24/07 (non toccate) + diagnostica statica sempre-short | ✅ monitor riparato e rigenerato il 26/08: la serie vera da' Sharpe **−1,61** a oggi → coi criteri invariati punta al **RITIRO**. Si legge il 27/09, non prima |
|
||||
| **STATARB** | 2026-09-27 | soglie 24/07 (non toccate), **copiate verbatim** da `scripts/research/r0724_statarb_deploy_gate.py` (docstring e costanti; diario `2026-07-24-goal-50eur-roadmap.md` §Migliorie 1). Metrica: «Sharpe annualizzato di net_modeled su TUTTA la finestra forward (nessuna sottofinestra), da data/paper_statarb/returns.jsonl». «Sharpe >= 0.5 -> CANDIDATO AL DEPLOY: proporre peso {10,15,20}% del book Deribit, giudicato con weights_tilt_null + stima maxDD combinata < 15%. Deploy solo se il tilt-null passa.» · «0 <= Sh < 0.5 -> ESTENDI una sola volta di altri 90g (decisione 2026-12-26). Alla seconda scadenza: o >=0.5 o RITIRO. Niente terza proroga.» · «Sharpe < 0 -> RITIRO dal forward-monitor». Guardie «tutte richieste per il deploy»: «fill-haircut cumulato \|MODELED - REAL\| < 0.5 pp» · «config invariata (W=45, sgn=+1)» · «maxDD forward < 10%». Addendum 25/07: Sharpe della **statica sempre-short** a pari vol-target sulla stessa finestra — «Non e' una soglia binding», diagnostica obbligatoria; renderla binding spetta all'operatore. La lettura si fa lanciando lo script (`uv run python scripts/research/r0724_statarb_deploy_gate.py`), che applica la regola dal 27/09 | ✅ monitor riparato e rigenerato il 26/08: la serie vera da' Sharpe **−1,61** a oggi → coi criteri invariati punta al **RITIRO**. Si legge il 27/09, non prima |
|
||||
| **XSR01** | 2026-10-23 | Sharpe ≥1,0 **E** haircut ≤40% — dal 26/08 il numero decisivo e' `r0826_xsr_haircut.py` a **pavimento $10**, citato con la frazione di ordini eseguiti — poi `weights_tilt_null` e **capitale ≥$20k** | ✅ **riscritto il 26/08 su decisione dell'operatore** (dichiarato prima dell'esito, stringe: diario `2026-08-26-xsr01-gate-riscritto`): capitale allineato a "100% Deribit fino a $20k"; serie forward rigenerata (31 barre vere, Sharpe −2,72). Sotto la lente RENDITA il sleeve resta **eguagliato da un conto remunerato** (📌 sotto) |
|
||||
| **DVOLSPREAD** | kill 2026-10-24 se Sh<−0,50; decisione 2027-01-24 | (a) Sharpe>0 (b) marginale ADDS+robust_oos+insample_edge (c) **DSR ≥0,95** (d) `weights_tilt_null`; **veto** se barre attive <80% → si estende, non si decide | ✅ serie rigenerata il 26/08 (−4,41 a oggi, era −14,73 sulla serie rotta); il veto barre-attive ora conta barre VERE e la guardia PREMATURO sorveglia il monitor |
|
||||
| **GATE PROP-01** | chiuso 22-23/08 | (a) listino 13/13 ✅ (b) ancora SKH01 23/23 ✅ (c) economica+sopravvivenza 2/2 ✅ | **chiuso 3/3** — ma il numero operativo e' sceso 42% → 7,8% → 4,4% → 2,6% |
|
||||
| **GATE PREVDAY-01** | scritto 23/08 | 10 condizioni | **8/10**: la cella che GIRA non e' quella che la selezione onesta sceglie, e quella viva **fallisce il DSR** (0,905) |
|
||||
| **GATE SCALA-01** (portare `book_scale_k` da 1,00 a 1,25) | non prima del **2026-10-01** | 9 condizioni **tutte necessarie** (`SPEC-scale-key` §6.2): A1 gradino di `SCALA_LADDER` · A2 k0 in produzione ≥180g col criterio passato ogni giorno · A3 **C1-C5 di WORST-DAY ricalcolati OGGI**, non ereditati · A4 invariante disaster-SL · A5 tetto sul prodotto · **A6 ANTI-RECENCY** (il criterio deve passare anche **escludendo gli ultimi 90 giorni**) · A7 `r0726_fee_sensitivity` rifatto · A8 la modifica e' **una riga di config + una di giornale, zero codice** · A9 T1-T11 verdi | ◐ **A8/A9 FATTI il 01/09** (chiave inerte + 18 test); **A2 non maturo** — la specifica impone ≥30 giorni a 1,00 col sorvegliante attivo *prima* di eseguire il gate: «l'unico modo di scoprire che il sorvegliante e' rotto mentre la leva e' ancora 1,00». 📌 **A6 oggi NON morde** (k ammesso ~3,5x contro un gradino di 1,25x): il vincolo che morde e' il **disaster-SL**. E' scritto ora **perche' non e' comodo per nessuno**, non quando servira' |
|
||||
| **GATE PREVDAY-01** | decisione **2027-06-21** (scritto 23/08, `RESULTS-0822` §54); kill leggibile da ~18/12/2026 | 7 condizioni (a)-(g) tutte necessarie + **veto d'integrita'** (≥80% barre ricostruibili, divergenze non crescenti) + **kill** Sharpe forward **giornaliero** < −0,50 su ≥180 giorni attivi, senza ri-ottimizzare | **5/7** (= l'«8/10» di §54 e di `20-ondate`, contato su una base a 10 voci: stessa sostanza, base diversa): la cella che GIRA non e' quella che la selezione onesta sceglie, e quella viva **fallisce il DSR** (0,905) — (a) e (b) sono STRUTTURALI, nessun giorno di forward le cambia. ✅ **Kill e veto cablati il 10/09** in `paper_prevday.py` (issue #4; fino ad allora esistevano solo nel testo e la revisione del 09/09 lesse «senza data»): al 10/09 Sharpe giornaliero +0,95 su 81 giorni (MDE ±4,25), 1942/1943 barre ricostruibili, kill NON MATURO. Decisione dell'operatore (10/09): si tiene la data — verso i 50 €/g PREVDAY vale centesimi, non si spende altro |
|
||||
| **GATE SCALA-01** (portare `book_scale_k` da 1,00 a 1,25) | non prima del **2027-02-28** (= 180 giorni dal **01/09**, primo giorno di `scale_watch`: A2 conta da quando il criterio e' MISURATO, non dall'arming — decisione dell'operatore 10/09, issue #5. «Non prima del 2026-10-01», scritto il 01/09, era incompatibile con A2 per costruzione) | 9 condizioni **tutte necessarie** (`SPEC-scale-key` §6.2): A1 gradino di `SCALA_LADDER` · A2 k0 in produzione ≥180g col criterio passato ogni giorno · A3 **C1-C5 di WORST-DAY ricalcolati OGGI**, non ereditati · A4 invariante disaster-SL · A5 tetto sul prodotto · **A6 ANTI-RECENCY** (il criterio deve passare anche **escludendo gli ultimi 90 giorni**) · A7 `r0726_fee_sensitivity` rifatto · A8 la modifica e' **una riga di config + una di giornale, zero codice** · A9 T1-T11 verdi | ◐ **A8/A9 FATTI il 01/09** (chiave inerte + 18 test); **A2 non maturo** — la specifica impone ≥30 giorni a 1,00 col sorvegliante attivo *prima* di eseguire il gate: «l'unico modo di scoprire che il sorvegliante e' rotto mentre la leva e' ancora 1,00». 📌 **A6 oggi NON morde** (k ammesso ~3,5x contro un gradino di 1,25x): il vincolo che morde e' il **disaster-SL**. E' scritto ora **perche' non e' comodo per nessuno**, non quando servira' |
|
||||
| **`edge_watch`** (kill del book live) | continuo | **(A)** Sharpe rolling 36m < −0,5 → si riapre `weights_tilt_null`+DSR, il book **non si spegne da solo**; **(B)** in un anno con DD buy&hold >10%, il DD di TP01 deve restare <75% di quello | stato 27/07: Sharpe 36m +1,51, protezione **8/8** |
|
||||
|
||||
📌 **XSR01 sotto la lente RENDITA (25/08): il meccanismo vale ~0.** Mescolarne i rendimenti da' lo
|
||||
@@ -438,13 +468,19 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
|
||||
`valida` sia la prima istruzione, che l'uso documenti i flag accettati, e che **i flag che il
|
||||
cron usa davvero restino accettati** (P15/P16). Diario `2026-09-02c-flag-e-pulizia.md`.
|
||||
|
||||
17. ⚠️ **Nessuno sorveglia il CUSCINO USDC di regolamento** (aperto il 2026-09-06, quando la quota USDE e'
|
||||
salita al 68,7%). P&L e funding dei perp si regolano in USDC: una perdita del libro consuma il cuscino e
|
||||
fa salire la quota USDE da sola, senza alcun ordine. `usde_watch` allerta solo su `quota > quota_max_frac`
|
||||
(0,85): a 68,7% lo slack sul cuscino e' **$58**, e un saldo USDC negativo costa lo 0,05%/giorno. Il
|
||||
sorvegliante giusto legge `USDC − cuscino_richiesto` (derivato da config come in
|
||||
`usde_convert.cuscino_richiesto_usd`, P1) ogni ora e allerta sotto zero; e la riconversione USDE→USDC che
|
||||
ripristina il cuscino e' `usde_convert --quota <minore>`, che esiste ma non e' automatica.
|
||||
17. ✅ **RIPARATO (2026-09-10, issue #3).** Nessuno sorvegliava il CUSCINO USDC di regolamento (aperto il 06/09
|
||||
con la quota USDE al 68,7%): P&L e funding dei perp si regolano in USDC, una perdita del libro lo consuma e fa
|
||||
salire la quota da sola; `usde_watch` allerta solo su `quota > 0,85`, e un USDC negativo costa lo 0,05%/giorno.
|
||||
Ora **`scripts/live/cuscino_watch.py`** (cron **:53**, orario, `cron_cuscino.sh`, sorvegliato da `monitor_health`)
|
||||
legge l'**EQUITY USDC** (decisione dell'operatore: e' quella che risponde del regolamento, il balance vede il buco
|
||||
solo a trade chiuso), deriva il cuscino da `usde.cuscino_richiesto_usd` — **la formula si e' spostata in
|
||||
`src/live/usde.py`, e `usde_convert` la importa da li'** (due lettori, una formula: P1) — e ha cinque stati:
|
||||
OK · **PREAVVISO** (slack < 10% del cuscino, ⚠️ alla transizione) · **ECCEDENTE** (sotto) · BLIND · **SCOPERTO** (slack < 0: 🚨 e
|
||||
**riconversione automatica** `usde_convert --quota usde.quota_ripristino(0,20) --esegui`, cioe' **0,64**, che lascia
|
||||
il cuscino piu' un 20% — il DOPPIO del preavviso, altrimenti ogni 🚨 sarebbe seguito da un ⚠️ per costruzione). Guardie: `execution_enabled` del libro (disarmare il libro disarma anche questo), niente
|
||||
vendita sotto `depeg_warn` (decide l'operatore), un tentativo ogni 6h, le guardie proprie di `usde_convert`;
|
||||
marcatore «gia' detto» = esito dell'invio (§5.2). **Primo giro (13:00Z): gia' PREAVVISO, slack +$26** su $1.335
|
||||
richiesti — la marcatura del libro dal 06/09 lo aveva consumato senza che nessuno lo vedesse. ✅ **Ratchet chiuso lo stesso giorno (issue #7, «riportarla a quota in autonomia»)**: quarto stato **ECCEDENTE** (slack > 40% del cuscino ⇒ **acquisto** di USDE fino allo stesso bersaglio 0,64). Le tre bande stanno in `config/live.json` (`cuscino_preavviso_frac` 0,10 · `cuscino_margine_frac` 0,20 · `cuscino_riacquisto_frac` 0,40, `usde.bande_cuscino` verifica l'ordine) e **`quota_target` 0,70 non esiste piu'**: a 0,70 lo slack era ZERO per costruzione e ogni ora in perdita avrebbe venduto. Isteresi [0 ; 0,40]×cuscino con bersaglio unico 0,20: per oscillare servono ±6% di equity di slack = **±8,6% di equity USDC (~$380)**, un giro costa ~6 bps sull'importo mosso; un bonifico alza lo slack di 0,7×importo e sopra ~9% dell'equity fa scattare il riacquisto da solo. Soglie dichiarate, non ottimizzate (M8). ⚠️ Trovato al primo giro vero (13:53Z): l'allerta PREAVVISO **non e' partita** — Telegram in HTML rifiuta un `<` nudo («(< 10%…)»); l'escape sta nel **sink** (`notifier.notify`, `html.escape` su titolo e valori: copre tutti i 45 chiamanti, incluso il campo `motivo` che la revisione ha trovato ancora scoperto), `allerta_errore` registrato nel record (P3). Un guasto persistente da ECCEDENTE (tetto del venue tornato, book illeggibile) si ritenta **una volta al giorno**, da SCOPERTO ogni 6h; una config con bande fuori ordine da' **BLIND con motivo**, non un traceback ingoiato dal cron. ✅ **0,64 e' la quota DECISA dall'operatore il 10/09** (§3): derivata dal margine 0,20 sul cuscino, sostituisce lo 0,70 del 30/08. 📌 **Allineato il 10/09 14:16Z su ordine dell'operatore**: `usde_convert --quota 0.64 --esegui`, 238 USDE in 3 ordini @ 1,0000, quota 69,4% → 64,02%, slack +$266 = **OK** (era PREAVVISO a +$29). Il tetto del venue in `usde_convert.piano` ora vale **solo in acquisto**: bloccava anche la vendita, e con `venue_cap_frac` rimesso in config la riconversione sarebbe stata morta. `tests/test_cuscino_watch.py` (24).
|
||||
|
||||
18. ✅ **RIPARATO (2026-09-09 sera).** `cblib.spot_series` + `asof(ts)` guardava **un'ora avanti** (feed
|
||||
1h etichettato all'apertura: verificato, chiusura 1h a T == chiusura 5m a T+55 nel 100% delle barre)
|
||||
@@ -665,6 +701,7 @@ scripts/live/ → book_execute · venue_watch · fee_watch · edg
|
||||
monitor_health · collect_chain · paper_* · trades_db ·
|
||||
journal · analista
|
||||
scripts/cron_{book,daily,chain,opt_snapshot,vol_term,review}.sh
|
||||
(la crontab ha anche cron_ombra.sh di TradingBook: vedi §1, OMBRA)
|
||||
docs/memory/ → LA MEMORIA (indice in testa a questo file)
|
||||
docs/research/ → BRIEF-0822 · RESULTS-0822 (§1-77) · SPEC-scale-key
|
||||
docs/diary/YYYY-MM-DD.md → una voce per esperimento (146)
|
||||
@@ -689,7 +726,7 @@ uv run python scripts/live/trades_db.py --reconcile # incrocio dell
|
||||
uv run python scripts/live/journal.py # voce del giorno (numeri + lettura)
|
||||
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
|
||||
uv run python scripts/live/revisione.py --secco # taglia del materiale della revisione settimanale (niente modello)
|
||||
uv run pytest # test (1031, tutti verdi al 09/09 sera)
|
||||
uv run pytest # test (1070, tutti verdi al 10/09)
|
||||
```
|
||||
|
||||
```python
|
||||
|
||||
+5
-2
@@ -10,11 +10,14 @@
|
||||
"max_data_age_days": 2,
|
||||
"_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.",
|
||||
"skh_feed_max_age_min": 30,
|
||||
"_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno perche' il 70% pareva irraggiungibile; RIMESSO A 0.85 il 2026-09-06, quando la quota e' arrivata al 68,7% su autorizzazione dell'operatore e la sonda non ha trovato tetto. 🚨 venue_cap_frac: il 06/09 il tetto NON C'ERA (null, vedi venue_cap_misurato) — quanto segue e' la storia del 30-31/08, tenuta perche' il tetto puo' tornare: venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).",
|
||||
"_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 — diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc — a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_target 0.70 (CHIAVE TOLTA il 2026-09-10, issue #7: il bersaglio operativo e' DERIVATO dalle bande del cuscino, vedi usde._nota_cuscino — quanto segue e' la storia della decisione) = la quota DECISA dall'operatore il 2026-08-30 (la decisione di quota che il gate USDE-01 apriva; anticipata di un giorno sul rinvio al 31/08, su richiesta esplicita dell'operatore \"porta in usde tutto il capitale che non viene usato\"). Il 70% NON e' un argmax (M8): e' il massimo compatibile col CUSCINO DI REGOLAMENTO, cioe' il vincolo che r0830_usde_quota non aveva guardato — il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel collaterale, quindi a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia a interesse. Il cuscino richiesto e' il disaster-SL sulla massima esposizione lorda: n_asset x frac x disaster_sl_pct = 2 x 0.5 x 0.30 = 30% dell'equity, che lascia esattamente il 70%. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile). Alzato a 0.85 il 2026-08-30 in previsione della quota al 70% e RIMESSO A 0.50 lo stesso giorno perche' il 70% pareva irraggiungibile; RIMESSO A 0.85 il 2026-09-06, quando la quota e' arrivata al 68,7% su autorizzazione dell'operatore e la sonda non ha trovato tetto. 🚨 venue_cap_frac: il 06/09 il tetto NON C'ERA (null, vedi venue_cap_misurato) — quanto segue e' la storia del 30-31/08, tenuta perche' il tetto puo' tornare: venue_cap_frac 0.318 = TETTO DEL VENUE sull'USDE, misurato e NON documentato da Deribit (ne' 'Cross collateral specifications' ne' 'Yield-generating collateral' prevedono un limite sulle quantita' detenibili; il Cap ETHENA che esiste diluisce il TASSO a livello di exchange, non limita gli acquisti). Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo $1.400 disponibili: messaggio FUORVIANTE. E' una FRAZIONE dell'equity, non un livello: provato il 31/08 lasciando scendere l'equity di $8, il tetto e' sceso con lei. ⚠️ DIPENDE DAL MODELLO DI MARGINE, ma pochissimo — misurato a saldo neutro sotto entrambi: SEGREGATO S:SM [643,18-644,18) con equity $2.055,56 = 31,29-31,34%; CROSS X:SM [654,18-655,18) con equity $2.054,90 = 31,84-31,88%. Il passaggio a cross ha comprato +0,55pp = **+11 USDE (~$11)**: il modello entra nel tetto, ma non lo spiega. ⇒ la quota resta ~31-32% sotto qualunque configurazione e il 70% di quota_target NON e' raggiungibile (manca un fattore ~2,2x). Il valore 0.318 e' il bordo BASSO del bracket CROSS, che e' il modello attivo: fa fallire il piano PRIMA dell'ordine. Se si torna a S:SM va rimesso a 0.312. haircut 0.05 — ✅ DIVERGENZA CHIUSA il 2026-08-31 (era 0.10, SBAGLIATO). La pagina margini del conto non espone l'haircut come numero: si RICAVA per differenza fra le due righe, perche' il modello CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto. Contributo dell'USDE al cross $610,81 su $643,05 di valore -> 5,0137%: l'ipotesi 5% torna a $0,09, la 10% sbaglia di $32,06. Riproducibile: `scripts/research/r0831_margini_conto.py` (N11). Il 26/08 il 10% fu registrato come 'verificato sul venue' senza lasciare traccia di come, e non lo era. 🚨 SCOPERTO NELLA STESSA LETTURA, e vale piu' dell'haircut: il modello di margine ATTIVO e' **Segregated: Standard Margin (S:SM)**, NON cross-collateral. Nella tabella del modello attivo l'USDE **non compare**: non fa margine per i perp USDC-settled del book. Percio' l'haircut oggi non si applica affatto — ed e' esattamente il motivo per cui la misura del 31/08 trovava $0,0000 accantonati. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile: e' una decisione dell'operatore, non un refactor, e porta con se' la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo, ribilanciamento automatico).",
|
||||
"usde": {
|
||||
"index_name": "usde_usdc",
|
||||
"haircut": 0.05,
|
||||
"quota_target": 0.7,
|
||||
"_nota_cuscino": "Dal 2026-09-10 (issue #7) la quota USDE NON e' un numero scelto: e' DERIVATA dal cuscino USDC di regolamento (n_asset x frac x disaster_sl_pct = 30% dell'equity) e da queste tre frazioni del cuscino, lette da src/live/usde.py per scripts/live/cuscino_watch.py (cron :53). cuscino_margine_frac 0.20 = bersaglio unico di ogni conversione: quota = 1 - 0.30 x 1.20 = 0.64 (il vecchio quota_target 0.70 lasciava slack ZERO: ogni ora in perdita avrebbe venduto). cuscino_preavviso_frac 0.10 = sotto, allerta. cuscino_riacquisto_frac 0.40 = sopra, si RICOMPRA USDE fino al bersaglio (un bonifico alza lo slack di 0,7 x importo e fa scattare il riacquisto da solo). Isteresi [0 ; 0.40] x cuscino con bersaglio a 0.20: oscillare richiede +-6% di equity di slack, cioe' +-8,6% di equity USDC (slack = 0,7 USDC - 0,3 USDE). Soglie DICHIARATE, non ottimizzate (M8): un giro costa ~6 bps sull'importo mosso. Ordine obbligato: 0 < preavviso < margine < riacquisto (usde.bande_cuscino lo verifica).",
|
||||
"cuscino_preavviso_frac": 0.1,
|
||||
"cuscino_margine_frac": 0.2,
|
||||
"cuscino_riacquisto_frac": 0.4,
|
||||
"venue_cap_frac": null,
|
||||
"venue_cap_misurato": "2026-09-06: NESSUN tetto fino al 68,7% (fermata dal cuscino di regolamento al 70%, non dal venue): +2.434 USDE in 39 ordini a saldo crescente, zero rifiuti (r0906_usde_tetto_sonda.py). Il 30-31/08 il tetto c'era: [643-644) e [654-655) USDE su ~$2.055 = 31,3-31,9%. Non spiegato: e' SPARITO, non e' scalato. null = nessun tetto noto; usde_convert si affida al chunk+backoff sui rifiuti",
|
||||
"quota_max_frac": 0.85,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# I cinque punti della revisione del 09/09 — decisi e fatti
|
||||
|
||||
**Data:** 2026-09-10 (sessione 12:08Z → 13:10Z; i timestamp qui dentro sono letti, non ricordati)
|
||||
**Origine:** la prima revisione settimanale (`docs/revisioni/2026-09-09.md`, revisore `fable`) e il
|
||||
giornale del 09/09. Ogni punto e' stato spiegato all'operatore con pro e contro e **deciso da lui**
|
||||
(§3-style: decisioni con l'informazione completa). Issue sul Gitea di casa #2-#6, chiuse con la
|
||||
soluzione scritta (regola globale del 10/09).
|
||||
|
||||
## Le decisioni, in una tabella
|
||||
|
||||
| # | punto | decisione dell'operatore | issue |
|
||||
|---|---|---|---|
|
||||
| 1 | `usde_watch` contava 400 USDE come reward (07/09) | ripara ora + issue | #2 bug |
|
||||
| 2 | cuscino USDC senza sorvegliante (§5.17) | **equity USDC, riconverte da solo** | #3 sviluppo |
|
||||
| 3 | versamento 04/09 solo rilevato; piano €5.000 «in attesa» | **e' l'INTERO versamento previsto**: dichiarato, piano chiuso | #6 sviluppo |
|
||||
| 4 | SCALA-01 A2: da quando contano i 180 giorni | **dal 01/09** (sorvegliante attivo) → gate non prima del **2027-02-28** | #5 bug |
|
||||
| 5 | PREVDAY-01 «senza data» | **tieni 21/06/2027, cabla kill e veto, correggi §4** | #4 bug |
|
||||
|
||||
Sul punto 5 la domanda giusta e' stata «ricorda dove dobbiamo arrivare (50 €/g)»: PREVDAY
|
||||
de-luckato al 15% vale +0,10 di Sharpe sul libro e la somma di *tutti* i lead dell'ondata di agosto
|
||||
vale +0,036 €/giorno. Verso i 50 €/g il candidato vale centesimi in qualunque esito del gate:
|
||||
non si spende una sessione di ricerca per anticipare (a)+(b), e non si butta l'unica gamba short
|
||||
scorrelata oltre a SKH01 per risparmiare un cron. Costo scelto: mezza giornata, una volta.
|
||||
|
||||
## Punto 1 — il lettore dei trade spot aveva DUE limiti, non uno
|
||||
|
||||
Il primo era noto dalla revisione: `trade_history(limit=50)` con 54 ordini. Il secondo l'ho
|
||||
trovato sondando il gateway per ripararlo: `get_user_trades_by_instrument` chiamato senza
|
||||
`historical` restituisce **solo le ultime ~24 ore** — il fill BTC dell'08/09 06:47Z e' invisibile
|
||||
alle 12:55Z del 10/09, quello del 09/09 21:47Z si vede. Il cron gira ogni 24h alle 12:35: sta
|
||||
dentro la finestra per pochi secondi. Un giro saltato avrebbe reso invisibili i trade fra 48h e
|
||||
24h fa, e il delta sarebbe stato attribuito al reward *esattamente come il 07/09*, con un
|
||||
meccanismo diverso.
|
||||
|
||||
Ora `trade_copertura()` dichiara **non leggibile** (somma `None`, e `analizza` non attribuisce:
|
||||
P12) in entrambi i casi — lista lunga quanto il limite (troncata) o ultima lettura oltre 24h+5min —
|
||||
e il record porta `trades_motivo` (P4: il perche'). `TRADE_LIMIT = 1000` e' il `count` massimo di
|
||||
Deribit. Controllo positivo nei test: 54 ordini si sommano tutti.
|
||||
|
||||
La riga del 07/09 in `data/live/usde_watch.jsonl` e' **corretta in loco** con campo `correzione`
|
||||
(originale conservato dentro, copia del file in `usde_watch.jsonl.pre_fix_20260910`): 54 ordini
|
||||
= 8 + 30 + 16 dal registro `usde_convert.jsonl`, 2.434 USDE, reward 0,080934. Il tasso non
|
||||
cambia — la riga era gia' esclusa per `con_trade` — e `rendimento()` oggi da' **3,94% su 14
|
||||
finestre, 12 pagate**.
|
||||
|
||||
## Punto 2 — `cuscino_watch`: la grandezza che nessuno guardava
|
||||
|
||||
Il cuscino richiesto e' `2 × 0,5 × 0,30 × equity_tot` = 30% dell'equity, **derivato** da
|
||||
config e `book.WEIGHT`. La formula e' passata da `usde_convert.py` a `src/live/usde.py`, e
|
||||
`usde_convert` la importa da li': due lettori, una formula (P1 — cinque occorrenze nel
|
||||
progetto di sorveglianti puntati su un bersaglio ridichiarato).
|
||||
|
||||
| stato | criterio | azione |
|
||||
|---|---|---|
|
||||
| OK | slack ≥ 10% del cuscino | niente |
|
||||
| PREAVVISO | 0 ≤ slack < 10% | ⚠️ alla transizione |
|
||||
| SCOPERTO | slack < 0 | 🚨 + `usde_convert --quota 0,64 --esegui` (cuscino + 20% di margine, alzato dal 10% in revisione) |
|
||||
| BLIND | conto non leggibile | ⚠️ alla transizione |
|
||||
|
||||
Guardie della riconversione: `execution_enabled` del libro (un solo interruttore), niente vendita
|
||||
sotto `depeg_warn` (li' decide l'operatore, e l'allarme lo dice), un tentativo ogni 6h, le guardie
|
||||
proprie di `usde_convert` (banda di prezzo, book leggibile, tetto HARD) ereditate lanciando lo
|
||||
script vero (P15). Il marcatore «gia' detto» e' l'esito di `notify`: un invio fallito non consuma
|
||||
la transizione (lezione del debito §5.2). Cron `:53` = dopo il giro del book (`:47`), fuori dal
|
||||
minuto tondo. `monitor_health` lo sorveglia (max 3h).
|
||||
|
||||
📌 **Primo giro a secco (13:00Z): gia' PREAVVISO.** USDC equity $1.361,23 contro $1.335,26
|
||||
richiesti: **slack +$25,97**. Il 09/09 il revisore aveva stimato +$41/+$59; la marcatura del
|
||||
libro dal picco del 06/09 ne aveva consumati altri 15-30 senza che nessuno lo vedesse. E' il
|
||||
caso d'uso del sorvegliante, arrivato prima del sorvegliante.
|
||||
|
||||
## Punto 3 — il versamento del 04/09, dal balance e non dall'equity
|
||||
|
||||
Il salto di equity 13:47→14:47Z diceva +$2.410,14 col mercato dell'ora dentro. Il **balance
|
||||
USDC** di `balance_watch` (cambia solo per P&L realizzato, fee, funding e movimenti) dice
|
||||
1.417,73455581 → 3.832,41432481 = **+$2.414,68**, nessun fill nella finestra, funding su $541
|
||||
di nozionale ~$0,01. Dichiarato con banda $0,05. Il report ora lo mostra come dichiarato e
|
||||
il TWR passa a **+6,66%** (era +7,22% stamattina, +7,83% il 06/09): la coda −0,63% dal 04/09 e'
|
||||
marcatura delle due long, trading da arming **+$13,82**.
|
||||
|
||||
L'operatore ha detto che era **l'intero versamento previsto**: la voce «in attesa di importo e
|
||||
data» di §1 e' chiusa. Resta la lezione di §2: per sei giorni il movimento e' stato solo
|
||||
rilevato, la regola diceva il giorno stesso.
|
||||
|
||||
## Punto 4 — SCALA-01: la data era il punto 5 della SPEC scambiato per il gate
|
||||
|
||||
«Non prima del 2026-10-01» erano i 30 giorni di sorvegliante del punto 5 (§7 della SPEC); A2
|
||||
chiede **≥180 giorni** col criterio passato ogni giorno, e il criterio lo misura solo
|
||||
`scale_watch`, attivo dal 01/09. Le tre letture possibili, con pro e contro, sono nel diario di
|
||||
sessione; l'operatore ha scelto **dal 01/09 → 2027-02-28**. Contare dall'arming avrebbe dato
|
||||
un criterio ricostruito a posteriori, che non prova nulla sul sorvegliante; ridurre A2 a 30 giorni
|
||||
avrebbe allentato un gate senza un costo d'attesa che lo giustifichi (il gradino 1,25x vale meno
|
||||
di €100/mese di versamento).
|
||||
|
||||
Riconciliata anche la data d'arming: **20/06 = TP01 da solo** (commit `4650aa7`), **23/06 = il
|
||||
BOOK** (commit `db738bc`, prima lettura di equity 23/06 22:00Z). CLAUDE.md §0 diceva solo la
|
||||
prima, `config/live.json` e il TWR usano la seconda: erano due eventi, non un errore.
|
||||
|
||||
## Punto 5 — PREVDAY-01 aveva la data; non l'aveva la tabella
|
||||
|
||||
`RESULTS-0822` §54: decisione **2027-06-21**, kill, veto. La tabella §4 diceva «scritto 23/08 ·
|
||||
10 condizioni», e il revisore ha letto quella. Ora la riga ha data, kill, veto e stato (5/7:
|
||||
(a) e (b) sono strutturali, nessun forward le cambia).
|
||||
|
||||
Cablato in `paper_prevday.py` (prima esisteva solo nel testo, a differenza di `paper_dvolspread`):
|
||||
|
||||
| misura | valore al 10/09 |
|
||||
|---|---|
|
||||
| Sharpe forward **giornaliero** (lente del kill; l'oraria +1,19 non si cita, §54) | **+0,95** su 81 giorni, 81 attivi |
|
||||
| kill (< −0,50 su ≥180 g attivi) | **NON MATURO**, leggibile dal ~18/12/2026 |
|
||||
| barre ricostruibili dal feed di oggi | **1.942/1.943** (99,9%); l'unica divergente e' il 26/08 00:00, il giorno del fix di `advance()` |
|
||||
| veto | ok |
|
||||
|
||||
⚠️ **Errore mio, catturato prima di committare:** la prima stesura del veto leggeva «divergenze
|
||||
non crescenti» come `tasso_recente > tasso_totale`, e con UNA barra divergente negli ultimi 30
|
||||
giorni (0,03/g contro 0,01/g) dichiarava VETO. Una guardia piu' stretta del contratto produce
|
||||
allarmi che si impara a ignorare (P14): ora la crescita richiede **≥5 barre recenti E tasso
|
||||
doppio**, dichiarato nel codice e provato nel test con i numeri della serie vera.
|
||||
|
||||
La ricostruzione al 99,9% contro il 95,6% di §54: la misura del 23/08 era stata fatta *prima*
|
||||
del fix di `advance()` del 26/08 (le 67 barre divergenti erano «all'ora del cron», cioe' barre
|
||||
parziali); oggi il feed rivisto e la serie coincidono tranne quella barra.
|
||||
|
||||
## Cose trovate per strada
|
||||
|
||||
- Due timestamp «di comodo» scritti a mano (13:05Z e 13:07Z) invece dell'ora letta (12:57:43Z e
|
||||
13:04:09Z): corretti subito. E' esattamente la regola globale «la data si LEGGE» — vale anche per
|
||||
chi la scrive nel campo `correzione`.
|
||||
- `issue --json` va **prima** del sottocomando; il percorso nella regola globale era
|
||||
`/opt/docker/AI-OS` invece di `/opt/AI-OS` (corretto la mattina, nel test dello strumento).
|
||||
|
||||
## Verifica
|
||||
|
||||
`uv run pytest`: **1060 passati** (erano 1031). Nuovi: `test_cuscino_watch.py` (14),
|
||||
`test_paper_prevday_gate.py` (8), +5 in `test_usde_watch.py`. Revisione del diff affidata a
|
||||
`fable` (agente fresco): esito e correzioni applicate in coda a questo diario.
|
||||
|
||||
## Revisione `fable` (13:10-13:18Z): 15 segnalazioni, 4 medie — tutte verificate, 12 applicate
|
||||
|
||||
| # | segnalazione | verifica | esito |
|
||||
|---|---|---|---|
|
||||
| 1 | `puo_riconvertire` contava anche i NON tentativi (`tentata=False`): un giro `--secco` o un prezzo illeggibile bloccava la riconversione vera per 6h | riprodotta | filtro su `tentata` + test |
|
||||
| 2 | il tetto del venue in `usde_convert.piano` bloccava anche la **vendita**: con `venue_cap_frac` rimesso in config la riconversione automatica sarebbe stata morta | riprodotta (`side=sell`, `ok=False`) | vale solo in acquisto |
|
||||
| 3 | `MARGINE_RIPRISTINO` 10% == `PREAVVISO_FRAC` 10%: dopo ogni 🚨 un ⚠️ per costruzione (P14) | riprodotta col floor di 1 USDE | margine **20%** → quota **0,64**, test |
|
||||
| 4 | `--secco` scriveva nel registro vivo (veicolo del punto 1, e maschera un cron fermo) | riprodotta (riga 13:00:15Z) | non scrive; riga tolta |
|
||||
| 5 | due bersagli di quota: `quota_target` 0,70 in config contro 0,64 del ripristino; il ripristino e' un **ratchet verso il basso** | letto | dichiarato in CLAUDE.md §5.17 e nel docstring: decisione dell'operatore (N9) |
|
||||
| 7 | buco di ≤5 min dentro la tolleranza della finestra 24h | letto | dichiarato (D5) + fonte Deribit documentata |
|
||||
| 8 | `giorni_attivi` legge posizioni REAL, il kill e' MODELED | letto | dichiarato nel docstring (81/81 oggi) |
|
||||
| 10 | il test del cron leggeva un commento nello `.sh`, non la crontab | letto | legge `crontab -l` come `test_book_cadenza` |
|
||||
| 11 | `prevday_target` patchato senza `monkeypatch` | letto | `monkeypatch` |
|
||||
| 13 | «QUATTRO movimenti» in §2: sono TRE, in quattro tratti | letto | corretto |
|
||||
| 14 | «5/7» contro «8/10» di §54: base diversa non dichiarata | letto | dichiarata |
|
||||
| 6, 9, 12, 15 | verifiche che reggono (`sys.executable`, `cwd`, `piano()` in SCOPERTO, ricostruzione ≡ `advance`) | — | — |
|
||||
|
||||
Non applicato: il lock fra cron e conversione a mano (DUBBIO 2) — dichiarato come limite nel
|
||||
docstring. Il DUBBIO 1 (ratchet) e' la domanda aperta per l'operatore.
|
||||
|
||||
## Coda (13:55Z →): il riacquisto automatico — issue #7
|
||||
|
||||
Alla domanda aperta (ratchet verso il basso) l'operatore ha risposto «crea una strategia per
|
||||
riportarla a quota in autonomia». La risposta ingenua — ricomprare fino a `quota_target` 0,70 —
|
||||
e' sbagliata per costruzione: a 0,70 lo slack e' zero, la prima ora in perdita vende, il
|
||||
riacquisto ricompra, e cosi' via a 6 bps al giro. Serve **isteresi con un bersaglio unico**:
|
||||
|
||||
| slack / cuscino | stato | azione |
|
||||
|---|---|---|
|
||||
| < 0 | SCOPERTO | 🚨 vende USDE → 0,20 |
|
||||
| 0 – 0,10 | PREAVVISO | ⚠️ |
|
||||
| 0,10 – 0,40 | OK | — |
|
||||
| > 0,40 | **ECCEDENTE** | 📌 compra USDE → 0,20 |
|
||||
|
||||
Bersaglio 0,20 ⇒ quota **0,64**. Per oscillare lo slack deve muoversi di ±0,20×cuscino = ±6%
|
||||
dell'equity, cioe' **±8,6% di equity USDC (~$380)** perche' slack = 0,7·USDC − 0,3·USDE (la prima
|
||||
stesura diceva ±6%/$270: corretta in revisione): a leva 0,26x sono ~33% di mercato. Un bonifico alza lo slack di
|
||||
0,7×importo: sopra ~9% dell'equity (~$380) il riacquisto scatta da solo — la regola di §1
|
||||
«dopo un bonifico si rilancia `usde_convert`» non dipende piu' da qualcuno che se ne ricordi.
|
||||
Le tre frazioni vivono in `config/live.json` (`usde.bande_cuscino` verifica 0 < preavviso <
|
||||
margine < riacquisto), `quota_target` 0,70 e' stato tolto. Soglie dichiarate, non ottimizzate
|
||||
(M8): il costo di un giro e' ~$0,2, la frequenza attesa qualche giro al mese nei tratti mossi.
|
||||
|
||||
📌 **Trovato al primo giro vero del cron (13:53Z): l'allerta PREAVVISO non e' partita.** Il token
|
||||
era valido (`getMe` ok); il testo conteneva «(< 10% del cuscino)» e Telegram in `parse_mode=HTML`
|
||||
rifiuta un `<` nudo. Testi riscritti senza `<`, `allerta_errore` registrato nel record (P3),
|
||||
test che legge il sorgente delle `notify`. Un sorvegliante nuovo che non riesce a parlare al
|
||||
primo giro e' il caso di P2: si valida sul segnale E sul trasporto.
|
||||
|
||||
Stato al giro a secco delle 14:0xZ: quota 69,3%, slack +$30 = PREAVVISO. Il piano a 0,64 e'
|
||||
valido (SELL 237 USDE, slack dopo +$267): il sorvegliante lo esegue alla prima ora sotto zero;
|
||||
allinearlo subito e' una riga a mano.
|
||||
|
||||
### Seconda revisione `fable` (14:04Z): 10 segnalazioni, 1 ALTA, 3 MEDIE — applicate 9
|
||||
|
||||
| # | segnalazione | esito |
|
||||
|---|---|---|
|
||||
| 1 ALTA | il `<` arrivava ancora a Telegram dal campo `motivo` («(< 6h)») dentro l'allerta 🚨: il 🚨 piu' importante perso fino a 4h | escape nel **sink** `notifier.notify` (`html.escape` su titolo e valori; nessuno dei 45 chiamanti passa tag: grep) + motivi senza `<` |
|
||||
| 2 MEDIA | il test sul sorgente era parziale (codice morto, cieco ai valori interpolati) | sostituito: cattura la POST e verifica `<` |
|
||||
| 3 MEDIA | ECCEDENTE con guasto persistente = «acquisto FALLITA» ogni 6h per sempre | da ECCEDENTE dopo un fallimento si riprova ogni 24h; da SCOPERTO resta 6h |
|
||||
| 4 MEDIA | bande fuori ordine ⇒ traceback ingoiato dal cron, nessun record, «fermo» invece di «rotto» | `giudica` ⇒ BLIND con motivo; record e allerta scritti |
|
||||
| 5-7 | CLAUDE.md §1 diceva ancora «nessuno sorveglia»; §5.17 «tre stati»; `_nota_usde` con `quota_target` | corretti |
|
||||
| 8 | «±6% di equity USDC» sbagliato di 0,7: slack = 0,7·USDC − 0,3·USDE ⇒ ±8,6% (~$380) | corretto in 4 posti |
|
||||
| 9 | `TOLL` ridichiarato nel test | importato da `usde_convert` |
|
||||
| 10 | esempi `--quota 0.70` nel docstring di `usde_convert` | 0,64 |
|
||||
|
||||
Verifiche numeriche del revisore (loop su `piano()` con passo 1 USDE, TOLL, min order): bonifico
|
||||
+$2.000 → 11 acquisti → OK, slack +$387; perdita −$50 → 3 vendite → OK, +$264; conto tutto USDC →
|
||||
20 acquisti → OK. **Nessuna sequenza vendita→acquisto→vendita.** Non applicato: `min(q*, cap)` con
|
||||
il tetto del venue (dubbio 3: un conto al tetto resta ECCEDENTE e ora allerta una volta al giorno).
|
||||
Dubbio 4, che vale una riga in §5.17: **0,64 e' una derivazione dell'autore, non la quota decisa
|
||||
dall'operatore il 30/08 (0,70)**; ~$10/anno di resa di differenza; da confermare.
|
||||
|
||||
### Allineamento eseguito (14:16:38Z, ordine dell'operatore «allinea subito a 0.64, esegui»)
|
||||
|
||||
`usde_convert --quota 0.64 --esegui`: SELL 100 + 100 + 38 USDE a 1,0000 medio (limite 0,9995,
|
||||
fill al book), quota 69,4% → **64,02%**, conto USDC $1.602,79 + USDE 2.851,95 = $4.454,74.
|
||||
Slack dopo **+$266** su $1.336 di cuscino: `cuscino_watch --secco` dice **OK** (era PREAVVISO a
|
||||
+$29). Costo: ~$0,1 di spread, fee zero. Registro in `data/live/usde_convert.jsonl`.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Risk-off ±2h intorno a FOMC e CPI su TP01 — REFUTED, e il segno e' l'opposto
|
||||
|
||||
**Data:** 2026-09-10 14:52-14:55Z · **Script:** `scripts/research/r0910_macro_riskoff.py` (N11: il
|
||||
verdetto lo calcola lo script) · **Issue:** #9 · **Origine:** domanda dell'operatore da un articolo su
|
||||
EA forex a griglia con «agente fondamentale» che approva/rifiuta i segnali intorno ai dati macro.
|
||||
|
||||
## Cosa si e' misurato
|
||||
|
||||
TP01 canonico (posizione decisa alla chiusura giornaliera, tenuta 24 barre 1h), 2019-03 → 2026-09,
|
||||
65.676 ore. Variante RISK-OFF: esposizione zero nelle barre che intersecano [t−2h, t+2h] intorno a
|
||||
**61 dichiarazioni FOMC** (14:00 ET, piu' le due d'emergenza di marzo 2020, notation vote esclusi) e
|
||||
**88 release CPI** (08:30 ET), con fee di chiusura e riapertura. Calendario **letto dal web** (Fed e
|
||||
BLS), non ricordato; l'archivio BLS raggruppa per anno di riferimento e le release di gennaio/febbraio
|
||||
vanno spostate di +1 anno (verificato sulla schedule 2026). 684 ore in finestra = **1,04%** del tempo.
|
||||
|
||||
Verdetto pre-registrato prima del primo numero: LEAD solo se (a) ΔSharpe con fee > 0, (b) Δ al ≥95°
|
||||
percentile del null location-matched (stessi conteggi per anno, stesse ore del giorno, giorni a caso,
|
||||
1.000 estrazioni — M18), (c) Δ > 0 in ≥70% degli anni (M9).
|
||||
|
||||
## Numeri
|
||||
|
||||
| | Sharpe giornaliero | drift/anno |
|
||||
|---|---|---|
|
||||
| TP01 base | 1,302 | +15,73% |
|
||||
| risk-off con fee | 1,205 | +14,49% |
|
||||
| risk-off senza fee | 1,228 | +14,77% |
|
||||
| **Δ** | **−0,097** | **−1,24%** ≈ **−0,14 €/giorno** a $4.455 |
|
||||
|
||||
Per tipo: FOMC ΔSh −0,012 (P&L rinunciato +0,79%), **CPI −0,084 (+6,36%)**. Per anno: Δ negativo in
|
||||
6/8, positivo solo 2024 e 2026 (+0,02/+0,03).
|
||||
|
||||
Null location-matched: ΔSh mediana −0,033 [p05 −0,095, p95 +0,027]. **Il Δ vero sta al 4,2°
|
||||
percentile**: le finestre vere sono fra le PEGGIORI da appiattire, cioe' fra le migliori in cui
|
||||
essere esposti. Dentro la finestra TP01 fa **+1,05 bp/ora contro +0,17 fuori** (t = 1,57 su 684 ore).
|
||||
|
||||
(a) FAIL · (b) FAIL · (c) 2/8 FAIL ⇒ **REFUTED**.
|
||||
|
||||
## Lettura
|
||||
|
||||
1. **Il risk-off toglie P&L, non rischio.** TP01 e' long-flat vol-targeted: nelle ore dei dati macro
|
||||
il trend che sta cavalcando accelera piu' spesso di quanto si rovesci, e l'1% del tempo in finestra
|
||||
porta il 7% del guadagno cumulato. E' la stessa lezione del weekend (§3, 17/07: il 38% del gross
|
||||
nel 31% del tempo): **la coda del trend vive nelle ore «pericolose»**.
|
||||
2. **Il segno opposto non e' un lead.** «Esporsi DI PIU' intorno ai dati» sarebbe un'ipotesi nuova
|
||||
(M12), con t = 1,57 e 684 ore: sotto qualunque MDE, e la selezione sull'esito la vieta. Non si apre.
|
||||
3. Il gate «fondamentale» dell'articolo, applicato al nostro libro, costerebbe **−1,24%/anno di drift**
|
||||
— la stessa taglia del funding non modellato (−2,16%), ma con segno scelto da chi lo propone.
|
||||
4. Coerente con `macro-regime-gate` (29/06, ridondante col trend) e con la conclusione di §3: **ogni
|
||||
risk-off parte REFUTED** e questo lo e' rimasto.
|
||||
|
||||
Non modellato (dichiarato): funding (identico nelle due varianti salvo l'1% delle ore); slippage
|
||||
della finestra (rende il risk-off *piu'* costoso, non meno). Costo: 3 minuti di calcolo, 0 ordini.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-10
|
||||
|
||||
*Scritto 2026-09-11T00:36:36+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $76,549.50 | -2.22% | -5.83% | +20.44% | 47.7% | ↑ ↑ ↑ (3/3 su) | 40.2 (43° pctl 1a) |
|
||||
| **ETH** | $2,437.50 | -1.23% | -2.81% | +29.57% | 70.5% | ↑ ↑ ↑ (3/3 su) | 53.4 (30° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,454.86** · nozionale lordo $1,147 · leva lorda 0.26x · barra dati 2026-09-10 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.419 | flat | $+700 | $+698 | HOLD (a target) |
|
||||
| **ETH** | +0.269 | flat | $+449 | $+449 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-17.24** di equity (24 letture)
|
||||
- realizzato: +0.00 netto su 0 round-trip chiusi · 1 fill · fee 0.0027
|
||||
- cumulato dall'arming: **$+3,856.80** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+17.83** · TWR **+6.76%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+698 (TP01 +0.419, SKH01 flat) e ETH $+449 (TP01 +0.269, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.26x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.74x.
|
||||
- `[pnl]` Equity $-17.24 con 1 fill e 0 round-trip chiusi (realizzato $+0.00 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.07%** sotto il picco ($4,502.93). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 40.2 contro realizzata 30g 47.7 (sotto); DVOL al 43° pctl di un anno; IV-rank espandente 0.15 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 53.4 contro realizzata 30g 70.5 (sotto); DVOL al 30° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **79 giorni, 42 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-11T00:38:42+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
La giornata prolunga una sequenza iniziata prima. Dal picco di $4,502.93 del 2026-09-06 ogni giornata ha chiuso in perdita, e i $-17.24 di oggi sono vicini ai $-18.75 del 2026-09-07. Il confronto più istruttivo però è un altro. Il trading cumulato dall'arming, $+17.83, ha ormai la stessa taglia della perdita di una sola giornata come questa. È la forma concreta dell'avvertenza sul campione di 79 giorni.
|
||||
|
||||
Dal salto di equity del 2026-09-04 le posizioni in dollari sono rimaste quasi ferme: BTC fra $+717 e $+698, ETH fra $+458 e $+449. Nello stesso periodo BTC ha segnato -5.83% ed ETH -2.81% a 7 giorni, con i 30 giorni ancora a +20.44% e +29.57%. Il libro non ha quindi ridotto l'esposizione nel calo. Il quadro è compatibile con due letture: un ritracciamento dentro un trend che tutte le finestre TSMOM vedono intatto, oppure un cambio di regime che finestre da 30 giorni in su non possono ancora registrare. La pagina non le distingue.
|
||||
|
||||
Non è misurabile qui quanto dei $-17.24 venga dalla marcatura delle due long e quanto da funding o collaterale. Non lo è nemmeno l'asset dell'unico fill, visto che a fine giornata entrambe le gambe erano a target.
|
||||
|
||||
Merita un controllo umano la descrizione del libro nel prompt dell'analista. Indica un capitale molto inferiore all'equity di $4,454.86 e andrebbe aggiornata.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,46 @@
|
||||
# Giornale di bordo — 2026-09-11
|
||||
|
||||
*Scritto 2026-09-12T00:36:53+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $77,220.50 | +0.88% | -3.06% | +21.72% | 47.6% | ↑ ↑ ↑ (3/3 su) | 36.8 (14° pctl 1a) |
|
||||
| **ETH** | $2,516.70 | +3.25% | +2.43% | +34.00% | 70.8% | ↑ ↑ ↑ (3/3 su) | 51.8 (22° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,466.16** · nozionale lordo $1,131 · leva lorda 0.25x · barra dati 2026-09-11 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.410 | flat | $+688 | $+687 | HOLD (a target) |
|
||||
| **ETH** | +0.268 | flat | $+449 | $+444 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+11.30** di equity (24 letture)
|
||||
- realizzato: +21.66 netto su 13 round-trip chiusi · 8 fill · fee 0.4046
|
||||
- cumulato dall'arming: **$+3,868.10** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+29.13** · TWR **+7.03%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+687 (TP01 +0.410, SKH01 flat) e ETH $+444 (TP01 +0.268, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.25x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.75x.
|
||||
- `[pnl]` Equity $+11.30 con 8 fill e 13 round-trip chiusi (realizzato $+21.66 netto).
|
||||
- `[drawdown]` 📌 Equity **-0.83%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 36.8 contro realizzata 30g 47.6 (sotto); DVOL al 14° pctl di un anno; IV-rank espandente 0.05 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 51.8 contro realizzata 30g 70.8 (sotto); DVOL al 22° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **80 giorni, 55 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-12
|
||||
|
||||
*Scritto 2026-09-13T00:37:30+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $77,288.50 | +0.09% | -3.19% | +21.80% | 47.6% | ↑ ↑ ↑ (3/3 su) | 37.0 (15° pctl 1a) |
|
||||
| **ETH** | $2,526.45 | +0.39% | +1.83% | +34.02% | 70.8% | ↑ ↑ ↑ (3/3 su) | 52.6 (26° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,468.59** · nozionale lordo $1,134 · leva lorda 0.25x · barra dati 2026-09-12 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.411 | flat | $+688 | $+688 | HOLD (a target) |
|
||||
| **ETH** | +0.267 | flat | $+447 | $+446 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+2.43** di equity (24 letture)
|
||||
- realizzato: +0.00 netto su 0 round-trip chiusi · 0 fill · fee 0.0000
|
||||
- cumulato dall'arming: **$+3,870.53** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+31.56** · TWR **+7.09%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+688 (TP01 +0.411, SKH01 flat) e ETH $+446 (TP01 +0.267, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.25x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.75x.
|
||||
- `[pnl]` Equity $+2.43 **senza operare**: e' mark-to-market sulle posizioni gia' aperte.
|
||||
- `[drawdown]` 📌 Equity **-0.78%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 37.0 contro realizzata 30g 47.6 (sotto); DVOL al 15° pctl di un anno; IV-rank espandente 0.06 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 52.6 contro realizzata 30g 70.8 (sotto); DVOL al 26° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **81 giorni, 55 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-13T00:38:21+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo è l'assenza di attività: zero fill e zero round-trip, dopo gli 8 fill e i 13 round-trip dell'11/09 — 13 dei 55 round-trip dell'intero campione di 81 giorni stanno in quella sola giornata. Oggi il libro ha solo tenuto: BTC $+688 ed ETH $+446, entrambi a target, e i $+2.43 sono marcatura su posizioni già aperte.
|
||||
|
||||
L'esposizione è tutta TP01: SKH01 è flat su entrambe le gambe, quindi la leva 0.25x contro un tetto di 1.00x descrive un solo sleeve dei due, non un libro deliberatamente piccolo.
|
||||
|
||||
Il −0.78% dal picco di $4,503.52 ammette due letture ugualmente compatibili: le quattro giornate negative dal 07 al 10/09, oppure la semplice marcatura di due long mentre BTC fa −3.19% su sette giorni contro +21.80% su trenta. Nulla di quel calo è stato realizzato — oggi zero fill, e il trading dall'arming resta $+31.56. Quale delle due pesi di più non è misurabile qui.
|
||||
|
||||
Salute piena: 24/24 giri, feed SKH a 0 minuti. Per l'occhio umano: il TWR +7.09% convive con $+3,838.97 di versamenti contro $+31.56 di trading, ed è il secondo numero, non il primo, a dire qualcosa del libro.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,60 @@
|
||||
# Giornale di bordo — 2026-09-13
|
||||
|
||||
*Scritto 2026-09-14T00:37:10+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $76,808.50 | -0.62% | -4.44% | +21.93% | 47.6% | ↑ ↑ ↑ (3/3 su) | 38.9 (34° pctl 1a) |
|
||||
| **ETH** | $2,476.90 | -1.96% | -1.52% | +31.73% | 71.4% | ↑ ↑ ↑ (3/3 su) | 54.3 (39° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,455.99** · nozionale lordo $1,134 · leva lorda 0.25x · barra dati 2026-09-13 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.412 | flat | $+689 | $+691 | HOLD (a target) |
|
||||
| **ETH** | +0.267 | flat | $+446 | $+443 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-12.60** di equity (24 letture)
|
||||
- realizzato: -0.19 netto su 1 round-trip chiusi · 4 fill · fee 0.0101
|
||||
- cumulato dall'arming: **$+3,857.93** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+18.96** · TWR **+6.79%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+691 (TP01 +0.412, SKH01 flat) e ETH $+443 (TP01 +0.267, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.25x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.75x.
|
||||
- `[pnl]` Equity $-12.60 con 4 fill e 1 round-trip chiusi (realizzato $-0.19 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.06%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 38.9 contro realizzata 30g 47.6 (sotto); DVOL al 34° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 54.3 contro realizzata 30g 71.4 (sotto); DVOL al 39° pctl di un anno; IV-rank espandente 0.16 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **82 giorni, 56 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-14T00:38:00+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Quarta giornata di fila senza decisione nuova: TP01 resta pieno su entrambe le gambe (+0.412 BTC, +0.267 ETH), SKH01 flat, posizioni a target, leva 0.25x. Il $-12.60 è quasi tutto marcatura — il realizzato è $-0.19 su 1 round-trip, 4 fill, fee 0.0101 — e ha la stessa forma dei cali del 07-10/09 (-18.75, -6.72, -5.36, -17.24). A $4,455.99 il libro sta appena sopra il minimo del 10/09 ($4,454.86) e -1.06% sotto il picco del 06/09.
|
||||
|
||||
L'insolito sta nell'attività: 13 round-trip l'11/09, zero il 12/09, 1 oggi, a posizione sostanzialmente ferma. Sono ri-taglie attorno al target, non cambi di vista.
|
||||
|
||||
Il contrasto che vale la pena registrare: BTC -4.44% su 7 giorni ed ETH -1.96% nel giorno, ma TSMOM 3/3 su per entrambi e +21.93%/+31.73% a 30 giorni. Il segnale lungo non vede la settimana — per costruzione. Lo stesso dato regge due letture ugualmente compatibili: ritirata dentro un trend intatto, oppure inizio di un giro che il filtro a 30/90/180 giorni vedrà con ritardo. Oggi non si separano.
|
||||
|
||||
Sulla vol, implicita sotto realizzata su entrambi (38.9 vs 47.6; 54.3 vs 71.4) con DVOL al 34°/39° percentile: VRP01 resta chiuso dal gate (0.11 e 0.16 contro 0.30).
|
||||
|
||||
Salute piena, 24/24 giri, feed a 0 min. Nulla da controllare a mano. Con 82 giorni e 56 round-trip, questi cali restano rumore, non evidenza.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-14
|
||||
|
||||
*Scritto 2026-09-15T00:37:38+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $78,192.50 | +1.80% | -1.15% | +24.05% | 47.7% | ↑ ↑ ↑ (3/3 su) | 38.6 (30° pctl 1a) |
|
||||
| **ETH** | $2,516.15 | +1.58% | +1.04% | +33.78% | 71.4% | ↑ ↑ ↑ (3/3 su) | 54.9 (42° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,477.74** · nozionale lordo $1,130 · leva lorda 0.25x · barra dati 2026-09-14 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.410 | flat | $+689 | $+689 | HOLD (a target) |
|
||||
| **ETH** | +0.265 | flat | $+444 | $+441 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+21.75** di equity (24 letture)
|
||||
- realizzato: -0.69 netto su 5 round-trip chiusi · 7 fill · fee 0.0189
|
||||
- cumulato dall'arming: **$+3,879.68** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+40.71** · TWR **+7.31%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+689 (TP01 +0.410, SKH01 flat) e ETH $+441 (TP01 +0.265, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.25x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.75x.
|
||||
- `[pnl]` Equity $+21.75 con 7 fill e 5 round-trip chiusi (realizzato $-0.69 netto).
|
||||
- `[drawdown]` 📌 Equity **-0.57%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 38.6 contro realizzata 30g 47.7 (sotto); DVOL al 30° pctl di un anno; IV-rank espandente 0.10 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 54.9 contro realizzata 30g 71.4 (sotto); DVOL al 42° pctl di un anno; IV-rank espandente 0.17 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **83 giorni, 61 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-15T00:38:16+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il +$21.75 è il delta giornaliero più ampio fra i dieci giorni elencati — il migliore precedente era +$12.27 del 06/09 — e arriva interamente dalla marcatura: il realizzato è **negativo**, $-0.69 su 5 round-trip chiusi con 7 fill e fee 0.0189. Lo stesso dato regge due letture ugualmente compatibili: il libro ha catturato il +1.80% di BTC e il +1.58% di ETH stando long su entrambi, oppure due posizioni ferme sono state semplicemente rivalutate da un mercato che saliva. Con 83 giorni e 61 round-trip non si distingue.
|
||||
|
||||
Il fatto insolito è la direzione del nozionale: dal 04/09 BTC scende da $+717 a $+689 e ETH da $+456 a $+441 mentre i prezzi salgono e TSMOM resta 3/3 su entrambi. Le cause — segnale che si assottiglia, equity che si è ritirata dal picco di $4,503.52 — non sono separabili qui: i segnali dei giorni precedenti non compaiono nella pagina.
|
||||
|
||||
Resta l'incoerenza di fondo con cui convivere: l'equity è a $4,477.74 e il cumulato $+3,879.68, ma il trading da arming vale $+40.71 con TWR +7.31%.
|
||||
|
||||
Meriterebbe uno sguardo perché cinque round-trip chiusi lascino un realizzato negativo in una giornata in salita: se sono ritagli di taglia va bene, se sono uscite no. Salute piena, 24/24 giri, feed a 0 minuti.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-15
|
||||
|
||||
*Scritto 2026-09-16T00:36:40+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $75,607.00 | -3.31% | -3.65% | +20.31% | 49.7% | ↑ ↑ ↑ (3/3 su) | 39.0 (35° pctl 1a) |
|
||||
| **ETH** | $2,397.35 | -4.72% | -3.54% | +27.89% | 74.1% | ↑ ↑ ↑ (3/3 su) | 55.2 (43° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,433.75** · nozionale lordo $568 · leva lorda 0.13x · barra dati 2026-09-15 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.410 | SHORT @ 75,707.5 | $+128 | $+129 | HOLD (a target) |
|
||||
| **ETH** | +0.265 | flat | $+440 | $+439 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-43.99** di equity (24 letture)
|
||||
- realizzato: -22.53 netto su 11 round-trip chiusi · 8 fill · fee 0.2114
|
||||
- cumulato dall'arming: **$+3,835.69** di equity — di cui $+3,838.97 versati/prelevati -> trading **$-3.28** · TWR **+6.25%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+129 (TP01 +0.410, SKH01 short) e ETH $+439 (TP01 +0.265, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.13x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.87x.
|
||||
- `[pnl]` Equity $-43.99 con 8 fill e 11 round-trip chiusi (realizzato $-22.53 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.55%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 39.0 contro realizzata 30g 49.7 (sotto); DVOL al 35° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 55.2 contro realizzata 30g 74.1 (sotto); DVOL al 43° pctl di un anno; IV-rank espandente 0.17 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **84 giorni, 72 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-16T00:37:25+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo non è la perdita, è la forma della posizione: il netto su BTC scende da $+689 a $+129 perché SKH01 è passato SHORT a 75,707.5 contro il long TP01 (+0.410), e la leva lorda cade a 0.13x. Il libro è a target su entrambe le gambe, non tagliato da un cap; ETH resta $+439 con SKH01 flat.
|
||||
|
||||
I $-43.99 di equity sono il giorno peggiore della finestra mostrata — il precedente era $-18.75 il 07/09 — e solo metà è realizzata: $-22.53 netti su 11 round-trip e 8 fill, fee 0.2114. Il resto è marcatura, in una giornata da BTC -3.31% e ETH -4.72% con i TSMOM ancora 3/3 su e il 30g a +20.31% e +27.89%: il libro perde dentro un trend che per le sue regole non è girato. L'implicita resta sotto la realizzata su entrambi (39.0 contro 49.7, 55.2 contro 74.1) e il gate di VRP01 resta chiuso.
|
||||
|
||||
Lettura alternativa altrettanto compatibile: lo short su BTC ha ridotto l'esposizione proprio mentre il prezzo scendeva. Se abbia attenuato o aggravato la giornata non è misurabile qui — la pagina non scompone il P&L per sleeve.
|
||||
|
||||
Merita un occhio umano: il trading dall'arming è ora $-3.28, negativo, mentre il TWR resta +6.25%. A 84 giorni e 72 round-trip è esattamente quanto il campione permette di dire.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,56 @@
|
||||
# Giornale di bordo — 2026-09-16
|
||||
|
||||
*Scritto 2026-09-17T00:38:45+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $76,167.50 | +0.74% | -2.70% | +18.11% | 49.1% | ↑ ↑ ↑ (3/3 su) | 36.2 (11° pctl 1a) |
|
||||
| **ETH** | $2,416.45 | +0.80% | -2.09% | +26.36% | 74.0% | ↑ ↑ ↑ (3/3 su) | 52.8 (27° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,438.59** · nozionale lordo $537 · leva lorda 0.12x · barra dati 2026-09-16 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.399 | SHORT @ 75,707.5 | $+109 | $+107 | HOLD (a target) |
|
||||
| **ETH** | +0.257 | flat | $+427 | $+430 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+4.84** di equity (24 letture)
|
||||
- realizzato: -2.08 netto su 2 round-trip chiusi · 2 fill · fee 0.0123
|
||||
- cumulato dall'arming: **$+3,840.53** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+1.56** · TWR **+6.37%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+107 (TP01 +0.399, SKH01 short) e ETH $+430 (TP01 +0.257, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.12x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.88x.
|
||||
- `[pnl]` Equity $+4.84 con 2 fill e 2 round-trip chiusi (realizzato $-2.08 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.44%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 36.2 contro realizzata 30g 49.1 (sotto); DVOL al 11° pctl di un anno; IV-rank espandente 0.04 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 52.8 contro realizzata 30g 74.0 (sotto); DVOL al 27° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **85 giorni, 74 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-17T00:39:34+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il cambiamento non è di oggi ma del 2026-09-15. Quel giorno la posizione BTC scende da $+689 a $+129, con 8 fill e la variazione peggiore della serie, -43.99. Oggi la posizione vale $+107, con SKH01 SHORT @ 75,707.5 contro un TP01 ancora a +0.399. La lettura più semplice è che lo short del breakout netti quasi tutto il long di trend. Però la pagina mostra i segnali solo per oggi: quando sia entrato lo short, e quanto dei -43.99 venga dal mercato e quanto dall'esecuzione, non è misurabile qui. L'esposizione è ora quasi tutta su ETH ($+430), e la chiusura BTC a $76,167.50 sta sopra l'ingresso dello short.
|
||||
|
||||
Dal 2026-09-06 l'equity è passata da 4,502.93 a 4,438.59. Il libro ha tenuto i long mentre BTC ed ETH segnavano -2.70% e -2.09% a 7 giorni. È compatibile con una pausa dentro un trend che a 30 giorni resta +18.11% e +26.36%, ma anche con il suo esaurimento: i dati non distinguono le due letture. Il trading a $+1.56 accanto al TWR +6.37% non è una contraddizione: il TWR pesa i periodi, i dollari pesano il capitale di ciascun periodo.
|
||||
|
||||
Merita un controllo umano il conteggio dei round-trip. Il 2026-09-11 e il 2026-09-15 supera quello dei fill (13 su 8, 11 su 8). Inoltre la taglia di capitale scritta nelle istruzioni dell'analista non coincide con l'equity della pagina.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-17
|
||||
|
||||
*Scritto 2026-09-18T00:39:56+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $76,380.00 | +0.28% | -0.22% | +18.06% | 49.1% | ↑ ↑ ↑ (3/3 su) | 33.6 (0° pctl 1a) |
|
||||
| **ETH** | $2,446.05 | +1.22% | +0.35% | +27.59% | 74.0% | ↑ ↑ ↑ (3/3 su) | 50.1 (13° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,444.68** · nozionale lordo $537 · leva lorda 0.12x · barra dati 2026-09-17 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.399 | SHORT @ 75,707.5 | $+109 | $+107 | HOLD (a target) |
|
||||
| **ETH** | +0.257 | flat | $+428 | $+430 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+6.09** di equity (24 letture)
|
||||
- realizzato: -0.37 netto su 1 round-trip chiusi · 1 fill · fee 0.0018
|
||||
- cumulato dall'arming: **$+3,846.62** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+7.65** · TWR **+6.52%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+107 (TP01 +0.399, SKH01 short) e ETH $+430 (TP01 +0.257, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.12x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.88x.
|
||||
- `[pnl]` Equity $+6.09 con 1 fill e 1 round-trip chiusi (realizzato $-0.37 netto).
|
||||
- `[drawdown]` 📌 Equity **-1.31%** sotto il picco ($4,503.52). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
|
||||
- `[vol]` BTC: implicita 33.6 contro realizzata 30g 49.1 (sotto); DVOL al 0° pctl di un anno; IV-rank espandente 0.00 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 50.1 contro realizzata 30g 74.0 (sotto); DVOL al 13° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **86 giorni, 75 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-18T00:40:39+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il cambiamento che conta non è di oggi ma del 15/09. Quel giorno la posizione BTC è passata da $+689 a $+129, e oggi è a $+107. ETH invece resta fra $+430 e $+453, come dal 07/09. TP01 BTC è ancora a +0.399 e SKH01 è SHORT @ 75,707.5. Il calo è quindi compatibile con la gamba breakout che compensa quella di trend. Se sia sceso anche TP01 non è misurabile qui, perché i giorni precedenti non riportano i segnali. Con leva lorda a 0.12x, oggi il libro è quasi solo ETH. BTC chiude a $76,380.00, sopra il prezzo d'ingresso dello short, con TSMOM 3/3 su: la gamba SKH01 va contro il trend che TP01 segue.
|
||||
|
||||
Il $+6.09 del giorno non si può dividere qui fra le due gambe e il collaterale. Dal 07/09 l'equity scende da $4,484.18 a $4,444.68. Il solo 15/09 (-43.99) pesa più di tutto il trading dall'arming, che vale $+7.65 contro $+3,838.97 versati: basta un giorno a cambiare il segno del risultato cumulato.
|
||||
|
||||
È insolito il DVOL BTC al 0° pctl di un anno con una RV30 al 49.1%. Due letture sono ugualmente compatibili: o l'implicita è compressa, o la RV30 contiene ancora il +18.06% del mese mentre la settimana è piatta (-0.22%).
|
||||
|
||||
Da controllare: l'11/09 e il 15/09 i round-trip (13 e 11) sono più dei fill (8 e 8). Va chiarito se dipende da come vengono abbinati i fill o da un errore di conteggio.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,60 @@
|
||||
# Giornale di bordo — 2026-09-18
|
||||
|
||||
*Scritto 2026-09-19T00:39:16+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $80,907.00 | +5.93% | +4.77% | +16.70% | 47.4% | ↑ ↑ ↑ (3/3 su) | 35.5 (7° pctl 1a) |
|
||||
| **ETH** | $2,612.25 | +6.79% | +3.80% | +15.98% | 53.9% | ↑ ↑ ↑ (3/3 su) | 49.9 (13° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,526.26** · nozionale lordo $1,444 · leva lorda 0.32x · barra dati 2026-09-18 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.452 | flat | $+768 | $+769 | HOLD (a target) |
|
||||
| **ETH** | +0.401 | flat | $+681 | $+675 | BUY $+6 |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+81.58** di equity (24 letture)
|
||||
- realizzato: -0.70 netto su 8 round-trip chiusi · 12 fill · fee 0.3354
|
||||
- cumulato dall'arming: **$+3,928.20** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+89.23** · TWR **+8.47%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+769 (TP01 +0.452, SKH01 flat) e ETH $+675 (TP01 +0.401, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.32x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.68x.
|
||||
- `[pnl]` Equity $+81.58 con 12 fill e 8 round-trip chiusi (realizzato $-0.70 netto).
|
||||
- `[concentrazione]` 📌 **Il 78% di tutto il P&L cumulato ($+89.23) viene dagli ultimi 7 giorni.** Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
|
||||
- `[vol]` BTC: implicita 35.5 contro realizzata 30g 47.4 (sotto); DVOL al 7° pctl di un anno; IV-rank espandente 0.02 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 49.9 contro realizzata 30g 53.9 (sotto); DVOL al 13° pctl di un anno; IV-rank espandente 0.11 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evento]` 📌 BTC: giornata a +5.93%, **2.4 deviazioni** giornaliere (sd implicita dalla RV30 = 2.48%).
|
||||
- `[evento]` 📌 ETH: giornata a +6.79%, **2.4 deviazioni** giornaliere (sd implicita dalla RV30 = 2.82%).
|
||||
- `[evidenza]` 📌 Campione a oggi: **87 giorni, 83 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-19T00:40:10+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il cambiamento della giornata sta nelle posizioni. Dal 15 al 17 settembre BTC era sceso a $+129 e poi a $+107, mentre ETH restava intorno a $+430. Oggi entrambe le gambe sono tornate a taglia piena, BTC $+769 ed ETH $+675, con SKH01 flat su tutti e due. La pagina non dice perché BTC fosse stato ridotto, né se il ritorno sia avvenuto prima o dopo il rialzo. Se la riduzione del 15 sia costata o abbia protetto non è misurabile qui.
|
||||
|
||||
I $+81.58 del giorno vengono dalla marcatura delle posizioni aperte, non dalle chiusure: gli 8 round-trip hanno realizzato $-0.70 netti. Da soli pesano quasi quanto l'intero trading dall'arming, $+89.23. La concentrazione segnalata è quindi, in sostanza, quella di un solo giorno. Esiste una lettura altrettanto compatibile: un libro lungo su entrambi gli asset ha semplicemente incassato una giornata da 2.4 deviazioni, e questo non dice nulla sul segnale.
|
||||
|
||||
È insolito anche il contesto di volatilità. Il DVOL sta al 7° e al 13° percentile di un anno, sotto la realizzata, proprio nel giorno di un movimento ampio. Se l'implicita abbia reagito non si vede, perché la pagina non riporta i DVOL dei giorni precedenti.
|
||||
|
||||
Da controllare: il capitale indicato nelle istruzioni dell'analista non corrisponde all'equity di $4,526.26 di questa pagina.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-19
|
||||
|
||||
*Scritto 2026-09-20T00:37:23+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $81,279.50 | +0.46% | +5.16% | +11.33% | 44.3% | ↑ ↑ ↑ (3/3 su) | 36.3 (13° pctl 1a) |
|
||||
| **ETH** | $2,632.95 | +0.79% | +4.22% | +13.16% | 53.0% | ↑ ↑ ↑ (3/3 su) | 50.8 (17° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,536.47** · nozionale lordo $1,385 · leva lorda 0.31x · barra dati 2026-09-19 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.443 | flat | $+754 | $+756 | HOLD (a target) |
|
||||
| **ETH** | +0.372 | flat | $+632 | $+629 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$+10.21** di equity (24 letture)
|
||||
- realizzato: +1.00 netto su 5 round-trip chiusi · 5 fill · fee 0.0316
|
||||
- cumulato dall'arming: **$+3,938.41** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+99.44** · TWR **+8.72%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+756 (TP01 +0.443, SKH01 flat) e ETH $+629 (TP01 +0.372, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.31x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.69x.
|
||||
- `[pnl]` Equity $+10.21 con 5 fill e 5 round-trip chiusi (realizzato $+1.00 netto).
|
||||
- `[concentrazione]` 📌 **Il 70% di tutto il P&L cumulato ($+99.44) viene dagli ultimi 7 giorni.** Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
|
||||
- `[vol]` BTC: implicita 36.3 contro realizzata 30g 44.3 (sotto); DVOL al 13° pctl di un anno; IV-rank espandente 0.04 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 50.8 contro realizzata 30g 53.0 (sotto); DVOL al 17° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **88 giorni, 88 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-20T00:37:51+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Giornata di consolidamento dopo lo strappo del 18/09: il libro resta a target su entrambe le gambe (BTC $+756 contro $+754, ETH $+629 contro $+632) e i 5 fill valgono $+1.00 realizzato netto con fee 0.0316 — ritagli, non decisioni. Il $+10.21 di equity è quasi tutto marcatura delle due long.
|
||||
|
||||
Quel che le regole non vedono è la forma della settimana. Dal 15 al 17 settembre la gamba BTC era ridotta a $+129 e poi $+107: il libro era sostanzialmente fuori dal rischio direzionale sull'asset principale. Il rientro pieno è del 18, lo stesso giorno del $+81.58. Da qui la concentrazione segnalata dalla regola `[concentrazione]` ha due letture ugualmente compatibili: TP01 ha ripreso un trend che ora conferma 3/3 su entrambi gli asset, oppure il rientro ha semplicemente coinciso con un rimbalzo e il merito è del calendario. Con 88 giorni e 88 round-trip non è distinguibile qui.
|
||||
|
||||
L'elemento insolito è la volatilità: implicita sotto la realizzata su entrambi (36.3 contro 44.3, 50.8 contro 53.0) con DVOL al 13° e 17° percentile dell'anno. Il gate di VRP01 resta chiuso e nessuna azione ne discende, ma la combinazione merita un'occhiata umana se persiste.
|
||||
|
||||
Salute piena: 24/24 giri, feed a 0 min.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Giornale di bordo — 2026-09-20
|
||||
|
||||
*Scritto 2026-09-21T00:39:45+00:00. Numeri misurati; nessuna interpretazione automatica.*
|
||||
|
||||
## Mercato
|
||||
|
||||
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **BTC** | $81,197.50 | -0.10% | +5.71% | +3.63% | 37.1% | ↑ ↑ ↑ (3/3 su) | 35.6 (8° pctl 1a) |
|
||||
| **ETH** | $2,645.45 | +0.47% | +6.80% | +5.10% | 45.7% | ↑ ↑ ↑ (3/3 su) | 51.5 (21° pctl 1a) |
|
||||
|
||||
## Libro
|
||||
|
||||
Equity **$4,536.18** · nozionale lordo $1,648 · leva lorda 0.36x · barra dati 2026-09-20 · 24 giri
|
||||
|
||||
| | TP01 | SKH01 | target | posizione | azione |
|
||||
|---|---|---|---|---|---|
|
||||
| **BTC** | +0.533 | flat | $+906 | $+909 | HOLD (a target) |
|
||||
| **ETH** | +0.435 | flat | $+739 | $+739 | HOLD (a target) |
|
||||
|
||||
## P&L
|
||||
|
||||
- giorno: **$-0.29** di equity (24 letture)
|
||||
- realizzato: +0.10 netto su 1 round-trip chiusi · 5 fill · fee 0.0985
|
||||
- cumulato dall'arming: **$+3,938.12** di equity — di cui $+3,838.97 versati/prelevati -> trading **$+99.15** · TWR **+8.71%**
|
||||
|
||||
## Salute
|
||||
|
||||
- giri di `book_execute`: 24/24
|
||||
- eta' feed SKH all'ultimo giro: 0 min
|
||||
|
||||
## Lettura
|
||||
|
||||
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
|
||||
|
||||
- `[stato]` A mercato su BTC $+909 (TP01 +0.533, SKH01 flat) e ETH $+739 (TP01 +0.435, SKH01 flat).
|
||||
- `[leva]` Leva lorda **0.36x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.64x.
|
||||
- `[pnl]` Equity $-0.29 con 5 fill e 1 round-trip chiusi (realizzato $+0.10 netto).
|
||||
- `[concentrazione]` 📌 **Il 69% di tutto il P&L cumulato ($+99.15) viene dagli ultimi 7 giorni.** Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
|
||||
- `[vol]` BTC: implicita 35.6 contro realizzata 30g 37.1 (sotto); DVOL al 8° pctl di un anno; IV-rank espandente 0.03 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[vol]` ETH: implicita 51.5 contro realizzata 30g 45.7 (sopra); DVOL al 21° pctl di un anno; IV-rank espandente 0.13 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
|
||||
- `[evidenza]` 📌 Campione a oggi: **89 giorni, 89 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
|
||||
|
||||
## Analisi (agente)
|
||||
|
||||
*Scritta da `claude-opus-5` il 2026-09-21T00:40:36+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
|
||||
|
||||
Il fatto nuovo non e' il P&L — $-0.29 su 24 letture — ma l'esposizione: BTC $+909 ed ETH $+739 sono i valori piu' alti dei dieci giorni elencati, e arrivano con l'equity ferma (ieri $4,536.47). Non e' capitale nuovo: e' TP01 che spinge (+0.533 e +0.435) dopo aver quasi lasciato BTC fra il 15 e il 17/09 ($+129, poi $+107). Il libro ha ricomprato quel rischio tre giorni dopo, e resta a 0.36x contro un tetto di 1.00x: il vincolo qui e' il segnale, non il cap.
|
||||
|
||||
Sulla concentrazione la regola dice il fatto, non la causa. Il 69% del cumulato $+99.15 cade nei sette giorni in cui BTC ha fatto +5.71% ed ETH +6.80% con il libro long: «un direzionale pagato da un rally» e' compatibile con questi numeri quanto «l'edge che si vede». A 89 giorni e 89 round-trip le due letture non si separano, e non sono separabili qui.
|
||||
|
||||
I due asset non concordano di segno sulla volatilita': implicita sotto la realizzata su BTC (35.6 contro 37.1), sopra su ETH (51.5 contro 45.7). VRP01 resta comunque fermo per l'IV-rank (0.03 e 0.13 contro 0.30), cioe' per l'espansione, non per il livello.
|
||||
|
||||
Merita un occhio umano il rapporto fra realizzato $+0.10 e fee 0.0985 su 5 fill. Salute pulita: 24/24 giri, feed a 0 min.
|
||||
|
||||
## Nota
|
||||
|
||||
*(vuota — campo dell'operatore)*
|
||||
@@ -69,7 +69,11 @@ chi lo riapre deve battere il motivo, non ripetere l'esperimento.
|
||||
solo LOWVOL 19-major regge standalone (FULL/HOLD 1.07) ma deflated-Sharpe 0.13 + storia 2.5a → **DEBOLE/
|
||||
forward STAT-MODE** (`2026-06-29-xsec-v2-nonmom.md`). (D) **MACRO regime-gate** (equity/credito/oro/tassi →
|
||||
de-risk crypto): **RIDONDANTE col trend** (corr→TP01 0.989; il gate lavora solo nel 2-3% dei giorni, TP01
|
||||
già flat nei crash) → SCARTATO (`2026-06-29-macro-regime-gate.md`).
|
||||
già flat nei crash) → SCARTATO (`2026-06-29-macro-regime-gate.md`). (D-bis, 10/09) **MACRO RISK-OFF ±2h intorno a
|
||||
FOMC/CPI su TP01** (`r0910_macro_riskoff.py`, §78): **REFUTED 0/3** — ΔSh −0,097, drift −1,24%/anno, Δ vero al
|
||||
4,2° pctl del null location-matched: nell'1% delle ore in finestra TP01 fa il 7% del guadagno (+1,05 bp/h
|
||||
contro +0,17). Come il weekend: la coda del trend vive nelle ore «pericolose». Un gate «fondamentale»
|
||||
che rifiuta i segnali intorno ai dati costerebbe quanto il funding, con segno scelto da chi lo propone.
|
||||
|
||||
- **Sweep strategie a 5 thread (2026-06-29) — 0 nuovi sleeve, 1 LEAD che rompe 2 muri su 3.** Ricerca
|
||||
parallela onesta su aree inesplorate (harness `altlib`+`xsec_v2_nonmom`, tutti i gate incl. il nuovo
|
||||
|
||||
@@ -4058,3 +4058,14 @@ Opzioni USDC-lineari su 7 sottostanti (SOL 676, HYPE 442, XRP 442, TRX, AVAX). T
|
||||
negoziabile) e **PAXG perp** (oro, dal 2024-12, $0,18M/g: non misurato, non morto, muri di SOL/XRP).
|
||||
**VERDETTO: `QUATTRO STRUMENTI LIQUIDI, TRE GIA' NEL LIBRO O MISURATI`** — l'universo "piu' monete" di Deribit e' finito.
|
||||
|
||||
## 78 — MACRO-RISKOFF (TP01 flat ±2h intorno a FOMC e CPI: vale qualcosa?)
|
||||
|
||||
`scripts/research/r0910_macro_riskoff.py` (10/09, issue #9, ~3 min). Calendario letto dal web (Fed + BLS,
|
||||
61 FOMC + 88 CPI, 2019-03 → 2026-09), 684 ore in finestra = 1,04% del tempo. Verdetto pre-registrato
|
||||
(ΔSh>0 con fee · ≥95° pctl del null location-matched a 1.000 estrazioni · positivo in ≥70% degli anni).
|
||||
**REFUTED 0/3**: ΔSharpe giornaliero **−0,097**, drift **−1,24%/anno** (≈ −0,14 €/g a $4.455), negativo in
|
||||
6/8 anni, e il Δ vero sta al **4,2° percentile** del null — le ore intorno ai dati macro sono fra le MIGLIORI
|
||||
in cui TP01 e' esposto (+1,05 bp/h contro +0,17, t = 1,57): l'1% del tempo porta il 7% del guadagno. Stessa
|
||||
lezione del weekend (§3): la coda del trend vive nelle ore «pericolose». Il segno opposto NON si apre
|
||||
(ipotesi nuova, sotto l'MDE, selezionata sull'esito). Diario `2026-09-10b-macro-riskoff.md`.
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
> `data/live/scale_history.jsonl`, `tests/test_book_scale.py` (T1-T11, **18 test verdi**).
|
||||
> 🚨 **`config/live.json` NON e' stato toccato**, come prescrive il punto 2: la chiave e' assente,
|
||||
> vale 1,00, e T7 dimostra bit-exact che il libro e' quello di sempre (`max|diff| = 0.0`).
|
||||
> 📌 **(10/09, issue #5) A2 conta dal 2026-09-01**, primo giorno di `scale_watch`: 180 giorni cadono il
|
||||
> **2027-02-28**, ed e' la data «non prima del» del gate in CLAUDE.md §4. Il «2026-10-01» scritto il 01/09 era
|
||||
> il punto 5 (30 giorni di sorvegliante) scambiato per la data del gate: incompatibile con A2 per costruzione.
|
||||
> **Restano i punti 5-7:** ≥30 giorni a 1,00 col sorvegliante attivo, poi `GATE SCALA-01`, poi
|
||||
> `r0726_fee_sensitivity` rifatto. Il punto 5 non e' burocrazia — e' l'unico modo di scoprire che il
|
||||
> sorvegliante e' rotto mentre la leva e' ancora 1,00.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Revisione settimanale — 2026-09-14
|
||||
|
||||
## Revisione NON eseguita
|
||||
|
||||
Il modello `claude-fable-5-1` non ha risposto: **uscita 1: You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue.**. Non c'e' una revisione questa settimana, e questo file esiste perche' un lunedi' senza rapporto si leggerebbe come «tutto bene» (P5).
|
||||
|
||||
---
|
||||
## Firma e materiale
|
||||
|
||||
- revisore: `claude-fable-5-1` · cadenza settimanale · giornale 7 g · diari 7 g · commit 14 g
|
||||
- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file scritto e' questo
|
||||
|
||||
| voce | caratteri | troncata |
|
||||
|---|---|---|
|
||||
| CLAUDE.md (stato, book, numeri, decisioni vincolanti, gate, debiti, regole) | 85,904 | |
|
||||
| config/live.json (unica autorita' dei guardrail live) | 8,439 | |
|
||||
| crontab installata (crontab -l) | 1,753 | |
|
||||
| git log dal 2026-08-31 + stato del working tree | 3,693 | |
|
||||
| monitor_health (i forward-monitor stanno registrando?) | 610 | |
|
||||
| stato scale_watch_state.json | 149 | |
|
||||
| stato scale_history.jsonl (ultime 3) | 259 | |
|
||||
| stato vrp_f_watch/state.json | 453 | |
|
||||
| stato usde_watch.jsonl (ultime 3) | 1,415 | |
|
||||
| stato usde_convert.jsonl (ultime 3) | 9,520 | |
|
||||
| stato balance_watch.jsonl (ultime 2) | 579 | |
|
||||
| stato venue_news.jsonl (ultime 5) | 1,564 | |
|
||||
| stato movimenti_dichiarati.jsonl | 1,101 | |
|
||||
| stato equity_seen.json (watermark) | 73 | |
|
||||
| giornale 2026-09-07 | 3,846 | |
|
||||
| giornale 2026-09-08 | 3,765 | |
|
||||
| giornale 2026-09-09 | 3,705 | |
|
||||
| giornale 2026-09-10 | 3,833 | |
|
||||
| giornale 2026-09-11 | 2,348 | |
|
||||
| giornale 2026-09-12 | 3,611 | |
|
||||
| giornale 2026-09-13 | 3,823 | |
|
||||
| diario 2026-09-09-crolli-opzioni-monete.md | 17,894 | |
|
||||
| diario 2026-09-09b-debito-18-cblib-causale.md | 14,150 | |
|
||||
| diario 2026-09-09c-revisione-settimanale.md | 8,031 | |
|
||||
| diario 2026-09-10-cinque-punti-revisione.md | 15,020 | |
|
||||
| diario 2026-09-10b-macro-riskoff.md | 3,150 | |
|
||||
@@ -0,0 +1,86 @@
|
||||
# Revisione settimanale — 2026-09-16
|
||||
|
||||
*Scritta il 2026-09-16T19:39:30Z dal modello `claude-fable-5-1`, revisore diverso dall'autore del codice, in SOLA LETTURA: nessun file del repo, nessuna config, nessun ordine e' stato toccato. E' l'opinione di un lettore fallibile (P13): ogni numero e ogni proposta vanno verificati alla fonte prima di agire. Guardia sui numeri: **sospetta** — 7 numeri non presenti nel materiale: 1583.06, 1605.12, 1621.12, 5.11, 5.12, 5.16, 5.7.*
|
||||
|
||||
## Sintesi
|
||||
|
||||
- **URGENTE (conferma, non allarme sul saldo).** La crontab installata contiene dal 13/09 una riga nuova: `17 * * * * /opt/AI-OS/automations/TradingBook/scripts/cron_ombra.sh` («giro d'ombra del book TRASLOCATO … NON invia»). Non compare in CLAUDE.md (§1, §12), in nessun commit dal 02/09, ne' in `monitor_health`. Che non invii lo dice solo un commento. L'operatore confermi cosa legge (stesso gateway e stesso token del debito §5.11?), dove scrive, e che non tocchi `data/live` di questo progetto (P16).
|
||||
- **Un sorvegliante morto 13 notti senza che nessuno lo vedesse**: commit `a6a49d9` (16/09) «cc01_regime_watch … moriva ogni notte dal 03/09 su from src.live.cli». Non e' nella tabella di `monitor_health`; e' un effetto collaterale della riparazione del debito §5.16 (02/09). P5: «non vedo» letto come «va bene».
|
||||
- **Libro**: settimana di marcatura negativa; trading da arming **$-3.28** al 15/09 (giornale 2026-09-15), TWR +6.25%; SKH01 SHORT su BTC @ 75,707.5 dal 15/09, leva 0.13x. Esecuzione 24/24 ogni giorno, feed SKH 0 min. Nessun gate scade prima del **27/09 (STATARB)**.
|
||||
- Il giornale **2026-09-11 non ha la sezione «Analisi (agente)»**: modello muto o errore, non determinabile dal materiale.
|
||||
- Ripetute dal 09/09 e ancora aperte: contraddizione sull'haircut (M28); sorveglianti senza ora dell'ultimo giro.
|
||||
|
||||
## Cosa non torna
|
||||
|
||||
1. **Riga di cron non registrata** (crontab, commento datato 13/09/2026). Il progetto ha una regola scritta per questo caso: `tests/test_book_cadenza.py` legge la crontab «tutte le righe attive» ma cerca solo `cron_book.sh` (CLAUDE.md §5.7); un secondo processo orario sullo stesso conto e' fuori da ogni guardia. Il git log dal 02/09 non contiene un commit che lo descriva.
|
||||
2. **`cc01_regime_watch`** (commit `a6a49d9`): 13 notti di traceback dal 03/09 al 16/09. `monitor_health` elenca 9 monitor e non questo. `tests/test_cli_flag.py` verifica che `valida` sia la prima istruzione (§5.16), non che l'import riesca lanciando lo script come fa il cron. Non determinabile se altri job di `cron_daily.sh` abbiano lo stesso difetto: lo `.sh` non e' nel materiale.
|
||||
3. **Haircut, due testi che si contraddicono (M28).** CLAUDE.md §1: X:SM dal 31/08, «available_funds … $2.011,09, contro $2.010,90 attesi con haircut 5%» — cioe' l'haircut si applica. Piu' sotto, stesso §1: «haircut 5% … oggi comunque non si applica, vedi sopra». E `config/live.json` `_nota_usde` dice ancora «il modello di margine ATTIVO e' Segregated: Standard Margin (S:SM) … l'haircut oggi non si applica affatto», scritto prima del passaggio a cross. Segnalato il 09/09 (diario 2026-09-09c §5), non fra i cinque punti del 10/09.
|
||||
4. **Giornale 2026-09-11: 13 round-trip chiusi con 8 fill, fee 0.4046**, posizioni a fine giornata quasi ferme (BTC $+688 → $+687, ETH $+449 → $+444). Il 15/09, con gli stessi 8 fill, la fee e' 0.2114 e il netto BTC e' passato da $+689 a $+129. **Dedotto**: l'11/09 c'e' stato un ciclo intraday che la pagina, fotografia di fine giornata, non mostra. L'analista del 14/09 ha posto la stessa domanda («se sono ritagli di taglia va bene, se sono uscite no») e resta senza risposta.
|
||||
5. **Prompt dell'analista stantio**: l'Analisi del giornale 2026-09-10 scrive che la descrizione del libro nel prompt «indica un capitale molto inferiore all'equity di $4,454.86». Nessun commit successivo lo tocca; non determinabile se corretto.
|
||||
6. **Sorveglianti senza prova di vita.** `scale_watch_state.json` non ha un timestamp e `scale_history.jsonl` ha la sola riga del 01/09: per A2 di SCALA-01 («criterio passato ogni giorno», §4) non c'e' nel materiale un'evidenza giornaliera. `venue_news.jsonl`: ultimo `visto_il` 2026-08-31, tutte righe `seed`: o nessuna notizia in 16 giorni o il lettore e' fermo, non distinguibile. `edge_watch`: ultimo stato citato 27/07 (§4), 51 giorni fa.
|
||||
7. **Working tree**: sei pagine di giornale (10-15/09) e `docs/revisioni/2026-09-14.md` non committate, mentre `79afe41` mostra che i giornali si committano. La revisione del 14/09 non e' nel materiale: non determinabile se le sue segnalazioni siano state trattate.
|
||||
8. **Cuscino USDC in erosione, dato mancante**: `usde_watch` eq_usdc 1621.12 (14/09) → 1605.12 (15/09) → 1583.06 (16/09), quota 0.6377 → 0.6431. `monitor_health` dice `cuscino_watch` OK, ma il suo record (slack, stato) non e' nel materiale. Non e' un allarme: e' un buco di lettura.
|
||||
|
||||
## Cosa e' maturo da decidere
|
||||
|
||||
| gate | data | criterio scritto | stato che il materiale mostra |
|
||||
|---|---|---|---|
|
||||
| **STATARB** | **2026-09-27** (fra 11 giorni) | soglie 24/07 + diagnostica statica sempre-short (§4) | Sharpe **−1,61** al 26/08; nessun valore piu' recente nel materiale; `paper_statarb` OK. Cade fra la revisione del 21/09 e quella del 28/09: **chi lo legge il 27/09 non e' stabilito** |
|
||||
| `edge_watch` | continuo | (A) Sharpe 36m < −0,5 · (B) DD TP01 < 75% del buy&hold | ultimo stato 27/07 (+1,51, 8/8); nulla di piu' recente |
|
||||
| USDE-01, PROP-01 | passati | — | chiusi (§4) |
|
||||
|
||||
Prossimi, fuori dai 30 giorni: XSR01 23/10, DVOLSPREAD kill 24/10. Nessuna condizione della colonna «cosa la riapre» di §3 risulta soddisfatta: `weights_tilt_null` fallisce ancora (26/07, cache al 25/07, diario 2026-09-09 §1); book a $4,433.75 contro la soglia $15k di GTAA01; stato di `fee_watch` assente dal materiale.
|
||||
|
||||
Decisioni dell'operatore: (a) registrare o togliere il giro d'ombra; (b) nominare chi legge STATARB il 27/09.
|
||||
|
||||
## Proposte (nessuna azione eseguita)
|
||||
|
||||
1. **Ogni job lanciato dal cron sotto `monitor_health`** o con un «ultimo esito + ora» leggibile, inclusi quelli interni a `cron_daily.sh` e il giro d'ombra. Verifica preliminare: confrontare l'elenco dei job in `cron_daily.sh` con le 9 righe di `monitor_health`.
|
||||
2. **Un test che lanci ogni script di `scripts/live/` come lo lancia il cron** (processo separato, cwd diverso, `--help`): e' il caso #10 reso strutturale, nello spirito del debito §5.12 («non si chiede a ogni autore di ricordarsene»).
|
||||
3. **Chiudere la contraddizione haircut** in §1 e in `_nota_usde`: un solo testo che dica quale modello e' attivo e se l'haircut si applica.
|
||||
4. **Fill del giorno nella pagina di giornale** (ora, asset, verso, importo): chiesto il 09/09 come «registro fill della settimana»; avrebbe risposto da solo ai punti 4 e all'analista del 14/09.
|
||||
5. **Timestamp dell'ultimo giro** in `scale_watch_state.json` e in `venue_news`: senza, A2 di SCALA-01 non e' dimostrabile alla sua data.
|
||||
6. **Committare** giornali e revisione del 14/09.
|
||||
|
||||
Nessuna idea di strategia: nulla nel materiale batte i motivi di morte di `docs/memory/20-ondate-e-scartati.md`; `r0910_macro_riskoff` (REFUTED 0/3, diario 2026-09-10b) lo conferma per l'ennesimo risk-off.
|
||||
|
||||
## Cosa ho letto e cosa mi mancava
|
||||
|
||||
**Letto**: CLAUDE.md intero; `config/live.json`; crontab; git log dal 02/09 e status; `monitor_health`; stati di scale_watch, scale_history, vrp_f_watch, usde_watch, usde_convert, balance_watch, venue_news, movimenti dichiarati, watermark; giornali 09-15/09; diari 2026-09-09, 09b, 09c, 10, 10b. Questo giro e' di mercoledi' 19:39Z, non del lunedi' 06:15: e' fuori cadenza.
|
||||
|
||||
**Mancava**: `docs/revisioni/2026-09-14.md` e l'esito delle sue segnalazioni · il contenuto di `cron_daily.sh` e di `cron_ombra.sh` · il record di `cuscino_watch` (slack e stato) · stati con ora di `edge_watch`, `fee_watch`, `venue_watch`, `scale_watch` · l'output di `usde_watch.rendimento()` col suo n · il valore a oggi delle serie paper (STATARB in particolare, a 11 giorni dal gate) · i fill della settimana · l'esito della suite dopo `a6a49d9` · il testo corrente del prompt dell'analista.
|
||||
|
||||
---
|
||||
## Firma e materiale
|
||||
|
||||
- revisore: `claude-fable-5-1` · cadenza settimanale · giornale 7 g · diari 7 g · commit 14 g
|
||||
- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file scritto e' questo
|
||||
|
||||
| voce | caratteri | troncata |
|
||||
|---|---|---|
|
||||
| CLAUDE.md (stato, book, numeri, decisioni vincolanti, gate, debiti, regole) | 85,904 | |
|
||||
| config/live.json (unica autorita' dei guardrail live) | 8,439 | |
|
||||
| crontab installata (crontab -l) | 1,753 | |
|
||||
| git log dal 2026-09-02 + stato del working tree | 1,680 | |
|
||||
| monitor_health (i forward-monitor stanno registrando?) | 610 | |
|
||||
| stato scale_watch_state.json | 149 | |
|
||||
| stato scale_history.jsonl (ultime 3) | 259 | |
|
||||
| stato vrp_f_watch/state.json | 454 | |
|
||||
| stato usde_watch.jsonl (ultime 3) | 1,407 | |
|
||||
| stato usde_convert.jsonl (ultime 3) | 9,520 | |
|
||||
| stato balance_watch.jsonl (ultime 2) | 581 | |
|
||||
| stato venue_news.jsonl (ultime 5) | 1,564 | |
|
||||
| stato movimenti_dichiarati.jsonl | 1,101 | |
|
||||
| stato equity_seen.json (watermark) | 73 | |
|
||||
| giornale 2026-09-09 | 3,705 | |
|
||||
| giornale 2026-09-10 | 3,833 | |
|
||||
| giornale 2026-09-11 | 2,348 | |
|
||||
| giornale 2026-09-12 | 3,611 | |
|
||||
| giornale 2026-09-13 | 3,823 | |
|
||||
| giornale 2026-09-14 | 3,736 | |
|
||||
| giornale 2026-09-15 | 3,731 | |
|
||||
| diario 2026-09-09-crolli-opzioni-monete.md | 17,894 | |
|
||||
| diario 2026-09-09b-debito-18-cblib-causale.md | 14,150 | |
|
||||
| diario 2026-09-09c-revisione-settimanale.md | 8,031 | |
|
||||
| diario 2026-09-10-cinque-punti-revisione.md | 15,020 | |
|
||||
| diario 2026-09-10b-macro-riskoff.md | 3,150 | |
|
||||
@@ -0,0 +1,94 @@
|
||||
# Revisione settimanale — 2026-09-21
|
||||
|
||||
*Scritta il 2026-09-21T06:15:02Z dal modello `claude-fable-5-1`, revisore diverso dall'autore del codice, in SOLA LETTURA: nessun file del repo, nessuna config, nessun ordine e' stato toccato. E' l'opinione di un lettore fallibile (P13): ogni numero e ogni proposta vanno verificati alla fonte prima di agire. Guardia sui numeri: **sospetta** — 9 numeri non presenti nel materiale: 2253, 3.83897, 4.55027, 5.8, 6333, 75.7075, 80.907, 871, 9915.*
|
||||
|
||||
## Sintesi
|
||||
|
||||
- **URGENTE (credenziali, operatore).** L'ombra di TradingBook gira ogni ora con la stessa chiave che manda gli ordini del libro. La garanzia che non invii è solo il codice (CLAUDE.md §1 OMBRA: «la chiave può fare trading»). La chiave di sola lettura può crearla solo l'operatore. Nel materiale non c'è traccia che esista, e la finestra d'ombra dura fino al 30/09.
|
||||
- Il libro è sano sui numeri: 24/24 giri ogni giorno dal 14/09 al 20/09. Equity, trading cumulato e conteggio dei round-trip si riconciliano giorno su giorno, per sottrazione mia, senza scarti. I versati ($3.838,97) coincidono con `movimenti_dichiarati.jsonl` più i $1.399,39 del 25/08.
|
||||
- `cc01_regime_watch` è morto ogni notte dal 03/09 al 16/09 senza che nessuno lo vedesse (commit `a6a49d9`). `edge_watch`, `fee_watch`, `scale_watch`, `venue_watch`, `vrp_f_watch` e `venue_news` non compaiono nella tabella `monitor_health` del materiale. Due di loro reggono una decisione vincolante e il kill del libro.
|
||||
- Il gate STATARB si legge fra 6 giorni, domenica 27/09. Le soglie non sono scritte in §4 e la prossima revisione cade il 28/09, dopo la lettura.
|
||||
- Da due pagine di giornale l'analista segnala due anomalie senza risposta: le Note dell'operatore sono vuote 7/7 e non c'è nessun commit dopo il 17/09.
|
||||
|
||||
## Cosa non torna
|
||||
|
||||
| # | Cosa | Fonte | Tipo |
|
||||
|---|---|---|---|
|
||||
| 1 | **Sorveglianti fuori da `monitor_health`.** Il guasto di cc01 (`from src.live.cli`) parte il giorno dopo la riparazione del debito 16 del 02/09 (`cli.valida` in ogni script), e `test_cli_flag.py` non l'ha visto. La tabella `monitor_health` copre 10 voci, fra cui nessuno dei sorveglianti elencati nella Sintesi. Eppure §3 (fee) dice «`fee_watch` allerta da solo», e in §4 lo stato di `edge_watch` è del **27/07**. | commit `a6a49d9`, `31f5915`; CLAUDE.md §5.16, §3, §4; tabella monitor_health | Misurato: gli assenti dalla tabella. Dedotto: che possano essere morti allo stesso modo. |
|
||||
| 2 | **Round-trip maggiori dei fill.** L'11/09 sono 13 su 8, il 15/09 sono 11 su 8. L'analista lo segnala due volte. Il 20/09 il rapporto si rovescia: 1 round-trip su 5 fill. Può essere l'abbinamento a lotti, ma nessuno l'ha verificato. | giornali 16/09, 17/09, 20/09 | Misurato il conteggio, non determinabile la causa. |
|
||||
| 3 | **Capitale sbagliato nelle istruzioni dell'analista.** «La taglia di capitale scritta nelle istruzioni dell'analista non coincide con l'equity della pagina.» È un valore ridichiarato invece che derivato, cioè la forma di P1. | giornali 16/09 e 18/09; CLAUDE.md §6 P1 | Misurato dall'analista, non da me. |
|
||||
| 4 | **Il P&L di SKH01 non è attribuibile.** Il 15/09 SKH01 va SHORT a 75.707,5 e BTC netto scende da $+689 a $+129, con realizzato −22,53. Il 18/09 SKH01 è flat, BTC chiude a $80.907,00 e il realizzato è solo −0,70. Col netting in software, l'uscita dello short è un riacquisto di long e il suo costo non appare in nessun «realizzato». SKH01 è «il meno affidabile dei cinque» (§2) e pesa 25%, ma dal giornale la sua resa live non si misura. | giornali 15-18/09; CLAUDE.md §1, §2 | Dedotto dal meccanismo di netting. |
|
||||
| 5 | **Giornali 17-20/09 non committati.** Dopo `8007158` non c'è nessun commit. Le pagine 10-15/09 erano già entrate in blocco (`e42ab53`). `docs/journal` non è nell'elenco di backup di §7, e l'Analisi del modello non è ricostruibile (P11). | git status; CLAUDE.md §7 | Misurato. |
|
||||
| 6 | **§2 invecchiato sul libro live.** «Esposto 14 giorni su 64, max 0,52x» è scritto al giorno 64. Oggi il campione è di 89 giorni e il libro è a mercato in tutte le pagine lette. La riga TWR (+6,66%, +$13,82, lettura 10/09) sta oggi a +8,71% e +$99,15. Il 69% di questi $99,15 cade negli ultimi 7 giorni: è un evento, non un tasso, e non va promosso a «numero da citare». | CLAUDE.md §2; giornale 20/09 | Misurato. |
|
||||
| 7 | **Contraddizione interna a §1.** Nella parentesi sull'autorità dei parametri si legge «tetto ALLERTA quota 50%», ma nello stesso paragrafo e in config `quota_max_frac` è **0,85** (M28). C'è anche una frase spezzata: «dopo un bonifico si **dal 10/09 lo fa…». | CLAUDE.md §1; config `usde` | Misurato. |
|
||||
| 8 | **Annuncio Deribit non letto.** `venue_news` ha registrato il 16/09 «Deribit Launches Its Biggest-Ever Product Expansion» con `match: []`. Un'espansione di prodotto può toccare margini, strumenti o collaterale senza contenere le parole chiave. Il debito 8 (Rulebook non sorvegliato) resta aperto. | venue_news.jsonl; CLAUDE.md §5.8 | Dedotto. |
|
||||
| 9 | **Revisioni saltate o dubbie.** Quella del 14/09 è «NON eseguita, limite Fable», quella del 16/09 ha «esito sospetta». Il testo non è nel materiale, quindi non posso dire se i loro punti siano stati chiusi. | commit `e42ab53` | Non determinabile. |
|
||||
|
||||
Sul cuscino: la quota USDE è 0,6333 (usde_watch 20/09), sotto il bersaglio 0,64, e `cuscino_watch` risulta OK in monitor_health. Lo slack corrente non è determinabile, perché il registro di `cuscino_watch` non è nel materiale.
|
||||
|
||||
## Cosa e' maturo da decidere
|
||||
|
||||
| Gate o scadenza | Data | Criterio scritto | Stato nel materiale |
|
||||
|---|---|---|---|
|
||||
| **STATARB** | 27/09 (fra 6 giorni) | «soglie 24/07 (non toccate) + diagnostica statica sempre-short» | Il monitor registra (OK). Lo Sharpe più recente che ho è −1,61 del 26/08. Le soglie numeriche **non stanno in §4**. Non anticipo il verdetto. Da decidere prima: chi lo legge domenica, e da quale documento prende le soglie. |
|
||||
| **Finestra OMBRA** | 30/09 | Nessun criterio di esito scritto: §1 cita solo lo strumento di confronto. | Esito dei confronti non nel materiale. Senza un criterio dichiarato prima, il 30/09 si decide guardando l'esito, e §4 lo vieta per i gate. Decide l'operatore. |
|
||||
| XSR01 · kill DVOLSPREAD | 23/10 · 24/10 | §4 | Cadono a 32 e 33 giorni, appena fuori dalla finestra dei 30. Entrambi i monitor sono OK. |
|
||||
| USDE-01, PROP-01 | passati | — | Chiusi (§4). La frase «`quota_da_decidere()` lo chiede ogni giorno da lunedì» è superata dalla quota decisa il 10/09. |
|
||||
| `vrp_f_watch` | — | criteri (a) e (b), §5.18 | pairs 30 (erano 28/40 il 09/09), `esclude_zero` true, `ready` false. Nessuna azione: la decisione §3 sul short-vol non è riaperta (IV-rank 0,03 e 0,13 contro 0,30, giornale 20/09). |
|
||||
|
||||
Le soglie di capitale ($15k GTAA01, $20k) non sono toccate: il watermark è $4.550,27.
|
||||
|
||||
## Proposte (nessuna azione eseguita)
|
||||
|
||||
1. **Operatore:** crei la chiave Deribit di sola lettura per l'ombra (§1) e dichiari entro il 30/09 il criterio con cui la finestra si chiude.
|
||||
2. **Agente:** verifichi che ogni script lanciato da `cron_daily` e dagli altri cron termini davvero, non solo cc01. Valuti anche di portare sotto `monitor_health` i sorveglianti che reggono una decisione (`fee_watch`, `edge_watch`, `scale_watch`), e di aggiornare in §4 lo stato di `edge_watch`.
|
||||
3. **Agente:** riporti in §4 le soglie STATARB del 24/07, copiate verbatim dalla fonte e senza toccarle, prima del 27/09.
|
||||
4. **Agente:** apra una issue per ciascuno dei punti 2 e 3 della tabella «Cosa non torna»: l'abbinamento dei round-trip e il capitale nel prompt dell'analista, che andrebbe derivato.
|
||||
5. **Attribuzione del P&L per sleeve** (registro virtuale TP01/SKH01 accanto al netto). È strumentazione, non una strategia. Prima di proporla va verificato in `docs/memory/40-produzione-e-deploy.md` se è già stata valutata.
|
||||
6. **Operatore o agente:** legga l'annuncio Deribit del 16/09, così da confermare che non tocchi i perp USDC, i margini X:SM o l'USDE.
|
||||
7. **Agente:** committi i giornali 17-20/09, chiarisca se il commit è automatico o no, e corregga §1 (50% contro 0,85, frase spezzata) e le due righe «live» di §2.
|
||||
|
||||
## Cosa ho letto e cosa mi mancava
|
||||
|
||||
**Letto:** CLAUDE.md per intero, `config/live.json`, crontab, git log dal 07/09 e git status, tabella monitor_health, gli stati di scale_watch, vrp_f_watch, usde_watch, usde_convert, balance_watch, venue_news, movimenti_dichiarati e equity_seen, i giornali dal 14/09 al 20/09. Nessun diario della settimana era nel materiale.
|
||||
|
||||
**Mi mancava:**
|
||||
- il testo delle revisioni del 09/09 e del 16/09, per seguire i punti ancora aperti;
|
||||
- gli ultimi record di `cuscino_watch`, `edge_watch`, `fee_watch` e `venue_watch`;
|
||||
- lo Sharpe corrente e il numero di barre dei quattro forward-monitor (STATARB soprattutto);
|
||||
- le soglie STATARB del 24/07;
|
||||
- l'output di `confronta_giornata.py` dell'ombra;
|
||||
- il giornale dell'11/09, citato dall'analista;
|
||||
- l'ora e il prezzo d'uscita dello short SKH01 del 15-18/09;
|
||||
- l'n corrente di `usde_watch.rendimento()`;
|
||||
- l'elenco degli script chiamati da `cron_daily`, con il loro codice d'uscita.
|
||||
|
||||
---
|
||||
## Firma e materiale
|
||||
|
||||
- revisore: `claude-fable-5-1` · cadenza settimanale · giornale 7 g · diari 7 g · commit 14 g
|
||||
- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file scritto e' questo
|
||||
|
||||
| voce | caratteri | troncata |
|
||||
|---|---|---|
|
||||
| CLAUDE.md (stato, book, numeri, decisioni vincolanti, gate, debiti, regole) | 87,916 | |
|
||||
| config/live.json (unica autorita' dei guardrail live) | 8,439 | |
|
||||
| crontab installata (crontab -l) | 1,860 | |
|
||||
| git log dal 2026-09-07 + stato del working tree | 1,775 | |
|
||||
| monitor_health (i forward-monitor stanno registrando?) | 717 | |
|
||||
| stato scale_watch_state.json | 149 | |
|
||||
| stato scale_history.jsonl (ultime 3) | 259 | |
|
||||
| stato vrp_f_watch/state.json | 452 | |
|
||||
| stato usde_watch.jsonl (ultime 3) | 1,399 | |
|
||||
| stato usde_convert.jsonl (ultime 3) | 9,520 | |
|
||||
| stato balance_watch.jsonl (ultime 2) | 583 | |
|
||||
| stato venue_news.jsonl (ultime 5) | 1,571 | |
|
||||
| stato movimenti_dichiarati.jsonl | 1,101 | |
|
||||
| stato equity_seen.json (watermark) | 74 | |
|
||||
| giornale 2026-09-14 | 3,736 | |
|
||||
| giornale 2026-09-15 | 3,731 | |
|
||||
| giornale 2026-09-16 | 3,809 | |
|
||||
| giornale 2026-09-17 | 3,816 | |
|
||||
| giornale 2026-09-18 | 4,055 | |
|
||||
| giornale 2026-09-19 | 3,774 | |
|
||||
| giornale 2026-09-20 | 3,767 | |
|
||||
@@ -35,10 +35,16 @@ mkdir -p logs
|
||||
# partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2
|
||||
# = allarme inviato); l'azione a un allarme e' manuale, vedi src/live/venue_watch.py.
|
||||
uv run python scripts/live/venue_watch.py --quiet || true
|
||||
uv run python scripts/live/book_execute.py --execute
|
||||
# LOCK SUL CONTO (#36): cuscino_watch (cron_cuscino.sh, :53) puo' mandare ordini spot USDE
|
||||
# sullo stesso conto — separati solo dal tempo, giri fino a 659s misurati. `flock -n` (mai
|
||||
# `-w`, un SIGTERM a meta' ordine perde la riga di log): se cuscino_watch tiene il lock questo
|
||||
# giro si SALTA (rc 75) invece di sovrapporsi. Niente kill/timeout sul comando.
|
||||
flock -n -E 75 logs/.conto.lock uv run python scripts/live/book_execute.py --execute; rc=$?
|
||||
[ $rc -eq 75 ] && echo " book: SALTATO — conto occupato da un altro giro (logs/.conto.lock)"
|
||||
# Libro di bordo: materializza in data/live/trades.db (che il backup COPRE) l'ora vera dei
|
||||
# fill, che altrimenti vive solo in questo log — gitignored, fuori dal backup e ruotabile.
|
||||
# Va DOPO book_execute: dentro lo stesso blocco vede le righe appena scritte.
|
||||
# Va DOPO book_execute: dentro lo stesso blocco vede le righe appena scritte. Fuori dal lock:
|
||||
# e' sola lettura, non tocca il conto.
|
||||
uv run python scripts/live/trades_db.py --sync --quiet || true
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_book.log 2>&1
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Sorveglianza ORARIA del cuscino USDC di regolamento (debito §5.17, issue #3) — v2.0.0+.
|
||||
# Minuto :53 = DOPO il giro del book (:47, i cui fill si regolano in USDC) e fuori dal minuto
|
||||
# tondo (rate-limit per-IP), dal :25 (catena), dal :35 (usde) e dal :42 (balance).
|
||||
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
|
||||
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
|
||||
# stesso del libro (execution_enabled in config/live.json).
|
||||
# LOCK SUL CONTO (#36): cuscino_watch e book_execute (cron_book.sh, :47) sono separati solo dal
|
||||
# tempo e possono mandare ordini sullo stesso conto — giri fino a 659s sono stati misurati.
|
||||
# `flock -n` (mai `-w`: un'attesa farebbe scivolare il giro orario) su logs/.conto.lock: se
|
||||
# book_execute lo tiene, questo giro si SALTA (rc 75) invece di sovrapporsi.
|
||||
export PATH="/home/adriano/.local/bin:$PATH"
|
||||
cd /opt/docker/PythagorasGoal || exit 1
|
||||
mkdir -p logs
|
||||
{
|
||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
|
||||
flock -n -E 75 logs/.conto.lock uv run python scripts/live/cuscino_watch.py --quiet; rc=$?
|
||||
[ $rc -eq 75 ] && echo " cuscino: SALTATO — conto occupato (logs/.conto.lock)"
|
||||
# (#30) giro orario dei sorveglianti a max_age<=6h: notifica solo il NUOVO, marcatore in
|
||||
# data/live/monitor_health_detto.json. Fuori dal lock: e' sola lettura, non tocca il conto.
|
||||
uv run python scripts/live/monitor_health.py --quiet --solo-orari || true
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_cuscino.log 2>&1
|
||||
@@ -73,7 +73,8 @@ def invia(con, g: date, voce: dict, analisi: str, stato: str, motivi: list[str],
|
||||
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True,
|
||||
telegram: bool = True) -> str:
|
||||
voce = J.costruisci(con, g)
|
||||
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
|
||||
if not secco: # --secco: stampa e NON salva (#34) — niente scrittura
|
||||
J.salva(con, voce) # le sezioni misurate prima, cosi' il modello le legge
|
||||
pagina = J.rendi_markdown(con, voce)
|
||||
st = storico(con, g)
|
||||
prompt = A.costruisci_prompt(pagina, st)
|
||||
|
||||
+110
-28
@@ -46,7 +46,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.book import ScalaNonAutorizzata, book_report
|
||||
from src.live.execution import DeribitTrader
|
||||
from src.live.execution import DeribitTrader, RebalanceParziale
|
||||
from src.live.notifier import notify
|
||||
from src.live.venue_probe import diagnose, errori_dal_report
|
||||
|
||||
@@ -85,6 +85,33 @@ def log_event(rec: dict):
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def _registra_fill(f, *, asset: str, act: str, net: float, bar_ts, tp_frac: float, skh_sign: int,
|
||||
pos_after: float | None = None):
|
||||
"""Registra UN fill (print + log_event + notify). SUBITO dopo l'ordine, PRIMA di leggere la
|
||||
posizione risultante: un ordine eseguito non deve dipendere dalla lettura della posizione
|
||||
successiva per essere salvato (#23 — un 502 su `position_usd()` dopo un fill riuscito lo
|
||||
faceva sparire dal log, perche' la lettura veniva PRIMA del giro `for f in fills`).
|
||||
|
||||
`pos_after` arriva dal chiamante (#39 — regressione di #23): nel ramo normale di `_run` si
|
||||
legge SUBITO dopo il rebalance, in un try che isola la lettura dalla registrazione dei fill;
|
||||
resta None quando la lettura solleva o quando il passo e' un `RebalanceParziale` (a meta')."""
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(bar_ts)), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=pos_after,
|
||||
tp_frac=tp_frac, skh_sign=skh_sign))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
|
||||
|
||||
def _run():
|
||||
cfg = load_config()
|
||||
want_execute = "--execute" in sys.argv[1:]
|
||||
@@ -129,8 +156,10 @@ def _run():
|
||||
print(f" ultima barra : {r['last_data']}\n")
|
||||
|
||||
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}")
|
||||
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
|
||||
# (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
|
||||
# quel segnale ignoto (modo SOLO PROTEZIONE, sotto, che porta anche questo errore su
|
||||
# Telegram — qui solo il print, per non mandare due notify per lo stesso guasto).
|
||||
print(f" ⚠️ SKH FEED ERRORE (SKH non calcolato: nessun ordine, solo protezione): {r['skh_error']}")
|
||||
|
||||
# --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) --------------------------
|
||||
# `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e
|
||||
@@ -220,10 +249,27 @@ def _run():
|
||||
"azione": "rebuild_history.py --asset BTC ETH"})
|
||||
return
|
||||
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
|
||||
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
|
||||
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
|
||||
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
|
||||
if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto
|
||||
# (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche
|
||||
# questo errore su Telegram — un solo avviso per lo stesso guasto, non due.
|
||||
print(f" ⚠️ EQUITY FALLBACK (nessun ordine, solo protezione): {r['eq_fallback']}")
|
||||
|
||||
# --- SOLO PROTEZIONE (#21 equity ignota, #22 SKH ignoto) ------------------------------------
|
||||
# Principio unico: quando il giro non sa QUANTO vale il conto o QUALE e' il segnale, NON manda
|
||||
# ordini di ribilancio (dimensionare un ordine su un numero che non si conosce e' peggio che
|
||||
# non ordinare) -> ma CONTROLLA LO STESSO il disaster-SL di ogni asset: la protezione dipende
|
||||
# da posizione e mark, non da equity o segnale. Stesso principio che pos_error applica gia'
|
||||
# bloccando l'esecuzione, qui senza saltare la protezione.
|
||||
solo_protezione: str | None = r.get("eq_fallback") or r.get("skh_error")
|
||||
# Calcolato una volta sola: serve sia nell'azione per-asset del ciclo sotto sia nel
|
||||
# riepilogo finale (#21, #22) — un solo motivo, non due troncamenti che potrebbero divergere.
|
||||
motivo_breve = (solo_protezione if solo_protezione and len(solo_protezione) <= 60
|
||||
else (solo_protezione[:60] + "…" if solo_protezione else None))
|
||||
if solo_protezione and do_execute:
|
||||
titolo = ("⚠️ BOOK LIVE — equity non leggibile: nessun ordine, solo protezione"
|
||||
if r.get("eq_fallback") else
|
||||
"⚠️ BOOK LIVE — SKH non calcolato: nessun ordine, solo protezione")
|
||||
notify(titolo, {"nota": solo_protezione})
|
||||
|
||||
# Salto di equity: un versamento (conferma che il sizing lo ha seguito) oppure un movimento
|
||||
# NON richiesto (prelievo, perdita anomala). In entrambi i casi va detto subito, e con il
|
||||
@@ -250,13 +296,18 @@ def _run():
|
||||
# non deve togliere la rete di sicurezza all'altro.
|
||||
falliti: list[str] = []
|
||||
scoperti: list[str] = []
|
||||
non_verificati: list[str] = []
|
||||
for a in r["assets"]:
|
||||
asset, inst = a["asset"], a["instrument"]
|
||||
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
||||
sk = a["skh_state"]
|
||||
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||
order = a["order"]
|
||||
if order is None:
|
||||
if solo_protezione:
|
||||
# (#21, #22) nessun rebalance: l'azione stampata lo dice, non e' "a target" — e'
|
||||
# "non tocco perche' non so quanto vale il conto / qual e' il segnale".
|
||||
act = f"HOLD (solo protezione: {motivo_breve})"
|
||||
elif order is None:
|
||||
act = "HOLD (a target)"
|
||||
elif order.get("is_close"):
|
||||
act = f"CLOSE ${cur:,.0f}"
|
||||
@@ -268,26 +319,37 @@ def _run():
|
||||
f"| pos ${cur:+,.0f} -> {act}")
|
||||
|
||||
try:
|
||||
if do_execute and order is not None:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
newpos = trader.position_usd(inst)
|
||||
if do_execute and order is not None and not solo_protezione:
|
||||
# (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala
|
||||
# o mark sono <=0 solleva ValueError: e' giusto, finisce nell'isolamento per asset
|
||||
# (except sotto), non deve fermare il giro intero.
|
||||
trader.set_open_cap(inst, r["cap_per_asset"] * r.get("scala", 1.0), mark)
|
||||
try:
|
||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
||||
except RebalanceParziale as rp:
|
||||
# (#23) almeno un fill e' passato prima che il resto del passo sollevasse
|
||||
# (es. flip: close ok, open KO) -> si registra quel fill SUBITO (non e' un
|
||||
# fill perso), poi si rilancia la causa nell'handler di isolamento esistente.
|
||||
for f in rp.fills:
|
||||
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"])
|
||||
raise rp.causa
|
||||
# newpos si legge SUBITO dopo il rebalance, in un try che ISOLA la lettura dalla
|
||||
# registrazione dei fill (#39, regressione di #23: dal merge di #23 pos_after era
|
||||
# sempre None, perche' la lettura restava un side-effect informativo dopo il ciclo
|
||||
# e il suo risultato non arrivava piu' a `_registra_fill`). Se solleva, i fill si
|
||||
# registrano comunque, con pos_after=None — il vincolo #23 (un fill non si perde
|
||||
# per un reconcile mancato) resta: il try isola solo la LETTURA, non la scrittura.
|
||||
try:
|
||||
newpos = trader.position_usd(inst)
|
||||
pos_after = newpos
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
except Exception as e: # noqa: BLE001 — non e' un fill perso, e' un reconcile mancato
|
||||
pos_after = None
|
||||
print(f" reconcile: pos illeggibile ({type(e).__name__})")
|
||||
for f in fills:
|
||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
||||
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
|
||||
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
|
||||
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
|
||||
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
|
||||
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
|
||||
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
|
||||
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
|
||||
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
|
||||
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
|
||||
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
|
||||
det if f.verified else {**det, "notes": f.notes})
|
||||
print(f" reconcile: pos ${newpos:,.0f}")
|
||||
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"], pos_after=pos_after)
|
||||
if do_execute:
|
||||
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||
stato = ds.get("state")
|
||||
@@ -308,6 +370,15 @@ def _run():
|
||||
"amount": round(ds.get("amount") or 0, 4),
|
||||
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
|
||||
"notes": str(ds.get("notes"))[:300]})
|
||||
elif stato == "unknown":
|
||||
# (#24) posizione o open_orders illeggibili: NON e' "naked" (non si e' tolto
|
||||
# nulla, ensure_disaster_sl non cancella ne' piazza su dati a meta'). E' un
|
||||
# giro degradato lo stesso: non si sa se la protezione esistente e' ancora
|
||||
# coerente con la posizione attuale.
|
||||
non_verificati.append(asset)
|
||||
print(f" ⚠️ {asset}: protezione NON VERIFICATA — {ds.get('notes')}")
|
||||
notify("⚠️ BOOK — disaster-SL non verificabile (lettura posizione/ordini fallita)",
|
||||
{"asset": asset, "notes": str(ds.get("notes"))[:300]})
|
||||
except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare
|
||||
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
|
||||
falliti.append(asset)
|
||||
@@ -327,16 +398,27 @@ def _run():
|
||||
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
|
||||
if falliti:
|
||||
print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}")
|
||||
if non_verificati:
|
||||
print(f" ⚠️ disaster-SL NON VERIFICATO (lettura posizione/ordini fallita): {', '.join(non_verificati)}")
|
||||
if not do_execute:
|
||||
print(" => DRY-RUN: nessun ordine inviato." +
|
||||
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
||||
elif solo_protezione:
|
||||
# (#21, #22) non e' "Nessuna azione: conto gia' al target" — il conto potrebbe NON esserci
|
||||
# per niente al target, semplicemente non lo si sa. Distinto dal ramo HOLD sotto.
|
||||
print(f" => SOLO PROTEZIONE: nessun ordine di ribilancio ({motivo_breve}), disaster-SL controllato.")
|
||||
elif all(x.startswith("HOLD") for x in actions):
|
||||
print(" => Nessuna azione: conto gia' al target netto del book.")
|
||||
else:
|
||||
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
||||
# Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza
|
||||
# rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`).
|
||||
if scoperti or falliti:
|
||||
# (#21, #22) SOLO PROTEZIONE e' degradato anche senza asset falliti/scoperti: il giro non ha
|
||||
# ribilanciato nulla perche' non sapeva quanto vale il conto o qual era il segnale. Solo se
|
||||
# il giro era ARMATO: in DRY-RUN non si e' comunque tentato nulla, e' solo un'anteprima.
|
||||
# (#24) `non_verificati` e' degradato allo stesso modo: la protezione esistente non e' stata
|
||||
# controllata, indipendentemente da solo_protezione.
|
||||
if scoperti or falliti or non_verificati or (solo_protezione and do_execute):
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
|
||||
@@ -13,16 +13,29 @@ Hyperliquid (tokenless, stessa fonte certificata di fetch_hl_funding.py) e stamp
|
||||
che resta comunque vincolato a ~$20k+ di capitale e venue con funding eseguibile).
|
||||
|
||||
Uso: `uv run python scripts/live/cc01_regime_watch.py`
|
||||
(candidato a una riga in cron_daily.sh: e' read-only, nessun ordine, nessuno stato.)
|
||||
(in cron_daily.sh: read-only verso il mercato, nessun ordine.)
|
||||
|
||||
REGISTRA OGNI GIRO MISURATO (dal 2026-09-16, #12) in `data/cc01_regime/runs.jsonl`: `ts` in ms,
|
||||
stato, funding 30g/7g per asset. Serve a `monitor_health`, che ne legge il percorso da `RUNS`
|
||||
(P1). Dal 03/09 al 16/09 lo script e' morto 14 notti di fila (#10) e nessuno l'ha visto, perche'
|
||||
non lasciava niente da sorvegliare. Un giro che non misura entrambi gli asset NON scrive: non e'
|
||||
una barra, e se lo fosse un cc01 cieco resterebbe «OK».
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
RUNS = ROOT / "data" / "cc01_regime" / "runs.jsonl"
|
||||
HL_INFO = "https://api.hyperliquid.xyz/info"
|
||||
HOURS_PER_YEAR = 24 * 365
|
||||
WARN_ANN = 0.10 # >=10% ann. su media 30g -> WARN
|
||||
@@ -70,6 +83,7 @@ def main() -> None:
|
||||
print(" CC01 REGIME WATCH — funding medio 1-mese (Hyperliquid, orario, tokenless)")
|
||||
print("=" * 78)
|
||||
worst = "QUIET"
|
||||
misure = {}
|
||||
for coin in ("BTC", "ETH"):
|
||||
f = fetch_recent_funding(coin)
|
||||
if len(f) < 24 * 7:
|
||||
@@ -81,8 +95,16 @@ def main() -> None:
|
||||
"WARN" if ann30 >= WARN_ANN else "QUIET")
|
||||
if state == "ALERT" or (state == "WARN" and worst == "QUIET"):
|
||||
worst = state
|
||||
misure[coin] = {"ann30": ann30, "ann7": ann7, "stato": state}
|
||||
print(f" {coin}: media 30g {ann30:+7.1%} ann. media 7g {ann7:+7.1%} ann. -> {state}")
|
||||
print("-" * 78)
|
||||
if len(misure) < 2:
|
||||
# P5: «non vedo» non e' «QUIET». Nessuna riga: monitor_health lo vedra' FERMO.
|
||||
print(f" NON VEDO: misurati {len(misure)} asset su 2 — nessuna riga registrata in {RUNS.name}.")
|
||||
return
|
||||
RUNS.parent.mkdir(parents=True, exist_ok=True)
|
||||
with RUNS.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"ts": int(time.time() * 1000), "stato": worst, **misure}) + "\n")
|
||||
if worst == "ALERT":
|
||||
print(" >>> ALERT CC01: funding 30g >= 15% ann. — regime ricco SOSTENUTO.")
|
||||
print(" Riaprire il dossier cash-and-carry (vincoli invariati: ~$20k+,")
|
||||
|
||||
Executable
+378
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python
|
||||
"""cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento. PUO' INVIARE ORDINI.
|
||||
|
||||
PERCHE' ESISTE (debito §5.17, issue #3). Dal 06/09 il 68,7% dell'equity e' USDE. P&L e funding dei
|
||||
perp USDC-lineari si regolano in USDC: una perdita del libro consuma il cuscino e fa salire la quota
|
||||
USDE DA SOLA, senza un ordine. `usde_watch` allerta solo su `quota > quota_max_frac` (0,85): a
|
||||
68,7% lo slack sul cuscino era ~$41-58, e un saldo USDC negativo costa lo 0,05%/GIORNO (18,25%/anno,
|
||||
4,3x la resa che l'USDE compra). Nessuno guardava la grandezza giusta.
|
||||
|
||||
LA MISURA (decisione dell'operatore, 10/09): l'EQUITY USDC del conto — balance piu' P&L non
|
||||
realizzato — perche' e' quella che risponde del regolamento; il balance vedrebbe il buco solo a
|
||||
trade chiuso. Il cuscino richiesto e' DERIVATO da config/live.json e src/live/book.py tramite
|
||||
`usde.cuscino_richiesto_usd` (n_asset x frac x disaster_sl_pct x equity totale): la stessa formula
|
||||
che `usde_convert` applica a ogni piano — un sorvegliante che ridichiara il proprio bersaglio non
|
||||
sta controllando niente (P1, 5 occorrenze nel progetto).
|
||||
|
||||
QUATTRO STATI SULLO SLACK (= equity USDC − cuscino), in frazioni del cuscino lette da config
|
||||
(`usde.bande_cuscino`: preavviso 0,10 · margine 0,20 · riacquisto 0,40 — P5, P1):
|
||||
* SCOPERTO — slack < 0: 🚨 alla transizione, e VENDITA automatica di USDE fino alla quota di
|
||||
ripristino q* = usde.quota_ripristino() = 1 − 0,30 × 1,20 = 0,64 (cuscino + 20%).
|
||||
* PREAVVISO — 0 <= slack < 10% del cuscino: ⚠️ alla transizione. Latenza comprata apposta.
|
||||
* OK — 10% <= slack <= 40%: niente.
|
||||
* ECCEDENTE — slack > 40% del cuscino: ACQUISTO automatico di USDE fino allo stesso q* (issue #7,
|
||||
decisione dell'operatore 10/09: «riportarla a quota in autonomia»). E' cio' che
|
||||
riporta la quota a bersaglio dopo un bonifico (+0,7 × importo di slack) o dopo un
|
||||
tratto di guadagni del libro, senza che nessuno se ne ricordi. 📌 alla transizione.
|
||||
* BLIND — conto non leggibile: ⚠️ alla transizione. "Non vedo" non e' "va tutto bene".
|
||||
ISTERESI: vendita sotto 0, acquisto sopra 0,40, entrambe verso 0,20. Per oscillare lo slack deve
|
||||
muoversi di ±0,20 × cuscino = ±6% dell'equity, e siccome slack = 0,7·USDC − 0,3·USDE, servono
|
||||
±8,6% di equity USDC (~$380 oggi); un giro costa ~6 bps sull'importo mosso. Soglie DICHIARATE, non ottimizzate (M8). Il vecchio `quota_target` 0,70
|
||||
lasciava slack ZERO per costruzione: a quella quota ogni ora in perdita avrebbe venduto.
|
||||
|
||||
LE GUARDIE DELLA RICONVERSIONE (tutte dichiarate qui):
|
||||
* `execution_enabled` in config/live.json deve essere true — lo stesso interruttore del libro:
|
||||
disarmare il libro disarma anche questo, senza un secondo posto da ricordare.
|
||||
* NON si converte sotto `depeg_warn`, in nessun verso: vendere USDE sotto la pari cristallizza
|
||||
la perdita del collaterale, comprarne mentre scivola e' peggio; li' decide l'operatore.
|
||||
* al massimo UN tentativo ogni RIPRISTINO_MIN_ORE (6h), in qualunque verso: un tentativo che si
|
||||
ripete ogni ora e' spam, e un sorvegliante che si ripete si impara a ignorare (P14).
|
||||
* dopo un tentativo FALLITO da ECCEDENTE si riprova dopo RITENTATIVO_FALLITO_ORE (24h): USDC
|
||||
fermo non e' un rischio, e un guasto persistente (tetto del venue tornato, book illeggibile)
|
||||
diventerebbe 4 Telegram al giorno per sempre (revisione fable 10/09). Da SCOPERTO il ritmo
|
||||
resta 6h: li' il fallimento e' un rischio che costa 0,05%/giorno.
|
||||
* `usde_convert` porta le proprie guardie (banda di prezzo, book leggibile, tetto HARD sulla
|
||||
quota): questo script NON le duplica, le eredita lanciando lo script vero (P15: la regola si
|
||||
prova contro il codice che la esegue).
|
||||
* `--secco`: niente ordini, niente Telegram, NIENTE riga nel registro: stampa cosa FAREBBE.
|
||||
* niente lock: un `usde_convert --esegui` lanciato a mano nello stesso minuto del cron (:53)
|
||||
puo' vendere due volte verso bersagli diversi. Limite dichiarato (D5), non riparato.
|
||||
|
||||
TRASPORTO (lezione del debito §5.2): il marcatore "gia' detto" e' l'esito di `notify` scritto nel
|
||||
record (`allertato`); se l'invio fallisce la transizione si ripete al giro dopo invece di andare
|
||||
persa per l'episodio intero.
|
||||
|
||||
La serie data/live/cuscino_watch.jsonl e' dentro il perimetro di backup ed e' sorvegliata da
|
||||
monitor_health (un watch fermo = nessuno guarda il cuscino, e il silenzio si legge come zero).
|
||||
|
||||
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
|
||||
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
|
||||
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import usde as U # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
STATE = ROOT / "data" / "live" / "cuscino_watch.jsonl"
|
||||
CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
|
||||
# Le bande (preavviso / margine / riacquisto) NON stanno qui: si leggono da config via
|
||||
# usde.bande_cuscino() a ogni giro (P1). Il margine e' il DOPPIO del preavviso per costruzione
|
||||
# dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️).
|
||||
RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore
|
||||
RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno
|
||||
TIMEOUT_CONVERT_S = 900 # usde_convert: 12s di pausa fra ordini + le GET per giro (~14s/giro
|
||||
# coi 12s di pausa); 60 ordini ~= 840s: il tetto e' stretto sul caso
|
||||
# massimo, non largo come "60 ordini = 12 min" lasciava credere (#35 B10)
|
||||
|
||||
|
||||
def leggi(path: Path = STATE) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
out = []
|
||||
for ln in path.read_text().splitlines():
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
out.append(json.loads(ln))
|
||||
return out
|
||||
|
||||
|
||||
def giudica(eq_usdc: float | None, equity_tot: float | None, cuscino: float | None,
|
||||
bande: dict | None = None) -> dict:
|
||||
"""PURA. -> stato, slack, soglie in dollari. `None` in ingresso = BLIND. `bande` = le frazioni
|
||||
del cuscino (default: config, via usde.bande_cuscino)."""
|
||||
if eq_usdc is None or equity_tot is None or cuscino is None:
|
||||
return dict(stato="BLIND", slack=None, preavviso_usd=None, riacquisto_usd=None)
|
||||
try:
|
||||
b = bande or U.bande_cuscino()
|
||||
except Exception as e: # noqa: BLE001 — config rotta = "non vedo", non un crash
|
||||
return dict(stato="BLIND", slack=None, preavviso_usd=None, riacquisto_usd=None,
|
||||
motivo=f"bande del cuscino non leggibili: {e}")
|
||||
slack = eq_usdc - cuscino
|
||||
preavviso, riacquisto = b["preavviso"] * cuscino, b["riacquisto"] * cuscino
|
||||
if slack < 0:
|
||||
stato = "SCOPERTO"
|
||||
elif slack < preavviso:
|
||||
stato = "PREAVVISO"
|
||||
elif slack > riacquisto:
|
||||
stato = "ECCEDENTE"
|
||||
else:
|
||||
stato = "OK"
|
||||
return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2),
|
||||
riacquisto_usd=round(riacquisto, 2))
|
||||
|
||||
|
||||
AZIONE = {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"} # gli stati che convertono
|
||||
|
||||
|
||||
def transizione(prev: dict | None, stato: str) -> bool:
|
||||
"""PURA. Si allerta se lo stato e' cambiato, O se l'ultima allerta per questo stato non e'
|
||||
partita (`allertato` False): un invio fallito non consuma la transizione (debito §5.2)."""
|
||||
if stato == "OK":
|
||||
return False
|
||||
if prev is None or prev.get("stato") != stato:
|
||||
return True
|
||||
return not bool(prev.get("allertato"))
|
||||
|
||||
|
||||
def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: bool,
|
||||
px: float | None, depeg_warn: float,
|
||||
min_ore: float = RIPRISTINO_MIN_ORE, stato: str = "SCOPERTO",
|
||||
dopo_fallito_ore: float = RITENTATIVO_FALLITO_ORE) -> tuple[bool, str]:
|
||||
"""PURA. Le guardie della conversione automatica. -> (si', motivo se no). ⚠️ il motivo finisce
|
||||
in un'allerta: niente '<' (Telegram HTML) — l'escape nel sink lo copre, ma qui non serve."""
|
||||
if not execution_enabled:
|
||||
return False, "execution_enabled=false in config/live.json (il libro e' disarmato: anche questo)"
|
||||
if px is None:
|
||||
return False, "prezzo USDE non leggibile: non si vende al buio (P5)"
|
||||
if px < depeg_warn:
|
||||
return False, f"USDE {px:.4f} sotto depeg_warn {depeg_warn}: ne' vendere (cristallizza) ne' comprare (scivola) — decide l'operatore"
|
||||
# contano solo i tentativi VERI (subprocess lanciato): un record con `tentata=False` — prezzo
|
||||
# illeggibile, --secco, interruttore spento — non consuma il budget (revisione fable 10/09)
|
||||
ultimo = [r for r in prev_records if (r.get("riconversione") or {}).get("tentata")]
|
||||
if ultimo:
|
||||
ore = (now_ms - int(ultimo[-1]["ts"])) / 3_600_000
|
||||
fallito = not (ultimo[-1].get("riconversione") or {}).get("ok")
|
||||
soglia = dopo_fallito_ore if (fallito and stato == "ECCEDENTE") else min_ore
|
||||
if ore < soglia:
|
||||
return False, (f"ultimo tentativo {ore:.1f}h fa, sotto le {soglia:.0f}h"
|
||||
+ (" (era FALLITO: da ECCEDENTE si riprova una volta al giorno)" if soglia == dopo_fallito_ore else "")
|
||||
+ ": non si insiste")
|
||||
return True, ""
|
||||
|
||||
|
||||
def _leggi_quota_usde() -> float | None:
|
||||
"""Quota USDE sul totale, STESSA formula di usde_watch.py:275 (usd/(usd+eq_usdc)), stessi
|
||||
mattoni di main() qui sotto (_safe_client, account_summary, U.prezzo, U.valuta): nessuna
|
||||
lettura nuova. None se il conto non e' leggibile — chi chiama tratta None come "non lo so",
|
||||
non come zero (#35 B10)."""
|
||||
client = _safe_client()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
eq_usdc = float(client.account_summary("USDC")["equity"])
|
||||
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
|
||||
except Exception:
|
||||
return None
|
||||
px, _ = U.prezzo(client)
|
||||
usd, _ = U.valuta(eq_usde, px)
|
||||
try:
|
||||
return round(usd / (usd + eq_usdc), 4)
|
||||
except ZeroDivisionError:
|
||||
return None
|
||||
|
||||
|
||||
def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict:
|
||||
"""Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).
|
||||
|
||||
Rilegge la quota USDE prima e dopo il subprocess, ANCHE su timeout o eccezione: un rc!=0 con
|
||||
la quota mossa e' una conversione PARZIALE (fill gia' avvenuti prima del kill a timeout_s),
|
||||
non "FALLITA" come diceva `ok = rc==0` (#35 B10). `esito` in "ok" | "parziale" | "fallita"* —
|
||||
"ok" solo per compatibilita' resta anche in `ok` (bool)."""
|
||||
quota_prima = _leggi_quota_usde()
|
||||
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
|
||||
rc = r.returncode
|
||||
coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
|
||||
except subprocess.TimeoutExpired:
|
||||
rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"]
|
||||
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
|
||||
rc, coda = None, [f"{type(e).__name__}: {e}"]
|
||||
quota_dopo = _leggi_quota_usde()
|
||||
|
||||
if rc == 0:
|
||||
esito = "ok"
|
||||
elif quota_dopo is None:
|
||||
esito = "fallita (quota dopo non leggibile)"
|
||||
else:
|
||||
mossa = quota_prima is not None and abs(quota_dopo - quota_prima) > 0.005
|
||||
esito = "parziale" if mossa else "fallita"
|
||||
|
||||
return dict(rc=rc, ok=esito == "ok", esito=esito, quota_prima=quota_prima, quota_dopo=quota_dopo,
|
||||
coda=coda, cmd=" ".join(cmd[1:]))
|
||||
|
||||
|
||||
def _safe_client():
|
||||
try:
|
||||
from src.live.deribit import DeribitRead
|
||||
return DeribitRead()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _execution_enabled() -> bool:
|
||||
try:
|
||||
return bool(json.loads((ROOT / "config" / "live.json").read_text()).get("execution_enabled"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
quiet, secco = "--quiet" in sys.argv, "--secco" in sys.argv
|
||||
c = U.cfg()
|
||||
records = leggi()
|
||||
prev = records[-1] if records else None
|
||||
now = datetime.now(timezone.utc)
|
||||
now_ms = int(now.timestamp() * 1000)
|
||||
client = _safe_client()
|
||||
|
||||
eq_usdc = eq_usde = None
|
||||
motivo_blind = None
|
||||
if client is None:
|
||||
motivo_blind = "gateway non raggiungibile"
|
||||
else:
|
||||
try:
|
||||
eq_usdc = float(client.account_summary("USDC")["equity"])
|
||||
except Exception as e:
|
||||
motivo_blind = f"conto USDC non leggibile ({type(e).__name__})"
|
||||
try:
|
||||
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
|
||||
except Exception as e:
|
||||
motivo_blind = motivo_blind or f"conto USDE non leggibile ({type(e).__name__})"
|
||||
px, px_fonte = U.prezzo(client)
|
||||
equity_tot = cuscino = come = None
|
||||
if eq_usdc is not None and eq_usde is not None:
|
||||
usd, _ = U.valuta(eq_usde, px)
|
||||
equity_tot = eq_usdc + usd
|
||||
cuscino, come = U.cuscino_richiesto_usd(equity_tot)
|
||||
try:
|
||||
bande = U.bande_cuscino(c)
|
||||
except Exception as e: # noqa: BLE001 — si registra e si allerta, non si crasha (P5)
|
||||
bande, motivo_blind = None, f"config usde: {e}"
|
||||
g = giudica(eq_usdc, equity_tot, cuscino, bande)
|
||||
stato = g["stato"]
|
||||
if stato == "BLIND" and g.get("motivo"):
|
||||
motivo_blind = motivo_blind or g["motivo"]
|
||||
|
||||
rec = dict(ts=now_ms, data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), stato=stato,
|
||||
motivo_blind=motivo_blind, eq_usdc=eq_usdc, eq_usde=eq_usde, px=px,
|
||||
px_fonte=px_fonte, equity_tot=equity_tot, cuscino=cuscino, cuscino_come=come,
|
||||
slack=g["slack"], preavviso_usd=g["preavviso_usd"], riacquisto_usd=g["riacquisto_usd"],
|
||||
bande=bande, allertato=None, allerta_errore=None, riconversione=None, secco=secco)
|
||||
|
||||
# --- conversione automatica: SCOPERTO vende, ECCEDENTE compra, stesso bersaglio q* ---
|
||||
if stato in AZIONE:
|
||||
ok, perche = puo_riconvertire(records, now_ms, _execution_enabled(), px, c["depeg_warn"], stato=stato)
|
||||
quota = U.quota_ripristino(bande["margine"])
|
||||
if not ok:
|
||||
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, motivo=perche)
|
||||
elif secco:
|
||||
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota,
|
||||
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})")
|
||||
else:
|
||||
ris = riconverti(quota)
|
||||
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **ris)
|
||||
|
||||
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
|
||||
# ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al
|
||||
# primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'.
|
||||
def _esito_azione(r: dict) -> str:
|
||||
if not r.get("tentata"):
|
||||
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
|
||||
esito = r.get("esito", "ok" if r.get("ok") else "fallita")
|
||||
if esito == "parziale":
|
||||
qp, qd = r.get("quota_prima"), r.get("quota_dopo")
|
||||
qp_s = f"{qp:.0%}" if qp is not None else "n/d"
|
||||
qd_s = f"{qd:.0%}" if qd is not None else "n/d"
|
||||
return f"{r['verso']} PARZIALE: quota da {qp_s} a {qd_s}"
|
||||
return (f"{r['verso']} " + ("ESEGUITA" if esito == "ok" else "FALLITA")
|
||||
+ f" (quota bersaglio {r['quota']:.0%})")
|
||||
|
||||
if transizione(prev, stato):
|
||||
if secco:
|
||||
rec["allertato"] = False
|
||||
elif stato == "SCOPERTO":
|
||||
rec["allertato"] = notify("🚨 CUSCINO USDC SCOPERTO", {
|
||||
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f} ({come})",
|
||||
"slack": f"${g['slack']:+,.2f}", "azione": _esito_azione(rec["riconversione"] or {}),
|
||||
"nota": "un USDC negativo costa 0,05%/giorno; il libro si regola in USDC"}, tentativi=3)
|
||||
elif stato == "PREAVVISO":
|
||||
rec["allertato"] = notify("⚠️ cuscino USDC in esaurimento", {
|
||||
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}",
|
||||
"slack": f"${g['slack']:+,.2f} (sotto il {bande['preavviso']:.0%} del cuscino)",
|
||||
"nota": f"sotto zero vende USDE da solo fino a quota {U.quota_ripristino(bande['margine']):.0%}"})
|
||||
elif stato == "ECCEDENTE":
|
||||
rec["allertato"] = notify("📌 cuscino USDC eccedente: riacquisto USDE", {
|
||||
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}",
|
||||
"slack": f"${g['slack']:+,.2f} (sopra il {bande['riacquisto']:.0%} del cuscino)",
|
||||
"azione": _esito_azione(rec["riconversione"] or {})})
|
||||
elif stato == "BLIND":
|
||||
rec["allertato"] = notify("⚠️ cuscino_watch BLIND", {"motivo": motivo_blind or "?",
|
||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||
elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
|
||||
# non e' una transizione ma e' un ordine mandato: si dice sempre
|
||||
r = rec["riconversione"]
|
||||
esito = r.get("esito", "ok" if r.get("ok") else "fallita")
|
||||
verbo = {"ok": "eseguita", "parziale": "PARZIALE"}.get(esito, "FALLITA")
|
||||
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} {verbo}",
|
||||
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
|
||||
if rec["allertato"] is False and not secco:
|
||||
from src.live.notifier import ultimo_errore
|
||||
rec["allerta_errore"] = ultimo_errore() # P3: si registra dove si ingoia
|
||||
|
||||
# --secco e' secco ANCHE sul registro: un giro a mano non deve diventare il `prev` del cron,
|
||||
# ne' far sembrare vivo un monitor fermo (revisione fable 10/09)
|
||||
if not secco:
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with STATE.open("a") as fh:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
|
||||
cambiato = prev is None or prev.get("stato") != stato or rec["riconversione"] is not None
|
||||
if not quiet or cambiato:
|
||||
print("=" * 78)
|
||||
print(f" CUSCINO WATCH — {rec['data']} {'(SECCO)' if secco else ''}")
|
||||
print("=" * 78)
|
||||
if stato == "BLIND":
|
||||
print(f" stato : BLIND — {motivo_blind}")
|
||||
else:
|
||||
print(f" conto : USDC equity ${eq_usdc:,.2f} + USDE {eq_usde:,.2f} @ {px if px else 'n/d'}"
|
||||
f" ({px_fonte}) = ${equity_tot:,.2f}")
|
||||
print(f" cuscino : ${cuscino:,.2f} richiesti ({come})")
|
||||
print(f" slack : ${g['slack']:+,.2f} (preavviso sotto ${g['preavviso_usd']:,.2f},"
|
||||
f" riacquisto sopra ${g['riacquisto_usd']:,.2f}, bersaglio quota {U.quota_ripristino(bande['margine']):.0%})")
|
||||
print(f" stato : {stato}")
|
||||
if rec["riconversione"]:
|
||||
r = rec["riconversione"]
|
||||
print(f" {r.get('verso', 'conversione'):<12}: {'TENTATA' if r.get('tentata') else 'NON tentata'} — quota {r['quota']:.4f}"
|
||||
+ (f" — {r.get('motivo')}" if r.get("motivo") else ""))
|
||||
for ln in r.get("coda", []):
|
||||
print(f" {ln}")
|
||||
if rec["allertato"] is not None:
|
||||
print(f" allerta : {'inviata' if rec['allertato'] else 'NON inviata (si ripete al giro dopo)'}"
|
||||
+ (f" — {rec['allerta_errore']}" if rec.get("allerta_errore") else ""))
|
||||
return 0
|
||||
|
||||
|
||||
USO = """uso: cuscino_watch.py [--quiet] [--secco]
|
||||
|
||||
cuscino_watch.py — sorveglianza ORARIA del cuscino USDC; sotto zero vende USDE, sopra la banda ricompra.
|
||||
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
|
||||
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
|
||||
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("cuscino_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
|
||||
sys.exit(main())
|
||||
@@ -42,10 +42,13 @@ import pandas as pd
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
from src.portfolio.portfolio import combine_outer # noqa: E402
|
||||
from src.portfolio.sleeves import deribit_book_sleeves, tp01_sleeve # noqa: E402
|
||||
|
||||
RUNS = ROOT / "data" / "edge_watch" / "runs.jsonl"
|
||||
|
||||
# --- soglie CONGELATE (cambiarle invalida la taratura di r0726_edge_death.py) ---
|
||||
WIN_MONTHS = 36
|
||||
SHARPE_KILL = -0.5
|
||||
@@ -119,6 +122,7 @@ def run() -> dict:
|
||||
|
||||
def main() -> int:
|
||||
r = run()
|
||||
registra_giro(RUNS, sharpe=r["sharpe"], n_alert=len(r["alerts"]))
|
||||
if "--quiet" not in sys.argv:
|
||||
print("=" * 78)
|
||||
print(" EDGE WATCH — il book live sta ancora funzionando?")
|
||||
@@ -136,7 +140,7 @@ def main() -> int:
|
||||
print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · "
|
||||
f"fallimenti consecutivi in corso: {r['consec_fail']}")
|
||||
if r["alerts"]:
|
||||
notify("🚨 EDGE WATCH — criterio di kill scattato sul book LIVE",
|
||||
notify("🚨 EDGE WATCH — criterio di kill scattato sul book (serie backtest dei sleeve, non il P&L eseguito)",
|
||||
{f"alert {i+1}": a for i, a in enumerate(r["alerts"])})
|
||||
for a in r["alerts"]:
|
||||
print(f" ALERT: {a}")
|
||||
|
||||
@@ -49,9 +49,11 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.book import INSTRUMENT as BOOK_INSTRUMENT # noqa: E402
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
STATE = ROOT / "data" / "fee_watch" / "state.json"
|
||||
RUNS = ROOT / "data" / "fee_watch" / "runs.jsonl"
|
||||
# ⚠️ CORREZIONE 2026-08-21. Qui c'era la tupla CABLATA ("BTC-PERPETUAL", "ETH-PERPETUAL") — gli
|
||||
# INVERSE, regolati in BTC/ETH — mentre il book esegue sui LINEARI USDC (BTC_USDC-PERPETUAL).
|
||||
# Sono due linee di prodotto distinte e Deribit le cambia in modo indipendente: il 18/08 ha
|
||||
@@ -77,6 +79,7 @@ SOGLIA_OK = 5.0 # <= -> nessuna azione
|
||||
SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01
|
||||
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
|
||||
D_CAGR_PER_BPS = -0.0023
|
||||
SOGLIA_DIVERGENZA_BPS = 1.0 # #37 B9: |fee realizzata - tier pubblico| oltre questo -> notify
|
||||
|
||||
|
||||
def fetch_fees(instrument: str) -> dict:
|
||||
@@ -207,14 +210,30 @@ def run() -> dict:
|
||||
changes = diff_vs(prev, cur)
|
||||
worst = max(c["taker_bps"] for c in cur.values())
|
||||
lvl, why = verdict(worst)
|
||||
# issue #37 (B9). realized_fee_bps() girava SOLO dentro il blocco di stampa: in cron
|
||||
# (--quiet, il caso normale) non girava mai e una fee realmente pagata diversa dal tier
|
||||
# pubblico non avvisava nessuno. Qui si calcola SEMPRE ed entra nel record del giro
|
||||
# (registra_giro, sotto). {} -> None: "non misurata" (nessun trade recente), mai "zero".
|
||||
real = realized_fee_bps() or None
|
||||
divergenze = []
|
||||
if real:
|
||||
for ins, bps in real.items():
|
||||
tier = cur.get(ins, {}).get("taker_bps")
|
||||
if tier is None:
|
||||
continue
|
||||
d = bps - tier
|
||||
if abs(d) > SOGLIA_DIVERGENZA_BPS:
|
||||
divergenze.append(
|
||||
f"{ins}: realizzata {bps:.2f} bps vs tier pubblico {tier:.2f} ({d:+.2f})")
|
||||
return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why,
|
||||
first_read=not prev)
|
||||
first_read=not prev, realized=real, divergenze=divergenze,
|
||||
divergenza_gia_detta=bool(prev.get("divergenza_detta")))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
r = run()
|
||||
quiet = "--quiet" in sys.argv
|
||||
interessante = bool(r["changes"]) or r["level"] != "OK"
|
||||
interessante = bool(r["changes"]) or r["level"] != "OK" or bool(r.get("divergenze"))
|
||||
|
||||
if not quiet or interessante:
|
||||
print("=" * 86)
|
||||
@@ -230,7 +249,7 @@ def main() -> int:
|
||||
print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
|
||||
for c in r["changes"]:
|
||||
print(f" CAMBIO: {c}")
|
||||
real = realized_fee_bps()
|
||||
real = r.get("realized") # calcolato una volta in run(), non piu' qui (#37 B9)
|
||||
if real:
|
||||
print("\n cross-check sui trade REALI del conto (fonte autorevole):")
|
||||
for ins, bps in real.items():
|
||||
@@ -238,17 +257,49 @@ def main() -> int:
|
||||
nota = ""
|
||||
if tier is not None:
|
||||
d = bps - tier
|
||||
nota = f" (tier taker {tier:.2f} → {d:+.2f})" + (" ⚠️ DIVERGE" if abs(d) > 1.0 else "")
|
||||
nota = (f" (tier taker {tier:.2f} → {d:+.2f})"
|
||||
+ (" ⚠️ DIVERGE" if abs(d) > SOGLIA_DIVERGENZA_BPS else ""))
|
||||
print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}")
|
||||
else:
|
||||
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
||||
|
||||
if r["changes"] or r["level"] == "AZIONE":
|
||||
notify("💸 FEE WATCH — schema fee Deribit", {
|
||||
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
|
||||
# il giro dopo `diff_vs` non avrebbe piu' visto il cambiamento (lo stato salvato era gia'
|
||||
# quello nuovo) e il cambio andava perso per sempre invece che ritentare al giro dopo.
|
||||
serve_invio = bool(r["changes"] or r["level"] == "AZIONE")
|
||||
inviato = True
|
||||
if serve_invio:
|
||||
inviato = notify("💸 FEE WATCH — schema fee Deribit", {
|
||||
"verdetto": f"[{r['level']}] {r['reason']}",
|
||||
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
||||
})
|
||||
save_state(r["current"])
|
||||
if not inviato and (not quiet or interessante):
|
||||
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
|
||||
# issue #37 (B9): fee realmente pagata che diverge dal tier pubblico atteso -> notify UNA
|
||||
# volta per episodio. Marcatore "divergenza_detta" nello stesso state.json (stessa regola di
|
||||
# B8: si scrive solo se l'invio e' riuscito), azzerato quando la divergenza torna sotto
|
||||
# soglia. E' un evento indipendente dal cambio di tier: se qui serve un invio e quello di B8
|
||||
# e' appena fallito, save_state sotto non gira (stessa gate di B8) e il marcatore non si
|
||||
# scrive — al giro dopo si ridice, ridondante ma mai persa, la stessa scelta di B8.
|
||||
divergenze = r.get("divergenze") or []
|
||||
gia_detta = bool(r.get("divergenza_gia_detta"))
|
||||
marcatore = gia_detta
|
||||
if divergenze and not gia_detta:
|
||||
inviata_div = notify("💸 FEE WATCH — fee realizzata diverge dal tier pubblico", {
|
||||
f"divergenza {i+1}": d for i, d in enumerate(divergenze)
|
||||
})
|
||||
if inviata_div:
|
||||
marcatore = True
|
||||
elif not quiet or interessante:
|
||||
print(" ⚠️ invio divergenza FALLITO — marcatore non scritto: il prossimo giro ritenta")
|
||||
elif not divergenze:
|
||||
marcatore = False
|
||||
|
||||
stato_da_salvare = dict(r["current"])
|
||||
stato_da_salvare["divergenza_detta"] = marcatore
|
||||
if not serve_invio or inviato:
|
||||
save_state(stato_da_salvare)
|
||||
registra_giro(RUNS, level=r["level"], realized=r.get("realized"))
|
||||
return 2 if r["level"] == "AZIONE" else 0
|
||||
|
||||
|
||||
|
||||
@@ -10,20 +10,91 @@ monitor e la soglia di copertura 0.80 (la stessa del veto d'integrita' di DVOLSP
|
||||
|
||||
uv run python scripts/live/monitor_health.py # report
|
||||
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
|
||||
uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a
|
||||
# max_age<=6h (cuscino_watch, venue_watch, collect_chain), notifica solo il NUOVO
|
||||
# rispetto a data/live/monitor_health_detto.json — pensato per girare ogni ora
|
||||
# (cron_cuscino.sh), non per sostituire il giro giornaliero completo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.monitor_health import MIN_COVERAGE, alerts_from, check_all # noqa: E402
|
||||
from src.live.monitor_health import ( # noqa: E402
|
||||
MIN_COVERAGE,
|
||||
alert_nuovi,
|
||||
alerts_from,
|
||||
check_all,
|
||||
marcatore_aggiornato,
|
||||
monitors_orari,
|
||||
)
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
# (#30) marcatore "gia' detto" del giro orario: {"detti": [<chiave alert>], "ts": ...}. Vive fuori
|
||||
# da src/ perche' e' I/O, non parte della misura pura.
|
||||
DETTO = ROOT / "data" / "live" / "monitor_health_detto.json"
|
||||
|
||||
|
||||
def _carica_detti(path: Path = DETTO) -> set[str]:
|
||||
if not path.exists():
|
||||
return set()
|
||||
try:
|
||||
return set(json.loads(path.read_text()).get("detti", []))
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _salva_detti(detti: set[str], path: Path = DETTO) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(
|
||||
{"detti": sorted(detti), "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
|
||||
indent=2))
|
||||
|
||||
|
||||
def main_orari() -> int:
|
||||
"""(#30) Giro orario: solo i sorveglianti a max_age<=6h, notifica solo il NUOVO rispetto al
|
||||
marcatore. Exit 2 se c'e' un alert (anche gia' detto), 0 altrimenti — il giro giornaliero
|
||||
resta l'unico posto dove "nessun alert nuovo" vuol dire davvero "tutto OK"."""
|
||||
quiet = "--quiet" in sys.argv
|
||||
rows = check_all(ROOT, monitors=monitors_orari())
|
||||
alerts = alerts_from(rows)
|
||||
detti = _carica_detti(DETTO) # DETTO letto qui, non nel default del parametro (monkeypatch)
|
||||
nuovi = alert_nuovi(rows, detti)
|
||||
aggiornato = marcatore_aggiornato(rows, detti)
|
||||
|
||||
if not quiet or alerts:
|
||||
print("=" * 92)
|
||||
print(" MONITOR HEALTH — giro orario (sorveglianti con max_age <= 6h)")
|
||||
print("=" * 92)
|
||||
for r in rows:
|
||||
cov = f"{r['coverage']:.0%}" if r["coverage"] is not None else "-"
|
||||
age = f"{r['age_h']:.0f}h" if r["age_h"] is not None else "-"
|
||||
print(f" {r['name']:<20}{r['status']:>10}{age:>13}{cov:>10}")
|
||||
|
||||
inviato = True
|
||||
if nuovi:
|
||||
msgs = alerts_from(nuovi)
|
||||
inviato = notify("🩺 MONITOR HEALTH — un sorvegliante orario non sta registrando",
|
||||
{f"alert {i+1}": m for i, m in enumerate(msgs)})
|
||||
for m in msgs:
|
||||
print(f" ALERT (nuovo): {m}")
|
||||
if not inviato:
|
||||
print(" ⚠️ invio FALLITO — marcatore NON aggiornato: il prossimo giro ritenta")
|
||||
if not nuovi or inviato:
|
||||
_salva_detti(aggiornato, DETTO)
|
||||
|
||||
return 2 if alerts else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if "--solo-orari" in sys.argv:
|
||||
return main_orari()
|
||||
|
||||
rows = check_all(ROOT)
|
||||
alerts = alerts_from(rows)
|
||||
|
||||
@@ -55,15 +126,17 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
USO = """uso: monitor_health.py [--quiet]
|
||||
USO = """uso: monitor_health.py [--quiet] [--solo-orari]
|
||||
|
||||
monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla.
|
||||
uv run python scripts/live/monitor_health.py # report
|
||||
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
|
||||
uv run python scripts/live/monitor_health.py --solo-orari # (#30) solo i sorveglianti a
|
||||
max_age<=6h, notifica solo il NUOVO rispetto al marcatore
|
||||
|
||||
Dettaglio nel docstring in testa al file."""
|
||||
|
||||
if __name__ == "__main__":
|
||||
from src.live.cli import valida
|
||||
valida("monitor_health.py", USO, flag=("--quiet",), con_valore=())
|
||||
valida("monitor_health.py", USO, flag=("--quiet", "--solo-orari"), con_valore=())
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -53,6 +53,21 @@ MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo
|
||||
REAL_CAPITAL = 600.0 # capitale mainnet reale
|
||||
MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia
|
||||
|
||||
# --- GATE PRE-REGISTRATO `GATE PREVDAY-01` (2026-08-23, RESULTS-0822 §54) -----------------------
|
||||
# Cablato qui il 2026-09-10 (issue #4): esisteva solo nel testo, e la revisione del 09/09 lo ha
|
||||
# letto come "gate senza data". Decisione dell'operatore: tenere la data, cablare kill e veto.
|
||||
GATE_DATE = "2027-06-21" # decisione piena: 7 condizioni (a)-(g), tutte necessarie
|
||||
KILL_SHARPE = -0.50 # kill: Sharpe forward < -0,50 ...
|
||||
KILL_MIN_ACTIVE_DAYS = 180 # ... su >= 180 giorni di barre ATTIVE (posizione != 0)
|
||||
MIN_RECON_FRAC = 0.80 # veto d'integrita': >= 80% di barre ricostruibili dal feed
|
||||
RECON_TOL = 1.5e-6 # net_modeled e' registrato a 6 decimali: mezzo ulp di tolleranza
|
||||
RECENTI_GIORNI = 30 # "i minuti non registrati non devono crescere": ultimi 30g vs tutto
|
||||
MIN_DIV_CRESCITA = 5 # ... ma UNA barra divergente su 1943 non e' una crescita (P14): la
|
||||
# crescita richiede >= 5 divergenti recenti E tasso doppio del totale
|
||||
# LENTE: il kill si misura sullo Sharpe GIORNALIERO (somma delle barre orarie per giorno UTC),
|
||||
# la lente con cui e' misurato ogni altro sleeve del progetto — §54: sulla lente ORARIA lo stesso
|
||||
# forward vale +2,04 contro +1,56 giornaliero. Al kill la famiglia NON si ri-ottimizza.
|
||||
|
||||
|
||||
def build_bars() -> dict[str, pd.DataFrame]:
|
||||
return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS}
|
||||
@@ -145,6 +160,151 @@ def advance(st: dict, dfs: dict) -> dict:
|
||||
return st
|
||||
|
||||
|
||||
def _returns_df() -> pd.DataFrame | None:
|
||||
if not RETURNS_FILE.exists():
|
||||
return None
|
||||
r = pd.read_json(RETURNS_FILE, lines=True)
|
||||
if r.empty:
|
||||
return None
|
||||
r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
|
||||
return r
|
||||
|
||||
|
||||
def sharpe_giornaliero(r: pd.DataFrame, col: str = "net_modeled") -> tuple[float, int]:
|
||||
"""PURA. Sharpe annualizzato sui rendimenti GIORNALIERI (somma delle barre orarie per giorno
|
||||
UTC) -> (sharpe, n_giorni). nan sotto 30 giorni o a varianza nulla."""
|
||||
d = r.groupby(r["dt"].dt.floor("D"))[col].sum()
|
||||
if len(d) < 30 or d.std() == 0:
|
||||
return float("nan"), int(len(d))
|
||||
return float(d.mean() / d.std() * np.sqrt(365.25)), int(len(d))
|
||||
|
||||
|
||||
def giorni_attivi(r: pd.DataFrame) -> int:
|
||||
"""PURA. Giorni UTC con almeno una barra a posizione != 0 (il kill conta le barre ATTIVE).
|
||||
⚠️ le posizioni registrate sono quelle del libro REAL-$600 (advance scrive `pr`), lo Sharpe
|
||||
del kill e' MODELED: divergono solo se un ingresso da 0 vale < $5 di nozionale, cosa che a
|
||||
vol-target 20% non succede (10/09: 81/81 giorni). Dichiarato, non riparato (D5)."""
|
||||
att = r[(r["pos_btc"] != 0) | (r["pos_eth"] != 0)]
|
||||
return int(att["dt"].dt.floor("D").nunique())
|
||||
|
||||
|
||||
def kill_check(sharpe: float, n_attivi: int, today: pd.Timestamp) -> tuple[str, str]:
|
||||
"""PURA. -> (stato, motivo). KILL solo con >= 180 giorni attivi E Sharpe < -0,50 (§54):
|
||||
a quell'orizzonte P(uccidere un edge vivo a Sharpe 1,2) = 9,7%."""
|
||||
if n_attivi < KILL_MIN_ACTIVE_DAYS:
|
||||
return "NON_MATURO", (f"{n_attivi} giorni attivi < {KILL_MIN_ACTIVE_DAYS}: il kill non e' "
|
||||
f"leggibile (mancano {KILL_MIN_ACTIVE_DAYS - n_attivi} giorni)")
|
||||
if not np.isfinite(sharpe):
|
||||
return "NON_MISURABILE", "Sharpe forward non misurabile"
|
||||
if sharpe < KILL_SHARPE:
|
||||
return "KILL", f"Sharpe {sharpe:+.2f} < {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi -> RITIRARE, senza ri-ottimizzare"
|
||||
return "SUPERATO", f"Sharpe {sharpe:+.2f} >= {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi"
|
||||
|
||||
|
||||
def ricostruzione(r: pd.DataFrame, dfs: dict, start_ts: int) -> dict:
|
||||
"""Rigioca la strategia CONGELATA sul feed di OGGI dallo `start_ts` del monitor e confronta
|
||||
barra per barra `net_modeled` registrato con quello ricalcolato (ribilanciamento continuo,
|
||||
la stessa aritmetica di `advance`). Una barra e' RICOSTRUIBILE se coincide entro RECON_TOL.
|
||||
|
||||
Cosa misura: quante barre registrate sono riproducibili dal dato certificato. Le divergenti
|
||||
sono barre calcolate su un feed che il rebuild ha poi rivisto (§54: 67 su 1512, tutte
|
||||
all'ora del cron) — e' la misura del veto d'integrita' (>= 80%, e i minuti persi non devono
|
||||
crescere: qui, divergenti/giorno negli ultimi 30g contro l'intera finestra)."""
|
||||
data = {}
|
||||
for a in ASSETS:
|
||||
df = dfs[a]
|
||||
c = df["close"].values.astype(float)
|
||||
rr = np.zeros(len(c)); rr[1:] = c[1:] / c[:-1] - 1.0
|
||||
data[a] = dict(ts=df["timestamp"].values.astype("int64"), r=rr, tgt=prevday_target(df))
|
||||
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
|
||||
# posizione iniziale come init_state: target all'ultima barra <= start_ts
|
||||
pos = {}
|
||||
for a in ASSETS:
|
||||
i0 = max(i for t, i in idx[a].items() if t <= start_ts)
|
||||
pos[a] = float(data[a]["tgt"][i0])
|
||||
ricalc, presenti = [], []
|
||||
for t in r["ts"].astype("int64"):
|
||||
t = int(t)
|
||||
if any(t not in idx[a] for a in ASSETS):
|
||||
ricalc.append(np.nan); presenti.append(False); continue
|
||||
net = 0.0
|
||||
for a in ASSETS:
|
||||
i = idx[a][t]
|
||||
tgt = float(data[a]["tgt"][i])
|
||||
net += WEIGHT * (pos[a] * float(data[a]["r"][i]) - FEE_SIDE * abs(tgt - pos[a]))
|
||||
pos[a] = tgt
|
||||
ricalc.append(net); presenti.append(True)
|
||||
ricalc = np.asarray(ricalc, dtype=float)
|
||||
reg = r["net_modeled"].values.astype(float)
|
||||
ok = np.isfinite(ricalc) & (np.abs(ricalc - reg) <= RECON_TOL)
|
||||
n = len(reg)
|
||||
giorni = r["dt"].dt.floor("D")
|
||||
span_g = max(1, int(giorni.nunique()))
|
||||
recenti = r["dt"] >= (r["dt"].max() - pd.Timedelta(days=RECENTI_GIORNI))
|
||||
div_rec = int((~ok[recenti.values]).sum())
|
||||
g_rec = max(1, int(giorni[recenti].nunique()))
|
||||
return dict(n=n, ricostruibili=int(ok.sum()), frac=float(ok.sum() / n) if n else float("nan"),
|
||||
assenti_nel_feed=int((~np.asarray(presenti)).sum()),
|
||||
div_per_giorno=float((n - ok.sum()) / span_g),
|
||||
div_recenti=div_rec, div_per_giorno_recenti=float(div_rec / g_rec),
|
||||
divergenti_ts=[int(t) for t in r["ts"].values[~ok]][:20])
|
||||
|
||||
|
||||
def veto_check(frac: float, div_g_tot: float, div_g_rec: float, div_rec: int) -> tuple[bool, str]:
|
||||
"""PURA. Veto d'integrita' (blocca, non decide): sotto l'80% di barre ricostruibili, o con le
|
||||
divergenze che CRESCONO negli ultimi 30 giorni, la finestra si ESTENDE invece di decidere."""
|
||||
motivi = []
|
||||
if not np.isfinite(frac) or frac < MIN_RECON_FRAC:
|
||||
motivi.append(f"barre ricostruibili {frac:.1%} < {MIN_RECON_FRAC:.0%}")
|
||||
if div_rec >= MIN_DIV_CRESCITA and div_g_rec > 2.0 * div_g_tot + 1e-12:
|
||||
motivi.append(f"divergenze in crescita: {div_rec} negli ultimi {RECENTI_GIORNI}g "
|
||||
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g sull'intera finestra)")
|
||||
return (not motivi), ("; ".join(motivi) if motivi else
|
||||
f"ricostruibili {frac:.1%}, divergenti {div_rec} negli ultimi {RECENTI_GIORNI}g "
|
||||
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g totale)")
|
||||
|
||||
|
||||
def print_gate(st: dict, dfs: dict, today: pd.Timestamp | None = None) -> dict:
|
||||
"""Stampa lo stato del gate pre-registrato e lo RITORNA (per i test e per chi legge il log).
|
||||
Non decide niente: al kill e alla data stampa cosa la regola dice, in maiuscolo."""
|
||||
r = _returns_df()
|
||||
out = dict(gate_date=GATE_DATE, kill="NON_MISURABILE", veto_ok=None)
|
||||
print(f"\n GATE PRE-REGISTRATO `PREVDAY-01` (23/08, RESULTS-0822 §54) — decisione {GATE_DATE}")
|
||||
if r is None or len(r) < 2:
|
||||
print(" serie forward assente o troppo corta: niente da misurare")
|
||||
return out
|
||||
today = today or pd.Timestamp.now(tz="UTC").normalize()
|
||||
sh, n_g = sharpe_giornaliero(r)
|
||||
n_att = giorni_attivi(r)
|
||||
stato, motivo = kill_check(sh, n_att, today)
|
||||
out.update(sharpe_giornaliero=sh, n_giorni=n_g, n_attivi=n_att, kill=stato, kill_motivo=motivo)
|
||||
print(f" Sharpe forward GIORNALIERO (MODELED): {sh:+.2f} su {n_g} giorni, {n_att} attivi"
|
||||
f" [lente oraria {_sharpe_orario(r):+.2f}: non si cita, §54]")
|
||||
if stato == "KILL":
|
||||
print(f" *** KILL: {motivo} ***")
|
||||
else:
|
||||
print(f" kill (Sharpe < {KILL_SHARPE:+.2f} su >= {KILL_MIN_ACTIVE_DAYS}g attivi): {stato} — {motivo}")
|
||||
rc = ricostruzione(r, dfs, int(st["start_ts"]))
|
||||
ok, vm = veto_check(rc["frac"], rc["div_per_giorno"], rc["div_per_giorno_recenti"], rc["div_recenti"])
|
||||
out.update(ricostruzione=rc, veto_ok=ok, veto_motivo=vm)
|
||||
print(f" veto d'integrita': {'ok' if ok else '*** VETO — la finestra si ESTENDE ***'} — {vm}"
|
||||
f" ({rc['ricostruibili']}/{rc['n']} barre, {rc['assenti_nel_feed']} assenti nel feed)")
|
||||
if today >= pd.Timestamp(GATE_DATE, tz="UTC"):
|
||||
print(f" *** DECISIONE DOVUTA ({GATE_DATE}): (a) DSR >= 0,95 sulla famiglia dichiarata "
|
||||
f"(b) cella al buio == congelata (c) delta libro > +0,05 col funding (d) weights_tilt_null "
|
||||
f"(e) ADDS+robust_oos+non-hedge sulla cella al buio (f) day_boundary_robust "
|
||||
f"(g) Sharpe forward > 0 [{sh:+.2f}] — TUTTE necessarie ***")
|
||||
else:
|
||||
print(f" decisione fra {(pd.Timestamp(GATE_DATE, tz='UTC') - today).days} giorni; "
|
||||
f"le condizioni (a)-(b) sono STRUTTURALI e il forward non le cambia (§54)")
|
||||
return out
|
||||
|
||||
|
||||
def _sharpe_orario(r: pd.DataFrame) -> float:
|
||||
x = r["net_modeled"].astype(float)
|
||||
return float(x.mean() / x.std() * np.sqrt(24 * 365.25)) if x.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def print_status(st: dict, dfs: dict):
|
||||
days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000
|
||||
rm = st["cap_modeled"] / MODELED_CAPITAL - 1
|
||||
@@ -156,7 +316,9 @@ def print_status(st: dict, dfs: dict):
|
||||
print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%")
|
||||
print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%")
|
||||
print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)")
|
||||
print(f" log: {RETURNS_FILE}\n")
|
||||
print(f" log: {RETURNS_FILE}")
|
||||
print_gate(st, dfs)
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -13,11 +13,15 @@ from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
from src.live.scale_watch import ALLARME, OK, run_once # noqa: E402
|
||||
|
||||
RUNS = ROOT / "data" / "scale_watch" / "runs.jsonl"
|
||||
|
||||
|
||||
def _sender(testo: str):
|
||||
try:
|
||||
@@ -31,6 +35,7 @@ def main() -> None:
|
||||
secco = "--secco" in sys.argv
|
||||
quiet = "--quiet" in sys.argv
|
||||
rep = run_once(sender=None if secco else _sender)
|
||||
registra_giro(RUNS, stato=rep["stato"])
|
||||
icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️")
|
||||
if quiet:
|
||||
fb = rep["fallback"]
|
||||
|
||||
@@ -25,17 +25,63 @@ import pandas as pd
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.monitor_health import MONITORS
|
||||
from src.live.notifier import send
|
||||
|
||||
HORIZONS = (30, 90, 180)
|
||||
GATES = [("STATARB-RESID", date(2026, 9, 27), "data/paper_statarb/returns.jsonl", "net_modeled"),
|
||||
("XSR01", date(2026, 10, 23), "data/paper_xsr/returns.jsonl", "net_modeled")]
|
||||
|
||||
|
||||
def _gate_date(nome_monitor: str, cablata: date) -> date:
|
||||
"""(#35 C14) la data del gate viene da monitor_health.MONITORS (`MonitorSpec.gate`), la
|
||||
STESSA tabella che "METTE A RISCHIO il gate" negli alert dei sorveglianti: una fonte, non
|
||||
una seconda dichiarazione duplicata di CLAUDE.md §4. Il campo e' prosa libera
|
||||
("YYYY-MM-DD — descrizione"): si legge la data solo se e' nella forma attesa. `cablata' e'
|
||||
rete di sicurezza se il monitor sparisse o il campo cambiasse forma — non deve succedere per
|
||||
i due gate qui sotto, oggi allineati (verificato a mano contro CLAUDE.md §4)."""
|
||||
for m in MONITORS:
|
||||
if m.name == nome_monitor and m.gate:
|
||||
try:
|
||||
return date.fromisoformat(m.gate[:10])
|
||||
except ValueError:
|
||||
break
|
||||
return cablata
|
||||
|
||||
|
||||
GATES = [("STATARB-RESID", _gate_date("paper_statarb", date(2026, 9, 27)),
|
||||
"data/paper_statarb/returns.jsonl", "net_modeled"),
|
||||
("XSR01", _gate_date("paper_xsr", date(2026, 10, 23)),
|
||||
"data/paper_xsr/returns.jsonl", "net_modeled")]
|
||||
|
||||
|
||||
def _fmt_usd(x: float) -> str:
|
||||
return f"${x:,.2f}"
|
||||
|
||||
|
||||
def _soglia_accensione(segni: list[int], neg_refs: list[float]) -> float | None:
|
||||
"""(#35 C3) Quale riferimento, superato, accende TP01 (mean(segni) > 0)?
|
||||
|
||||
I segni sono sempre in {-1,+1}, mai 0 (trend_state li costruisce con `1 if px>refs[h]
|
||||
else -1`: nessun pareggio possibile). Con k segni negativi da far diventare positivi, la
|
||||
somma cresce di 2 per flip: serve il piu' piccolo k tale che sum(segni)+2k > 0. La soglia
|
||||
e' il k-esimo riferimento negativo piu' basso (k=1 -> il minimo, come prima del fix; k=2 ->
|
||||
il secondo, ecc.) — non sempre il minimo: con TUTTI i riferimenti negativi, superare solo il
|
||||
piu' basso lascia la media <= 0 e TP01 a 0 (bug segnalato in #35 C3).
|
||||
|
||||
None se la media e' gia' positiva (nessuna soglia da dare) o se k supera il numero di
|
||||
riferimenti negativi disponibili (non dovrebbe accadere: con n segni, flippare tutti gli n
|
||||
negativi porta comunque la somma a +n > 0, quindi k <= len(neg_refs) sempre — resta una
|
||||
guardia difensiva, non un caso noto)."""
|
||||
s = sum(segni)
|
||||
if s > 0 or not neg_refs:
|
||||
return None
|
||||
k = 0
|
||||
while s + 2 * k <= 0:
|
||||
k += 1
|
||||
if k > len(neg_refs):
|
||||
return None
|
||||
return sorted(neg_refs)[k - 1]
|
||||
|
||||
|
||||
def trend_state() -> list[str]:
|
||||
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
|
||||
|
||||
@@ -59,32 +105,51 @@ def trend_state() -> list[str]:
|
||||
tgt = max(0.0, m)
|
||||
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
|
||||
f" → TP01 {tgt:.2f}"
|
||||
neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0]
|
||||
if neg and tgt == 0.0:
|
||||
h, r = min(neg, key=lambda t: t[1])
|
||||
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
||||
if tgt == 0.0:
|
||||
r = _soglia_accensione(list(sg.values()), [refs[h] for h in HORIZONS if sg[h] < 0])
|
||||
if r is not None:
|
||||
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
||||
out.append(line)
|
||||
except Exception as e:
|
||||
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
|
||||
return out
|
||||
|
||||
|
||||
def book_state() -> list[str]:
|
||||
"""Conto reale, posizioni e target netto correnti. Sola lettura."""
|
||||
def book_state() -> tuple[list[str], bool | None]:
|
||||
"""Conto reale, posizioni e target netto correnti. Sola lettura.
|
||||
|
||||
(#35 C14) Ritorna anche se il libro e' DAVVERO flat (nessuna posizione, nessun target: stessa
|
||||
condizione che questa funzione gia' usa riga per riga per stampare "flat" invece di "pos ->
|
||||
target"): serve a `build()` per decidere se l'header "Perche' non opera" e' vero o
|
||||
fuorviante, SENZA una lettura nuova — None se lo stato del libro non e' leggibile (in quel
|
||||
caso non sappiamo dire se opera)."""
|
||||
try:
|
||||
from src.live.book import book_report
|
||||
r = book_report()
|
||||
eq = r.get("equity")
|
||||
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
|
||||
flat = True
|
||||
for a in r.get("assets", []):
|
||||
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
|
||||
stato = "flat" if abs(pos) < 1 and abs(net) < 1 else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
|
||||
is_flat = abs(pos) < 1 and abs(net) < 1
|
||||
flat = flat and is_flat
|
||||
stato = "flat" if is_flat else f"pos {_fmt_usd(pos)} → target {_fmt_usd(net)}"
|
||||
lines.append(f" {a['asset']}: {stato} (TP {a.get('tp_frac', 0):+.2f} · SKH {a.get('skh_sign', 0):+d})")
|
||||
if r.get("skh_error"):
|
||||
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
|
||||
return lines
|
||||
return lines, flat
|
||||
except Exception as e:
|
||||
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"]
|
||||
return [f" ⚠️ stato libro non leggibile: {type(e).__name__}: {e}"], None
|
||||
|
||||
|
||||
def _fmt_giorni(giorni: int) -> str:
|
||||
"""(#35 C14) tre casi distinti, non un segno "−" cablato davanti a un intero che puo' essere
|
||||
negativo: con giorni=-1 quel cablaggio stampava "(−-1g)", un doppio segno illeggibile."""
|
||||
if giorni > 0:
|
||||
return f"-{giorni}g"
|
||||
if giorni == 0:
|
||||
return "oggi"
|
||||
return f"scaduto da {-giorni}g"
|
||||
|
||||
|
||||
def monitors() -> list[str]:
|
||||
@@ -92,20 +157,21 @@ def monitors() -> list[str]:
|
||||
for name, dec, path, key in GATES:
|
||||
p = ROOT / path
|
||||
giorni = (dec - date.today()).days
|
||||
gate_txt = f"gate {dec} ({_fmt_giorni(giorni)})"
|
||||
if not p.exists():
|
||||
out.append(f" {name}: in attesa della prima barra · gate {dec} (−{giorni}g)")
|
||||
out.append(f" {name}: in attesa della prima barra · {gate_txt}")
|
||||
continue
|
||||
try:
|
||||
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
||||
if not rows:
|
||||
out.append(f" {name}: 0 barre · gate {dec} (−{giorni}g)")
|
||||
out.append(f" {name}: 0 barre · {gate_txt}")
|
||||
continue
|
||||
r = np.array([x[key] for x in rows], float)
|
||||
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
|
||||
tot = float(np.prod(1 + r) - 1) * 100
|
||||
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · gate {dec} (−{giorni}g)")
|
||||
out.append(f" {name}: {len(rows)}g · {tot:+.2f}% · Sh {sh:+.2f} · {gate_txt}")
|
||||
except Exception:
|
||||
out.append(f" {name}: log illeggibile · gate {dec} (−{giorni}g)")
|
||||
out.append(f" {name}: log illeggibile · {gate_txt}")
|
||||
return out
|
||||
|
||||
|
||||
@@ -129,8 +195,13 @@ def last_trade() -> str:
|
||||
def build() -> str:
|
||||
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
|
||||
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
|
||||
L += book_state()
|
||||
L += ["", "<b>Perche' non opera</b>"]
|
||||
righe_conto, flat = book_state()
|
||||
L += righe_conto
|
||||
# (#35 C14) "Perche' non opera" e' vero solo se il libro e' DAVVERO flat (nessuna posizione,
|
||||
# nessun target: gia' letto sopra da book_state, nessuna lettura nuova). Se non e' flat (ha
|
||||
# posizioni o un target diverso da zero) o non si sa (book non leggibile), l'header fisso
|
||||
# mentirebbe: si usa un titolo neutro.
|
||||
L += ["", "<b>Perche' non opera</b>" if flat else "<b>Stato trend</b>"]
|
||||
L += trend_state()
|
||||
L += ["", "<b>Ordini</b>", last_trade()]
|
||||
L += ["", "<b>Monitor in osservazione</b>"]
|
||||
|
||||
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
|
||||
def sync(verbose: bool = True) -> dict:
|
||||
@@ -25,16 +26,51 @@ def sync(verbose: bool = True) -> dict:
|
||||
fills = [f for r in runs for f in r.fills]
|
||||
n_f = T.upsert_fills(con, fills)
|
||||
n_e = T.upsert_equity(con, [(r.ts_utc, r.equity, "cron_book") for r in runs if r.equity is not None])
|
||||
|
||||
# #40 (TradingBook#11): arricchimento PRIMA dei round-trip, cosi' il FIFO vede gia' i prezzi
|
||||
# pieni e le ore vere delle righe abbinate al jsonl, non quelle (arrotondate, di inizio giro)
|
||||
# di cron_book.log. `righe_jsonl` si legge una volta sola e serve anche al controllo di
|
||||
# conteggio (#37 C11) qui sotto.
|
||||
righe_jsonl = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
|
||||
n_arricchiti, n_ambigui = T.arricchisci_da_jsonl(con, righe_jsonl)
|
||||
|
||||
n_r = T.rebuild_roundtrips(con)
|
||||
if runs:
|
||||
T.set_meta(con, "ultimo_giro", runs[-1].ts_utc)
|
||||
T.set_meta(con, "ultimo_sync", T.ora())
|
||||
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
|
||||
tot_e = con.execute("SELECT COUNT(*) c FROM equity").fetchone()["c"]
|
||||
|
||||
# issue #37 (C11). Dal 2026-08-24T01:07Z (T.CUTOFF_TS_VERO) book_executions.jsonl porta l'ora
|
||||
# VERA per-fill (docstring di tradesdb.py). Un conteggio diverso in quella finestra segnala
|
||||
# che una delle due fonti sta perdendo fill — questo controllo resta indipendente
|
||||
# dall'arricchimento sopra (che abbina riga per riga, non conta): un conteggio uguale non
|
||||
# esclude un abbinamento ambiguo, e viceversa. Notify UNA volta per episodio: marcatore in
|
||||
# `meta` (stessa regola di fee_watch #37 B9 — scritto solo se l'invio riesce, azzerato
|
||||
# quando i conteggi tornano uguali).
|
||||
n_jsonl_dal = sum(1 for r in righe_jsonl if str(r.get("ts_utc", "")) >= T.CUTOFF_TS_VERO)
|
||||
n_db_dal = con.execute("SELECT COUNT(*) c FROM fills WHERE ts_utc >= ?",
|
||||
(T.CUTOFF_TS_VERO,)).fetchone()["c"]
|
||||
diverge = n_jsonl_dal != n_db_dal
|
||||
gia_detto = T.get_meta(con, "conteggio_diverso_detto") == "1"
|
||||
if diverge:
|
||||
msg = f"⚠️ trades.db: {n_jsonl_dal} fill nel jsonl, {n_db_dal} nel DB dal 24/08"
|
||||
if verbose:
|
||||
print(f" {msg}")
|
||||
if not gia_detto:
|
||||
if notify("⚠️ trades.db — conteggio fill diverso dal jsonl dal 24/08",
|
||||
{"fill nel jsonl": n_jsonl_dal, "fill nel DB": n_db_dal}):
|
||||
T.set_meta(con, "conteggio_diverso_detto", "1")
|
||||
elif gia_detto:
|
||||
T.set_meta(con, "conteggio_diverso_detto", "0")
|
||||
|
||||
if verbose:
|
||||
print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}")
|
||||
print(f" arricchiti dal jsonl: {n_arricchiti} | ambigui: {n_ambigui}")
|
||||
con.close()
|
||||
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot)
|
||||
return dict(nuovi_fill=n_f, nuovi_equity=n_e, roundtrip=n_r, totale_fill=tot,
|
||||
jsonl_dal_24_08=n_jsonl_dal, db_dal_24_08=n_db_dal,
|
||||
arricchiti=n_arricchiti, ambigui=n_ambigui)
|
||||
|
||||
|
||||
def reconcile() -> None:
|
||||
@@ -42,8 +78,8 @@ def reconcile() -> None:
|
||||
fills = [f for r in runs for f in r.fills]
|
||||
righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
|
||||
rec = T.reconcile(fills, righe)
|
||||
print(f"\n cron_book.log : {len(fills)} fill (con ORA VERA)")
|
||||
print(f" book_executions : {len(righe)} righe (senza ora)")
|
||||
print(f"\n cron_book.log : {len(fills)} fill (ora di inizio giro)")
|
||||
print(f" book_executions : {len(righe)} righe (ora VERA per-fill dal 24/08, #37 C11)")
|
||||
print(f" concordano : {len(rec['ok'])}")
|
||||
print(f" solo nel log : {len(rec['solo_log'])}")
|
||||
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
|
||||
@@ -125,7 +161,9 @@ def report() -> None:
|
||||
if ap:
|
||||
print(" posizioni aperte :")
|
||||
for a, d in sorted(ap.items()):
|
||||
print(f" {a} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} (dal {d['dal'][:16]})")
|
||||
marca = "" if d.get("direzione", "long") == "long" else " SHORT" # #32 C2
|
||||
print(f" {a}{marca} {d['qty']:.4f} @ medio ${d['prezzo_medio']:,.2f} "
|
||||
f"(dal {d['dal'][:16]})")
|
||||
else:
|
||||
print(" posizioni aperte : nessuna (flat)")
|
||||
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
||||
|
||||
@@ -59,8 +59,20 @@ la size. Percio' si SPAZIANO gli ordini (`--pausa`) e si fa backoff sul TEMPO, r
|
||||
solo dopo rifiuti ripetuti. Dimezzare la size a ogni rifiuto, come faceva la prima versione, e' la
|
||||
reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa.
|
||||
|
||||
uv run python scripts/live/usde_convert.py --quota 0.70 # piano, nessun ordine
|
||||
uv run python scripts/live/usde_convert.py --quota 0.70 --esegui # manda l'ordine
|
||||
uv run python scripts/live/usde_convert.py --quota 0.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch)
|
||||
uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine
|
||||
|
||||
LOCK SUL CONTO (#36). Un lancio a MANO con --esegui condivide il conto con cuscino_watch e
|
||||
book_execute (cron_cuscino.sh/cron_book.sh, entrambi dentro `flock -n logs/.conto.lock`): senza
|
||||
lo stesso lock un ordine a mano nello stesso minuto del cron puo' convertire due volte verso
|
||||
bersagli diversi (limite D5 gia' dichiarato sopra). Si lancia quindi con:
|
||||
|
||||
flock logs/.conto.lock uv run python scripts/live/usde_convert.py --quota 0.64 --esegui
|
||||
|
||||
⚠️ Qui NON senza `-n`: un lancio a mano puo' aspettare il turno invece di saltare. Questo script
|
||||
stesso NON prende il lock (vedi cuscino_watch.py): se lo facesse e girasse come figlio di
|
||||
cuscino_watch (che il lock lo tiene gia'), un secondo `flock -n` lo troverebbe occupato e
|
||||
fallirebbe subito, per costruzione, ogni singola volta.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -88,15 +100,63 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
|
||||
# questo epsilon il rumore float boccia il caso di pareggio.
|
||||
|
||||
|
||||
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
|
||||
"""Cuscino USDC di REGOLAMENTO = disaster-SL sulla massima esposizione lorda del libro.
|
||||
Derivato da config/live.json + src/live/book.py, mai ridichiarato qui."""
|
||||
cfg = json.loads((ROOT / "config" / "live.json").read_text())
|
||||
sl = float(cfg["disaster_sl_pct"])
|
||||
from src.live import book as B
|
||||
frac, n_asset = float(B.WEIGHT), 2
|
||||
lordo_max = equity_tot * frac * n_asset
|
||||
return lordo_max * sl, f"{n_asset} asset x frac {frac} x SL {sl:.0%} su ${equity_tot:,.2f}"
|
||||
# Il cuscino di regolamento vive in src/live/usde.py dal 10/09 (issue #3): cuscino_watch lo
|
||||
# sorveglia ogni ora e questo script lo rispetta a ogni piano — due lettori, UNA formula (P1).
|
||||
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
||||
cuscino_richiesto_usd = U.cuscino_richiesto_usd
|
||||
|
||||
# issue #26: label dei NOSTRI ordini sullo spot, per riconoscere i residui che questo script
|
||||
# stesso ha lasciato aperti (mai altri: un ordine con label diversa non e' nostro e non si tocca).
|
||||
LABEL_PREFIX = "usde-quota-"
|
||||
|
||||
|
||||
def _cancella(c, order_id: str) -> tuple[bool, str]:
|
||||
"""Cancella UN ordine. -> (ok, motivo se no). NON solleva: il chiamante decide (P3) — la
|
||||
lettura degli ordini aperti solleva (P5), la cancellazione di un singolo ordine no, perche'
|
||||
il chiamante deve poter fermarsi con un messaggio invece di un traceback."""
|
||||
try:
|
||||
resp = c._unwrap(c._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
|
||||
except Exception as e: # noqa: BLE001 — si registra (P3)
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error":
|
||||
return False, str(resp.get("error", resp)) if isinstance(resp, dict) else str(resp)
|
||||
return True, ""
|
||||
|
||||
|
||||
def cancella_residui(c) -> list[str]:
|
||||
"""issue #26 — GTC mai cancellati. Il gateway cerbero-mcp NON accetta `time_in_force`
|
||||
(PlaceOrderReq non ha il campo, extra ignorati: gitea:Adriano/Cerbero-mcp#1): un limit
|
||||
riempito solo in parte resta APERTO per sempre e si accumula a ogni giro invece di sparire
|
||||
con l'ordine. Qui si legge lo spot U.SPOT e si cancella SOLO cio' che porta la NOSTRA label
|
||||
(`usde-quota-*`): un ordine con altra label non e' nostro e non si tocca.
|
||||
|
||||
-> lista degli order_id cancellati. Se la LETTURA degli ordini aperti fallisce SOLLEVA: non
|
||||
si piazza un nuovo ordine senza sapere cosa c'e' gia' aperto (P5, non si ordina al buio)."""
|
||||
# non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC (quotazione USDC o
|
||||
# base USDE): si interrogano entrambe e si unisce per order_id, cosi' non si perde il residuo
|
||||
# se il venue lo elenca sotto la base.
|
||||
visti = {}
|
||||
for valuta in ("USDC", "USDE"):
|
||||
out = c._unwrap(c._post("/mcp-deribit/tools/get_open_orders",
|
||||
{"currency": valuta, "kind": "spot", "type": "all"}))
|
||||
ordini = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||
for o in ordini:
|
||||
if isinstance(o, dict) and o.get("order_id") is not None:
|
||||
visti[o["order_id"]] = o
|
||||
ordini = list(visti.values())
|
||||
cancellati = []
|
||||
for o in ordini:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
instrument = o.get("instrument") or o.get("instrument_name")
|
||||
label = o.get("label") or ""
|
||||
if instrument == U.SPOT and label.startswith(LABEL_PREFIX):
|
||||
oid = o.get("order_id")
|
||||
ok, motivo = _cancella(c, oid)
|
||||
if not ok:
|
||||
raise RuntimeError(f"cancellazione del residuo {oid} ({label}) fallita: {motivo}")
|
||||
cancellati.append(oid)
|
||||
return cancellati
|
||||
|
||||
|
||||
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
|
||||
@@ -165,7 +225,9 @@ def piano(eq_usdc: float, eq_usde: float, px: float, quota_target: float,
|
||||
motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})")
|
||||
if abs(delta_usd) < MIN_ORDER_USD:
|
||||
motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}")
|
||||
if cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
|
||||
# il tetto del venue limita l'ACQUISTO di USDE: una VENDITA va sempre verso il tetto, e bloccarla
|
||||
# renderebbe morta la riconversione automatica di cuscino_watch (revisione fable 10/09)
|
||||
if side == "buy" and cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
|
||||
motivi.append(
|
||||
f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} "
|
||||
f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). "
|
||||
@@ -235,6 +297,9 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
residui = cancella_residui(c)
|
||||
print(f" residui cancellati all'avvio: {len(residui)}"
|
||||
+ (f" ({', '.join(residui)})" if residui else ""))
|
||||
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
|
||||
for _ in range(60):
|
||||
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
||||
@@ -277,6 +342,18 @@ def main() -> int:
|
||||
"amount": amt, "filled": filled})
|
||||
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
||||
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
|
||||
# issue #26: un GTC riempito solo in parte (o per niente) resta APERTO — il gateway non
|
||||
# accetta time_in_force, quindi la cancellazione e' tutta qui. Se la cancel fallisce si
|
||||
# FERMA il ciclo: mai un secondo ordine mentre il residuo del primo e' ancora vivo.
|
||||
if filled < amt:
|
||||
oid = order.get("order_id")
|
||||
ok, motivo = _cancella(c, oid)
|
||||
fatti[-1]["cancellato"] = True if ok else f"errore: {motivo}"
|
||||
if not ok:
|
||||
print(f" ✗ cancellazione del residuo {oid} FALLITA: {motivo}"
|
||||
" — mi fermo (mai un secondo ordine con un residuo vivo)")
|
||||
break
|
||||
print(f" · residuo cancellato: order_id {oid} (filled {filled:,.0f}/{amt:,.0f})")
|
||||
if filled <= 0:
|
||||
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
||||
time.sleep(a.pausa)
|
||||
|
||||
+105
-61
@@ -58,14 +58,27 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
|
||||
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
|
||||
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
|
||||
|
||||
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
|
||||
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
|
||||
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
|
||||
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
|
||||
# ogni giorno finche' la riga non viene tolta.
|
||||
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
|
||||
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
|
||||
|
||||
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
|
||||
# (1) `count` massimo di Deribit per get_user_trades_by_instrument: 1000. Il 07/09 il lettore
|
||||
# chiedeva limit=50 e la sonda del 06/09 aveva fatto 54 ordini: i 4 non letti (400 USDE)
|
||||
# sono finiti nel «reward» (issue #2). Se il venue restituisce ESATTAMENTE `limit` righe la
|
||||
# lista puo' essere troncata e la somma NON e' leggibile (P12: non si attribuisce).
|
||||
# (2) il gateway chiama l'endpoint senza `historical`/timestamp: Deribit restituisce solo le
|
||||
# ultime 24h — DOCUMENTATO ("Accessing historical trades and orders using API": recent
|
||||
# trades 24h, orders 30 min, count max 1000) e misurato (il fill 08/09 06:47Z invisibile
|
||||
# alle 12:55Z del 10/09, quello del 09/09 21:47Z visibile). Se l'ultima lettura e' piu'
|
||||
# vecchia della finestra, i trade fra la lettura e il bordo sono INVISIBILI e la somma non
|
||||
# e' leggibile.
|
||||
# ⚠️ BUCO DICHIARATO (D5): il cron gira ogni 24h + pochi secondi; con la tolleranza di 5 min
|
||||
# i trade nei primi ≤5 min dopo la lettura precedente sarebbero invisibili e sommati come
|
||||
# «leggibile». Innocuo: nessun attrezzo converte USDE alle 12:35 UTC, e senza tolleranza il
|
||||
# giro quotidiano sarebbe SEMPRE «non leggibile» e il tasso non avrebbe mai finestre nuove.
|
||||
TRADE_LIMIT = 1000
|
||||
FINESTRA_GATEWAY_MS = 24 * 3_600_000
|
||||
TOLL_FINESTRA_MS = 5 * 60_000
|
||||
|
||||
|
||||
def _parse(ts: str) -> datetime:
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
@@ -143,13 +156,6 @@ def rendimento(records: list[dict]) -> dict:
|
||||
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
||||
|
||||
|
||||
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
|
||||
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
|
||||
if verdetto_corrente != "IDONEO":
|
||||
return False
|
||||
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
|
||||
|
||||
|
||||
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||
"""PURA. Condizioni di allerta attive su un record."""
|
||||
if not rec:
|
||||
@@ -169,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||
return att
|
||||
|
||||
|
||||
def nuove_condizioni(att_now: set[str], att_prev: set[str], prev: dict | None) -> set[str]:
|
||||
"""PURA (issue #29 B2). Le condizioni da notificare in questo giro.
|
||||
|
||||
Fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si leggeva: un
|
||||
invio fallito restava "gia' detto" per l'episodio intero (l'ora dopo att_prev diceva ancora
|
||||
che la condizione c'era, quindi non era piu' "nuova"). Schema di cuscino_watch.py: una
|
||||
condizione conta come "gia' detta" solo se era attiva l'ultima volta E il suo invio e'
|
||||
RIUSCITO — cioe' compare in `allertati` del record precedente. Un record vecchio senza
|
||||
`allertati` (scritto prima di questo fix) vale come "tutto allertato": nessun rispam
|
||||
retroattivo delle condizioni gia' viste."""
|
||||
confermate_prev = set((prev or {}).get("allertati", att_prev))
|
||||
return att_now - (att_prev & confermate_prev)
|
||||
|
||||
|
||||
def _safe_client():
|
||||
try:
|
||||
from src.live.deribit import DeribitRead
|
||||
@@ -177,14 +197,36 @@ def _safe_client():
|
||||
return None
|
||||
|
||||
|
||||
def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
|
||||
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile."""
|
||||
def trade_copertura(n_righe: int, prev_ts_ms: int, now_ms: int,
|
||||
limit: int = TRADE_LIMIT, finestra_ms: int = FINESTRA_GATEWAY_MS,
|
||||
toll_ms: int = TOLL_FINESTRA_MS) -> str | None:
|
||||
"""PURA. None se la lista di trade COPRE l'intervallo (prev, now]; altrimenti il motivo per cui
|
||||
NON lo copre — e allora la somma non si usa (P12: un reward calcolato su una lista incompleta
|
||||
e' un'invenzione, il 07/09 valeva 400 USDE)."""
|
||||
if n_righe >= limit:
|
||||
return f"lista troncata: {n_righe} righe = limite {limit} del venue"
|
||||
if now_ms - prev_ts_ms > finestra_ms + toll_ms:
|
||||
ore = (now_ms - prev_ts_ms) / 3_600_000
|
||||
return (f"ultima lettura {ore:.1f}h fa, oltre la finestra di {finestra_ms / 3_600_000:.0f}h "
|
||||
f"del gateway: i trade piu' vecchi sono invisibili")
|
||||
return None
|
||||
|
||||
|
||||
def _trades_spot_da(client, ts_ms: int, now_ms: int | None = None
|
||||
) -> tuple[float | None, int | None, str | None]:
|
||||
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms.
|
||||
-> (somma, n, motivo). somma None = non leggibile, e `motivo` dice perche' (P4)."""
|
||||
if client is None:
|
||||
return None, None
|
||||
return None, None, "gateway non raggiungibile"
|
||||
try:
|
||||
rows = client.trade_history(U.SPOT, limit=50)
|
||||
except Exception:
|
||||
return None, None
|
||||
rows = client.trade_history(U.SPOT, limit=TRADE_LIMIT)
|
||||
except Exception as e:
|
||||
return None, None, f"trade_history: {type(e).__name__}"
|
||||
if now_ms is None:
|
||||
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
motivo = trade_copertura(len(rows), ts_ms, now_ms)
|
||||
if motivo is not None:
|
||||
return None, None, motivo
|
||||
tot, n = 0.0, 0
|
||||
for t in rows:
|
||||
if int(t.get("timestamp") or 0) <= ts_ms:
|
||||
@@ -192,7 +234,7 @@ def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
|
||||
amt = float(t.get("amount") or 0)
|
||||
tot += amt if (t.get("direction") or "").lower() == "buy" else -amt
|
||||
n += 1
|
||||
return round(tot, 8), n
|
||||
return round(tot, 8), n, None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -221,10 +263,11 @@ def main() -> int:
|
||||
if eq_usde is not None:
|
||||
usd, _nota = U.valuta(eq_usde, px)
|
||||
|
||||
trades_usde, n_trades = (None, None)
|
||||
trades_usde, n_trades, trades_motivo = (None, None, None)
|
||||
an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None)
|
||||
if stato == "OK" and prev is not None and prev.get("eq_usde") is not None:
|
||||
trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"]))
|
||||
trades_usde, n_trades, trades_motivo = _trades_spot_da(
|
||||
client, int(prev["ts"]), int(now.timestamp() * 1000))
|
||||
an = analizza(prev, eq_usde, trades_usde)
|
||||
|
||||
quota = None
|
||||
@@ -237,59 +280,63 @@ def main() -> int:
|
||||
stato=stato, motivo_blind=motivo_blind,
|
||||
eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd,
|
||||
eq_usdc=eq_usdc, quota=quota,
|
||||
trades_usde=trades_usde, n_trades=n_trades, **an,
|
||||
trades_usde=trades_usde, n_trades=n_trades, trades_motivo=trades_motivo, **an,
|
||||
)
|
||||
v, v_motivo = verdetto(records + [rec], now)
|
||||
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
||||
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with STATE.open("a") as fh:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
|
||||
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit ---
|
||||
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
|
||||
# issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
|
||||
# non si leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema di
|
||||
# cuscino_watch.py: `allertati` nel record = le condizioni il cui invio E' RIUSCITO. Lo stato
|
||||
# si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
|
||||
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
|
||||
nuove = att_now - att_prev
|
||||
nuove = nuove_condizioni(att_now, att_prev, prev)
|
||||
confermate_prev = set((prev or {}).get("allertati", att_prev))
|
||||
inviati = []
|
||||
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
|
||||
if "DEPEG_CRIT" in att_now:
|
||||
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
||||
"collaterale": f"${usd:,.0f}" if usd else "?",
|
||||
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
||||
ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
||||
"collaterale": f"${usd:,.0f}" if usd else "?",
|
||||
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
||||
inviati.append("DEPEG_CRIT")
|
||||
if ok:
|
||||
allertati.add("DEPEG_CRIT")
|
||||
if "DEPEG_WARN" in nuove:
|
||||
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
||||
"nota": "entra gia' nel sizing orario del book"})
|
||||
ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
||||
"nota": "entra gia' nel sizing orario del book"})
|
||||
inviati.append("DEPEG_WARN")
|
||||
if ok:
|
||||
allertati.add("DEPEG_WARN")
|
||||
if "QUOTA_OVER" in nuove:
|
||||
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
||||
"tetto": f"{c['quota_max_frac']:.0%}",
|
||||
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
||||
ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
||||
"tetto": f"{c['quota_max_frac']:.0%}",
|
||||
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
||||
inviati.append("QUOTA_OVER")
|
||||
if ok:
|
||||
allertati.add("QUOTA_OVER")
|
||||
if "BLIND" in nuove:
|
||||
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||
ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||
inviati.append("BLIND")
|
||||
if ok:
|
||||
allertati.add("BLIND")
|
||||
rec["allertati"] = sorted(allertati)
|
||||
|
||||
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
||||
if v != v_prev:
|
||||
if v == "IDONEO":
|
||||
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
|
||||
"prossimo passo": "decisione di quota dell'operatore (tetto allerta 50%)"})
|
||||
"prossimo passo": f"decisione di quota dell'operatore (tetto allerta {c['quota_max_frac']:.0%})"})
|
||||
elif v == "NON_IDONEO":
|
||||
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
||||
inviati.append(f"VERDETTO->{v}")
|
||||
|
||||
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
|
||||
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
|
||||
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
|
||||
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
|
||||
rend = rendimento(records + [rec])
|
||||
if quota_da_decidere(now, v):
|
||||
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
||||
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
|
||||
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
|
||||
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
|
||||
"rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni",
|
||||
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
|
||||
inviati.append("QUOTA_DA_DECIDERE")
|
||||
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with STATE.open("a") as fh:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
|
||||
cambiato = bool(inviati) or v != v_prev
|
||||
if not quiet or cambiato:
|
||||
@@ -302,17 +349,16 @@ def main() -> int:
|
||||
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
|
||||
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
|
||||
if quota is not None:
|
||||
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
|
||||
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
|
||||
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
|
||||
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
|
||||
# Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
|
||||
# dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
|
||||
# NON e' piu' il Segregated Standard Margin di prima di quella data.
|
||||
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
||||
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
|
||||
print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non"
|
||||
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
|
||||
print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
|
||||
f" {c['haircut']:.0%})")
|
||||
if an["delta"] is not None:
|
||||
attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None
|
||||
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
|
||||
if an["reward_stimato"] is None
|
||||
else f"reward stimato {an['reward_stimato']:+.6f} USDE"
|
||||
+ (" [con trade nel mezzo]" if an["con_trade"] else ""))
|
||||
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")
|
||||
@@ -323,8 +369,6 @@ def main() -> int:
|
||||
f" (tot {rend['tot']:+.6f} USDE)")
|
||||
if rend["finestre"] < 4:
|
||||
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
||||
if v == "IDONEO" and not quota_da_decidere(now, v):
|
||||
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
|
||||
if inviati:
|
||||
print(f" allarmi : {', '.join(inviati)}")
|
||||
return 0
|
||||
|
||||
@@ -40,10 +40,12 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
FEED = "https://insights.deribit.com/exchange-updates/feed/"
|
||||
STATE = ROOT / "data" / "live" / "venue_news.jsonl"
|
||||
RUNS = ROOT / "data" / "venue_news" / "runs.jsonl"
|
||||
|
||||
# Parole che alzano un annuncio a 🚨. Le prime sono FISSE (categorie di rischio che ci toccano
|
||||
# per costruzione); le altre si DERIVANO dal codice, sotto.
|
||||
@@ -140,6 +142,7 @@ def main() -> int:
|
||||
v["seed"] = primo_giro
|
||||
f.write(json.dumps(v) + "\n")
|
||||
nuove.append(v)
|
||||
registra_giro(RUNS, voci=len(tutte), nuove=len(nuove))
|
||||
|
||||
# Primo giro: si semina lo storico SENZA allertare (P9: l'allarme massimo non si spende
|
||||
# per un arretrato di 10 voci, o non verra' letto il giorno che e' vero).
|
||||
|
||||
@@ -16,10 +16,13 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.deribit import check_specs # noqa: E402
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
from src.live.notifier import notify, ultimo_errore # noqa: E402
|
||||
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
|
||||
run_once)
|
||||
|
||||
RUNS = ROOT / "data" / "venue_watch" / "runs.jsonl"
|
||||
|
||||
|
||||
def _titolo(rep: dict) -> str:
|
||||
"""⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
|
||||
@@ -28,6 +31,8 @@ def _titolo(rep: dict) -> str:
|
||||
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
|
||||
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
|
||||
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
|
||||
if rep.get("severity") == "blind":
|
||||
return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue"
|
||||
if rep.get("severity") == "warn":
|
||||
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
|
||||
@@ -70,6 +75,7 @@ def main() -> int:
|
||||
# che manda ordini. Il 18/08 Deribit ha cambiato tick e size dei perpetual lineari USDC e
|
||||
# niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni.
|
||||
spec = check_specs()
|
||||
registra_giro(RUNS, livelli=rep["levels"])
|
||||
if spec["divergenze"]:
|
||||
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
|
||||
righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / "
|
||||
|
||||
@@ -51,9 +51,11 @@ sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||
from cblib import ( # noqa: E402
|
||||
dvol_series, f_factors, load_chain, pick_legs, puts, spot_series,
|
||||
)
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json"
|
||||
RUNS = PROJECT_ROOT / "data" / "vrp_f_watch" / "runs.jsonl"
|
||||
|
||||
# --- config CONGELATA il 2026-07-30 ---------------------------------------
|
||||
STRUCTS = {
|
||||
@@ -190,6 +192,8 @@ def main() -> int:
|
||||
a["notified"] = bool(prev.get("notified", False))
|
||||
|
||||
_save_state(a)
|
||||
if not R.empty:
|
||||
registra_giro(RUNS, pairs=a["pairs"], ready=a["ready"])
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0910_macro_riskoff.py — «risk-off» di TP01 nella finestra ±2h intorno a FOMC e CPI: vale qualcosa?
|
||||
|
||||
DOMANDA (operatore, 2026-09-10, da un articolo su EA forex con «agente fondamentale»): se TP01 si
|
||||
mettesse FLAT nelle due ore prima e dopo la dichiarazione FOMC (14:00 ET) e la release CPI
|
||||
(08:30 ET), guadagnerebbe Sharpe o drift? E' un risk-off, quindi per la regola di §3 («non
|
||||
de-esporre il weekend») PARTE REFUTED e deve superare il null del de-levering (M5) e un null
|
||||
location-matched (M18) prima di essere creduto.
|
||||
|
||||
VERDETTO PRE-REGISTRATO (scritto PRIMA di vedere un numero):
|
||||
LEAD solo se TUTTE: (a) ΔSharpe(riskoff − base) > 0 con le fee di chiusura/riapertura;
|
||||
(b) il Δ vero sta al ≥95° percentile del null location-matched (stesso numero di eventi per
|
||||
anno, stesse ore del giorno, giorni estratti a caso); (c) Δ positivo in ≥70% degli anni con ≥5
|
||||
eventi (M9). Altrimenti REFUTED. E l'effetto si cita anche in €/giorno all'equity di oggi.
|
||||
|
||||
COSA NON C'E' (dichiarato): il funding (mai in nessun backtest, −2,16%/anno di drift) — irrilevante
|
||||
qui perche' identico nelle due varianti salvo ~1% delle ore; il rimbalzo di prezzo nella finestra
|
||||
(slippage: TP01 chiude e riapre a mid, in un'ora in cui lo spread e' largo — se il risultato fosse
|
||||
positivo andrebbe rimisurato col costo vero della finestra, C1). SKH01 non e' qui: e' un breakout su
|
||||
barra 230m, appiattirlo cambierebbe il percorso del segnale.
|
||||
|
||||
CALENDARIO (letto dal web il 2026-09-10, non ricordato — regola globale):
|
||||
FOMC: federalreserve.gov/monetarypolicy/fomccalendars.htm (2021-2026) e fomchistorical2019/2020.
|
||||
Ore: 14:00 ET per le riunioni programmate (standard Fed); le due d'emergenza del 2020
|
||||
(03/03 10:00 ET, 15/03 17:00 ET) con la loro ora; i «notation vote» ESCLUSI (nessuna
|
||||
dichiarazione a orario). ET→UTC con America/New_York (DST).
|
||||
CPI: bls.gov/bls/news-release/cpi.htm (archivio 2019-2026), 08:30 ET (bls.gov/schedule).
|
||||
L'archivio raggruppa per anno di RIFERIMENTO: le release di gennaio/febbraio stanno sotto
|
||||
l'anno prima — corrette qui (+1 anno), e la correzione e' verificata sul 2026 (13/01 e
|
||||
13/02 stanno nella schedule ufficiale 2026).
|
||||
|
||||
TP01 ORARIO: posizione decisa alla chiusura giornaliera (00:00 UTC) e tenuta per le 24 barre 1h del
|
||||
giorno (la stessa `held` di sleeves._tp01_returns, spalmata sulle ore); P&L orario = held × rendimento
|
||||
1h; fee sul turnover giornaliero. RISK-OFF: held = 0 nelle barre 1h che intersecano [t−2h, t+2h],
|
||||
con fee_side su chiusura e riapertura (|held| × 2 × fee).
|
||||
|
||||
uv run python scripts/research/r0910_macro_riskoff.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.data.downloader import load_data # noqa: E402
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
ET = ZoneInfo("America/New_York")
|
||||
FINESTRA_H = 2.0
|
||||
N_NULL = 1000
|
||||
SEED = 20260910
|
||||
EQUITY_OGGI = 4454.83 # trades_db --report 10/09 14:21Z, per il €/giorno
|
||||
EURUSD = 1.08 # ordine di grandezza, dichiarato: serve solo per la riga in €/g
|
||||
|
||||
# ------------------------------------------------------------------ calendario
|
||||
FOMC_PROGRAMMATE = """
|
||||
2019-01-30 2019-03-20 2019-05-01 2019-06-19 2019-07-31 2019-09-18 2019-10-30 2019-12-11
|
||||
2020-01-29 2020-04-29 2020-06-10 2020-07-29 2020-09-16 2020-11-05 2020-12-16
|
||||
2021-01-27 2021-03-17 2021-04-28 2021-06-16 2021-07-28 2021-09-22 2021-11-03 2021-12-15
|
||||
2022-01-26 2022-03-16 2022-05-04 2022-06-15 2022-07-27 2022-09-21 2022-11-02 2022-12-14
|
||||
2023-02-01 2023-03-22 2023-05-03 2023-06-14 2023-07-26 2023-09-20 2023-11-01 2023-12-13
|
||||
2024-01-31 2024-03-20 2024-05-01 2024-06-12 2024-07-31 2024-09-18 2024-11-07 2024-12-18
|
||||
2025-01-29 2025-03-19 2025-05-07 2025-06-18 2025-07-30 2025-09-17 2025-10-29 2025-12-10
|
||||
2026-01-28 2026-03-18 2026-04-29 2026-06-17 2026-07-29
|
||||
""".split()
|
||||
FOMC_EMERGENZA = [("2020-03-03", "10:00"), ("2020-03-15", "17:00")]
|
||||
|
||||
# archivio BLS, ordine della pagina (per anno di riferimento): le release di gen/feb vanno +1 anno
|
||||
CPI_ARCHIVIO = """
|
||||
2026-08-12 2026-07-14 2026-06-10 2026-05-12 2026-04-10 2026-03-11 2026-02-13
|
||||
2025-01-13 2025-12-18 2025-10-24 2025-09-11 2025-08-12 2025-07-15 2025-06-11 2025-05-13 2025-04-10 2025-03-12 2025-02-12
|
||||
2024-01-15 2024-12-11 2024-11-13 2024-10-10 2024-09-11 2024-08-14 2024-07-11 2024-06-12 2024-05-15 2024-04-10 2024-03-12 2024-02-13
|
||||
2023-01-11 2023-12-12 2023-11-14 2023-10-12 2023-09-13 2023-08-10 2023-07-12 2023-06-13 2023-05-10 2023-04-12 2023-03-14
|
||||
2022-02-14 2022-01-12 2022-12-13 2022-11-10 2022-10-13 2022-09-13 2022-08-10 2022-07-13 2022-06-10 2022-05-11 2022-04-12 2022-03-10
|
||||
2021-02-10 2021-01-12 2021-12-10 2021-11-10 2021-10-13 2021-09-14 2021-08-11 2021-07-13 2021-06-10 2021-05-12 2021-04-13 2021-03-10
|
||||
2020-02-10 2020-01-13 2020-12-10 2020-11-12 2020-10-13 2020-09-11 2020-08-12 2020-07-14 2020-06-10 2020-05-12 2020-04-10 2020-03-11
|
||||
2019-02-13 2019-01-14 2019-12-11 2019-11-13 2019-10-10 2019-09-12 2019-08-13 2019-07-11 2019-06-12 2019-05-10 2019-04-10 2019-03-12
|
||||
""".split()
|
||||
|
||||
|
||||
def cpi_date_corrette(seq: list[str]) -> list[str]:
|
||||
"""Una data di gen/feb che PRECEDE (nell'ordine della pagina, discendente) una data di
|
||||
marzo-dicembre dello STESSO anno e' la release dell'anno dopo."""
|
||||
out = []
|
||||
for i, d in enumerate(seq):
|
||||
y, m = int(d[:4]), int(d[5:7])
|
||||
if m <= 2:
|
||||
j = i + 1
|
||||
while j < len(seq) and int(seq[j][5:7]) <= 2:
|
||||
j += 1
|
||||
if j < len(seq) and int(seq[j][:4]) == y:
|
||||
d = f"{y + 1}{d[4:]}"
|
||||
out.append(d)
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def eventi() -> pd.DataFrame:
|
||||
rows = []
|
||||
for d in FOMC_PROGRAMMATE:
|
||||
rows.append(("FOMC", datetime.fromisoformat(f"{d} 14:00").replace(tzinfo=ET)))
|
||||
for d, hm in FOMC_EMERGENZA:
|
||||
rows.append(("FOMC", datetime.fromisoformat(f"{d} {hm}").replace(tzinfo=ET)))
|
||||
for d in cpi_date_corrette(CPI_ARCHIVIO):
|
||||
rows.append(("CPI", datetime.fromisoformat(f"{d} 08:30").replace(tzinfo=ET)))
|
||||
ev = pd.DataFrame(rows, columns=["tipo", "t_et"])
|
||||
ev["t"] = ev["t_et"].map(lambda x: pd.Timestamp(x.astimezone(ZoneInfo("UTC"))))
|
||||
return ev.sort_values("t").reset_index(drop=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ TP01 orario
|
||||
def tp01_orario() -> pd.DataFrame:
|
||||
"""-> DataFrame orario con held_BTC, held_ETH, r_BTC, r_ETH, fee_day (fee del turnover
|
||||
giornaliero, sulla prima barra del giorno)."""
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
parts = {}
|
||||
for a in ASSETS:
|
||||
h = load_data(a, "1h")
|
||||
hidx = pd.to_datetime(h["datetime"], utc=True)
|
||||
r1h = pd.Series(h["close"].values.astype(float), index=hidx).pct_change().fillna(0.0)
|
||||
d = resample_1d(h)
|
||||
tgt = tp.target_series(d)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
didx = pd.to_datetime(d["datetime"], utc=True).dt.floor("D")
|
||||
held_d = pd.Series(held, index=didx)
|
||||
turn_d = pd.Series(np.abs(np.diff(held, prepend=0.0)) * tp.fee_side, index=didx)
|
||||
day_of_h = hidx.dt.floor("D")
|
||||
held_h = held_d.reindex(day_of_h).values
|
||||
fee_h = np.where(hidx.dt.hour.values == 0, turn_d.reindex(day_of_h).values, 0.0)
|
||||
parts[a] = pd.DataFrame({f"held_{a}": held_h, f"r_{a}": r1h.values, f"fee_{a}": fee_h}, index=hidx)
|
||||
J = pd.concat(parts.values(), axis=1, join="inner").dropna()
|
||||
return J
|
||||
|
||||
|
||||
def pnl(J: pd.DataFrame, flat_mask: np.ndarray | None, fee_side: float) -> pd.Series:
|
||||
"""Rendimento orario del portafoglio 50/50. `flat_mask` True = barra a esposizione zero
|
||||
(con fee di chiusura all'ingresso nella finestra e di riapertura all'uscita)."""
|
||||
out = np.zeros(len(J))
|
||||
for a in ASSETS:
|
||||
held = J[f"held_{a}"].values.copy()
|
||||
fee = J[f"fee_{a}"].values.copy()
|
||||
if flat_mask is not None:
|
||||
h0 = held.copy()
|
||||
held = np.where(flat_mask, 0.0, held)
|
||||
# transizioni dentro/fuori finestra: |Δheld| × fee, oltre al turnover giornaliero
|
||||
extra = np.abs(np.diff(held, prepend=held[0])) - np.abs(np.diff(h0, prepend=h0[0]))
|
||||
fee = fee + np.clip(extra, 0, None) * fee_side
|
||||
out += 0.5 * (held * J[f"r_{a}"].values - fee)
|
||||
return pd.Series(out, index=J.index)
|
||||
|
||||
|
||||
def maschera(J: pd.DataFrame, tempi: pd.Series, w_h: float = FINESTRA_H) -> np.ndarray:
|
||||
"""Barre 1h (open-labeled) che intersecano [t−w, t+w] per almeno un evento."""
|
||||
idx = J.index.values.astype("datetime64[ns]")
|
||||
m = np.zeros(len(J), dtype=bool)
|
||||
w = np.timedelta64(int(w_h * 3600), "s")
|
||||
one_h = np.timedelta64(3600, "s")
|
||||
for t in tempi:
|
||||
t64 = np.datetime64(pd.Timestamp(t).tz_convert("UTC").tz_localize(None))
|
||||
lo, hi = np.searchsorted(idx, t64 - w - one_h, side="right"), np.searchsorted(idx, t64 + w, side="left")
|
||||
m[lo:hi] = True
|
||||
return m
|
||||
|
||||
|
||||
def sharpe_h(s: pd.Series) -> float:
|
||||
return float(s.mean() / s.std() * np.sqrt(24 * 365.25)) if s.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def sharpe_d(s: pd.Series) -> float:
|
||||
d = s.groupby(s.index.floor("D")).sum()
|
||||
return float(d.mean() / d.std() * np.sqrt(365.25)) if d.std() > 0 else float("nan")
|
||||
|
||||
|
||||
def drift_annuo(s: pd.Series) -> float:
|
||||
return float(s.mean() * 24 * 365.25)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ misura
|
||||
def misura() -> dict:
|
||||
J = tp01_orario()
|
||||
fee_side = TrendPortfolio(**CANONICAL).fee_side
|
||||
ev = eventi()
|
||||
ev = ev[(ev["t"] >= J.index[0]) & (ev["t"] <= J.index[-1])].reset_index(drop=True)
|
||||
base = pnl(J, None, fee_side)
|
||||
m_all = maschera(J, ev["t"])
|
||||
ro = pnl(J, m_all, fee_side)
|
||||
ro_nofee = pnl(J, m_all, 0.0)
|
||||
|
||||
out = dict(n_ore=len(J), da=str(J.index[0]), a=str(J.index[-1]), n_ev=len(ev),
|
||||
n_fomc=int((ev.tipo == "FOMC").sum()), n_cpi=int((ev.tipo == "CPI").sum()),
|
||||
ore_finestra=int(m_all.sum()), quota_ore=float(m_all.mean()),
|
||||
base=dict(sh_d=sharpe_d(base), sh_h=sharpe_h(base), drift=drift_annuo(base)),
|
||||
riskoff=dict(sh_d=sharpe_d(ro), sh_h=sharpe_h(ro), drift=drift_annuo(ro)),
|
||||
riskoff_nofee=dict(sh_d=sharpe_d(ro_nofee), drift=drift_annuo(ro_nofee)))
|
||||
out["d_sh_d"] = out["riskoff"]["sh_d"] - out["base"]["sh_d"]
|
||||
out["d_drift"] = out["riskoff"]["drift"] - out["base"]["drift"]
|
||||
# cosa succede DENTRO la finestra (il P&L che il risk-off rinuncia)
|
||||
dentro = base[m_all]; fuori = base[~m_all]
|
||||
esposto = (J.loc[m_all, [f"held_{a}" for a in ASSETS]].abs().sum(axis=1) > 0)
|
||||
out["dentro"] = dict(n=int(m_all.sum()), n_esposte=int(esposto.sum()), media_h=float(dentro.mean()),
|
||||
t=float(dentro.mean() / dentro.std() * np.sqrt(len(dentro))) if dentro.std() > 0 else float("nan"),
|
||||
somma=float(dentro.sum()), media_fuori_h=float(fuori.mean()))
|
||||
# per tipo
|
||||
out["per_tipo"] = {}
|
||||
for tipo in ("FOMC", "CPI"):
|
||||
m = maschera(J, ev.loc[ev.tipo == tipo, "t"])
|
||||
r = pnl(J, m, fee_side)
|
||||
out["per_tipo"][tipo] = dict(n=int((ev.tipo == tipo).sum()), d_sh_d=sharpe_d(r) - out["base"]["sh_d"],
|
||||
d_drift=drift_annuo(r) - out["base"]["drift"], somma_dentro=float(base[m].sum()))
|
||||
# per anno (M9)
|
||||
anni = {}
|
||||
for y, g in base.groupby(base.index.year):
|
||||
my = m_all[base.index.year == y]
|
||||
ry = ro[base.index.year == y]
|
||||
ney = int((ev["t"].dt.year == y).sum())
|
||||
anni[int(y)] = dict(n_ev=ney, d_sh_d=sharpe_d(ry) - sharpe_d(g), somma_dentro=float(g[my].sum()),
|
||||
d_cum=float((1 + ry).prod() - (1 + g).prod()))
|
||||
out["anni"] = anni
|
||||
# null location-matched (M18): stessi conteggi per anno e stesse ORE del giorno, giorni a caso
|
||||
rng = np.random.default_rng(SEED)
|
||||
giorni_per_anno = {y: np.array(sorted(set(J.index[J.index.year == y].normalize()))) for y in J.index.year.unique()}
|
||||
d_null = np.empty(N_NULL)
|
||||
for k in range(N_NULL):
|
||||
t_fake = []
|
||||
for y, g in ev.groupby(ev["t"].dt.year):
|
||||
pool = giorni_per_anno.get(int(y))
|
||||
if pool is None or len(pool) == 0:
|
||||
continue
|
||||
days = rng.choice(pool, size=len(g), replace=False)
|
||||
for d, t in zip(days, g["t"]):
|
||||
t_fake.append(pd.Timestamp(d) + (t - t.normalize()))
|
||||
mk = maschera(J, pd.Series(t_fake))
|
||||
d_null[k] = sharpe_d(pnl(J, mk, fee_side)) - out["base"]["sh_d"]
|
||||
out["null"] = dict(n=N_NULL, media=float(d_null.mean()), p50=float(np.median(d_null)),
|
||||
p95=float(np.percentile(d_null, 95)), p05=float(np.percentile(d_null, 5)),
|
||||
pctl_vero=float((d_null < out["d_sh_d"]).mean() * 100))
|
||||
# €/giorno all'equity di oggi
|
||||
out["eur_giorno"] = out["d_drift"] * EQUITY_OGGI / EURUSD / 365.25
|
||||
return out
|
||||
|
||||
|
||||
def verdetto(m: dict) -> tuple[str, list[str]]:
|
||||
a = m["d_sh_d"] > 0
|
||||
b = m["null"]["pctl_vero"] >= 95.0
|
||||
anni_ok = [y for y, v in m["anni"].items() if v["n_ev"] >= 5]
|
||||
pos = sum(1 for y in anni_ok if m["anni"][y]["d_sh_d"] > 0)
|
||||
c = anni_ok and pos / len(anni_ok) >= 0.70
|
||||
righe = [f"(a) ΔSharpe giornaliero con fee > 0: {m['d_sh_d']:+.3f} → {'PASS' if a else 'FAIL'}",
|
||||
f"(b) Δ vero al ≥95° pctl del null location-matched: {m['null']['pctl_vero']:.1f}° → {'PASS' if b else 'FAIL'}",
|
||||
f"(c) Δ > 0 in ≥70% degli anni con ≥5 eventi: {pos}/{len(anni_ok)} → {'PASS' if c else 'FAIL'}"]
|
||||
return ("LEAD" if (a and b and c) else "REFUTED"), righe
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 88)
|
||||
print(" r0910 — RISK-OFF ±2h intorno a FOMC e CPI su TP01 (lente oraria, posizione giornaliera)")
|
||||
print("=" * 88)
|
||||
m = misura()
|
||||
print(f" ore: {m['n_ore']} {m['da'][:10]} → {m['a'][:10]} eventi: {m['n_ev']} "
|
||||
f"(FOMC {m['n_fomc']}, CPI {m['n_cpi']}) ore in finestra: {m['ore_finestra']} = {m['quota_ore']:.2%}")
|
||||
print(f"\n {'':14s} {'Sh giorn.':>10s} {'Sh orario':>10s} {'drift/anno':>11s}")
|
||||
for k in ("base", "riskoff"):
|
||||
v = m[k]; print(f" {k:14s} {v['sh_d']:>10.3f} {v['sh_h']:>10.3f} {v['drift']:>+11.2%}")
|
||||
v = m["riskoff_nofee"]; print(f" {'riskoff nofee':14s} {v['sh_d']:>10.3f} {'':>10s} {v['drift']:>+11.2%}")
|
||||
print(f"\n Δ riskoff − base: Sharpe giornaliero {m['d_sh_d']:+.3f} · drift {m['d_drift']:+.2%}/anno "
|
||||
f"· ≈ {m['eur_giorno']:+.3f} €/giorno a ${EQUITY_OGGI:,.0f}")
|
||||
d = m["dentro"]
|
||||
print(f" dentro la finestra: {d['n']} ore ({d['n_esposte']} esposte), media {d['media_h']*1e4:+.2f} bp/h "
|
||||
f"(fuori {d['media_fuori_h']*1e4:+.2f} bp/h), t = {d['t']:+.2f}, somma {d['somma']:+.2%}")
|
||||
print("\n per tipo:")
|
||||
for t, v in m["per_tipo"].items():
|
||||
print(f" {t:5s} n={v['n']:3d} ΔSh {v['d_sh_d']:+.3f} Δdrift {v['d_drift']:+.2%} P&L dentro {v['somma_dentro']:+.2%}")
|
||||
print("\n per anno (M9):")
|
||||
print(f" {'anno':>4s} {'ev':>3s} {'ΔSh':>7s} {'P&L dentro':>11s} {'Δcum':>8s}")
|
||||
for y, v in m["anni"].items():
|
||||
print(f" {y:>4d} {v['n_ev']:>3d} {v['d_sh_d']:>+7.3f} {v['somma_dentro']:>+11.2%} {v['d_cum']:>+8.2%}")
|
||||
n = m["null"]
|
||||
print(f"\n null location-matched (M18, {n['n']} estrazioni): ΔSh mediana {n['p50']:+.3f} "
|
||||
f"[p05 {n['p05']:+.3f}, p95 {n['p95']:+.3f}] — il Δ vero sta al {n['pctl_vero']:.1f}° percentile")
|
||||
v, righe = verdetto(m)
|
||||
print("\n VERDETTO PRE-REGISTRATO:")
|
||||
for r in righe:
|
||||
print(" " + r)
|
||||
print(f"\n ⇒ {v}")
|
||||
print("\n non modellato: funding (identico nelle due varianti salvo ~1% delle ore), slippage della "
|
||||
"finestra (spread largo: se il risultato fosse positivo andrebbe rimisurato col costo vero, C1).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+32
-8
@@ -58,9 +58,13 @@ REGOLE VINCOLANTI:
|
||||
5. Se noti qualcosa che merita un controllo umano, dillo in una frase, senza drammatizzare.
|
||||
6. Massimo %d parole. Prosa, niente elenchi puntati, niente titoli.
|
||||
|
||||
Il libro e' TP01 (trend long-flat, 75%%) + SKH01 (breakout, 25%%) su Deribit, capitale ~$635.
|
||||
Il libro e' TP01 (trend long-flat, 75%%) + SKH01 (breakout, 25%%) su Deribit; il capitale e'
|
||||
l'equity scritta nella pagina qui sotto, non un valore da ricordare.
|
||||
Il progetto e' esplicitamente scettico: il suo campione e' corto e il P&L non distingue ancora
|
||||
l'edge dalla fortuna. Scrivere con quella consapevolezza e' parte del compito.
|
||||
Un round-trip e' un lotto chiuso in FIFO (tradesdb.fifo_roundtrips): una vendita puo' chiudere
|
||||
piu' lotti in una volta, quindi vedere piu' round-trip che fill nello stesso giorno e' atteso,
|
||||
non un'anomalia da segnalare.
|
||||
""" % MAX_PAROLE
|
||||
|
||||
|
||||
@@ -202,9 +206,22 @@ def testata(voce: dict) -> str:
|
||||
p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
|
||||
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
|
||||
eq = p.get("equity_fine")
|
||||
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: "oggi" e' ieri -> "nel giorno". E se
|
||||
# ci sono movimenti di capitale CERTI (mov_giorno; bool() come journal.scorporo: assente o
|
||||
# 0 -> nessun movimento) la cifra da mostrare e' trading_giorno (al netto), non l'equity
|
||||
# grezza: altrimenti un versamento si legge come P&L (successo il 25/08, +$1.399 letti come
|
||||
# profitto). Se trading_giorno non e' calcolabile si resta su delta_equity, ma con l'etichetta
|
||||
# nuova: non e' "oggi", e non e' comunque il trading al netto.
|
||||
dq_t = p.get("trading_giorno")
|
||||
if p.get("mov_giorno") and dq_t is not None:
|
||||
nota_dq = f" ({dq_t:+.2f} nel giorno, di cui {p['mov_giorno']:+.2f} movimenti)"
|
||||
elif dq is not None:
|
||||
nota_dq = f" ({dq:+.2f} nel giorno)"
|
||||
else:
|
||||
nota_dq = ""
|
||||
r = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
|
||||
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
|
||||
+ ("" if dq is None else f" ({dq:+.2f} oggi)")
|
||||
+ nota_dq
|
||||
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"
|
||||
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
|
||||
f"{p['trading_da_arming']:+.2f})"
|
||||
@@ -224,15 +241,22 @@ def per_telegram(voce: dict, analisi: str, stato: str, motivi: list[str] | None
|
||||
"""
|
||||
testo = testata(voce)
|
||||
if analisi.strip():
|
||||
corpo = _b(analisi.strip())
|
||||
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
|
||||
grezzo = analisi.strip()
|
||||
if stato == "sospetta":
|
||||
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
|
||||
testo += "\n\n" + corpo
|
||||
# (#35 C13) il taglio va sul GREZZO, PRIMA di _b (html.escape) e PRIMA di **-><b>: un
|
||||
# taglio sul testo gia' composto puo' spezzare un'entita' (es. "&am") o lasciare un <b>
|
||||
# aperto, e Telegram rifiuta l'INTERO messaggio ("can't parse entities") — il taglio si
|
||||
# perde tutto, non si accorcia. Il marcatore si aggiunge dopo, fuori dall'escape (e'
|
||||
# testo nostro, senza `&`/`<`: non ha bisogno di escape).
|
||||
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
|
||||
budget = LIMITE_TELEGRAM - len(testo) - len("\n\n") - len(taglio)
|
||||
tagliata = len(grezzo) > budget
|
||||
if tagliata:
|
||||
grezzo = grezzo[:max(budget, 0)]
|
||||
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", _b(grezzo)) # il grassetto del giornale
|
||||
testo += "\n\n" + corpo + (taglio if tagliata else "")
|
||||
else:
|
||||
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
|
||||
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
|
||||
if len(testo) > LIMITE_TELEGRAM:
|
||||
taglio = "\n\n<i>[…tagliato: la voce intera e' in docs/journal/%s.md]</i>" % voce["giorno"]
|
||||
testo = testo[:LIMITE_TELEGRAM - len(taglio)] + taglio
|
||||
return testo
|
||||
|
||||
+42
-5
@@ -4,8 +4,8 @@ TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit e
|
||||
posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in
|
||||
software in un unico target netto, e si manda UN ordine per asset per raggiungerlo.
|
||||
|
||||
Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni
|
||||
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
||||
Formula (preserva il budget di rischio per-asset di config (CAP sotto: `max_notional_per_asset_usd`
|
||||
o `_frac`), split 75/25; coerente col blend di ritorni deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
||||
|
||||
net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP )
|
||||
|
||||
@@ -13,8 +13,8 @@ deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
||||
tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target
|
||||
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
|
||||
E = equity reale del conto (fallback paper se offline)
|
||||
CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE
|
||||
DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la
|
||||
CAP = cap notional per-asset. Fisso = `max_notional_per_asset_usd` di config, OPPURE
|
||||
DINAMICO = equity * `max_notional_per_asset_frac` (es. 0.5 -> equity/2) se la
|
||||
frazione e' presente in config -> il cap cresce col capitale e non strozza il
|
||||
book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale
|
||||
fidata; su fallback/offline si torna al cap fisso (protezione downside).
|
||||
@@ -136,6 +136,42 @@ def _scala(real_equity: float | None = None, eq_fallback: str | None = None,
|
||||
return s
|
||||
|
||||
|
||||
def leva_tetto(cfg: dict | None = None) -> tuple[float, str]:
|
||||
"""Leva lorda massima che il libro PUO' prendere. UNA formula, non tre (#28): prima era
|
||||
ridichiarata in `usde.frazione_cuscino` (N_ASSET fisso, senza scala) e nella regola `leva` del
|
||||
giornale (senza scala) -> a scala 1,25 il cuscino USDC risultava 0,30 dell'equity invece di
|
||||
0,375, e la regola `leva` mostrava un tetto diverso da quello che il libro rispetta davvero.
|
||||
|
||||
leva = len(ASSETS) x min(WEIGHT, frac) x scala, al piu' LEVA_LORDA_MAX. -> (leva, come).
|
||||
- frac = cfg["max_notional_per_asset_frac"] se presente, altrimenti WEIGHT. Il min(WEIGHT, frac)
|
||||
viene dal fatto che `book_net_target` non supera mai weight*equity*(W_TP01+W_SKH) =
|
||||
weight*equity (i due pesi sommano a 1): |raw| <= weight*equity, quindi |net_target| <=
|
||||
scala*min(cap, weight*equity) -- un frac oltre WEIGHT alza il cap nominale ma non la leva
|
||||
REALE che il libro puo' esprimere.
|
||||
- scala = la scala DICHIARATA (`_scala(real_equity=1.0, cfg=cfg)`, gia' validata): un tetto
|
||||
descrive quanto la config AUTORIZZA, non quanto un giro con equity illeggibile ha preso.
|
||||
- Su `ScalaNonAutorizzata` (config fuori scaletta o fuori tetto) NON si solleva: un
|
||||
sorvegliante (`cuscino_watch`) non deve morire proprio quando la config e' sbagliata
|
||||
(verifica fable B5) -> si ritorna il tetto di CODICE, dichiarando l'anomalia in `come`.
|
||||
"""
|
||||
if cfg is None:
|
||||
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||
try:
|
||||
scala = _scala(real_equity=1.0, cfg=cfg)
|
||||
except ScalaNonAutorizzata as e:
|
||||
breve = str(e).splitlines()[0]
|
||||
breve = breve if len(breve) <= 80 else breve[:77] + "..."
|
||||
return LEVA_LORDA_MAX, (f"config fuori scaletta/tetto ({breve}): "
|
||||
f"tetto di codice LEVA_LORDA_MAX")
|
||||
raw_frac = cfg.get("max_notional_per_asset_frac")
|
||||
frac = float(raw_frac) if raw_frac is not None else WEIGHT
|
||||
frac_eff = min(WEIGHT, frac)
|
||||
leva = len(ASSETS) * frac_eff * scala
|
||||
base = f"{len(ASSETS)} asset x frac {frac_eff} x scala {scala} = {leva:.2f}x"
|
||||
if leva > LEVA_LORDA_MAX + 1e-12:
|
||||
return LEVA_LORDA_MAX, f"{base}, troncato al tetto di codice {LEVA_LORDA_MAX}x"
|
||||
return leva, base
|
||||
|
||||
|
||||
def _read_watermark() -> float | None:
|
||||
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
|
||||
@@ -269,6 +305,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
|
||||
live_feed: se True usa il feed 5m fresco effimero per SKH01 (src/live/livefeed.fresh_5m) ->
|
||||
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
|
||||
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
|
||||
cfg = json.loads(CONFIG.read_text()) if CONFIG.exists() else {}
|
||||
sh = shadow_report(offline=offline, equity_override=equity_override)
|
||||
equity = sh["equity"]
|
||||
# registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di
|
||||
@@ -315,7 +352,7 @@ def book_report(offline: bool = False, equity_override: float | None = None,
|
||||
net = book_net_target(tp_frac, sign, equity, cap, scala=scala)
|
||||
cur = float(a_rec["position_usd"])
|
||||
mark = a_rec["mark"]
|
||||
order = build_book_order(inst, net, cur, mark, min_usd=5.0)
|
||||
order = build_book_order(inst, net, cur, mark, min_usd=float(cfg.get("min_order_usd", 5.0)))
|
||||
if order:
|
||||
orders.append(order)
|
||||
assets.append(dict(
|
||||
|
||||
+37
-17
@@ -1,13 +1,16 @@
|
||||
"""Accesso Deribit MAINNET in SOLA LETTURA (via Cerbero MCP) + costruttore ordini deterministico.
|
||||
"""Accesso Deribit MAINNET (via Cerbero MCP) + costruttore ordini deterministico. La classe
|
||||
`DeribitRead` qui e' in SOLA LETTURA (nessun metodo di trading, by design); l'esecuzione REALE la
|
||||
estende in un modulo separato (`src.live.execution.DeribitTrader`) — non e' piu' un progetto
|
||||
futuro (#35 A9): e' ARMATA e LIVE su Deribit mainnet dal 2026-06-23 (`execution_enabled` in
|
||||
config/live.json), usata da `scripts/live/book_execute.py`.
|
||||
|
||||
Serve lo SHADOW MODE di TP01 (`scripts/live/live_trend.py`): legge prezzi / conto / posizioni REALI
|
||||
dal mainnet (token `.env.mainnet`) e costruisce gli ordini di ribilancio **senza inviarli**. Qui NON
|
||||
esiste alcun metodo di trading — by design: l'unica via per piazzare ordini sara' un modulo separato,
|
||||
abilitato esplicitamente, dopo la validazione shadow + micro-test.
|
||||
Da qui vengono prezzi / conto / posizioni REALI (token `.env.mainnet`): li usa sia il libro live
|
||||
(TP01+SKH01, via src.live.book + execution) sia lo shadow mode e i monitor di sorveglianza.
|
||||
|
||||
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet reale,
|
||||
e in questa fase solo in lettura. I contratti sono ristretti a BTC/ETH-PERPETUAL (inverse: `amount`
|
||||
in USD notional, step verificato su Deribit: BTC $10, ETH $1).
|
||||
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet
|
||||
reale. I contratti ESEGUITI sono i perp LINEARI USDC (`_USDC-PERPETUAL`, non inverse): `amount` in
|
||||
base-coin (BTC/ETH), margine e P&L in USDC — vedi `_CONTRACT` sotto per step/tick/minimi per
|
||||
strumento.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -236,11 +239,22 @@ class DeribitRead:
|
||||
return out.get("positions", []) if isinstance(out, dict) else []
|
||||
|
||||
def position_usd(self, instrument: str) -> float:
|
||||
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat."""
|
||||
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.
|
||||
|
||||
⚠️ Il gateway manda `size`/`size_currency` in ASSOLUTO e il segno a parte in `direction`
|
||||
(#17): letta com'era, uno short risultava un long, e close()/ensure_disaster_sl decidevano
|
||||
lato ordine e prezzo dello stop dal segno sbagliato. `direction` assente o `"zero"` -> ci si
|
||||
fida di `size` cosi' com'e' (compatibile con un gateway che un giorno mandasse gia' il segno)."""
|
||||
cur = _CONTRACT[instrument]["settle"]
|
||||
for p in self.positions(cur):
|
||||
if p.get("instrument_name") == instrument or p.get("instrument") == instrument:
|
||||
return float(p.get("size") or p.get("size_currency") or 0.0)
|
||||
s = float(p.get("size") or p.get("size_currency") or 0.0)
|
||||
direction = str(p.get("direction") or "").lower()
|
||||
if direction in ("sell", "short"):
|
||||
return -abs(s)
|
||||
if direction in ("buy", "long"):
|
||||
return abs(s)
|
||||
return s
|
||||
return 0.0
|
||||
|
||||
def trade_history(self, instrument: str, limit: int = 20) -> list[dict]:
|
||||
@@ -251,17 +265,23 @@ class DeribitRead:
|
||||
|
||||
def open_orders(self, instrument: str) -> list[dict]:
|
||||
"""Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger
|
||||
untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id."""
|
||||
untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.
|
||||
|
||||
⚠️ Se UNA delle due chiamate solleva, NON si ingoia (#24): prima ritornava [] anche a meta'
|
||||
letta, e un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione
|
||||
persa su 'all' — il chiamante (ensure_disaster_sl) deve saperlo, non decidere su dati a
|
||||
meta'. Il chiamante che vuole restare in piedi comunque (shadow.shadow_report) si mette il
|
||||
try/except da solo."""
|
||||
cur = _CONTRACT[instrument]["settle"]
|
||||
seen: dict = {}
|
||||
for typ in ("all", "trigger_all"):
|
||||
try:
|
||||
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
|
||||
{"currency": cur, "type": typ}))
|
||||
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||
for o in lst:
|
||||
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
||||
seen[o.get("order_id")] = o
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
|
||||
raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
|
||||
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||
for o in lst:
|
||||
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
||||
seen[o.get("order_id")] = o
|
||||
return list(seen.values())
|
||||
|
||||
+99
-39
@@ -4,12 +4,17 @@ Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA
|
||||
(Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee
|
||||
reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only.
|
||||
|
||||
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE (MAX_AMOUNT). Le CHIUSURE si
|
||||
tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione).
|
||||
Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
|
||||
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
|
||||
per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
|
||||
DEFAULT per chi non lo chiama (il micro-test, size fissa minima). Le CHIUSURE si tentano SEMPRE
|
||||
senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). Nessun
|
||||
parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
|
||||
|
||||
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Finestra d'uso attuale: micro-test (scripts/live/microtest.py).
|
||||
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente.
|
||||
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
|
||||
2026-06-23 (commit db738bc): questo NON e' un percorso di micro-test, e' l'esecuzione di produzione
|
||||
usata da `scripts/live/book_execute.py` (cron del book). Il micro-test
|
||||
(scripts/live/microtest.py) resta un percorso a parte, sotto lo stesso guardrail, col cap di
|
||||
DEFAULT (MAX_AMOUNT).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,11 +22,14 @@ from dataclasses import dataclass
|
||||
|
||||
from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price
|
||||
|
||||
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger
|
||||
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello
|
||||
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima.
|
||||
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
|
||||
# DEFAULT (in coin, sotto), usato SOLO quando nessuno ha chiamato set_open_cap() (il micro-test,
|
||||
# che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva set_open_cap()
|
||||
# dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop sopra il sizing
|
||||
# di TP01/book, non il sizing operativo. (#35 A9: via il "~$630/$430 su conto ~$600" cablato —
|
||||
# MAX_AMOUNT e' in coin, non in dollari, e non e' il tetto operativo da quando esiste set_open_cap.)
|
||||
ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"}
|
||||
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25}
|
||||
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap()
|
||||
FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat
|
||||
|
||||
|
||||
@@ -29,6 +37,19 @@ class GuardrailError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RebalanceParziale(RuntimeError):
|
||||
"""`rebalance_signed` ha eseguito almeno un fill (es. il close di un flip) e POI un passo
|
||||
successivo ha sollevato (es. l'open dall'altro lato). Il fill gia' eseguito NON e' un fill
|
||||
perso: `.fills` li porta al chiamante (che li registra) e `.causa` e' l'eccezione originale
|
||||
(il chiamante la rilancia dentro il suo handler di isolamento, #23)."""
|
||||
|
||||
def __init__(self, fills: list["Fill"], causa: Exception):
|
||||
self.fills = fills
|
||||
self.causa = causa
|
||||
super().__init__(f"rebalance parziale: {len(fills)} fill eseguiti, poi "
|
||||
f"{type(causa).__name__}: {causa}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fill:
|
||||
"""Esito verificato di un ordine reale."""
|
||||
@@ -55,6 +76,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
|
||||
class DeribitTrader(DeribitRead):
|
||||
"""Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._open_cap: dict[str, float] = {} # per-strumento, da set_open_cap() (#18)
|
||||
|
||||
def set_open_cap(self, instrument: str, usd_cap: float, mark: float) -> None:
|
||||
"""Tetto di apertura per `instrument`, derivato dal cap di config del libro (#18):
|
||||
amount_max = 1,10 * usd_cap / mark — 10% sopra il cap nozionale per non rifiutare
|
||||
un'apertura legittima per un arrotondamento di sizing/mark. Backstop anti-fat-finger
|
||||
sopra il sizing di TP01/book, non il sizing operativo (quello resta config/live.json).
|
||||
Senza chiamata, `_submit` resta sul DEFAULT MAX_AMOUNT (il micro-test)."""
|
||||
if usd_cap <= 0 or mark <= 0:
|
||||
raise ValueError(f"set_open_cap richiede usd_cap>0 e mark>0 (usd_cap={usd_cap}, mark={mark})")
|
||||
self._open_cap[instrument] = 1.10 * usd_cap / mark
|
||||
|
||||
def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool,
|
||||
label: str, order_type: str = "market", price: float | None = None) -> Fill:
|
||||
if instrument not in ALLOWED:
|
||||
@@ -62,7 +97,9 @@ class DeribitTrader(DeribitRead):
|
||||
if side not in ("buy", "sell"):
|
||||
raise GuardrailError(f"side non valido: {side}")
|
||||
if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre
|
||||
cap = MAX_AMOUNT.get(instrument, 0.0)
|
||||
# getattr, non self._open_cap diretto: un DeribitTrader creato con object.__new__
|
||||
# (test che saltano __init__) non ha l'attributo, e qui non deve dare AttributeError.
|
||||
cap = getattr(self, "_open_cap", {}).get(instrument, MAX_AMOUNT[instrument])
|
||||
if amount <= 0 or amount > cap:
|
||||
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
|
||||
if amount <= 0:
|
||||
@@ -150,36 +187,46 @@ class DeribitTrader(DeribitRead):
|
||||
- target ~0 -> close();
|
||||
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
||||
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
|
||||
Ritorna i Fill eseguiti."""
|
||||
Ritorna i Fill eseguiti.
|
||||
|
||||
⚠️ FILL PERSI (#23). Un flip e' DUE passi (close, poi open dall'altra parte): se il primo
|
||||
va a segno e il secondo solleva, il fill del primo passo NON deve sparire. Da qui in poi
|
||||
un'eccezione con almeno un fill gia' in `fills` diventa `RebalanceParziale(fills, causa)`
|
||||
invece di propagare nuda: il chiamante registra i fill fatti e poi rilancia `causa`."""
|
||||
cur = self.position_usd(instrument)
|
||||
if abs(target_notional_usd - cur) < min_usd:
|
||||
return []
|
||||
fills: list[Fill] = []
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
try:
|
||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||
if crossing: # flip: flatta, poi riparti da zero
|
||||
f = self.close(instrument, label="book-flip-close")
|
||||
if f:
|
||||
fills.append(f)
|
||||
cur = 0.0
|
||||
if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa)
|
||||
if abs(cur) > FLAT_USD:
|
||||
f = self.close(instrument, label="book-exit")
|
||||
if f:
|
||||
fills.append(f)
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
delta = target_notional_usd - cur
|
||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
||||
if amount <= 0:
|
||||
return fills
|
||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
||||
else:
|
||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
||||
return fills
|
||||
except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23)
|
||||
if fills:
|
||||
raise RebalanceParziale(fills, e) from e
|
||||
raise
|
||||
|
||||
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
||||
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
||||
@@ -194,10 +241,20 @@ class DeribitTrader(DeribitRead):
|
||||
return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
|
||||
|
||||
def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
|
||||
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente):
|
||||
- flat -> cancella eventuali bracket orfani;
|
||||
- long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione;
|
||||
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
|
||||
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente).
|
||||
|
||||
Stati possibili (`dict["state"]`):
|
||||
- `"flat"` -> nessuna posizione: cancella eventuali bracket orfani;
|
||||
- `"ok"` -> gia' coerente (1 bracket, amount~=, stop entro 5%): lascia com'e';
|
||||
- `"placed"` -> ricostruito (cancellato l'incoerente e ripiazzato) al primo o secondo
|
||||
tentativo, e il piazzamento e' verificato;
|
||||
- `"place-failed"` -> il ripiazzamento e' partito ma NON e' verificato (state/trade non
|
||||
confermano): l'ordine potrebbe non essere a book;
|
||||
- `"naked"` -> il bracket incoerente e' stato cancellato e il ripiazzamento e'
|
||||
fallito (eccezione) 2 volte di fila: la posizione e' SENZA stop;
|
||||
- `"unknown"` -> posizione o open_orders illeggibili (#24): NON cancella ne' piazza —
|
||||
decidere alla cieca su meta' dei dati e' peggio che aspettare il giro
|
||||
dopo. `notes` porta tipo ed errore della lettura fallita.
|
||||
|
||||
⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima
|
||||
e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il
|
||||
@@ -216,8 +273,11 @@ class DeribitTrader(DeribitRead):
|
||||
non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza
|
||||
misurarlo. Riparato il silenzio, non toccata la sequenza.
|
||||
"""
|
||||
pos = self.position_usd(instrument)
|
||||
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
|
||||
try:
|
||||
pos = self.position_usd(instrument)
|
||||
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
|
||||
except Exception as e: # noqa: BLE001 — si registra il motivo (P3); non si cancella ne' piazza (#24)
|
||||
return {"state": "unknown", "notes": f"{type(e).__name__}: {e}"}
|
||||
if abs(pos) < FLAT_USD:
|
||||
for o in brackets:
|
||||
self.cancel_order(o.get("order_id"))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""registra_giro — il giro che un sorvegliante ha misurato, come riga jsonl (2026-09-21, #16).
|
||||
|
||||
PERCHE' ESISTE. `cc01_regime_watch` e' morto 14 notti di fila (#10) senza che nessuno lo vedesse,
|
||||
perche' non scriveva niente da sorvegliare: `monitor_health` guarda solo le serie che i monitor
|
||||
scrivono, e un monitor silenzioso non e' distinguibile da uno che dice "tutto ok". La riparazione
|
||||
di cc01 (`data/cc01_regime/runs.jsonl`, commit 31f5915) e' il modello: qui si generalizza a ogni
|
||||
sorvegliante, cosi' un monitor fermo diventa FERMO in `monitor_health`, non invisibile.
|
||||
|
||||
Un registro che fallisce non deve cambiare l'esito del sorvegliante che lo scrive: `registra_giro`
|
||||
non solleva mai, e su errore lo dice su stderr e ritorna False.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def registra_giro(path: Path, **campi) -> bool:
|
||||
"""Appende `{"ts": <ms epoch>, **campi}` a `path` (jsonl, una riga). Crea la cartella se manca.
|
||||
|
||||
Ritorna True se la riga e' stata scritta, False su OSError (disco pieno, permessi, percorso
|
||||
invalido...) dopo aver scritto il motivo su stderr. Non solleva mai: la misura del
|
||||
sorvegliante e' gia' fatta, e un registro che fallisce non deve buttarla via.
|
||||
"""
|
||||
riga = {"ts": int(time.time() * 1000), **campi}
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(riga, default=str) + "\n")
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"registra_giro: non scrivo {path}: {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return False
|
||||
+61
-27
@@ -192,12 +192,10 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
|
||||
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
|
||||
try:
|
||||
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
||||
# la leva lorda massima che il libro puo' tenere: frac x n_asset x scala, mai oltre il
|
||||
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del
|
||||
# 01/09 entra qui da sola)
|
||||
scala = float(cfg.get("book_scale_k") or 1.0)
|
||||
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
|
||||
float(_book.LEVA_LORDA_MAX))
|
||||
# la leva lorda massima che il libro puo' tenere: UNA sola formula, in `book.leva_tetto`
|
||||
# (P1: derivata da `book`, non ridichiarata — #28 ne aveva tre diverse fra qui,
|
||||
# usde.frazione_cuscino e la regola `leva` sotto, e non tornavano)
|
||||
leva_tetto = _book.leva_tetto(cfg)[0]
|
||||
except Exception:
|
||||
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
|
||||
px = {a: _px_1h(a) for a in ASSETS} if candidati else {}
|
||||
@@ -248,12 +246,26 @@ def movimenti_capitale(con, fino_ts: str, rows=None) -> dict:
|
||||
+ (f" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
|
||||
+ (f": {d['nota']}" if d["nota"] else "")
|
||||
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] else ""))
|
||||
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]), None)
|
||||
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
||||
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
||||
# (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si
|
||||
# cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso
|
||||
# intervallo troverebbe il primo (gia' "dichiarato") e lo SOVRASCRIVEREBBE, perdendo
|
||||
# il primo importo invece di somarli.
|
||||
rilevato = next((e for e in eventi
|
||||
if e["ts_dopo"] == cur["ts_utc"] and e["fonte"] == "rilevato"), None)
|
||||
if rilevato is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
||||
rilevato.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
||||
classe="movimento", fonte="dichiarato+rilevato",
|
||||
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
|
||||
continue
|
||||
gia = next((e for e in eventi if e["ts_dopo"] == cur["ts_utc"]
|
||||
and e["fonte"] in ("dichiarato", "dichiarato+rilevato")), None)
|
||||
if gia is not None: # un altro dichiarato nello stesso intervallo: si SOMMA
|
||||
nuovo_delta = gia["delta"] + d["delta"]
|
||||
gia.update(delta=nuovo_delta,
|
||||
pct=(nuovo_delta / float(gia["prima"]) if gia["prima"] else None),
|
||||
dopo=float(gia["prima"]) + nuovo_delta,
|
||||
dichiarazione=(gia["dichiarazione"] + "; " + nota_d))
|
||||
continue
|
||||
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
||||
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
|
||||
delta=d["delta"],
|
||||
@@ -376,16 +388,23 @@ def pnl_giorno(con, giorno: date) -> dict:
|
||||
twr_da_arming=(r_twr["twr"] if cum is not None else None), twr_motivo=r_twr["motivo"])
|
||||
|
||||
|
||||
MINUTO_GIRO = 47 # deve restare d'accordo con la crontab e scripts/cron_book.sh (#35 C9)
|
||||
|
||||
|
||||
def salute(con, giorno: date) -> dict:
|
||||
"""Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso.
|
||||
|
||||
Un giorno in corso confrontato con 24 produce un allarme a ogni esecuzione — e un
|
||||
allarme che scatta sempre e' un allarme che non verra' letto il giorno che e' vero.
|
||||
|
||||
Il giro dell'ora CORRENTE scatta al minuto `MINUTO_GIRO` (il cron e' `47 * * * *`): prima
|
||||
di quel minuto non e' ancora avvenuto, e contarlo comunque (#35 C9) segnalava un giro
|
||||
"mancante" che semplicemente non era ancora il suo turno.
|
||||
"""
|
||||
lb = stato_libro(con, giorno)
|
||||
adesso = datetime.now(timezone.utc)
|
||||
in_corso = giorno == adesso.date()
|
||||
atteso = (adesso.hour + 1) if in_corso else 24
|
||||
atteso = (adesso.hour + (1 if adesso.minute >= MINUTO_GIRO + 1 else 0)) if in_corso else 24
|
||||
fatti = lb.get("giri", 0)
|
||||
return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso,
|
||||
giorno_in_corso=in_corso, giri_mancanti=max(0, atteso - fatti))
|
||||
@@ -546,17 +565,17 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
# --- quanto capitale e' esposto ----------------------------------------------------------
|
||||
lv = lb.get("leva_lorda")
|
||||
if lv is not None:
|
||||
# Il tetto si LEGGE da config/live.json. Ridichiararlo qui e' lo schema che questo
|
||||
# progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap, taratura
|
||||
# venue_watch, indice del namespace): un sorvegliante che ridichiara il proprio
|
||||
# bersaglio continua a passare il giorno che il bersaglio cambia.
|
||||
# Il tetto si LEGGE da `book.leva_tetto`, non ridichiarato qui. Ridichiararlo e' lo schema
|
||||
# che questo progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap,
|
||||
# taratura venue_watch, indice del namespace) — e una quinta, con una formula diversa da
|
||||
# quella di `movimenti_capitale` e di `usde.frazione_cuscino`, e' quella corretta da #28.
|
||||
from src.live import book as _book # lazy: prende il tetto vivo, anche monkeypatchato
|
||||
try:
|
||||
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
||||
cap_frac = float(cfg["max_notional_per_asset_frac"])
|
||||
tetto = cap_frac * len(lb["asset"])
|
||||
tetto, come = _book.leva_tetto(cfg)
|
||||
dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x "
|
||||
f"(`max_notional_per_asset_frac` {cap_frac:.2f} x {len(lb['asset'])} "
|
||||
f"asset, letto da `config/live.json`): margine {tetto - lv:.2f}x.")
|
||||
f"({come}, da `config/live.json` e `book.leva_tetto`): "
|
||||
f"margine {tetto - lv:.2f}x.")
|
||||
except Exception as e:
|
||||
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
|
||||
f"({type(e).__name__} su config/live.json).", "attenzione")
|
||||
@@ -591,16 +610,19 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
# il cumulato di TRADING: i movimenti di capitale certi escono, o un versamento
|
||||
# diventa "concentrazione di P&L" (e' successo: 25/08, +$1.399 letti come profitto)
|
||||
mv_ev = [e for e in (p.get("movimenti_arming") or []) if e["classe"] == "movimento"]
|
||||
g7s = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat()
|
||||
tot = e1 - e0 - sum(e["delta"] for e in mv_ev)
|
||||
picco = max(r["equity"] for r in eq)
|
||||
g7 = [r for r in eq if r["ts_utc"][:10] >= g7s]
|
||||
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s)
|
||||
# (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o
|
||||
# prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per
|
||||
# data (>= giorno-7) include anche le ore del giorno-7 gia' trascorse, cioe' 8 giorni di
|
||||
# calendario, non 7.
|
||||
g7_cutoff = (date.fromisoformat(voce["giorno"]) - timedelta(days=7)).isoformat() + "T23:59:59+00:00"
|
||||
base7 = next((r for r in reversed(eq) if r["ts_utc"] <= g7_cutoff), None)
|
||||
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"] > g7_cutoff)
|
||||
# Soglia di rilevanza: su un cumulato di pochi dollari la "concentrazione" e' aritmetica
|
||||
# vera e informazione zero. Una regola che si accende su $2 insegna a saltare la sezione.
|
||||
rilevante = abs(tot) >= max(5.0, 0.01 * e1)
|
||||
if rilevante and g7:
|
||||
quota = (e1 - g7[0]["equity"] - mv7) / tot
|
||||
if rilevante and base7 is not None:
|
||||
quota = (e1 - base7["equity"] - mv7) / tot
|
||||
if quota > 0.60:
|
||||
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
|
||||
"e' un evento.")
|
||||
@@ -609,10 +631,22 @@ def lettura(con, voce: dict) -> list[dict]:
|
||||
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
|
||||
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
|
||||
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
|
||||
if picco > 0:
|
||||
dd = (e1 - picco) / picco
|
||||
# (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il
|
||||
# TWR scorpora): sulla serie grezza un prelievo si legge come drawdown di trading e un
|
||||
# versamento alza il picco, mascherando un DD vero appena prima del versamento.
|
||||
mv_ord = sorted(mv_ev, key=lambda e: e["ts_dopo"])
|
||||
cum_mov, i_mov, eq_netto = 0.0, 0, []
|
||||
for r in eq:
|
||||
while i_mov < len(mv_ord) and r["ts_utc"] >= mv_ord[i_mov]["ts_dopo"]:
|
||||
cum_mov += mv_ord[i_mov]["delta"]
|
||||
i_mov += 1
|
||||
eq_netto.append(r["equity"] - cum_mov)
|
||||
picco_netto = max(eq_netto)
|
||||
if picco_netto > 0:
|
||||
dd = (eq_netto[-1] - picco_netto) / picco_netto
|
||||
if dd < -0.005:
|
||||
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco:,.2f}). "
|
||||
dire("drawdown", f"Equity **{100*dd:.2f}%** sotto il picco (${picco_netto:,.2f}, "
|
||||
"al netto dei movimenti di capitale certi). "
|
||||
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
|
||||
|
||||
# --- volatilita' implicita contro realizzata ---------------------------------------------
|
||||
|
||||
@@ -49,6 +49,9 @@ import numpy as np
|
||||
MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate
|
||||
MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura
|
||||
GRAZIA_PREMATURO_MIN = 5.0 # tolleranza fra chiusura della barra e mtime del file (clock/IO)
|
||||
SOGLIA_ORARI_H = 6.0 # (#30) max_age_h <= questa soglia = "sorvegliante orario": i tre
|
||||
# con max_age 3h (cuscino_watch, venue_watch, collect_chain) oggi,
|
||||
# derivato dalla tabella e non da una lista di nomi cablata
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -92,6 +95,42 @@ MONITORS: tuple[MonitorSpec, ...] = (
|
||||
MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08",
|
||||
open_labeled=False),
|
||||
# Cuscino USDC di regolamento (cron :53, ORARIO, debito §5.17 / issue #3): se si ferma nessuno
|
||||
# vede il saldo USDC scendere sotto il cuscino, e la riconversione automatica non parte.
|
||||
# max_age 3h = due giri persi; timbri di esecuzione -> open_labeled=False.
|
||||
MonitorSpec("cuscino_watch", "live", "cuscino_watch.jsonl", 1.0, max_age_h=3.0,
|
||||
gate="cuscino USDC di regolamento: allerta + riconversione automatica",
|
||||
open_labeled=False),
|
||||
# Trigger di regime CC01 (cron_daily 00:30, GIORNALIERO, #12): dal 03/09 al 16/09 e' morto 14
|
||||
# notti di fila senza che nessuno lo vedesse (#10), perche' non scriveva niente da sorvegliare.
|
||||
# Il percorso e' quello di `cc01_regime_watch.RUNS` (un test li incrocia, P1). max_age 30h =
|
||||
# un giro mancato + margine; timbri di esecuzione -> open_labeled=False.
|
||||
MonitorSpec("cc01_regime_watch", "cc01_regime", "runs.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="trigger di regime CC01: WARN>=10% / ALERT>=15% funding 30g (riapre il dossier)",
|
||||
open_labeled=False),
|
||||
# I sei sorveglianti che generalizzano la riparazione di cc01 (#16): ognuno registra il
|
||||
# proprio giro in `data/<dir>/runs.jsonl` (src.live.giri.registra_giro), timbri di
|
||||
# ESECUZIONE come cc01 -> open_labeled=False. cron_daily.sh (GIORNALIERO) per i primi
|
||||
# cinque: max_age 30h = un giro mancato + margine.
|
||||
MonitorSpec("edge_watch", "edge_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="kill del libro live (Sharpe 36m < -0,5 / protezione TP01)",
|
||||
open_labeled=False),
|
||||
MonitorSpec("fee_watch", "fee_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="regola fee §3 decisa in anticipo",
|
||||
open_labeled=False),
|
||||
MonitorSpec("scale_watch", "scale_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="GATE SCALA-01 criterio A2",
|
||||
open_labeled=False),
|
||||
MonitorSpec("vrp_f_watch", "vrp_f_watch", "runs.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="criteri (a)(b) §5.18",
|
||||
open_labeled=False),
|
||||
MonitorSpec("venue_news", "venue_news", "runs.jsonl", 24.0, max_age_h=30.0,
|
||||
gate="annunci Deribit, debito 8",
|
||||
open_labeled=False),
|
||||
# cron_book.sh: gira OGNI ORA accanto al libro vivo. max_age 3h = due giri persi.
|
||||
MonitorSpec("venue_watch", "venue_watch", "runs.jsonl", 1.0, max_age_h=3.0,
|
||||
gate="tripwire di venue orario",
|
||||
open_labeled=False),
|
||||
)
|
||||
|
||||
|
||||
@@ -208,3 +247,46 @@ def alerts_from(rows: list[dict]) -> list[str]:
|
||||
msg += f" | METTE A RISCHIO il gate {r['gate']}"
|
||||
out.append(msg)
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# GIRO ORARIO (#30). Il giro giornaliero (cron_daily) resta com'e': report completo, avviso
|
||||
# sempre. I sorveglianti a max_age <= 6h (oggi cuscino_watch, venue_watch, collect_chain) si
|
||||
# scoprono fermi fino a 24h dopo se nessuno li guarda fuori da quel giro: qui si guardano ogni
|
||||
# ora, ma si notifica solo cio' che e' NUOVO rispetto all'ultimo avviso (niente Telegram a
|
||||
# raffica sullo stesso guasto fermo). Tutto PURO: il marcatore lo legge/scrive lo script.
|
||||
# ===========================================================================
|
||||
def monitors_orari(monitors: tuple[MonitorSpec, ...] = MONITORS,
|
||||
soglia_h: float = SOGLIA_ORARI_H) -> tuple[MonitorSpec, ...]:
|
||||
"""I monitor con cadenza di controllo ORARIA: max_age_h <= soglia_h. Derivato dalla tabella
|
||||
`MONITORS`, non da una lista di nomi cablata: aggiungere un monitor orario lo include qui
|
||||
senza toccare questa funzione."""
|
||||
return tuple(m for m in monitors if m.max_age_h <= soglia_h)
|
||||
|
||||
|
||||
def chiave_alert(row: dict) -> str:
|
||||
"""Chiave stabile di un alert per il marcatore 'gia' detto': nome + stato, NON il testo
|
||||
(`why` contiene l'eta' dell'ultima barra, che cambia a ogni giro anche se il guasto e' lo
|
||||
stesso)."""
|
||||
return f"{row['name']}:{row['status']}"
|
||||
|
||||
|
||||
def alert_nuovi(rows: list[dict], detti: set[str]) -> list[dict]:
|
||||
"""Le righe-alert (non OK, non NUOVO — vedi `alerts_from`) la cui chiave NON e' nel
|
||||
marcatore `detti`: quelle davvero nuove, le sole da notificare."""
|
||||
return [r for r in rows if r["status"] not in ("OK", "NUOVO") and chiave_alert(r) not in detti]
|
||||
|
||||
|
||||
def marcatore_aggiornato(rows: list[dict], detti: set[str]) -> set[str]:
|
||||
"""Il marcatore dopo questo giro: le chiavi dei monitor ancora in alert restano/si aggiungono,
|
||||
quelle dei monitor tornati OK si TOLGONO — cosi' un nuovo guasto sullo stesso monitor riparte
|
||||
con un nuovo avviso invece di restare 'gia' detto' per sempre. Non scrive nulla: lo fa lo
|
||||
script, e solo se la notifica e' partita (stessa regola di fee_watch #29)."""
|
||||
out = set(detti)
|
||||
for r in rows:
|
||||
chiavi_monitor = {k for k in out if k.startswith(f"{r['name']}:")}
|
||||
if r["status"] == "OK":
|
||||
out -= chiavi_monitor
|
||||
elif r["status"] != "NUOVO":
|
||||
out.add(chiave_alert(r))
|
||||
return out
|
||||
|
||||
@@ -87,9 +87,15 @@ def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
|
||||
ver = f" <code>v{APP_VERSION}</code>"
|
||||
except Exception:
|
||||
ver = ""
|
||||
lines = [f"<b>{title}</b>{ver}"]
|
||||
# Telegram e' in parse_mode=HTML: un '<' o '&' nudo in un valore fa fallire l'INTERO invio
|
||||
# (HTTP 400, "can't parse entities"). Successo il 2026-09-10 13:53Z: la prima allerta di
|
||||
# cuscino_watch conteneva «(< 10% del cuscino)» ed e' andata persa. L'escape sta QUI, nel sink,
|
||||
# perche' nessuno dei 45 chiamanti passa tag di proposito (verificato con grep) e un chiamante
|
||||
# nuovo non deve ricordarsene. `send()` resta grezzo per chi costruisce il proprio HTML.
|
||||
import html
|
||||
lines = [f"<b>{html.escape(str(title), quote=False)}</b>{ver}"]
|
||||
for k, v in (data or {}).items():
|
||||
lines.append(f" {k}: {v}")
|
||||
lines.append(f" {html.escape(str(k), quote=False)}: {html.escape(str(v), quote=False)}")
|
||||
return send("\n".join(lines), tentativi=tentativi)
|
||||
|
||||
|
||||
|
||||
+40
-8
@@ -39,8 +39,16 @@ FINESTRA_GIORNALE_G = 7 # pagine del giornale lette
|
||||
FINESTRA_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale)
|
||||
FINESTRA_COMMIT_G = 14 # commit letti
|
||||
CARTELLA = Path("docs") / "revisioni"
|
||||
MAX_CHARS_VOCE = 90_000 # CLAUDE.md sta sotto; il resto e' molto piu' piccolo
|
||||
MAX_CHARS_VOCE = 90_000 # il resto delle voci e' molto piu' piccolo
|
||||
# #37 C12. CLAUDE.md e' arrivato a 89.590 caratteri (99,5% di MAX_CHARS_VOCE, 27/09): al primo
|
||||
# paragrafo aggiunto si sarebbe troncato. Tetto SUO, piu' largo — il vincolo vero resta
|
||||
# MAX_CHARS_TOTALE, applicato A PARTE in `materiale()` a prescindere dal tetto di ogni voce: la
|
||||
# somma dei tetti per-voce (che supera 400k) non e' il limite, lo e' il secondo giro di `_tronca`.
|
||||
MAX_CHARS_CLAUDE_MD = 150_000
|
||||
MAX_CHARS_TOTALE = 400_000
|
||||
SOGLIA_AVVISO_TETTO = 0.95 # avviso quando una voce supera questa frazione del SUO tetto
|
||||
_MARCA_VICINO_TETTO = "[… vicino al tetto:" # marcatore condiviso: materiale() lo scrive, i
|
||||
# consumatori (prompt, rapporto) lo rileggono
|
||||
MAX_NUMERI_LIBERI = 12 # una revisione confronta e sottrae: qualche numero suo e' lecito
|
||||
LIMITE_TELEGRAM = 3_500 # sotto i 4.096 di Telegram, con la testata
|
||||
TIMEOUT_S = 900 # il materiale e' grande: 5 minuti non bastano sempre
|
||||
@@ -89,6 +97,12 @@ def _tronca(testo: str, n: int | None = None) -> tuple[str, bool]:
|
||||
return testo[:n] + f"\n[… troncato a {n:,} caratteri su {len(testo):,}]", True
|
||||
|
||||
|
||||
def _cap_voce(nome: str) -> int:
|
||||
"""Tetto PER-VOCE: CLAUDE.md ne ha uno suo, piu' largo (#37 C12). Il totale
|
||||
(MAX_CHARS_TOTALE) resta un vincolo A PARTE, applicato in `materiale()` a valle di questo."""
|
||||
return MAX_CHARS_CLAUDE_MD if nome.startswith("CLAUDE.md") else MAX_CHARS_VOCE
|
||||
|
||||
|
||||
def _cmd(args: list[str], cwd: Path) -> str:
|
||||
"""Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo»
|
||||
non e' «va tutto bene»)."""
|
||||
@@ -175,7 +189,18 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
|
||||
|
||||
out, tot = [], 0
|
||||
for nome, testo in voci:
|
||||
t, tr = _tronca(testo)
|
||||
cap = _cap_voce(nome)
|
||||
# #37 C12: avviso PRIMA di troncare, quando la voce e' vicina al SUO tetto (non ancora al
|
||||
# tetto totale, sotto): la nota entra nel testo, cosi' arriva sia al prompt del revisore
|
||||
# sia — via _MARCA_VICINO_TETTO — al rapporto, come [TRONCATO] oggi. Il margine
|
||||
# (`+ len(nota) <= cap`) e' voluto: la nota stessa non deve MAI far scattare il
|
||||
# troncamento che sta segnalando in anticipo.
|
||||
if len(testo) >= SOGLIA_AVVISO_TETTO * cap:
|
||||
nota = (f"\n{_MARCA_VICINO_TETTO} {len(testo):,} di {cap:,} caratteri "
|
||||
f"({100 * len(testo) / cap:.0f}%)]")
|
||||
if len(testo) + len(nota) <= cap:
|
||||
testo = testo + nota
|
||||
t, tr = _tronca(testo, cap)
|
||||
if tot + len(t) > MAX_CHARS_TOTALE:
|
||||
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
|
||||
tot += len(t)
|
||||
@@ -186,7 +211,8 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
|
||||
def costruisci_prompt(voci: list[tuple[str, str, bool]], adesso: datetime) -> str:
|
||||
parti = [ISTRUZIONI, f"\nDATA DELLA REVISIONE: {adesso:%Y-%m-%d %H:%M}Z (usa questa per «oggi» e per le date dei gate).\n"]
|
||||
for nome, testo, tr in voci:
|
||||
parti.append(f"\n=== {nome}{' [TRONCATO]' if tr else ''} ===\n{testo}\n")
|
||||
tag = " [TRONCATO]" if tr else (" [VICINO AL TETTO]" if _MARCA_VICINO_TETTO in testo else "")
|
||||
parti.append(f"\n=== {nome}{tag} ===\n{testo}\n")
|
||||
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
|
||||
return "\n".join(parti)
|
||||
|
||||
@@ -236,7 +262,8 @@ def rapporto(testo: str | None, errore: str | None, stato: str, motivi: list[str
|
||||
"- scritto da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file "
|
||||
"scritto e' questo", "",
|
||||
"| voce | caratteri | troncata |", "|---|---|---|"]
|
||||
coda += [f"| {n} | {len(t):,} | {'si' if tr else ''} |" for n, t, tr in voci]
|
||||
coda += [f"| {n} | {len(t):,} | "
|
||||
f"{'si' if tr else ('quasi' if _MARCA_VICINO_TETTO in t else '')} |" for n, t, tr in voci]
|
||||
return "\n".join(testa + corpo + coda) + "\n"
|
||||
|
||||
|
||||
@@ -258,10 +285,14 @@ def per_telegram(testo: str | None, errore: str | None, stato: str, adesso: date
|
||||
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
|
||||
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
|
||||
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
|
||||
corpo = html.escape(sintesi, quote=False)
|
||||
tagliato = ""
|
||||
if len(corpo) > LIMITE_TELEGRAM:
|
||||
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
|
||||
# (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare
|
||||
# un'entita' a meta' (es. "&am") e Telegram rifiuta l'intero messaggio ("can't parse
|
||||
# entities") invece di accorciarlo. Il marcatore di taglio si aggiunge dopo, fuori dall'escape
|
||||
# (e' testo nostro, senza `&`/`<`: non ha bisogno di escape).
|
||||
if len(sintesi) > LIMITE_TELEGRAM:
|
||||
sintesi, tagliato = sintesi[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
|
||||
corpo = html.escape(sintesi, quote=False)
|
||||
return (f"<b>🔍 Revisione {CADENZA}</b> {adesso:%Y-%m-%d} · guardia numeri: {stato}\n\n"
|
||||
f"{corpo}{tagliato}\n\n<code>{html.escape(str(path))}</code>")
|
||||
|
||||
@@ -312,7 +343,8 @@ def scrivi(root: Path, adesso: datetime | None = None, modello: str = MODELLO, *
|
||||
print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, "
|
||||
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
|
||||
for n, t, tr in voci:
|
||||
print(f" {len(t):>8,} {n}{' [troncata]' if tr else ''}")
|
||||
tag = " [troncata]" if tr else (" [vicino al tetto]" if _MARCA_VICINO_TETTO in t else "")
|
||||
print(f" {len(t):>8,} {n}{tag}")
|
||||
if secco:
|
||||
return esito
|
||||
|
||||
|
||||
+20
-7
@@ -95,15 +95,20 @@ def _positions(client):
|
||||
USDE_INDEX_URL = _usde.index_url()
|
||||
|
||||
|
||||
def _collaterale_usde(client) -> tuple[float, str]:
|
||||
def _collaterale_usde(client) -> tuple[float | None, str]:
|
||||
"""Valore USD dell'USDE tenuto come collaterale. Il PERCHE' e le proprieta' (depeg che
|
||||
PASSA nel sizing, clamp sopra la pari, fallback 1.0 dichiarato mai 0) stanno in
|
||||
src/live/usde.py — difetto d'origine 2026-08-26: `_equity` leggeva solo il conto USDC,
|
||||
-24% fantasma dopo la prima conversione."""
|
||||
-24% fantasma dopo la prima conversione.
|
||||
|
||||
⚠️ (#20) Un'eccezione sulla LETTURA del conto USDE non e' "0 USDE": e' "non lo so". Ritorna
|
||||
None (non 0.0) + la nota dell'eccezione -> `_equity` propaga l'ignoto invece di dedurre un
|
||||
equity piu' basso di quello vero. Eq letta con successo e <= 0 resta 0.0 (conto senza USDE:
|
||||
quello e' un fatto accertato, legittimo)."""
|
||||
try:
|
||||
eq = float(client.account_summary("USDE").get("equity") or 0)
|
||||
except Exception:
|
||||
return 0.0, ""
|
||||
except Exception as e:
|
||||
return None, f"USDE illeggibile: {type(e).__name__}: {e}"
|
||||
if eq <= 0:
|
||||
return 0.0, ""
|
||||
px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL)
|
||||
@@ -117,6 +122,8 @@ def _equity(client, marks):
|
||||
eq = float(client.account_summary("USDC").get("equity") or 0)
|
||||
if eq > 1:
|
||||
usde_usd, usde_note = _collaterale_usde(client)
|
||||
if usde_usd is None: # (#20) USDE illeggibile -> l'equity e' IGNOTA,
|
||||
return None, usde_note # non "USDC senza USDE": non si assume 0.
|
||||
if usde_usd > 0:
|
||||
return eq + usde_usd, f"mainnet USDC + {usde_note}"
|
||||
return eq, "mainnet USDC"
|
||||
@@ -133,7 +140,8 @@ def _equity(client, marks):
|
||||
if any_ok and tot > 1:
|
||||
return tot, "mainnet coin-margined"
|
||||
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B):
|
||||
# segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside).
|
||||
# segnalato a book_execute, che in modo SOLO PROTEZIONE non manda alcun rebalance_signed —
|
||||
# controlla comunque il disaster-SL, che dipende da posizione/mark, non da equity (#21).
|
||||
return None, "conto flat / non finanziato"
|
||||
|
||||
|
||||
@@ -158,7 +166,9 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
||||
eq_basis = ("override" if equity_override is not None
|
||||
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
||||
# Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del
|
||||
# conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO.
|
||||
# conto reale. SEGNALATO, e book_execute passa a SOLO PROTEZIONE: nessun rebalance_signed (non
|
||||
# si dimensiona un ordine senza sapere quanto vale il conto), ma il disaster-SL si controlla
|
||||
# comunque per ogni asset (#21).
|
||||
eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} "
|
||||
f"invece del conto reale") if (equity_override is None and client is not None
|
||||
and not real_eq) else None
|
||||
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
||||
|
||||
return dict(
|
||||
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
||||
online=(client is not None and marks_src.get("BTC") == "mainnet"),
|
||||
# (#25) TUTTI i mark devono venire da mainnet, non solo BTC: con due asset, un solo
|
||||
# controllo lasciava ETH libero di essere in fallback (close certificata) senza che
|
||||
# `online` lo dicesse -> il gate offline di book_execute non si accorgeva del guasto.
|
||||
online=(client is not None and all(marks_src.get(a) == "mainnet" for a in ASSETS)),
|
||||
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
||||
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
|
||||
assets=assets, orders=orders, live_trades=live_trades,
|
||||
|
||||
+152
-34
@@ -2,20 +2,34 @@
|
||||
|
||||
PERCHE' ESISTE
|
||||
--------------
|
||||
`data/live/book_executions.jsonl` registra i fill, ma il suo `ts_utc` e' la data della
|
||||
**barra di segnale** (`book_execute.py`: `pd.Timestamp(r['last_data'])`), non l'ora del fill:
|
||||
19 righe su 19 a `00:00:00`. L'ora vera sta solo in `logs/cron_book.log` — che e' **gitignored,
|
||||
non nel backup e ruotabile**. Quindi oggi la cronologia reale del libro live vive in un file
|
||||
che una rotazione cancella, e nessuno se ne accorgerebbe.
|
||||
`logs/cron_book.log` e' **gitignored, non nel backup e ruotabile**, ed e' la fonte che `sync()`
|
||||
usa per popolare il DB: una rotazione lo cancellerebbe senza che nessuno se ne accorga.
|
||||
|
||||
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
|
||||
il perimetro gia' coperto dal backup rotativo della VPS.
|
||||
|
||||
⚠️ CORREZIONE (#37 C11, #35 A9). Fino al 2026-08-23 `data/live/book_executions.jsonl` scriveva in
|
||||
`ts_utc` la data della **barra di segnale** (`pd.Timestamp(r['last_data'])`), non l'ora del fill:
|
||||
19 righe su 19 a `00:00:00`. Dal 2026-08-24 01:07Z `book_execute.py` (`_registra_fill`) scrive
|
||||
l'ora VERA del fill (`datetime.now(timezone.utc)` al momento dell'ordine, non piu' la barra),
|
||||
UNA per fill — con il prezzo a piena precisione (non arrotondato come nella stampa del log) e il
|
||||
campo `pos_after` (letto dal reconcile subito dopo il rebalance, #39 — None se quella lettura
|
||||
solleva, o se il passo e' un `RebalanceParziale` a meta'). E' quindi, dal 24/08 in poi, la fonte
|
||||
con l'ora PIU' precisa per-fill. Dal 2026-09-27 (#40) `sync()` la usa anche per ARRICCHIRE il
|
||||
DB: `arricchisci_da_jsonl()` gira dopo l'inserimento e prima dei round-trip, e le righe dal 24/08
|
||||
finiscono con `ts_utc`/`price` dal fill vero e `ts_source='jsonl'` invece che `'cron_log'` (e
|
||||
`pos_after` quando il jsonl lo porta) — SOLO se l'abbinamento e' univoco, altrimenti resta com'e'
|
||||
e conta come ambiguo.
|
||||
|
||||
FONTI, in ordine di autorita' — e nessuna delle tre e' completa da sola
|
||||
----------------------------------------------------------------------
|
||||
1. `logs/cron_book.log` -> ORA VERA del giro + contesto (equity, tp_frac, skh, target, posizione)
|
||||
e la riga di fill `-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`.
|
||||
2. `data/live/book_executions.jsonl` -> gli stessi fill con piu' cifre, ma senza ora.
|
||||
1. `logs/cron_book.log` -> l'ora di INIZIO GIRO (una per run, condivisa da tutti i fill di quel
|
||||
run, non per-fill) + contesto (equity, tp_frac, skh, target,
|
||||
posizione) e la riga di fill
|
||||
`-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`. FONTE usata da `sync()`.
|
||||
2. `data/live/book_executions.jsonl` -> gli stessi fill, con l'ora VERA per-fill dal 2026-08-24
|
||||
01:07Z (prima: la data della barra di segnale, 00:00:00), prezzo a
|
||||
piena precisione e `pos_after`. Entra SOLO in `reconcile()`.
|
||||
3. venue (`DeribitRead.trade_history`) -> autorevole su `order_id` e timestamp in ms,
|
||||
**ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH.
|
||||
|
||||
@@ -32,7 +46,7 @@ import json
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -42,6 +56,10 @@ EXEC_JSONL = PROJECT_ROOT / "data" / "live" / "book_executions.jsonl"
|
||||
|
||||
# Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20).
|
||||
ARMING = "2026-06-20"
|
||||
# Da qui in poi book_executions.jsonl porta l'ora VERA del fill in `ts_utc` (fix #35 A9, docstring
|
||||
# sopra): soglia per il controllo di conteggio jsonl-vs-DB in scripts/live/trades_db.py:sync()
|
||||
# (#37 C11). Prima di questo istante il campo e' la barra di segnale, non comparabile.
|
||||
CUTOFF_TS_VERO = "2026-08-24T01:07:00+00:00"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS fills (
|
||||
@@ -262,31 +280,43 @@ def reconcile(fills_log: list[Fill], righe_jsonl: list[dict], tol_px: float = 0.
|
||||
|
||||
|
||||
def fifo_roundtrips(fills: list[Fill]) -> tuple[list[dict], dict]:
|
||||
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty."""
|
||||
"""Round-trip chiusi per FIFO + lotti residui. Le fee si allocano PRO-QUOTA sulla qty.
|
||||
|
||||
Lotti CON SEGNO (#32 C2): ogni lotto e' long (+1) o short (-1). Un fill chiude PRIMA i
|
||||
lotti di segno OPPOSTO (FIFO), e l'eccedenza — se resta qty dopo aver chiuso tutto il
|
||||
lato opposto — apre (o accoda) un lotto nel segno del fill stesso. Prima di questa
|
||||
riparazione un SELL senza lotti long da chiudere spariva in silenzio (nessun lotto,
|
||||
nessun round-trip): l'inversione long -> short in un solo fill e' lo stesso meccanismo,
|
||||
solo che il lato opposto si esaurisce a meta' fill invece che all'inizio.
|
||||
"""
|
||||
from collections import deque, defaultdict
|
||||
lotti: dict[str, deque] = defaultdict(deque)
|
||||
lotti: dict[str, deque] = defaultdict(deque) # ciascun lotto: [qty, price, ts, fee_u, segno]
|
||||
rts: list[dict] = []
|
||||
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
|
||||
fee_u = f.fee / f.qty if f.qty else 0.0
|
||||
if f.side == "buy":
|
||||
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
|
||||
else:
|
||||
resto = f.qty
|
||||
while resto > 1e-12 and lotti[f.asset]:
|
||||
lq, lp, lts, lfee_u = lotti[f.asset][0]
|
||||
usa = min(resto, lq)
|
||||
lordo = usa * (f.price - lp)
|
||||
quota_fee = usa * (lfee_u + fee_u)
|
||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
||||
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
|
||||
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
|
||||
lq -= usa; resto -= usa
|
||||
if lq <= 1e-12:
|
||||
lotti[f.asset].popleft()
|
||||
else:
|
||||
lotti[f.asset][0][0] = lq
|
||||
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq}
|
||||
segno_fill = 1 if f.side == "buy" else -1
|
||||
dq = lotti[f.asset]
|
||||
resto = f.qty
|
||||
while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
|
||||
lq, lp, lts, lfee_u, lseg = dq[0]
|
||||
usa = min(resto, lq)
|
||||
# long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit)
|
||||
lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price)
|
||||
quota_fee = usa * (lfee_u + fee_u)
|
||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
||||
rts.append(dict(asset=f.asset, qty=usa, ts_in=lts, px_in=lp, ts_out=f.ts_utc,
|
||||
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
|
||||
direzione=("long" if lseg == 1 else "short")))
|
||||
lq -= usa; resto -= usa
|
||||
if lq <= 1e-12:
|
||||
dq.popleft()
|
||||
else:
|
||||
dq[0][0] = lq
|
||||
if resto > 1e-12: # l'eccedenza apre/accoda nel segno del fill
|
||||
dq.append([resto, f.price, f.ts_utc, fee_u, segno_fill])
|
||||
aperti = {a: [dict(qty=q, price=p, ts=t, direzione=("long" if s == 1 else "short"))
|
||||
for q, p, t, _, s in dq] for a, dq in lotti.items() if dq}
|
||||
return rts, aperti
|
||||
|
||||
|
||||
@@ -321,6 +351,86 @@ def upsert_fills(con: sqlite3.Connection, fills: list[Fill]) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def _ts_utc(s) -> datetime | None:
|
||||
"""Parsa un timestamp ISO in un datetime AWARE utc. None se illeggibile.
|
||||
|
||||
Le 19 righe scritte prima del fix #35 A9 (barra di segnale, `pd.Timestamp` in stampa: es.
|
||||
"2026-07-08 00:00:00", spazio invece di 'T', nessun offset) sono NAIVE — senza questa
|
||||
normalizzazione, confrontarle con un ts_utc del DB (sempre aware, +00:00) solleva
|
||||
`TypeError: can't compare offset-naive and offset-aware datetimes` (misurato sui dati veri,
|
||||
27/09/2026). Sono tutte precedenti a CUTOFF_TS_VERO: qui si limita a non farle esplodere,
|
||||
non a farle abbinare — restano fuori dalla finestra di qualunque candidato dal 24/08."""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
ts = datetime.fromisoformat(str(s))
|
||||
except ValueError:
|
||||
return None
|
||||
return ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else ts
|
||||
|
||||
|
||||
def arricchisci_da_jsonl(con: sqlite3.Connection, righe_jsonl: list[dict]) -> tuple[int, int]:
|
||||
"""Arricchisce le righe `ts_source='cron_log'` con l'ora vera, il prezzo e `pos_after` letti
|
||||
da `book_executions.jsonl` (#40, TradingBook#11: lo sviluppo lasciato non fatto dal
|
||||
commento sopra). NON tocca `fill_id`: e' la chiave della riga, calcolata all'INSERT da
|
||||
ts_utc/asset/side/qty/price ORIGINALI (cron_log) — qui si fa un UPDATE per `fill_id`, che
|
||||
cambia solo le colonne, mai la chiave con cui la riga e' indicizzata.
|
||||
|
||||
Candidate: `ts_source='cron_log'` e `ts_utc >= CUTOFF_TS_VERO` (prima di quella soglia il
|
||||
jsonl portava la barra di segnale, non l'ora vera — non ha una controparte comparabile).
|
||||
Abbinamento a una riga jsonl: stesso asset, stesso side, |qty - filled| < 1e-9, e ts jsonl in
|
||||
[ts_utc DB, ts_utc DB + 15 minuti] (il DB ha l'ora di INIZIO GIRO, il jsonl l'ora del fill,
|
||||
che viene dopo). Esattamente UN candidato -> UPDATE; zero o piu' di uno -> non si tocca (non
|
||||
si indovina quale, o se ce n'e' uno) e conta come ambiguo. Un record jsonl assegnato non si
|
||||
riusa: due fill identici nello stesso giro non finiscono abbinati alla stessa riga.
|
||||
Idempotente: una riga gia' `ts_source='jsonl'` non e' piu' candidata al giro successivo.
|
||||
|
||||
Ritorna (righe_aggiornate, righe_ambigue)."""
|
||||
candidati = con.execute(
|
||||
"SELECT fill_id, ts_utc, asset, side, qty FROM fills "
|
||||
"WHERE ts_source='cron_log' AND ts_utc >= ? ORDER BY ts_utc, asset",
|
||||
(CUTOFF_TS_VERO,)).fetchall()
|
||||
usati: set[int] = set() # indici in righe_jsonl gia' assegnati a un fill_id
|
||||
aggiornate = 0
|
||||
ambigue = 0
|
||||
for riga in candidati:
|
||||
ts_db = _ts_utc(riga["ts_utc"])
|
||||
if ts_db is None:
|
||||
ambigue += 1 # ts illeggibile: non e' un abbinamento, e' un dato rotto
|
||||
continue
|
||||
fine = ts_db + timedelta(minutes=15)
|
||||
trovati = []
|
||||
for i, r in enumerate(righe_jsonl):
|
||||
if i in usati:
|
||||
continue
|
||||
if r.get("asset") != riga["asset"] or r.get("side") != riga["side"]:
|
||||
continue
|
||||
filled = r.get("filled")
|
||||
if filled is None or abs(float(filled) - riga["qty"]) >= 1e-9:
|
||||
continue
|
||||
ts_j = _ts_utc(r.get("ts_utc"))
|
||||
if ts_j is None or not (ts_db <= ts_j <= fine):
|
||||
continue
|
||||
trovati.append(i)
|
||||
if len(trovati) != 1:
|
||||
ambigue += 1
|
||||
continue
|
||||
i = trovati[0]
|
||||
r = righe_jsonl[i]
|
||||
usati.add(i)
|
||||
pos_after = r.get("pos_after")
|
||||
if pos_after is None:
|
||||
con.execute("UPDATE fills SET ts_utc=?, ts_source='jsonl', price=? WHERE fill_id=?",
|
||||
(r["ts_utc"], r["price"], riga["fill_id"]))
|
||||
else:
|
||||
con.execute(
|
||||
"UPDATE fills SET ts_utc=?, ts_source='jsonl', price=?, pos_after=? WHERE fill_id=?",
|
||||
(r["ts_utc"], r["price"], pos_after, riga["fill_id"]))
|
||||
aggiornate += 1
|
||||
con.commit()
|
||||
return aggiornate, ambigue
|
||||
|
||||
|
||||
def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int:
|
||||
n = 0
|
||||
for ts, v, src in punti:
|
||||
@@ -332,8 +442,12 @@ def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]])
|
||||
|
||||
|
||||
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
|
||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
|
||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
|
||||
|
||||
`qty > 0` (#32 C10): un fill "NON VERIFICATO" da `place_order error` puo' arrivare a
|
||||
qty 0 — non e' un trade, resta nel DB marcato (`verified=0`) ma fuori dal FIFO.
|
||||
"""
|
||||
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
|
||||
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
|
||||
price=r["price"], fee=r["fee"]) for r in righe]
|
||||
rts, _ = fifo_roundtrips(fills)
|
||||
@@ -358,7 +472,8 @@ def get_meta(con: sqlite3.Connection, k: str, default=None):
|
||||
|
||||
|
||||
def stato_aperto(con: sqlite3.Connection) -> dict:
|
||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
||||
# qty > 0 (#32 C10): stessa esclusione di rebuild_roundtrips, un fill a qty 0 non e' posizione.
|
||||
righe = con.execute("SELECT * FROM fills WHERE qty > 0 ORDER BY ts_utc, asset").fetchall()
|
||||
fills = [Fill(ts_utc=r["ts_utc"], asset=r["asset"], side=r["side"], qty=r["qty"],
|
||||
price=r["price"], fee=r["fee"]) for r in righe]
|
||||
_, aperti = fifo_roundtrips(fills)
|
||||
@@ -366,7 +481,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
|
||||
for a, lotti in aperti.items():
|
||||
q = sum(x["qty"] for x in lotti)
|
||||
costo = sum(x["qty"] * x["price"] for x in lotti)
|
||||
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti))
|
||||
# un asset ha un solo segno di lotti aperti alla volta (fifo_roundtrips chiude prima
|
||||
# il lato opposto): la direzione del primo lotto vale per tutti (#32 C2).
|
||||
out[a] = dict(qty=q, prezzo_medio=costo / q if q else 0.0, dal=min(x["ts"] for x in lotti),
|
||||
direzione=lotti[0]["direzione"])
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ _DEFAULTS = {
|
||||
"venue_cap_misurato": "2026-08-31",
|
||||
"depeg_warn": 0.99,
|
||||
"depeg_crit": 0.95,
|
||||
# bande del cuscino USDC (frazioni del cuscino richiesto), issue #7 — vedi `_nota_cuscino`
|
||||
"cuscino_preavviso_frac": 0.10,
|
||||
"cuscino_margine_frac": 0.20,
|
||||
"cuscino_riacquisto_frac": 0.40,
|
||||
}
|
||||
|
||||
|
||||
@@ -93,3 +97,54 @@ def valuta(eq_usde: float, px: float | None) -> tuple[float, str]:
|
||||
return eq_usde, f"USDE {eq_usde:,.0f} valutato 1.0000 (indice non leggibile)"
|
||||
p = min(px, 1.0)
|
||||
return eq_usde * p, f"USDE {eq_usde:,.0f} @ {p:.4f}"
|
||||
|
||||
|
||||
# --- CUSCINO USDC DI REGOLAMENTO ------------------------------------------------------------
|
||||
# P&L e funding dei perp USDC-lineari si regolano in USDC, non nel collaterale: a quota USDE alta
|
||||
# il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO
|
||||
# (KB "Cross collateral specifications": 18,25%/anno, 4,3x la resa USDE). Il cuscino richiesto e'
|
||||
# il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere:
|
||||
# book.leva_tetto() x disaster_sl_pct x equity_totale
|
||||
# tutto letto da config/live.json e src/live/book.py, MAI ridichiarato (P1). Prima del 10/09 la
|
||||
# formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui,
|
||||
# e usde_convert pure — due lettori, una formula. ⚠️ Fino al #28 la leva lorda era RIDICHIARATA
|
||||
# qui (N_ASSET fisso x WEIGHT, senza scala): a scala 1,25 dava 0,30 invece di 0,375. Ora la stessa
|
||||
# `book.leva_tetto` che usa anche il giornale (src/live/journal.py).
|
||||
|
||||
|
||||
def frazione_cuscino() -> tuple[float, str]:
|
||||
"""PURA sulla config. -> (frazione dell'equity totale che deve restare in USDC, come)."""
|
||||
cfg = json.loads(CFG_PATH.read_text())
|
||||
sl = float(cfg["disaster_sl_pct"])
|
||||
from src.live import book as B # lazy: book importa shadow, che importa questo modulo
|
||||
leva, come_leva = B.leva_tetto(cfg)
|
||||
return leva * sl, f"{come_leva} x SL {sl:.0%}"
|
||||
|
||||
|
||||
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
|
||||
"""Cuscino USDC di REGOLAMENTO in dollari, per un'equity totale data. -> (usd, come)."""
|
||||
f, come = frazione_cuscino()
|
||||
return equity_tot * f, f"{come} su ${equity_tot:,.2f}"
|
||||
|
||||
|
||||
def bande_cuscino(c: dict | None = None) -> dict:
|
||||
"""PURA sulla config. Le tre frazioni del cuscino che governano cuscino_watch, con l'ordine
|
||||
verificato: 0 < preavviso < margine < riacquisto. Fuori ordine la banda di isteresi non
|
||||
esiste (vendita e acquisto si inseguirebbero): si solleva, non si indovina."""
|
||||
c = c or cfg()
|
||||
b = dict(preavviso=float(c["cuscino_preavviso_frac"]), margine=float(c["cuscino_margine_frac"]),
|
||||
riacquisto=float(c["cuscino_riacquisto_frac"]))
|
||||
if not (0.0 < b["preavviso"] < b["margine"] < b["riacquisto"]):
|
||||
raise ValueError(f"bande del cuscino fuori ordine (serve 0 < preavviso < margine < riacquisto): {b}")
|
||||
return b
|
||||
|
||||
|
||||
def quota_ripristino(margine_frac: float | None = None) -> float:
|
||||
"""PURA sulla config. Quota USDE che lascia il cuscino PIU' un margine (frazione del cuscino):
|
||||
e' il bersaglio UNICO di ogni conversione automatica — vendita quando il cuscino e' scoperto,
|
||||
acquisto quando lo slack eccede la banda. Il margine serve a non tornare sul bordo alla prima
|
||||
ora in perdita (P6: la persistenza e' latenza). Default: `cuscino_margine_frac` di config."""
|
||||
if margine_frac is None:
|
||||
margine_frac = bande_cuscino()["margine"]
|
||||
f, _ = frazione_cuscino()
|
||||
return max(0.0, 1.0 - f * (1.0 + margine_frac))
|
||||
|
||||
+33
-22
@@ -28,19 +28,21 @@ col codice 11051, e' manutenzione; se non risponde affatto, e' il venue ma per u
|
||||
se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5).
|
||||
La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente.
|
||||
|
||||
⚠️ **LA FINESTRA NON E' UNA PROVA.** `in_release_window()` dice soltanto che l'orologio e'
|
||||
compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA
|
||||
ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che
|
||||
sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un
|
||||
guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio
|
||||
proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di
|
||||
`venue_watch.MAINT_GRACE_HOURS` — *declassa dentro la tolleranza, RIALZA oltre* — applicata pero'
|
||||
a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario,
|
||||
non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare.
|
||||
|
||||
REGOLA P9 — *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il
|
||||
giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra
|
||||
di meno. L'episodio finisce nel log e nella notifica comunque.
|
||||
⚠️ **IL RAMO CHE DECLASSAVA E' MORTO, RIMOSSO DA `diagnose()` (#35 B11, 27/09/2026).**
|
||||
`in_release_window()` diceva se l'orologio cadeva nello slot di release Deribit (martedi'
|
||||
09:00-09:30 UTC); `diagnose()` la usava per declassare la GRAVITA' di una manutenzione gia'
|
||||
riconosciuta dalla SONDA (REGOLA P9 — *un allarme massimo speso per un evento atteso e' un
|
||||
allarme che non verra' letto il giorno che e' vero*). Ma l'UNICO chiamante di `diagnose()` e'
|
||||
`book_execute.py`, in cron al minuto **:47** di ogni ora (dal 25/08): la finestra 09:00-09:30 non
|
||||
contiene mai :47, quindi quel ramo non poteva MAI diventare vero — si leggeva come una
|
||||
declassazione attiva e non lo e' mai stata. Il campo `atteso` RESTA sul verdetto
|
||||
(`book_execute.py` lo scrive nel dict del report: cambiare il formato per una pulizia e' un costo
|
||||
che non vale la pena) ma e' hardcoded a False in `diagnose()`. `in_release_window()` e le
|
||||
costanti `RELEASE_*` restano invece nel modulo: le usa `test_book_cadenza.py`/`test_revisione.py`
|
||||
per verificare che i cron di book/review evitino lo stesso slot — uno scopo indipendente da
|
||||
`diagnose()` e ancora valido. Per declassare di nuovo un evento annunciato in `diagnose()` la
|
||||
strada e' legarlo alla DURATA dell'annuncio (`venue_news`, se e quando esiste), non all'orologio
|
||||
di un chiamante che non e' mai sincrono con lo slot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -53,7 +55,7 @@ import requests
|
||||
from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui
|
||||
|
||||
# --- verdetti -----------------------------------------------------------------------------
|
||||
V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051: atteso se dentro lo slot
|
||||
V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051
|
||||
V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione
|
||||
V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile)
|
||||
V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5)
|
||||
@@ -70,8 +72,12 @@ PROBE_TIMEOUT = 8.0
|
||||
# 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes").
|
||||
# ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica
|
||||
# dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta
|
||||
# lo slot, questa costante mente in silenzio. E' per questo che la finestra non decide da sola —
|
||||
# decide la sonda, e la finestra al massimo declassa.
|
||||
# lo slot, questa costante mente in silenzio.
|
||||
# ⚠️ #35 B11 (27/09/2026): questa finestra NON declassa piu' niente in `diagnose()` (vedi sotto):
|
||||
# l'unico chiamante gira in cron al minuto :47, che non ci cade mai dentro. La funzione e le
|
||||
# costanti restano perche' `test_book_cadenza.py`/`test_revisione.py` le usano per un controllo
|
||||
# diverso e ancora valido: che i cron di `book`/`review` cadano FUORI da questo slot (P1, la
|
||||
# stessa finestra, non una seconda dichiarata a mano).
|
||||
RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi'
|
||||
RELEASE_START_MIN = 9 * 60 # 09:00 UTC
|
||||
RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia.
|
||||
@@ -79,7 +85,8 @@ RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione to
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diagnosi:
|
||||
"""Il verdetto (*cosa*), la spiegazione (*perche'*), e se l'evento era atteso (P9)."""
|
||||
"""Il verdetto (*cosa*) e la spiegazione (*perche'*). `atteso` resta nel formato ma e' sempre
|
||||
False (#35 B11): l'unico chiamante non cade mai nella finestra che l'avrebbe declassato."""
|
||||
verdetto: str
|
||||
perche: str
|
||||
atteso: bool
|
||||
@@ -87,7 +94,8 @@ class Diagnosi:
|
||||
|
||||
@property
|
||||
def gravita(self) -> str:
|
||||
"""Emoji del titolo di notifica. `atteso` declassa, non silenzia (P9)."""
|
||||
"""Emoji del titolo di notifica. `atteso` e' sempre False (#35 B11): ramo morto lasciato
|
||||
per compatibilita' di formato, non per una declassazione che avviene ancora."""
|
||||
if self.atteso:
|
||||
return "ℹ️"
|
||||
return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️")
|
||||
@@ -106,8 +114,9 @@ class Diagnosi:
|
||||
def in_release_window(now: datetime | None = None) -> bool:
|
||||
"""L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)?
|
||||
|
||||
PURA e testabile. Non e' una prova di niente da sola: vedi la nota in testa al modulo.
|
||||
"""
|
||||
PURA e testabile. Non declassa piu' niente in `diagnose()` (#35 B11): resta per chi verifica
|
||||
che un cron (book, review) sia schedulato fuori da questo slot (P1: la finestra si deriva da
|
||||
qui, non si riscrive una seconda volta nel test)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
now = now.astimezone(timezone.utc)
|
||||
if now.weekday() != RELEASE_WEEKDAY:
|
||||
@@ -136,7 +145,6 @@ def probe_public(timeout: float = PROBE_TIMEOUT) -> tuple[bool | None, str]:
|
||||
|
||||
|
||||
def diagnose(errori_osservati: list[str] | None = None,
|
||||
now: datetime | None = None,
|
||||
sonda=None) -> Diagnosi:
|
||||
"""Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete.
|
||||
|
||||
@@ -169,7 +177,10 @@ def diagnose(errori_osservati: list[str] | None = None,
|
||||
verdetto = V_VENUE_GIU
|
||||
perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione"
|
||||
|
||||
atteso = verdetto == V_MANUTENZIONE and in_release_window(now)
|
||||
# #35 B11: qui c'era `verdetto == V_MANUTENZIONE and in_release_window(now)`. L'unico
|
||||
# chiamante (book_execute.py, cron :47) non cade mai nella finestra 09:00-09:30 di martedi':
|
||||
# il ramo non poteva mai diventare vero. Il campo resta (formato del report), sempre False.
|
||||
atteso = False
|
||||
prova = "; ".join([*errori[:3], grezzo])[:500]
|
||||
return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova)
|
||||
|
||||
|
||||
@@ -346,6 +346,11 @@ def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
|
||||
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
|
||||
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
||||
report["severity"] = "alert"
|
||||
# issue #31: senza ALERT, un asset BLIND lasciava severity=None — il titolo del runner
|
||||
# cadeva sul default piu' forte ("PRIMO PASSO: prelievo di prova") per un episodio che e'
|
||||
# "non vedo", non "stress misurato". BLIND e' lo stato MORBIDO: cede il passo ad ALERT.
|
||||
elif report["levels"] and any(l == "BLIND" for l in report["levels"].values()):
|
||||
report["severity"] = "blind"
|
||||
|
||||
# --- blocco piattaforma: adesso passa dalla stessa disciplina degli altri stati
|
||||
lock_new, lock_lvl = lock_step(st.lock, obs["platform_locked"], obs.get("maintenance", False))
|
||||
|
||||
@@ -400,8 +400,14 @@ def deribit_book_sleeves() -> list[Sleeve]:
|
||||
e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step).
|
||||
Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento
|
||||
periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py).
|
||||
TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione)."""
|
||||
TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).
|
||||
|
||||
I pesi si DERIVANO da src.live.book (P1): due letterali qui erano una seconda dichiarazione,
|
||||
libera di divergere dal book vero (#35 B12). Import locale: book importa questo modulo
|
||||
(_skyhook_positions) a livello di modulo -> un import in testa qui creerebbe un ciclo (come
|
||||
fa usde.py con book)."""
|
||||
from src.live import book as B # lazy: vedi nota sul ciclo qui sopra
|
||||
return [
|
||||
tp01_sleeve(weight=0.75),
|
||||
skyhook_sleeve(weight=0.25),
|
||||
tp01_sleeve(weight=B.W_TP01),
|
||||
skyhook_sleeve(weight=B.W_SKH),
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ giorno che non risponde.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
@@ -75,6 +76,18 @@ def test_il_prompt_non_contiene_l_analisi_vecchia():
|
||||
assert "Testo vecchio" not in p and "storico" in p
|
||||
|
||||
|
||||
# ------------------------------------------------- le istruzioni non portano un capitale fisso
|
||||
|
||||
def test_istruzioni_non_hanno_un_importo_in_dollari():
|
||||
"""Il capitale e' l'equity in pagina, non una cifra da ricordare (#13)."""
|
||||
assert re.search(r"\$\s?\d", A.ISTRUZIONI) is None
|
||||
|
||||
|
||||
def test_istruzioni_nominano_fifo_e_lotto():
|
||||
"""Piu' round-trip che fill nello stesso giorno e' atteso, non un'anomalia (#13)."""
|
||||
assert "FIFO" in A.ISTRUZIONI and "lotto" in A.ISTRUZIONI
|
||||
|
||||
|
||||
# ------------------------------------------------- i numeri inventati
|
||||
|
||||
def test_scopre_un_numero_che_non_c_e():
|
||||
@@ -197,6 +210,21 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
|
||||
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
|
||||
|
||||
|
||||
def test_secco_stampa_e_non_salva(db, monkeypatch, tmp_path, capsys):
|
||||
"""#34: prima del fix `J.salva()` veniva chiamato comunque, prima del controllo su
|
||||
`secco` — un giro "a secco" scriveva lo stesso le sezioni misurate nel DB."""
|
||||
import analista as CLI
|
||||
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi che non deve finire nel DB.", None))
|
||||
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
|
||||
g = date(2026, 8, 21)
|
||||
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB vuoto in partenza
|
||||
stato = CLI.scrivi(db, g, modello="finto", secco=True, verbose=False, telegram=False)
|
||||
assert stato == "ok"
|
||||
capsys.readouterr() # non e' l'oggetto del test
|
||||
assert db.execute("SELECT COUNT(*) c FROM journal").fetchone()["c"] == 0 # DB ANCORA vuoto
|
||||
assert not (tmp_path / f"{g}.md").exists() # scrivi_file non chiamato
|
||||
|
||||
|
||||
def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, monkeypatch, tmp_path):
|
||||
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
|
||||
import analista as CLI
|
||||
@@ -257,6 +285,39 @@ def test_la_testata_porta_i_numeri_che_si_leggono_dalla_notifica():
|
||||
t = A.testata(_voce_piena())
|
||||
assert "$637.36" in t and "+2.36" in t and "+39.30" in t
|
||||
assert "BTC $+116" in t and "leva 0.41x" in t and "1 fill" in t
|
||||
# (#35 C8) il cron gira alle 00:30 sul giorno CHIUSO: non e' "oggi", e' "nel giorno".
|
||||
assert "nel giorno" in t and "oggi" not in t
|
||||
|
||||
|
||||
def test_testata_con_movimento_mostra_trading_giorno_non_il_grezzo():
|
||||
"""(#35 C8) Un versamento di $1.399 non deve leggersi come P&L: con mov_giorno != 0 e
|
||||
trading_giorno calcolabile, la cifra mostrata e' trading_giorno, non delta_equity."""
|
||||
voce = _voce_piena()
|
||||
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=2.36)
|
||||
t = A.testata(voce)
|
||||
assert "+2.36 nel giorno" in t
|
||||
assert "di cui +1399.00 movimenti" in t
|
||||
assert "+1401.36" not in t # il grezzo non compare piu' in testata
|
||||
|
||||
|
||||
def test_testata_con_movimento_ma_trading_giorno_none_resta_sul_grezzo():
|
||||
"""(#35 C8) Se trading_giorno non e' calcolabile (None) si resta su delta_equity, ma con
|
||||
l'etichetta nuova, non "oggi"."""
|
||||
voce = _voce_piena()
|
||||
voce["pnl"] = dict(voce["pnl"], delta_equity=1401.36, mov_giorno=1399.0, trading_giorno=None)
|
||||
t = A.testata(voce)
|
||||
assert "+1401.36 nel giorno" in t
|
||||
assert "di cui" not in t
|
||||
|
||||
|
||||
def test_testata_senza_movimento_mostra_il_grezzo_con_etichetta_nuova():
|
||||
"""(#35 C8) mov_giorno assente o 0: nessun movimento da scorporare, stesso trattamento di
|
||||
journal.scorporo (bool())."""
|
||||
voce = _voce_piena()
|
||||
voce["pnl"] = dict(voce["pnl"], mov_giorno=0.0, trading_giorno=2.36)
|
||||
t = A.testata(voce)
|
||||
assert "+2.36 nel giorno" in t
|
||||
assert "di cui" not in t
|
||||
|
||||
|
||||
def test_il_messaggio_sfugge_l_HTML_e_converte_il_grassetto():
|
||||
@@ -284,6 +345,21 @@ def test_il_taglio_a_4096_si_DICHIARA():
|
||||
assert "tagliato" in m and "docs/journal/2026-08-23.md" in m
|
||||
|
||||
|
||||
def test_taglio_a_cavallo_di_entita_e_tag_resta_HTML_valido():
|
||||
"""(#35 C13) Il taglio e' sul GREZZO, prima di _b/html.escape e prima di **-><b>: un '&' o
|
||||
un '**grassetto**' a cavallo del punto di taglio non deve produrre un'entita' spezzata
|
||||
(es. '&am') ne' un <b> senza chiusura -- Telegram rifiuta l'INTERO messaggio altrimenti
|
||||
("can't parse entities"). Il pattern si ripete per tutta la lunghezza, cosi' il punto di
|
||||
taglio (qualunque sia) cade sempre dentro o a cavallo di un '&' o di '**...**'."""
|
||||
pattern = "parola & altra parola **in grassetto** ancora testo "
|
||||
analisi = pattern * 200 # ben oltre qualunque budget qui
|
||||
m = A.per_telegram(_voce_piena(), analisi, "ok", [])
|
||||
assert "tagliato" in m
|
||||
assert re.search(r"&(?!amp;|lt;|gt;)", m) is None # nessuna entita' spezzata o '&' nudo
|
||||
assert m.count("<b>") == m.count("</b>") # ogni <b> aperto e' chiuso
|
||||
assert m.count("<i>") == m.count("</i>")
|
||||
|
||||
|
||||
def test_un_messaggio_normale_non_viene_toccato():
|
||||
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
|
||||
assert "tagliato" not in m and m.endswith("Analisi breve.")
|
||||
|
||||
+65
-13
@@ -150,6 +150,27 @@ def test_weights_match_backtest_sleeves():
|
||||
assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12
|
||||
|
||||
|
||||
def test_deribit_book_sleeves_deriva_i_pesi_da_book_non_li_ridichiara():
|
||||
"""#35 B12 — P1: stesso pattern di test_r0909_libro_nei_crolli.test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia.
|
||||
`test_weights_match_backtest_sleeves` sopra passerebbe anche con due letterali per coincidenza
|
||||
di valore (0.75/0.25 riscritti a mano): qui si verifica l'IDENTITA' con B.W_TP01/B.W_SKH e
|
||||
che il letterale non sia piu' nella fonte di `deribit_book_sleeves`."""
|
||||
import inspect
|
||||
import re
|
||||
|
||||
import src.live.book as B
|
||||
from src.portfolio.sleeves import deribit_book_sleeves
|
||||
|
||||
pesi = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()}
|
||||
assert pesi["TP01"] is B.W_TP01 or pesi["TP01"] == B.W_TP01
|
||||
assert pesi["SKH01"] is B.W_SKH or pesi["SKH01"] == B.W_SKH
|
||||
|
||||
src = inspect.getsource(deribit_book_sleeves)
|
||||
assert "from src.live import book" in src, "i pesi devono derivare da src.live.book (P1)"
|
||||
assert not re.search(r"weight\s*=\s*0\.75", src), "peso TP01 ancora cablato"
|
||||
assert not re.search(r"weight\s*=\s*0\.25", src), "peso SKH01 ancora cablato"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Costruzione ordine: side, reduce_only, flip, close, soglia minima.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -415,7 +436,10 @@ def test_book_report_flags_skh_feed_error(monkeypatch):
|
||||
|
||||
|
||||
def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
|
||||
"""Se il report porta skh_error, _run() lo stampa E chiama notify (niente flat silenzioso)."""
|
||||
"""Se il report porta skh_error, _run() lo stampa SEMPRE (anche in dry-run); quando il giro e'
|
||||
ARMATO l'alert parte UNA sola volta, nel modo SOLO PROTEZIONE (#22) — niente flat silenzioso,
|
||||
ma nemmeno due notify per lo stesso guasto (il vecchio notify incondizionato e' stato tolto,
|
||||
U6 #22)."""
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
@@ -426,17 +450,27 @@ def test_book_execute_surfaces_skh_error(monkeypatch, capsys):
|
||||
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
|
||||
orders=[],
|
||||
)
|
||||
|
||||
class _TraderOk:
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run
|
||||
mod._run()
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "SKH FEED ERRORE" in out # stampato nel log
|
||||
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
|
||||
assert "SKH FEED ERRORE" in out # stampato nel log, sempre
|
||||
assert len(alerts) == 1 # UNA sola notify, non due
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -535,8 +569,11 @@ def test_book_report_propagates_eq_fallback(monkeypatch):
|
||||
assert book.book_report(offline=True, equity_override=600.0).get("eq_fallback") == "PAPER_CAP"
|
||||
|
||||
|
||||
def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
|
||||
def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
|
||||
"""(#21, U2) Comportamento cambiato: eq_fallback NON fa piu' proseguire un rebalance sul
|
||||
paper_cap — passa a SOLO PROTEZIONE (nessun ordine, disaster-SL comunque controllato, exit 2).
|
||||
Prima di questa riparazione il giro dimensionava un ordine reale su un capitale fittizio da
|
||||
$2.000 quando non si sapeva quanto valesse il conto vero; ora non manda alcun ordine."""
|
||||
from helpers import carica_script
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
@@ -551,18 +588,33 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
||||
alerts = []
|
||||
|
||||
class FakeDT:
|
||||
def __init__(self):
|
||||
self.rebalance_chiamato = False
|
||||
def rebalance_signed(self, *a, **k):
|
||||
self.rebalance_chiamato = True
|
||||
return []
|
||||
def ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
|
||||
def position_usd(self, inst): return 0.0
|
||||
|
||||
trader = FakeDT()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda: FakeDT())
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
assert "EQUITY FALLBACK" in out # avvisato
|
||||
assert any("equity fallback" in t for t, _ in alerts) # alert inviato
|
||||
assert "Nessuna azione" in out # HA PROSEGUITO (non halt)
|
||||
assert "EQUITY FALLBACK" in out # avvisato nel log (print, resta)
|
||||
# (#21, U6) UNA sola notify, non due: il vecchio alert "equity fallback (sizing su paper_cap)"
|
||||
# e' stato tolto perche' diceva il falso (qui non si dimensiona alcun ordine) — resta solo
|
||||
# quello del modo SOLO PROTEZIONE.
|
||||
assert len(alerts) == 1
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
assert trader.rebalance_chiamato is False # NESSUN ordine dimensionato
|
||||
assert uscita == 2 # giro degradato
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
"""Test U2 (gitea:Adriano/PythagorasGoal #20, #21, #22, #23, #25).
|
||||
|
||||
Principio unico: quando il giro non sa QUANTO vale il conto (#20, #21) o QUALE e' il segnale
|
||||
(#22), NON manda ordini di ribilancio, ma CONTROLLA LO STESSO il disaster-SL di ogni asset — la
|
||||
protezione dipende da posizione e mark, non da equity o segnale. E un fill gia' eseguito non e'
|
||||
un fill perso se un passo successivo del rebalance, o la lettura della posizione dopo, falliscono
|
||||
(#23). `online` richiede TUTTI i mark da mainnet, non solo BTC (#25).
|
||||
|
||||
Nessun test tocca la rete: trader ed eccezioni sono finti.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from helpers import carica_script
|
||||
from src.live import shadow as S
|
||||
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, RebalanceParziale
|
||||
|
||||
|
||||
def _fresh_bar() -> str:
|
||||
import pandas as _pd
|
||||
return str(_pd.Timestamp.now(tz="UTC").normalize().date())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #20 — USDE illeggibile: l'equity diventa IGNOTA (None), non 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _ClientUSDEBoom:
|
||||
def __init__(self, usdc=2056.0):
|
||||
self._usdc = usdc
|
||||
|
||||
def account_summary(self, cur):
|
||||
if cur == "USDE":
|
||||
raise RuntimeError("gateway giu'")
|
||||
return {"equity": self._usdc}
|
||||
|
||||
|
||||
def test_usde_illeggibile_rende_equity_ignota_non_zero():
|
||||
eq, src = S._equity(_ClientUSDEBoom(), {})
|
||||
assert eq is None
|
||||
assert "USDE illeggibile" in src and "RuntimeError" in src
|
||||
|
||||
|
||||
def test_watermark_non_si_scrive_su_equity_ignota(tmp_path, monkeypatch):
|
||||
"""`write_equity_watermark` riceve None quando l'equity e' ignota: non deve scrivere il
|
||||
file (gia' cosi' nel codice esistente — qui lo si blinda con un test, come chiesto)."""
|
||||
import src.live.book as book
|
||||
monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json")
|
||||
assert book.write_equity_watermark(None) is None
|
||||
assert not (tmp_path / "equity_seen.json").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #25 — online richiede TUTTI i mark mainnet, non solo BTC.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_online_richiede_tutti_i_mark_mainnet(monkeypatch):
|
||||
monkeypatch.setattr(S, "_safe_client", lambda: object())
|
||||
monkeypatch.setattr(S, "_marks", lambda c, d: (
|
||||
{"BTC": 60000.0, "ETH": 3000.0},
|
||||
{"BTC": "mainnet", "ETH": "fallback close (RuntimeError)"}))
|
||||
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
|
||||
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
|
||||
r = S.shadow_report()
|
||||
assert r["online"] is False # prima di #25 sarebbe stato True (guardava solo BTC)
|
||||
|
||||
|
||||
def test_online_true_quando_tutti_i_mark_sono_mainnet(monkeypatch):
|
||||
monkeypatch.setattr(S, "_safe_client", lambda: object())
|
||||
monkeypatch.setattr(S, "_marks", lambda c, d: (
|
||||
{"BTC": 60000.0, "ETH": 3000.0}, {"BTC": "mainnet", "ETH": "mainnet"}))
|
||||
monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None))
|
||||
monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC"))
|
||||
assert S.shadow_report()["online"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #21, #22 — SOLO PROTEZIONE: equity ignota o SKH ignoto -> nessun rebalance_signed, ma il
|
||||
# disaster-SL si controlla LO STESSO per ogni asset. Giro degradato -> exit 2 (se ARMATO).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _asset(nome, inst, mark, order=None):
|
||||
return dict(asset=nome, instrument=inst, tp_frac=1.0, skh_sign=1, skh_state="flat",
|
||||
net_target=225.0, position_usd=0.0, mark=mark, order=order)
|
||||
|
||||
|
||||
def _canned_report(**overrides):
|
||||
base = dict(
|
||||
last_data=_fresh_bar(), online=True, real_equity=None, equity=2000.0,
|
||||
eq_basis="paper capital (ipotetico)", cap_per_asset=300.0, skh_error=None, pos_error=None,
|
||||
eq_fallback=None,
|
||||
assets=[
|
||||
_asset("BTC", "BTC_USDC-PERPETUAL", 60000.0,
|
||||
order=dict(side="buy", delta=225.0, is_close=False, needs_flip=False)),
|
||||
_asset("ETH", "ETH_USDC-PERPETUAL", 3000.0, order=None),
|
||||
],
|
||||
orders=[],
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class _TraderSoloProtezione:
|
||||
"""Trader finto: registra se rebalance_signed viene chiamato (NON deve) e quali asset
|
||||
ricevono ensure_disaster_sl (TUTTI, comunque)."""
|
||||
|
||||
def __init__(self):
|
||||
self.rebalance_chiamato = False
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def rebalance_signed(self, *a, **k):
|
||||
self.rebalance_chiamato = True
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
|
||||
def _run_solo_protezione(canned, monkeypatch, capsys):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
trader = _TraderSoloProtezione()
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
return trader, alerts, uscita, out
|
||||
|
||||
|
||||
def test_eq_fallback_e_solo_protezione(monkeypatch, capsys):
|
||||
canned = _canned_report(
|
||||
eq_fallback="equity reale non leggibile (conto flat) -> sizing su paper_cap $2,000")
|
||||
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
|
||||
assert trader.rebalance_chiamato is False # nessun ordine dimensionato
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione per TUTTI
|
||||
# (#21) UNA sola notify per il guasto, non due: il vecchio testo diceva "sizing su paper_cap",
|
||||
# falso in modo SOLO PROTEZIONE (nessun ordine dimensionato affatto).
|
||||
assert len(alerts) == 1
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
assert "paper_cap" not in alerts[0][0]
|
||||
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out # riepilogo finale corretto
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
def test_skh_error_e_solo_protezione(monkeypatch, capsys):
|
||||
canned = _canned_report(skh_error="RuntimeError: feed 5m giu")
|
||||
trader, alerts, uscita, out = _run_solo_protezione(canned, monkeypatch, capsys)
|
||||
assert trader.rebalance_chiamato is False
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"]
|
||||
# (#22) UNA sola notify, non due ("SKH feed fallito" + "SKH non calcolato").
|
||||
assert len(alerts) == 1
|
||||
assert "solo protezione" in alerts[0][0].lower()
|
||||
assert "SOLO PROTEZIONE: nessun ordine di ribilancio" in out
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
def test_solo_protezione_in_dry_run_non_esce_2(monkeypatch, capsys):
|
||||
"""Un'anteprima (senza execution_enabled) non ha tentato nulla: non e' un giro degradato."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
canned = _canned_report(eq_fallback="equity reale non leggibile -> sizing su paper_cap")
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=False, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute
|
||||
mod._run() # non deve sollevare
|
||||
assert "solo protezione" in capsys.readouterr().out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #23 — fill persi: un fill gia' eseguito non sparisce se un passo successivo del rebalance
|
||||
# (flip) o la lettura della posizione dopo falliscono.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeExec(DeribitTrader):
|
||||
"""DeribitTrader senza rete: instrumenta close/open per il test del flip parziale."""
|
||||
|
||||
def __init__(self, pos_usd, open_boom=None, chiusura_fill=None):
|
||||
self._pos = pos_usd
|
||||
self._open_boom = open_boom
|
||||
self._chiusura_fill = chiusura_fill
|
||||
|
||||
def position_usd(self, instrument):
|
||||
return self._pos
|
||||
|
||||
def close(self, instrument, label="tp01-close"):
|
||||
return self._chiusura_fill
|
||||
|
||||
def open(self, instrument, side, amount, label="tp01-open"):
|
||||
if self._open_boom is not None:
|
||||
raise self._open_boom
|
||||
return Fill(instrument, side, amount, amount, 60000.0, 0.01, "oid", "filled", True, "")
|
||||
|
||||
|
||||
def test_flip_con_open_che_esplode_dopo_close_solleva_rebalance_parziale():
|
||||
"""execution.py (#23a): il close del flip e' andato, l'open dall'altra parte no -> il fill
|
||||
del close NON deve sparire dentro un'eccezione nuda."""
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
t = _FakeExec(pos_usd=120.0, open_boom=RuntimeError("502 Bad Gateway"), chiusura_fill=close_fill)
|
||||
with pytest.raises(RebalanceParziale) as ei:
|
||||
t.rebalance_signed("BTC_USDC-PERPETUAL", -80.0, 60000.0, min_usd=5.0)
|
||||
assert ei.value.fills == [close_fill]
|
||||
assert isinstance(ei.value.causa, RuntimeError) and "502" in str(ei.value.causa)
|
||||
|
||||
|
||||
def test_fill_del_close_non_si_perde_se_lopen_del_flip_fallisce(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#23b): su RebalanceParziale, il fill del close finisce nel jsonl e nella
|
||||
riga di log PRIMA che la causa venga rilanciata; l'asset risulta fallito, l'altro asset
|
||||
(ETH) resta elaborato normalmente."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
|
||||
class _TraderFlip:
|
||||
def __init__(self):
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
if inst.startswith("BTC"):
|
||||
raise RebalanceParziale([close_fill], RuntimeError("open KO dopo close"))
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderFlip()
|
||||
canned = _canned_report() # BTC ha un ordine, ETH order=None
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert uscita == 2
|
||||
assert "-> SELL 0.0020 @ $60,000.0" in out # riga di fill stampata
|
||||
assert "asset falliti in questo giro: BTC" in out # isolamento esistente
|
||||
assert trader.visti_sl == ["ETH_USDC-PERPETUAL"] # BTC salta l'SL questo giro
|
||||
# (il fill del close NON e' un fill perso: e' nel jsonl anche se il resto del flip e' fallito)
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["side"] == "sell" and righe[0]["filled"] == 0.002
|
||||
assert righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
def test_position_usd_illeggibile_dopo_un_fill_non_perde_il_fill(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#23b): rebalance_signed va a segno, ma `position_usd` (reconcile) solleva
|
||||
dopo -> il fill e' comunque registrato con pos_after None, e non e' un guasto del giro."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderReconcileBoom:
|
||||
def __init__(self):
|
||||
self.visti_sl: list[str] = []
|
||||
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
raise RuntimeError("api 500")
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
self.visti_sl.append(inst)
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderReconcileBoom()
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run() # non deve sollevare: il reconcile mancato non e' fatale
|
||||
out = capsys.readouterr().out
|
||||
assert "reconcile: pos illeggibile (RuntimeError)" in out
|
||||
assert "asset falliti" not in out # NON e' trattato come giro fallito
|
||||
assert trader.visti_sl == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # protezione comunque
|
||||
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #39 — regressione di #23: pos_after finiva sempre None nel jsonl, anche quando il reconcile
|
||||
# leggeva la posizione senza problemi (la lettura era rimasta un side-effect informativo, il suo
|
||||
# risultato non arrivava piu' a `_registra_fill`).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_rebalance_ok_scrive_pos_after_dal_reconcile(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#39): rebalance_signed e position_usd vanno a segno entrambi -> il record
|
||||
di log_event ha pos_after = il valore letto dal reconcile, non None."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderOk:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 500.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
out = capsys.readouterr().out
|
||||
assert "reconcile: pos $500" in out
|
||||
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["pos_after"] == 500.0
|
||||
|
||||
|
||||
def test_position_usd_che_solleva_non_impedisce_il_fill_pos_after_none(tmp_path, monkeypatch, capsys):
|
||||
"""book_execute (#39): stesso scenario di sopra ma il reconcile solleva -> il fill si
|
||||
registra comunque (vincolo #23), con pos_after None."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderReconcileBoom:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
raise RuntimeError("api 500")
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderReconcileBoom())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run() # non deve sollevare: il fill non e' perso
|
||||
out = capsys.readouterr().out
|
||||
assert "reconcile: pos illeggibile (RuntimeError)" in out
|
||||
|
||||
righe = [json.loads(l) for l in (tmp_path / "book_executions.jsonl").read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["asset"] == "BTC" and righe[0]["filled"] == 0.003
|
||||
assert righe[0]["pos_after"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Il formato delle righe stampate non e' cambiato: tradesdb.parse_cron_log deve continuare a
|
||||
# leggerlo, sia in un giro normale sia in un giro con RebalanceParziale (#23).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_cron_log_legge_un_giro_normale_prodotto_dal_nuovo_codice(monkeypatch, capsys, tmp_path):
|
||||
from src.live import tradesdb as T
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
fill_btc = Fill("BTC_USDC-PERPETUAL", "buy", 0.003, 0.003, 60000.0, 0.02, "o1", "filled", True, "")
|
||||
|
||||
class _TraderOk:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return [fill_btc] if inst.startswith("BTC") else []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 180.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderOk())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
out = capsys.readouterr().out
|
||||
log = f"===== 2026-09-27T13:47:01Z cron_book =====\n{out}===== done 13:47:05Z =====\n"
|
||||
runs = T.parse_cron_log(log)
|
||||
assert len(runs) == 1 and len(runs[0].fills) == 1
|
||||
f = runs[0].fills[0]
|
||||
assert f.asset == "BTC" and f.side == "buy" and abs(f.qty - 0.003) < 1e-9 and f.verified == 1
|
||||
|
||||
|
||||
def test_parse_cron_log_legge_il_fill_di_un_RebalanceParziale(monkeypatch, capsys, tmp_path):
|
||||
from src.live import tradesdb as T
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
monkeypatch.setattr(mod, "LOG_DIR", tmp_path)
|
||||
monkeypatch.setattr(mod, "LOG", tmp_path / "book_executions.jsonl")
|
||||
|
||||
close_fill = Fill("BTC_USDC-PERPETUAL", "sell", 0.002, 0.002, 60000.0, 0.01, "c1",
|
||||
"filled", True, "")
|
||||
|
||||
class _TraderFlip:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
if inst.startswith("BTC"):
|
||||
raise RebalanceParziale([close_fill], RuntimeError("open KO"))
|
||||
return []
|
||||
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report()
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderFlip())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit:
|
||||
pass
|
||||
out = capsys.readouterr().out
|
||||
log = f"===== 2026-09-27T14:47:01Z cron_book =====\n{out}===== done 14:47:05Z =====\n"
|
||||
runs = T.parse_cron_log(log)
|
||||
assert len(runs) == 1 and len(runs[0].fills) == 1
|
||||
f = runs[0].fills[0]
|
||||
assert f.asset == "BTC" and f.side == "sell" and abs(f.qty - 0.002) < 1e-9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set_open_cap (#18): chiamato SEMPRE, diretto (l'hasattr era solo per il periodo prima del
|
||||
# merge con U1, in cui DeribitTrader non lo aveva ancora — tolto qui).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_set_open_cap_chiamato_se_il_trader_lo_espone(monkeypatch):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderConCap:
|
||||
def __init__(self):
|
||||
self.cap_visti = []
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
self.cap_visti.append((inst, cap_usd, mark))
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderConCap()
|
||||
canned = _canned_report(cap_per_asset=300.0, scala=1.25)
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
mod._run()
|
||||
assert trader.cap_visti == [("BTC_USDC-PERPETUAL", 300.0 * 1.25, 60000.0)] # solo BTC ha un ordine
|
||||
|
||||
|
||||
def test_trader_senza_set_open_cap_fallisce_isolato_non_lascia_morire_il_giro(monkeypatch, capsys):
|
||||
"""Post-merge: `set_open_cap` si chiama diretto, senza hasattr (era solo transitorio prima
|
||||
del merge con U1). Un trader che non lo espone ora fa fallire QUELL'asset (isolamento per
|
||||
asset, gia' esistente) — non l'intero giro: ETH resta elaborato normalmente."""
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderSenzaCap:
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
canned = _canned_report() # BTC ha un ordine (set_open_cap si chiama), ETH order=None
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: None)
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderSenzaCap())
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
assert "AttributeError" in out # isolato: si vede nel log, non muore il giro
|
||||
assert "asset falliti in questo giro: BTC" in out
|
||||
assert uscita == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #24 — ensure_disaster_sl "unknown" in book_execute: NON e' "naked" (non si e' tolto nulla),
|
||||
# ma il giro resta degradato -> notify dedicata e exit 2.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ensure_disaster_sl_unknown_notifica_e_degrada_il_giro(monkeypatch, capsys):
|
||||
mod = carica_script("scripts/live/book_execute.py")
|
||||
|
||||
class _TraderUnknown:
|
||||
def set_open_cap(self, inst, cap_usd, mark):
|
||||
pass
|
||||
def rebalance_signed(self, inst, net, mark, min_usd=5.0):
|
||||
return []
|
||||
def position_usd(self, inst):
|
||||
return 0.0
|
||||
def ensure_disaster_sl(self, inst, sl_pct):
|
||||
if inst.startswith("ETH"):
|
||||
return {"state": "unknown", "notes": "RuntimeError: 502 su get_positions"}
|
||||
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||
|
||||
trader = _TraderUnknown()
|
||||
canned = _canned_report() # BTC ha un ordine, ETH order=None -> ETH e' l'asset "unknown"
|
||||
alerts = []
|
||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
|
||||
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
|
||||
monkeypatch.setattr(mod, "load_config",
|
||||
lambda: dict(execution_enabled=True, min_order_usd=5.0,
|
||||
disaster_sl_pct=0.30, max_data_age_days=2.0,
|
||||
skh_feed_max_age_min=30.0))
|
||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
||||
|
||||
uscita = None
|
||||
try:
|
||||
mod._run()
|
||||
except SystemExit as e:
|
||||
uscita = e.code
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert uscita == 2
|
||||
assert "ETH: protezione NON VERIFICATA" in out
|
||||
notifiche = [d for t, d in alerts if "non verificabile" in t.lower()]
|
||||
assert len(notifiche) == 1
|
||||
assert notifiche[0]["asset"] == "ETH"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# execution.py::_submit (#21) — legge il tetto con getattr: un DeribitTrader creato senza
|
||||
# passare da __init__ (object.__new__, come qui) non deve rompersi con AttributeError perche'
|
||||
# gli manca l'attributo _open_cap.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_submit_su_trader_senza_open_cap_non_solleva_attributeerror(monkeypatch):
|
||||
t = object.__new__(DeribitTrader) # bypassa __init__: niente self._open_cap
|
||||
monkeypatch.setattr(t, "_post", lambda path, payload: {
|
||||
"order": {"order_id": "1", "order_state": "filled",
|
||||
"filled_amount": payload["amount"]},
|
||||
"trades": [{"price": 1.0, "amount": payload["amount"], "fee": 0.0}]})
|
||||
dentro = MAX_AMOUNT["ETH_USDC-PERPETUAL"] * 0.5
|
||||
f = t._submit("ETH_USDC-PERPETUAL", "buy", dentro, reduce_only=False, label="book-open")
|
||||
assert f.verified is True # arrivato in fondo: niente AttributeError sul cap
|
||||
@@ -0,0 +1,90 @@
|
||||
"""cc01_regime_watch registra ogni giro misurato, e monitor_health lo sorveglia (#12).
|
||||
|
||||
PERCHE' ESISTE. Dal 03/09 al 16/09/2026 lo script e' morto 14 notti di fila con
|
||||
ModuleNotFoundError (#10) e nessuno se n'e' accorto: `cron_daily.sh` va avanti dopo un errore,
|
||||
e `monitor_health` guarda solo le serie che i monitor scrivono — cc01 non ne scriveva. Ora ogni
|
||||
giro misurato lascia una riga, e un cc01 fermo diventa FERMO come ogni altro sorvegliante.
|
||||
|
||||
Niente rete: il funding di Hyperliquid e' finto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.monitor_health import MONITORS, check_all # noqa: E402
|
||||
|
||||
|
||||
def _carica():
|
||||
sp = importlib.util.spec_from_file_location("cc01_regime_watch",
|
||||
ROOT / "scripts" / "live" / "cc01_regime_watch.py")
|
||||
m = importlib.util.module_from_spec(sp)
|
||||
sp.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
W = _carica()
|
||||
SPEC = next((m for m in MONITORS if m.name == "cc01_regime_watch"), None)
|
||||
|
||||
|
||||
def _funding_annuo(ann: float, ore: int = 24 * 35):
|
||||
"""Una serie oraria costante che annualizza a `ann`."""
|
||||
return lambda coin, days=35: np.full(ore, ann / W.HOURS_PER_YEAR)
|
||||
|
||||
|
||||
def test_e_registrato_in_monitor_health_sul_file_che_scrive():
|
||||
# P1: il bersaglio della sorveglianza si deriva dal codice sorvegliato, non si ridichiara
|
||||
assert SPEC is not None, "cc01_regime_watch non e' in monitor_health.MONITORS"
|
||||
assert ROOT / "data" / SPEC.directory / SPEC.series == W.RUNS
|
||||
assert SPEC.cadence_h == 24.0 and SPEC.open_labeled is False and SPEC.max_age_h <= 30.0
|
||||
|
||||
|
||||
def test_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(W, "RUNS", runs)
|
||||
monkeypatch.setattr(W, "fetch_recent_funding", _funding_annuo(0.12))
|
||||
W.main()
|
||||
righe = [json.loads(r) for r in runs.read_text().splitlines()]
|
||||
assert len(righe) == 1
|
||||
r = righe[0]
|
||||
ora_ms = datetime.now(timezone.utc).timestamp() * 1000
|
||||
assert abs(r["ts"] - ora_ms) < 60_000, "ts in millisecondi epoch, come legge monitor_health"
|
||||
assert r["stato"] == "WARN"
|
||||
assert abs(r["BTC"]["ann30"] - 0.12) < 1e-9 and abs(r["ETH"]["ann7"] - 0.12) < 1e-9
|
||||
|
||||
|
||||
def test_un_giro_senza_dati_non_scrive_e_non_dice_quiet(tmp_path, monkeypatch, capsys):
|
||||
# un giro che non ha misurato non e' una barra: se scrivesse, un cc01 cieco resterebbe «OK»
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(W, "RUNS", runs)
|
||||
monkeypatch.setattr(W, "fetch_recent_funding", _funding_annuo(0.12, ore=10))
|
||||
W.main()
|
||||
assert not runs.exists()
|
||||
out = capsys.readouterr().out
|
||||
assert "regime QUIET" not in out and "nessuna riga registrata" in out
|
||||
|
||||
|
||||
def test_monitor_health_legge_le_righe_di_cc01(tmp_path, monkeypatch):
|
||||
# integrazione: le righe scritte da cc01 sono barre per monitor_health, e un cc01 fermo e' FERMO
|
||||
runs = tmp_path / "data" / SPEC.directory / SPEC.series
|
||||
runs.parent.mkdir(parents=True)
|
||||
monkeypatch.setattr(W, "RUNS", runs)
|
||||
monkeypatch.setattr(W, "fetch_recent_funding", _funding_annuo(0.05))
|
||||
W.main()
|
||||
ieri = json.loads(runs.read_text())
|
||||
ieri["ts"] -= 24 * 3600 * 1000
|
||||
runs.write_text(json.dumps(ieri) + "\n" + runs.read_text())
|
||||
|
||||
adesso = datetime.now(timezone.utc)
|
||||
stato = {r["name"]: r for r in check_all(tmp_path, now=adesso + timedelta(minutes=1), monitors=(SPEC,))}
|
||||
assert stato["cc01_regime_watch"]["status"] == "OK"
|
||||
fermo = check_all(tmp_path, now=adesso + timedelta(hours=31), monitors=(SPEC,))[0]
|
||||
assert fermo["status"] == "FERMO"
|
||||
@@ -0,0 +1,331 @@
|
||||
"""cuscino_watch: il cuscino USDC e' sorvegliato; sotto zero si vende USDE, sopra la banda si ricompra.
|
||||
|
||||
Blindano: (a) il cuscino e' DERIVATO da config + book (P1), la stessa formula di usde_convert;
|
||||
(b) i tre stati e la soglia di preavviso; (c) la transizione non si consuma se l'invio fallisce
|
||||
(debito §5.2); (d) le guardie della riconversione automatica — interruttore del libro, depeg,
|
||||
cadenza — e la quota di ripristino che lascia margine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import usde as U # noqa: E402
|
||||
|
||||
|
||||
def _carica(nome):
|
||||
sp = importlib.util.spec_from_file_location(nome, ROOT / "scripts" / "live" / f"{nome}.py")
|
||||
m = importlib.util.module_from_spec(sp)
|
||||
sp.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
W = _carica("cuscino_watch")
|
||||
|
||||
|
||||
# ------------------------------ il cuscino e' derivato, non ridichiarato ------------------------------
|
||||
|
||||
def test_cuscino_derivato_da_config_e_book():
|
||||
cfg = json.loads((ROOT / "config" / "live.json").read_text())
|
||||
from src.live import book as B
|
||||
f, come = U.frazione_cuscino()
|
||||
assert abs(f - 2 * float(B.WEIGHT) * float(cfg["disaster_sl_pct"])) < 1e-12
|
||||
usd, _ = U.cuscino_richiesto_usd(1000.0)
|
||||
assert abs(usd - 1000.0 * f) < 1e-9
|
||||
assert "SL" in come
|
||||
|
||||
|
||||
def test_usde_convert_usa_la_stessa_formula():
|
||||
C = _carica("usde_convert")
|
||||
assert C.cuscino_richiesto_usd(4469.46) == U.cuscino_richiesto_usd(4469.46)
|
||||
|
||||
|
||||
def test_quota_ripristino_lascia_margine_sul_cuscino():
|
||||
f, _ = U.frazione_cuscino()
|
||||
q0, q10 = U.quota_ripristino(0.0), U.quota_ripristino(0.10)
|
||||
assert abs(q0 - (1 - f)) < 1e-12 # a margine zero e' il complemento del cuscino
|
||||
assert q10 < q0 # col margine si vende UN PO' di piu'
|
||||
# dopo la riconversione a q10 lo slack e' esattamente il 10% del cuscino
|
||||
E = 4000.0
|
||||
usdc_dopo = E * (1 - q10)
|
||||
assert abs((usdc_dopo - E * f) - 0.10 * E * f) < 1e-9
|
||||
|
||||
|
||||
# ------------------------------ i quattro stati (issue #7) ------------------------------
|
||||
|
||||
B = dict(preavviso=0.10, margine=0.20, riacquisto=0.40)
|
||||
|
||||
|
||||
def test_stati_scoperto_preavviso_ok_eccedente():
|
||||
assert W.giudica(1199.0, 4000.0, 1200.0, B)["stato"] == "SCOPERTO"
|
||||
g = W.giudica(1300.0, 4000.0, 1200.0, B)
|
||||
assert g["stato"] == "PREAVVISO" and abs(g["slack"] - 100.0) < 1e-9 and g["preavviso_usd"] == 120.0
|
||||
assert W.giudica(1500.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 300: dentro [120, 480]
|
||||
assert W.giudica(1680.0, 4000.0, 1200.0, B)["stato"] == "OK" # slack 480: sul bordo, non compra
|
||||
g = W.giudica(1700.0, 4000.0, 1200.0, B)
|
||||
assert g["stato"] == "ECCEDENTE" and g["riacquisto_usd"] == 480.0
|
||||
|
||||
|
||||
def test_le_bande_vengono_dalla_config_e_sono_ordinate():
|
||||
b = U.bande_cuscino()
|
||||
assert 0 < b["preavviso"] < b["margine"] < b["riacquisto"]
|
||||
assert W.giudica(1500.0, 4000.0, 1200.0)["stato"] == W.giudica(1500.0, 4000.0, 1200.0, b)["stato"]
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
U.bande_cuscino({"cuscino_preavviso_frac": 0.3, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4})
|
||||
|
||||
|
||||
def test_isteresi_vendita_e_acquisto_convergono_allo_stesso_bersaglio():
|
||||
"""Dopo una vendita da SCOPERTO o un acquisto da ECCEDENTE lo slack e' margine x cuscino: OK,
|
||||
a distanza da entrambe le soglie. Un giro non ne innesca un altro."""
|
||||
f, _ = U.frazione_cuscino(); b = U.bande_cuscino()
|
||||
E = 4450.0; cusc = E * f
|
||||
q = U.quota_ripristino(b["margine"])
|
||||
usdc_dopo = E * (1 - q)
|
||||
g = W.giudica(usdc_dopo, E, cusc, b)
|
||||
assert g["stato"] == "OK"
|
||||
assert g["slack"] > g["preavviso_usd"] * 1.5 and g["slack"] < g["riacquisto_usd"] / 1.5
|
||||
|
||||
|
||||
def test_un_bonifico_fa_scattare_il_riacquisto_da_solo():
|
||||
"""Conto a bersaglio (slack = 0,20 x cuscino), bonifico D in USDC: slack +0,7 D. Con D = 12%
|
||||
dell'equity si supera 0,40 x cuscino e si ricompra; con D = 3% no (resta in USDC)."""
|
||||
f, _ = U.frazione_cuscino(); b = U.bande_cuscino()
|
||||
E = 4450.0; cusc = E * f; usdc = cusc * (1 + b["margine"])
|
||||
for D, atteso in ((0.03 * E, "OK"), (0.12 * E, "ECCEDENTE")):
|
||||
E2 = E + D; cusc2 = E2 * f
|
||||
assert W.giudica(usdc + D, E2, cusc2, b)["stato"] == atteso, D
|
||||
|
||||
|
||||
def test_non_vedo_e_blind_non_ok():
|
||||
assert W.giudica(None, 4000.0, 1200.0, B)["stato"] == "BLIND"
|
||||
assert W.giudica(1500.0, None, None, B)["stato"] == "BLIND"
|
||||
|
||||
|
||||
def test_gli_stati_che_convertono_sono_due_e_hanno_un_verso():
|
||||
assert W.AZIONE == {"SCOPERTO": "vendita", "ECCEDENTE": "acquisto"}
|
||||
|
||||
|
||||
# ------------------------------ transizione e trasporto ------------------------------
|
||||
|
||||
def test_ok_non_allerta_mai():
|
||||
assert not W.transizione(None, "OK") and not W.transizione({"stato": "SCOPERTO"}, "OK")
|
||||
|
||||
|
||||
def test_transizione_alla_prima_e_al_cambio_di_stato():
|
||||
assert W.transizione(None, "PREAVVISO")
|
||||
assert W.transizione({"stato": "OK", "allertato": None}, "ECCEDENTE")
|
||||
assert W.transizione({"stato": "OK", "allertato": None}, "PREAVVISO")
|
||||
assert W.transizione({"stato": "PREAVVISO", "allertato": True}, "SCOPERTO")
|
||||
assert not W.transizione({"stato": "PREAVVISO", "allertato": True}, "PREAVVISO")
|
||||
|
||||
|
||||
def test_un_invio_fallito_non_consuma_la_transizione():
|
||||
"""Debito §5.2: il marcatore 'gia' detto' e' l'esito dell'invio."""
|
||||
assert W.transizione({"stato": "SCOPERTO", "allertato": False}, "SCOPERTO")
|
||||
assert W.transizione({"stato": "SCOPERTO", "allertato": None}, "SCOPERTO")
|
||||
|
||||
|
||||
# ------------------------------ guardie della riconversione ------------------------------
|
||||
|
||||
def test_disarmare_il_libro_disarma_la_riconversione():
|
||||
ok, m = W.puo_riconvertire([], 0, False, 1.0, 0.99)
|
||||
assert not ok and "execution_enabled" in m
|
||||
|
||||
|
||||
def test_sotto_depeg_non_si_vende_al_buio():
|
||||
ok, m = W.puo_riconvertire([], 0, True, 0.985, 0.99)
|
||||
assert not ok and "depeg" in m
|
||||
ok, m = W.puo_riconvertire([], 0, True, None, 0.99)
|
||||
assert not ok and "leggibile" in m
|
||||
|
||||
|
||||
def test_un_tentativo_ogni_sei_ore():
|
||||
now = 1_789_000_000_000
|
||||
prev = [{"ts": now - 2 * 3_600_000, "riconversione": {"tentata": True}}]
|
||||
ok, m = W.puo_riconvertire(prev, now, True, 1.0, 0.99)
|
||||
assert not ok and "non si insiste" in m
|
||||
prev = [{"ts": now - 7 * 3_600_000, "riconversione": {"tentata": True}}]
|
||||
assert W.puo_riconvertire(prev, now, True, 1.0, 0.99)[0]
|
||||
# un record SENZA riconversione non conta come tentativo
|
||||
assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0]
|
||||
|
||||
|
||||
def _finto_lettore_quota(valori):
|
||||
"""Ritorna i valori dati in ordine, uno per chiamata (per monkeypatch di _leggi_quota_usde)."""
|
||||
it = iter(valori)
|
||||
return lambda: next(it)
|
||||
|
||||
|
||||
def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
|
||||
# rc==0: "ok" a prescindere dalla quota (lo script vero ha gia' le sue guardie, P15) — la
|
||||
# quota qui non si muove, per mostrare che il caso "ok" non dipende dalla rilettura.
|
||||
chiamate = []
|
||||
|
||||
class R:
|
||||
returncode, stdout, stderr = 0, " → piano valido\n ESITO: 10 USDE\n", ""
|
||||
|
||||
def finto_run(cmd, **kw):
|
||||
chiamate.append(cmd)
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(W.subprocess, "run", finto_run)
|
||||
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.67, 0.67]))
|
||||
e = W.riconverti(0.67)
|
||||
assert e["ok"] and e["esito"] == "ok" and chiamate and chiamate[0][1].endswith("usde_convert.py")
|
||||
assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0]
|
||||
assert e["coda"][-1].startswith(" ESITO")
|
||||
assert e["quota_prima"] == 0.67 and e["quota_dopo"] == 0.67
|
||||
|
||||
|
||||
def test_riconverti_rc_diverso_da_zero_con_quota_mossa_e_parziale(monkeypatch):
|
||||
# #35 B10: rc!=0 (es. ordine parzialmente fillato poi rifiutato) ma la quota si e' mossa
|
||||
# per davvero -> "parziale", non "fallita": la vendita/acquisto e' avvenuto, solo non finito.
|
||||
class R:
|
||||
returncode, stdout, stderr = 1, "", "book illeggibile a meta' piano\n"
|
||||
|
||||
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
|
||||
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.70]))
|
||||
e = W.riconverti(0.64)
|
||||
assert e["esito"] == "parziale" and e["ok"] is False
|
||||
assert e["quota_prima"] == 0.75 and e["quota_dopo"] == 0.70
|
||||
|
||||
|
||||
def test_riconverti_rc_diverso_da_zero_con_quota_ferma_e_fallita(monkeypatch):
|
||||
class R:
|
||||
returncode, stdout, stderr = 1, "", "prezzo fuori banda: niente ordini\n"
|
||||
|
||||
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
|
||||
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.751]))
|
||||
e = W.riconverti(0.64)
|
||||
assert e["esito"] == "fallita" and e["ok"] is False
|
||||
|
||||
|
||||
def test_riconverti_timeout_con_quota_mossa_e_parziale(monkeypatch):
|
||||
# #35 B10: il subprocess viene ucciso a TIMEOUT_CONVERT_S ma alcuni ordini erano gia' fillati
|
||||
# prima del kill -> oggi (ok=rc==0) diventava "FALLITA", corretto e' "parziale".
|
||||
def finto_run(cmd, **kw):
|
||||
raise W.subprocess.TimeoutExpired(cmd, kw.get("timeout"))
|
||||
|
||||
monkeypatch.setattr(W.subprocess, "run", finto_run)
|
||||
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.80, 0.72]))
|
||||
e = W.riconverti(0.64)
|
||||
assert e["rc"] is None and e["esito"] == "parziale" and e["ok"] is False
|
||||
assert e["coda"][0].startswith("timeout dopo")
|
||||
|
||||
|
||||
def test_riconverti_rilettura_dopo_fallita_e_fallita_esplicita(monkeypatch):
|
||||
# se non si riesce a rileggere la quota dopo, non si puo' dire "parziale": si dichiara
|
||||
# l'incertezza invece di dedurre un movimento zero che non si e' verificato.
|
||||
class R:
|
||||
returncode, stdout, stderr = 1, "", "errore\n"
|
||||
|
||||
monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R())
|
||||
monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, None]))
|
||||
e = W.riconverti(0.64)
|
||||
assert e["esito"] == "fallita (quota dopo non leggibile)" and e["ok"] is False
|
||||
assert e["quota_dopo"] is None
|
||||
|
||||
|
||||
def test_il_monitor_e_registrato_in_monitor_health():
|
||||
from src.live.monitor_health import MONITORS
|
||||
spec = [m for m in MONITORS if m.name == "cuscino_watch"]
|
||||
assert spec and spec[0].cadence_h == 1.0 and spec[0].open_labeled is False
|
||||
|
||||
|
||||
def test_un_non_tentativo_non_consuma_il_budget():
|
||||
"""Prezzo illeggibile, --secco, interruttore spento: `tentata=False` non blocca il giro dopo."""
|
||||
now = 1_789_000_000_000
|
||||
prev = [{"ts": now - 3_600_000, "riconversione": {"tentata": False, "motivo": "--secco"}}]
|
||||
assert W.puo_riconvertire(prev, now, True, 1.0, 0.99)[0]
|
||||
|
||||
|
||||
def test_dopo_il_ripristino_lo_stato_e_OK_non_preavviso():
|
||||
"""Il margine di ripristino deve superare la soglia di preavviso, o ogni 🚨 e' seguito da un ⚠️
|
||||
per costruzione (P14). Include il floor di 1 USDE e la tolleranza di quota di usde_convert."""
|
||||
b = U.bande_cuscino()
|
||||
assert b["margine"] > b["preavviso"]
|
||||
f, _ = U.frazione_cuscino()
|
||||
E = 4450.0
|
||||
C = _carica("usde_convert")
|
||||
q = U.quota_ripristino(b["margine"]) + C.TOLL # usde_convert si ferma entro TOLL (importato, P1)
|
||||
usdc_dopo = E * (1 - q) - 1.0 # e vende fino a 1 USDE in meno (passo del venue)
|
||||
assert W.giudica(usdc_dopo, E, E * f, b)["stato"] == "OK"
|
||||
|
||||
|
||||
def test_il_sink_telegram_fa_escape_di_minore_e_ampersand(monkeypatch):
|
||||
"""Telegram in parse_mode HTML rifiuta un '<' nudo: il primo giro vero (13:53Z del 10/09) ha
|
||||
perso l'allerta PREAVVISO per «(< 10% del cuscino)». L'escape sta nel SINK (`notify`), non nei
|
||||
45 chiamanti — qui si cattura il corpo della POST e si verifica che il '<' sia diventato <."""
|
||||
from src.live import notifier as N
|
||||
catturato = {}
|
||||
|
||||
def finto_urlopen(url, data, timeout=10):
|
||||
import urllib.parse
|
||||
catturato.update(urllib.parse.parse_qs(data.decode()))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(N, "_cfg", lambda: ("tok", "chat"))
|
||||
monkeypatch.setattr(N.urllib.request, "urlopen", finto_urlopen)
|
||||
assert N.notify("⚠️ prova <x>", {"slack": "$+30 (< 10% del cuscino)", "a&b": "c"})
|
||||
testo = catturato["text"][0]
|
||||
assert "(< 10%" in testo and "<x>" in testo and "a&b" in testo
|
||||
assert testo.startswith("<b>") # il grassetto del titolo resta HTML vero
|
||||
|
||||
|
||||
def test_bande_fuori_ordine_danno_BLIND_con_motivo_non_un_crash():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
U.bande_cuscino({"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4})
|
||||
monkey = {"cuscino_preavviso_frac": 0.5, "cuscino_margine_frac": 0.2, "cuscino_riacquisto_frac": 0.4}
|
||||
orig = U.cfg
|
||||
try:
|
||||
U.cfg = lambda: {**orig(), **monkey}
|
||||
g = W.giudica(1500.0, 4000.0, 1200.0) # bande=None -> config rotta
|
||||
finally:
|
||||
U.cfg = orig
|
||||
assert g["stato"] == "BLIND" and "fuori ordine" in g["motivo"]
|
||||
|
||||
|
||||
def test_da_eccedente_un_fallimento_si_ritenta_una_volta_al_giorno():
|
||||
"""USDC fermo non e' un rischio: un guasto persistente (tetto del venue, book illeggibile) non
|
||||
deve diventare 4 Telegram al giorno per sempre. Da SCOPERTO il ritmo resta 6h."""
|
||||
now = 1_789_000_000_000
|
||||
fallito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}]
|
||||
ok, m = W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="ECCEDENTE")
|
||||
assert not ok and "una volta al giorno" in m
|
||||
assert W.puo_riconvertire(fallito, now, True, 1.0, 0.99, stato="SCOPERTO")[0]
|
||||
riuscito = [{"ts": now - 8 * 3_600_000, "riconversione": {"tentata": True, "ok": True}}]
|
||||
assert W.puo_riconvertire(riuscito, now, True, 1.0, 0.99, stato="ECCEDENTE")[0]
|
||||
tardi = [{"ts": now - 25 * 3_600_000, "riconversione": {"tentata": True, "ok": False}}]
|
||||
assert W.puo_riconvertire(tardi, now, True, 1.0, 0.99, stato="ECCEDENTE")[0]
|
||||
|
||||
|
||||
def test_i_motivi_delle_guardie_non_hanno_minore_nudo():
|
||||
"""Difesa in profondita' oltre al sink: i motivi finiscono nel campo `azione` delle allerte."""
|
||||
now = 1_789_000_000_000
|
||||
casi = [W.puo_riconvertire([], now, False, 1.0, 0.99), W.puo_riconvertire([], now, True, None, 0.99),
|
||||
W.puo_riconvertire([], now, True, 0.98, 0.99),
|
||||
W.puo_riconvertire([{"ts": now - 1000, "riconversione": {"tentata": True, "ok": True}}], now, True, 1.0, 0.99)]
|
||||
assert all("<" not in m for _, m in casi)
|
||||
|
||||
|
||||
def test_cron_installato_e_dichiarato_fuori_dal_tondo_e_dopo_il_book():
|
||||
"""Legge la crontab VIVA (come test_book_cadenza), non un commento nello .sh (P1)."""
|
||||
import shutil
|
||||
import subprocess
|
||||
s = (ROOT / "scripts" / "cron_cuscino.sh").read_text()
|
||||
assert "cuscino_watch.py --quiet" in s
|
||||
if not shutil.which("crontab"):
|
||||
return # SALTATO: distinto da 'assente'
|
||||
out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, timeout=10)
|
||||
if out.returncode != 0 or "PythagorasGoal" not in out.stdout:
|
||||
return # altra macchina / altro utente
|
||||
righe = [ln for ln in out.stdout.splitlines() if "cron_cuscino.sh" in ln and not ln.startswith("#")]
|
||||
assert len(righe) == 1, "cron_cuscino.sh deve essere installato UNA volta"
|
||||
minuto, ora = righe[0].split()[:2]
|
||||
assert minuto == "53" and ora == "*" # orario, dopo il book (:47)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tre riparazioni sul percorso con soldi veri (unita' U1, 2026-09-27), provate contro il codice
|
||||
che dovrebbe farle — non discusse.
|
||||
|
||||
#17. `DeribitRead.position_usd` leggeva `size`/`size_currency` in ASSOLUTO e ignorava
|
||||
`direction`: uno short risultava un long, e close()/ensure_disaster_sl decidevano lato
|
||||
ordine e prezzo dello stop dal segno sbagliato.
|
||||
#18. Tetto per ordine d'apertura: `MAX_AMOUNT` era un tetto FISSO uguale per tutti i conti;
|
||||
ora il libro deriva il proprio tetto dal cap di config via `set_open_cap()`.
|
||||
#24. `DeribitRead.open_orders` ingoiava gli errori di ENTRAMBE le chiamate ('all' e
|
||||
'trigger_all') e ritornava []: un bracket disaster-SL perso passava per "nessun bracket".
|
||||
Ora solleva, e `ensure_disaster_sl` distingue lo stato "unknown" (letture fallite, non si
|
||||
cancella ne' si piazza) da "naked" (protezione tolta e non rimessa).
|
||||
|
||||
Nessun test tocca la rete.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.live.deribit import DeribitRead
|
||||
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, GuardrailError
|
||||
|
||||
ETH = "ETH_USDC-PERPETUAL"
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# #17 — position_usd: il segno viene da `direction`, non assunto dal solo `size`.
|
||||
# =================================================================================================
|
||||
class _FakeRead(DeribitRead):
|
||||
"""DeribitRead senza rete: `positions()` finto, per esercitare `position_usd` con la forma
|
||||
REALE del gateway (size in assoluto + direction a parte)."""
|
||||
|
||||
def __init__(self, raw: list[dict]) -> None:
|
||||
self._raw = raw
|
||||
|
||||
def positions(self, currency):
|
||||
return self._raw
|
||||
|
||||
|
||||
def test_position_usd_sell_e_negativo():
|
||||
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
|
||||
assert r.position_usd(ETH) == -560.0
|
||||
|
||||
|
||||
def test_position_usd_buy_e_positivo():
|
||||
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "buy"}])
|
||||
assert r.position_usd(ETH) == 560.0
|
||||
|
||||
|
||||
def test_position_usd_senza_direction_si_fida_del_segno_di_size():
|
||||
"""Compatibile con un gateway che un giorno mandasse gia' il segno in `size`."""
|
||||
r = _FakeRead([{"instrument_name": ETH, "size": -3}])
|
||||
assert r.position_usd(ETH) == -3.0
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# Verifica a valle di #17: close() e ensure_disaster_sl() decidono lato/stop da `pos > 0`.
|
||||
# Si mocka `positions()` (dati grezzi del gateway), NON `position_usd()`: cosi' si esercita
|
||||
# davvero la pipeline positions() -> position_usd() -> close()/ensure_disaster_sl().
|
||||
# =================================================================================================
|
||||
class _FakeTraderShort(DeribitTrader):
|
||||
"""DeribitTrader senza rete: posizione SHORT letta come il gateway la manda davvero, mark
|
||||
fisso, `_submit` registra la chiamata invece di piazzare l'ordine."""
|
||||
|
||||
def __init__(self, raw_positions: list[dict], mark: float = 2694.0) -> None:
|
||||
self._open_cap: dict[str, float] = {}
|
||||
self._raw = raw_positions
|
||||
self._mark = mark
|
||||
self.submitted: list[dict] = []
|
||||
self.cancelled: list[str] = []
|
||||
|
||||
def positions(self, currency):
|
||||
return self._raw
|
||||
|
||||
def mark_price(self, instrument):
|
||||
return self._mark
|
||||
|
||||
def open_orders(self, instrument):
|
||||
return []
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
self.cancelled.append(order_id)
|
||||
return {}
|
||||
|
||||
def _submit(self, instrument, side, amount, *, reduce_only, label,
|
||||
order_type="market", price=None):
|
||||
self.submitted.append(dict(instrument=instrument, side=side, amount=amount,
|
||||
reduce_only=reduce_only, label=label,
|
||||
order_type=order_type, price=price))
|
||||
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
|
||||
fee_usdc=0.0, order_id="1", state="filled", verified=True)
|
||||
|
||||
|
||||
def _short_560():
|
||||
return _FakeTraderShort([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
|
||||
|
||||
|
||||
def test_close_su_short_usa_side_buy_reduce_only():
|
||||
t = _short_560()
|
||||
f = t.close(ETH)
|
||||
assert f is not None
|
||||
assert len(t.submitted) == 1
|
||||
call = t.submitted[0]
|
||||
assert call["side"] == "buy" and call["reduce_only"] is True
|
||||
assert call["label"] == "tp01-close" and call["amount"] > 0
|
||||
|
||||
|
||||
def test_ensure_disaster_sl_su_short_mette_lo_stop_sopra_il_mark_lato_buy():
|
||||
t = _short_560()
|
||||
ds = t.ensure_disaster_sl(ETH, 0.30)
|
||||
assert ds["state"] == "placed"
|
||||
assert len(t.submitted) == 1
|
||||
call = t.submitted[0]
|
||||
assert call["side"] == "buy"
|
||||
assert call["order_type"] == "stop_market"
|
||||
assert call["price"] > t._mark # stop SOPRA il mark per uno short
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# #18 — set_open_cap(): tetto di apertura per strumento, derivato dal cap di config.
|
||||
# =================================================================================================
|
||||
class _FakeTraderCap(DeribitTrader):
|
||||
"""DeribitTrader senza rete: `_post` risponde con un ordine fillato finto, cosi' la parte di
|
||||
_submit DOPO il guardrail (che qui non ci interessa) non tocca la rete."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._open_cap: dict[str, float] = {}
|
||||
|
||||
def _post(self, path, payload):
|
||||
amt = payload["amount"]
|
||||
return {"order": {"order_id": "1", "order_state": "filled", "filled_amount": amt},
|
||||
"trades": [{"price": 1.0, "amount": amt, "fee": 0.0}]}
|
||||
|
||||
|
||||
def test_set_open_cap_valida_gli_argomenti():
|
||||
t = _FakeTraderCap()
|
||||
with pytest.raises(ValueError):
|
||||
t.set_open_cap(ETH, 0.0, 2694.0)
|
||||
with pytest.raises(ValueError):
|
||||
t.set_open_cap(ETH, 2291.5, 0.0)
|
||||
|
||||
|
||||
def test_set_open_cap_apertura_entro_il_tetto_non_solleva():
|
||||
"""Equity 4583, cap/asset 2291.5, ETH mark 2694 -> apertura di 0,2807 ETH e' ben dentro il
|
||||
tetto (1,10 * 2291.5 / 2694 ~= 0,9355 ETH)."""
|
||||
t = _FakeTraderCap()
|
||||
t.set_open_cap(ETH, 2291.5, 2694.0)
|
||||
t._submit(ETH, "buy", 0.2807, reduce_only=False, label="book-open") # non deve solllevare
|
||||
|
||||
|
||||
def test_set_open_cap_3x_il_tetto_solleva_guardrail():
|
||||
t = _FakeTraderCap()
|
||||
t.set_open_cap(ETH, 2291.5, 2694.0)
|
||||
cap = t._open_cap[ETH]
|
||||
with pytest.raises(GuardrailError):
|
||||
t._submit(ETH, "buy", cap * 3, reduce_only=False, label="book-open")
|
||||
|
||||
|
||||
def test_senza_set_open_cap_resta_il_default_max_amount():
|
||||
t = _FakeTraderCap()
|
||||
dentro = MAX_AMOUNT[ETH] * 0.5
|
||||
t._submit(ETH, "buy", dentro, reduce_only=False, label="book-open") # non deve solllevare
|
||||
with pytest.raises(GuardrailError):
|
||||
t._submit(ETH, "buy", MAX_AMOUNT[ETH] * 3, reduce_only=False, label="book-open")
|
||||
|
||||
|
||||
# =================================================================================================
|
||||
# #24 — open_orders non ingoia gli errori; ensure_disaster_sl diventa "unknown", non a caso.
|
||||
# =================================================================================================
|
||||
class _FakeOpenOrders(DeribitRead):
|
||||
"""DeribitRead senza rete: 'all' risponde vuoto, 'trigger_all' solleva — replica un bracket
|
||||
disaster-SL perso su un solo dei due tipi di chiamata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass # niente token, niente rete
|
||||
|
||||
def _post(self, path, payload):
|
||||
if payload.get("type") == "trigger_all":
|
||||
raise RuntimeError("502 Server Error: Bad Gateway")
|
||||
return {"result": []}
|
||||
|
||||
|
||||
def test_open_orders_trigger_all_solleva_runtime_error():
|
||||
r = _FakeOpenOrders()
|
||||
with pytest.raises(RuntimeError, match="open_orders illeggibile"):
|
||||
r.open_orders(ETH)
|
||||
|
||||
|
||||
class _FakeTraderOpenOrdersRompe(DeribitTrader):
|
||||
"""position_usd legge una posizione REALE, ma open_orders solleva: ensure_disaster_sl non
|
||||
deve decidere su meta' dei dati."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._open_cap: dict[str, float] = {}
|
||||
self.submitted: list[dict] = []
|
||||
self.cancelled: list[str] = []
|
||||
|
||||
def positions(self, currency):
|
||||
return [{"instrument_name": ETH, "size": 560, "direction": "sell"}]
|
||||
|
||||
def mark_price(self, instrument):
|
||||
return 2694.0
|
||||
|
||||
def open_orders(self, instrument):
|
||||
raise RuntimeError("open_orders illeggibile: RuntimeError: 502 Server Error: Bad Gateway")
|
||||
|
||||
def cancel_order(self, order_id):
|
||||
self.cancelled.append(order_id)
|
||||
return {}
|
||||
|
||||
def _submit(self, instrument, side, amount, *, reduce_only, label,
|
||||
order_type="market", price=None):
|
||||
self.submitted.append(dict(side=side, reduce_only=reduce_only))
|
||||
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
|
||||
fee_usdc=0.0, order_id="1", state="filled", verified=True)
|
||||
|
||||
|
||||
def test_ensure_disaster_sl_con_open_orders_che_solleva_diventa_unknown_senza_agire():
|
||||
t = _FakeTraderOpenOrdersRompe()
|
||||
ds = t.ensure_disaster_sl(ETH, 0.30)
|
||||
assert ds["state"] == "unknown"
|
||||
assert "502" in ds["notes"]
|
||||
assert t.cancelled == [] # nessuna cancel
|
||||
assert t.submitted == [] # nessun piazzamento
|
||||
@@ -162,3 +162,150 @@ def test_un_fill_non_misurabile_e_None_non_zero():
|
||||
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None
|
||||
# fee nulla e' invece una misura legittima (ordine maker a rebate zero)
|
||||
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# issue #29 (B8) — save_state SOLO se l'invio (quando serviva) e' riuscito
|
||||
# ===========================================================================
|
||||
# Fino a qui `save_state` girava SEMPRE dopo l'eventuale notify, anche se l'invio necessario
|
||||
# falliva: il giro dopo `diff_vs` avrebbe confrontato lo stato nuovo con se stesso e il cambio
|
||||
# sarebbe sparito senza essere mai stato detto.
|
||||
|
||||
_RIS_AZIONE = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=12.0, maker_bps=0.0, liq_bps=100.0)},
|
||||
changes=["BTC_USDC-PERPETUAL taker: 5.00 -> 12.00 bps"], worst_taker_bps=12.0,
|
||||
level="AZIONE", reason="taker 12.0bps > 10: rivedere il peso di SKH01", first_read=False,
|
||||
)
|
||||
_RIS_OK_SENZA_CAMBI = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=3.0, maker_bps=0.0, liq_bps=75.0)},
|
||||
changes=[], worst_taker_bps=3.0, level="OK", reason="ok", first_read=False,
|
||||
)
|
||||
|
||||
|
||||
def _prepara(monkeypatch, risultato, esito_invio):
|
||||
salvato, inviato = [], []
|
||||
monkeypatch.setattr(FW, "run", lambda: risultato)
|
||||
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
|
||||
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
|
||||
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda *a, **k: {})
|
||||
return salvato, inviato
|
||||
|
||||
|
||||
def test_invio_fallito_non_salva_lo_stato(monkeypatch):
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=False)
|
||||
rc = FW.main()
|
||||
assert len(inviato) == 1, "un cambio con livello AZIONE deve tentare l'invio"
|
||||
assert salvato == [], "invio fallito -> non si salva, il giro dopo ritenta lo stesso cambio"
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_invio_riuscito_salva_lo_stato(monkeypatch):
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
# (#37 B9) lo stato salvato porta anche il marcatore "divergenza_detta" (qui False: la
|
||||
# fixture non ha divergenze), sibling delle fee per-strumento, non piu' solo `current`.
|
||||
assert len(salvato) == 1
|
||||
assert salvato[0] == {**_RIS_AZIONE["current"], "divergenza_detta": False}
|
||||
|
||||
|
||||
def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
|
||||
"""Senza cambi e a livello OK non c'e' niente da dire: si salva, e non si chiama notify."""
|
||||
salvato, inviato = _prepara(monkeypatch, _RIS_OK_SENZA_CAMBI, esito_invio=False)
|
||||
FW.main()
|
||||
assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
|
||||
assert len(salvato) == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# issue #37 (B9) — realized_fee_bps() SEMPRE calcolato (non solo nel blocco di stampa) e
|
||||
# divergenza dal tier pubblico notificata UNA volta per episodio.
|
||||
# ===========================================================================
|
||||
def test_run_calcola_sempre_la_fee_realizzata(monkeypatch):
|
||||
"""Prima del #37 la fee realizzata girava solo dentro `if not quiet or interessante`: in
|
||||
cron (--quiet) non girava mai. Ora e' `run()` a calcolarla, sempre."""
|
||||
monkeypatch.setattr(FW, "fetch_fees",
|
||||
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
|
||||
monkeypatch.setattr(FW, "load_state", lambda: {})
|
||||
ins = FW.INSTRUMENTS[0]
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 7.5})
|
||||
r = FW.run()
|
||||
assert r["realized"] == {ins: 7.5}
|
||||
assert len(r["divergenze"]) == 1 and ins in r["divergenze"][0]
|
||||
assert r["divergenza_gia_detta"] is False
|
||||
|
||||
|
||||
def test_run_nessun_trade_e_None_non_zero(monkeypatch):
|
||||
monkeypatch.setattr(FW, "fetch_fees",
|
||||
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
|
||||
monkeypatch.setattr(FW, "load_state", lambda: {})
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {})
|
||||
r = FW.run()
|
||||
assert r["realized"] is None
|
||||
assert r["divergenze"] == []
|
||||
|
||||
|
||||
def test_run_sotto_soglia_non_e_divergenza(monkeypatch):
|
||||
monkeypatch.setattr(FW, "fetch_fees",
|
||||
lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0))
|
||||
monkeypatch.setattr(FW, "load_state", lambda: {})
|
||||
ins = FW.INSTRUMENTS[0]
|
||||
monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 5.3}) # +0.3 < 1.0
|
||||
r = FW.run()
|
||||
assert r["divergenze"] == []
|
||||
|
||||
|
||||
_RIS_DIVERGE = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
|
||||
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
|
||||
realized={"BTC_USDC-PERPETUAL": 7.5},
|
||||
divergenze=["BTC_USDC-PERPETUAL: realizzata 7.50 bps vs tier pubblico 5.00 (+2.50)"],
|
||||
divergenza_gia_detta=False,
|
||||
)
|
||||
_RIS_DIVERGE_RIPETUTA = dict(_RIS_DIVERGE, divergenza_gia_detta=True)
|
||||
_RIS_DIVERGE_RIENTRATA = dict(
|
||||
current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)},
|
||||
changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False,
|
||||
realized={"BTC_USDC-PERPETUAL": 5.3}, divergenze=[], divergenza_gia_detta=True,
|
||||
)
|
||||
|
||||
|
||||
def _prepara_b9(monkeypatch, risultato, esito_invio):
|
||||
"""Come `_prepara`, ma senza `changes`/livello AZIONE: isola il notify di B9 da quello di
|
||||
B8 (qui non richiesto, dato che tutte le fixture hanno level='OK' e changes=[])."""
|
||||
salvato, inviato = [], []
|
||||
monkeypatch.setattr(FW, "run", lambda: risultato)
|
||||
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
|
||||
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
|
||||
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
|
||||
return salvato, inviato
|
||||
|
||||
|
||||
def test_divergenza_prima_volta_notifica_e_marca(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=True)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
assert salvato[-1]["divergenza_detta"] is True
|
||||
|
||||
|
||||
def test_divergenza_stesso_episodio_non_ripete(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIPETUTA, esito_invio=True)
|
||||
FW.main()
|
||||
assert inviato == [], "gia' detta in questo episodio: nessun secondo invio"
|
||||
assert salvato[-1]["divergenza_detta"] is True
|
||||
|
||||
|
||||
def test_divergenza_notify_fallita_non_marca(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=False)
|
||||
FW.main()
|
||||
assert len(inviato) == 1
|
||||
assert salvato[-1]["divergenza_detta"] is False, \
|
||||
"invio fallito -> marcatore non scritto, il giro dopo ritenta"
|
||||
|
||||
|
||||
def test_divergenza_rientrata_azzera_il_marcatore(monkeypatch):
|
||||
salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIENTRATA, esito_invio=False)
|
||||
FW.main()
|
||||
assert inviato == [], "sotto soglia: nessun invio da tentare"
|
||||
assert salvato[-1]["divergenza_detta"] is False
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""registra_giro (src/live/giri.py, #16): il registro che i sorveglianti scrivono a ogni giro.
|
||||
|
||||
Il contratto e' quello di cc01_regime_watch, generalizzato: appende `{"ts": <ms>, **campi}` come
|
||||
riga jsonl e non solleva MAI — un registro che fallisce non deve buttare via una misura gia' fatta.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.giri import registra_giro # noqa: E402
|
||||
|
||||
|
||||
def test_scrive_una_riga_con_ts_in_millisecondi(tmp_path):
|
||||
p = tmp_path / "sub" / "runs.jsonl"
|
||||
prima = time.time() * 1000
|
||||
assert registra_giro(p, stato="OK", n=3) is True
|
||||
riga = json.loads(p.read_text().strip())
|
||||
assert abs(riga["ts"] - prima) < 5_000
|
||||
assert isinstance(riga["ts"], int)
|
||||
assert riga["stato"] == "OK" and riga["n"] == 3
|
||||
|
||||
|
||||
def test_crea_la_cartella_se_manca(tmp_path):
|
||||
p = tmp_path / "non-esiste-ancora" / "runs.jsonl"
|
||||
assert not p.parent.exists()
|
||||
registra_giro(p, x=1)
|
||||
assert p.exists()
|
||||
|
||||
|
||||
def test_appende_senza_troncare(tmp_path):
|
||||
p = tmp_path / "runs.jsonl"
|
||||
registra_giro(p, giro=1)
|
||||
registra_giro(p, giro=2)
|
||||
righe = [json.loads(r) for r in p.read_text().splitlines()]
|
||||
assert [r["giro"] for r in righe] == [1, 2]
|
||||
|
||||
|
||||
def test_default_str_copre_i_valori_non_serializzabili(tmp_path):
|
||||
"""json.dumps(default=str): un valore che non e' nativamente JSON (es. un Path, o un
|
||||
dataclass senza __dict__ diretto) non deve far fallire l'append — diventa la sua str()."""
|
||||
p = tmp_path / "runs.jsonl"
|
||||
assert registra_giro(p, percorso=Path("/tmp/x")) is True
|
||||
riga = json.loads(p.read_text().strip())
|
||||
assert riga["percorso"] == str(Path("/tmp/x"))
|
||||
|
||||
|
||||
def test_oserror_ritorna_false_senza_sollevare(tmp_path, capsys):
|
||||
"""Un percorso impossibile (qui: dentro un FILE, non una cartella) e' un OSError alla
|
||||
mkdir/scrittura. Il chiamante non deve vedere un'eccezione: la misura e' gia' fatta."""
|
||||
ostacolo = tmp_path / "non-e-una-cartella"
|
||||
ostacolo.write_text("sono un file")
|
||||
p = ostacolo / "runs.jsonl"
|
||||
assert registra_giro(p, stato="OK") is False
|
||||
err = capsys.readouterr().err
|
||||
assert "registra_giro" in err and str(p) in err
|
||||
|
||||
|
||||
def test_un_valore_non_serializzabile_diventa_stringa_non_solleva(tmp_path):
|
||||
p = tmp_path / "runs.jsonl"
|
||||
oggetto = object()
|
||||
assert registra_giro(p, oggetto=oggetto) is True
|
||||
riga = json.loads(p.read_text().strip())
|
||||
assert riga["oggetto"] == str(oggetto)
|
||||
@@ -31,12 +31,16 @@ def db(tmp_path):
|
||||
|
||||
def test_giorno_in_corso_non_e_un_giorno_bucato(db, monkeypatch):
|
||||
"""24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore."""
|
||||
oggi = datetime.now(timezone.utc).date()
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1,
|
||||
feed_skh_min=0))
|
||||
class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
oggi = _OraFissa.now(timezone.utc).date()
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
|
||||
s = J.salute(db, oggi)
|
||||
assert s["giorno_in_corso"] is True
|
||||
assert s["atteso_giri"] == datetime.now(timezone.utc).hour + 1
|
||||
assert s["atteso_giri"] == 15 # ore 14:50, il giro delle 14 (:47) e' scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Test nuovi per l'unita' U4 (issue #27, #35 C9/C15/C7) — journal.py.
|
||||
|
||||
Non ripete la suite esistente: la tocca solo dove il comportamento CAMBIA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import journal as J # noqa: E402
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
|
||||
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
|
||||
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
|
||||
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
|
||||
giorno_in_corso=False, giri_mancanti=0))
|
||||
|
||||
|
||||
def _libro(**asset):
|
||||
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
|
||||
azione="HOLD (a target)"), **v) for k, v in asset.items()}
|
||||
lordo = sum(abs(v["posizione"]) for v in a.values())
|
||||
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
|
||||
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
|
||||
|
||||
|
||||
def _pnl(**kw):
|
||||
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
|
||||
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
|
||||
cumulato_da_arming=0.0, letture_equity=24)
|
||||
return {**base, **kw}
|
||||
|
||||
|
||||
def _ids(righe):
|
||||
return [r["id"] for r in righe]
|
||||
|
||||
|
||||
def _serie_equity(con, punti):
|
||||
for ts, eq in punti:
|
||||
con.execute("INSERT INTO equity (ts_utc, equity, src) VALUES (?,?,?)", (ts, eq, "test"))
|
||||
con.commit()
|
||||
|
||||
|
||||
def _dichiara(monkeypatch, tmp_path, righe):
|
||||
f = tmp_path / "mov.jsonl"
|
||||
f.write_text("\n".join(json.dumps(r) for r in righe) + "\n")
|
||||
monkeypatch.setattr(J, "MOVIMENTI_DICHIARATI", f)
|
||||
return f
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #27 — due dichiarati nello stesso intervallo si SOMMANO, non si sovrascrivono
|
||||
# ============================================================================================
|
||||
|
||||
def test_due_dichiarati_nello_stesso_intervallo_si_sommano(db, monkeypatch, tmp_path):
|
||||
"""+25 e +30 dichiarati nello stesso intervallo, trading reale +5: prima del fix il
|
||||
secondo dichiarato sovrascriveva il primo (delta finale 30, non 55)."""
|
||||
_serie_equity(db, [("2026-08-25T07:07:01+00:00", 1000.0),
|
||||
("2026-08-25T08:07:01+00:00", 1060.0)]) # +60 = 25+30 mov. +5 trading
|
||||
_dichiara(monkeypatch, tmp_path, [
|
||||
dict(ts_utc="2026-08-25T07:20:00+00:00", delta_usd=25.0, nota="primo versamento"),
|
||||
dict(ts_utc="2026-08-25T07:40:00+00:00", delta_usd=30.0, nota="secondo versamento")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1 # UN evento, non due
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato" and e["delta"] == pytest.approx(55.0) # 25+30, non 30
|
||||
assert e["dopo"] == pytest.approx(1055.0)
|
||||
assert "primo versamento" in e["dichiarazione"] and "secondo versamento" in e["dichiarazione"]
|
||||
assert r["certi"] == pytest.approx(55.0)
|
||||
assert r["trading"] == pytest.approx(5.0) # 1060-1000-55
|
||||
# solo due letture: l'intero intervallo e' il movimento, nessun segmento di trading — 0%
|
||||
# e' il limite DICHIARATO di rendimento_twr ("nessun segmento = nessun tempo a mercato"),
|
||||
# non un valore inventato.
|
||||
assert r["twr"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_dichiarato_su_rilevato_resta_come_prima_anche_con_un_altro_dichiarato_altrove(db, monkeypatch, tmp_path):
|
||||
"""Non regressione: l'aggancio dichiarato+rilevato non deve rompersi perche' ora si guarda
|
||||
anche ad altri dichiarati."""
|
||||
monkeypatch.setattr(J, "_px_1h", lambda a: __import__("pandas").Series(
|
||||
100.0, index=__import__("pandas").date_range("2026-08-20", "2026-08-27", freq="1h", tz="UTC")))
|
||||
_serie_equity(db, [("2026-08-25T10:47:01+00:00", 667.49),
|
||||
("2026-08-25T11:47:01+00:00", 2066.88),
|
||||
("2026-08-25T12:47:01+00:00", 2064.56)])
|
||||
_dichiara(monkeypatch, tmp_path, [dict(ts_utc="2026-08-25T11:20:00+00:00", delta_usd=1399.39,
|
||||
nota="bonifico")])
|
||||
r = J.rendimento_twr(db, "2026-08-25T23:59:59+00:00")
|
||||
assert len(r["eventi"]) == 1
|
||||
e = r["eventi"][0]
|
||||
assert e["fonte"] == "dichiarato+rilevato" and e["delta"] == pytest.approx(1399.39)
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C9 — il giro dell'ora corrente conta solo dopo che e' scattato (minuto :47 + margine)
|
||||
# ============================================================================================
|
||||
|
||||
def test_atteso_giri_al_minuto_10_non_conta_ancora_il_giro_dell_ora_corrente(db, monkeypatch):
|
||||
class _OraFissa(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 10, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=14, feed_skh_min=0))
|
||||
s = J.salute(db, date(2026, 8, 25))
|
||||
assert s["atteso_giri"] == 14 # il giro delle 14 (:47) non e' ancora scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
def test_atteso_giri_al_minuto_50_conta_il_giro_dell_ora_corrente(db, monkeypatch):
|
||||
class _OraFissa(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return datetime(2026, 8, 25, 14, 50, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(J, "datetime", _OraFissa)
|
||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=15, feed_skh_min=0))
|
||||
s = J.salute(db, date(2026, 8, 25))
|
||||
assert s["atteso_giri"] == 15 # dopo le :47 il giro delle 14 e' scattato
|
||||
assert s["giri_mancanti"] == 0
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C15 — "ultimi 7 giorni" deve coprire 7 giorni di calendario, non 8
|
||||
# ============================================================================================
|
||||
|
||||
def test_la_finestra_di_concentrazione_e_di_7_giorni_non_8(db):
|
||||
base = date(2026, 6, 24) # base .. base+13 = 14 giorni prima del giorno
|
||||
righe = []
|
||||
for i in range(7): # giorni 0..6: piatto
|
||||
d = (base + timedelta(days=i)).isoformat()
|
||||
righe += [(f"{d}T06:00:00+00:00", 1000.0), (f"{d}T18:00:00+00:00", 1000.0)]
|
||||
d7 = (base + timedelta(days=7)).isoformat() # giorno-7: il salto avviene QUI, a meta' giornata
|
||||
righe += [(f"{d7}T06:00:00+00:00", 1000.0), (f"{d7}T18:00:00+00:00", 1020.0)]
|
||||
for i in range(8, 14): # giorni 8..13: piatto al nuovo livello
|
||||
d = (base + timedelta(days=i)).isoformat()
|
||||
righe += [(f"{d}T06:00:00+00:00", 1020.0), (f"{d}T18:00:00+00:00", 1020.0)]
|
||||
d14 = (base + timedelta(days=14)).isoformat() # il giorno della voce
|
||||
righe += [(f"{d14}T06:00:00+00:00", 1020.0), (f"{d14}T18:00:00+00:00", 1100.0)]
|
||||
_serie_equity(db, righe)
|
||||
assert len(righe) == 30 # >24: la sezione si attiva
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[]), giorno=d14)
|
||||
out = J.lettura(db, v)
|
||||
riga = next(r for r in out if r["id"] == "concentrazione")
|
||||
# baseline corretta = ULTIMA lettura di giorno-7 (1020, alle 18:00), non la PRIMA (1000,
|
||||
# alle 06:00): (1100-1020)/(1100-1000) = 80%. Con la finestra sbagliata (8 giorni di
|
||||
# calendario) sarebbe (1100-1000)/100 = 100%.
|
||||
assert "80%" in riga["testo"]
|
||||
assert "100%" not in riga["testo"]
|
||||
|
||||
|
||||
# ============================================================================================
|
||||
# #35 C7 — il drawdown si legge sulla serie AL NETTO dei movimenti certi
|
||||
# ============================================================================================
|
||||
|
||||
def test_un_prelievo_non_diventa_un_drawdown_di_trading(db):
|
||||
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
punti = [base + timedelta(hours=i) for i in range(26)] # 26 letture, >24
|
||||
for t in punti[:-1]:
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), 1000.0, "t"))
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (punti[-1].isoformat(), 800.0, "t"))
|
||||
db.commit()
|
||||
mov = dict(classe="movimento", delta=-200.0, ts_prima=punti[-2].isoformat(),
|
||||
ts_dopo=punti[-1].isoformat(), prima=1000.0, dopo=800.0, pct=-0.2,
|
||||
fonte="dichiarato", dichiarazione="prelievo di prova")
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
|
||||
giorno=punti[-1].date().isoformat())
|
||||
out = J.lettura(db, v)
|
||||
# sulla serie grezza (picco 1000, ultima 800) sarebbe -20%: un prelievo, non una perdita.
|
||||
assert "drawdown" not in _ids(out)
|
||||
|
||||
|
||||
def test_un_versamento_non_nasconde_un_drawdown_di_trading_vero(db):
|
||||
base = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
letture = [(base + timedelta(hours=i), 1000.0) for i in range(20)] # piatto, 20 letture
|
||||
letture.append((base + timedelta(hours=20), 1500.0)) # versamento +500
|
||||
for h, v in ((21, 1490.0), (22, 1480.0), (23, 1475.0), (24, 1470.0)): # trading in calo
|
||||
letture.append((base + timedelta(hours=h), v))
|
||||
for t, val in letture:
|
||||
db.execute("INSERT INTO equity VALUES (?,?,?)", (t.isoformat(), val, "t"))
|
||||
db.commit()
|
||||
assert len(letture) == 25 # >24
|
||||
mov = dict(classe="movimento", delta=500.0, ts_prima=letture[19][0].isoformat(),
|
||||
ts_dopo=letture[20][0].isoformat(), prima=1000.0, dopo=1500.0, pct=0.5,
|
||||
fonte="dichiarato", dichiarazione="versamento di prova")
|
||||
v = _voce(libro=_libro(BTC=dict(posizione=100.0)), pnl=_pnl(movimenti_arming=[mov]),
|
||||
giorno=letture[-1][0].date().isoformat())
|
||||
out = J.lettura(db, v)
|
||||
riga = next(r for r in out if r["id"] == "drawdown")
|
||||
# netto: picco 1000 (prima/al versamento), ultima 970 (1470-500) -> -3.00%. Sulla serie
|
||||
# grezza (picco 1500 gonfiato dal versamento) sarebbe -2.00%: sottostima il vero calo.
|
||||
assert "-3.00%" in riga["testo"]
|
||||
assert "-2.00%" not in riga["testo"]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""book.leva_tetto — la leva lorda massima del libro, in UNA sola formula (#28).
|
||||
|
||||
Prima della correzione la leva lorda tornava con tre formule diverse: `usde.frazione_cuscino`
|
||||
(N_ASSET fisso, senza scala), la regola `leva` del giornale (senza scala) e
|
||||
`journal.movimenti_capitale` (con scala, ma ridichiarata). A scala 1,25 il cuscino USDC
|
||||
risultava 0,30 dell'equity invece di 0,375 — il caso che T2 riproduce.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import book as B # noqa: E402
|
||||
from src.live import journal as J # noqa: E402
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
from src.live import usde as U # noqa: E402
|
||||
|
||||
|
||||
def _cfg(**kw) -> dict:
|
||||
base = dict(max_notional_per_asset_frac=0.5, disaster_sl_pct=0.3)
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- T1: scala assente
|
||||
|
||||
def test_T1_scala_assente_e_frac_uguale_al_weight():
|
||||
leva, come = B.leva_tetto(_cfg())
|
||||
assert leva == pytest.approx(len(B.ASSETS) * B.WEIGHT)
|
||||
assert "scala 1.0" in come
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- T2: il caso dell'issue
|
||||
|
||||
def test_T2_scala_1_25_alza_la_leva_e_il_cuscino_usde(tmp_path, monkeypatch):
|
||||
"""A scala 1,25 il cuscino USDC deve valere 0,375 dell'equity, non 0,30 (#28)."""
|
||||
cfg = _cfg(book_scale_k=1.25)
|
||||
leva, _ = B.leva_tetto(cfg)
|
||||
assert leva == pytest.approx(1.25)
|
||||
|
||||
# frazione_cuscino legge da CFG_PATH: si monkeypatcha il file, come fanno gli altri test
|
||||
# su usde/_scala (grep "CFG_PATH\|CONFIG" tests/).
|
||||
cpath = tmp_path / "live.json"
|
||||
cpath.write_text(json.dumps(cfg))
|
||||
monkeypatch.setattr(U, "CFG_PATH", cpath)
|
||||
f, come = U.frazione_cuscino()
|
||||
assert f == pytest.approx(0.375)
|
||||
assert "SL 30%" in come
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- T3: fuori scaletta non solleva
|
||||
|
||||
def test_T3_scala_fuori_scaletta_non_solleva_e_torna_il_tetto_di_codice():
|
||||
"""cuscino_watch non deve morire proprio quando la config e' sbagliata (verifica fable B5)."""
|
||||
leva, come = B.leva_tetto(_cfg(book_scale_k=1.1))
|
||||
assert leva == pytest.approx(B.LEVA_LORDA_MAX)
|
||||
assert "tetto di codice" in come
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- T4: frac assente
|
||||
|
||||
def test_T4_frac_assente_usa_weight():
|
||||
cfg = dict(disaster_sl_pct=0.3) # niente max_notional_per_asset_frac
|
||||
leva, come = B.leva_tetto(cfg)
|
||||
assert leva == pytest.approx(len(B.ASSETS) * B.WEIGHT)
|
||||
assert f"frac {B.WEIGHT}" in come
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- T5: il tronco al tetto di codice
|
||||
|
||||
def test_T5_leva_oltre_il_tetto_di_codice_viene_troncata(monkeypatch):
|
||||
"""Verifica diretta del troncamento. Con WEIGHT alzato il prodotto supera LEVA_LORDA_MAX pur
|
||||
restando `_scala` silenziosa (frac ASSENTE dalla config -> il suo controllo sul prodotto non
|
||||
scatta, perche' guarda solo `max_notional_per_asset_frac` quando e' dichiarato)."""
|
||||
monkeypatch.setattr(B, "WEIGHT", 0.9)
|
||||
leva, come = B.leva_tetto(dict(book_scale_k=1.25)) # frac assente -> usa WEIGHT = 0.9
|
||||
assert leva == pytest.approx(B.LEVA_LORDA_MAX)
|
||||
assert "troncato" in come
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- T6: il giornale usa la stessa leva_tetto
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
def _voce(mercato=None, libro=None, pnl=None, salute=None, giorno="2026-08-21"):
|
||||
return dict(giorno=giorno, ts_scritto=T.ora(), mercato=mercato or {},
|
||||
libro=libro or {"errore": "nessun giro", "giri": 0}, pnl=pnl or {},
|
||||
salute=salute or dict(giri_book=24, feed_skh_min=0, atteso_giri=24,
|
||||
giorno_in_corso=False, giri_mancanti=0))
|
||||
|
||||
|
||||
def _libro(**asset):
|
||||
a = {k: dict(dict(tp_frac=0.0, skh_sign=0, skh_entry=None, target=0.0, posizione=0.0,
|
||||
azione="HOLD (a target)"), **v) for k, v in asset.items()}
|
||||
lordo = sum(abs(v["posizione"]) for v in a.values())
|
||||
return dict(giri=24, ultimo_giro="2026-08-21T23:07:01+00:00", equity=600.0, barra="2026-08-21",
|
||||
feed_skh_min=0, nozionale_lordo=lordo, leva_lorda=lordo / 600.0, asset=a)
|
||||
|
||||
|
||||
def _pnl(**kw):
|
||||
base = dict(equity_inizio=600.0, equity_fine=600.0, delta_equity=0.0, realizzato_lordo=0.0,
|
||||
realizzato_netto=0.0, roundtrip_chiusi=0, fill=0, fee=0.0,
|
||||
cumulato_da_arming=0.0, letture_equity=24)
|
||||
return {**base, **kw}
|
||||
|
||||
|
||||
def test_T6_regola_leva_margine_positivo_a_scala_1_25(db, monkeypatch, tmp_path):
|
||||
"""lv=1.2 su un tetto di 1.25x (frac 0.5, scala 1.25): margine positivo, non negativo."""
|
||||
(tmp_path / "config").mkdir()
|
||||
(tmp_path / "config" / "live.json").write_text(
|
||||
json.dumps(dict(max_notional_per_asset_frac=0.5, book_scale_k=1.25)))
|
||||
monkeypatch.setattr(J, "PROJECT_ROOT", tmp_path)
|
||||
lb = _libro(BTC=dict(posizione=360.0), ETH=dict(posizione=360.0)) # 720/600 = 1.2x
|
||||
assert lb["leva_lorda"] == pytest.approx(1.2)
|
||||
r = J.lettura(db, _voce(libro=lb, pnl=_pnl()))
|
||||
riga = next(x for x in r if x["id"] == "leva")
|
||||
assert "1.25x" in riga["testo"] and "margine 0.05x" in riga["testo"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- A7 (#35): min_order_usd da config
|
||||
|
||||
def test_A7_build_book_order_rispetta_min_usd_di_config():
|
||||
"""book_report legge min_order_usd da config (default 5.0 se assente, come book_execute.py).
|
||||
Qui si verifica direttamente build_book_order, che e' il punto dove il valore viene usato:
|
||||
con min_usd=20 un delta di $10 non genera ordine, con min_usd=5 (il default di oggi) si."""
|
||||
order_stretto = B.build_book_order("BTC_USDC-PERPETUAL", 100.0, 90.0, 50000.0, min_usd=20.0)
|
||||
assert order_stretto is None # |delta|=10 < 20: sotto soglia col tetto alzato
|
||||
order_default = B.build_book_order("BTC_USDC-PERPETUAL", 100.0, 90.0, 50000.0, min_usd=5.0)
|
||||
assert order_default is not None # |delta|=10 >= 5: lo stesso caso, oggi, genera ordine
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Test del giro ORARIO di monitor_health (#30).
|
||||
|
||||
I sorveglianti a max_age<=6h (cuscino_watch, venue_watch, collect_chain) girano alle 00:30 col
|
||||
report giornaliero e basta: un guasto si scopre fino a 24h dopo. Qui si guarda ogni ora, ma si
|
||||
notifica solo cio' che e' NUOVO rispetto al marcatore "gia' detto" — altrimenti un monitor fermo
|
||||
manderebbe lo stesso Telegram ogni ora per giorni.
|
||||
|
||||
Le funzioni PURE (filtro per max_age, chiave dell'alert, chi e' nuovo, il marcatore aggiornato)
|
||||
stanno in src/live/monitor_health.py e si testano su righe finte, senza toccare disco o rete.
|
||||
L'orchestrazione (leggere/scrivere il marcatore, chiamare notify) sta nello script e si testa qui
|
||||
sotto con monkeypatch — NIENTE Telegram vero."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
from src.live.monitor_health import ( # noqa: E402
|
||||
MonitorSpec,
|
||||
alert_nuovi,
|
||||
chiave_alert,
|
||||
marcatore_aggiornato,
|
||||
monitors_orari,
|
||||
)
|
||||
|
||||
import monitor_health as MH # noqa: E402 (scripts/live/monitor_health.py)
|
||||
|
||||
# righe finte, nella forma che assess()/check_all() restituiscono davvero
|
||||
ROW_FERMO = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=3, age_h=4.0,
|
||||
coverage=0.9, expected=3, status="FERMO", why="ultima barra 4h fa (limite 3h)")
|
||||
ROW_OK = dict(name="venue_watch", gate="tripwire di venue orario", n_bars=10, age_h=0.2,
|
||||
coverage=1.0, expected=10, status="OK", why="")
|
||||
ROW_NUOVO = dict(name="venue_watch", gate=None, n_bars=1, age_h=0.1, coverage=None,
|
||||
expected=None, status="NUOVO", why="1 barra: non c'e' abbastanza serie")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# funzioni PURE — filtro per max_age (src/live/monitor_health.py)
|
||||
# ===========================================================================
|
||||
def test_il_filtro_deriva_dalla_tabella_non_da_una_lista_di_nomi():
|
||||
specs = (
|
||||
MonitorSpec("a", "a", "runs.jsonl", 1.0, max_age_h=3.0),
|
||||
MonitorSpec("b", "b", "runs.jsonl", 1.0, max_age_h=6.0), # al bordo: dentro
|
||||
MonitorSpec("c", "c", "runs.jsonl", 24.0, max_age_h=30.0),
|
||||
)
|
||||
assert {m.name for m in monitors_orari(specs)} == {"a", "b"}
|
||||
|
||||
|
||||
def test_i_tre_orari_di_oggi_sono_quelli_noti():
|
||||
"""Se la tabella cambia questo test lo dice: e' l'ancora fra il registro e il flag."""
|
||||
assert {m.name for m in monitors_orari()} == {"collect_chain", "cuscino_watch", "venue_watch"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# funzioni PURE — chiave, nuovi, marcatore
|
||||
# ===========================================================================
|
||||
def test_chiave_e_nome_piu_stato_non_il_testo():
|
||||
"""Il testo (`why`) contiene l'eta', che cambia ogni ora anche se il guasto e' lo stesso."""
|
||||
assert chiave_alert(ROW_FERMO) == "venue_watch:FERMO"
|
||||
piu_vecchio = {**ROW_FERMO, "age_h": 9.0, "why": "ultima barra 9h fa (limite 3h)"}
|
||||
assert chiave_alert(piu_vecchio) == chiave_alert(ROW_FERMO)
|
||||
|
||||
|
||||
def test_alert_nuovo_se_la_chiave_non_e_nel_marcatore():
|
||||
assert alert_nuovi([ROW_FERMO], set()) == [ROW_FERMO]
|
||||
|
||||
|
||||
def test_lo_stesso_alert_al_secondo_giro_non_e_piu_nuovo():
|
||||
assert alert_nuovi([ROW_FERMO], {"venue_watch:FERMO"}) == []
|
||||
|
||||
|
||||
def test_nuovo_non_e_un_alert_da_notificare():
|
||||
assert alert_nuovi([ROW_NUOVO], set()) == []
|
||||
|
||||
|
||||
def test_il_marcatore_aggiunge_la_chiave_di_un_alert():
|
||||
assert marcatore_aggiornato([ROW_FERMO], set()) == {"venue_watch:FERMO"}
|
||||
|
||||
|
||||
def test_il_ritorno_a_ok_toglie_la_chiave_dal_marcatore():
|
||||
"""Cosi' un nuovo guasto sullo stesso monitor riparte con un nuovo avviso."""
|
||||
assert marcatore_aggiornato([ROW_OK], {"venue_watch:FERMO"}) == set()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# orchestrazione — scripts/live/monitor_health.py main_orari(), NIENTE Telegram
|
||||
# ===========================================================================
|
||||
def test_notify_fallito_non_scrive_il_marcatore(tmp_path, monkeypatch):
|
||||
"""Stessa regola di fee_watch #29: niente 'gia' detto' su un invio fallito, altrimenti il
|
||||
prossimo giro non ritenterebbe."""
|
||||
detto = tmp_path / "detto.json"
|
||||
monkeypatch.setattr(MH, "DETTO", detto)
|
||||
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO])
|
||||
chiamate = []
|
||||
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append((a, k)), False)[1])
|
||||
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
|
||||
|
||||
rc = MH.main_orari()
|
||||
|
||||
assert rc == 2 # c'e' un alert
|
||||
assert len(chiamate) == 1 # e si e' provato a notificarlo
|
||||
assert not detto.exists() # ma l'invio e' fallito: nessun marcatore
|
||||
|
||||
|
||||
def test_notify_riuscito_scrive_il_marcatore_e_non_rinotifica(tmp_path, monkeypatch):
|
||||
detto = tmp_path / "detto.json"
|
||||
monkeypatch.setattr(MH, "DETTO", detto)
|
||||
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_FERMO])
|
||||
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
|
||||
|
||||
monkeypatch.setattr(MH, "notify", lambda *a, **k: True)
|
||||
rc1 = MH.main_orari()
|
||||
assert rc1 == 2
|
||||
assert json.loads(detto.read_text())["detti"] == ["venue_watch:FERMO"]
|
||||
|
||||
chiamate = []
|
||||
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1])
|
||||
rc2 = MH.main_orari()
|
||||
assert rc2 == 2 # l'alert c'e' ancora (anche se gia' detto: exit 2 comunque)
|
||||
assert chiamate == [] # ma non si rimanda lo stesso Telegram
|
||||
|
||||
|
||||
def test_tornato_ok_il_giro_dopo_pulisce_il_marcatore(tmp_path, monkeypatch):
|
||||
detto = tmp_path / "detto.json"
|
||||
detto.write_text(json.dumps({"detti": ["venue_watch:FERMO"], "ts": "2026-09-27T00:00:00Z"}))
|
||||
monkeypatch.setattr(MH, "DETTO", detto)
|
||||
monkeypatch.setattr(MH, "check_all", lambda root, monitors=None: [ROW_OK])
|
||||
monkeypatch.setattr(sys, "argv", ["monitor_health.py", "--quiet", "--solo-orari"])
|
||||
chiamate = []
|
||||
monkeypatch.setattr(MH, "notify", lambda *a, **k: (chiamate.append(1), True)[1])
|
||||
|
||||
rc = MH.main_orari()
|
||||
|
||||
assert rc == 0
|
||||
assert chiamate == [] # OK non notifica mai
|
||||
assert json.loads(detto.read_text())["detti"] == []
|
||||
@@ -0,0 +1,126 @@
|
||||
"""GATE PREVDAY-01 cablato nel monitor (issue #4): kill, veto d'integrita', data.
|
||||
|
||||
Prima del 10/09 il gate esisteva solo nel testo (RESULTS-0822 §54) e la revisione settimanale lo
|
||||
ha letto come «senza data». Qui si blinda che: (a) le costanti sono quelle scritte il 23/08;
|
||||
(b) il kill non e' leggibile sotto 180 giorni attivi e scatta solo sotto -0,50; (c) il veto
|
||||
blocca sotto l'80% di barre ricostruibili e su una crescita MATERIALE delle divergenze — non su
|
||||
una barra sola (P14); (d) la ricostruzione riproduce una serie generata con la stessa aritmetica
|
||||
di `advance`; (e) la lente del kill e' GIORNALIERA."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
sp = importlib.util.spec_from_file_location("paper_prevday", ROOT / "scripts" / "live" / "paper_prevday.py")
|
||||
PP = importlib.util.module_from_spec(sp)
|
||||
sp.loader.exec_module(PP)
|
||||
|
||||
|
||||
def test_le_costanti_sono_quelle_del_gate_scritto_il_23_08():
|
||||
assert PP.GATE_DATE == "2027-06-21"
|
||||
assert PP.KILL_SHARPE == -0.50 and PP.KILL_MIN_ACTIVE_DAYS == 180
|
||||
assert PP.MIN_RECON_FRAC == 0.80
|
||||
# e CLAUDE.md §4 cita la stessa data (era assente fino al 10/09)
|
||||
assert "2027-06-21" in (ROOT / "CLAUDE.md").read_text()
|
||||
|
||||
|
||||
def _serie(n_giorni: int, mu_h: float, sd_h: float, seed: int = 0, pos: float = 0.3) -> pd.DataFrame:
|
||||
rng = np.random.default_rng(seed)
|
||||
n = n_giorni * 24
|
||||
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
|
||||
x = rng.normal(mu_h, sd_h, n)
|
||||
return pd.DataFrame(dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True), net_modeled=x,
|
||||
net_real=x, pos_btc=pos, pos_eth=pos))
|
||||
|
||||
|
||||
def test_kill_non_leggibile_sotto_180_giorni_attivi():
|
||||
r = _serie(81, -0.001, 0.004)
|
||||
sh, n = PP.sharpe_giornaliero(r)
|
||||
assert n == 81 and sh < PP.KILL_SHARPE # Sharpe pessimo...
|
||||
stato, motivo = PP.kill_check(sh, PP.giorni_attivi(r), pd.Timestamp("2026-09-10", tz="UTC"))
|
||||
assert stato == "NON_MATURO" and "99" in motivo # ...ma il kill non e' leggibile
|
||||
|
||||
|
||||
def test_kill_scatta_sopra_180_giorni_solo_sotto_la_soglia():
|
||||
r_male = _serie(200, -0.001, 0.004)
|
||||
sh, _ = PP.sharpe_giornaliero(r_male)
|
||||
assert PP.kill_check(sh, PP.giorni_attivi(r_male), pd.Timestamp("2027-01-01", tz="UTC"))[0] == "KILL"
|
||||
oggi = pd.Timestamp("2027-01-01", tz="UTC")
|
||||
assert PP.kill_check(-0.49, 200, oggi)[0] == "SUPERATO" # la soglia e' STRETTA: -0,49 passa
|
||||
assert PP.kill_check(-0.51, 200, oggi)[0] == "KILL"
|
||||
assert PP.kill_check(float("nan"), 200, oggi)[0] == "NON_MISURABILE"
|
||||
|
||||
|
||||
def test_i_giorni_flat_non_contano_come_attivi():
|
||||
r = _serie(200, 0.0, 0.004)
|
||||
r.loc[r.index[: 50 * 24], ["pos_btc", "pos_eth"]] = 0.0
|
||||
assert PP.giorni_attivi(r) == 150
|
||||
|
||||
|
||||
def test_la_lente_del_kill_e_giornaliera_non_oraria():
|
||||
"""§54: sulla lente oraria lo stesso forward vale +2,04 contro +1,56. Con autocorrelazione
|
||||
positiva intragiornaliera la lente oraria SOVRASTIMA: il kill usa la giornaliera."""
|
||||
rng = np.random.default_rng(3)
|
||||
n = 120 * 24
|
||||
base = np.repeat(rng.normal(0.0004, 0.004, 120), 24) # shock comune al giorno
|
||||
x = base + rng.normal(0, 0.0005, n)
|
||||
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
|
||||
r = pd.DataFrame(dict(ts=ts, dt=pd.to_datetime(ts, unit="ms", utc=True), net_modeled=x,
|
||||
net_real=x, pos_btc=0.3, pos_eth=0.3))
|
||||
sh_g, _ = PP.sharpe_giornaliero(r)
|
||||
assert PP._sharpe_orario(r) > sh_g * 2 # la lente oraria gonfia; il kill non la usa
|
||||
|
||||
|
||||
def test_veto_sotto_80_pct_ricostruibili():
|
||||
ok, m = PP.veto_check(0.79, 0.5, 0.5, 10)
|
||||
assert not ok and "79.0%" in m
|
||||
assert PP.veto_check(0.80, 0.5, 0.5, 10)[0]
|
||||
|
||||
|
||||
def test_veto_su_crescita_materiale_non_su_una_barra(monkeypatch):
|
||||
"""Il 10/09 la serie reale aveva UNA barra divergente su 1943 (26/08 00:00, il giorno del fix
|
||||
di advance()) e la prima stesura del veto la leggeva come 'crescita' (0,03/g contro 0,01/g)."""
|
||||
assert PP.veto_check(0.999, 0.012, 0.032, 1)[0] # 1 barra: non e' crescita
|
||||
ok, m = PP.veto_check(0.95, 0.10, 0.50, 12) # 12 recenti, tasso 5x: crescita
|
||||
assert not ok and "crescita" in m
|
||||
assert PP.veto_check(0.95, 0.30, 0.50, 12)[0] # tasso < 2x: no
|
||||
|
||||
|
||||
def test_ricostruzione_riproduce_una_serie_generata_come_advance(monkeypatch):
|
||||
"""Feed sintetico, target congelato deterministico (monkeypatch su prevday_target): la serie
|
||||
registrata con l'aritmetica di advance si ricostruisce al 100%; una barra manomessa no."""
|
||||
n = 24 * 10
|
||||
ts = 1_782_000_000_000 + np.arange(n) * 3_600_000
|
||||
rng = np.random.default_rng(7)
|
||||
dfs = {}
|
||||
for a, p0 in (("BTC", 60000.0), ("ETH", 2000.0)):
|
||||
close = p0 * np.cumprod(1 + rng.normal(0, 0.003, n))
|
||||
dfs[a] = pd.DataFrame(dict(timestamp=ts, datetime=pd.to_datetime(ts, unit="ms", utc=True), close=close))
|
||||
tgt = {a: np.where((np.arange(n) // 24) % 2 == 0, 0.3, -0.3) for a in dfs}
|
||||
monkeypatch.setattr(PP, "prevday_target",
|
||||
lambda df: tgt["BTC"] if float(df["close"].iloc[0]) > 10000 else tgt["ETH"])
|
||||
start = int(ts[23])
|
||||
# registrazione con la stessa aritmetica di advance (ribilanciamento continuo)
|
||||
pos = {a: float(tgt[a][23]) for a in dfs}
|
||||
rows = []
|
||||
for i in range(24, n):
|
||||
net = 0.0
|
||||
for a in dfs:
|
||||
c = dfs[a]["close"].values
|
||||
rr = c[i] / c[i - 1] - 1
|
||||
net += PP.WEIGHT * (pos[a] * rr - PP.FEE_SIDE * abs(tgt[a][i] - pos[a]))
|
||||
pos[a] = float(tgt[a][i])
|
||||
rows.append(dict(ts=int(ts[i]), net_modeled=round(net, 6), pos_btc=pos["BTC"], pos_eth=pos["ETH"]))
|
||||
r = pd.DataFrame(rows); r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
|
||||
rc = PP.ricostruzione(r, dfs, start)
|
||||
assert rc["ricostruibili"] == rc["n"] == n - 24 and rc["assenti_nel_feed"] == 0
|
||||
r2 = r.copy(); r2.loc[r2.index[-1], "net_modeled"] += 0.01
|
||||
rc2 = PP.ricostruzione(r2, dfs, start)
|
||||
assert rc2["ricostruibili"] == rc2["n"] - 1 and rc2["div_recenti"] == 1
|
||||
+64
-1
@@ -68,14 +68,65 @@ def test_i_sorveglianti_vengono_prima_delle_voci_grandi(tmp_path):
|
||||
|
||||
|
||||
def test_il_troncamento_e_dichiarato(tmp_path, monkeypatch):
|
||||
"""CLAUDE.md e' la prima voce e ha un tetto SUO (MAX_CHARS_CLAUDE_MD, #37 C12): si monkeypatcha
|
||||
quello, non il MAX_CHARS_VOCE generico, che non lo tocca piu'."""
|
||||
r = _radice(tmp_path)
|
||||
(r / "CLAUDE.md").write_text("x" * 1000)
|
||||
monkeypatch.setattr(R, "MAX_CHARS_VOCE", 100)
|
||||
monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 100)
|
||||
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||
assert tr and "[… troncato a 100 caratteri su 1,000]" in t
|
||||
assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
|
||||
|
||||
|
||||
def test_claude_md_ha_un_tetto_suo_distinto_dal_generico(tmp_path, monkeypatch):
|
||||
"""#37 C12: CLAUDE.md sfiora MAX_CHARS_VOCE con un margine minimo — deve avere un tetto suo
|
||||
piu' largo, cosi' un piccolo aggiornamento non lo tronca."""
|
||||
r = _radice(tmp_path)
|
||||
n_chars = R.MAX_CHARS_VOCE + 1_000 # sopra il generico, ma sotto MAX_CHARS_CLAUDE_MD
|
||||
assert n_chars < R.MAX_CHARS_CLAUDE_MD
|
||||
(r / "CLAUDE.md").write_text("x" * n_chars)
|
||||
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||
assert n.startswith("CLAUDE.md")
|
||||
assert not tr, "sotto il tetto SUO (150k): non deve troncare, anche se supera i 90k generici"
|
||||
|
||||
|
||||
def test_voce_vicina_al_tetto_e_avvisata_nel_prompt_e_nel_rapporto(tmp_path, monkeypatch):
|
||||
"""#37 C12: al 95% del tetto (non ancora troncata) l'avviso entra nel testo, e da li' arriva
|
||||
sia al prompt del revisore sia alla tabella del rapporto — come [TRONCATO] oggi."""
|
||||
r = _radice(tmp_path)
|
||||
monkeypatch.setattr(R, "MAX_CHARS_CLAUDE_MD", 10_000)
|
||||
(r / "CLAUDE.md").write_text("x" * 9_600) # 96% di 10.000, margine largo per la nota
|
||||
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||
assert not tr, "non e' troncata: e' vicina"
|
||||
assert R._MARCA_VICINO_TETTO in t
|
||||
assert "[VICINO AL TETTO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
|
||||
rap = R.rapporto("revisione", None, "ok", [], "claude-fable-5-1", ADESSO, [(n, t, tr)])
|
||||
assert "| quasi |" in rap
|
||||
|
||||
|
||||
def test_voce_lontana_dal_tetto_non_e_avvisata(tmp_path):
|
||||
voci = R.materiale(_radice(tmp_path), ADESSO)
|
||||
claude_md = [t for n, t, tr in voci if n.startswith("CLAUDE.md")][0]
|
||||
assert R._MARCA_VICINO_TETTO not in claude_md
|
||||
|
||||
|
||||
def test_il_totale_resta_sotto_il_tetto_anche_se_la_somma_dei_tetti_lo_supera(tmp_path, monkeypatch):
|
||||
"""#37 C12: CLAUDE.md (150k) + il resto delle voci (90k l'una) puo' superare facilmente i 400k
|
||||
di MAX_CHARS_TOTALE in somma — il tetto totale e' applicato A PARTE, a valle, e resta il vero
|
||||
vincolo: non deve MAI sfondare, qualunque sia il tetto di ogni singola voce."""
|
||||
r = _radice(tmp_path)
|
||||
(r / "CLAUDE.md").write_text("x" * R.MAX_CHARS_CLAUDE_MD) # al SUO tetto: 150k
|
||||
j = r / "docs" / "journal"
|
||||
for i in range(1, 10): # sovrascrive i giornali della finestra
|
||||
g = (ADESSO - timedelta(days=i)).date().isoformat()
|
||||
(j / f"{g}.md").write_text("y" * R.MAX_CHARS_VOCE) # ognuno al SUO tetto: 90k
|
||||
voci = R.materiale(r, ADESSO)
|
||||
tot = sum(len(t) for _, t, _ in voci)
|
||||
somma_tetti = sum(R._cap_voce(n) for n, _, _ in voci)
|
||||
assert somma_tetti > R.MAX_CHARS_TOTALE, "il caso e' quello che sfonderebbe se il totale non fosse a parte"
|
||||
assert tot <= R.MAX_CHARS_TOTALE
|
||||
|
||||
|
||||
def test_il_prompt_porta_le_regole_e_la_data(tmp_path):
|
||||
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
|
||||
assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p
|
||||
@@ -132,6 +183,18 @@ def test_telegram_porta_la_sintesi_e_dichiara_il_taglio():
|
||||
assert "[… sintesi tagliata" in lunga and len(lunga) < 4096
|
||||
|
||||
|
||||
def test_taglio_a_cavallo_di_una_entita_resta_HTML_valido():
|
||||
"""(#35 C13) Un '&' esattamente al taglio non deve spezzarsi in un'entita' a meta' (es.
|
||||
'&am'): il taglio e' sul GREZZO, prima di html.escape, quindi il carattere e' incluso o
|
||||
escluso per intero — mai spezzato a meta' entita'."""
|
||||
sintesi = "a" * (R.LIMITE_TELEGRAM - 1) + "&resto che verra' tagliato via di sicuro"
|
||||
tg = R.per_telegram(_revisione(sintesi=sintesi), None, "ok", ADESSO, Path("p.md"))
|
||||
assert "[… sintesi tagliata" in tg
|
||||
corpo = tg.split("\n\n", 1)[1].split("\n[…", 1)[0]
|
||||
assert corpo == "a" * (R.LIMITE_TELEGRAM - 1) + "&" # il '&' e' incluso PER INTERO
|
||||
assert "&am;" not in tg and "resto che verra'" not in tg # niente entita' a meta', niente coda
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
|
||||
|
||||
def _albero(root: Path) -> set[str]:
|
||||
|
||||
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
|
||||
assert "non leggibile" in src
|
||||
|
||||
|
||||
def test_gateway_usde_giu_non_rompe_la_lettura_usdc():
|
||||
def test_gateway_usde_giu_rende_lequity_IGNOTA_non_solo_usdc():
|
||||
"""(#20) Comportamento cambiato: un'eccezione sulla lettura del conto USDE NON vale 0 USDE.
|
||||
Prima di questa riparazione, `eq` restava $2056 (solo USDC) come se l'USDE non ci fosse:
|
||||
se sul conto c'era davvero collaterale USDE, quel $2056 era un equity sottostimato spacciato
|
||||
per certo. Ora l'intera equity e' IGNOTA (None) — coerente col trattamento di ogni altra
|
||||
lettura fallita nel modulo (posizione, mark): non si assume, si segnala."""
|
||||
eq, src = S._equity(_Client(usdc=2056.0, usde=500.0, usde_raises=True), {})
|
||||
assert eq == 2056.0 and src == "mainnet USDC"
|
||||
assert eq is None
|
||||
assert "USDE illeggibile" in src and "RuntimeError" in src
|
||||
|
||||
@@ -7,6 +7,7 @@ e non invia niente in dry-run.
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -21,8 +22,33 @@ telegram_daily = pytest.importorskip("telegram_daily")
|
||||
def test_build_non_solleva_e_ha_le_sezioni():
|
||||
txt = telegram_daily.build()
|
||||
assert isinstance(txt, str) and len(txt) > 50
|
||||
for sezione in ("Conto", "Perche' non opera", "Ordini", "Monitor in osservazione"):
|
||||
for sezione in ("Conto", "Ordini", "Monitor in osservazione"):
|
||||
assert sezione in txt, f"sezione mancante: {sezione}"
|
||||
# (#35 C14) l'header sul trend e' condizionale: esattamente uno dei due, non piu' fisso.
|
||||
assert ("Perche' non opera" in txt) != ("Stato trend" in txt)
|
||||
|
||||
|
||||
def test_header_perche_non_opera_solo_se_il_libro_e_flat(monkeypatch):
|
||||
"""(#35 C14) L'header fisso mentiva quando il libro NON e' fermo (posizioni o target != 0):
|
||||
con flat=True resta "Perche' non opera"."""
|
||||
monkeypatch.setattr(telegram_daily, "book_state", lambda: ([" equity $1.00"], True))
|
||||
assert "Perche' non opera" in telegram_daily.build()
|
||||
|
||||
|
||||
def test_header_neutro_se_il_libro_non_e_flat(monkeypatch):
|
||||
monkeypatch.setattr(telegram_daily, "book_state",
|
||||
lambda: ([" BTC: pos $300 → target $300"], False))
|
||||
txt = telegram_daily.build()
|
||||
assert "Stato trend" in txt and "Perche' non opera" not in txt
|
||||
|
||||
|
||||
def test_header_neutro_se_il_libro_non_e_leggibile(monkeypatch):
|
||||
"""Se book_state non sa dire se e' flat (eccezione -> None), niente header "non opera"
|
||||
dichiarato senza saperlo davvero: si usa il neutro."""
|
||||
monkeypatch.setattr(telegram_daily, "book_state",
|
||||
lambda: ([" ⚠️ stato libro non leggibile: Errore"], None))
|
||||
txt = telegram_daily.build()
|
||||
assert "Stato trend" in txt and "Perche' non opera" not in txt
|
||||
|
||||
|
||||
def test_nessuna_scrittura_ne_ordine():
|
||||
@@ -52,3 +78,77 @@ def test_convenzione_trend_e_documentata():
|
||||
"""La convenzione media-dei-segni (non frazione) e' la ragione per cui TP01 e' 0 con 1/3 su:
|
||||
se sparisce dal codice, il report mentirebbe sul 'quanto manca'."""
|
||||
assert "media dei SEGNI" in telegram_daily.trend_state.__doc__
|
||||
|
||||
|
||||
def test_soglia_accensione_2_negativi_1_positivo():
|
||||
"""2 negativi + 1 positivo: basta 1 flip -> soglia = il riferimento negativo PIU' BASSO."""
|
||||
r = telegram_daily._soglia_accensione([-1, -1, 1], [100.0, 200.0])
|
||||
assert r == 100.0
|
||||
|
||||
|
||||
def test_soglia_accensione_3_negativi():
|
||||
"""3 negativi: servono 2 flip -> soglia = il SECONDO riferimento negativo piu' basso, non il
|
||||
minimo (il bug di #35 C3: superare solo il minimo lascia la media <= 0)."""
|
||||
r = telegram_daily._soglia_accensione([-1, -1, -1], [100.0, 200.0, 300.0])
|
||||
assert r == 200.0
|
||||
|
||||
|
||||
def test_soglia_accensione_1_negativo_2_positivi_gia_acceso():
|
||||
"""1 negativo + 2 positivi: la media e' gia' positiva (TP01 > 0) -> nessuna soglia da dare."""
|
||||
assert telegram_daily._soglia_accensione([-1, 1, 1], [100.0]) is None
|
||||
|
||||
|
||||
def test_soglia_accensione_nessun_negativo():
|
||||
"""Tutti positivi: nessun riferimento negativo, nessuna soglia."""
|
||||
assert telegram_daily._soglia_accensione([1, 1, 1], []) is None
|
||||
|
||||
|
||||
def test_soglia_accensione_servono_tutti_i_flip():
|
||||
"""2 negativi su 2 (nessun positivo): servono ENTRAMBI i flip -> k == len(neg_refs), soglia
|
||||
= il piu' alto dei due (l'ultimo a bastare), non il minimo."""
|
||||
r = telegram_daily._soglia_accensione([-1, -1], [200.0, 100.0])
|
||||
assert r == 200.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- (#35 C14) gate: data, formato
|
||||
|
||||
|
||||
def test_gate_date_legge_da_monitor_health():
|
||||
"""La data non e' una seconda dichiarazione: viene da monitor_health.MONITORS (`gate`),
|
||||
la stessa tabella che gli alert dei sorveglianti citano come "METTE A RISCHIO il gate"."""
|
||||
from src.live.monitor_health import MONITORS
|
||||
m = next(x for x in MONITORS if x.name == "paper_statarb")
|
||||
assert m.gate[:10] == "2026-09-27"
|
||||
assert telegram_daily._gate_date("paper_statarb", date(2030, 1, 1)) == date(2026, 9, 27)
|
||||
m2 = next(x for x in MONITORS if x.name == "paper_xsr")
|
||||
assert telegram_daily._gate_date("paper_xsr", date(2030, 1, 1)) == date.fromisoformat(m2.gate[:10])
|
||||
|
||||
|
||||
def test_gate_date_usa_la_cablata_se_il_monitor_non_esiste():
|
||||
"""Rete di sicurezza: se il nome non e' in MONITORS (o cambia), non si inventa una data."""
|
||||
assert telegram_daily._gate_date("non_esiste_piu'", date(2030, 1, 1)) == date(2030, 1, 1)
|
||||
|
||||
|
||||
def test_fmt_giorni_tre_casi_mai_il_doppio_segno():
|
||||
"""(#35 C14) giorni>0 -> '-Ng'; 0 -> 'oggi'; <0 -> 'scaduto da Ng'. Mai '(−-1g)'."""
|
||||
assert telegram_daily._fmt_giorni(5) == "-5g"
|
||||
assert telegram_daily._fmt_giorni(0) == "oggi"
|
||||
assert telegram_daily._fmt_giorni(-1) == "scaduto da 1g"
|
||||
assert "-1g" not in telegram_daily._fmt_giorni(-1).replace("scaduto da 1g", "") # niente doppio segno
|
||||
|
||||
|
||||
def test_monitors_formatta_i_tre_casi_del_giorno(monkeypatch, tmp_path):
|
||||
"""I rami di monitors() (barra assente qui: file inesistenti in tmp_path) usano tutti
|
||||
_fmt_giorni, non il vecchio '(−{giorni}g)' cablato: futuro, oggi e passato si distinguono."""
|
||||
from datetime import timedelta
|
||||
oggi = date.today()
|
||||
monkeypatch.setattr(telegram_daily, "ROOT", tmp_path)
|
||||
monkeypatch.setattr(telegram_daily, "GATES",
|
||||
[("FUTURO", oggi + timedelta(days=10), "data/nonexiste1/r.jsonl", "k"),
|
||||
("OGGI", oggi, "data/nonexiste2/r.jsonl", "k"),
|
||||
("PASSATO", oggi - timedelta(days=3), "data/nonexiste3/r.jsonl", "k")])
|
||||
righe = telegram_daily.monitors()
|
||||
assert "(-10g)" in righe[0]
|
||||
assert "(oggi)" in righe[1]
|
||||
assert "(scaduto da 3g)" in righe[2]
|
||||
assert "−-" not in "".join(righe) and "(−" not in "".join(righe) # mai il doppio segno
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""scripts/live/trades_db.py:sync() — controllo di conteggio jsonl-vs-DB (#37 C11, #35 A9).
|
||||
|
||||
Dal 2026-08-24T01:07Z (`tradesdb.CUTOFF_TS_VERO`) `book_executions.jsonl` porta l'ora VERA del
|
||||
fill: un conteggio diverso da quello del DB in quella finestra segnala che una delle due fonti
|
||||
sta perdendo fill. `sync()` non ripara (come `reconcile()`): riporta e notifica UNA volta per
|
||||
episodio, con lo stesso marcatore-solo-su-invio-riuscito di fee_watch (#37 B9).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
import trades_db as TD # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def redirige(monkeypatch, tmp_path):
|
||||
"""DB, cron_book.log e jsonl finti in tmp_path: non si tocca mai il libro vero."""
|
||||
reale = T.connect
|
||||
monkeypatch.setattr(T, "connect", lambda *a, **k: reale(tmp_path / "t.db"))
|
||||
monkeypatch.setattr(T, "CRON_LOG", tmp_path / "cron_book.log") # assente -> nessun fill dal log
|
||||
monkeypatch.setattr(T, "EXEC_JSONL", tmp_path / "book_executions.jsonl")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _semina_db(tmp_path, n, ts=None):
|
||||
ts = ts or T.CUTOFF_TS_VERO
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
fills = [T.Fill(ts_utc=ts, asset="BTC", side="buy", qty=0.001 * (i + 1), price=70000.0, fee=0.01)
|
||||
for i in range(n)]
|
||||
T.upsert_fills(con, fills)
|
||||
con.close()
|
||||
|
||||
|
||||
def _scrivi_jsonl(tmp_path, n, ts=None):
|
||||
ts = ts or T.CUTOFF_TS_VERO
|
||||
righe = [dict(ts_utc=ts, asset="BTC", side="buy", filled=0.001 * (i + 1), price=70000.0, fee=0.01)
|
||||
for i in range(n)]
|
||||
(tmp_path / "book_executions.jsonl").write_text(
|
||||
"\n".join(json.dumps(r) for r in righe) + ("\n" if righe else ""))
|
||||
|
||||
|
||||
def test_conteggi_uguali_non_notifica(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 2)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
r = TD.sync(verbose=False)
|
||||
assert inviato == []
|
||||
assert r["jsonl_dal_24_08"] == r["db_dal_24_08"] == 2
|
||||
|
||||
|
||||
def test_conteggi_diversi_notifica_una_volta_per_episodio(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 3)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
r = TD.sync(verbose=False)
|
||||
assert len(inviato) == 1, "conteggi diversi: un invio"
|
||||
assert r["jsonl_dal_24_08"] == 3 and r["db_dal_24_08"] == 2
|
||||
|
||||
# secondo giro, stessa divergenza -> nessun invio aggiuntivo (marcatore gia' scritto)
|
||||
r2 = TD.sync(verbose=False)
|
||||
assert len(inviato) == 1, "stesso episodio: nessun secondo invio"
|
||||
assert r2["jsonl_dal_24_08"] == 3 and r2["db_dal_24_08"] == 2
|
||||
|
||||
|
||||
def test_invio_fallito_non_marca_ritenta_al_giro_dopo(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 3)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or False)
|
||||
TD.sync(verbose=False)
|
||||
TD.sync(verbose=False)
|
||||
assert len(inviato) == 2, "invio fallito -> marcatore non scritto, si ritenta ogni giro"
|
||||
|
||||
|
||||
def test_conteggio_rientrato_azzera_il_marcatore(monkeypatch, redirige):
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 2)
|
||||
_scrivi_jsonl(tmp_path, 3)
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: True)
|
||||
TD.sync(verbose=False) # divergenza detta
|
||||
|
||||
# il jsonl si allinea (es. arrivato il fill mancante) -> episodio chiuso
|
||||
_scrivi_jsonl(tmp_path, 2)
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
TD.sync(verbose=False)
|
||||
assert inviato == [], "conteggi tornati uguali: nessun invio da fare"
|
||||
|
||||
# una NUOVA divergenza deve poter notificare di nuovo (il marcatore era stato azzerato)
|
||||
_scrivi_jsonl(tmp_path, 4)
|
||||
TD.sync(verbose=False)
|
||||
assert len(inviato) == 1
|
||||
|
||||
|
||||
def test_fill_prima_del_24_08_non_entra_nel_conteggio(monkeypatch, redirige):
|
||||
"""Prima di CUTOFF_TS_VERO il ts_utc del jsonl e' la barra di segnale, non l'ora vera: non
|
||||
e' comparabile col DB, e non deve contare come divergenza."""
|
||||
tmp_path = redirige
|
||||
_semina_db(tmp_path, 0)
|
||||
_scrivi_jsonl(tmp_path, 2, ts="2026-08-10T00:00:00+00:00") # prima della soglia
|
||||
inviato = []
|
||||
monkeypatch.setattr(TD, "notify", lambda *a, **k: inviato.append((a, k)) or True)
|
||||
r = TD.sync(verbose=False)
|
||||
assert inviato == []
|
||||
assert r["jsonl_dal_24_08"] == 0 and r["db_dal_24_08"] == 0
|
||||
+10
-2
@@ -116,7 +116,11 @@ def test_fifo_non_mescola_gli_asset():
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert rts == [] # la vendita ETH non chiude il long BTC
|
||||
assert set(aperti) == {"BTC"}
|
||||
# (#32 C2) una vendita ETH senza lotti long apre un SHORT — non sparisce piu' in silenzio:
|
||||
# e' comunque isolata dal long BTC, che e' quel che il test verifica.
|
||||
assert set(aperti) == {"BTC", "ETH"}
|
||||
assert aperti["BTC"][0]["direzione"] == "long"
|
||||
assert aperti["ETH"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
def test_fifo_riproduce_il_conto_del_libro_vero():
|
||||
@@ -200,8 +204,12 @@ def test_l_esecutore_non_data_piu_i_fill_alla_barra_di_segnale():
|
||||
"""Il difetto riparato il 2026-08-23: `ts_utc` era `pd.Timestamp(r['last_data'])`.
|
||||
|
||||
Se qualcuno lo rimette, questo test lo dice — il DB verrebbe ricostruito su date finte.
|
||||
Dal refactor U2 (#23) la registrazione di un fill vive in `_registra_fill(...)`, che riceve
|
||||
`bar_ts` come parametro invece di leggere `r['last_data']` alla riga del log_event: il call
|
||||
site passa `bar_ts=r["last_data"]`.
|
||||
"""
|
||||
src = (Path(__file__).resolve().parents[1] / "scripts" / "live" / "book_execute.py").read_text()
|
||||
assert "ts_utc=str(pd.Timestamp(r['last_data']))" not in src
|
||||
assert "bar_ts=str(pd.Timestamp(r['last_data']))" in src
|
||||
assert "bar_ts=str(pd.Timestamp(bar_ts))" in src # dentro _registra_fill
|
||||
assert 'bar_ts=r["last_data"]' in src # il call site passa la barra
|
||||
assert "ts_utc=datetime.now(timezone.utc)" in src
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""tradesdb.arricchisci_da_jsonl (#40, TradingBook#11): le righe `ts_source='cron_log'` dal
|
||||
24/08 (`CUTOFF_TS_VERO`) prendono l'ora vera, il prezzo pieno e `pos_after` dal jsonl, SOLO
|
||||
quando l'abbinamento (asset, side, qty, finestra di 15 minuti) e' univoco. `fill_id` e' la
|
||||
chiave della riga: l'arricchimento aggiorna le colonne con un UPDATE per `fill_id`, mai la
|
||||
chiave stessa.
|
||||
|
||||
Nessun test tocca la rete o il libro vero: DB e righe jsonl sono finti, in tmp_path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
# ts di riferimento: subito dopo la soglia, cosi' i fill sono sempre candidati.
|
||||
T0 = "2026-08-24T02:00:00+00:00"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
def _f(ts, asset="BTC", side="buy", qty=0.005, price=70000.0, fee=0.02):
|
||||
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee)
|
||||
|
||||
|
||||
def _riga_jsonl(ts, asset="BTC", side="buy", filled=0.005, price=70123.4, pos_after=None):
|
||||
return dict(ts_utc=ts, asset=asset, side=side, filled=filled, price=price, pos_after=pos_after)
|
||||
|
||||
|
||||
def test_abbinamento_unico_aggiorna_e_non_cambia_fill_id(db):
|
||||
fill = _f(T0)
|
||||
T.upsert_fills(db, [fill])
|
||||
fill_id_prima = fill.fill_id
|
||||
|
||||
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=555.0)] # +5 min, in finestra
|
||||
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
|
||||
assert (aggiornate, ambigue) == (1, 0)
|
||||
|
||||
riga = db.execute("SELECT * FROM fills WHERE fill_id=?", (fill_id_prima,)).fetchone()
|
||||
assert riga is not None, "il fill_id originale resta la chiave: l'UPDATE non lo cambia"
|
||||
assert riga["ts_source"] == "jsonl"
|
||||
assert riga["ts_utc"] == "2026-08-24T02:05:00+00:00"
|
||||
assert riga["price"] == 70123.4
|
||||
assert riga["pos_after"] == 555.0
|
||||
# nessuna riga in piu' o in meno: l'arricchimento aggiorna, non inserisce.
|
||||
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 1
|
||||
|
||||
|
||||
def test_pos_after_none_nel_jsonl_non_sovrascrive_quello_gia_nel_db(db):
|
||||
"""(#40, punto 1) `pos_after=None` nella riga jsonl non e' un valore: la colonna resta
|
||||
quella che aveva (qui: None dal cron_log, ma la regola e' 'non toccare', non 'azzerare')."""
|
||||
fill = _f(T0)
|
||||
T.upsert_fills(db, [fill])
|
||||
|
||||
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=None)]
|
||||
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
|
||||
assert (aggiornate, ambigue) == (1, 0)
|
||||
|
||||
riga = db.execute("SELECT * FROM fills").fetchone()
|
||||
assert riga["ts_source"] == "jsonl" and riga["price"] == 70123.4
|
||||
assert riga["pos_after"] is None
|
||||
|
||||
|
||||
def test_due_jsonl_compatibili_e_ambiguo_non_si_tocca(db):
|
||||
fill = _f(T0)
|
||||
T.upsert_fills(db, [fill])
|
||||
|
||||
righe = [_riga_jsonl("2026-08-24T02:03:00+00:00", price=70100.0),
|
||||
_riga_jsonl("2026-08-24T02:07:00+00:00", price=70200.0)] # entrambe in finestra
|
||||
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
|
||||
assert (aggiornate, ambigue) == (0, 1)
|
||||
|
||||
riga = db.execute("SELECT * FROM fills").fetchone()
|
||||
assert riga["ts_source"] == "cron_log" # non toccata
|
||||
assert riga["ts_utc"] == T0
|
||||
assert riga["price"] == 70000.0
|
||||
|
||||
|
||||
def test_secondo_giro_zero_aggiornati_idempotente(db):
|
||||
fill = _f(T0)
|
||||
T.upsert_fills(db, [fill])
|
||||
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=555.0)]
|
||||
|
||||
assert T.arricchisci_da_jsonl(db, righe) == (1, 0)
|
||||
# la riga e' gia' ts_source='jsonl': non e' piu' candidata, anche con le stesse righe jsonl.
|
||||
assert T.arricchisci_da_jsonl(db, righe) == (0, 0)
|
||||
assert db.execute("SELECT COUNT(*) c FROM fills WHERE ts_source='jsonl'").fetchone()["c"] == 1
|
||||
|
||||
|
||||
def test_riga_prima_del_cutoff_non_e_candidata(db):
|
||||
ts_prima = "2026-08-10T00:00:00+00:00" # prima di CUTOFF_TS_VERO (24/08)
|
||||
fill = _f(ts_prima)
|
||||
T.upsert_fills(db, [fill])
|
||||
|
||||
righe = [_riga_jsonl("2026-08-10T00:05:00+00:00", pos_after=1.0)] # abbinamento perfetto
|
||||
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
|
||||
assert (aggiornate, ambigue) == (0, 0) # non e' ambigua: non e' nemmeno candidata
|
||||
|
||||
riga = db.execute("SELECT * FROM fills").fetchone()
|
||||
assert riga["ts_source"] == "cron_log" and riga["ts_utc"] == ts_prima and riga["price"] == 70000.0
|
||||
|
||||
|
||||
def test_fuori_dalla_finestra_dei_15_minuti_non_abbina(db):
|
||||
fill = _f(T0)
|
||||
T.upsert_fills(db, [fill])
|
||||
|
||||
righe = [_riga_jsonl("2026-08-24T02:16:00+00:00", pos_after=1.0)] # +16 min: fuori finestra
|
||||
assert T.arricchisci_da_jsonl(db, righe) == (0, 1)
|
||||
|
||||
riga = db.execute("SELECT * FROM fills").fetchone()
|
||||
assert riga["ts_source"] == "cron_log"
|
||||
|
||||
|
||||
def test_qty_diversa_non_abbina(db):
|
||||
fill = _f(T0, qty=0.005)
|
||||
T.upsert_fills(db, [fill])
|
||||
|
||||
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", filled=0.006, pos_after=1.0)]
|
||||
assert T.arricchisci_da_jsonl(db, righe) == (0, 1)
|
||||
|
||||
|
||||
def test_record_jsonl_usato_una_volta_non_si_riusa(db):
|
||||
"""Due fill IDENTICI nello stesso giro (stesso asset/side/qty, ts diversi) non devono
|
||||
finire abbinati alla stessa riga jsonl: la prima riga jsonl la prende un solo fill_id."""
|
||||
f1 = _f("2026-08-24T02:00:00+00:00")
|
||||
f2 = _f("2026-08-24T02:01:00+00:00") # ts diverso -> fill_id diverso, stesso asset/side/qty
|
||||
T.upsert_fills(db, [f1, f2])
|
||||
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
|
||||
|
||||
righe = [_riga_jsonl("2026-08-24T02:05:00+00:00", pos_after=1.0)] # UNA sola riga jsonl
|
||||
aggiornate, ambigue = T.arricchisci_da_jsonl(db, righe)
|
||||
# un solo candidato jsonl per due righe DB candidate: la prima (ordinata per ts_utc) lo
|
||||
# prende, la seconda resta senza candidati liberi -> ambigua (zero disponibili).
|
||||
assert aggiornate == 1
|
||||
assert ambigue == 1
|
||||
presi = db.execute("SELECT COUNT(*) c FROM fills WHERE ts_source='jsonl'").fetchone()["c"]
|
||||
assert presi == 1
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Test nuovi per l'unita' U4 (issue #32 C2, C10) — tradesdb.py.
|
||||
|
||||
Non ripete la suite esistente (`test_tradesdb.py`): quella resta bit-exact sui casi
|
||||
long-aperto/long-chiuso (vedi il commit). Qui solo cio' che il fix aggiunge: lotti SHORT,
|
||||
inversione in un fill, e i fill a qty<=0 fuori dal FIFO.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live import tradesdb as T # noqa: E402
|
||||
|
||||
|
||||
def _f(ts, asset, side, qty, price, fee=0.0, verified=1):
|
||||
return T.Fill(ts_utc=ts, asset=asset, side=side, qty=qty, price=price, fee=fee,
|
||||
verified=verified)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
con = T.connect(tmp_path / "t.db")
|
||||
yield con
|
||||
con.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- short: apertura e chiusura
|
||||
|
||||
def test_una_vendita_senza_lotti_long_apre_un_short(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert rts == [] # niente da chiudere: solo apertura
|
||||
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0)
|
||||
assert aperti["BTC"][0]["price"] == pytest.approx(100.0)
|
||||
assert aperti["BTC"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
def test_un_acquisto_chiude_il_short_pnl_entry_meno_exit(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "sell", 1.0, 100.0, fee=1.0), # apre short @100
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 90.0, fee=1.0)] # chiude @90
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
r = rts[0]
|
||||
assert r["direzione"] == "short"
|
||||
assert r["px_in"] == 100.0 and r["px_out"] == 90.0
|
||||
assert r["pnl_lordo"] == pytest.approx(10.0) # venduto alto, comprato basso: profitto
|
||||
assert r["fee_quota"] == pytest.approx(2.0)
|
||||
assert r["pnl_netto"] == pytest.approx(8.0)
|
||||
assert aperti == {} # flat: nessun lotto residuo
|
||||
|
||||
|
||||
def test_chiusura_parziale_di_uno_short_lascia_il_resto_short(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0),
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 0.25, 80.0)]
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1 and rts[0]["qty"] == pytest.approx(0.25)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(0.25 * (100.0 - 80.0))
|
||||
assert aperti["ETH"][0]["qty"] == pytest.approx(0.75)
|
||||
assert aperti["ETH"][0]["direzione"] == "short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- inversione in un solo fill
|
||||
|
||||
def test_inversione_long_a_short_in_un_fill_non_scarta_il_resto(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 1.0, 100.0), # long 1.0 @100
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "sell", 2.0, 150.0)] # chiude + apre short
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
assert rts[0]["direzione"] == "long" and rts[0]["qty"] == pytest.approx(1.0)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(50.0) # (150-100)*1
|
||||
assert aperti["BTC"][0]["qty"] == pytest.approx(1.0) # il resto: 2.0-1.0
|
||||
assert aperti["BTC"][0]["price"] == pytest.approx(150.0)
|
||||
assert aperti["BTC"][0]["direzione"] == "short" # nessun resto scartato
|
||||
|
||||
|
||||
def test_inversione_short_a_long_in_un_fill(db):
|
||||
fills = [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0), # short 1.0 @100
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 3.0, 90.0)] # chiude + apre long
|
||||
rts, aperti = T.fifo_roundtrips(fills)
|
||||
assert len(rts) == 1
|
||||
assert rts[0]["direzione"] == "short" and rts[0]["qty"] == pytest.approx(1.0)
|
||||
assert rts[0]["pnl_lordo"] == pytest.approx(10.0) # (100-90)*1
|
||||
assert aperti["ETH"][0]["qty"] == pytest.approx(2.0) # il resto: 3.0-1.0
|
||||
assert aperti["ETH"][0]["direzione"] == "long"
|
||||
|
||||
|
||||
def test_stato_aperto_riporta_la_direzione_short(db):
|
||||
T.upsert_fills(db, [_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)])
|
||||
st = T.stato_aperto(db)
|
||||
assert st["ETH"]["qty"] == pytest.approx(1.0)
|
||||
assert st["ETH"]["direzione"] == "short"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- #32 C10: fill a qty<=0
|
||||
|
||||
def test_fill_a_qty_zero_non_entra_nel_fifo(db):
|
||||
T.upsert_fills(db, [
|
||||
_f("2026-01-01T00:00:00+00:00", "BTC", "buy", 0.0, 100.0, verified=0), # place_order error
|
||||
_f("2026-01-02T00:00:00+00:00", "BTC", "buy", 1.0, 100.0),
|
||||
_f("2026-01-03T00:00:00+00:00", "BTC", "sell", 1.0, 110.0)])
|
||||
assert T.rebuild_roundtrips(db) == 1 # solo il round-trip vero, non uno spurio
|
||||
rt = db.execute("SELECT * FROM roundtrips").fetchall()
|
||||
assert len(rt) == 1 and rt[0]["qty"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_fill_a_qty_zero_non_entra_in_stato_aperto_ma_resta_nel_db(db):
|
||||
T.upsert_fills(db, [
|
||||
_f("2026-01-01T00:00:00+00:00", "ETH", "sell", 0.0, 100.0, verified=0), # place_order error
|
||||
_f("2026-01-02T00:00:00+00:00", "ETH", "buy", 1.0, 100.0)])
|
||||
st = T.stato_aperto(db)
|
||||
assert st["ETH"]["qty"] == pytest.approx(1.0) # il fill a qty 0 non conta come lotto
|
||||
# ma resta leggibile nel DB, marcato NON verificato — non e' sparito, e' escluso dal FIFO
|
||||
righe = db.execute("SELECT qty, verified FROM fills WHERE qty = 0").fetchall()
|
||||
assert len(righe) == 1 and righe[0]["verified"] == 0
|
||||
assert db.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"] == 2
|
||||
@@ -0,0 +1,259 @@
|
||||
"""usde_convert.py — GTC mai cancellati (issue #26).
|
||||
|
||||
Il gateway cerbero-mcp NON accetta `time_in_force` (PlaceOrderReq non ha il campo, extra
|
||||
ignorati: gitea:Adriano/Cerbero-mcp#1): un limit riempito solo in parte resta APERTO per
|
||||
sempre, e senza guardia si accumula a ogni giro dello script. Blindano: (a) all'avvio si
|
||||
cancellano SOLO i residui con la nostra label (`usde-quota-*`), un ordine con altra label non
|
||||
si tocca; (b) nel ciclo, un ordine con `filled < amt` viene cancellato subito dopo; (c) se la
|
||||
cancellazione fallisce il ciclo si FERMA (mai un secondo ordine con un residuo vivo).
|
||||
|
||||
Client finto (nessuna rete), `time.sleep` patchato: nessun ordine vero puo' partire da questi
|
||||
test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
import usde_convert as C # noqa: E402
|
||||
from src.live import usde as U # noqa: E402
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# cancella_residui — SOLA lettura + cancellazione dei nostri ordini
|
||||
# =============================================================================================
|
||||
class _ClientOrdini:
|
||||
"""Fake minimale per _post/_unwrap: nessuna rete."""
|
||||
|
||||
def __init__(self, open_orders=None, open_orders_usde=None, risposte_place=None,
|
||||
boom_cancel=None):
|
||||
self.open_orders = open_orders or []
|
||||
# None = stessa lista di open_orders (retrocompat dei test esistenti, che non
|
||||
# distinguono la valuta interrogata); una lista propria serve solo a chi vuole
|
||||
# simulare un residuo elencato SOLO sotto USDE.
|
||||
self.open_orders_usde = open_orders_usde
|
||||
self.risposte_place = list(risposte_place or [])
|
||||
self.boom_cancel = boom_cancel or set() # order_id la cui cancel fallisce
|
||||
self.cancellati = []
|
||||
self.get_open_orders_chiamate = 0
|
||||
|
||||
def _post(self, path, payload):
|
||||
if path.endswith("get_open_orders"):
|
||||
self.get_open_orders_chiamate += 1
|
||||
if payload.get("currency") == "USDE" and self.open_orders_usde is not None:
|
||||
return {"result": self.open_orders_usde}
|
||||
return {"result": self.open_orders}
|
||||
if path.endswith("cancel_order"):
|
||||
oid = payload["order_id"]
|
||||
self.cancellati.append(oid)
|
||||
if oid in self.boom_cancel:
|
||||
# forma reale di client.py::cancel_order su un fallimento del venue: il gateway
|
||||
# avvolge il dict del tool in {"result": ...}, MAI in {"result": None, "error"}
|
||||
return {"result": {"order_id": oid, "state": "error", "error": "cancel rifiutata dal venue"}}
|
||||
return {"result": {"order_id": oid, "state": "cancelled"}}
|
||||
if path.endswith("place_order"):
|
||||
if not self.risposte_place:
|
||||
raise AssertionError("place_order chiamato senza risposte in coda")
|
||||
return self.risposte_place.pop(0)
|
||||
raise AssertionError(f"chiamata inattesa: {path}")
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp):
|
||||
return resp.get("result", resp) if isinstance(resp, dict) else resp
|
||||
|
||||
|
||||
def test_cancella_residui_solo_le_nostre_label():
|
||||
client = _ClientOrdini(open_orders=[
|
||||
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
|
||||
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"}, # NON nostro
|
||||
{"instrument": "BTC_USDC-PERPETUAL", "label": "usde-quota-0.64", "order_id": "R3"},
|
||||
])
|
||||
out = C.cancella_residui(client)
|
||||
assert out == ["R1"]
|
||||
assert client.cancellati == ["R1"], "l'ordine con altra label o su altro strumento non si tocca"
|
||||
|
||||
|
||||
def test_cancella_residui_trovato_solo_sotto_USDE():
|
||||
"""Non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC: se lo elenca
|
||||
sotto la base (USDE) invece che sotto la quotazione (USDC), la sola lettura USDC non lo
|
||||
vedrebbe mai e il residuo si accumulerebbe per sempre."""
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
open_orders_usde=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R9"}],
|
||||
)
|
||||
out = C.cancella_residui(client)
|
||||
assert out == ["R9"]
|
||||
assert client.cancellati == ["R9"]
|
||||
|
||||
|
||||
def test_cancella_residui_senza_residui_non_cancella_nulla():
|
||||
client = _ClientOrdini(open_orders=[
|
||||
{"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"},
|
||||
])
|
||||
assert C.cancella_residui(client) == []
|
||||
assert client.cancellati == []
|
||||
|
||||
|
||||
def test_cancella_residui_lettura_fallita_solleva():
|
||||
"""P5: non si ordina al buio. Se la LETTURA degli ordini aperti fallisce, la funzione
|
||||
deve sollevare invece di procedere come se non ci fosse nulla da cancellare."""
|
||||
class _ClientRotto:
|
||||
def _post(self, path, payload):
|
||||
raise RuntimeError("gateway giu'")
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp):
|
||||
return resp
|
||||
|
||||
with pytest.raises(Exception):
|
||||
C.cancella_residui(_ClientRotto())
|
||||
|
||||
|
||||
def test_cancella_residui_un_residuo_che_non_si_cancella_solleva():
|
||||
"""Se non riusciamo a liberare un residuo NOSTRO, non si procede: si solleva (non si piazza
|
||||
un nuovo ordine sopra un residuo che non siamo riusciti a togliere)."""
|
||||
client = _ClientOrdini(open_orders=[
|
||||
{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"},
|
||||
], boom_cancel={"R1"})
|
||||
with pytest.raises(Exception):
|
||||
C.cancella_residui(client)
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# _cancella — l'helper di basso livello usato sia da cancella_residui sia dal ciclo di main()
|
||||
# =============================================================================================
|
||||
def test__cancella_ok():
|
||||
client = _ClientOrdini()
|
||||
ok, motivo = C._cancella(client, "X1")
|
||||
assert ok and motivo == "" and client.cancellati == ["X1"]
|
||||
|
||||
|
||||
def test__cancella_errore_nel_payload_non_solleva():
|
||||
client = _ClientOrdini(boom_cancel={"X1"})
|
||||
ok, motivo = C._cancella(client, "X1")
|
||||
assert not ok and "rifiutata" in motivo
|
||||
|
||||
|
||||
def test__cancella_eccezione_di_rete_si_registra_non_si_propaga():
|
||||
class _Rotto:
|
||||
def _post(self, path, payload):
|
||||
raise RuntimeError("timeout")
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp):
|
||||
return resp
|
||||
|
||||
ok, motivo = C._cancella(_Rotto(), "X1")
|
||||
assert not ok and "timeout" in motivo
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# main() con --esegui: cancellazione dei residui PRIMA del ciclo, e dopo ogni ordine parziale
|
||||
# =============================================================================================
|
||||
def _piano_finto(**over):
|
||||
base = dict(ok=True, side="buy", amount=10.0, px=1.0, px_ordine=1.0000, motivi=[],
|
||||
quota_ora=0.20, quota_target=0.64, cap_usd=None, cap_frac=0.0,
|
||||
val_usde=500.0, equity_tot=1500.0, delta_usd=10.0,
|
||||
usdc_dopo=990.0, cuscino=300.0, cuscino_come="test")
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _prepara_main(monkeypatch, client, tmp_path, argv_extra=()):
|
||||
monkeypatch.setattr(C, "DeribitRead", lambda: client)
|
||||
monkeypatch.setattr(C, "leggi_stato", lambda c: (1000.0, 500.0, 1.0, "test"))
|
||||
monkeypatch.setattr(C, "book_top", lambda *a, **k: (0.9999, 1.0001))
|
||||
monkeypatch.setattr(C, "piano", lambda *a, **k: _piano_finto())
|
||||
monkeypatch.setattr(C.time, "sleep", lambda s: None)
|
||||
monkeypatch.setattr(C, "LOG", tmp_path / "usde_convert.jsonl")
|
||||
monkeypatch.setattr(C, "ROOT", tmp_path) # solo per il print finale (LOG.relative_to(ROOT))
|
||||
monkeypatch.setattr(sys, "argv", ["usde_convert.py", "--quota", "0.64", "--esegui", *argv_extra])
|
||||
|
||||
|
||||
def test_ordine_con_filled_zero_viene_cancellato_e_il_ciclo_si_ferma(monkeypatch, tmp_path, capsys):
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "open",
|
||||
"filled_amount": 0}, "trades": []}}],
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
rc = C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert client.cancellati == ["O1"], "un ordine non fillato deve essere cancellato, non lasciato aperto"
|
||||
assert "residuo cancellato" in out
|
||||
assert rc == 1 # nessun fill -> esito negativo, comportamento invariato
|
||||
|
||||
|
||||
def test_cancel_che_fallisce_ferma_il_ciclo_e_non_manda_un_secondo_ordine(monkeypatch, tmp_path, capsys):
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
risposte_place=[
|
||||
{"result": {"order": {"order_id": "BOOM", "order_state": "open",
|
||||
"filled_amount": 0}, "trades": []}},
|
||||
{"result": {"order": {"order_id": "O2", "order_state": "open",
|
||||
"filled_amount": 10}, "trades": []}}, # non deve mai partire
|
||||
],
|
||||
boom_cancel={"BOOM"},
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
rc = C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert client.cancellati == ["BOOM"]
|
||||
assert "FALLITA" in out and "mai un secondo ordine" in out
|
||||
assert len(client.risposte_place) == 1, "il secondo ordine non deve essere stato piazzato"
|
||||
assert rc == 1
|
||||
|
||||
|
||||
def test_ordine_fillato_per_intero_non_si_cancella(monkeypatch, tmp_path, capsys):
|
||||
"""filled == amt: nessun residuo, nessuna cancellazione (P3: non si spreca una chiamata su
|
||||
un ordine che non ha lasciato nulla aperto)."""
|
||||
client = _ClientOrdini(
|
||||
open_orders=[],
|
||||
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
|
||||
"filled_amount": 10.0}, "trades": []}}],
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
# piano() e' chiamato una volta PRIMA del ciclo (piano dry-run) e una volta per ogni giro:
|
||||
# le prime due chiamate (piano pre-ciclo + 1° giro) sono valide, la terza (2° giro) dice
|
||||
# 'gia' a bersaglio' per fermare il ciclo dopo il primo (e unico) ordine.
|
||||
chiamate = {"n": 0}
|
||||
|
||||
def piano_poi_fine(*a, **k):
|
||||
chiamate["n"] += 1
|
||||
if chiamate["n"] <= 2:
|
||||
return _piano_finto()
|
||||
return _piano_finto(ok=False, motivi=["gia' a bersaglio"])
|
||||
monkeypatch.setattr(C, "piano", piano_poi_fine)
|
||||
C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert client.cancellati == []
|
||||
assert "residuo cancellato" not in out
|
||||
|
||||
|
||||
def test_residui_all_avvio_vengono_cancellati_prima_del_ciclo(monkeypatch, tmp_path, capsys):
|
||||
client = _ClientOrdini(
|
||||
open_orders=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "VECCHIO"}],
|
||||
risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled",
|
||||
"filled_amount": 10.0}, "trades": []}}],
|
||||
)
|
||||
_prepara_main(monkeypatch, client, tmp_path)
|
||||
# piano() e' chiamato una volta PRIMA del ciclo e una volta per giro: le prime due chiamate
|
||||
# sono valide (piano pre-ciclo + 1° giro, che piazza l'unico ordine), la terza ferma il ciclo.
|
||||
chiamate = {"n": 0}
|
||||
|
||||
def piano_poi_fine(*a, **k):
|
||||
chiamate["n"] += 1
|
||||
return _piano_finto() if chiamate["n"] <= 2 else _piano_finto(ok=False, motivi=["fine"])
|
||||
monkeypatch.setattr(C, "piano", piano_poi_fine)
|
||||
C.main()
|
||||
out = capsys.readouterr().out
|
||||
assert "VECCHIO" in client.cancellati
|
||||
assert "residui cancellati all'avvio: 1" in out
|
||||
# e il residuo pre-esistente e' stato cancellato PRIMA che partisse il primo place_order
|
||||
assert client.cancellati.index("VECCHIO") == 0
|
||||
+103
-10
@@ -193,15 +193,108 @@ def test_nessuna_finestra_misurabile_non_e_zero():
|
||||
assert r["apr"] is None and r["finestre"] == 0
|
||||
|
||||
|
||||
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
|
||||
from datetime import datetime, timezone
|
||||
def test_quota_da_decidere_e_stata_rimossa():
|
||||
"""#35 (B3/B4): dal 10/09 (issue #7) la quota e' DERIVATA dal cuscino USDC
|
||||
(usde._nota_cuscino), non e' piu' una decisione dell'operatore rinviata da ricordare ogni
|
||||
giorno finche' non la si toglie a mano."""
|
||||
assert not hasattr(W, "quota_da_decidere")
|
||||
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
|
||||
|
||||
def q(giorno, v):
|
||||
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
|
||||
|
||||
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
|
||||
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
|
||||
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
|
||||
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
|
||||
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
|
||||
assert q("2026-08-31", "NON_IDONEO") is False
|
||||
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
|
||||
# Il 07/09 il lettore chiedeva limit=50, la sonda del 06/09 aveva fatto 54 ordini: 400 USDE di
|
||||
# acquisti sono finiti nel «reward». Il lettore ora chiede il massimo del venue (1000) e, se la
|
||||
# lista puo' essere incompleta — troncata al limite, o ultima lettura oltre la finestra di 24h
|
||||
# che il gateway espone — dichiara «non leggibile» invece di sommare (P12).
|
||||
|
||||
class _ClientFinto:
|
||||
def __init__(self, rows):
|
||||
self.rows, self.limit_chiesto = rows, None
|
||||
|
||||
def trade_history(self, instrument, limit=20):
|
||||
self.limit_chiesto = limit
|
||||
return self.rows[:limit]
|
||||
|
||||
|
||||
def _ordini(n, t0_ms, amt=100.0, passo_ms=15_000):
|
||||
return [{"timestamp": t0_ms + i * passo_ms, "direction": "buy", "amount": amt} for i in range(n)]
|
||||
|
||||
|
||||
def test_controllo_positivo_54_ordini_si_sommano_tutti():
|
||||
"""Il caso del 06-07/09: 54 ordini in 24h. Con il vecchio limit=50 ne mancavano 4."""
|
||||
prev, now = 1_788_698_101_000, 1_788_784_501_000 # 06/09 12:35Z -> 07/09 12:35Z
|
||||
c = _ClientFinto(_ordini(54, prev + 30_000_000))
|
||||
tot, n, motivo = W._trades_spot_da(c, prev, now)
|
||||
assert motivo is None and n == 54 and abs(tot - 5400.0) < 1e-9
|
||||
assert c.limit_chiesto == W.TRADE_LIMIT >= 54
|
||||
|
||||
|
||||
def test_lista_al_limite_del_venue_non_e_leggibile():
|
||||
prev, now = 1_788_698_101_000, 1_788_784_501_000
|
||||
c = _ClientFinto(_ordini(W.TRADE_LIMIT + 5, prev + 1000))
|
||||
tot, n, motivo = W._trades_spot_da(c, prev, now)
|
||||
assert tot is None and n is None and "troncata" in motivo
|
||||
# e il reward NON si attribuisce (P12)
|
||||
an = W.analizza({"eq_usde": 1000.0}, 1400.0, tot)
|
||||
assert an["reward_stimato"] is None and not an["reward_rilevato"]
|
||||
|
||||
|
||||
def test_lettura_oltre_la_finestra_del_gateway_non_e_leggibile():
|
||||
"""Giro saltato: 48h dall'ultima lettura. I trade fra 48h e 24h fa sono invisibili."""
|
||||
prev = 1_788_698_101_000
|
||||
now = prev + 48 * 3_600_000
|
||||
c = _ClientFinto(_ordini(3, now - 3_600_000))
|
||||
tot, n, motivo = W._trades_spot_da(c, prev, now)
|
||||
assert tot is None and "finestra" in motivo
|
||||
|
||||
|
||||
def test_la_cadenza_del_cron_sta_dentro_la_tolleranza():
|
||||
"""24h + qualche secondo (il cron delle 12:35 di due giorni consecutivi) e' coperta."""
|
||||
prev = 1_788_698_101_000
|
||||
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + 2_000) is None
|
||||
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + W.TOLL_FINESTRA_MS + 1) is not None
|
||||
|
||||
|
||||
def test_i_trade_prima_della_lettura_precedente_non_contano():
|
||||
prev, now = 1_788_698_101_000, 1_788_784_501_000
|
||||
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
|
||||
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
|
||||
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# issue #29 (B2) — un invio fallito non deve consumare la transizione (schema di cuscino_watch)
|
||||
# =============================================================================================
|
||||
|
||||
def test_notify_fallito_la_condizione_si_ripresenta_come_nuova():
|
||||
"""Il difetto: lo stato si salvava PRIMA dell'invio e il False di notify() non si leggeva ->
|
||||
un invio fallito restava 'gia' detto' per l'episodio intero. Qui: la condizione era attiva
|
||||
l'ultima volta ma NON e' fra le confermate (`allertati`) -> deve tornare 'nuova'."""
|
||||
att = {"QUOTA_OVER"}
|
||||
prev = dict(allertati=[]) # QUOTA_OVER era attivo, l'invio era FALLITO
|
||||
assert W.nuove_condizioni(att, att, prev) == {"QUOTA_OVER"}
|
||||
|
||||
|
||||
def test_notify_riuscito_non_si_ripete():
|
||||
att = {"QUOTA_OVER"}
|
||||
prev = dict(allertati=["QUOTA_OVER"]) # invio RIUSCITO l'ultima volta
|
||||
assert W.nuove_condizioni(att, att, prev) == set()
|
||||
|
||||
|
||||
def test_record_vecchio_senza_allertati_vale_come_tutto_allertato():
|
||||
"""Compatibilita': un record scritto prima di questo fix non ha il campo `allertati`. Non
|
||||
deve produrre un rispam retroattivo delle condizioni gia' viste."""
|
||||
att = {"QUOTA_OVER", "BLIND"}
|
||||
prev = dict(stato="OK") # nessun campo `allertati`
|
||||
assert W.nuove_condizioni(att, att, prev) == set()
|
||||
|
||||
|
||||
def test_senza_record_precedente_tutto_e_nuovo():
|
||||
att = {"QUOTA_OVER", "DEPEG_WARN"}
|
||||
assert W.nuove_condizioni(att, set(), None) == att
|
||||
|
||||
|
||||
def test_una_condizione_appena_comparsa_e_sempre_nuova():
|
||||
"""Anche con `allertati` presente, una condizione che non era in att_prev e' nuova per
|
||||
definizione — l'insieme confermato non la riguarda."""
|
||||
assert W.nuove_condizioni({"BLIND"}, set(), dict(allertati=["QUOTA_OVER"])) == {"BLIND"}
|
||||
|
||||
+12
-32
@@ -8,17 +8,17 @@ Cosa difendono, in ordine di gravita':
|
||||
da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta.
|
||||
2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto.
|
||||
3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5).
|
||||
4. **P9**: `atteso` declassa la gravita' SOLO su evidenza di manutenzione, e SOLO dentro lo slot.
|
||||
4. **`atteso` e' sempre False** (#35 B11): il ramo che lo declassava (finestra di release del
|
||||
martedi') e' stato rimosso perche' l'unico chiamante (cron :47) non ci cadeva mai dentro.
|
||||
"""
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose,
|
||||
errori_dal_report, in_release_window)
|
||||
errori_dal_report)
|
||||
|
||||
MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":'
|
||||
'{"message":"system_maintenance","code":11051}}')
|
||||
@@ -94,39 +94,19 @@ def test_la_prova_viaggia_sempre_con_il_verdetto():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. P9 — `atteso` declassa, ma solo su EVIDENZA e solo DENTRO lo slot.
|
||||
# 2. `atteso` e' sempre False (#35 B11) — nessun verdetto, manutenzione compresa, si declassa.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_finestra_di_release_martedi_nove_utc():
|
||||
dentro = datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc) # martedi', l'episodio vero
|
||||
assert in_release_window(dentro) is True
|
||||
assert in_release_window(datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)) is True
|
||||
assert in_release_window(datetime(2026, 8, 25, 9, 29, tzinfo=timezone.utc)) is True
|
||||
assert in_release_window(datetime(2026, 8, 25, 9, 30, tzinfo=timezone.utc)) is False # sforata
|
||||
assert in_release_window(datetime(2026, 8, 25, 8, 59, tzinfo=timezone.utc)) is False
|
||||
assert in_release_window(datetime(2026, 8, 26, 9, 7, tzinfo=timezone.utc)) is False # mercoledi'
|
||||
|
||||
|
||||
def test_manutenzione_dentro_lo_slot_e_attesa_e_declassa():
|
||||
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc),
|
||||
sonda=_sonda_manutenzione)
|
||||
assert d.verdetto == V_MANUTENZIONE and d.atteso is True
|
||||
assert d.gravita == "ℹ️" # declassato, NON silenziato
|
||||
assert "ATTESO" in d.riga()
|
||||
|
||||
|
||||
def test_manutenzione_che_sfora_lo_slot_torna_rumorosa():
|
||||
"""Il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora: alle 10:07 la
|
||||
manutenzione non era piu' l'evento annunciato, ed e' di nuovo una notizia."""
|
||||
d = diagnose([], now=datetime(2026, 8, 18, 10, 7, tzinfo=timezone.utc),
|
||||
sonda=_sonda_manutenzione)
|
||||
def test_manutenzione_non_si_declassa_piu_atteso_sempre_falso():
|
||||
"""Il ramo che declassava (finestra di release del martedi') e' rimosso: una manutenzione
|
||||
riconosciuta dalla sonda resta comunque `atteso is False` e alla gravita' piena."""
|
||||
d = diagnose([], sonda=_sonda_manutenzione)
|
||||
assert d.verdetto == V_MANUTENZIONE and d.atteso is False
|
||||
assert d.gravita != "ℹ️"
|
||||
assert d.gravita == "⚠️"
|
||||
assert "ATTESO" not in d.riga()
|
||||
|
||||
|
||||
def test_un_guasto_vero_di_martedi_mattina_non_viene_declassato():
|
||||
"""Il rischio della finestra: costruire il silenzio proprio nell'ora in cui serve. Un gateway
|
||||
rotto dentro lo slot di release deve restare al massimo della gravita'."""
|
||||
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc), sonda=_sonda_ok)
|
||||
def test_un_guasto_vero_non_e_mai_atteso():
|
||||
d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok)
|
||||
assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑"
|
||||
|
||||
|
||||
|
||||
@@ -499,3 +499,84 @@ def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
|
||||
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
|
||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
||||
assert "non tentato" in rep["invio"]
|
||||
|
||||
|
||||
# =============================================================================================
|
||||
# issue #31 — BLIND senza ALERT lasciava severity=None: il titolo del runner cadeva sul default
|
||||
# piu' forte ("PRIMO PASSO: prelievo di prova") anche per un episodio che e' "non vedo", non
|
||||
# "stress misurato". BLIND e' lo stato MORBIDO (docstring del modulo).
|
||||
# =============================================================================================
|
||||
|
||||
def test_severity_blind_quando_un_asset_e_cieco_senza_alert(monkeypatch, tmp_path):
|
||||
sp = tmp_path / "vw.json"
|
||||
|
||||
def fake():
|
||||
return {"ts": 1, "platform_locked": False, "locked_raw": "false", "maintenance": False,
|
||||
"assets": {a: dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None)
|
||||
for a in VW.ASSETS},
|
||||
"deribit_errors": []}
|
||||
monkeypatch.setattr(VW, "observe", fake)
|
||||
|
||||
rep = None
|
||||
for _ in range(VW.BLIND_ALERT_HOURS):
|
||||
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||
assert any(l == "BLIND" for l in rep["levels"].values())
|
||||
assert not any(l == "ALERT" for l in rep["levels"].values())
|
||||
assert rep["severity"] == "blind"
|
||||
|
||||
|
||||
def test_severity_alert_prevale_su_blind(monkeypatch, tmp_path):
|
||||
"""Se un asset e' cieco ma l'altro e' in ALERT vero, la gravita' massima vince (P9).
|
||||
|
||||
BTC resta senza referenze per tutto il giro (arriva a BLIND all'ora `BLIND_ALERT_HOURS`);
|
||||
ETH parte harmless e passa a un vero streak sopra soglia SOLO negli ultimi `PERSIST_HOURS`
|
||||
round, cosi' che il round in cui ETH scatta ALERT sia lo stesso in cui BTC e' gia' BLIND."""
|
||||
sp = tmp_path / "vw.json"
|
||||
letture = {"n": 0}
|
||||
soglia_eth = VW.BLIND_ALERT_HOURS - VW.PERSIST_HOURS # da qui in poi ETH costruisce lo streak
|
||||
|
||||
def fake():
|
||||
letture["n"] += 1
|
||||
eth_bps = VW.THRESHOLD_BPS * 3 if letture["n"] > soglia_eth else 1.0
|
||||
return {"ts": letture["n"], "platform_locked": False, "locked_raw": "false",
|
||||
"maintenance": False,
|
||||
"assets": {"BTC": dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None),
|
||||
"ETH": dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=eth_bps)},
|
||||
"deribit_errors": []}
|
||||
monkeypatch.setattr(VW, "observe", fake)
|
||||
|
||||
rep = None
|
||||
for _ in range(VW.BLIND_ALERT_HOURS):
|
||||
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
|
||||
assert rep["levels"]["BTC"] == "BLIND"
|
||||
assert rep["levels"]["ETH"] == "ALERT"
|
||||
assert rep["severity"] == "alert"
|
||||
|
||||
|
||||
def _carica_script_venue_watch():
|
||||
"""scripts/live/venue_watch.py (il runner) — via importlib, per non collidere col nome
|
||||
del modulo `src.live.venue_watch` gia' importato sopra come `VW`."""
|
||||
import importlib.util
|
||||
sp = importlib.util.spec_from_file_location(
|
||||
"venue_watch_script", ROOT / "scripts" / "live" / "venue_watch.py")
|
||||
m = importlib.util.module_from_spec(sp)
|
||||
sp.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_titolo_blind_non_dice_prelievo():
|
||||
SVW = _carica_script_venue_watch()
|
||||
t = SVW._titolo({"severity": "blind"})
|
||||
assert "prelievo" not in t.lower()
|
||||
assert "CIECO" in t
|
||||
|
||||
|
||||
def test_titolo_alert_dice_ancora_prelievo():
|
||||
SVW = _carica_script_venue_watch()
|
||||
assert "prelievo" in SVW._titolo({"severity": "alert"}).lower()
|
||||
assert "prelievo" in SVW._titolo({"severity": None}).lower() # default: nessuna gravita' letta
|
||||
|
||||
|
||||
def test_titolo_warn_resta_quello_di_prima():
|
||||
SVW = _carica_script_venue_watch()
|
||||
assert "Nessuna azione" in SVW._titolo({"severity": "warn"})
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""registra_giro nei sei sorveglianti generalizzati da cc01_regime_watch (2026-09-21, #16).
|
||||
|
||||
PERCHE' ESISTE. `cc01_regime_watch` e' morto 14 notti di fila (#10) senza che nessuno lo vedesse,
|
||||
perche' non scriveva niente da sorvegliare (`monitor_health` guarda solo le serie che i monitor
|
||||
scrivono). La riparazione di cc01 (`data/cc01_regime/runs.jsonl`, commit 31f5915,
|
||||
tests/test_cc01_regime_watch.py) e' il modello: qui si verifica che sia stata applicata agli
|
||||
altri sei sorveglianti giornalieri/orari, con lo stesso incrocio P1 con `monitor_health.MONITORS`
|
||||
e lo stesso doppio ramo (misura -> una riga, non-misura -> nessuna riga).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
||||
|
||||
from src.live.monitor_health import MONITORS # noqa: E402
|
||||
|
||||
import edge_watch as EW # noqa: E402
|
||||
import fee_watch as FW # noqa: E402
|
||||
import scale_watch as SW # noqa: E402
|
||||
|
||||
|
||||
def _carica(nome: str):
|
||||
sp = importlib.util.spec_from_file_location(nome, ROOT / "scripts" / "live" / f"{nome}.py")
|
||||
m = importlib.util.module_from_spec(sp)
|
||||
sp.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
VF = _carica("vrp_f_watch")
|
||||
VN = _carica("venue_news")
|
||||
VW = _carica("venue_watch")
|
||||
|
||||
|
||||
def _righe(path: Path) -> list[dict]:
|
||||
import json
|
||||
if not path.exists():
|
||||
return []
|
||||
return [json.loads(r) for r in path.read_text().splitlines() if r.strip()]
|
||||
|
||||
|
||||
def _spec(nome: str):
|
||||
s = next((m for m in MONITORS if m.name == nome), None)
|
||||
assert s is not None, f"{nome} non e' in monitor_health.MONITORS"
|
||||
return s
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# P1 — il bersaglio della sorveglianza si deriva dal codice sorvegliato
|
||||
# ===========================================================================
|
||||
@pytest.mark.parametrize("nome,modulo,cadence_h,max_age_h", [
|
||||
("edge_watch", EW, 24.0, 30.0),
|
||||
("fee_watch", FW, 24.0, 30.0),
|
||||
("scale_watch", SW, 24.0, 30.0),
|
||||
("vrp_f_watch", VF, 24.0, 30.0),
|
||||
("venue_news", VN, 24.0, 30.0),
|
||||
("venue_watch", VW, 1.0, 3.0),
|
||||
])
|
||||
def test_registrato_in_monitor_health_sul_file_che_scrive(nome, modulo, cadence_h, max_age_h):
|
||||
s = _spec(nome)
|
||||
assert ROOT / "data" / s.directory / s.series == modulo.RUNS
|
||||
assert s.open_labeled is False
|
||||
assert s.cadence_h == cadence_h
|
||||
assert s.max_age_h <= max_age_h
|
||||
|
||||
|
||||
def test_venue_watch_ha_la_cadenza_oraria_del_cron_book():
|
||||
"""⚠️ venue_watch gira OGNI ORA in cron_book.sh: max_age stretto, non i 30h dei giornalieri."""
|
||||
assert _spec("venue_watch").cadence_h == 1.0 and _spec("venue_watch").max_age_h == 3.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# edge_watch — dopo run(): sharpe, n_alert
|
||||
# ===========================================================================
|
||||
def test_edge_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(EW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["edge_watch.py", "--quiet"])
|
||||
monkeypatch.setattr(EW, "run", lambda: dict(sharpe=0.7, protection=[], consec_fail=0, alerts=[]))
|
||||
assert EW.main() == 0
|
||||
righe = _righe(runs)
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["sharpe"] == 0.7 and righe[0]["n_alert"] == 0
|
||||
assert isinstance(righe[0]["ts"], int)
|
||||
|
||||
|
||||
def test_edge_watch_run_che_fallisce_non_scrive(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(EW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["edge_watch.py", "--quiet"])
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("dati di mercato non leggibili")
|
||||
monkeypatch.setattr(EW, "run", boom)
|
||||
with pytest.raises(RuntimeError):
|
||||
EW.main()
|
||||
assert not runs.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# fee_watch — dopo save_state(): level
|
||||
# ===========================================================================
|
||||
def test_fee_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(FW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["fee_watch.py", "--quiet"])
|
||||
monkeypatch.setattr(FW, "run", lambda: dict(current={}, changes=[], worst_taker_bps=0.0,
|
||||
level="OK", reason="tutto in regola", first_read=True))
|
||||
monkeypatch.setattr(FW, "save_state", lambda cur: None)
|
||||
assert FW.main() == 0
|
||||
righe = _righe(runs)
|
||||
assert len(righe) == 1 and righe[0]["level"] == "OK"
|
||||
|
||||
|
||||
def test_fee_watch_run_che_fallisce_non_scrive(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(FW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["fee_watch.py", "--quiet"])
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("API Deribit irraggiungibile")
|
||||
monkeypatch.setattr(FW, "run", boom)
|
||||
with pytest.raises(RuntimeError):
|
||||
FW.main()
|
||||
assert not runs.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# scale_watch — dopo run_once(): stato
|
||||
# ===========================================================================
|
||||
_REP_SCALA = dict(stato="OK", scala_dichiarata=1.0, ladder="0.5/1/2", tetto=2.0,
|
||||
domande={}, fallback={"stato": "OK", "quota": 0.0, "fallback_veri": 0,
|
||||
"giri": 10, "soglia": 0.1, "paper": 0},
|
||||
lettore={"stato": "OK", "motivo": "ok"}, invio="non tentato")
|
||||
|
||||
|
||||
def test_scale_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(SW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["scale_watch.py", "--quiet", "--secco"])
|
||||
monkeypatch.setattr(SW, "run_once", lambda sender=None: dict(_REP_SCALA))
|
||||
SW.main()
|
||||
righe = _righe(runs)
|
||||
assert len(righe) == 1 and righe[0]["stato"] == "OK"
|
||||
|
||||
|
||||
def test_scale_watch_run_once_che_fallisce_non_scrive(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(SW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["scale_watch.py", "--quiet", "--secco"])
|
||||
|
||||
def boom(sender=None):
|
||||
raise RuntimeError("lettore di produzione irraggiungibile")
|
||||
monkeypatch.setattr(SW, "run_once", boom)
|
||||
with pytest.raises(RuntimeError):
|
||||
SW.main()
|
||||
assert not runs.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# vrp_f_watch — dopo _save_state(), SOLO se R non e' vuoto: pairs, ready
|
||||
# ===========================================================================
|
||||
def _frame_coppie(n: int = 45) -> pd.DataFrame:
|
||||
rows = []
|
||||
for i in range(n):
|
||||
exp = pd.Timestamp("2026-01-01") + pd.Timedelta(days=i)
|
||||
for name, v in (("canonico", 0.70), ("candidato", 0.80)):
|
||||
rows.append(dict(asset="BTC", struct=name, exp=exp, ts=pd.Timestamp("2026-01-01"),
|
||||
dte=7.0, f_short=1.0, f_long=2.0, f_net=v, f_net_mid=v,
|
||||
quota_lunga=0.18))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def test_vrp_f_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(VF, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["vrp_f_watch.py", "--quiet"])
|
||||
frame = _frame_coppie()
|
||||
monkeypatch.setattr(VF, "measure", lambda chain=None: frame)
|
||||
monkeypatch.setattr(VF, "_save_state", lambda d: None)
|
||||
monkeypatch.setattr(VF, "_load_state", lambda: {})
|
||||
monkeypatch.setattr(VF, "notify", lambda *a, **k: True)
|
||||
assert VF.main() == 0
|
||||
righe = _righe(runs)
|
||||
atteso = VF.assess(frame)
|
||||
assert len(righe) == 1
|
||||
assert righe[0]["pairs"] == atteso["pairs"] and righe[0]["ready"] == atteso["ready"]
|
||||
|
||||
|
||||
def test_vrp_f_watch_senza_osservazioni_non_scrive(tmp_path, monkeypatch):
|
||||
"""P5: R vuoto non e' un giro misurato — nessuna riga (a differenza di cc01, qui non
|
||||
c'e' nemmeno un `return` anticipato: la guardia sta nella condizione prima di registrare)."""
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(VF, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["vrp_f_watch.py", "--quiet"])
|
||||
monkeypatch.setattr(VF, "measure", lambda chain=None: pd.DataFrame())
|
||||
monkeypatch.setattr(VF, "_save_state", lambda d: None)
|
||||
monkeypatch.setattr(VF, "_load_state", lambda: {})
|
||||
assert VF.main() == 0
|
||||
assert not runs.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# venue_news — dopo la scrittura delle voci: voci, nuove. Feed illeggibile -> nessuna riga
|
||||
# ===========================================================================
|
||||
_FEED = """<rss><channel>
|
||||
<item><title>Deribit Welcomes Orbit Markets</title>
|
||||
<link>https://x/a</link><guid>https://x/?p=1</guid><pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate></item>
|
||||
<item><title>Another Unrelated Announcement</title>
|
||||
<link>https://x/b</link><guid>https://x/?p=2</guid><pubDate>Fri, 11 Jul 2026 00:00:00 +0000</pubDate></item>
|
||||
</channel></rss>"""
|
||||
|
||||
|
||||
def test_venue_news_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
state = tmp_path / "venue_news.jsonl"
|
||||
monkeypatch.setattr(VN, "RUNS", runs)
|
||||
monkeypatch.setattr(VN, "STATE", state)
|
||||
monkeypatch.setattr(VN, "scarica", lambda *a, **k: _FEED)
|
||||
monkeypatch.setattr(VN, "notify", lambda *a, **k: True)
|
||||
monkeypatch.setattr(sys, "argv", ["venue_news.py", "--quiet"])
|
||||
assert VN.main() == 0
|
||||
righe = _righe(runs)
|
||||
assert len(righe) == 1 and righe[0]["voci"] == 2 and righe[0]["nuove"] == 2
|
||||
|
||||
|
||||
def test_venue_news_feed_illeggibile_non_scrive(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(VN, "RUNS", runs)
|
||||
monkeypatch.setattr(VN, "STATE", tmp_path / "venue_news.jsonl")
|
||||
monkeypatch.setattr(VN, "scarica", lambda *a, **k: None)
|
||||
monkeypatch.setattr(VN, "notify", lambda *a, **k: True)
|
||||
monkeypatch.setattr(sys, "argv", ["venue_news.py", "--quiet"])
|
||||
assert VN.main() == 2
|
||||
assert not runs.exists()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# venue_watch — dopo run_once() e check_specs(): livelli (rep["levels"])
|
||||
# ===========================================================================
|
||||
def test_venue_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(VW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["venue_watch.py", "--quiet"])
|
||||
monkeypatch.setattr(VW, "run_once", lambda sender=None: dict(
|
||||
levels={"BTC": "OK", "ETH": "OK"}, alerts=[], platform_locked=False))
|
||||
monkeypatch.setattr(VW, "check_specs", lambda: dict(divergenze=[]))
|
||||
assert VW.main() == 0
|
||||
righe = _righe(runs)
|
||||
assert len(righe) == 1 and righe[0]["livelli"] == {"BTC": "OK", "ETH": "OK"}
|
||||
|
||||
|
||||
def test_venue_watch_run_once_che_fallisce_non_scrive(tmp_path, monkeypatch):
|
||||
runs = tmp_path / "runs.jsonl"
|
||||
monkeypatch.setattr(VW, "RUNS", runs)
|
||||
monkeypatch.setattr(sys, "argv", ["venue_watch.py", "--quiet"])
|
||||
|
||||
def boom(sender=None):
|
||||
raise RuntimeError("osservazione del consenso fallita")
|
||||
monkeypatch.setattr(VW, "run_once", boom)
|
||||
with pytest.raises(RuntimeError):
|
||||
VW.main()
|
||||
assert not runs.exists()
|
||||
Reference in New Issue
Block a user