# 2026-07-29 — L'allerta funzionava, la sua CAUSA era una riga cablata **Fatto operativo del giorno.** Il 29/07 il feed 5m che alimenta il segnale SKH01 e' ricaduto sul feed certificato in **6 giri orari su 8** fra le 05:00 e le 12:00 UTC. L'allerta cablata il 26/07 ha segnalato **ogni volta** — ha fatto esattamente il suo lavoro. Poi ha stampato una causa che non aveva misurato. **Codice:** `src/live/livefeed.py` (`last_fetch_error`), `src/live/book.py` (`skh_feed_errors` nel report), `scripts/live/book_execute.py` (allerta con la causa), `scripts/cron_book.sh` (nota sul minuto). **Test:** `tests/test_skh_feed_freshness.py` (11 → **16**). **Book, pesi, config, strategia: INVARIATI.** Nessuna posizione aperta durante l'incidente (book flat: TP01 risk-off, SKH01 flat). --- ## 1. Cosa e' successo, misurato Dal log `logs/cron_book.log` (la VPS si e' riavviata alle **04:11 UTC**, kernel 6.8.0-134 → 6.8.0-136; il giro delle 04:00 non e' partito perche' la macchina era gia' giu' dalle 03:55): ``` giro (UTC) eta' ultima barra 5m durata del giro 05:00 265 min 46 s 06:00 325 24 07:00 385 29 08:00 445 26 09:00 505 34 10:00 565 25 11:00 0 (fresco) 16 <- ma qui e' fallita la lettura del CONTO 12:00 685 25 12:07 0 (fresco) 10 ``` L'eta' cresce di **60 minuti a ogni giro**: e' la firma esatta del fallback, cioe' l'ultima barra resta quella del feed certificato (rebuild giornaliero delle 00:30) mentre l'orologio avanza. Al picco la latenza d'uscita di SKH01 e' passata da **~1 ora a ~11 ore**. ## 2. Cosa ha funzionato e cosa no **Ha funzionato:** l'instrumentazione del 26/07. `feed_age_minutes` → `skh_feed_age_min` → allerta Telegram, 6 volte su 6, con la scelta dichiarata allora (**allerta, non blocca** — bloccare fermerebbe anche il ribilancio di TP01, nettato sullo stesso strumento, per un guasto di rete). **Non ha funzionato:** la riga che diceva *perche'*. Era questa, **cablata**: ```python "nota": "fresh_5m e' ricaduto sul feed certificato (fetch pubblico KO)" ``` Non veniva da nessuna misura: veniva stampata identica in ogni caso, compreso quello in cui la coda fresca **era** attaccata e il vecchio era il certificato stesso. E' una presunzione con l'aspetto di un dato — la forma di difetto che questo progetto ha gia' pagato tre volte in docstring di produzione (26/07, tre correzioni in due giorni). Peggio: la causa vera **non era recuperabile a posteriori**, per costruzione. In `_fetch_recent_5m` l'eccezione di pagina viene ingoiata con un `break` e la funzione ritorna quel che ha raccolto; se a fallire e' la **prima** pagina il risultato e' un DataFrame vuoto, che a valle e' indistinguibile da "il venue non ha barre". Quando il guasto e' rientrato, non resta niente da interrogare — e infatti alle 12:14, provando a mano, `fresh_5m` rispondeva in 1.7 s senza errori. ## 3. La correzione: registrare la causa, non indovinarla `livefeed._LAST_ERROR` + `last_fetch_error()`. Il fallback resta **silenzioso come comportamento** (mai operare a cieco > mai operare, scelta del 26/07 confermata): quello che smette di essere silenzioso e' il **perche'**. La causa risale fino al report (`skh_feed_errors`, per asset) e fino all'allerta, che ora dice cosa e' successo invece di ipotizzarlo. Tre guasti che prima erano la stessa riga, e ora sono tre righe diverse: | caso | cosa si vede oggi | |---|---| | eccezione al fetch (rete, rate limit, venue) | `RuntimeError: 429 ... (pagina 1, BTC/USD:BTC)` | | risposta senza barre, nessuna eccezione | `nessuna barra restituita da BTC/USD:BTC (risposta vuota...)` | | **coda attaccata, ma il certificato e' vecchio** | `coda fresca attaccata: il feed certificato stesso e' vecchio` | La terza riga e' quella che la nota cablata **negava**: e' il caso in cui il colpevole non e' la rete ma il rebuild giornaliero, cioe' il feed-freeze del 14/07 in un'altra veste. ⚠️ Il caso intermedio, che un test pigro salta: l'errore a **meta' paginazione**. La coda parziale viene attaccata comunque — quindi il feed non e' "caduto", e' solo piu' vecchio del dovuto — e siccome la paginazione va **in avanti**, cio' che manca sono le barre **piu' recenti**. E' proprio il caso in cui l'eta' da sola direbbe poco. Blindato in `test_causa_registrata_anche_con_coda_TRONCATA`. Blindato anche che la causa **non sopravviva a una chiamata riuscita**: un errore appiccicato farebbe allertare su un guasto gia' rientrato, e un'allerta che grida senza motivo e' un'allerta che si smette di leggere. ## 4. Sull'onestà della diagnosi: la causa del 29/07 resta IGNOTA Ed e' importante scriverlo, perche' la tentazione era chiudere il cerchio con una spiegazione plausibile. Cio' che si sa e' solo **circostanziale**: - i giri falliti durano quanto quelli riusciti (**16-46 s in entrambi gli stati**) → errore **immediato**, non timeout; - la finestra si apre subito dopo il **riavvio delle 04:11**, ma non si chiude con esso (alle 11:00 funziona, alle 12:00 no); - lo stesso giorno il percorso del **conto** — rete diversa, via `cerbero-mcp`, stesso venue a valle — rispondeva `ReadTimeout(15s)` / `404` / `502`; - i due percorsi falliscono **in alternanza**, non insieme (11:00 feed ok + conto ko; 12:00 conto ok + feed ko). Ipotesi compatibili e **non distinguibili a posteriori**: rate limit per-IP del venue, contesa di rete/CPU sulla VPS al minuto tondo, degrado post-riavvio. Una prima stesura di questa modifica scriveva nei commenti "*la causa vera era il rate limit Deribit per-IP saturato da un altro progetto sulla stessa VPS*" come se fosse un fatto: **rimossa**. Sarebbe stato lo stesso difetto della riga che stavo correggendo, scritto meglio. ## 5. Il ripiego sul cron, dichiarato come tale Il job e' stato spostato da `0 * * * *` a **`7 * * * *`** durante l'incidente (ultimo giro al minuto tondo: 12:00; primo al :07: 12:07, riuscito). Motivo: l'ipotesi della contesa al minuto tondo, che e' quando parte tutto il resto della macchina. ⚠️ **Non e' un fix verificato: e' un ripiego da UNA osservazione**, preso perche' costa zero. Un giro riuscito al :07 contro sei falliti al :00 non e' un esperimento — e' un punto. Se il feed torna stantio anche al :07, l'ipotesi e' morta; e in entrambi i casi la risposta la dara' `skh_feed_errors` alla prossima occorrenza, non un altro ragionamento. La nota sta in testa a `scripts/cron_book.sh`, perche' la riga di crontab vive fuori dal repo e una mitigazione invisibile e' una mitigazione che verra' rimossa per sbaglio. ## 6. Sottoprodotto: anche "conto offline" diceva solo che era offline Stesso buco, altro ramo. Quando `online` e' falso la ragione c'e' gia' — sta in `mark_src` (`"fallback close ()"`) — ma non veniva **mai** stampata: l'allerta diceva *"conto offline, salto l'esecuzione"*, che e' vero e inutile. Ora l'allerta porta `mark_src` per asset. Il comportamento (fermarsi, non operare a cieco) e' invariato. --- ## Regole 1. **Una nota di diagnosi cablata e' peggio di nessuna nota.** Nessuna nota manda a guardare i dati; una nota sbagliata manda a guardare la pista sbagliata, e sembra una misura. 2. **Se un errore viene ingoiato per non bloccare, va registrato nello stesso punto in cui lo si ingoia.** Fra il `break` e la fine dell'incidente non c'e' un secondo momento buono: quando la diagnosi diventa urgente, il guasto e' gia' rientrato. 3. **Un'allerta risponde a due domande diverse — *cosa* e *perche'*.** La prima decide (si opera o no), la seconda ripara. Misurare solo la prima costa un'intera occorrenza del guasto. 4. **Una mitigazione basata su un'osservazione sola si applica pure, ma si scrive che lo e'** — altrimenti fra tre mesi e' una scelta di progetto di cui nessuno ricorda la fragilita'. 5. **Distinguere i guasti anche quando l'azione e' la stessa.** "Rete ko", "venue senza barre" e "certificato vecchio" portano tutti a "non mi fido del segnale", ma a tre riparazioni diverse.