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:
Adriano Dal Pastro
2026-08-19 10:10:55 +00:00
parent b1c3ff1bb8
commit c932fab304
5 changed files with 522 additions and 20 deletions
+111
View File
@@ -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},
}) == []