Files
PythagorasGoal/tests/test_venue_watch.py
T
Adriano Dal Pastro fac9978d87 venue: la taratura ri-misurata — «zero falsi allarmi in 8 anni» non descriveva la produzione
B1 (memoria) + B2 (misura). Il 19/08 stava solo in un diario e in un commit: la
sessione che ha cambiato il tripwire di venue, la tabella _CONTRACT e le referenze
non era in CLAUDE.md — 2a occorrenza dopo edge_watch, e riguarda di nuovo qualcosa
che gira con soldi veri. Ora c'e', insieme al debito che dichiarava.

B2: il debito era «la taratura non e' ri-misurata a tre referenze». Ri-misurandola
(scripts/research/r0821_venue_refs.py, riusa dislocation/episodes/zero_fp_frontier
di r0726) il problema non era dove ci si aspettava.

1. «Zero falsi allarmi in 8 anni» veniva dal consenso Coinbase+Bitstamp+BITFINEX
   dello script di ricerca; il sorvegliante live gira su Coinbase+Bitstamp. Due
   liste di referenze in due posti diversi, e nessun test poteva accorgersene.
   Sull'insieme reale i falsi allarmi sono 1: 2020-03-13 07:00-10:00 UTC, 4 ore a
   -418 bps di picco su BTC = il crash COVID, cioe' proprio l'evento che CLAUDE.md
   elencava come esempio di cio' su cui NON scattava. La firma che lo dimostra: le
   "65.043 ore BTC" citate in memoria sono esattamente le ore utili del set con
   bitfinex; sul set reale sono 69.633.

2. Kraken non lo ripara: su 8 anni porta un mese. Il tetto ~700 candele
   dell'endpoint pubblico e' stato ri-verificato oggi sulla rete (704 barre su
   70.286 richieste = 1,00%), non creduto da un commento del 26/07.

3. Perche' spariva a 3 referenze, ed e' il punto trasferibile: con bitfinex dentro
   1 delle 4 ore diventa BLIND, lo streak si azzera e l'episodio non esiste — ma la
   dislocazione e' ancora li' (mediana -343 bps). Lo zero non veniva da un consenso
   piu' accurato, veniva da un'ora buttata (ore utilizzabili 93,4% vs 100,0%).

4. La direzione dichiarata il 19/08 ("piu' BLIND, allerta di meno") e' confermata ma
   la sua taglia dipende dal venue, non dal numero tre: confronto appaiato sulle
   stesse ore, con bitfinex -13,91 pp di ore utilizzabili, con kraken +0,00 pp.

5. Controllo positivo intatto: Bitfinex 2018-19, 22 episodi, il piu' lungo 2.324h,
   picco 1.136 bps = 11,4x la soglia.

