live: diagnosi del guasto venue, disaster-SL scoperto, isolamento per asset, cron :47

Nato da "stato trades" durante una manutenzione Deribit (system_maintenance 11051,
08:57-09:20 UTC). Contati i log invece che ricordarli: 1.499 giri dal 23/06, 3 con
manutenzione, 5 traceback duri — e 4 dei 5 sono il NOSTRO gateway, non il venue.
Le release Deribit escono il martedi' alle 09:00 UTC: il cron al :07 ci cadeva dentro
per costruzione (21/07, 11/08, 18/08, 25/08).

DIAGNOSI (src/live/venue_probe.py, nuovo)
Sonda l'API PUBBLICA Deribit senza gateway e senza credenziali, e classifica il guasto:
VENUE_MANUTENZIONE / VENUE_GIU / GATEWAY / IGNOTO. Prima ogni causa stampava la stessa
riga ("conto non leggibile (offline)") con nota di diagnosi CABLATA — P4 violata, e il
18/08 il codice 11051 era gia' dentro il processo senza arrivare a chi decideva la
gravita'. Parte solo dopo un guasto: sul percorso sano costa zero. P1 rispettata: la
firma 11051 si importa da venue_watch.is_maintenance, non si ridichiara.

P9: la manutenzione dentro lo slot declassa il titolo a info, ma solo su EVIDENZA della
sonda (mai sull'orologio) e solo dentro la durata annunciata — oltre, RIALZA. Un gateway
rotto di martedi' mattina resta al massimo.

DISASTER-SL SCOPERTO (src/live/execution.py)
ensure_disaster_sl cancella i bracket incoerenti PRIMA di ripiazzarne uno: fra le due
chiamate la posizione e' senza stop. Se il ripiazzamento sollevava, l'eccezione risaliva
a main() e il guasto peggiore aveva la faccia di un errore qualunque. Ora: due tentativi,
poi stato `naked`, distinto da `place-failed` (P5). Non e' "non sono riuscito a
proteggere": e' "ho tolto la protezione e non sono riuscito a rimetterla" -> allarme
massimo, mai declassato. La SEQUENZA non e' stata invertita: piazza-poi-cancella sembra
piu' sicuro ma "sembra" non basta con soldi veri senza misurarlo.

ISOLAMENTO PER ASSET (scripts/live/book_execute.py)
Il 21/07 un 502 dentro ensure_disaster_sl su BTC ha ucciso il giro intero: nel log ETH
non compare — ne' ribilanciato ne' verificato nella protezione. Ora un asset che esplode
non ferma il ciclo, e il giro degradato esce con codice 2.

CRON :07 -> :47
Il vincolo vero non era ":07" ma "fuori dai ~26s del minuto tondo" (rate-limit per-IP
auto-saturato dal collettore catena, misura del 30/07). Il :47 lo soddisfa e in piu' sta
fuori dallo slot di release. PREVISIONE DICHIARATA (M12): 3 delle 4 finestre osservate
sono rientrate entro l'ora -> il :47 ne avrebbe scavalcate 3 su 4; se martedi' prossimo
becca comunque la manutenzione, la previsione e' sbagliata.

WATERMARK AVVELENATO DAI TEST (tests/conftest.py, nuovo)
Trovato addosso: lanciando la suite, data/live/equity_seen.json passava a $5.000 e il giro
successivo mandava un allarme Telegram FALSO ("USCITA DI FONDI -86,6%"). Non cosmetico:
cap_fallback = min(cap_config, watermark x frac) sarebbe passato da $334 a $2.500/asset,
~7,5x di leva su un conto da $668, sul ramo eq_fallback che allerta e NON blocca — cioe'
i test potevano armare il pericolo che il watermark esiste per impedire. Riparato con una
fixture AUTOUSE, non per-test: chiedere a ogni autore di ricordarsene ha gia' perso il
26/07 e il 21/08. Resta esposto lo stesso errore su trades.db e book_executions.jsonl.

BLOCCATO, NON RINVIATO
Il fallback diretto ai privati Deribit richiede chiavi API create dall'operatore: in
locale esiste solo CERBERO_TOKEN. Il gateway resta un punto singolo di guasto non
aggirabile (CLAUDE.md 5.11). La sonda dice di chi e' il guasto, non lo aggira.

Test: 20 nuovi, nessuno tocca la rete; controllo positivo fatto (5 su 7 falliscono contro
il codice vecchio). Suite 730 passati, 1 fallito — quello gia' noto di 5.9 (deriva dati).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B9UyJLHzR7EJzxR3iQ3RN1
This commit is contained in:
Adriano Dal Pastro
2026-08-25 10:27:36 +00:00
parent 2751a7efd0
commit 426735448e
12 changed files with 1136 additions and 47 deletions
+27 -4
View File
@@ -47,8 +47,10 @@ il 2026-07-26, e sei ondate lo hanno **confermato** invece che ribaltarlo.* I vi
**LIVE (soldi veri).** Deribit mainnet, **TP01 + SKH01 a 75/25**, nettati in software su **una sola
posizione per asset** (`src/live/book.py`: `W_TP01=0.75`, `W_SKH=0.25`, `WEIGHT=0.5` → 50/50 BTC/ETH).
Esecutore `scripts/live/book_execute.py`, cron **orario** `scripts/cron_book.sh` (minuto **:07**,
scelto per stare fuori dalla finestra di rate-limit per-IP). **GTAA01, XS01, VRP01, XSR01 NON sono
Esecutore `scripts/live/book_execute.py`, cron **orario** `scripts/cron_book.sh` (minuto **:47**
dal 2026-08-25; era `:07`). Due vincoli, entrambi misurati: fuori dai ~26s del minuto tondo
(rate-limit per-IP) **e** fuori dallo slot di release Deribit (**martedi' 09:00 UTC**, 15-30 min
annunciati), che il `:07` beccava — 4 volte in 63 giorni. **GTAA01, XS01, VRP01, XSR01 NON sono
nel book live.**
**RICERCA (paper).** Portafoglio a 5 sleeve `src/portfolio/sleeves.active_sleeves`:
@@ -182,14 +184,35 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
sposterebbe il libro sulla riga 4h, dove BTC **raddoppia** gli scatti del disaster-SL.
8. ⚠️ **Il Rulebook Deribit** (ADL, perdita socializzata, *emergency powers*, conti dormienti) **non
lo sorveglia nessuno**; `MAINT_GRACE_HOURS`=2 **presume** gli annunci invece di leggerli.
*Ridotto in parte il 2026-08-25:* `src/live/venue_probe.py` interroga l'API **pubblica**
Deribit e distingue **manutenzione / venue giu' / NOSTRO gateway / non vedo**, e lo slot di
release (**martedi' 09:00 UTC**) e' ora una costante dichiarata e testata. Resta scoperto il
Rulebook vero e proprio: la sonda legge se il venue *risponde*, non cosa il venue *annuncia*.
9. ⚠️ **`tests/test_wave_0726.py::test_t1_canonical_riproduce_il_backtest_ufficiale` FALLISCE**
(dal ~24-25/08): Sharpe hold-out SKH01 `canonical` **1,9223** contro la banda cablata
`1.3 < h < 1.9`. **Il codice non e' cambiato, sono cambiati i DATI**`data/raw/` e' gitignored
e il cron lo ricostruisce ogni notte, quindi la finestra si allunga e il numero deriva (verso
l'**alto**). **La banda non e' stata allargata**: si guarda sotto prima di toccarla (lezione
07/08). Suite: **710 passati, 1 fallito**.
07/08). Suite: **730 passati, 1 fallito** (25/08).
10. ⚠️ **Nessuna tabella pubblicata prima del 22/08 contiene fisco E funding insieme.** L'unica
congiunta e' quella in `30-piano-capitale-fisco.md` — le altre vanno lette con lo sconto.
11. 🚨 **Le credenziali Deribit esistono SOLO dentro il gateway** (`cerbero-mcp`): in locale c'e'
solo `CERBERO_TOKEN`. Quindi il gateway e' un **punto singolo di guasto non aggirabile**
**4 dei 5 traceback in 63 giorni** sono suoi (404 su `get_positions`, 502, due `ReadTimeout`),
ed e' l'unico pezzo della catena che possediamo. Un fallback diretto ai **privati** Deribit
(conto/posizioni/ordini) richiede **chiavi API create dall'operatore** sul conto Deribit:
e' una decisione, non un refactor. Fatto intanto il pezzo che non le richiede: la sonda
**pubblica**, che almeno dice *di chi e' il guasto*.
12. ⚠️ **Un test scriveva nel watermark VIVO** (`data/live/equity_seen.json`): lanciare la suite
ci metteva **$5.000** e il giro successivo del book mandava un allarme falso *"USCITA DI FONDI
86,6%"*. Non era cosmetico: `cap_fallback = min(cap_config, watermark × frac)` sarebbe passato
da **$334 a $2.500/asset — ~7,5x di leva** su un conto da $668, per giunta sul ramo `eq_fallback`
che **allerta e NON blocca**. ✅ Riparato il 2026-08-25 con una fixture **autouse** in
`tests/conftest.py` (strutturale: non si chiede a ogni autore di ricordarsene — quella scommessa
ha gia' perso il 26/07 e il 21/08) + guardia in `test_cap_watermark.py`. **Resta il principio:
un test non deve poter scrivere in `data/live/`; oggi e' deviato solo il watermark, non
`trades.db` ne' `book_executions.jsonl`.**
---
@@ -405,7 +428,7 @@ uv run python scripts/live/trades_db.py --report # stato libro d
uv run python scripts/live/trades_db.py --reconcile # incrocio delle 3 fonti sui fill
uv run python scripts/live/journal.py # voce del giorno (numeri + lettura)
uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare
uv run pytest # test (708)
uv run pytest # test (731: 730 ok, 1 noto §5.9)
```
```python
@@ -0,0 +1,196 @@
# 2026-08-25 — La manutenzione del martedì, e i due difetti che ha scoperchiato
*Scritto da Claude (agente), su richiesta dell'operatore. Firmato come vuole P13: l'analisi in
prosa è opinione di un lettore fallibile, i numeri qui sotto vengono dai log e sono riproducibili.*
## Come è cominciata
Domanda dell'operatore: «stato trades». Report normale del libro di bordo — 24 fill, equity
$598,06 → $667,88 (+11,67%), 16 round-trip di cui 15 in utile, netto +39,78. Poi il `--reconcile`
ha stampato la riga che ha aperto la giornata:
```
venue: NON LETTO (HTTPError) — non e' 'zero trade', e' 'non misurato'
```
Non era il gateway. Era **Deribit in manutenzione**: `system_maintenance`, codice 11051, HTTP 503
sia sul pubblico che sul privato. Iniziata fra le **08:57:40Z** (ultimo 200 OK nei log di
`cerbero-mcp`) e le **09:01:46Z** (primo 503). Rientrata verso le 09:20 — ~20 minuti, coerenti con
i 15-30 annunciati da Deribit per le sue release.
## Cosa dicono i log, contati invece che ricordati
1.499 giri di `cron_book` fra il 2026-06-23 e il 2026-08-25:
| esito | n | % |
|---|---|---|
| manutenzione Deribit | 3 | 0,20% |
| traceback duro | 5 | 0,33% |
| giri senza esito utile | 26 | 1,7% |
E gli episodi di venue non sono sparsi:
```
2026-07-21T09:00 Tue 502 su get_positions (Deribit giù, vista dal gateway)
2026-08-11T09:07 Tue system_maintenance 11051
2026-08-18T09:07 Tue system_maintenance 11051 (giù anche alle 10:07)
2026-08-25T09:07 Tue system_maintenance 11051
```
**Quattro martedì su dieci, tutti fra le 09:00 e le 09:07 UTC.** Il supporto Deribit conferma la
meccanica: le release escono il martedì alle 09:00 UTC. Il minuto `:07` del cron cadeva dentro
quella finestra — e ci cadeva per costruzione, non per sfortuna.
**Il punto singolo di guasto non è Deribit: siamo noi.** Dei 5 traceback, **4 sono il nostro
gateway** `cerbero-mcp.tielogic.xyz` (un 404 su `get_positions`, un 502, due `ReadTimeout`).
## I due difetti veri
### 1. Una riga sola per due guasti che vogliono azioni opposte
Fino a oggi qualunque guasto sul percorso Deribit stampava `conto non leggibile (offline)`, con la
nota di diagnosi **cablata**. Ma "Deribit in manutenzione" (aspetta, rientra da sola) e "il nostro
gateway è rotto" (ripara) non sono la stessa notizia. È **P4** violata, con l'aggravante di **P4
seconda metà**: *una nota di diagnosi cablata è peggio di nessuna nota*, perché si legge come una
misura.
Peggio ancora: il 18/08 **il codice 11051 era già dentro il processo**, nello stesso minuto,
raccolto da `livefeed`. Semplicemente non arrivava a chi decideva la gravità dell'allarme.
### 2. La finestra scoperta del disaster-SL — e l'asset che sparisce
`ensure_disaster_sl` ricostruisce un bracket incoerente **cancellando prima e ripiazzando dopo**.
Fra le due chiamate la posizione è senza alcuno stop on-book. Finché il ripiazzamento sollevava,
quell'eccezione risaliva fino a `main()`: il guasto peggiore (**posizione scoperta**) aveva la
stessa faccia di un errore qualunque.
E c'è il corollario che è successo davvero. Il **2026-07-21 alle 09:00 UTC** il 502 è arrivato
dentro `ensure_disaster_sl` su BTC. Nel log di quel giro **ETH non compare**: non è stato
ribilanciato e — quel che conta — **la sua protezione non è stata verificata**. Un guasto su un
asset toglieva la rete di sicurezza all'altro.
## Cosa è stato fatto
**`src/live/venue_probe.py` (nuovo).** Interroga l'API **pubblica** Deribit in diretta — niente
gateway, niente credenziali — e classifica: `VENUE_MANUTENZIONE` / `VENUE_GIU` / `GATEWAY` /
`IGNOTO`. La sonda parte **solo dopo un guasto**: sul percorso sano costa zero. Rispetta **P1**:
non ridichiara la firma 11051, la importa da `venue_watch.is_maintenance`.
Su **P9** (*un allarme massimo speso per un evento atteso è un allarme che non verrà letto il
giorno che è vero*): la manutenzione dentro lo slot declassa il titolo a ️. Ma con due paletti,
perché il rischio qui è costruire il silenzio proprio nell'ora in cui serve:
- declassa **solo su evidenza** della sonda, mai sull'orologio da solo → un gateway rotto di
martedì mattina resta 🛑;
- declassa **solo dentro la durata annunciata** (30 min) → il 18/08 alle 10:07 la manutenzione
aveva sforato, e torna una notizia. Stessa logica di `MAINT_GRACE_HOURS`, altra domanda.
**Isolamento per asset** in `book_execute`: un asset che esplode non ferma il ciclo, e il giro
esce con codice 2 per essere contabile.
**Stato `naked`** in `ensure_disaster_sl`: due tentativi di ripiazzamento, e se falliscono
entrambi lo stato è distinto da `place-failed` (**P5**: guasti diversi si distinguono anche quando
l'azione è la stessa). Non è "non sono riuscito a proteggere": è "**ho tolto la protezione e non
sono riuscito a rimetterla**" → 🚨 sempre, **mai** declassato da P9.
Non è stata invertita la sequenza in *piazza-poi-cancella*: due STOP `reduce_only` contemporanei
sono *probabilmente* innocui, ma "probabilmente" 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.**
**Cron `:07` → `:47`.** I due vincoli sono entrambi misurati e compatibili: fuori dai ~26s del
minuto tondo (il collettore catena si auto-satura il rate-limit per-IP: 12.186 risposte 429 in 26
ore, 96% nel minuto `:00`, misura del 30/07) **e** fuori dallo slot di release. Il commento in
`cron_book.sh` è stato riscritto: lasciarlo dire `:07` sarebbe stato il difetto §5.7 in versione
nuova.
**Previsione dichiarata (M12).** Sulle 4 finestre osservate, 3 sono rientrate entro l'ora. Il `:47`
ne avrebbe scavalcate **3 su 4**. Se martedì prossimo il `:47` becca comunque la manutenzione, la
previsione è sbagliata e lo slot non è quello che credo.
## Cosa NON è stato fatto, e perché
**Il fallback diretto ai privati Deribit è bloccato, non rinviato.** Le credenziali Deribit
esistono **solo dentro il gateway**: in locale c'è `CERBERO_TOKEN` e basta. Leggere conto e
posizioni scavalcando `cerbero-mcp` richiede **chiavi API create dall'operatore** sul conto
Deribit. È una decisione con una superficie di rischio propria (una chiave in più che può
trapelare), non un refactor.
È stato fatto il pezzo che non le richiede — la sonda pubblica — e resta a debito in §5.11 il
resto. Nota onesta: la sonda pubblica **dice di chi è il guasto, non lo aggira**. Con il gateway
giù il libro continua ad astenersi; sa solo dire perché.
## Test
**19 nuovi** (12 in `test_venue_probe.py`, 7 in `test_book_resilienza_venue.py`), nessuno tocca la
rete. Suite: **730 passati, 1 fallito** — il fallito è quello già noto di §5.9 (SKH01 `canonical`
1,9223 contro banda cablata `<1,9`, deriva dei dati, non del codice).
Controllo positivo fatto, perché un test mai visto fallire non dimostra niente: contro il codice
vecchio **5 dei 7** test di resilienza falliscono. Con una riserva da dichiarare — il test di
isolamento, sul codice vecchio, fallisce perché il modulo di diagnosi non esiste, non perché
dimostri l'isolamento rotto. La prova di *quel* difetto è il log del 21/07, dove ETH non compare.
---
## Coda: la suite di test ha mandato un allarme falso sul telefono dell'operatore
Il primo giro al nuovo minuto `:47` è andato bene — entrambi gli asset elaborati, entrambi i
disaster-SL verificati `ok`, exit 0 — ma nel log c'era una riga che non poteva essere vera:
```
💰 USCITA DI FONDI: $5,000.00 -> $667.68 (-86.6%) · cap/asset ora $333.84
```
Il conto non ha mai visto $5.000. Ha visto $598-668 da sempre.
**L'ho causato io**, lanciando `uv run pytest` per verificare le riparazioni di oggi.
`test_book_live.py::test_la_formula_del_report_ha_potenza_anche_a_libro_flat` prende solo
`monkeypatch` (niente `tmp_path`), sostituisce `shadow_report` con uno che dichiara
`real_equity=5000.0`, e chiama `book.book_report()` — che come **effetto collaterale** scrive
`data/live/equity_seen.json`. Riprodotto isolando il singolo test: watermark $667,68 → **$5.000**.
L'helper `_write_cfg` esisteva già proprio per questo — il difetto gemello è del **2026-07-26**
ma quel test, aggiunto il **21/08**, non lo usa. È la terza volta che la stessa scommessa perde.
### Non era cosmetico
Il watermark alimenta il cap di fallback:
```
cap_fallback = min(cap_fisso_di_config, watermark × frac)
```
Con $5.000 dentro: `min($3.000, $2.500) = $2.500` per asset invece di `$334`. Su un conto da $668
sono fino a **$5.000 di nozionale lordo, ~7,5x di leva**. E il ramo che ci arriva è raggiungibile:
`eq_fallback` in `book_execute` **allerta e NON blocca**, per scelta dichiarata.
Cioè: **lanciare la suite di test poteva armare esattamente il pericolo che il watermark esiste
per impedire** (nota del 26/07 in `book.py` sul cap fisso e la leva 3,35x).
Danno reale oggi: **nessuno**. Alle 09:47 l'equity era leggibile, quindi il cap è stato calcolato
sull'equity vera e il watermark è stato riscritto col valore giusto. La finestra scoperta è stata
~09:25 → 09:47, e in quella finestra non c'è stato nessun giro con equity illeggibile. È andata
bene per la direzione del caso, non perché ci fosse una protezione.
### Riparazione, e perché è strutturale
`tests/conftest.py` con una fixture **autouse** che devia `EQUITY_WATERMARK` in `tmp_path` per
**ogni** test. Chiedere a ogni autore di ricordarsi il monkeypatch è la scommessa che ha già perso
due volte: la protezione deve valere anche per il test che qualcuno scriverà domani senza aver
letto niente. Un test che vuole davvero pilotare il watermark continua a funzionare — il suo
monkeypatch esplicito gira dopo e vince.
Più una guardia in `test_cap_watermark.py` che si accende se qualcuno rimuove la fixture:
controllo positivo, perché una protezione mai vista fallire non è una protezione.
**Resta aperto il principio più largo** (§5.12): un test non dovrebbe poter scrivere in
`data/live/` *affatto*. Oggi è deviato solo il watermark — `trades.db` e `book_executions.jsonl`
sono ancora esposti allo stesso errore.
### La lezione
Un **effetto collaterale su file** trasforma un test puro in un attore sul sistema vivo. Qui il
test non menzionava il watermark, non lo importava, non lo asseriva: lo scriveva passando per una
funzione di produzione tre livelli più in basso. **Il perimetro di un test non è quello che il test
dice di toccare: è quello che tocca il codice che chiama.**
+98
View File
@@ -800,3 +800,101 @@ e il cron lo ricostruisce ogni notte, quindi la finestra hold-out si allunga e i
lezione del 2026-08-07 (*"un test che fallisce senza che il codice sia cambiato sta segnalando
che i dati non sono versionati — si guarda sotto prima di toccarlo"*), e allargare una tolleranza
per far passare un test e' esattamente la manovra che quella lezione vieta. **Resta aperto.**
---
## Addendum 2026-08-25 — La manutenzione del martedì: chi è rotto, e chi resta scoperto
Racconto completo in `docs/diary/2026-08-25-manutenzione-deribit-e-resilienza-venue.md`.
Qui restano i fatti citabili e i motivi, che valgono più degli esperimenti che li hanno prodotti.
### I numeri, contati sui log (1.499 giri, 2026-06-23 → 2026-08-25)
| esito | n | % |
|---|---|---|
| manutenzione Deribit | 3 | 0,20% |
| traceback duro | 5 | 0,33% |
| giri senza esito utile | 26 | 1,7% |
**Lo slot di release Deribit è il MARTEDÌ alle 09:00 UTC**, 15-30 min annunciati. Quattro episodi,
tutti fra le 09:00 e le 09:07: `2026-07-21` (502 visto dal gateway), `11/08`, `18/08` (giù anche
alle 10:07 → **l'annuncio non è la durata**), `25/08` (~20 min). Il cron al `:07` ci cadeva
**per costruzione**.
🚨 **Il punto singolo di guasto non è il venue, è NOSTRO.** Dei 5 traceback, **4 sono
`cerbero-mcp.tielogic.xyz`**: un 404 su `get_positions`, un 502, due `ReadTimeout`. Sostituire
Deribit non toccherebbe il guasto che ci ha davvero morso. E il gateway **non è aggirabile**: le
credenziali Deribit vivono solo lì dentro (§5.11).
### I motivi — cioè la parte che chi riapre il tema deve battere
- **Una nota di diagnosi cablata è peggio di nessuna nota (P4).** `conto non leggibile (offline)`
copriva due guasti con azioni opposte. E il 18/08 il codice **11051 era già dentro il processo**,
nello stesso minuto, raccolto da `livefeed`: non mancava il dato, mancava il **trasporto** del
dato a chi decideva la gravità.
- **Declassare un allarme atteso (P9) è giusto, ma la finestra non è una prova.** `venue_probe`
declassa **solo su evidenza** della sonda (mai sull'orologio) e **solo dentro la durata
annunciata**: oltre, RIALZA. Senza questi due paletti si costruisce il silenzio esattamente
nell'ora in cui è più probabile che serva.
- **`naked``place-failed` (P5).** Il primo è "*ho tolto la protezione e non sono riuscito a
rimetterla*", il secondo "*non sono riuscito a metterla*". Solo uno lascia una posizione aperta
senza stop on-book, e non è mai un evento atteso: 🚨 sempre.
- **Un guasto su un asset non deve togliere la rete all'altro.** Il 21/07 il ciclo è morto su BTC
e nel log **ETH non compare**: né ribilanciato, né verificato nella sua protezione. L'isolamento
per asset è la riparazione; il costo di non averlo era un'ora di posizione non controllata.
- **Riparato il silenzio, NON toccata la sequenza.** `ensure_disaster_sl` continua a cancellare
prima e piazzare dopo. *Piazza-poi-cancella* sembra più sicuro (due STOP `reduce_only` dovrebbero
essere innocui) ma "dovrebbe" non basta per cambiare il ciclo di vita dei bracket con soldi veri
senza misurarlo. **Chi lo riapre deve portare la misura, non l'intuizione.**
- **Il vincolo del minuto `:07` non era "il :07": era "fuori dai ~26s del minuto tondo"** (il
collettore catena si auto-satura il rate-limit per-IP — 12.186 risposte 429 in 26 ore, 96% nel
minuto `:00`, misura del 30/07). Letto così, il vincolo lascia libero qualunque minuto ≠ `:00`,
e il `:47` soddisfa anche il secondo (fuori dallo slot di release). **Una regola operativa va
riletta nella sua ragione, non nel suo valore** — altrimenti si difende un numero invece di un
motivo.
### Previsione dichiarata, da misurare (M12)
Sulle 4 finestre osservate 3 sono rientrate entro l'ora → il `:47` ne avrebbe scavalcate **3 su 4**.
**Se al prossimo martedì il `:47` becca comunque la manutenzione, la previsione è sbagliata** e lo
slot non è quello descritto in `venue_probe.RELEASE_*`: rileggerlo prima di spostare ancora.
### Cosa resta scoperto
1. **La sonda dice di chi è il guasto, non lo aggira.** Col gateway giù il libro continua ad
astenersi. Il fallback vero richiede **chiavi API Deribit create dall'operatore** — decisione,
non refactor, con una superficie di rischio propria (§5.11).
2. **La sonda legge se il venue RISPONDE, non cosa il venue ANNUNCIA.** Il Rulebook (ADL, perdita
socializzata, *emergency powers*) resta non sorvegliato (§5.8).
### Coda 2026-08-25 — Un test scriveva nel watermark VIVO (e ha mandato un allarme falso)
Lanciando la suite completa, `data/live/equity_seen.json` è passato da **$667,68 a $5.000**; il
giro del book alle 09:47 ha confrontato l'equity vera contro quel valore e ha spedito su Telegram
un **`💰 USCITA DI FONDI: $5.000 → $667,68 (86,6%)`** completamente falso.
Colpevole: `test_book_live.py::test_la_formula_del_report_ha_potenza_anche_a_libro_flat` — prende
solo `monkeypatch` (niente `tmp_path`), finge `real_equity=5000.0` e chiama `book.book_report()`,
che **come effetto collaterale** scrive il watermark. Riprodotto isolando il singolo test.
🚨 **Non era cosmetico.** `cap_fallback = min(cap_config, watermark × frac)`: con $5.000 dentro, il
cap sarebbe passato da **$334 a $2.500/asset** — fino a **$5.000 di nozionale lordo su un conto da
$668, ~7,5x di leva**. Il ramo che ci arriva (`eq_fallback`) **allerta e NON blocca**, per scelta
dichiarata. **Lanciare i test poteva armare esattamente il pericolo che il watermark esiste per
impedire.** Danno reale quel giorno: nessuno — alle 09:47 l'equity era leggibile, quindi il cap si
è calcolato sul vero. *È andata bene per la direzione del caso, non per una protezione.*
**I motivi da non ri-derivare:**
- **Il perimetro di un test non è quello che il test dice di toccare: è quello che tocca il codice
che chiama.** Quel test non menziona il watermark, non lo importa, non lo asserisce — lo scrive
passando per una funzione di produzione tre livelli sotto.
- **La riparazione per-test non regge, ed è dimostrato tre volte.** L'helper `_write_cfg` nasce per
questo il **26/07**; il test colpevole è del **21/08** e non lo usa; oggi la stessa scommessa ha
perso di nuovo. Quindi fixture **autouse** in `tests/conftest.py`: vale anche per il test che
qualcuno scriverà domani senza aver letto niente. Il monkeypatch esplicito di chi vuole davvero
pilotare il watermark gira dopo e continua a vincere.
- **Una protezione mai vista fallire non è una protezione** → guardia in `test_cap_watermark.py`
che si accende se la fixture viene rimossa.
- ⚠️ **Resta il principio più largo:** oggi è deviato **solo** il watermark. `data/live/trades.db` e
`data/live/book_executions.jsonl` sono esposti allo stesso errore (§5.12).
+21 -5
View File
@@ -5,11 +5,27 @@
# segnale SKH e' preso IN MEMORIA dentro book_execute (livefeed.fresh_5m): NON tocca i dati
# certificati su disco. Esecuzione reale gated da config/live.json (execution_enabled) + --execute.
#
# INSTALLATO AL MINUTO :07, NON :00 (`7 * * * *`, spostato il 2026-07-29 durante l'incidente del
# feed 5m). Motivo: IPOTESI di contesa al minuto tondo (l'ora esatta e' quando parte tutto il
# resto della VPS). ⚠️ NON e' un fix verificato — e' un ripiego da un'osservazione sola, preso
# perche' costa zero. Se il feed torna stantio anche al :07, l'ipotesi e' morta e la causa vera
# la dira' `skh_feed_errors` nel report (instrumentazione del 29/07, vedi src/live/livefeed.py).
# INSTALLATO AL MINUTO :47 (`47 * * * *`). Due vincoli, entrambi misurati — e questa riga deve
# restare d'accordo con la crontab: un commento che dichiara un minuto diverso da quello che gira
# e' lo stesso difetto del docstring "ogni ~230 minuti" (§5.7), e si paga quando qualcuno lo
# "corregge" nel verso sbagliato.
#
# 1. FUORI DAL MINUTO TONDO. Il collettore full-chain al :00 produce 12.186 risposte 429 in 26
# ore, di cui 11.700 (96%) dentro il minuto :00 — ~770 ticker + ~770 orderbook in ~26s da un
# IP solo, che si auto-saturano il rate-limit Deribit per-IP (misura del 2026-07-30). Il
# vincolo vero e' *stare fuori da quei ~26 secondi*: qualunque minuto != :00 lo soddisfa.
# Storia: :00 -> :07 il 2026-07-29 (ipotesi di contesa, dichiarata non verificata), :07 ->
# :47 il 2026-08-25.
# 2. FUORI DALLO SLOT DI RELEASE DERIBIT. Le release Deribit escono il MARTEDI' alle 09:00 UTC,
# annunciate 15-30 minuti. Il :07 cadeva dentro quella finestra, e ci e' caduto 4 volte in 63
# giorni: 2026-07-21 (che uccise anche il giro di ETH), 11/08, 18/08, 25/08. Il :47 lascia
# 47 minuti di margine dall'inizio dello slot, e 22 dal collettore catena del :25.
#
# ⚠️ PREVISIONE DA MISURARE (M12: un follow-up dichiarato contiene una previsione). Sulle 4
# finestre osservate, 3 sono rientrate entro l'ora (21/07, 11/08, 25/08 ~20 min) e una no (18/08,
# giu' anche alle 10:07). Il :47 avrebbe quindi scavalcato 3 episodi su 4. Se al prossimo martedi'
# il :47 becca comunque la manutenzione, la previsione e' sbagliata e lo slot non e' quello che
# credo: rileggere `venue_probe.RELEASE_*` prima di spostare ancora.
export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs
+98 -33
View File
@@ -33,6 +33,7 @@ sys.path.insert(0, str(PROJECT_ROOT))
from src.live.book import book_report
from src.live.execution import DeribitTrader
from src.live.notifier import notify
from src.live.venue_probe import diagnose, errori_dal_report
CONFIG = PROJECT_ROOT / "config" / "live.json"
LOG_DIR = PROJECT_ROOT / "data" / "live"
@@ -131,18 +132,35 @@ def _run():
# `online` e' falso quando il mark di BTC non viene da mainnet: la ragione sta in
# `mark_src` ("fallback close (<Eccezione>)") ma non veniva MAI stampata, quindi
# l'allerta diceva solo "conto offline" — vero e inutile. Stesso buco del feed SKH.
#
# DAL 2026-08-25 la riga dice anche DI CHI E' IL GUASTO (P4: *cosa* e *perche'*). Fino a
# ieri "conto offline" copriva due cause con azioni opposte: Deribit in manutenzione
# (attesa, rientra da sola, azione nessuna) e il nostro gateway rotto (4 dei 5 traceback
# in 63 giorni, ed e' l'unico pezzo riparabile). Vedi src/live/venue_probe.py.
srcs = "; ".join(f"{a['asset']}: {a.get('mark_src')}" for a in r["assets"])
d = diagnose(errori_dal_report(r))
print(f" conto non leggibile (offline) -> stop, non eseguo a cieco.\n mark: {srcs}")
print(f" diagnosi: {d.riga()}")
if do_execute:
notify("⚠️ BOOK LIVE — conto offline", {"nota": "salto l'esecuzione, non opero a cieco",
"mark": srcs[:300]})
# P9: un allarme MASSIMO speso per un evento ATTESO e' un allarme che non verra'
# letto il giorno che e' vero. `atteso` declassa la gravita', NON registra di meno.
notify(f"{d.gravita} BOOK LIVE — conto offline ({d.verdetto})",
{"nota": "salto l'esecuzione, non opero a cieco",
"perche": d.perche, "atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no",
"mark": srcs[:200], "prova": d.prova[:200]})
return
if r.get("pos_error"): # ONLINE ma posizione IGNOTA (read fallita -> assunta flat)
d = diagnose(errori_dal_report(r))
print(f" 🛑 POSIZIONE NON LEGGIBILE -> NON eseguo a cieco: {r['pos_error']}")
print(f" diagnosi: {d.riga()}")
if do_execute:
notify("🛑 BOOK LIVE — posizione non leggibile", {"error": r["pos_error"],
"nota": "salto l'esecuzione, non opero a cieco"})
notify(f"{d.gravita} BOOK LIVE — posizione non leggibile ({d.verdetto})",
{"error": r["pos_error"], "nota": "salto l'esecuzione, non opero a cieco",
"perche": d.perche, "atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no",
"prova": d.prova[:200]})
return
stale_days = _data_age_days(r.get("last_data"))
@@ -189,6 +207,13 @@ def _run():
trader = DeribitTrader() if do_execute else None
actions = []
# ISOLAMENTO PER ASSET (2026-08-25). Prima, un'eccezione dentro il corpo del ciclo risaliva
# fino a `main()` e uccideva il GIRO INTERO: il 2026-07-21 alle 09:00 UTC un 502 dentro
# `ensure_disaster_sl` su BTC ha fatto si' che ETH non venisse nemmeno guardato — niente
# ribilancio e, soprattutto, **nessuna verifica della sua protezione**. Un guasto su un asset
# non deve togliere la rete di sicurezza all'altro.
falliti: list[str] = []
scoperti: list[str] = []
for a in r["assets"]:
asset, inst = a["asset"], a["instrument"]
net, cur, mark = a["net_target"], a["position_usd"], a["mark"]
@@ -206,37 +231,66 @@ def _run():
print(f" {asset:<3} TP {a['tp_frac']:+.3f} · SKH {a['skh_sign']:+d}({sk_txt}) -> net ${net:+,.0f} "
f"| pos ${cur:+,.0f} -> {act}")
if do_execute and order is not None:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
newpos = trader.position_usd(inst)
for f in fills:
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
# `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:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
print(f" disaster-SL: {ds.get('state')}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
if ds.get("state") == "placed":
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
"amount": round(ds.get("amount") or 0, 4)})
elif ds.get("state") == "place-failed":
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
try:
if do_execute and order is not None:
fills = trader.rebalance_signed(inst, net, mark, min_usd=min_order)
newpos = trader.position_usd(inst)
for f in fills:
print(f" -> {f.side.upper()} {f.filled:.4f} @ ${f.price or 0:,.1f} fee {f.fee_usdc:.5f} "
f"({'OK' if f.verified else 'NON VERIFICATO: ' + f.notes})")
# `ts_utc` = ORA VERA del fill. Fino al 2026-08-23 qui c'era la data della
# BARRA di segnale (`r['last_data']`): 19 righe su 19 a 00:00:00, e un trade
# registrato SEI GIORNI prima di essere eseguito (ETH 0.04 del 14/07, scritto
# 08/07). La barra resta, sotto il suo nome: `bar_ts`.
log_event(dict(ts_utc=datetime.now(timezone.utc).isoformat(timespec="seconds"),
bar_ts=str(pd.Timestamp(r['last_data'])), asset=asset, action=act,
side=f.side, filled=f.filled, price=f.price, fee=f.fee_usdc,
verified=f.verified, notes=f.notes, net_target=net, pos_after=newpos,
tp_frac=a["tp_frac"], skh_sign=a["skh_sign"]))
det = dict(asset=asset, side=f.side, amount=round(f.filled, 4), price=round(f.price or 0, 1),
fee=round(f.fee_usdc, 5), net=round(net, 0), pos_after=round(newpos, 0))
notify(f"✅ BOOK {act}" if f.verified else "⚠️ BOOK ORDINE NON VERIFICATO",
det if f.verified else {**det, "notes": f.notes})
print(f" reconcile: pos ${newpos:,.0f}")
if do_execute:
ds = trader.ensure_disaster_sl(inst, sl_pct) # bracket su posizione NETTA (adatta long/short)
stato = ds.get("state")
print(f" disaster-SL: {stato}" + (f" @ ${ds['stop']:,.1f}" if ds.get("stop") else ""))
if stato == "placed":
notify("🛡️ BOOK disaster-SL piazzato", {"asset": asset, "stop": round(ds.get("stop") or 0, 1),
"amount": round(ds.get("amount") or 0, 4)})
elif stato == "place-failed":
notify("⚠️ BOOK disaster-SL FALLITO", {"asset": asset, "notes": ds.get("notes")})
elif stato == "naked":
# NON e' "non sono riuscito a proteggere": e' "HO TOLTO la protezione e non
# sono riuscito a rimetterla". Posizione aperta senza stop on-book -> gravita'
# massima, e MAI declassata da P9: una posizione scoperta non e' mai attesa.
scoperti.append(asset)
print(f" 🚨 {asset}: POSIZIONE SCOPERTA — {ds.get('notes')}")
notify("🚨 BOOK — POSIZIONE SCOPERTA (disaster-SL rimosso e non ripiazzato)",
{"asset": asset, "stop_voluto": round(ds.get("stop") or 0, 1),
"amount": round(ds.get("amount") or 0, 4),
"azione": "ripiazzare il bracket a mano, oppure chiudere la posizione",
"notes": str(ds.get("notes"))[:300]})
except Exception as e: # noqa: BLE001 — isolamento per asset: l'altro deve continuare
d = diagnose(errori_dal_report(r) + [f"{type(e).__name__}: {e}"])
falliti.append(asset)
act = f"{act} [FALLITO]"
print(f" 🛑 {asset}: giro fallito, PASSO ALL'ASSET SUCCESSIVO -> {type(e).__name__}: {e}")
print(f" diagnosi: {d.riga()}")
if do_execute:
notify(f"{d.gravita} BOOK — asset {asset} fallito ({d.verdetto})",
{"asset": asset, "error": f"{type(e).__name__}: {e}"[:200],
"perche": d.perche, "atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no",
"nota": "gli altri asset del book sono stati comunque elaborati"})
actions.append(act)
print()
if scoperti:
print(f" 🚨 POSIZIONI SCOPERTE (nessun disaster-SL on-book): {', '.join(scoperti)}")
if falliti:
print(f" 🛑 asset falliti in questo giro: {', '.join(falliti)}")
if not do_execute:
print(" => DRY-RUN: nessun ordine inviato." +
("" if enabled else " Per armare: config/live.json execution_enabled=true + --execute."))
@@ -244,13 +298,24 @@ def _run():
print(" => Nessuna azione: conto gia' al target netto del book.")
else:
print(" => Esecuzione completata (vedi data/live/book_executions.jsonl).")
# Uscita non-zero se il giro e' stato DEGRADATO: il log del cron deve poterlo contare senza
# rileggere la prosa. Non cambia nulla operativamente (cron_book.sh non ha `set -e`).
if scoperti or falliti:
sys.exit(2)
def main():
try:
_run()
except Exception as e:
notify("🛑 BOOK LIVE — ERRORE", {"error": f"{type(e).__name__}: {e}"})
# Anche l'ultima rete porta la DIAGNOSI, non solo il tipo d'errore: e' l'allerta che
# arriva quando tutto il resto non ha funzionato, ed e' proprio li' che serve sapere se
# riaprire il gateway o aspettare che rientri il venue (P4).
d = diagnose([f"{type(e).__name__}: {e}"])
notify(f"{d.gravita} BOOK LIVE — ERRORE ({d.verdetto})",
{"error": f"{type(e).__name__}: {e}"[:250], "perche": d.perche,
"atteso": "si'" if d.atteso else "no",
"riparabile_da_noi": "si'" if d.riparabile_da_noi else "no"})
raise
+6
View File
@@ -60,7 +60,13 @@ def reconcile() -> None:
t = d.trade_history(ins, limit=100)
print(f" venue {ins:<20}: {len(t)} trade visibili (l'endpoint TRONCA: non e' un backfill)")
except Exception as e:
# "NON LETTO" resta (P5: *non vedo* non e' *zero*), ma da solo non diceva DI CHI e' il
# guasto: il 2026-08-25 questa riga stampava `HTTPError` mentre Deribit era in
# manutenzione annunciata, indistinguibile da un gateway rotto. Ora porta il perche' (P4).
from src.live.venue_probe import diagnose
d = diagnose([f"{type(e).__name__}: {e}"])
print(f" venue: NON LETTO ({type(e).__name__}) — non e' 'zero trade', e' 'non misurato'")
print(f" diagnosi: {d.riga()}")
def report() -> None:
+34 -5
View File
@@ -197,7 +197,25 @@ class DeribitTrader(DeribitRead):
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente):
- flat -> cancella eventuali bracket orfani;
- long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione;
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap)."""
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
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
ripiazzamento sollevava, quell'eccezione risaliva fino a `main()` e il giro moriva —
cioe' il guasto peggiore (posizione SCOPERTA) si presentava con la stessa faccia di un
errore qualunque, e per giunta **saltava l'altro asset**. E' successo davvero il
2026-07-21 alle 09:00 UTC: 502 su `get_positions` dentro `ensure_disaster_sl` su BTC,
ed ETH non e' stato nemmeno guardato.
Ora: un secondo tentativo immediato, e se anche quello fallisce si ritorna lo stato
**`naked`** distinto da `place-failed` (P5: *distinguere guasti diversi anche quando
l'azione e' la stessa*), perche' qui non e' "non sono riuscito a mettere la protezione":
e' "**ho tolto la protezione e non sono riuscito a rimetterla**". Il chiamante lo
escala al massimo.
NB non si inverte l'ordine in *piazza-poi-cancella*: due STOP reduce_only contemporanei
sono probabilmente innocui (il secondo diventa no-op a posizione chiusa), ma "probabilmente"
non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza
misurarlo. Riparato il silenzio, non toccata la sequenza.
"""
pos = self.position_usd(instrument)
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
if abs(pos) < FLAT_USD:
@@ -215,10 +233,21 @@ class DeribitTrader(DeribitRead):
if want_amount and abs(amt - want_amount) < want_amount * 0.1 and stp > 0 \
and abs(stp - want_stop) / want_stop < 0.05:
return {"state": "ok", "stop": stp, "amount": amt}
cancellati = 0
for o in brackets: # incoerente o multipli -> ricostruisci UN bracket
self.cancel_order(o.get("order_id"))
f = self.place_disaster_sl(instrument, "buy" if long else "sell", want_amount, want_stop,
label=DISASTER_LABEL)
return {"state": "placed" if f.verified else "place-failed", "stop": want_stop,
"amount": want_amount, "notes": f.notes}
cancellati += 1
tentativi: list[str] = []
for _ in range(2): # la posizione e' scoperta da qui: si riprova subito
try:
f = self.place_disaster_sl(instrument, "buy" if long else "sell", want_amount,
want_stop, label=DISASTER_LABEL)
return {"state": "placed" if f.verified else "place-failed", "stop": want_stop,
"amount": want_amount, "cancelled": cancellati, "notes": f.notes}
except Exception as e: # noqa: BLE001 — si registra il motivo (P3), non si ingoia
tentativi.append(f"{type(e).__name__}: {e}")
return {"state": "naked", "stop": want_stop, "amount": want_amount,
"cancelled": cancellati,
"notes": (f"bracket cancellati ({cancellati}) e ripiazzamento fallito 2 volte: "
+ " | ".join(tentativi))}
# trade_history / open_orders ereditati da DeribitRead (read-only)
+192
View File
@@ -0,0 +1,192 @@
"""Diagnosi di un guasto sul percorso d'esecuzione: e' il VENUE, o e' il NOSTRO gateway?
PERCHE' ESISTE (2026-08-25). Fino a oggi un guasto sul percorso Deribit produceva sempre lo
stesso messaggio «conto non leggibile (offline)» a prescindere dalla causa. Ma le cause sono
due, e vogliono azioni OPPOSTE:
* **Deribit in MANUTENZIONE.** Evento ATTESO: le release Deribit escono il martedi' alle 09:00
UTC, annunciate 15-30 minuti. Rientra da sola, l'azione giusta e' NESSUNA. Misurato sul nostro
log: 4 episodi in 1.499 giri (0,27%), tutti di martedi' fra le 09:00 e le 09:07 —
2026-07-21, 2026-08-11, 2026-08-18, 2026-08-25.
* **Il NOSTRO gateway** (`cerbero-mcp.tielogic.xyz`) rotto. Sono **4 dei 5 traceback** in 63
giorni: un 404 su `get_positions`, un 502, due `ReadTimeout`. E' l'unico pezzo della catena
che possiamo riparare, ed e' quello che ha fallito di piu'.
Stessa riga di log per due guasti che vogliono due azioni diverse: e' la **regola P4** violata
(*un'allerta risponde a DUE domande: «cosa» — decide — e «perche'» ripara*), con l'aggravante
che la nota di diagnosi era CABLATA ("offline"): peggio di nessuna nota, perche' si legge come
una misura invece che come un'ipotesi.
REGOLA P1 *un sorvegliante deve DERIVARE il proprio bersaglio dal codice sorvegliato, mai
ridichiararlo.* Questo modulo **non ridichiara** la firma della manutenzione Deribit: la IMPORTA
da `venue_watch.is_maintenance`, dove vive ed e' gia' testata. Un secondo elenco di codici
d'errore, libero di divergere dal primo, e' il difetto piu' ricorrente del progetto (5 occorrenze).
COME DISTINGUE. Interroga l'API **pubblica** di Deribit in diretta: niente gateway, niente
credenziali (`public/test` e' tokenless). Se il venue risponde, il guasto e' nostro; se risponde
col codice 11051, e' manutenzione; se non risponde affatto, e' il venue ma per un'altra ragione;
se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5).
La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente.
**LA FINESTRA NON E' UNA PROVA.** `in_release_window()` dice soltanto che l'orologio e'
compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA
ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che
sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un
guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio
proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di
`venue_watch.MAINT_GRACE_HOURS` *declassa dentro la tolleranza, RIALZA oltre* applicata pero'
a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario,
non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare.
REGOLA P9 *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il
giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra
di meno. L'episodio finisce nel log e nella notifica comunque.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import datetime, timezone
import requests
from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui
# --- verdetti -----------------------------------------------------------------------------
V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051: atteso se dentro lo slot
V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione
V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile)
V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5)
# Endpoint PUBBLICO e tokenless. Sovrascrivibile per i test: nessun test tocca la rete.
DERIBIT_PUBLIC_URL = os.environ.get(
"DERIBIT_PUBLIC_URL", "https://www.deribit.com/api/v2/public/test")
PROBE_TIMEOUT = 8.0
# --- slot di release Deribit --------------------------------------------------------------
# TARATURA DICHIARATA, e va etichettata con la sua CONFIGURAZIONE (P7), non solo con la finestra:
# 4 episodi osservati sul log di `cron_book` fra il 2026-06-23 e il 2026-08-25, tutti di martedi'
# fra le 09:00 e le 09:07 UTC, piu' la conferma documentale del supporto Deribit (release del
# 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes").
# ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica
# dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta
# lo slot, questa costante mente in silenzio. E' per questo che la finestra non decide da sola —
# decide la sonda, e la finestra al massimo declassa.
RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi'
RELEASE_START_MIN = 9 * 60 # 09:00 UTC
RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia.
@dataclass(frozen=True)
class Diagnosi:
"""Il verdetto (*cosa*), la spiegazione (*perche'*), e se l'evento era atteso (P9)."""
verdetto: str
perche: str
atteso: bool
prova: str
@property
def gravita(self) -> str:
"""Emoji del titolo di notifica. `atteso` declassa, non silenzia (P9)."""
if self.atteso:
return ""
return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️")
@property
def riparabile_da_noi(self) -> bool:
"""True solo quando il guasto e' nel pezzo che possediamo."""
return self.verdetto == V_GATEWAY
def riga(self) -> str:
"""Una riga per il log: verdetto + perche'. Mai solo il verdetto."""
atteso = " [ATTESO: slot di release]" if self.atteso else ""
return f"{self.verdetto}{atteso}{self.perche}"
def in_release_window(now: datetime | None = None) -> bool:
"""L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)?
PURA e testabile. Non e' una prova di niente da sola: vedi la nota in testa al modulo.
"""
now = now or datetime.now(timezone.utc)
now = now.astimezone(timezone.utc)
if now.weekday() != RELEASE_WEEKDAY:
return False
minuti = now.hour * 60 + now.minute
return RELEASE_START_MIN <= minuti < RELEASE_START_MIN + RELEASE_LEN_MIN
def probe_public(timeout: float = PROBE_TIMEOUT) -> tuple[bool | None, str]:
"""Interroga l'API pubblica Deribit **senza gateway e senza credenziali**.
Ritorna `(raggiungibile, grezzo)`:
* `True` il venue risponde e serve (`"result"` nel corpo);
* `False` il venue risponde ma rifiuta (manutenzione 11051, 5xx, rate-limit...);
* `None` la sonda non e' arrivata (rete locale, DNS): **non vedo**, non "va bene".
Il grezzo si porta dietro apposta: e' la PROVA che finisce nell'allerta, ed e' cio' che
distingue una misura da una presunzione. Stessa scelta di `venue_watch.platform_status()`.
"""
try:
r = requests.get(DERIBIT_PUBLIC_URL, timeout=timeout)
grezzo = (r.text or "")[:400]
return (r.status_code == 200 and '"result"' in grezzo), f"HTTP {r.status_code} {grezzo}"
except Exception as e: # noqa: BLE001 — la ragione si REGISTRA, non si ingoia (P3)
return None, f"{type(e).__name__}: {e}"
def diagnose(errori_osservati: list[str] | None = None,
now: datetime | None = None,
sonda=None) -> Diagnosi:
"""Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete.
`errori_osservati` sono i motivi raccolti dallo strato che ha fallito (mark_src, errori del
feed SKH, pos_error...). Vengono guardati PRIMA della sonda perche' sono contemporanei al
guasto, mentre la sonda parte dopo: il 18/08 il codice 11051 era gia' li' dentro, nello
stesso identico minuto, e non arrivava a chi decideva la gravita' dell'allarme.
"""
errori = [e for e in (errori_osservati or []) if e]
sonda = sonda or probe_public
if is_maintenance(errori):
raggiungibile, grezzo = None, "(sonda non interrogata: 11051 gia' negli errori osservati)"
verdetto = V_MANUTENZIONE
perche = "Deribit dichiara manutenzione (codice 11051) negli errori raccolti sul posto"
else:
raggiungibile, grezzo = sonda()
if raggiungibile is None:
verdetto = V_IGNOTO
perche = ("la sonda pubblica non e' partita: non distinguo venue e gateway "
"(rete locale?)")
elif is_maintenance([grezzo]):
verdetto = V_MANUTENZIONE
perche = "l'API pubblica Deribit dichiara manutenzione (codice 11051)"
elif raggiungibile:
verdetto = V_GATEWAY
perche = ("l'API pubblica Deribit risponde: il venue sta bene, il guasto e' nel "
"NOSTRO gateway (cerbero-mcp) — e' il pezzo riparabile")
else:
verdetto = V_VENUE_GIU
perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione"
atteso = verdetto == V_MANUTENZIONE and in_release_window(now)
prova = "; ".join([*errori[:3], grezzo])[:500]
return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova)
def errori_dal_report(r: dict) -> list[str]:
"""Raccoglie i motivi gia' presenti nel report del book — li DERIVA, non li reinventa (P1).
Sono le stringhe che lo strato book ha gia' misurato mentre falliva: la sorgente del mark per
asset, gli errori del feed SKH, la lettura della posizione, il fallback di equity.
"""
out: list[str] = []
for a in r.get("assets") or []:
if a.get("mark_src"):
out.append(f"{a.get('asset')}: {a['mark_src']}")
for a, e in sorted((r.get("skh_feed_errors") or {}).items()):
out.append(f"{a}: {e}")
for chiave in ("pos_error", "eq_fallback", "skh_error"):
if r.get(chiave):
out.append(f"{chiave}: {r[chiave]}")
return out
+52
View File
@@ -0,0 +1,52 @@
"""Isolamento della suite dai FILE OPERATIVI VIVI.
PERCHE' ESISTE (2026-08-25, difetto trovato addosso). Lanciando `uv run pytest` la suite ha
scritto **$5.000 dentro `data/live/equity_seen.json`**, il watermark dell'equity reale del conto.
Al primo giro successivo del book (09:47 UTC) l'esecutore ha confrontato l'equity vera contro quel
valore e ha mandato un allarme Telegram:
💰 USCITA DI FONDI: $5,000.00 -> $667.68 (-86.6%)
**Falso, e generato da un test.** Il colpevole e'
`test_book_live.py::test_la_formula_del_report_ha_potenza_anche_a_libro_flat`: prende solo
`monkeypatch` (niente `tmp_path`), sostituisce `shadow_report` con uno che dichiara
`real_equity=5000.0` e poi chiama `book.book_report()` che come effetto collaterale scrive il
watermark. L'helper `_write_cfg` esisteva gia' proprio per questo (difetto gemello del 2026-07-26),
ma quel test, aggiunto il 21/08, non lo usa.
**NON e' un fastidio cosmetico.** Il watermark alimenta il cap di fallback:
cap_fallback = min(cap_fisso_di_config, watermark * frac)
Con `watermark = $5.000` il cap diventa `min($3.000, $2.500) = $2.500` per asset invece di `$334`:
su un conto da $668 sono fino a **$5.000 di nozionale lordo, ~7,5x di leva**. Ed e' raggiungibile:
il ramo `eq_fallback` di `book_execute` **allerta e NON blocca** per scelta dichiarata. Cioe'
lanciare la suite di test poteva **armare esattamente il pericolo che il watermark esiste per
impedire** (vedi `src/live/book.py`, nota del 2026-07-26 sul cap fisso e la leva 3,35x).
**LA RIPARAZIONE E' STRUTTURALE, NON PER-TEST.** Chiedere a ogni autore di ricordarsi il
monkeypatch e' la stessa scommessa che ha gia' perso due volte (26/07 e 21/08). Qui il
reindirizzamento e' **autouse**: vale per ogni test, anche per quelli che verranno scritti domani
da chi non ha letto questo file. Un test che vuole davvero pilotare il watermark continua a
funzionare il suo monkeypatch esplicito vince su questo, che gira prima.
"""
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
@pytest.fixture(autouse=True)
def _isola_file_operativi_vivi(tmp_path, monkeypatch):
"""Ogni test scrive il watermark in una cartella temporanea, mai in `data/live/`.
Il valore iniziale NON viene precaricato: un test che si aspetta un watermark deve
dichiararlo, e la prima lettura in assoluto non produce allarmi per costruzione
(`write_equity_watermark` ritorna None quando non c'e' un "prima").
"""
import src.live.book as book
monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json")
yield
+235
View File
@@ -0,0 +1,235 @@
"""Le due riparazioni del 2026-08-25 sul percorso con soldi veri, provate contro il codice che
dovrebbe eseguirle (P15) invece che discusse:
A. **La finestra scoperta del disaster-SL.** `ensure_disaster_sl` cancella i bracket incoerenti
PRIMA di ripiazzarne uno: se il venue cade in mezzo, la posizione resta senza alcuno stop.
Prima l'eccezione risaliva fino a `main()`, e il guasto peggiore (posizione SCOPERTA) aveva
la stessa faccia di un errore qualunque. Ora ha uno stato suo: `naked`.
B. **L'isolamento per asset.** Il 2026-07-21 alle 09:00 UTC un 502 dentro `ensure_disaster_sl`
su BTC ha ucciso il giro intero: ETH non e' stato nemmeno guardato — niente ribilancio e,
soprattutto, nessuna verifica della sua protezione.
Nessun test tocca la rete.
"""
import importlib.util
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.execution import DISASTER_LABEL, DeribitTrader, Fill
def _fresh_bar() -> str:
import pandas as _pd
return str(_pd.Timestamp.now(tz="UTC").normalize().date())
# ---------------------------------------------------------------------------
# A. Il disaster-SL: `naked` esiste, ed e' distinto da `place-failed`.
# ---------------------------------------------------------------------------
class _FakeSL(DeribitTrader):
"""DeribitTrader senza rete: si controlla quante volte il piazzamento fallisce."""
def __init__(self, pos_usd, brackets, fallimenti_piazzamento=0, verified=True):
self._pos = float(pos_usd)
self._brackets = list(brackets)
self._da_fallire = int(fallimenti_piazzamento)
self._verified = verified
self.cancellati = []
self.piazzamenti = 0
def position_usd(self, instrument):
return self._pos
def open_orders(self, instrument):
return self._brackets
def cancel_order(self, order_id):
self.cancellati.append(order_id)
return {}
def mark_price(self, instrument):
return 80000.0
def place_disaster_sl(self, instrument, side_held, amount, stop_price, label=DISASTER_LABEL):
self.piazzamenti += 1
if self._da_fallire > 0:
self._da_fallire -= 1
raise RuntimeError("502 Server Error: Bad Gateway")
return Fill(instrument=instrument, side="sell", amount=amount, filled=amount,
price=stop_price, fee_usdc=0.0, order_id="sl1", state="open",
verified=self._verified, notes="")
def _bracket_incoerente():
"""Un bracket con size sbagliata -> forza il ramo 'cancella e ricostruisci'."""
return [{"order_id": "vecchio", "label": DISASTER_LABEL, "amount": 0.0001,
"trigger_price": 1.0}]
def test_ripiazzamento_ok_al_primo_colpo_resta_placed():
"""Il percorso sano non deve essere cambiato dalla riparazione."""
t = _FakeSL(pos_usd=112.0, brackets=_bracket_incoerente())
ds = t.ensure_disaster_sl("BTC_USDC-PERPETUAL", 0.30)
assert ds["state"] == "placed"
assert t.cancellati == ["vecchio"] and t.piazzamenti == 1
def test_un_fallimento_isolato_viene_ritentato_e_la_posizione_resta_protetta():
"""La posizione e' scoperta fra il cancel e il place: si riprova SUBITO, non al giro dopo."""
t = _FakeSL(pos_usd=112.0, brackets=_bracket_incoerente(), fallimenti_piazzamento=1)
ds = t.ensure_disaster_sl("BTC_USDC-PERPETUAL", 0.30)
assert ds["state"] == "placed" # il secondo tentativo ha funzionato
assert t.piazzamenti == 2
def test_due_fallimenti_lasciano_lo_stato_naked_non_una_eccezione():
"""IL TEST CHE CONTA. Prima qui volava un'eccezione e moriva il giro (e l'altro asset).
Ora: stato `naked`, con dentro il fatto che i bracket erano gia' stati cancellati."""
t = _FakeSL(pos_usd=112.0, brackets=_bracket_incoerente(), fallimenti_piazzamento=2)
ds = t.ensure_disaster_sl("BTC_USDC-PERPETUAL", 0.30)
assert ds["state"] == "naked"
assert ds["cancelled"] == 1 # la protezione era stata TOLTA
assert "502" in ds["notes"] # il motivo si registra (P3)
assert t.piazzamenti == 2
def test_naked_e_place_failed_sono_guasti_DIVERSI():
"""P5: distinguere guasti diversi anche quando l'azione e' la stessa. `place-failed` = l'ordine
e' partito ma non e' verificato; `naked` = la protezione e' stata rimossa e non rimessa."""
t = _FakeSL(pos_usd=112.0, brackets=_bracket_incoerente(), verified=False)
assert t.ensure_disaster_sl("BTC_USDC-PERPETUAL", 0.30)["state"] == "place-failed"
t2 = _FakeSL(pos_usd=112.0, brackets=_bracket_incoerente(), fallimenti_piazzamento=2)
assert t2.ensure_disaster_sl("BTC_USDC-PERPETUAL", 0.30)["state"] == "naked"
def test_posizione_flat_non_puo_diventare_naked():
"""A libro flat non c'e' niente da proteggere: il ramo non deve nemmeno provarci."""
t = _FakeSL(pos_usd=0.0, brackets=_bracket_incoerente(), fallimenti_piazzamento=2)
ds = t.ensure_disaster_sl("BTC_USDC-PERPETUAL", 0.30)
assert ds["state"] == "flat" and t.piazzamenti == 0
# ---------------------------------------------------------------------------
# B. Isolamento per asset dentro book_execute._run().
# ---------------------------------------------------------------------------
def _carica_book_execute():
spec = importlib.util.spec_from_file_location(
"book_execute", PROJECT_ROOT / "scripts/live/book_execute.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _report_due_asset():
def _asset(nome, inst):
return dict(asset=nome, instrument=inst, tp_frac=0.45, skh_sign=0, skh_state="flat",
net_target=113.0, position_usd=112.0, mark=80000.0, order=None)
return dict(last_data=_fresh_bar(), online=True, real_equity=667.0, equity=667.0,
eq_basis="test", cap_per_asset=334.0,
assets=[_asset("BTC", "BTC_USDC-PERPETUAL"), _asset("ETH", "ETH_USDC-PERPETUAL")],
orders=[])
class _DiagnosiFinta:
verdetto = "GATEWAY"
perche = "l'API pubblica Deribit risponde: il guasto e' nel nostro gateway"
atteso = False
prova = "prova"
gravita = "🛑"
riparabile_da_noi = True
def riga(self):
return f"{self.verdetto}{self.perche}"
def _diagnosi_finta(*a, **k):
return _DiagnosiFinta()
class _TraderCheEsplodeSuBTC:
"""Riproduce il 2026-07-21: 502 dentro ensure_disaster_sl su BTC, ETH sano."""
def __init__(self):
self.visti = []
def rebalance_signed(self, *a, **k):
return []
def position_usd(self, instrument):
return 112.0
def ensure_disaster_sl(self, instrument, sl_pct):
self.visti.append(instrument)
if instrument.startswith("BTC"):
raise RuntimeError("502 Server Error: Bad Gateway for url: .../get_positions")
return {"state": "ok", "stop": 1755.8, "amount": 0.06}
def test_un_asset_che_esplode_non_impedisce_all_altro_di_essere_protetto(monkeypatch, capsys):
"""IL TEST CHE CONTA. Prima ETH non veniva nemmeno guardato."""
mod = _carica_book_execute()
trader = _TraderCheEsplodeSuBTC()
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: _report_due_asset())
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: trader)
monkeypatch.setattr(mod, "diagnose", _diagnosi_finta)
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 as e:
assert e.code == 2 # giro DEGRADATO, dichiarato all'uscita
out = capsys.readouterr().out
assert trader.visti == ["BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"] # ETH e' stato guardato
assert "PASSO ALL'ASSET SUCCESSIVO" in out
assert "asset falliti in questo giro: BTC" in out
assert any("asset BTC fallito" in t for t, _ in alerts)
def test_posizione_scoperta_esce_con_codice_due_e_allarme_massimo(monkeypatch, capsys):
"""Una posizione senza stop on-book non e' MAI un evento atteso: gravita' massima sempre."""
mod = _carica_book_execute()
class _TraderNaked:
def rebalance_signed(self, *a, **k):
return []
def position_usd(self, instrument):
return 112.0
def ensure_disaster_sl(self, instrument, sl_pct):
return {"state": "naked", "stop": 56000.0, "amount": 0.0014, "cancelled": 1,
"notes": "bracket cancellati (1) e ripiazzamento fallito 2 volte"}
alerts = []
monkeypatch.setattr(mod, "book_report", lambda **k: _report_due_asset())
monkeypatch.setattr(mod, "notify", lambda t, d=None: alerts.append((t, d)))
monkeypatch.setattr(mod, "DeribitTrader", lambda *a, **k: _TraderNaked())
monkeypatch.setattr(mod, "diagnose", _diagnosi_finta)
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 "POSIZIONI SCOPERTE" in out
titoli = [t for t, _ in alerts]
assert any("POSIZIONE SCOPERTA" in t and t.startswith("🚨") for t in titoli)
assert sum("POSIZIONE SCOPERTA" in t for t in titoli) == 2 # uno per asset, nessuno perso
+27
View File
@@ -163,3 +163,30 @@ def test_la_soglia_di_salto_e_congelata():
def test_book_execute_avvisa_sul_salto():
src = (ROOT / "scripts" / "live" / "book_execute.py").read_text()
assert "equity_jump" in src and "VERSAMENTO" in src
# ---------------------------------------------------------------------------
# GUARDIA: la suite non deve poter scrivere il watermark VIVO (2026-08-25).
# ---------------------------------------------------------------------------
def test_la_suite_non_scrive_il_watermark_vivo():
"""Controllo POSITIVO della protezione in `tests/conftest.py`.
Il 2026-08-25 la suite ha scritto $5.000 in `data/live/equity_seen.json` e il giro successivo
del book ha mandato un allarme Telegram falso ("USCITA DI FONDI -86,6%"). Peggio: con quel
valore il cap di fallback sarebbe passato da $334 a $2.500 per asset ~7,5x di leva su un
conto da $668, per un ramo (`eq_fallback`) che per scelta dichiarata NON blocca.
Questo test NON verifica il file vivo (dipenderebbe dal conto e da chi ha girato prima):
verifica che dentro un test il bersaglio sia gia' stato deviato fuori da `data/live/`.
Se qualcuno rimuove la fixture autouse, questo si accende.
"""
import src.live.book as book
vivo = book.PROJECT_ROOT / "data" / "live" / "equity_seen.json"
assert book.EQUITY_WATERMARK != vivo, \
"la fixture autouse di conftest.py non sta deviando EQUITY_WATERMARK"
assert "data/live" not in str(book.EQUITY_WATERMARK).replace("\\", "/")
# e la scrittura deve finire davvero nel bersaglio deviato, non nel vivo
book.write_equity_watermark(5000.0)
assert book.EQUITY_WATERMARK.exists()
assert "5000" in book.EQUITY_WATERMARK.read_text()
+150
View File
@@ -0,0 +1,150 @@
"""Test della diagnosi di guasto sul percorso Deribit (`src/live/venue_probe.py`) e delle due
riparazioni che ne dipendono: la finestra scoperta del disaster-SL e l'isolamento per asset.
NESSUN TEST TOCCA LA RETE: la sonda pubblica e' iniettabile: `diagnose(..., sonda=...)`.
Cosa difendono, in ordine di gravita':
1. **`naked`**: il disaster-SL cancellato e non ripiazzato deve avere uno stato PROPRIO, diverso
da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta.
2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto.
3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5).
4. **P9**: `atteso` declassa la gravita' SOLO su evidenza di manutenzione, e SOLO dentro lo slot.
"""
import sys
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose,
errori_dal_report, in_release_window)
MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":'
'{"message":"system_maintenance","code":11051}}')
# --- sonde finte -----------------------------------------------------------------------------
def _sonda_ok():
return True, 'HTTP 200 {"jsonrpc":"2.0","result":{"version":"1.2.26"}}'
def _sonda_manutenzione():
return False, MANUTENZIONE_GREZZA
def _sonda_rifiuta():
return False, "HTTP 500 <html>bad gateway</html>"
def _sonda_morta():
return None, "ConnectionError: dns"
# ---------------------------------------------------------------------------
# 1. La diagnosi distingue i tre guasti che vogliono azioni diverse.
# ---------------------------------------------------------------------------
def test_venue_sano_significa_che_il_guasto_e_nostro():
"""Il caso che vale di piu': 4 dei 5 traceback in 63 giorni erano il NOSTRO gateway.
Se l'API pubblica risponde, il venue sta bene e il pezzo rotto e' quello riparabile."""
d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok)
assert d.verdetto == V_GATEWAY
assert d.riparabile_da_noi is True
assert d.atteso is False # un gateway rotto non e' MAI atteso
assert d.gravita == "🛑"
assert "gateway" in d.perche.lower()
def test_manutenzione_riconosciuta_dalla_sonda():
d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE
assert d.riparabile_da_noi is False # aspettare, non riparare
def test_manutenzione_riconosciuta_dagli_errori_gia_raccolti_senza_sondare():
"""Il 18/08 il codice 11051 era GIA' negli errori dello strato book, nello stesso minuto, e
non arrivava a chi decideva la gravita'. Ora lo si guarda per primo: la sonda non parte
nemmeno (qui lo verifichiamo con una sonda che esploderebbe)."""
def _sonda_vietata():
raise AssertionError("la sonda non doveva partire: l'11051 era gia' negli errori")
d = diagnose(['OnMaintenance: deribit {"error":{"code":11051}}'], sonda=_sonda_vietata)
assert d.verdetto == V_MANUTENZIONE
def test_venue_giu_non_e_manutenzione():
d = diagnose([], sonda=_sonda_rifiuta)
assert d.verdetto == V_VENUE_GIU
assert d.atteso is False # non dichiara manutenzione -> resta rumoroso
def test_sonda_morta_e_ignoto_non_va_tutto_bene():
"""P5: «non vedo» non e' «va tutto bene». Il verdetto deve restare IGNOTO, mai GATEWAY."""
d = diagnose([], sonda=_sonda_morta)
assert d.verdetto == V_IGNOTO
assert d.riparabile_da_noi is False
assert d.atteso is False
def test_la_prova_viaggia_sempre_con_il_verdetto():
"""Un verdetto senza prova si legge come opinione. La riga deve portare il perche' (P4)."""
d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok)
assert d.prova # non vuota
assert "" in d.riga() and d.verdetto in d.riga()
# ---------------------------------------------------------------------------
# 2. P9 — `atteso` declassa, ma solo su EVIDENZA e solo DENTRO lo slot.
# ---------------------------------------------------------------------------
def test_finestra_di_release_martedi_nove_utc():
dentro = datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc) # martedi', l'episodio vero
assert in_release_window(dentro) is True
assert in_release_window(datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)) is True
assert in_release_window(datetime(2026, 8, 25, 9, 29, tzinfo=timezone.utc)) is True
assert in_release_window(datetime(2026, 8, 25, 9, 30, tzinfo=timezone.utc)) is False # sforata
assert in_release_window(datetime(2026, 8, 25, 8, 59, tzinfo=timezone.utc)) is False
assert in_release_window(datetime(2026, 8, 26, 9, 7, tzinfo=timezone.utc)) is False # mercoledi'
def test_manutenzione_dentro_lo_slot_e_attesa_e_declassa():
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc),
sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE and d.atteso is True
assert d.gravita == "" # declassato, NON silenziato
assert "ATTESO" in d.riga()
def test_manutenzione_che_sfora_lo_slot_torna_rumorosa():
"""Il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora: alle 10:07 la
manutenzione non era piu' l'evento annunciato, ed e' di nuovo una notizia."""
d = diagnose([], now=datetime(2026, 8, 18, 10, 7, tzinfo=timezone.utc),
sonda=_sonda_manutenzione)
assert d.verdetto == V_MANUTENZIONE and d.atteso is False
assert d.gravita != ""
def test_un_guasto_vero_di_martedi_mattina_non_viene_declassato():
"""Il rischio della finestra: costruire il silenzio proprio nell'ora in cui serve. Un gateway
rotto dentro lo slot di release deve restare al massimo della gravita'."""
d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc), sonda=_sonda_ok)
assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑"
# ---------------------------------------------------------------------------
# 3. Gli errori si DERIVANO dal report, non si reinventano (P1).
# ---------------------------------------------------------------------------
def test_errori_dal_report_raccoglie_le_misure_gia_fatte():
r = {"assets": [{"asset": "BTC", "mark_src": "fallback close (HTTPError)"},
{"asset": "ETH", "mark_src": "mainnet"}],
"skh_feed_errors": {"BTC": "OnMaintenance: deribit 11051"},
"pos_error": "posizione non leggibile: api 500"}
errori = errori_dal_report(r)
assert any("fallback close" in e for e in errori)
assert any("11051" in e for e in errori)
assert any("api 500" in e for e in errori)
# e la catena completa deve concludere MANUTENZIONE senza nemmeno sondare
assert diagnose(errori, sonda=_sonda_ok).verdetto == V_MANUTENZIONE
def test_report_vuoto_non_esplode():
assert errori_dal_report({}) == []