venue_watch: disciplina degli allarmi, specifiche contratto controllate, terza referenza
Il 18/08 sono usciti quattro 🚨 identici con 'PRIMO PASSO: prelievo di prova' per una manutenzione Deribit annunciata, e tutti e quattro DOPO che il book aveva gia' ripreso a eseguire. Il book si era comportato bene (due giri di astensione, 'non eseguo a cieco'); il difetto era negli allarmi. 1. Il blocco piattaforma era un if secco senza memoria, accanto a un rilevatore tarato con cura che allerta una volta per streak. Ora passa da lock_step(), pura e testata: manutenzione entro tolleranza = un solo avviso morbido, che sfora = un solo 🚨 ('ha SFORATO'), blocco inspiegato = 🚨 subito, rientro annunciato una volta. public/status illeggibile NON e' un rientro. 2. Il messaggio diceva 'locked=true' cablato mentre il parser accetta anche 'partial': dichiarava un valore che non aveva letto, e il runbook manda a controllare proprio quel campo. Ora stampa e salva il valore grezzo. 3. Deribit ha cambiato tick e size dei perpetual lineari USDC il 18/08 e la tabella _CONTRACT, cablata a mano, non se n'era accorta. Nessun ordine rifiutato solo perche' i cambi erano riduzioni: e' andata bene per la direzione, non perche' ce ne fossimo accorti. Tabella aggiornata ai valori verificati e aggiunto check_specs(), che gira nel venue_watch orario (fuori dal percorso ordini) e dichiara la direzione: 'granularita'' = conforme, 'rifiuto' = il venue rifiuta. La tabella dichiarata resta l'autorita' per costruire un ordine, il venue e' il controllore. 4. Aggiunta Kraken come terza referenza: Coinbase ha comprato Deribit, e una referenza che e' la casa madre non misura piu' se Deribit scolla dal mondo. THRESHOLD_BPS e PERSIST_HOURS invariati, ma il consenso ora e' su tre serie e quel numero non e' ri-misurato: dichiarato nel codice e nel diario. La direzione dell'errore e' 'allerta di meno', non 'grida al lupo'. Test 23 -> 35 su venue_watch, suite intera 618 verdi. Book, pesi e config invariati; dry-run del book verificato. Diario: docs/diary/2026-08-19-*. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
# 2026-08-19 — Quattro 🚨 per una manutenzione annunciata, e cosa c'era dietro
|
||||
|
||||
**Richiesta:** *"su Adp_VPS mi sono arrivati questi messaggi"* — quattro allarmi VENUE WATCH del
|
||||
18/08, incollati senza domanda. La domanda vera era: e' successo qualcosa di grave?
|
||||
|
||||
**No.** Ma il modo in cui l'ho scoperto ha rivelato tre difetti, e uno riguarda gli ordini.
|
||||
|
||||
**Toccati:** `src/live/venue_watch.py`, `src/live/deribit.py` (tabella `_CONTRACT` + `check_specs`),
|
||||
`scripts/live/venue_watch.py`. **Test:** `tests/test_venue_watch.py` 23 → **35**; suite intera
|
||||
**618 verdi**. **Book, pesi, `config/live.json`, THRESHOLD_BPS, PERSIST_HOURS: INVARIATI.**
|
||||
|
||||
---
|
||||
|
||||
## 0. Cosa era successo davvero
|
||||
|
||||
Manutenzione Deribit **annunciata il 14/08 per il 18/08 alle 09:00 UTC**, downtime dichiarato
|
||||
**15–30 minuti**. Il book si e' astenuto ai giri delle 09:07 e 10:07 UTC — `conto non leggibile
|
||||
(offline) -> stop, non eseguo a cieco` — e alle 11:07 eseguiva di nuovo. **Il sistema ha fatto la
|
||||
cosa giusta.**
|
||||
|
||||
I 572 minuti di "feed SKH stantio" non erano una deriva nascosta: con la coda 5m non attaccabile
|
||||
il book e' ripiegato sul feed certificato, che e' di suo vecchio di ~9,5 ore. Prima (08:07) e dopo
|
||||
(11:07) diceva `fresco (0 min)`.
|
||||
|
||||
Ma `venue_watch` ha visto la piattaforma bloccata fino alle **14:07 UTC**: la manutenzione ha
|
||||
sforato l'annuncio **di ore, non di minuti**. E ha mandato quattro 🚨 identici, **tutti dopo che
|
||||
il book aveva gia' ripreso**.
|
||||
|
||||
## 1. Perche' quattro messaggi uguali: l'allarme non aveva memoria
|
||||
|
||||
Il rilevatore di dislocazione e' tarato con cura — 100 bps, 4 ore a segno costante, «zero falsi
|
||||
allarmi in 8 anni», controllo positivo su Bitfinex 2018-19 — e la macchina a stati `step()`
|
||||
allerta **una volta per streak** e poi tace.
|
||||
|
||||
Accanto, il blocco piattaforma era un `if` secco senza stato:
|
||||
|
||||
```python
|
||||
if obs["platform_locked"]:
|
||||
report["alerts"].append("Deribit public/status: PIATTAFORMA BLOCCATA (locked=true)")
|
||||
```
|
||||
|
||||
Nessuna persistenza, nessuna deduplica, nessuna nozione di manutenzione. Un rilevatore disciplinato
|
||||
con accanto uno che grida al lupo ogni ora, **e con lo stesso titolo**: *«PRIMO PASSO: prelievo di
|
||||
prova»*. Il costo non e' il fastidio: e' che un allarme massimo speso per un evento atteso e'
|
||||
un allarme che non verra' letto il giorno che e' vero — l'unico giorno che conta.
|
||||
|
||||
Ora il lock passa da `lock_step()`, pura e testata, con gli stessi livelli degli altri stati:
|
||||
|
||||
| condizione | esito |
|
||||
|---|---|
|
||||
| bloccata + manutenzione dichiarata, entro `MAINT_GRACE_HOURS` (2) | ⚠️ **una volta**, «attesa, non allarme» |
|
||||
| manutenzione che **sfora** la tolleranza | 🚨 **una volta**, «ha SFORATO 2h» |
|
||||
| bloccata **senza** manutenzione dichiarata | 🚨 subito, una volta |
|
||||
| rientrata | avviso di rientro, una volta |
|
||||
| `public/status` **illeggibile** | **niente**: le ore restano dove sono |
|
||||
|
||||
Rigiocando il 18/08: quattro messaggi identici diventano **⚠️ → 🚨 (ha sforato) → rientrato**. Tre
|
||||
messaggi che dicono tre cose diverse.
|
||||
|
||||
⚠️ L'ultima riga della tabella e' quella che mi premeva: dichiarare «e' rientrato» perche' non si
|
||||
e' riusciti a *guardare* sarebbe la bugia peggiore possibile in questo file. `locked is None` non
|
||||
azzera niente e non annuncia niente.
|
||||
|
||||
⚠️ E la tolleranza non e' un'assoluzione: la manutenzione declassa l'allarme **solo dentro la
|
||||
finestra**, e oltre lo **rialza**. Il 18/08 e' la ragione per cui la regola non poteva essere
|
||||
«se e' manutenzione, stai zitto»: Deribit aveva annunciato mezz'ora ed e' rimasta bloccata per ore.
|
||||
Una manutenzione che dura sei volte l'annuncio **e' di nuovo una notizia.**
|
||||
|
||||
## 2. Il messaggio dichiarava un valore che non aveva letto
|
||||
|
||||
Il parser accetta qualunque valore diverso da `false`/`none` — Deribit risponde anche `partial` —
|
||||
e il testo era cablato: `(locked=true)`. Il runbook al passo 2 manda a controllare *proprio quel
|
||||
campo*: mandarci qualcuno con in testa la stringa sbagliata e' peggio che non dirgliela.
|
||||
|
||||
Adesso `platform_status()` restituisce il valore grezzo, il messaggio lo stampa e lo **stato lo
|
||||
salva**. Del 18/08 non e' piu' ricostruibile quale fosse: nessuno lo registrava.
|
||||
|
||||
## 3. Il difetto che poteva costare: le specifiche contratto
|
||||
|
||||
Cercando altro, il confronto con gli annunci Deribit ha trovato una cosa che nessun allarme
|
||||
sorvegliava. Il 18/08 dopo le 09:00 UTC Deribit ha aggiornato **tick, contract size e minimum
|
||||
order size dei perpetual lineari USDC** (annuncio del 14/08). La tabella `_CONTRACT` di
|
||||
`deribit.py`, che e' quella con cui si costruiscono gli ordini, era **cablata a mano** e non se
|
||||
n'e' accorta:
|
||||
|
||||
| | dichiarato | venue dal 18/08 |
|
||||
|---|---|---|
|
||||
| BTC tick | 0.5 | **0.1** |
|
||||
| ETH tick | 0.05 | **0.01** |
|
||||
| ETH min/step | 0.001 | **0.0001** |
|
||||
|
||||
Nessun ordine e' stato rifiutato, e la ragione **non e' merito nostro**: i cambi erano *riduzioni*,
|
||||
e un valore piu' grosso resta conforme. Il costo effettivo era di granularita' — su ETH
|
||||
l'incremento minimo restava **$1.92** invece di **$0.19** su un conto da $597 (0,32% contro 0,03%).
|
||||
|
||||
⚠️ Il giorno che Deribit **alza** un minimo, la stessa cecita' fa **rifiutare gli ordini**, e non
|
||||
c'e' niente che lo dica prima.
|
||||
|
||||
**Scelta di disegno: la tabella dichiarata resta l'autorita' per costruire un ordine; il venue
|
||||
diventa il controllore, non la fonte.** Prendere i valori dall'API dentro il percorso di
|
||||
esecuzione avrebbe reso l'ordine dipendente da come ha risposto una GET — cioe' non ricostruibile
|
||||
dopo. Invece `check_specs()` gira **nel venue_watch orario** (sola lettura, fuori dal percorso
|
||||
ordini) e confronta; la tabella si aggiorna a mano, di proposito, in git.
|
||||
|
||||
Il confronto dichiara la **direzione**, che e' l'unica cosa che serve per decidere se correre:
|
||||
|
||||
- `granularita'` — dichiarato piu' grosso: conforme, si perde precisione → ⚠️, si aggiorna con calma;
|
||||
- `rifiuto` — dichiarato piu' fine: **il venue rifiuta** → 🚨, si corregge subito.
|
||||
|
||||
⚠️ Uno strumento **non letto** non e' una divergenza e non viene contato come «combacia»: finisce
|
||||
in `non_letti`. Silenzio e uguaglianza non sono la stessa cosa, e solo una delle due e' rassicurante.
|
||||
|
||||
Tabella aggiornata ai valori veri e verificata: `check_specs()` risponde `divergenze: []`,
|
||||
`non_letti: []`. Il floor `min_order_usd = $5` del book e' indipendente, quindi lo step piu' fine
|
||||
**non** apre la porta a ordini da 19 centesimi.
|
||||
|
||||
## 4. Il difetto piu' lento: la referenza non e' piu' indipendente
|
||||
|
||||
`venue_watch` misura Deribit contro un consenso di referenze USD. Erano **due**: Coinbase e
|
||||
Bitstamp. Ma le comunicazioni del 12–18/08 dicono «Deribit **by Coinbase**», mettono il **Coinbase
|
||||
Index** come riferimento per una parte dei perpetual lineari, e instradano lo spot Deribit su
|
||||
**Coinbase Exchange**.
|
||||
|
||||
Il rilevatore esiste per rispondere a «Deribit sta scollando dal mondo?». Se una delle due
|
||||
referenze e' **la casa madre**, uno shock di Coinbase muove Deribit e la referenza *insieme*, e lo
|
||||
scarto in bps resta piccolo **proprio nell'ora in cui dovrebbe aprirsi**. Con due referenze, il
|
||||
consenso indipendente si riduceva di fatto a Bitstamp.
|
||||
|
||||
Aggiunta **Kraken** come terza. BTC ed ETH restano sul Deribit Index (hanno opzioni quotate), quindi
|
||||
oggi la contaminazione e' di *proprieta'*, non ancora di *calcolo*: si interviene finche' e' teorica.
|
||||
|
||||
⚠️ **Onesta' sulla taratura.** «Zero falsi allarmi in 8 anni» e' stato misurato con l'insieme di
|
||||
referenze di allora. `THRESHOLD_BPS` e `PERSIST_HOURS` non sono stati toccati, ma il consenso ora
|
||||
si calcola su tre serie invece di due, e **quel numero non e' stato ri-misurato**. Quello che si
|
||||
puo' affermare leggendo il codice e' la *direzione*: con tre referenze il consenso e' la **mediana**
|
||||
(robusta a un outlier) invece della media di due, e lo spread max-min si allarga → si va piu'
|
||||
spesso in **BLIND**, che e' lo stato morbido. Cioe' il modo in cui questa modifica puo' sbagliare
|
||||
e' **«allerta di meno»**, non «grida al lupo». Per il numero vero si rilancia
|
||||
`scripts/research/r0726_venue_tripwire.py` con la terza serie.
|
||||
|
||||
Misurato subito dopo: BTC +3.0 bps, ETH +2.5 bps, **3 referenze**, spread fra referenze 1.5 e 0.7
|
||||
bps. Le tre concordano strettamente: nessuna deriva verso BLIND nella pratica.
|
||||
|
||||
## 5. Cosa NON e' stato fatto, e resta aperto
|
||||
|
||||
- **Il Rulebook del 12/08 non lo sorveglia nessuno.** Introduce ADL e perdita socializzata,
|
||||
*emergency powers in qualifying circumstances*, la disciplina dei conti dormienti, e la
|
||||
precisazione che Deribit ha *«limited administrative authority, rather than direct control»*
|
||||
sugli asset custoditi da terzi. Per un sistema il cui runbook dice «l'azione giusta e'
|
||||
prelevare», questa e' materia di rischio-venue pura — e il watcher guarda solo i prezzi.
|
||||
- **La taratura a tre referenze non e' ri-misurata** (vedi §4).
|
||||
- **Il verso della finestra di manutenzione e' euristico**: `MAINT_GRACE_HOURS = 2` non legge gli
|
||||
annunci, li **presume**. Un feed degli annunci Deribit renderebbe la tolleranza esatta invece
|
||||
che ragionevole.
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.deribit import check_specs # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
|
||||
run_once)
|
||||
@@ -30,7 +31,10 @@ def main() -> int:
|
||||
print("=" * 78)
|
||||
print(f" soglia: {THRESHOLD_BPS:.0f} bps persistenti {PERSIST_HOURS}h a segno costante")
|
||||
lock = rep["platform_locked"]
|
||||
print(f" public/status locked: {lock if lock is not None else 'non leggibile'}")
|
||||
print(f" public/status locked: {rep.get('locked_raw') or 'non leggibile'}"
|
||||
+ (f" (da {rep['lock_hours']}h"
|
||||
+ (", manutenzione dichiarata" if rep.get("maintenance") else "") + ")"
|
||||
if lock else ""))
|
||||
for a, lvl in rep["levels"].items():
|
||||
d = rep["detail"][a]
|
||||
bps = f"{d['bps']:+7.1f} bps" if d["bps"] is not None else " n/d "
|
||||
@@ -38,14 +42,45 @@ def main() -> int:
|
||||
f"spread {d['ref_spread_bps']:.1f} bps)" if d["ref_spread_bps"] is not None
|
||||
else f" {a:<4} {lvl:<6} {bps} ({d['n_refs']} referenze)")
|
||||
|
||||
# --- specifiche contratto: la tabella dichiarata si e' scostata dal venue?
|
||||
# ⚠️ Gira qui e non nel book: e' un controllo di SOLA LETTURA e non deve stare sul percorso
|
||||
# che manda ordini. Il 18/08 Deribit ha cambiato tick e size dei perpetual lineari USDC e
|
||||
# niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni.
|
||||
spec = check_specs()
|
||||
if spec["divergenze"]:
|
||||
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
|
||||
righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / "
|
||||
f"venue {d['venue']} -> {d['rischio']}"
|
||||
for d in spec["divergenze"]}
|
||||
if rifiuto:
|
||||
righe["azione"] = ("il dichiarato e' PIU' FINE del venue: gli ordini verranno "
|
||||
"RIFIUTATI. Correggi _CONTRACT in src/live/deribit.py adesso.")
|
||||
notify("🚨 SPEC CONTRATTO — Deribit rifiutera' gli ordini", righe)
|
||||
else:
|
||||
righe["azione"] = ("il dichiarato e' piu' GROSSO del venue: gli ordini restano "
|
||||
"conformi, si perde solo granularita'. Aggiorna _CONTRACT "
|
||||
"in src/live/deribit.py.")
|
||||
notify("⚠️ SPEC CONTRATTO — la tabella dichiarata e' vecchia", righe)
|
||||
for k, v in righe.items():
|
||||
print(f" SPEC: {k}: {v}")
|
||||
|
||||
if rep["alerts"]:
|
||||
# ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
|
||||
# sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py).
|
||||
notify("🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova",
|
||||
{f"alert {i+1}": a for i, a in enumerate(rep["alerts"])})
|
||||
# ⚠️ Dal 19/08 il titolo DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di
|
||||
# prova, anche per un blocco da manutenzione annunciata: il 18/08 sono usciti quattro
|
||||
# messaggi identici che chiedevano di prelevare, tutti dopo che il book aveva gia'
|
||||
# ripreso. Un allarme massimo speso per un evento atteso e' un allarme che non verra'
|
||||
# letto il giorno che e' vero — ed e' l'unico giorno che conta.
|
||||
if rep.get("severity") == "warn":
|
||||
titolo = "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
|
||||
else:
|
||||
titolo = ("🚨 VENUE WATCH — possibile stress su Deribit. "
|
||||
"PRIMO PASSO: prelievo di prova")
|
||||
notify(titolo, {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])})
|
||||
for a in rep["alerts"]:
|
||||
print(f" ALERT: {a}")
|
||||
return 2
|
||||
return 2 if rep.get("severity") == "alert" else 0
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+78
-2
@@ -11,6 +11,7 @@ in USD notional, step verificato su Deribit: BTC $10, ETH $1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
@@ -24,12 +25,87 @@ TIMEOUT = 15
|
||||
# Inverse perp: amount = USD notional, step in USD, settle = base-coin (BTC/ETH).
|
||||
# Linear USDC perp: amount = base-coin (BTC/ETH), step in base-coin, settle = USDC (margine USDC).
|
||||
# NB il conto reale e' USDC -> gli strumenti ESEGUIBILI sono i LINEARI _USDC-PERPETUAL.
|
||||
# ⚠️ TABELLA DICHIARATA, ed e' lei l'autorita' per costruire un ordine — non l'API.
|
||||
# L'esecuzione dev'essere deterministica, in git e leggibile offline: un ordine che cambia forma
|
||||
# perche' una GET ha risposto diverso e' un ordine che non si puo' ricostruire dopo. Il venue fa
|
||||
# il CONTROLLORE, non la fonte: `check_specs()` gira ogni ora e allerta quando questa tabella si
|
||||
# scosta dal vero, e allora la si aggiorna qui a mano, di proposito.
|
||||
#
|
||||
# ⚠️ VERIFICATI contro public/get_instrument il 2026-08-19. Deribit ha cambiato le specifiche dei
|
||||
# perpetual lineari USDC il 18/08 alle 09:00 UTC (annuncio del 14/08) e questa tabella non se n'era
|
||||
# accorta: tick BTC 0.5->0.1, tick ETH 0.05->0.01, min/step ETH 0.001->0.0001. Nessun ordine e'
|
||||
# stato rifiutato perche' i cambi erano RIDUZIONI e un valore piu' grosso resta conforme — cioe'
|
||||
# e' andata bene per la direzione del cambiamento, non perche' ce ne fossimo accorti. Il costo
|
||||
# misurato era di granularita': su ETH l'incremento minimo restava $1.92 invece di $0.19 su un
|
||||
# conto da $597 (0.32% contro 0.03%). Il giorno che Deribit ALZA un minimo la stessa cecita' fa
|
||||
# rifiutare gli ordini: per quello adesso c'e' check_specs().
|
||||
_CONTRACT = {
|
||||
"BTC-PERPETUAL": {"min": 10.0, "step": 10.0, "tick": 0.5, "settle": "BTC"},
|
||||
"ETH-PERPETUAL": {"min": 1.0, "step": 1.0, "tick": 0.05, "settle": "ETH"},
|
||||
"BTC_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC", "linear": True},
|
||||
"ETH_USDC-PERPETUAL": {"min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC", "linear": True},
|
||||
"BTC_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.1, "settle": "USDC", "linear": True},
|
||||
"ETH_USDC-PERPETUAL": {"min": 0.0001, "step": 0.0001, "tick": 0.01, "settle": "USDC", "linear": True},
|
||||
}
|
||||
|
||||
# nome nostro -> campo di public/get_instrument
|
||||
_SPEC_FIELDS = (("tick", "tick_size"), ("min", "min_trade_amount"), ("step", "contract_size"))
|
||||
|
||||
|
||||
def compare_specs(declared: dict, live: dict) -> list[dict]:
|
||||
"""PURA, niente rete. Divergenze fra la tabella dichiarata e le specifiche del venue.
|
||||
|
||||
Ogni voce porta la DIREZIONE, che e' l'informazione che serve per decidere se correre:
|
||||
* `rischio: "granularita'"` — il dichiarato e' piu' GROSSO del vero. L'ordine resta conforme
|
||||
(un multiplo del tick e' un tick valido), si perde solo precisione. Si aggiorna con calma.
|
||||
* `rischio: "rifiuto"` — il dichiarato e' piu' FINE del vero. Il venue RIFIUTA l'ordine.
|
||||
Va corretto prima del prossimo giro.
|
||||
Uno strumento assente dal `live` NON e' una divergenza: puo' essere la rete. Il silenzio non
|
||||
va confuso con l'uguaglianza, e infatti `check_specs()` dichiara a parte cosa non ha letto.
|
||||
"""
|
||||
fuori = []
|
||||
for strumento, atteso in declared.items():
|
||||
vero = live.get(strumento)
|
||||
if not vero:
|
||||
continue
|
||||
for nostro, loro in _SPEC_FIELDS:
|
||||
a, v = atteso.get(nostro), vero.get(loro)
|
||||
if a is None or v is None:
|
||||
continue
|
||||
a, v = float(a), float(v)
|
||||
if abs(a - v) <= 1e-12 * max(1.0, abs(v)):
|
||||
continue
|
||||
fuori.append({"strumento": strumento, "campo": nostro, "dichiarato": a, "venue": v,
|
||||
"rischio": "granularita'" if a > v else "rifiuto"})
|
||||
return fuori
|
||||
|
||||
|
||||
def fetch_live_specs(instruments: list[str] | None = None) -> tuple[dict, list[str]]:
|
||||
"""public/get_instrument per ogni strumento. RETE. Ritorna (specifiche, non_letti).
|
||||
|
||||
⚠️ Non solleva mai: un controllo che non parte non deve poter fermare il cron del book. Cio'
|
||||
che non ha letto lo DICHIARA invece di ometterlo, perche' "non l'ho guardato" e "e' uguale"
|
||||
sono due cose diverse e solo una delle due e' rassicurante.
|
||||
"""
|
||||
import urllib.request
|
||||
fuori, non_letti = {}, []
|
||||
for strumento in (instruments or list(_CONTRACT)):
|
||||
try:
|
||||
url = ("https://www.deribit.com/api/v2/public/get_instrument"
|
||||
f"?instrument_name={strumento}")
|
||||
with urllib.request.urlopen(url, timeout=15) as r:
|
||||
res = json.loads(r.read()).get("result") or {}
|
||||
if res.get("tick_size") is None:
|
||||
non_letti.append(strumento)
|
||||
else:
|
||||
fuori[strumento] = res
|
||||
except Exception as e: # noqa: BLE001
|
||||
non_letti.append(f"{strumento} ({type(e).__name__})")
|
||||
return fuori, non_letti
|
||||
|
||||
|
||||
def check_specs() -> dict:
|
||||
"""Confronta la tabella dichiarata col venue. Sola lettura, mai solleva."""
|
||||
live, non_letti = fetch_live_specs()
|
||||
return {"divergenze": compare_specs(_CONTRACT, live), "non_letti": non_letti}
|
||||
# Il conto reale e' USDC -> mappiamo gli asset sui perp LINEARI USDC (gli unici eseguibili qui).
|
||||
INSTRUMENT = {"BTC": "BTC_USDC-PERPETUAL", "ETH": "ETH_USDC-PERPETUAL"}
|
||||
DISASTER_LABEL = "tp01-disaster" # label dei bracket disaster-SL (per ritrovarli/gestirli/mostrarli)
|
||||
|
||||
+140
-14
@@ -60,12 +60,36 @@ THRESHOLD_BPS = 100.0 # scarto minimo per contare come anomalia
|
||||
PERSIST_HOURS = 4 # ore consecutive a segno costante prima di allertare
|
||||
REF_DISAGREE_BPS = 100.0 # oltre questo le referenze litigano fra loro -> BLIND
|
||||
BLIND_ALERT_HOURS = 12 # "non vedo" per tanto tempo e' anch'esso una notizia
|
||||
# Una manutenzione ANNUNCIATA e' attesa: vale un avviso morbido, non il runbook del prelievo.
|
||||
# Ma il 18/08 Deribit aveva annunciato 15-30 minuti e la piattaforma e' rimasta bloccata per ore:
|
||||
# quindi la manutenzione declassa l'allarme SOLO dentro una finestra di tolleranza, e oltre quella
|
||||
# RIALZA — perche' una manutenzione che dura sei volte l'annuncio e' di nuovo una notizia.
|
||||
# 2 osservazioni orarie: sopra ogni finestra annunciata plausibile, sotto l'evento vero di ieri.
|
||||
MAINT_GRACE_HOURS = 2
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
# Referenze USD indipendenti da Deribit. NIENTE USDT: il depeg 2022 sposta BTC/USDT fino al 3%
|
||||
# dal dollaro e produrrebbe falsi allarmi giganti (regola del progetto sul DATO).
|
||||
# ⚠️⚠️ KRAKEN AGGIUNTA IL 2026-08-19, e la ragione non e' la robustezza generica: **Coinbase ha
|
||||
# comprato Deribit**. Le comunicazioni del 12-18/08 parlano di "Deribit by Coinbase", del Coinbase
|
||||
# Index come riferimento per una parte dei perpetual lineari, e dello spot Deribit instradato su
|
||||
# Coinbase Exchange. Questo rilevatore misura "Deribit sta scollando dal mondo?" contro un consenso
|
||||
# indipendente: se una delle due referenze e' la CASA MADRE, uno shock di Coinbase muove Deribit e
|
||||
# la referenza INSIEME, e lo scarto in bps resta piccolo proprio nell'ora in cui dovrebbe aprirsi.
|
||||
# Con due sole referenze, il consenso indipendente si riduceva di fatto a Bitstamp.
|
||||
# NB: BTC ed ETH restano sul Deribit Index (hanno opzioni quotate), quindi la contaminazione oggi
|
||||
# e' di proprieta', non ancora di calcolo. Si aggiunge finche' la cosa e' teorica.
|
||||
# ⚠️ ONESTA' SULLA TARATURA: "zero falsi allarmi in 8 anni" e' stato misurato con l'insieme di
|
||||
# referenze di allora. THRESHOLD_BPS e PERSIST_HOURS non sono stati toccati, ma il CONSENSO si
|
||||
# calcola su tre serie invece che due, e quel numero non e' stato ri-misurato. La direzione pero'
|
||||
# e' verificabile a mente sul codice: con 3 referenze il consenso e' la MEDIANA (robusta a un
|
||||
# outlier) invece della media di due, e lo spread max-min si allarga -> si va piu' spesso in BLIND,
|
||||
# che e' lo stato MORBIDO. Cioe' il modo in cui questa modifica puo' sbagliare e' "allerta di
|
||||
# meno", non "grida al lupo". Se un giorno serve il numero vero, si rilancia
|
||||
# scripts/research/r0726_venue_tripwire.py con la terza serie.
|
||||
REF_VENUES = [("coinbase", {"BTC": "BTC/USD", "ETH": "ETH/USD"}),
|
||||
("bitstamp", {"BTC": "BTC/USD", "ETH": "ETH/USD"})]
|
||||
("bitstamp", {"BTC": "BTC/USD", "ETH": "ETH/USD"}),
|
||||
("kraken", {"BTC": "BTC/USD", "ETH": "ETH/USD"})]
|
||||
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
|
||||
|
||||
|
||||
@@ -82,15 +106,59 @@ class AssetState:
|
||||
alerted: bool = False # gia' allertato per QUESTO streak (evita spam orario)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LockState:
|
||||
"""Stato del blocco piattaforma. Prima non esisteva: l'allarme era un `if` secco senza
|
||||
memoria, quindi ripartiva a ogni giro dell'ora. Il 18/08 sono usciti quattro 🚨 identici
|
||||
con scritto 'PRIMO PASSO: prelievo di prova' per una manutenzione annunciata — e tutti e
|
||||
quattro DOPO che il book aveva gia' ripreso a eseguire."""
|
||||
hours: int = 0
|
||||
alerted_soft: bool = False
|
||||
alerted_hard: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatchState:
|
||||
assets: dict[str, AssetState] = field(default_factory=dict)
|
||||
lock: LockState = field(default_factory=LockState)
|
||||
last_ts: int = 0
|
||||
|
||||
def get(self, a: str) -> AssetState:
|
||||
return self.assets.setdefault(a, AssetState())
|
||||
|
||||
|
||||
def lock_step(st: LockState, locked: bool | None, maintenance: bool,
|
||||
grace: int = MAINT_GRACE_HOURS) -> tuple[LockState, str]:
|
||||
"""Avanza lo stato del lock di UNA osservazione oraria. PURA.
|
||||
|
||||
Livelli: "OK" | "MAINT" (avviso morbido) | "ALERT" (runbook prelievo) | "RIENTRATO" | "MUTO".
|
||||
"MUTO" = la condizione dura ma e' gia' stata detta: si tace invece di ripetersi ogni ora.
|
||||
|
||||
⚠️ `locked is None` (public/status illeggibile) NON e' un rientro: le ore restano dove sono e
|
||||
non si annuncia niente. Dichiarare "e' rientrato" perche' non si e' riusciti a guardare
|
||||
sarebbe la bugia peggiore di tutte in questo file.
|
||||
"""
|
||||
new = LockState(**asdict(st))
|
||||
if locked is None:
|
||||
return new, "MUTO" if st.hours else "OK"
|
||||
if not locked:
|
||||
if st.hours:
|
||||
return LockState(), "RIENTRATO"
|
||||
return LockState(), "OK"
|
||||
new.hours = st.hours + 1
|
||||
if maintenance and new.hours <= grace:
|
||||
if st.alerted_soft:
|
||||
return new, "MUTO"
|
||||
new.alerted_soft = True
|
||||
return new, "MAINT"
|
||||
# niente manutenzione dichiarata -> un blocco inspiegato e' subito il caso serio;
|
||||
# manutenzione oltre la tolleranza -> ha sforato, e lo sforamento e' esso stesso la notizia.
|
||||
if st.alerted_hard:
|
||||
return new, "MUTO"
|
||||
new.alerted_soft, new.alerted_hard = True, True
|
||||
return new, "ALERT"
|
||||
|
||||
|
||||
def dislocation_bps(deribit: float, refs: list[float]) -> tuple[float | None, int, float | None]:
|
||||
"""(scarto firmato in bps, n referenze, spread fra referenze in bps).
|
||||
|
||||
@@ -151,6 +219,7 @@ def load_state(path: Path = STATE_PATH) -> WatchState:
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
return WatchState(assets={k: AssetState(**v) for k, v in raw.get("assets", {}).items()},
|
||||
lock=LockState(**raw.get("lock", {})),
|
||||
last_ts=int(raw.get("last_ts", 0)))
|
||||
except Exception:
|
||||
return WatchState() # stato illeggibile -> si riparte pulito, mai un crash del cron
|
||||
@@ -159,40 +228,74 @@ def load_state(path: Path = STATE_PATH) -> WatchState:
|
||||
def save_state(st: WatchState, path: Path = STATE_PATH) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(
|
||||
{"assets": {k: asdict(v) for k, v in st.assets.items()}, "last_ts": st.last_ts}, indent=2))
|
||||
{"assets": {k: asdict(v) for k, v in st.assets.items()},
|
||||
"lock": asdict(st.lock), "last_ts": st.last_ts}, indent=2))
|
||||
|
||||
|
||||
def platform_locked() -> bool | None:
|
||||
"""`public/status` di Deribit: True se la piattaforma e' bloccata (halt). None se non leggibile.
|
||||
Segnale DIRETTO e gratuito, indipendente dal prezzo."""
|
||||
def platform_status() -> tuple[bool | None, str]:
|
||||
"""`public/status` di Deribit: (bloccata?, valore GREZZO). None se non leggibile.
|
||||
|
||||
⚠️ Il valore grezzo si porta dietro apposta: Deribit risponde anche `"partial"`, e fino al
|
||||
2026-08-19 il messaggio diceva comunque «locked=true» — cioe' DICHIARAVA un valore che non
|
||||
aveva letto. Il runbook al passo 2 manda a controllare proprio questo campo: mandarci qualcuno
|
||||
con in testa la stringa sbagliata e' peggio che non dirgliela. E lo stato non lo salvava
|
||||
nessuno, per cui il valore del 18/08 oggi non e' piu' ricostruibile.
|
||||
"""
|
||||
try:
|
||||
import ccxt
|
||||
r = ccxt.deribit({"enableRateLimit": True}).publicGetStatus()
|
||||
return str(r.get("result", {}).get("locked", "false")).lower() not in ("false", "none")
|
||||
grezzo = str(r.get("result", {}).get("locked", "false")).lower()
|
||||
return grezzo not in ("false", "none"), grezzo
|
||||
except Exception:
|
||||
return None, "non leggibile"
|
||||
|
||||
|
||||
def platform_locked() -> bool | None:
|
||||
"""Compatibilita': solo il booleano. Il valore vero sta in `platform_status()`."""
|
||||
try:
|
||||
return platform_status()[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _last_price(exchange_id: str, symbol: str) -> float | None:
|
||||
def _last_price(exchange_id: str, symbol: str, errori: list[str] | None = None) -> float | None:
|
||||
"""⚠️ Raccoglie il MOTIVO del fallimento invece di inghiottirlo. E' cosi' che si distingue
|
||||
«Deribit e' in manutenzione annunciata» da «Deribit non risponde e non si sa perche'»: ccxt
|
||||
solleva `OnMaintenance` col codice 11051, e quell'informazione il 18/08 c'era gia' — ce
|
||||
l'aveva lo strato book, nello stesso identico minuto, e non arrivava a chi decide la
|
||||
gravita' dell'allarme."""
|
||||
try:
|
||||
import ccxt
|
||||
ex = getattr(ccxt, exchange_id)({"enableRateLimit": True})
|
||||
t = ex.fetch_ticker(symbol)
|
||||
p = t.get("last") or t.get("close")
|
||||
return float(p) if p else None
|
||||
except Exception:
|
||||
except Exception as e: # noqa: BLE001
|
||||
if errori is not None:
|
||||
errori.append(f"{type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_maintenance(errori: list[str]) -> bool:
|
||||
"""PURA. La firma della manutenzione Deribit negli errori raccolti."""
|
||||
return any("onmaintenance" in e.lower() or "system_maintenance" in e.lower() or "11051" in e
|
||||
for e in errori)
|
||||
|
||||
|
||||
def observe() -> dict:
|
||||
"""Una osservazione: prezzo Deribit e referenze, per asset. Solo letture pubbliche."""
|
||||
out: dict = {"ts": int(time.time()), "platform_locked": platform_locked(), "assets": {}}
|
||||
locked, grezzo = platform_status()
|
||||
err_deribit: list[str] = []
|
||||
out: dict = {"ts": int(time.time()), "platform_locked": locked, "locked_raw": grezzo,
|
||||
"assets": {}}
|
||||
for a in ASSETS:
|
||||
der = _last_price("deribit", DERIBIT_SYMBOL[a])
|
||||
der = _last_price("deribit", DERIBIT_SYMBOL[a], err_deribit)
|
||||
refs = [_last_price(eid, syms[a]) for eid, syms in REF_VENUES]
|
||||
refs = [r for r in refs if r]
|
||||
bps, n, spread = (None, 0, None) if der is None else dislocation_bps(der, refs)
|
||||
out["assets"][a] = dict(deribit=der, n_refs=n, ref_spread_bps=spread, bps=bps)
|
||||
out["maintenance"] = is_maintenance(err_deribit)
|
||||
out["deribit_errors"] = err_deribit
|
||||
return out
|
||||
|
||||
|
||||
@@ -201,8 +304,9 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
|
||||
L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita."""
|
||||
st = load_state(state_path)
|
||||
obs = observe()
|
||||
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"], "levels": {},
|
||||
"detail": obs["assets"], "alerts": []}
|
||||
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"],
|
||||
"locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False),
|
||||
"levels": {}, "detail": obs["assets"], "alerts": [], "severity": None}
|
||||
for a in ASSETS:
|
||||
o = obs["assets"][a]
|
||||
new, level = step(st.get(a), o["bps"])
|
||||
@@ -216,8 +320,30 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
|
||||
report["alerts"].append(
|
||||
f"{a}: consenso NON misurabile da {new.blind_hours}h "
|
||||
f"({o['n_refs']} referenze, spread {o['ref_spread_bps']})")
|
||||
if obs["platform_locked"]:
|
||||
report["alerts"].append("Deribit public/status: PIATTAFORMA BLOCCATA (locked=true)")
|
||||
if report["levels"] and any(l == "ALERT" for l in report["levels"].values()):
|
||||
report["severity"] = "alert"
|
||||
|
||||
# --- 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))
|
||||
st.lock = lock_new
|
||||
report["lock_level"], report["lock_hours"] = lock_lvl, lock_new.hours
|
||||
grezzo = obs.get("locked_raw") or "?"
|
||||
if lock_lvl == "MAINT":
|
||||
report["alerts"].append(
|
||||
f"Deribit bloccata (locked={grezzo}) e dichiara MANUTENZIONE: attesa, non allarme. "
|
||||
f"Il book si astiene da solo. Se supera {MAINT_GRACE_HOURS}h te lo ridico piu' forte.")
|
||||
report["severity"] = report["severity"] or "warn"
|
||||
elif lock_lvl == "ALERT":
|
||||
motivo = (f"la manutenzione ha SFORATO {MAINT_GRACE_HOURS}h" if obs.get("maintenance")
|
||||
else "nessuna manutenzione dichiarata")
|
||||
report["alerts"].append(
|
||||
f"Deribit public/status: PIATTAFORMA BLOCCATA (locked={grezzo}) da {lock_new.hours}h "
|
||||
f"— {motivo}")
|
||||
report["severity"] = "alert"
|
||||
elif lock_lvl == "RIENTRATO":
|
||||
report["alerts"].append(f"Deribit public/status: rientrata (locked={grezzo})")
|
||||
report["severity"] = report["severity"] or "warn"
|
||||
|
||||
st.last_ts = obs["ts"]
|
||||
save_state(st, state_path)
|
||||
return report
|
||||
|
||||
@@ -243,3 +243,114 @@ def test_il_watch_e_cablato_nel_cron_orario():
|
||||
assert sh.index("venue_watch.py") < sh.index("book_execute.py"), (
|
||||
"il watch deve girare PRIMA dell'esecuzione: se Deribit e' in stress l'allarme deve "
|
||||
"partire anche quando book_execute fallisce per la stessa ragione")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Disciplina del blocco piattaforma (2026-08-19). Il 18/08 sono usciti QUATTRO
|
||||
# 🚨 identici con "PRIMO PASSO: prelievo di prova" per una manutenzione
|
||||
# annunciata, e tutti e quattro DOPO che il book aveva gia' ripreso a eseguire.
|
||||
# ===========================================================================
|
||||
from src.live.venue_watch import LockState, is_maintenance, lock_step # noqa: E402
|
||||
|
||||
|
||||
def _replay(sequenza, grace=2):
|
||||
st, out = LockState(), []
|
||||
for locked, maint in sequenza:
|
||||
st, lvl = lock_step(st, locked, maint, grace=grace)
|
||||
out.append(lvl)
|
||||
return out
|
||||
|
||||
|
||||
def test_manutenzione_breve_e_un_avviso_morbido_non_il_runbook_del_prelievo():
|
||||
assert _replay([(True, True), (False, False)]) == ["MAINT", "RIENTRATO"]
|
||||
|
||||
|
||||
def test_non_si_ripete_ogni_ora_mentre_la_condizione_dura():
|
||||
"""Il difetto vero del 18/08: nessuna memoria, quindi un messaggio identico ogni giro."""
|
||||
livelli = _replay([(True, True)] * 5 + [(False, False)])
|
||||
assert livelli.count("MAINT") == 1
|
||||
assert livelli.count("ALERT") == 1
|
||||
assert livelli == ["MAINT", "MUTO", "ALERT", "MUTO", "MUTO", "RIENTRATO"]
|
||||
|
||||
|
||||
def test_la_manutenzione_che_sfora_RIALZA_invece_di_restare_morbida():
|
||||
"""Deribit aveva annunciato 15-30 minuti e la piattaforma e' rimasta bloccata per ore:
|
||||
una manutenzione che dura sei volte l'annuncio torna a essere una notizia."""
|
||||
assert _replay([(True, True)] * 3)[-1] == "ALERT"
|
||||
|
||||
|
||||
def test_un_blocco_senza_manutenzione_dichiarata_e_subito_il_caso_serio():
|
||||
assert _replay([(True, False)])[0] == "ALERT"
|
||||
|
||||
|
||||
def test_status_illeggibile_non_e_un_rientro():
|
||||
"""Dichiarare 'e' rientrato' perche' non si e' riusciti a guardare sarebbe la bugia peggiore."""
|
||||
st = LockState()
|
||||
st, _ = lock_step(st, True, False)
|
||||
prima = st.hours
|
||||
st, lvl = lock_step(st, None, False)
|
||||
assert lvl == "MUTO" and st.hours == prima
|
||||
|
||||
|
||||
def test_dopo_un_rientro_un_nuovo_blocco_riallerta():
|
||||
"""L'ammutolimento vale per QUESTO episodio, non per sempre."""
|
||||
livelli = _replay([(True, False), (False, False), (True, False)])
|
||||
assert livelli == ["ALERT", "RIENTRATO", "ALERT"]
|
||||
|
||||
|
||||
def test_riconosce_la_firma_della_manutenzione_deribit():
|
||||
assert is_maintenance(['OnMaintenance: deribit {"error":{"message":"system_maintenance",'
|
||||
'"code":11051}}'])
|
||||
assert not is_maintenance(["HTTPError: 502 Bad Gateway"])
|
||||
assert not is_maintenance([])
|
||||
|
||||
|
||||
def test_lo_stato_del_lock_sopravvive_al_giro_successivo(tmp_path):
|
||||
"""Senza persistenza la memoria si perde a ogni cron e si ricomincia a gridare."""
|
||||
from src.live.venue_watch import WatchState, load_state, save_state
|
||||
p = tmp_path / "s.json"
|
||||
st = WatchState()
|
||||
st.lock, _ = lock_step(st.lock, True, True)
|
||||
save_state(st, p)
|
||||
assert load_state(p).lock.hours == 1 and load_state(p).lock.alerted_soft
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Specifiche contratto vs venue (2026-08-19)
|
||||
# ===========================================================================
|
||||
from src.live.deribit import compare_specs # noqa: E402
|
||||
|
||||
_VERO = {"ETH_USDC-PERPETUAL": {"tick_size": 0.01, "min_trade_amount": 0.0001,
|
||||
"contract_size": 0.0001}}
|
||||
|
||||
|
||||
def test_dichiarato_piu_grosso_e_solo_granularita():
|
||||
"""La situazione del 18/08: conforme, si perde precisione. Non e' un'emergenza."""
|
||||
d = compare_specs({"ETH_USDC-PERPETUAL": {"tick": 0.05, "min": 0.001, "step": 0.001}}, _VERO)
|
||||
assert d and all(x["rischio"] == "granularita'" for x in d)
|
||||
|
||||
|
||||
def test_dichiarato_piu_fine_significa_ordini_RIFIUTATI():
|
||||
"""L'altra direzione, quella che costa: il venue alza un minimo e noi non lo sappiamo."""
|
||||
d = compare_specs({"ETH_USDC-PERPETUAL": {"tick": 0.001, "min": 1e-5, "step": 1e-5}}, _VERO)
|
||||
assert d and all(x["rischio"] == "rifiuto" for x in d)
|
||||
|
||||
|
||||
def test_uno_strumento_non_letto_non_e_una_divergenza():
|
||||
"""Silenzio e uguaglianza non sono la stessa cosa: cio' che non si e' letto va dichiarato
|
||||
a parte (`non_letti`), non fatto passare per 'combacia'."""
|
||||
assert compare_specs({"X": {"tick": 1.0}}, {}) == []
|
||||
|
||||
|
||||
def test_la_tabella_dichiarata_combacia_col_venue_oggi():
|
||||
"""Verificato a mano il 19/08. Se questo rompe, o Deribit ha cambiato le specifiche o
|
||||
qualcuno ha toccato _CONTRACT: in entrambi i casi va guardato, non silenziato."""
|
||||
from src.live.deribit import _CONTRACT
|
||||
assert compare_specs(_CONTRACT, {
|
||||
"BTC_USDC-PERPETUAL": {"tick_size": 0.1, "min_trade_amount": 0.0001,
|
||||
"contract_size": 0.0001},
|
||||
"ETH_USDC-PERPETUAL": {"tick_size": 0.01, "min_trade_amount": 0.0001,
|
||||
"contract_size": 0.0001},
|
||||
"BTC-PERPETUAL": {"tick_size": 0.5, "min_trade_amount": 10.0, "contract_size": 10.0},
|
||||
"ETH-PERPETUAL": {"tick_size": 0.05, "min_trade_amount": 1.0, "contract_size": 1.0},
|
||||
}) == []
|
||||
|
||||
Reference in New Issue
Block a user