Compare commits
44 Commits
4b04831352
..
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 |
@@ -81,6 +81,10 @@ data/chain_collect/
|
|||||||
data/vrp_f_watch/
|
data/vrp_f_watch/
|
||||||
# giri misurati del trigger di regime CC01 (letti da monitor_health, #12)
|
# giri misurati del trigger di regime CC01 (letti da monitor_health, #12)
|
||||||
data/cc01_regime/
|
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)
|
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
|
||||||
data/live/
|
data/live/
|
||||||
|
|||||||
@@ -72,10 +72,14 @@ scrive in un registro, **senza inviare**. Finestra dichiarata fino al **30/09/20
|
|||||||
`data/live/equity_seen.json`. Sui giri delle 18:17, 19:17 e 20:44 del 16/09 **nessun file** di
|
`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).
|
`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
|
- **perche' non invia**: `book_ombra.py` non contiene il percorso d'invio, e
|
||||||
`TradingBook/tests/test_ombra_non_invia.py` lo verifica sul sorgente. 🚨 **La garanzia e' il CODICE,
|
`TradingBook/tests/test_ombra_non_invia.py` lo verifica sul sorgente. ✅ **Dal 21/09 anche il
|
||||||
non il permesso**: la chiave puo' fare trading. Una chiave Deribit di **sola lettura** per l'ombra
|
PERMESSO** (TradingBook#19): il gateway chiede a Deribit un token con scope
|
||||||
la crea l'operatore; finche' non c'e', qualunque script di TradingBook con un percorso d'invio
|
`account:read trade:read wallet:none block_trade:none block_rfq:none custody:none` e rifiuta il token
|
||||||
opererebbe con soldi veri.
|
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.
|
- **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
|
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
|
11051, due BUY calcolati sul paper capital nel registro d'ombra); da TradingBook#16 un conto o una
|
||||||
@@ -111,9 +115,9 @@ fino al 31,8%*, che dimostra ≥, non =. Non spiegato (D5): puo' tornare. `venue
|
|||||||
`quota_max_frac` → **0,85** (il valore che l'operatore aveva scelto il 30/08 per questo scenario).
|
`quota_max_frac` → **0,85** (il valore che l'operatore aveva scelto il 30/08 per questo scenario).
|
||||||
✅ **Il cuscino USDC e' sorvegliato dal 10/09** (`cuscino_watch`, §5.17): una perdita del libro (che si regola in USDC)
|
✅ **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
|
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 si
|
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`
|
**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 50%, soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta
|
(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
|
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`
|
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%"
|
leggeva SOLO il conto USDC — la prima conversione avrebbe causato un falso "USCITA DI FONDI −24%"
|
||||||
@@ -277,7 +281,7 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure.
|
|||||||
| **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
|
| **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
|
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) |
|
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) |
|
| **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 |
|
| **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 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% |
|
||||||
|
|||||||
@@ -35,10 +35,16 @@ mkdir -p logs
|
|||||||
# partire anche quando book_execute fallisce per la stessa ragione. Non blocca (exit 2
|
# 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.
|
# = 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/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
|
# 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.
|
# 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
|
uv run python scripts/live/trades_db.py --sync --quiet || true
|
||||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
} >> logs/cron_book.log 2>&1
|
} >> logs/cron_book.log 2>&1
|
||||||
|
|||||||
@@ -5,11 +5,19 @@
|
|||||||
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
|
# 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
|
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
|
||||||
# stesso del libro (execution_enabled in config/live.json).
|
# 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"
|
export PATH="/home/adriano/.local/bin:$PATH"
|
||||||
cd /opt/docker/PythagorasGoal || exit 1
|
cd /opt/docker/PythagorasGoal || exit 1
|
||||||
mkdir -p logs
|
mkdir -p logs
|
||||||
{
|
{
|
||||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
|
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
|
||||||
uv run python scripts/live/cuscino_watch.py --quiet || true
|
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') ====="
|
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||||
} >> logs/cron_cuscino.log 2>&1
|
} >> 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,
|
def scrivi(con, g: date, modello: str, secco: bool = False, verbose: bool = True,
|
||||||
telegram: bool = True) -> str:
|
telegram: bool = True) -> str:
|
||||||
voce = J.costruisci(con, g)
|
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)
|
pagina = J.rendi_markdown(con, voce)
|
||||||
st = storico(con, g)
|
st = storico(con, g)
|
||||||
prompt = A.costruisci_prompt(pagina, st)
|
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))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
from src.live.book import ScalaNonAutorizzata, book_report
|
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.notifier import notify
|
||||||
from src.live.venue_probe import diagnose, errori_dal_report
|
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")
|
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():
|
def _run():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
want_execute = "--execute" in sys.argv[1:]
|
want_execute = "--execute" in sys.argv[1:]
|
||||||
@@ -129,8 +156,10 @@ def _run():
|
|||||||
print(f" ultima barra : {r['last_data']}\n")
|
print(f" ultima barra : {r['last_data']}\n")
|
||||||
|
|
||||||
if r.get("skh_error"): # SKH feed fallito -> book.py ha forzato flat IN SILENZIO
|
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']}")
|
# (#22) Non e' piu' "SKH forzato flat!": book_execute non manda PIU' ordini basati su
|
||||||
notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]})
|
# 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) --------------------------
|
# --- 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
|
# `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"})
|
"azione": "rebuild_history.py --asset BTC ETH"})
|
||||||
return
|
return
|
||||||
|
|
||||||
if r.get("eq_fallback"): # equity reale non leggibile -> sizing su paper_cap
|
if r.get("eq_fallback"): # equity reale non leggibile -> niente sizing sul conto
|
||||||
print(f" ⚠️ EQUITY FALLBACK (sizing su paper_cap, NON blocco): {r['eq_fallback']}")
|
# (#21) qui solo il print: il notify e' nel blocco SOLO PROTEZIONE sotto, che porta anche
|
||||||
if do_execute: # solo diagnostica: l'hard-cap $/asset limita il downside
|
# questo errore su Telegram — un solo avviso per lo stesso guasto, non due.
|
||||||
notify("⚠️ BOOK LIVE — equity fallback (sizing su paper_cap)", {"nota": r["eq_fallback"]})
|
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
|
# 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
|
# 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.
|
# non deve togliere la rete di sicurezza all'altro.
|
||||||
falliti: list[str] = []
|
falliti: list[str] = []
|
||||||
scoperti: list[str] = []
|
scoperti: list[str] = []
|
||||||
|
non_verificati: list[str] = []
|
||||||
for a in r["assets"]:
|
for a in r["assets"]:
|
||||||
asset, inst = a["asset"], a["instrument"]
|
asset, inst = a["asset"], a["instrument"]
|
||||||
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
|
||||||
sk = a["skh_state"]
|
sk = a["skh_state"]
|
||||||
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
sk_txt = "flat" if sk == "flat" else f"{sk['dir']}@{sk.get('entry')}"
|
||||||
order = a["order"]
|
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)"
|
act = "HOLD (a target)"
|
||||||
elif order.get("is_close"):
|
elif order.get("is_close"):
|
||||||
act = f"CLOSE ${cur:,.0f}"
|
act = f"CLOSE ${cur:,.0f}"
|
||||||
@@ -268,26 +319,37 @@ def _run():
|
|||||||
f"| pos ${cur:+,.0f} -> {act}")
|
f"| pos ${cur:+,.0f} -> {act}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if do_execute and order is not None:
|
if do_execute and order is not None and not solo_protezione:
|
||||||
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
|
# (#18) tetto di apertura derivato dal cap di config del libro. Se cap_per_asset*scala
|
||||||
newpos = trader.position_usd(inst)
|
# 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:
|
for f in fills:
|
||||||
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
|
_registra_fill(f, asset=asset, act=act, net=net, bar_ts=r["last_data"],
|
||||||
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
|
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"], pos_after=pos_after)
|
||||||
# `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}")
|
|
||||||
if do_execute:
|
if do_execute:
|
||||||
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
|
||||||
stato = ds.get("state")
|
stato = ds.get("state")
|
||||||
@@ -308,6 +370,15 @@ def _run():
|
|||||||
"amount": round(ds.get("amount") or 0, 4),
|
"amount": round(ds.get("amount") or 0, 4),
|
||||||
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
|
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
|
||||||
"notes": str(ds.get("notes"))[:300]})
|
"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
|
except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare
|
||||||
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
|
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
|
||||||
falliti.append(asset)
|
falliti.append(asset)
|
||||||
@@ -327,16 +398,27 @@ def _run():
|
|||||||
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
|
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
|
||||||
if falliti:
|
if falliti:
|
||||||
print(f" 🛑 asset falliti in questo giro: {', '.join(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:
|
if not do_execute:
|
||||||
print(" => DRY-RUN: nessun ordine inviato." +
|
print(" => DRY-RUN: nessun ordine inviato." +
|
||||||
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
|
("" 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):
|
elif all(x.startswith("HOLD") for x in actions):
|
||||||
print(" => Nessuna azione: conto gia' al target netto del book.")
|
print(" => Nessuna azione: conto gia' al target netto del book.")
|
||||||
else:
|
else:
|
||||||
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
|
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
|
# 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`).
|
# 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)
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
|
|||||||
# dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️).
|
# 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
|
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
|
RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno
|
||||||
TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min
|
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]:
|
def leggi(path: Path = STATE) -> list[dict]:
|
||||||
@@ -158,17 +160,56 @@ def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: b
|
|||||||
return True, ""
|
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:
|
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)."""
|
"""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"]
|
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
|
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:]
|
coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
|
||||||
return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:]))
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:]))
|
rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"]
|
||||||
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
|
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
|
||||||
return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:]))
|
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():
|
def _safe_client():
|
||||||
@@ -239,17 +280,23 @@ def main() -> int:
|
|||||||
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota,
|
rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota,
|
||||||
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})")
|
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})")
|
||||||
else:
|
else:
|
||||||
esito = riconverti(quota)
|
ris = riconverti(quota)
|
||||||
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **esito)
|
rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **ris)
|
||||||
|
|
||||||
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
|
# --- 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
|
# ⚠️ 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 '<'.
|
# primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'.
|
||||||
def _esito_azione(r: dict) -> str:
|
def _esito_azione(r: dict) -> str:
|
||||||
if r.get("tentata"):
|
if not r.get("tentata"):
|
||||||
return (f"{r['verso']} " + ("ESEGUITA" if r.get("ok") else "FALLITA")
|
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
|
||||||
+ f" (quota bersaglio {r['quota']:.0%})")
|
esito = r.get("esito", "ok" if r.get("ok") else "fallita")
|
||||||
return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}"
|
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 transizione(prev, stato):
|
||||||
if secco:
|
if secco:
|
||||||
@@ -275,7 +322,9 @@ def main() -> int:
|
|||||||
elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
|
elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
|
||||||
# non e' una transizione ma e' un ordine mandato: si dice sempre
|
# non e' una transizione ma e' un ordine mandato: si dice sempre
|
||||||
r = rec["riconversione"]
|
r = rec["riconversione"]
|
||||||
rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} " + ("eseguita" if r["ok"] else "FALLITA"),
|
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:])})
|
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
|
||||||
if rec["allertato"] is False and not secco:
|
if rec["allertato"] is False and not secco:
|
||||||
from src.live.notifier import ultimo_errore
|
from src.live.notifier import ultimo_errore
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ import pandas as pd
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT))
|
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.notifier import notify # noqa: E402
|
||||||
from src.portfolio.portfolio import combine_outer # noqa: E402
|
from src.portfolio.portfolio import combine_outer # noqa: E402
|
||||||
from src.portfolio.sleeves import deribit_book_sleeves, tp01_sleeve # 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) ---
|
# --- soglie CONGELATE (cambiarle invalida la taratura di r0726_edge_death.py) ---
|
||||||
WIN_MONTHS = 36
|
WIN_MONTHS = 36
|
||||||
SHARPE_KILL = -0.5
|
SHARPE_KILL = -0.5
|
||||||
@@ -119,6 +122,7 @@ def run() -> dict:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
r = run()
|
r = run()
|
||||||
|
registra_giro(RUNS, sharpe=r["sharpe"], n_alert=len(r["alerts"]))
|
||||||
if "--quiet" not in sys.argv:
|
if "--quiet" not in sys.argv:
|
||||||
print("=" * 78)
|
print("=" * 78)
|
||||||
print(" EDGE WATCH — il book live sta ancora funzionando?")
|
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 · "
|
print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · "
|
||||||
f"fallimenti consecutivi in corso: {r['consec_fail']}")
|
f"fallimenti consecutivi in corso: {r['consec_fail']}")
|
||||||
if r["alerts"]:
|
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"])})
|
{f"alert {i+1}": a for i, a in enumerate(r["alerts"])})
|
||||||
for a in r["alerts"]:
|
for a in r["alerts"]:
|
||||||
print(f" ALERT: {a}")
|
print(f" ALERT: {a}")
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from src.live.book import INSTRUMENT as BOOK_INSTRUMENT # noqa: E402
|
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
|
from src.live.notifier import notify # noqa: E402
|
||||||
|
|
||||||
STATE = ROOT / "data" / "fee_watch" / "state.json"
|
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
|
# ⚠️ 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).
|
# 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
|
# 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
|
SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01
|
||||||
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
|
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
|
||||||
D_CAGR_PER_BPS = -0.0023
|
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:
|
def fetch_fees(instrument: str) -> dict:
|
||||||
@@ -207,14 +210,30 @@ def run() -> dict:
|
|||||||
changes = diff_vs(prev, cur)
|
changes = diff_vs(prev, cur)
|
||||||
worst = max(c["taker_bps"] for c in cur.values())
|
worst = max(c["taker_bps"] for c in cur.values())
|
||||||
lvl, why = verdict(worst)
|
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,
|
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:
|
def main() -> int:
|
||||||
r = run()
|
r = run()
|
||||||
quiet = "--quiet" in sys.argv
|
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:
|
if not quiet or interessante:
|
||||||
print("=" * 86)
|
print("=" * 86)
|
||||||
@@ -230,7 +249,7 @@ def main() -> int:
|
|||||||
print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
|
print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
|
||||||
for c in r["changes"]:
|
for c in r["changes"]:
|
||||||
print(f" CAMBIO: {c}")
|
print(f" CAMBIO: {c}")
|
||||||
real = realized_fee_bps()
|
real = r.get("realized") # calcolato una volta in run(), non piu' qui (#37 B9)
|
||||||
if real:
|
if real:
|
||||||
print("\n cross-check sui trade REALI del conto (fonte autorevole):")
|
print("\n cross-check sui trade REALI del conto (fonte autorevole):")
|
||||||
for ins, bps in real.items():
|
for ins, bps in real.items():
|
||||||
@@ -238,17 +257,49 @@ def main() -> int:
|
|||||||
nota = ""
|
nota = ""
|
||||||
if tier is not None:
|
if tier is not None:
|
||||||
d = bps - tier
|
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}")
|
print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}")
|
||||||
else:
|
else:
|
||||||
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
||||||
|
|
||||||
if r["changes"] or r["level"] == "AZIONE":
|
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
|
||||||
notify("💸 FEE WATCH — schema fee Deribit", {
|
# 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']}",
|
"verdetto": f"[{r['level']}] {r['reason']}",
|
||||||
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
**{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
|
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 # report
|
||||||
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT))
|
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
|
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:
|
def main() -> int:
|
||||||
|
if "--solo-orari" in sys.argv:
|
||||||
|
return main_orari()
|
||||||
|
|
||||||
rows = check_all(ROOT)
|
rows = check_all(ROOT)
|
||||||
alerts = alerts_from(rows)
|
alerts = alerts_from(rows)
|
||||||
|
|
||||||
@@ -55,15 +126,17 @@ def main() -> int:
|
|||||||
return 0
|
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.
|
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 # report
|
||||||
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
|
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."""
|
Dettaglio nel docstring in testa al file."""
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from src.live.cli import valida
|
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())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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.notifier import notify # noqa: E402
|
||||||
from src.live.scale_watch import ALLARME, OK, run_once # 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):
|
def _sender(testo: str):
|
||||||
try:
|
try:
|
||||||
@@ -31,6 +35,7 @@ def main() -> None:
|
|||||||
secco = "--secco" in sys.argv
|
secco = "--secco" in sys.argv
|
||||||
quiet = "--quiet" in sys.argv
|
quiet = "--quiet" in sys.argv
|
||||||
rep = run_once(sender=None if secco else _sender)
|
rep = run_once(sender=None if secco else _sender)
|
||||||
|
registra_giro(RUNS, stato=rep["stato"])
|
||||||
icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️")
|
icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️")
|
||||||
if quiet:
|
if quiet:
|
||||||
fb = rep["fallback"]
|
fb = rep["fallback"]
|
||||||
|
|||||||
@@ -25,17 +25,63 @@ import pandas as pd
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from src.live.monitor_health import MONITORS
|
||||||
from src.live.notifier import send
|
from src.live.notifier import send
|
||||||
|
|
||||||
HORIZONS = (30, 90, 180)
|
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:
|
def _fmt_usd(x: float) -> str:
|
||||||
return f"${x:,.2f}"
|
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]:
|
def trend_state() -> list[str]:
|
||||||
"""Stato TSMOM per asset + quanto manca all'accensione (2 orizzonti su 3 positivi).
|
"""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)
|
tgt = max(0.0, m)
|
||||||
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
|
line = f" {a} {px:,.0f} · segni " + "/".join(f"{sg[h]:+d}" for h in HORIZONS) + \
|
||||||
f" → TP01 {tgt:.2f}"
|
f" → TP01 {tgt:.2f}"
|
||||||
neg = [(h, refs[h]) for h in HORIZONS if sg[h] < 0]
|
if tgt == 0.0:
|
||||||
if neg and tgt == 0.0:
|
r = _soglia_accensione(list(sg.values()), [refs[h] for h in HORIZONS if sg[h] < 0])
|
||||||
h, r = min(neg, key=lambda t: t[1])
|
if r is not None:
|
||||||
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
line += f"\n accensione a {r:,.0f} ({(r/px-1)*100:+.1f}%)"
|
||||||
out.append(line)
|
out.append(line)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
|
out.append(f" {a}: stato trend non calcolabile ({type(e).__name__})")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def book_state() -> list[str]:
|
def book_state() -> tuple[list[str], bool | None]:
|
||||||
"""Conto reale, posizioni e target netto correnti. Sola lettura."""
|
"""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:
|
try:
|
||||||
from src.live.book import book_report
|
from src.live.book import book_report
|
||||||
r = book_report()
|
r = book_report()
|
||||||
eq = r.get("equity")
|
eq = r.get("equity")
|
||||||
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
|
lines = [f" equity {_fmt_usd(eq)}" if eq else " equity non leggibile"]
|
||||||
|
flat = True
|
||||||
for a in r.get("assets", []):
|
for a in r.get("assets", []):
|
||||||
pos, net = a.get("position_usd", 0.0), a.get("net_target", 0.0)
|
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})")
|
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"):
|
if r.get("skh_error"):
|
||||||
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
|
lines.append(f" ⚠️ SKH feed KO: {r['skh_error']}")
|
||||||
return lines
|
return lines, flat
|
||||||
except Exception as e:
|
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]:
|
def monitors() -> list[str]:
|
||||||
@@ -92,20 +157,21 @@ def monitors() -> list[str]:
|
|||||||
for name, dec, path, key in GATES:
|
for name, dec, path, key in GATES:
|
||||||
p = ROOT / path
|
p = ROOT / path
|
||||||
giorni = (dec - date.today()).days
|
giorni = (dec - date.today()).days
|
||||||
|
gate_txt = f"gate {dec} ({_fmt_giorni(giorni)})"
|
||||||
if not p.exists():
|
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
|
continue
|
||||||
try:
|
try:
|
||||||
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
rows = [json.loads(x) for x in p.read_text().splitlines() if x.strip()]
|
||||||
if not rows:
|
if not rows:
|
||||||
out.append(f" {name}: 0 barre · gate {dec} (−{giorni}g)")
|
out.append(f" {name}: 0 barre · {gate_txt}")
|
||||||
continue
|
continue
|
||||||
r = np.array([x[key] for x in rows], float)
|
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
|
sh = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
|
||||||
tot = float(np.prod(1 + r) - 1) * 100
|
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:
|
except Exception:
|
||||||
out.append(f" {name}: log illeggibile · gate {dec} (−{giorni}g)")
|
out.append(f" {name}: log illeggibile · {gate_txt}")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -129,8 +195,13 @@ def last_trade() -> str:
|
|||||||
def build() -> str:
|
def build() -> str:
|
||||||
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
|
oggi = datetime.now(timezone.utc).strftime("%d/%m %H:%M UTC")
|
||||||
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
|
L = [f"📊 <b>Book Deribit — {oggi}</b>", "", "<b>Conto</b>"]
|
||||||
L += book_state()
|
righe_conto, flat = book_state()
|
||||||
L += ["", "<b>Perche' non opera</b>"]
|
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 += trend_state()
|
||||||
L += ["", "<b>Ordini</b>", last_trade()]
|
L += ["", "<b>Ordini</b>", last_trade()]
|
||||||
L += ["", "<b>Monitor in osservazione</b>"]
|
L += ["", "<b>Monitor in osservazione</b>"]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from src.live import tradesdb as T # noqa: E402
|
from src.live import tradesdb as T # noqa: E402
|
||||||
|
from src.live.notifier import notify # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def sync(verbose: bool = True) -> dict:
|
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]
|
fills = [f for r in runs for f in r.fills]
|
||||||
n_f = T.upsert_fills(con, 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])
|
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)
|
n_r = T.rebuild_roundtrips(con)
|
||||||
if runs:
|
if runs:
|
||||||
T.set_meta(con, "ultimo_giro", runs[-1].ts_utc)
|
T.set_meta(con, "ultimo_giro", runs[-1].ts_utc)
|
||||||
T.set_meta(con, "ultimo_sync", T.ora())
|
T.set_meta(con, "ultimo_sync", T.ora())
|
||||||
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
|
tot = con.execute("SELECT COUNT(*) c FROM fills").fetchone()["c"]
|
||||||
tot_e = con.execute("SELECT COUNT(*) c FROM equity").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:
|
if verbose:
|
||||||
print(f" fills: +{n_f} (totale {tot}) | equity: +{n_e} (totale {tot_e}) | round-trip {n_r}")
|
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()
|
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:
|
def reconcile() -> None:
|
||||||
@@ -42,8 +78,8 @@ def reconcile() -> None:
|
|||||||
fills = [f for r in runs for f in r.fills]
|
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 []
|
righe = T.parse_executions_jsonl(T.EXEC_JSONL.read_text()) if T.EXEC_JSONL.exists() else []
|
||||||
rec = T.reconcile(fills, righe)
|
rec = T.reconcile(fills, righe)
|
||||||
print(f"\n cron_book.log : {len(fills)} fill (con ORA VERA)")
|
print(f"\n cron_book.log : {len(fills)} fill (ora di inizio giro)")
|
||||||
print(f" book_executions : {len(righe)} righe (senza ora)")
|
print(f" book_executions : {len(righe)} righe (ora VERA per-fill dal 24/08, #37 C11)")
|
||||||
print(f" concordano : {len(rec['ok'])}")
|
print(f" concordano : {len(rec['ok'])}")
|
||||||
print(f" solo nel log : {len(rec['solo_log'])}")
|
print(f" solo nel log : {len(rec['solo_log'])}")
|
||||||
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
|
print(f" solo nel jsonl : {len(rec['solo_jsonl'])}")
|
||||||
@@ -125,7 +161,9 @@ def report() -> None:
|
|||||||
if ap:
|
if ap:
|
||||||
print(" posizioni aperte :")
|
print(" posizioni aperte :")
|
||||||
for a, d in sorted(ap.items()):
|
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:
|
else:
|
||||||
print(" posizioni aperte : nessuna (flat)")
|
print(" posizioni aperte : nessuna (flat)")
|
||||||
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
print(f" ultimo sync : {T.get_meta(con, 'ultimo_sync', 'mai')}")
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa
|
|||||||
|
|
||||||
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 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch)
|
||||||
uv run python scripts/live/usde_convert.py --quota 0.64 --esegui # manda l'ordine
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -93,6 +105,59 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
|
|||||||
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
||||||
cuscino_richiesto_usd = U.cuscino_richiesto_usd
|
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]:
|
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
|
||||||
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
|
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
|
||||||
@@ -232,6 +297,9 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
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
|
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
||||||
@@ -274,6 +342,18 @@ def main() -> int:
|
|||||||
"amount": amt, "filled": filled})
|
"amount": amt, "filled": filled})
|
||||||
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
||||||
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
|
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:
|
if filled <= 0:
|
||||||
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
||||||
time.sleep(a.pausa)
|
time.sleep(a.pausa)
|
||||||
|
|||||||
+51
-50
@@ -58,12 +58,6 @@ 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
|
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
|
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
|
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 -----------------
|
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
|
||||||
@@ -162,13 +156,6 @@ def rendimento(records: list[dict]) -> dict:
|
|||||||
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
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]:
|
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
||||||
"""PURA. Condizioni di allerta attive su un record."""
|
"""PURA. Condizioni di allerta attive su un record."""
|
||||||
if not rec:
|
if not rec:
|
||||||
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
|
|||||||
return att
|
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():
|
def _safe_client():
|
||||||
try:
|
try:
|
||||||
from src.live.deribit import DeribitRead
|
from src.live.deribit import DeribitRead
|
||||||
@@ -284,54 +285,58 @@ def main() -> int:
|
|||||||
v, v_motivo = verdetto(records + [rec], now)
|
v, v_motivo = verdetto(records + [rec], now)
|
||||||
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
||||||
|
|
||||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
|
||||||
with STATE.open("a") as fh:
|
# issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
|
||||||
fh.write(json.dumps(rec) + "\n")
|
# 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
|
||||||
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit ---
|
# si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
|
||||||
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
|
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 = []
|
inviati = []
|
||||||
|
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
|
||||||
if "DEPEG_CRIT" in att_now:
|
if "DEPEG_CRIT" in att_now:
|
||||||
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
||||||
"collaterale": f"${usd:,.0f}" if usd else "?",
|
"collaterale": f"${usd:,.0f}" if usd else "?",
|
||||||
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
||||||
inviati.append("DEPEG_CRIT")
|
inviati.append("DEPEG_CRIT")
|
||||||
|
if ok:
|
||||||
|
allertati.add("DEPEG_CRIT")
|
||||||
if "DEPEG_WARN" in nuove:
|
if "DEPEG_WARN" in nuove:
|
||||||
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
||||||
"nota": "entra gia' nel sizing orario del book"})
|
"nota": "entra gia' nel sizing orario del book"})
|
||||||
inviati.append("DEPEG_WARN")
|
inviati.append("DEPEG_WARN")
|
||||||
|
if ok:
|
||||||
|
allertati.add("DEPEG_WARN")
|
||||||
if "QUOTA_OVER" in nuove:
|
if "QUOTA_OVER" in nuove:
|
||||||
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
||||||
"tetto": f"{c['quota_max_frac']:.0%}",
|
"tetto": f"{c['quota_max_frac']:.0%}",
|
||||||
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
||||||
inviati.append("QUOTA_OVER")
|
inviati.append("QUOTA_OVER")
|
||||||
|
if ok:
|
||||||
|
allertati.add("QUOTA_OVER")
|
||||||
if "BLIND" in nuove:
|
if "BLIND" in nuove:
|
||||||
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
||||||
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
||||||
inviati.append("BLIND")
|
inviati.append("BLIND")
|
||||||
|
if ok:
|
||||||
|
allertati.add("BLIND")
|
||||||
|
rec["allertati"] = sorted(allertati)
|
||||||
|
|
||||||
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
||||||
if v != v_prev:
|
if v != v_prev:
|
||||||
if v == "IDONEO":
|
if v == "IDONEO":
|
||||||
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
|
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":
|
elif v == "NON_IDONEO":
|
||||||
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
||||||
inviati.append(f"VERDETTO->{v}")
|
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])
|
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"
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
|
with STATE.open("a") as fh:
|
||||||
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
|
fh.write(json.dumps(rec) + "\n")
|
||||||
"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")
|
|
||||||
|
|
||||||
cambiato = bool(inviati) or v != v_prev
|
cambiato = bool(inviati) or v != v_prev
|
||||||
if not quiet or cambiato:
|
if not quiet or cambiato:
|
||||||
@@ -344,14 +349,12 @@ def main() -> int:
|
|||||||
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
|
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")
|
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
|
||||||
if quota is not None:
|
if quota is not None:
|
||||||
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
|
# Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
|
||||||
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
|
# dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
|
||||||
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
|
# NON e' piu' il Segregated Standard Margin di prima di quella data.
|
||||||
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
|
|
||||||
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
||||||
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
|
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"
|
print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
|
||||||
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
|
|
||||||
f" {c['haircut']:.0%})")
|
f" {c['haircut']:.0%})")
|
||||||
if an["delta"] is not None:
|
if an["delta"] is not None:
|
||||||
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
|
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
|
||||||
@@ -366,8 +369,6 @@ def main() -> int:
|
|||||||
f" (tot {rend['tot']:+.6f} USDE)")
|
f" (tot {rend['tot']:+.6f} USDE)")
|
||||||
if rend["finestre"] < 4:
|
if rend["finestre"] < 4:
|
||||||
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
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:
|
if inviati:
|
||||||
print(f" allarmi : {', '.join(inviati)}")
|
print(f" allarmi : {', '.join(inviati)}")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT))
|
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.notifier import notify # noqa: E402
|
||||||
|
|
||||||
FEED = "https://insights.deribit.com/exchange-updates/feed/"
|
FEED = "https://insights.deribit.com/exchange-updates/feed/"
|
||||||
STATE = ROOT / "data" / "live" / "venue_news.jsonl"
|
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
|
# 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.
|
# per costruzione); le altre si DERIVANO dal codice, sotto.
|
||||||
@@ -140,6 +142,7 @@ def main() -> int:
|
|||||||
v["seed"] = primo_giro
|
v["seed"] = primo_giro
|
||||||
f.write(json.dumps(v) + "\n")
|
f.write(json.dumps(v) + "\n")
|
||||||
nuove.append(v)
|
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
|
# 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).
|
# 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))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from src.live.deribit import check_specs # noqa: E402
|
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.notifier import notify, ultimo_errore # noqa: E402
|
||||||
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
|
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
|
||||||
run_once)
|
run_once)
|
||||||
|
|
||||||
|
RUNS = ROOT / "data" / "venue_watch" / "runs.jsonl"
|
||||||
|
|
||||||
|
|
||||||
def _titolo(rep: dict) -> str:
|
def _titolo(rep: dict) -> str:
|
||||||
"""⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
|
"""⚠️ 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
|
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
|
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."""
|
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":
|
if rep.get("severity") == "warn":
|
||||||
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||||
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
|
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
|
# 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.
|
# niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni.
|
||||||
spec = check_specs()
|
spec = check_specs()
|
||||||
|
registra_giro(RUNS, livelli=rep["levels"])
|
||||||
if spec["divergenze"]:
|
if spec["divergenze"]:
|
||||||
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
|
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
|
||||||
righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / "
|
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
|
from cblib import ( # noqa: E402
|
||||||
dvol_series, f_factors, load_chain, pick_legs, puts, spot_series,
|
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
|
from src.live.notifier import notify # noqa: E402
|
||||||
|
|
||||||
STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json"
|
STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json"
|
||||||
|
RUNS = PROJECT_ROOT / "data" / "vrp_f_watch" / "runs.jsonl"
|
||||||
|
|
||||||
# --- config CONGELATA il 2026-07-30 ---------------------------------------
|
# --- config CONGELATA il 2026-07-30 ---------------------------------------
|
||||||
STRUCTS = {
|
STRUCTS = {
|
||||||
@@ -190,6 +192,8 @@ def main() -> int:
|
|||||||
a["notified"] = bool(prev.get("notified", False))
|
a["notified"] = bool(prev.get("notified", False))
|
||||||
|
|
||||||
_save_state(a)
|
_save_state(a)
|
||||||
|
if not R.empty:
|
||||||
|
registra_giro(RUNS, pairs=a["pairs"], ready=a["ready"])
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-8
@@ -58,9 +58,13 @@ REGOLE VINCOLANTI:
|
|||||||
5. Se noti qualcosa che merita un controllo umano, dillo in una frase, senza drammatizzare.
|
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.
|
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
|
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.
|
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
|
""" % MAX_PAROLE
|
||||||
|
|
||||||
|
|
||||||
@@ -202,9 +206,22 @@ def testata(voce: dict) -> str:
|
|||||||
p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
|
p, lb = voce.get("pnl") or {}, voce.get("libro") or {}
|
||||||
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
|
dq, cum = p.get("delta_equity"), p.get("cumulato_da_arming")
|
||||||
eq = p.get("equity_fine")
|
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 = [f"<b>📓 Giornale — {_b(voce['giorno'])}</b>"]
|
||||||
r.append(" equity " + (f"<b>${eq:,.2f}</b>" if eq is not None else "n/d")
|
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}"
|
+ ("" if cum is None else f" · dall'arming {cum:+.2f}"
|
||||||
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
|
+ (f" (di cui {p['mov_arming']:+.2f} versati -> trading "
|
||||||
f"{p['trading_da_arming']:+.2f})"
|
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)
|
testo = testata(voce)
|
||||||
if analisi.strip():
|
if analisi.strip():
|
||||||
corpo = _b(analisi.strip())
|
grezzo = analisi.strip()
|
||||||
corpo = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", corpo) # il grassetto del giornale
|
|
||||||
if stato == "sospetta":
|
if stato == "sospetta":
|
||||||
testo += "\n\n⚠️ <i>controllo sui numeri: sospetta — " + _b("; ".join(motivi or [])) + "</i>"
|
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:
|
else:
|
||||||
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
|
testo += ("\n\n⚠️ <i>analisi non disponibile (" + _b(stato) + "): "
|
||||||
+ _b("; ".join(motivi or ["motivo non registrato"])) + "</i>")
|
+ _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
|
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
|
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.
|
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
|
Formula (preserva il budget di rischio per-asset di config (CAP sotto: `max_notional_per_asset_usd`
|
||||||
deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01):
|
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 )
|
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
|
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)
|
skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m)
|
||||||
E = equity reale del conto (fallback paper se offline)
|
E = equity reale del conto (fallback paper se offline)
|
||||||
CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE
|
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
|
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
|
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
|
book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale
|
||||||
fidata; su fallback/offline si torna al cap fisso (protezione downside).
|
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
|
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:
|
def _read_watermark() -> float | None:
|
||||||
"""Ultima equity REALE osservata sul conto. None se mai vista o file illeggibile."""
|
"""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) ->
|
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,
|
segnale 230m all'ultima barra chiusa, per l'esecuzione reale. Default False (feed certificato,
|
||||||
deterministico: dashboard/test). TP01 resta sul certificato (è giornaliero)."""
|
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)
|
sh = shadow_report(offline=offline, equity_override=equity_override)
|
||||||
equity = sh["equity"]
|
equity = sh["equity"]
|
||||||
# registra l'equity reale quando e' leggibile: e' cio' che rende sicuro il cap di
|
# 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)
|
net = book_net_target(tp_frac, sign, equity, cap, scala=scala)
|
||||||
cur = float(a_rec["position_usd"])
|
cur = float(a_rec["position_usd"])
|
||||||
mark = a_rec["mark"]
|
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:
|
if order:
|
||||||
orders.append(order)
|
orders.append(order)
|
||||||
assets.append(dict(
|
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
|
Da qui vengono prezzi / conto / posizioni REALI (token `.env.mainnet`): li usa sia il libro live
|
||||||
dal mainnet (token `.env.mainnet`) e costruisce gli ordini di ribilancio **senza inviarli**. Qui NON
|
(TP01+SKH01, via src.live.book + execution) sia lo shadow mode e i monitor di sorveglianza.
|
||||||
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.
|
|
||||||
|
|
||||||
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet reale,
|
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet
|
||||||
e in questa fase solo in lettura. I contratti sono ristretti a BTC/ETH-PERPETUAL (inverse: `amount`
|
reale. I contratti ESEGUITI sono i perp LINEARI USDC (`_USDC-PERPETUAL`, non inverse): `amount` in
|
||||||
in USD notional, step verificato su Deribit: BTC $10, ETH $1).
|
base-coin (BTC/ETH), margine e P&L in USDC — vedi `_CONTRACT` sotto per step/tick/minimi per
|
||||||
|
strumento.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -236,11 +239,22 @@ class DeribitRead:
|
|||||||
return out.get("positions", []) if isinstance(out, dict) else []
|
return out.get("positions", []) if isinstance(out, dict) else []
|
||||||
|
|
||||||
def position_usd(self, instrument: str) -> float:
|
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"]
|
cur = _CONTRACT[instrument]["settle"]
|
||||||
for p in self.positions(cur):
|
for p in self.positions(cur):
|
||||||
if p.get("instrument_name") == instrument or p.get("instrument") == instrument:
|
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
|
return 0.0
|
||||||
|
|
||||||
def trade_history(self, instrument: str, limit: int = 20) -> list[dict]:
|
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]:
|
def open_orders(self, instrument: str) -> list[dict]:
|
||||||
"""Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger
|
"""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"]
|
cur = _CONTRACT[instrument]["settle"]
|
||||||
seen: dict = {}
|
seen: dict = {}
|
||||||
for typ in ("all", "trigger_all"):
|
for typ in ("all", "trigger_all"):
|
||||||
try:
|
try:
|
||||||
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
|
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
|
||||||
{"currency": cur, "type": typ}))
|
{"currency": cur, "type": typ}))
|
||||||
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
|
||||||
for o in lst:
|
raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
|
||||||
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
|
||||||
seen[o.get("order_id")] = o
|
for o in lst:
|
||||||
except Exception:
|
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
|
||||||
pass
|
seen[o.get("order_id")] = o
|
||||||
return list(seen.values())
|
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
|
(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.
|
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
|
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
|
||||||
tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione).
|
per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
|
||||||
Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
|
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).
|
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
|
||||||
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente.
|
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
|
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
|
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
|
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
|
||||||
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello
|
# DEFAULT (in coin, sotto), usato SOLO quando nessuno ha chiamato set_open_cap() (il micro-test,
|
||||||
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima.
|
# 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"}
|
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
|
FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +37,19 @@ class GuardrailError(RuntimeError):
|
|||||||
pass
|
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
|
@dataclass
|
||||||
class Fill:
|
class Fill:
|
||||||
"""Esito verificato di un ordine reale."""
|
"""Esito verificato di un ordine reale."""
|
||||||
@@ -55,6 +76,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
|
|||||||
class DeribitTrader(DeribitRead):
|
class DeribitTrader(DeribitRead):
|
||||||
"""Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre."""
|
"""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,
|
def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool,
|
||||||
label: str, order_type: str = "market", price: float | None = None) -> Fill:
|
label: str, order_type: str = "market", price: float | None = None) -> Fill:
|
||||||
if instrument not in ALLOWED:
|
if instrument not in ALLOWED:
|
||||||
@@ -62,7 +97,9 @@ class DeribitTrader(DeribitRead):
|
|||||||
if side not in ("buy", "sell"):
|
if side not in ("buy", "sell"):
|
||||||
raise GuardrailError(f"side non valido: {side}")
|
raise GuardrailError(f"side non valido: {side}")
|
||||||
if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre
|
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:
|
if amount <= 0 or amount > cap:
|
||||||
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
|
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
@@ -150,36 +187,46 @@ class DeribitTrader(DeribitRead):
|
|||||||
- target ~0 -> close();
|
- target ~0 -> close();
|
||||||
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
- stesso segno, |target|<|cur| -> REDUCE reduce_only;
|
||||||
- apertura/aumento -> open buy/sell (capped dal guardrail apertura).
|
- 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)
|
cur = self.position_usd(instrument)
|
||||||
if abs(target_notional_usd - cur) < min_usd:
|
if abs(target_notional_usd - cur) < min_usd:
|
||||||
return []
|
return []
|
||||||
fills: list[Fill] = []
|
fills: list[Fill] = []
|
||||||
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
try:
|
||||||
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
|
||||||
if crossing: # flip: flatta, poi riparti da zero
|
(cur < -FLAT_USD and target_notional_usd > FLAT_USD)
|
||||||
f = self.close(instrument, label="book-flip-close")
|
if crossing: # flip: flatta, poi riparti da zero
|
||||||
if f:
|
f = self.close(instrument, label="book-flip-close")
|
||||||
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:
|
if f:
|
||||||
fills.append(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
|
return fills
|
||||||
delta = target_notional_usd - cur
|
except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23)
|
||||||
amount = notional_to_amount(instrument, abs(delta), price=mark)
|
if fills:
|
||||||
if amount <= 0:
|
raise RebalanceParziale(fills, e) from e
|
||||||
return fills
|
raise
|
||||||
same_sign = (target_notional_usd > 0) == (cur > 0)
|
|
||||||
if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur):
|
|
||||||
side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only
|
|
||||||
fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce"))
|
|
||||||
else:
|
|
||||||
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
|
|
||||||
fills.append(self.open(instrument, side, amount, label="book-open"))
|
|
||||||
return fills
|
|
||||||
|
|
||||||
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
# --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
|
||||||
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
|
||||||
@@ -194,10 +241,20 @@ class DeribitTrader(DeribitRead):
|
|||||||
return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
|
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:
|
def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
|
||||||
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente):
|
"""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;
|
Stati possibili (`dict["state"]`):
|
||||||
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
|
- `"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
|
⚠️ 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
|
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
|
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.
|
misurarlo. Riparato il silenzio, non toccata la sequenza.
|
||||||
"""
|
"""
|
||||||
pos = self.position_usd(instrument)
|
try:
|
||||||
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
|
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:
|
if abs(pos) < FLAT_USD:
|
||||||
for o in brackets:
|
for o in brackets:
|
||||||
self.cancel_order(o.get("order_id"))
|
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)
|
return dict(eventi=[], certi=0.0, ambigui=0.0, soglia=soglia, avvisi=avvisi)
|
||||||
try:
|
try:
|
||||||
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
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
|
# la leva lorda massima che il libro puo' tenere: UNA sola formula, in `book.leva_tetto`
|
||||||
# tetto di CODICE (P1: derivata da `book`, non ridichiarata — la chiave di scala del
|
# (P1: derivata da `book`, non ridichiarata — #28 ne aveva tre diverse fra qui,
|
||||||
# 01/09 entra qui da sola)
|
# usde.frazione_cuscino e la regola `leva` sotto, e non tornavano)
|
||||||
scala = float(cfg.get("book_scale_k") or 1.0)
|
leva_tetto = _book.leva_tetto(cfg)[0]
|
||||||
leva_tetto = min(float(cfg["max_notional_per_asset_frac"]) * len(ASSETS) * scala,
|
|
||||||
float(_book.LEVA_LORDA_MAX))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
leva_tetto = float(_book.LEVA_LORDA_MAX) # config illeggibile: il tetto di codice
|
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 {}
|
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" il {d['dichiarato_il']}" if d.get("dichiarato_il") else "")
|
||||||
+ (f": {d['nota']}" if d["nota"] else "")
|
+ (f": {d['nota']}" if d["nota"] else "")
|
||||||
+ (f" (banda ±${d['banda']:,.2f})" if d["banda"] 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)
|
# (#27) l'aggancio "dichiarato+rilevato" vale SOLO contro un evento rilevato: se si
|
||||||
if gia is not None: # rilevato E dichiarato: l'importo e' quello dichiarato
|
# cercasse un qualunque evento sullo stesso ts_dopo, il secondo dichiarato nello stesso
|
||||||
gia.update(delta=d["delta"], pct=d["delta"] / float(prev["equity"]) if prev["equity"] else None,
|
# 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",
|
classe="movimento", fonte="dichiarato+rilevato",
|
||||||
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
|
dopo=float(prev["equity"]) + d["delta"], dichiarazione=nota_d)
|
||||||
continue
|
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"],
|
eventi.append(dict(ts_prima=prev["ts_utc"], ts_dopo=cur["ts_utc"],
|
||||||
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
|
prima=float(prev["equity"]), dopo=float(prev["equity"]) + d["delta"],
|
||||||
delta=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"])
|
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:
|
def salute(con, giorno: date) -> dict:
|
||||||
"""Giri attesi: 24 per un giorno CHIUSO, le ore trascorse per quello in corso.
|
"""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
|
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.
|
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)
|
lb = stato_libro(con, giorno)
|
||||||
adesso = datetime.now(timezone.utc)
|
adesso = datetime.now(timezone.utc)
|
||||||
in_corso = giorno == adesso.date()
|
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)
|
fatti = lb.get("giri", 0)
|
||||||
return dict(giri_book=fatti, feed_skh_min=lb.get("feed_skh_min"), atteso_giri=atteso,
|
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))
|
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 ----------------------------------------------------------
|
# --- quanto capitale e' esposto ----------------------------------------------------------
|
||||||
lv = lb.get("leva_lorda")
|
lv = lb.get("leva_lorda")
|
||||||
if lv is not None:
|
if lv is not None:
|
||||||
# Il tetto si LEGGE da config/live.json. Ridichiararlo qui e' lo schema che questo
|
# Il tetto si LEGGE da `book.leva_tetto`, non ridichiarato qui. Ridichiararlo e' lo schema
|
||||||
# progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap, taratura
|
# che questo progetto ha gia' pagato quattro volte (fee_watch, test di guardia sul cap,
|
||||||
# venue_watch, indice del namespace): un sorvegliante che ridichiara il proprio
|
# taratura venue_watch, indice del namespace) — e una quinta, con una formula diversa da
|
||||||
# bersaglio continua a passare il giorno che il bersaglio cambia.
|
# 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:
|
try:
|
||||||
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
cfg = json.loads((PROJECT_ROOT / "config" / "live.json").read_text())
|
||||||
cap_frac = float(cfg["max_notional_per_asset_frac"])
|
tetto, come = _book.leva_tetto(cfg)
|
||||||
tetto = cap_frac * len(lb["asset"])
|
|
||||||
dire("leva", f"Leva lorda **{lv:.2f}x** su un tetto di {tetto:.2f}x "
|
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"({come}, da `config/live.json` e `book.leva_tetto`): "
|
||||||
f"asset, letto da `config/live.json`): margine {tetto - lv:.2f}x.")
|
f"margine {tetto - lv:.2f}x.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
|
dire("leva", f"Leva lorda **{lv:.2f}x**; tetto non verificabile "
|
||||||
f"({type(e).__name__} su config/live.json).", "attenzione")
|
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
|
# 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)
|
# 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"]
|
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)
|
tot = e1 - e0 - sum(e["delta"] for e in mv_ev)
|
||||||
picco = max(r["equity"] for r in eq)
|
# (#35 C15) "ultimi 7 giorni" deve essere VERO: la baseline e' l'ULTIMA lettura al o
|
||||||
g7 = [r for r in eq if r["ts_utc"][:10] >= g7s]
|
# prima del giorno-7 a fine giornata — non la PRIMA lettura di quel giorno. Filtrare per
|
||||||
mv7 = sum(e["delta"] for e in mv_ev if e["ts_dopo"][:10] >= g7s)
|
# 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
|
# 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.
|
# 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)
|
rilevante = abs(tot) >= max(5.0, 0.01 * e1)
|
||||||
if rilevante and g7:
|
if rilevante and base7 is not None:
|
||||||
quota = (e1 - g7[0]["equity"] - mv7) / tot
|
quota = (e1 - base7["equity"] - mv7) / tot
|
||||||
if quota > 0.60:
|
if quota > 0.60:
|
||||||
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
|
coda = ("Un risultato concentrato in una finestra non e' un tasso di rendimento: "
|
||||||
"e' un evento.")
|
"e' un evento.")
|
||||||
@@ -609,10 +631,22 @@ def lettura(con, voce: dict) -> list[dict]:
|
|||||||
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
|
"senza questi 7 giorni il libro sarebbe sotto. " + coda)
|
||||||
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
|
dire("concentrazione", f"**Il {100*quota:.0f}% di tutto il P&L cumulato "
|
||||||
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
|
f"(${tot:+,.2f}) viene dagli ultimi 7 giorni.** " + coda, "nota")
|
||||||
if picco > 0:
|
# (#35 C7) il DD si calcola sulla serie AL NETTO dei movimenti CERTI (gli stessi che il
|
||||||
dd = (e1 - picco) / picco
|
# 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:
|
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")
|
"⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.", "nota")
|
||||||
|
|
||||||
# --- volatilita' implicita contro realizzata ---------------------------------------------
|
# --- 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_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
|
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)
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -105,6 +108,29 @@ MONITORS: tuple[MonitorSpec, ...] = (
|
|||||||
MonitorSpec("cc01_regime_watch", "cc01_regime", "runs.jsonl", 24.0, max_age_h=30.0,
|
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)",
|
gate="trigger di regime CC01: WARN>=10% / ALERT>=15% funding 30g (riapre il dossier)",
|
||||||
open_labeled=False),
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,3 +247,46 @@ def alerts_from(rows: list[dict]) -> list[str]:
|
|||||||
msg += f" | METTE A RISCHIO il gate {r['gate']}"
|
msg += f" | METTE A RISCHIO il gate {r['gate']}"
|
||||||
out.append(msg)
|
out.append(msg)
|
||||||
return out
|
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
|
||||||
|
|||||||
+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_DIARI_G = 7 # diari letti (la settimana: e' una revisione settimanale)
|
||||||
FINESTRA_COMMIT_G = 14 # commit letti
|
FINESTRA_COMMIT_G = 14 # commit letti
|
||||||
CARTELLA = Path("docs") / "revisioni"
|
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
|
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
|
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
|
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
|
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
|
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:
|
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»
|
"""Uscita di un comando di SOLA LETTURA, o il motivo per cui non si legge (P5: «non vedo»
|
||||||
non e' «va tutto bene»)."""
|
non e' «va tutto bene»)."""
|
||||||
@@ -175,7 +189,18 @@ def materiale(root: Path, adesso: datetime) -> list[tuple[str, str, bool]]:
|
|||||||
|
|
||||||
out, tot = [], 0
|
out, tot = [], 0
|
||||||
for nome, testo in voci:
|
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:
|
if tot + len(t) > MAX_CHARS_TOTALE:
|
||||||
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
|
t, tr = _tronca(t, max(0, MAX_CHARS_TOTALE - tot)), True
|
||||||
tot += len(t)
|
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:
|
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"]
|
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:
|
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.")
|
parti.append("\nScrivi ora la revisione, coi cinque titoli, e nient'altro.")
|
||||||
return "\n".join(parti)
|
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 da `scripts/live/revisione.py` (cron `scripts/cron_review.sh`); l'unico file "
|
||||||
"scritto e' questo", "",
|
"scritto e' questo", "",
|
||||||
"| voce | caratteri | troncata |", "|---|---|---|"]
|
"| 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"
|
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"
|
return (f"<b>🚨 Revisione {CADENZA} NON eseguita</b> {adesso:%Y-%m-%d}\n"
|
||||||
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
|
f"{html.escape(errore)}\n<code>{html.escape(str(path))}</code>")
|
||||||
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
|
sintesi = _sezione(testo or "", TITOLI[0]) or "(nessuna Sintesi nel rapporto)"
|
||||||
corpo = html.escape(sintesi, quote=False)
|
|
||||||
tagliato = ""
|
tagliato = ""
|
||||||
if len(corpo) > LIMITE_TELEGRAM:
|
# (#35 C13) il taglio va sul testo GREZZO, PRIMA di html.escape: tagliare dopo puo' spezzare
|
||||||
corpo, tagliato = corpo[:LIMITE_TELEGRAM], "\n[… sintesi tagliata: il resto e' nel file]"
|
# 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"
|
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>")
|
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, "
|
print(f" revisione {esito['data']} modello {modello} materiale {len(voci)} voci, "
|
||||||
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
|
f"{len(prompt):,} caratteri" + (" [SECCO: nessuna chiamata, nessuna scrittura]" if secco else ""))
|
||||||
for n, t, tr in voci:
|
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:
|
if secco:
|
||||||
return esito
|
return esito
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -95,15 +95,20 @@ def _positions(client):
|
|||||||
USDE_INDEX_URL = _usde.index_url()
|
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
|
"""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
|
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,
|
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:
|
try:
|
||||||
eq = float(client.account_summary("USDE").get("equity") or 0)
|
eq = float(client.account_summary("USDE").get("equity") or 0)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
return 0.0, ""
|
return None, f"USDE illeggibile: {type(e).__name__}: {e}"
|
||||||
if eq <= 0:
|
if eq <= 0:
|
||||||
return 0.0, ""
|
return 0.0, ""
|
||||||
px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL)
|
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)
|
eq = float(client.account_summary("USDC").get("equity") or 0)
|
||||||
if eq > 1:
|
if eq > 1:
|
||||||
usde_usd, usde_note = _collaterale_usde(client)
|
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:
|
if usde_usd > 0:
|
||||||
return eq + usde_usd, f"mainnet USDC + {usde_note}"
|
return eq + usde_usd, f"mainnet USDC + {usde_note}"
|
||||||
return eq, "mainnet USDC"
|
return eq, "mainnet USDC"
|
||||||
@@ -133,7 +140,8 @@ def _equity(client, marks):
|
|||||||
if any_ok and tot > 1:
|
if any_ok and tot > 1:
|
||||||
return tot, "mainnet coin-margined"
|
return tot, "mainnet coin-margined"
|
||||||
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B):
|
# 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"
|
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
|
eq_basis = ("override" if equity_override is not None
|
||||||
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
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
|
# 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} "
|
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
|
f"invece del conto reale") if (equity_override is None and client is not None
|
||||||
and not real_eq) else None
|
and not real_eq) else None
|
||||||
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
|
|||||||
|
|
||||||
return dict(
|
return dict(
|
||||||
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
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,
|
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
||||||
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
|
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
|
||||||
assets=assets, orders=orders, live_trades=live_trades,
|
assets=assets, orders=orders, live_trades=live_trades,
|
||||||
|
|||||||
+152
-34
@@ -2,20 +2,34 @@
|
|||||||
|
|
||||||
PERCHE' ESISTE
|
PERCHE' ESISTE
|
||||||
--------------
|
--------------
|
||||||
`data/live/book_executions.jsonl` registra i fill, ma il suo `ts_utc` e' la data della
|
`logs/cron_book.log` e' **gitignored, non nel backup e ruotabile**, ed e' la fonte che `sync()`
|
||||||
**barra di segnale** (`book_execute.py`: `pd.Timestamp(r['last_data'])`), non l'ora del fill:
|
usa per popolare il DB: una rotazione lo cancellerebbe senza che nessuno se ne accorga.
|
||||||
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.
|
|
||||||
|
|
||||||
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
|
Questo modulo MATERIALIZZA quella cronologia in `data/live/trades.db` (sqlite), che sta dentro
|
||||||
il perimetro gia' coperto dal backup rotativo della VPS.
|
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
|
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)
|
1. `logs/cron_book.log` -> l'ora di INIZIO GIRO (una per run, condivisa da tutti i fill di quel
|
||||||
e la riga di fill `-> BUY 0.0008 @ $78,094.2 fee 0.02187 (OK)`.
|
run, non per-fill) + contesto (equity, tp_frac, skh, target,
|
||||||
2. `data/live/book_executions.jsonl` -> gli stessi fill con piu' cifre, ma senza ora.
|
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,
|
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.
|
**ma tronca**: al 2026-08-23 ritorna 1 trade su BTC e 0 su ETH.
|
||||||
|
|
||||||
@@ -32,7 +46,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
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).
|
# Data di armamento dell'esecuzione reale (CLAUDE.md, 2026-06-20).
|
||||||
ARMING = "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 = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS fills (
|
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]:
|
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
|
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] = []
|
rts: list[dict] = []
|
||||||
for f in sorted(fills, key=lambda x: (x.ts_utc, x.asset)):
|
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
|
fee_u = f.fee / f.qty if f.qty else 0.0
|
||||||
if f.side == "buy":
|
segno_fill = 1 if f.side == "buy" else -1
|
||||||
lotti[f.asset].append([f.qty, f.price, f.ts_utc, fee_u])
|
dq = lotti[f.asset]
|
||||||
else:
|
resto = f.qty
|
||||||
resto = f.qty
|
while resto > 1e-12 and dq and dq[0][4] == -segno_fill:
|
||||||
while resto > 1e-12 and lotti[f.asset]:
|
lq, lp, lts, lfee_u, lseg = dq[0]
|
||||||
lq, lp, lts, lfee_u = lotti[f.asset][0]
|
usa = min(resto, lq)
|
||||||
usa = min(resto, lq)
|
# long chiuso da una vendita: (exit - entry); short chiuso da un acquisto: (entry - exit)
|
||||||
lordo = usa * (f.price - lp)
|
lordo = usa * (f.price - lp) if lseg == 1 else usa * (lp - f.price)
|
||||||
quota_fee = usa * (lfee_u + fee_u)
|
quota_fee = usa * (lfee_u + fee_u)
|
||||||
ore = (datetime.fromisoformat(f.ts_utc) - datetime.fromisoformat(lts)).total_seconds() / 3600
|
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,
|
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,
|
px_out=f.price, ore_tenuta=ore, pnl_lordo=lordo,
|
||||||
fee_quota=quota_fee, pnl_netto=lordo - quota_fee))
|
fee_quota=quota_fee, pnl_netto=lordo - quota_fee,
|
||||||
lq -= usa; resto -= usa
|
direzione=("long" if lseg == 1 else "short")))
|
||||||
if lq <= 1e-12:
|
lq -= usa; resto -= usa
|
||||||
lotti[f.asset].popleft()
|
if lq <= 1e-12:
|
||||||
else:
|
dq.popleft()
|
||||||
lotti[f.asset][0][0] = lq
|
else:
|
||||||
aperti = {a: [dict(qty=q, price=p, ts=t) for q, p, t, _ in dq] for a, dq in lotti.items() if dq}
|
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
|
return rts, aperti
|
||||||
|
|
||||||
|
|
||||||
@@ -321,6 +351,86 @@ def upsert_fills(con: sqlite3.Connection, fills: list[Fill]) -> int:
|
|||||||
return n
|
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:
|
def upsert_equity(con: sqlite3.Connection, punti: list[tuple[str, float, str]]) -> int:
|
||||||
n = 0
|
n = 0
|
||||||
for ts, v, src in punti:
|
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:
|
def rebuild_roundtrips(con: sqlite3.Connection) -> int:
|
||||||
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano."""
|
"""Ricalcola da zero: i round-trip sono DERIVATI, mai inseriti a mano.
|
||||||
righe = con.execute("SELECT * FROM fills ORDER BY ts_utc, asset").fetchall()
|
|
||||||
|
`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"],
|
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]
|
price=r["price"], fee=r["fee"]) for r in righe]
|
||||||
rts, _ = fifo_roundtrips(fills)
|
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:
|
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"],
|
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]
|
price=r["price"], fee=r["fee"]) for r in righe]
|
||||||
_, aperti = fifo_roundtrips(fills)
|
_, aperti = fifo_roundtrips(fills)
|
||||||
@@ -366,7 +481,10 @@ def stato_aperto(con: sqlite3.Connection) -> dict:
|
|||||||
for a, lotti in aperti.items():
|
for a, lotti in aperti.items():
|
||||||
q = sum(x["qty"] for x in lotti)
|
q = sum(x["qty"] for x in lotti)
|
||||||
costo = sum(x["qty"] * x["price"] 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -104,12 +104,12 @@ def valuta(eq_usde: float, px: float | None) -> tuple[float, str]:
|
|||||||
# il saldo USDC va negativo alla prima perdita del libro e Deribit lo finanzia allo 0,05%/GIORNO
|
# 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'
|
# (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:
|
# il disaster-SL sulla MASSIMA esposizione lorda che il libro puo' prendere:
|
||||||
# n_asset x frac x disaster_sl_pct x equity_totale
|
# 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
|
# 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,
|
# formula viveva in scripts/live/usde_convert.py; il sorvegliante cuscino_watch la deriva da qui,
|
||||||
# e usde_convert pure — due lettori, una formula.
|
# 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
|
||||||
N_ASSET = 2
|
# `book.leva_tetto` che usa anche il giornale (src/live/journal.py).
|
||||||
|
|
||||||
|
|
||||||
def frazione_cuscino() -> tuple[float, str]:
|
def frazione_cuscino() -> tuple[float, str]:
|
||||||
@@ -117,8 +117,8 @@ def frazione_cuscino() -> tuple[float, str]:
|
|||||||
cfg = json.loads(CFG_PATH.read_text())
|
cfg = json.loads(CFG_PATH.read_text())
|
||||||
sl = float(cfg["disaster_sl_pct"])
|
sl = float(cfg["disaster_sl_pct"])
|
||||||
from src.live import book as B # lazy: book importa shadow, che importa questo modulo
|
from src.live import book as B # lazy: book importa shadow, che importa questo modulo
|
||||||
frac = float(B.WEIGHT)
|
leva, come_leva = B.leva_tetto(cfg)
|
||||||
return N_ASSET * frac * sl, f"{N_ASSET} asset x frac {frac} x SL {sl:.0%}"
|
return leva * sl, f"{come_leva} x SL {sl:.0%}"
|
||||||
|
|
||||||
|
|
||||||
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
|
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
|
||||||
|
|||||||
+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).
|
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 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'
|
⚠️ **IL RAMO CHE DECLASSAVA E' MORTO, RIMOSSO DA `diagnose()` (#35 B11, 27/09/2026).**
|
||||||
compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA
|
`in_release_window()` diceva se l'orologio cadeva nello slot di release Deribit (martedi'
|
||||||
ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che
|
09:00-09:30 UTC); `diagnose()` la usava per declassare la GRAVITA' di una manutenzione gia'
|
||||||
sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un
|
riconosciuta dalla SONDA (REGOLA P9 — *un allarme massimo speso per un evento atteso e' un
|
||||||
guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio
|
allarme che non verra' letto il giorno che e' vero*). Ma l'UNICO chiamante di `diagnose()` e'
|
||||||
proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di
|
`book_execute.py`, in cron al minuto **:47** di ogni ora (dal 25/08): la finestra 09:00-09:30 non
|
||||||
`venue_watch.MAINT_GRACE_HOURS` — *declassa dentro la tolleranza, RIALZA oltre* — applicata pero'
|
contiene mai :47, quindi quel ramo non poteva MAI diventare vero — si leggeva come una
|
||||||
a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario,
|
declassazione attiva e non lo e' mai stata. Il campo `atteso` RESTA sul verdetto
|
||||||
non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare.
|
(`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
|
||||||
REGOLA P9 — *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il
|
costanti `RELEASE_*` restano invece nel modulo: le usa `test_book_cadenza.py`/`test_revisione.py`
|
||||||
giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra
|
per verificare che i cron di book/review evitino lo stesso slot — uno scopo indipendente da
|
||||||
di meno. L'episodio finisce nel log e nella notifica comunque.
|
`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
|
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
|
from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui
|
||||||
|
|
||||||
# --- verdetti -----------------------------------------------------------------------------
|
# --- 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_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_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile)
|
||||||
V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5)
|
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").
|
# 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
|
# ⚠️ 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
|
# 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 —
|
# lo slot, questa costante mente in silenzio.
|
||||||
# decide la sonda, e la finestra al massimo declassa.
|
# ⚠️ #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_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi'
|
||||||
RELEASE_START_MIN = 9 * 60 # 09:00 UTC
|
RELEASE_START_MIN = 9 * 60 # 09:00 UTC
|
||||||
RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia.
|
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)
|
@dataclass(frozen=True)
|
||||||
class Diagnosi:
|
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
|
verdetto: str
|
||||||
perche: str
|
perche: str
|
||||||
atteso: bool
|
atteso: bool
|
||||||
@@ -87,7 +94,8 @@ class Diagnosi:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def gravita(self) -> str:
|
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:
|
if self.atteso:
|
||||||
return "ℹ️"
|
return "ℹ️"
|
||||||
return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️")
|
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:
|
def in_release_window(now: datetime | None = None) -> bool:
|
||||||
"""L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)?
|
"""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 or datetime.now(timezone.utc)
|
||||||
now = now.astimezone(timezone.utc)
|
now = now.astimezone(timezone.utc)
|
||||||
if now.weekday() != RELEASE_WEEKDAY:
|
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,
|
def diagnose(errori_osservati: list[str] | None = None,
|
||||||
now: datetime | None = None,
|
|
||||||
sonda=None) -> Diagnosi:
|
sonda=None) -> Diagnosi:
|
||||||
"""Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete.
|
"""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
|
verdetto = V_VENUE_GIU
|
||||||
perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione"
|
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]
|
prova = "; ".join([*errori[:3], grezzo])[:500]
|
||||||
return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova)
|
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']})")
|
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
|
||||||
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
||||||
report["severity"] = "alert"
|
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
|
# --- 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))
|
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).
|
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
|
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).
|
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 [
|
return [
|
||||||
tp01_sleeve(weight=0.75),
|
tp01_sleeve(weight=B.W_TP01),
|
||||||
skyhook_sleeve(weight=0.25),
|
skyhook_sleeve(weight=B.W_SKH),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ giorno che non risponde.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import date
|
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
|
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
|
# ------------------------------------------------- i numeri inventati
|
||||||
|
|
||||||
def test_scopre_un_numero_che_non_c_e():
|
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"
|
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):
|
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."""
|
"""Il guasto peggiore: una pagina che mostra l'analisi del giorno prima come se fosse di oggi."""
|
||||||
import analista as CLI
|
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())
|
t = A.testata(_voce_piena())
|
||||||
assert "$637.36" in t and "+2.36" in t and "+39.30" in t
|
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
|
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():
|
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
|
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():
|
def test_un_messaggio_normale_non_viene_toccato():
|
||||||
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
|
m = A.per_telegram(_voce_piena(), "Analisi breve.", "ok", [])
|
||||||
assert "tagliato" not in m and m.endswith("Analisi breve.")
|
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
|
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.
|
# 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):
|
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
|
from helpers import carica_script
|
||||||
mod = carica_script("scripts/live/book_execute.py")
|
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)],
|
skh_state="flat", net_target=0.0, position_usd=0.0, mark=60000.0, order=None)],
|
||||||
orders=[],
|
orders=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class _TraderOk:
|
||||||
|
def ensure_disaster_sl(self, inst, sl_pct):
|
||||||
|
return {"state": "ok", "stop": 1.0, "amount": 0.0}
|
||||||
|
|
||||||
alerts = []
|
alerts = []
|
||||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||||
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
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",
|
monkeypatch.setattr(mod, "load_config",
|
||||||
lambda: dict(execution_enabled=False, min_order_usd=5.0, disaster_sl_pct=0.30))
|
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||||
monkeypatch.setattr(sys, "argv", ["book_execute.py"]) # niente --execute -> dry-run
|
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"]) # ARMATO: qui l'alert conta
|
||||||
mod._run()
|
try:
|
||||||
|
mod._run()
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "SKH FEED ERRORE" in out # stampato nel log
|
assert "SKH FEED ERRORE" in out # stampato nel log, sempre
|
||||||
assert any("SKH feed fallito" in title for title, _ in alerts) # alert inviato
|
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"
|
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):
|
def test_book_execute_eq_fallback_e_SOLO_PROTEZIONE(monkeypatch, capsys):
|
||||||
"""eq_fallback: avvisa + notify MA PROSEGUE (non e' un halt, a differenza di pos_error)."""
|
"""(#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
|
from helpers import carica_script
|
||||||
mod = carica_script("scripts/live/book_execute.py")
|
mod = carica_script("scripts/live/book_execute.py")
|
||||||
|
|
||||||
@@ -551,18 +588,33 @@ def test_book_execute_eq_fallback_warns_but_proceeds(monkeypatch, capsys):
|
|||||||
alerts = []
|
alerts = []
|
||||||
|
|
||||||
class FakeDT:
|
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 ensure_disaster_sl(self, inst, sl_pct): return {"state": "flat"}
|
||||||
def position_usd(self, inst): return 0.0
|
def position_usd(self, inst): return 0.0
|
||||||
|
|
||||||
|
trader = FakeDT()
|
||||||
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
monkeypatch.setattr(mod, "book_report", lambda **k: canned)
|
||||||
monkeypatch.setattr(mod, "notify", lambda title, det=None: alerts.append((title, det)))
|
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",
|
monkeypatch.setattr(mod, "load_config",
|
||||||
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
lambda: dict(execution_enabled=True, min_order_usd=5.0, disaster_sl_pct=0.30))
|
||||||
monkeypatch.setattr(sys, "argv", ["book_execute.py", "--execute"])
|
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
|
out = capsys.readouterr().out
|
||||||
assert "EQUITY FALLBACK" in out # avvisato
|
assert "EQUITY FALLBACK" in out # avvisato nel log (print, resta)
|
||||||
assert any("equity fallback" in t for t, _ in alerts) # alert inviato
|
# (#21, U6) UNA sola notify, non due: il vecchio alert "equity fallback (sizing su paper_cap)"
|
||||||
assert "Nessuna azione" in out # HA PROSEGUITO (non halt)
|
# 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
|
||||||
@@ -155,7 +155,15 @@ def test_un_tentativo_ogni_sei_ore():
|
|||||||
assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0]
|
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):
|
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 = []
|
chiamate = []
|
||||||
|
|
||||||
class R:
|
class R:
|
||||||
@@ -166,10 +174,61 @@ def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch):
|
|||||||
return R()
|
return R()
|
||||||
|
|
||||||
monkeypatch.setattr(W.subprocess, "run", finto_run)
|
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)
|
e = W.riconverti(0.67)
|
||||||
assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py")
|
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 "--esegui" in chiamate[0] and "0.6700" in chiamate[0]
|
||||||
assert e["coda"][-1].startswith(" ESITO")
|
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():
|
def test_il_monitor_e_registrato_in_monitor_health():
|
||||||
|
|||||||
@@ -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
|
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)
|
# 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
|
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):
|
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."""
|
"""24 giri attesi su un giorno non finito = allarme a ogni esecuzione, cioe' rumore."""
|
||||||
oggi = datetime.now(timezone.utc).date()
|
class _OraFissa(datetime): # ora ferma: il test non dipende da quando gira
|
||||||
monkeypatch.setattr(J, "stato_libro", lambda c, g: dict(giri=datetime.now(timezone.utc).hour + 1,
|
@classmethod
|
||||||
feed_skh_min=0))
|
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)
|
s = J.salute(db, oggi)
|
||||||
assert s["giorno_in_corso"] is True
|
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
|
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"] == []
|
||||||
+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):
|
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 = _radice(tmp_path)
|
||||||
(r / "CLAUDE.md").write_text("x" * 1000)
|
(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]
|
n, t, tr = R.materiale(r, ADESSO)[0]
|
||||||
assert tr and "[… troncato a 100 caratteri su 1,000]" in t
|
assert tr and "[… troncato a 100 caratteri su 1,000]" in t
|
||||||
assert "[TRONCATO]" in R.costruisci_prompt([(n, t, tr)], ADESSO)
|
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):
|
def test_il_prompt_porta_le_regole_e_la_data(tmp_path):
|
||||||
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
|
p = R.costruisci_prompt(R.materiale(_radice(tmp_path), ADESSO), ADESSO)
|
||||||
assert "DATA DELLA REVISIONE: 2026-09-14 06:15Z" in p
|
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
|
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
|
# ---------------------------------------------------------------- orchestrazione: cosa scrive, cosa no
|
||||||
|
|
||||||
def _albero(root: Path) -> set[str]:
|
def _albero(root: Path) -> set[str]:
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
|
|||||||
assert "non leggibile" in src
|
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), {})
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -21,8 +22,33 @@ telegram_daily = pytest.importorskip("telegram_daily")
|
|||||||
def test_build_non_solleva_e_ha_le_sezioni():
|
def test_build_non_solleva_e_ha_le_sezioni():
|
||||||
txt = telegram_daily.build()
|
txt = telegram_daily.build()
|
||||||
assert isinstance(txt, str) and len(txt) > 50
|
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}"
|
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():
|
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:
|
"""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'."""
|
se sparisce dal codice, il report mentirebbe sul 'quanto manca'."""
|
||||||
assert "media dei SEGNI" in telegram_daily.trend_state.__doc__
|
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)]
|
_f("2026-01-02T00:00:00+00:00", "ETH", "sell", 1.0, 100.0)]
|
||||||
rts, aperti = T.fifo_roundtrips(fills)
|
rts, aperti = T.fifo_roundtrips(fills)
|
||||||
assert rts == [] # la vendita ETH non chiude il long BTC
|
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():
|
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'])`.
|
"""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.
|
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()
|
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 "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
|
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
|
||||||
+44
-12
@@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero():
|
|||||||
assert r["apr"] is None and r["finestre"] == 0
|
assert r["apr"] is None and r["finestre"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
|
def test_quota_da_decidere_e_stata_rimossa():
|
||||||
from datetime import datetime, timezone
|
"""#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
|
||||||
def q(giorno, v):
|
giorno finche' non la si toglie a mano."""
|
||||||
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
|
assert not hasattr(W, "quota_da_decidere")
|
||||||
|
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
|
||||||
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) ------------------------------
|
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
|
||||||
@@ -266,3 +260,41 @@ def test_i_trade_prima_della_lettura_precedente_non_contano():
|
|||||||
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
|
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)
|
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
|
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.
|
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.
|
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).
|
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
|
import sys
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose,
|
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":'
|
MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":'
|
||||||
'{"message":"system_maintenance","code":11051}}')
|
'{"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():
|
def test_manutenzione_non_si_declassa_piu_atteso_sempre_falso():
|
||||||
dentro = datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc) # martedi', l'episodio vero
|
"""Il ramo che declassava (finestra di release del martedi') e' rimosso: una manutenzione
|
||||||
assert in_release_window(dentro) is True
|
riconosciuta dalla sonda resta comunque `atteso is False` e alla gravita' piena."""
|
||||||
assert in_release_window(datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)) is True
|
d = diagnose([], sonda=_sonda_manutenzione)
|
||||||
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)
|
|
||||||
assert d.verdetto == V_MANUTENZIONE and d.atteso is False
|
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():
|
def test_un_guasto_vero_non_e_mai_atteso():
|
||||||
"""Il rischio della finestra: costruire il silenzio proprio nell'ora in cui serve. Un gateway
|
d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok)
|
||||||
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)
|
|
||||||
assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑"
|
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."""
|
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
|
||||||
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
|
||||||
assert "non tentato" in rep["invio"]
|
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