Files
PythagorasGoal/tests/test_book_resilienza_venue.py
T
Adriano Dal Pastro 426735448e 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
2026-08-25 10:27:36 +00:00

236 lines
9.8 KiB
Python

"""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