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,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
|
||||
Reference in New Issue
Block a user