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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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({}) == []
|
||||
Reference in New Issue
Block a user