de83909db9
MISURATO oggi durante la revisione: `trades_db.py --help` non stampava l'uso, cadeva in sync() e riscriveva meta.ultimo_sync. Nessuno dei 18 script di scripts/live/ usava argparse: un flag sbagliato era il ramo else. Su journal.py avrebbe scritto pagina e riga di DB, su analista.py avrebbe speso una chiamata al modello e mandato un Telegram. - src/live/cli.valida: prima istruzione di ogni __main__, prima di connect()/sync/rete. --help -> 0 con l'uso; flag ignoto, valore mancante o posizionale -> 2 con l'elenco dei previsti (P4). NIENTE argparse: cambierebbe messaggi, codici d'uscita e --help di script che il cron gia' chiama. - 18 script cablati (i 3 che scrivono + 14 + cc01), flag invariati. - tests/test_cli_flag.py (30): elenco DERIVATO dalla cartella (P1), valida come prima istruzione, uso che documenta i flag, e i flag che il CRON usa davvero restano accettati (P15/P16); end-to-end su --help e flag ignoto con trades.db non toccato (M15). Verificato a mano: monitor_health --quiet, trades_db --sync --quiet, book_execute dry-run. Debito §5.15, primo passo: balance_watch (orario) registra `usde_usdc` a ogni campione — None con la ragione se illeggibile, mai 1,0. Il cablaggio nel bound quando la serie ha storia. Pulizia dalla revisione: tests/helpers.carica_script al posto della 15a copia del loader importlib (5 file del libro live); il fill di prova via upsert_fills invece di un INSERT che lasciava verified NULL; asserzioni non ancorate al padding; movimenti_capitale accetta le righe gia' lette (una SELECT invece di due ai due lati di una scrittura del cron). Test 910 verdi (+52). Diario 2026-09-02c. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDJsH3iDSaBns3ccpPBwiu
233 lines
9.7 KiB
Python
233 lines
9.7 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():
|
|
from helpers import carica_script
|
|
return carica_script("scripts/live/book_execute.py")
|
|
|
|
|
|
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
|