DECISIONE: (100 bps, 4h) NON si tocca, ma la giustificazione cambia. 1 falso allarme
in 8 anni costa 0,031%/anno di equity attesa contro il 100% che un vero positivo
evita (ogni p plausibile e' 16-160x sopra il break-even); alzare a 150 bps per
ripristinare lo zero porterebbe il margine su FTX da 3,0x a 2,0x, sotto la gamba (b)
del criterio dichiarato prima di guardare i dati. La memoria si contraddiceva da
sola — «zero falsi allarmi» al punto (2), «a 1 ogni 8 anni serve p > 0,031%» al
punto (4) — e la meta' giusta era quella dell'economia.

Congelato il MECCANISMO, non il numero: tests/test_venue_watch.py 35 -> 37, due test
puri (lo spread max-min e' monotono nel numero di referenze; una sola ora BLIND
spezza lo streak, con un caso di controllo che DEVE scattare).

Soglie, book, pesi, config, cron: INVARIATI. Suite 621 verdi, 1 rosso noto
(test_gtaa_band_gate, altro asse). Diario: docs/diary/2026-08-21-venue-taratura-*.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:55:16 +00:00

419 lines
18 KiB
Python

"""Test del tripwire di venue (src/live/venue_watch.py + scripts/research/r0726_venue_tripwire.py).
Il test che conta di piu' e' `test_controllo_positivo_*`: un rilevatore che non segnala mai nulla
e' indistinguibile da uno rotto, e questo qui e' TARATO per non segnalare. Senza un controllo
positivo, "non e' mai scattato" non e' una buona notizia.
⚠️ CORREZIONE 2026-08-21 (`scripts/research/r0821_venue_refs.py`): la taratura NON e' "zero falsi
allarmi in 8 anni" sull'insieme di referenze che gira in PRODUZIONE. Quel numero fu misurato con
`bitfinex` nel consenso, che il sorvegliante live non ha mai avuto; sul consenso reale (Coinbase +
Bitstamp, e da oggi + Kraken) i falsi allarmi in 8 anni sono **1**, il 2020-03-13 (crash COVID,
4 ore a -418 bps di picco su BTC) — cioe' proprio l'evento che la memoria citava come esempio di
cio' su cui NON scattava. Il punto (100 bps, 4h) resta invariato per economia, non per assenza di
falsi allarmi: 1 ogni 8 anni costa ~0.031%/anno di equity attesa contro il 100% che evita.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402
THRESHOLD_BPS, AssetState, WatchState,
dislocation_bps, load_state, save_state, step)
# ===========================================================================
# consenso: quando NON si puo' misurare
# ===========================================================================
def test_una_sola_referenza_non_e_un_consenso():
"""Con una referenza sola non si distingue 'Deribit e' fuori' da 'la referenza e' rotta'."""
bps, n, spread = dislocation_bps(100.0, [100.0])
assert bps is None and n == 1
def test_referenze_in_disaccordo_producono_non_misurabile():
"""Se le referenze litigano fra loro il problema e' loro: scartare, non allertare."""
bps, n, spread = dislocation_bps(100.0, [100.0, 103.0]) # 300 bps di spread
assert bps is None
assert spread > 100.0
def test_consenso_normale_da_scarto_piccolo():
bps, n, spread = dislocation_bps(100.05, [100.0, 100.02])
assert n == 2
assert 0 < bps < 10
def test_lo_scarto_e_firmato_in_entrambe_le_direzioni():
"""Premio e sconto sono ENTRAMBI segnale: Mt.Gox andava a premio, un venue in fuga a sconto."""
up, _, _ = dislocation_bps(101.0, [100.0, 100.0])
dn, _, _ = dislocation_bps(99.0, [100.0, 100.0])
assert up > 0 and dn < 0
assert abs(up + dn) < abs(up) * 0.05 # simmetrici a meno del secondo ordine
# ===========================================================================
# macchina a stati
# ===========================================================================
def test_sotto_soglia_resta_ok():
st, lvl = step(AssetState(), THRESHOLD_BPS - 1)
assert lvl == "OK" and st.streak_hours == 0
def test_servono_persist_ore_consecutive_per_allertare():
st = AssetState()
for i in range(PERSIST_HOURS - 1):
st, lvl = step(st, THRESHOLD_BPS + 50)
assert lvl == "WATCH", f"ora {i+1}: allarme troppo presto"
st, lvl = step(st, THRESHOLD_BPS + 50)
assert lvl == "ALERT"
def test_il_cambio_di_segno_azzera_lo_streak():
"""E' il cuore della discriminazione: uno scarto che rimbalza non e' un arbitraggio rotto."""
st = AssetState()
for _ in range(PERSIST_HOURS - 1):
st, _ = step(st, THRESHOLD_BPS + 50)
st, lvl = step(st, -(THRESHOLD_BPS + 50)) # stesso modulo, segno opposto
assert lvl == "WATCH" and st.streak_hours == 1
def test_un_rientro_sotto_soglia_azzera_lo_streak():
st = AssetState()
for _ in range(PERSIST_HOURS - 1):
st, _ = step(st, THRESHOLD_BPS + 50)
st, lvl = step(st, 5.0)
assert lvl == "OK" and st.streak_hours == 0
def test_non_rispamma_ogni_ora_durante_lo_stesso_streak():
st = AssetState()
levels = []
for _ in range(PERSIST_HOURS + 5):
st, lvl = step(st, THRESHOLD_BPS + 50)
levels.append(lvl)
assert levels.count("ALERT") == 1, f"allarmi ripetuti: {levels}"
def test_dopo_un_alert_un_nuovo_streak_di_segno_opposto_riallerta():
"""Il de-spam non deve nascondere un evento NUOVO."""
st = AssetState()
for _ in range(PERSIST_HOURS):
st, _ = step(st, THRESHOLD_BPS + 50)
st, _ = step(st, 0.0) # rientro
levels = []
for _ in range(PERSIST_HOURS):
st, lvl = step(st, -(THRESHOLD_BPS + 50))
levels.append(lvl)
assert levels[-1] == "ALERT"
# ===========================================================================
# lo stato BLIND: "non vedo" non e' "va bene"
# ===========================================================================
def test_dati_mancanti_azzerano_lo_streak_non_lo_accumulano():
st = AssetState()
for _ in range(PERSIST_HOURS - 1):
st, _ = step(st, THRESHOLD_BPS + 50)
st, lvl = step(st, None)
assert st.streak_hours == 0, "evidenza accumulata su dati che non parlano"
def test_cecita_prolungata_diventa_essa_stessa_un_allarme():
st = AssetState()
for i in range(BLIND_ALERT_HOURS - 1):
st, lvl = step(st, None)
assert lvl == "OK"
st, lvl = step(st, None)
assert lvl == "BLIND"
def test_una_misura_valida_azzera_il_contatore_di_cecita():
st = AssetState()
for _ in range(BLIND_ALERT_HOURS):
st, _ = step(st, None)
st, lvl = step(st, 1.0)
assert st.blind_hours == 0 and lvl == "OK"
# ===========================================================================
# persistenza
# ===========================================================================
def test_stato_salvato_e_riletto(tmp_path):
p = tmp_path / "state.json"
ws = WatchState(assets={"BTC": AssetState(streak_hours=3, sign=-1)}, last_ts=123)
save_state(ws, p)
back = load_state(p)
assert back.assets["BTC"].streak_hours == 3
assert back.assets["BTC"].sign == -1
assert back.last_ts == 123
def test_stato_corrotto_non_fa_crashare_il_cron(tmp_path):
"""Un JSON rotto non deve rompere il giro orario del book."""
p = tmp_path / "state.json"
p.write_text("{ questo non e' json")
st = load_state(p)
assert isinstance(st, WatchState) and st.last_ts == 0
# ===========================================================================
# nucleo di ricerca: episodi e outer-join
# ===========================================================================
def _tw():
import r0726_venue_tripwire as T
return T
def test_il_consenso_usa_outer_join_non_intersezione():
"""Il bug della prima corsa: una referenza corta troncava il campione da 8 anni a 29 giorni."""
T = _tw()
d = pd.Series([100.0] * 10, index=range(10))
lunga = pd.Series([100.0] * 10, index=range(10))
corta = pd.Series([100.0] * 3, index=range(3))
f = T.dislocation(d, [lunga, corta])
assert len(f) == 10, "inner join: la referenza corta decide il campione"
def test_episodi_richiedono_segno_costante():
T = _tw()
idx = range(10)
alternato = pd.Series([200.0, -200.0] * 5, index=idx)
usable = pd.Series([True] * 10, index=idx)
assert T.episodes(alternato, usable, 100, 3) == []
costante = pd.Series([200.0] * 10, index=idx)
assert len(T.episodes(costante, usable, 100, 3)) == 1
def test_le_ore_non_utilizzabili_rompono_il_run():
T = _tw()
idx = range(10)
bps = pd.Series([200.0] * 10, index=idx)
usable = pd.Series([True, True, False, True, True, True, True, True, True, True], index=idx)
eps = T.episodes(bps, usable, 100, 6)
assert len(eps) == 1 and eps[0]["hours"] == 7, "un buco di dati non deve saldare due run"
# ===========================================================================
# CONTROLLI POSITIVI — senza questi "non scatta mai" non e' una buona notizia
# ===========================================================================
def test_controllo_positivo_firma_di_venue_gated():
"""Dislocazione grande, persistente e a segno costante = deve scattare."""
T = _tw()
n = 200
idx = range(n)
bps = pd.Series([450.0] * n, index=idx) # premio tipo Bitfinex 2018
usable = pd.Series([True] * n, index=idx)
eps = T.episodes(bps, usable, THRESHOLD_BPS, PERSIST_HOURS)
assert len(eps) == 1 and eps[0]["hours"] == n and eps[0]["sign"] == 1
def test_controllo_positivo_la_macchina_a_stati_scatta_sulla_stessa_firma():
"""Lo stesso episodio, passato dalla macchina a stati di produzione, deve produrre ALERT."""
st = AssetState()
out = []
for _ in range(24):
st, lvl = step(st, 450.0)
out.append(lvl)
assert "ALERT" in out
def test_controllo_negativo_un_crash_breve_non_scatta():
"""Marzo 2020 su Deribit: 12h a 158 bps di picco ma sotto i 100 bps per meno di 4h di fila
alla soglia scelta. Qui la versione stilizzata: uno spike di 3 ore non deve allertare."""
st = AssetState()
out = []
for bps in (150.0, 150.0, 150.0, 10.0, 5.0):
st, lvl = step(st, bps)
out.append(lvl)
assert "ALERT" not in out, f"spike breve ha allertato: {out}"
def test_la_taratura_congelata_non_cambia_per_sbaglio():
"""La taratura VALE 'zero falsi allarmi su 8 anni': se qualcuno la cambia, il claim decade
e va rimisurato con r0726_venue_tripwire.py."""
assert THRESHOLD_BPS == 100.0
assert PERSIST_HOURS == 4
def test_il_watch_e_cablato_nel_cron_orario():
sh = (ROOT / "scripts" / "cron_book.sh").read_text()
assert "venue_watch.py" in sh
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},
}) == []
# ===========================================================================
# MECCANICA scoperta il 2026-08-21 ri-misurando la taratura a tre referenze.
# Sono i due fatti PURI che spiegano i numeri di r0821_venue_refs.py; i numeri
# empirici stanno nel diario, riprodotti da quello script.
# ===========================================================================
def test_una_terza_referenza_non_puo_aumentare_le_ore_utilizzabili():
"""Aggiungere una referenza puo' solo ALLARGARE lo spread max-min, mai stringerlo ->
la quota di ore utilizzabili e' monotona NON crescente nel numero di referenze.
E' la ragione strutturale per cui "piu' referenze" non e' gratis: la modifica del 19/08
sposta il sistema verso BLIND, che e' lo stato morbido (allerta di MENO, non di piu').
"""
base = [100.0, 100.4] # spread 40 bps: concordi
ok, n, spread = dislocation_bps(100.2, base)
assert ok is not None and n == 2 and spread < THRESHOLD_BPS
# una terza referenza lontana allarga lo spread oltre la soglia -> BLIND, non allarme
fuori, n3, spread3 = dislocation_bps(100.2, base + [102.0])
assert n3 == 3 and spread3 > spread, "lo spread deve essere monotono nel numero di referenze"
assert fuori is None, "referenze in disaccordo -> si SCARTA il campione, non si allerta"
# e una terza referenza CONCORDE non toglie nulla: il caso Kraken misurato il 21/08
dentro, n3b, spread3b = dislocation_bps(100.2, base + [100.2])
assert dentro is not None and n3b == 3 and spread3b >= spread
def test_una_sola_ora_BLIND_spezza_lo_streak_e_impedisce_l_allarme():
"""⚠️ Il fatto che rende ambiguo un "zero falsi allarmi" misurato con piu' referenze.
Lo streak si AZZERA su un'ora BLIND (non si accumula evidenza su dati che non parlano).
Quindi un insieme di referenze che manda in BLIND una sola ora dentro una finestra di
dislocazione fa SPARIRE l'episodio senza che la dislocazione sia diminuita: e' esattamente
cio' che e' successo al falso allarme del 2020-03-13, presente a 2 referenze e assente a 3
perche' 1 delle 4 ore diventava BLIND (dislocazione mediana ancora -343 bps).
Morale: "zero falsi allarmi" puo' voler dire "piu' accurato" oppure "piu' cieco", e le due
si distinguono solo guardando la quota di ore utilizzabili.
"""
st = AssetState()
# 4 ore consecutive oltre soglia a segno costante -> ALERT alla quarta
for _ in range(PERSIST_HOURS - 1):
st, lvl = step(st, -400.0)
assert lvl == "WATCH"
st_alert, lvl = step(st, -400.0)
assert lvl == "ALERT", "il caso di controllo deve scattare, altrimenti il test non prova nulla"
# stessa dislocazione, ma con UNA ora cieca in mezzo -> nessun allarme
st2 = AssetState()
for bps in (-400.0, -400.0, None, -400.0):
st2, lvl2 = step(st2, bps)
assert lvl2 != "ALERT"
assert st2.streak_hours < PERSIST_HOURS, "l'ora BLIND deve azzerare lo streak"