"""usde_convert.py — GTC mai cancellati (issue #26). Il gateway cerbero-mcp NON accetta `time_in_force` (PlaceOrderReq non ha il campo, extra ignorati: gitea:Adriano/Cerbero-mcp#1): un limit riempito solo in parte resta APERTO per sempre, e senza guardia si accumula a ogni giro dello script. Blindano: (a) all'avvio si cancellano SOLO i residui con la nostra label (`usde-quota-*`), un ordine con altra label non si tocca; (b) nel ciclo, un ordine con `filled < amt` viene cancellato subito dopo; (c) se la cancellazione fallisce il ciclo si FERMA (mai un secondo ordine con un residuo vivo). Client finto (nessuna rete), `time.sleep` patchato: nessun ordine vero puo' partire da questi test. """ from __future__ import annotations import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scripts" / "live")) import usde_convert as C # noqa: E402 from src.live import usde as U # noqa: E402 # ============================================================================================= # cancella_residui — SOLA lettura + cancellazione dei nostri ordini # ============================================================================================= class _ClientOrdini: """Fake minimale per _post/_unwrap: nessuna rete.""" def __init__(self, open_orders=None, open_orders_usde=None, risposte_place=None, boom_cancel=None): self.open_orders = open_orders or [] # None = stessa lista di open_orders (retrocompat dei test esistenti, che non # distinguono la valuta interrogata); una lista propria serve solo a chi vuole # simulare un residuo elencato SOLO sotto USDE. self.open_orders_usde = open_orders_usde self.risposte_place = list(risposte_place or []) self.boom_cancel = boom_cancel or set() # order_id la cui cancel fallisce self.cancellati = [] self.get_open_orders_chiamate = 0 def _post(self, path, payload): if path.endswith("get_open_orders"): self.get_open_orders_chiamate += 1 if payload.get("currency") == "USDE" and self.open_orders_usde is not None: return {"result": self.open_orders_usde} return {"result": self.open_orders} if path.endswith("cancel_order"): oid = payload["order_id"] self.cancellati.append(oid) if oid in self.boom_cancel: # forma reale di client.py::cancel_order su un fallimento del venue: il gateway # avvolge il dict del tool in {"result": ...}, MAI in {"result": None, "error"} return {"result": {"order_id": oid, "state": "error", "error": "cancel rifiutata dal venue"}} return {"result": {"order_id": oid, "state": "cancelled"}} if path.endswith("place_order"): if not self.risposte_place: raise AssertionError("place_order chiamato senza risposte in coda") return self.risposte_place.pop(0) raise AssertionError(f"chiamata inattesa: {path}") @staticmethod def _unwrap(resp): return resp.get("result", resp) if isinstance(resp, dict) else resp def test_cancella_residui_solo_le_nostre_label(): client = _ClientOrdini(open_orders=[ {"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"}, {"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"}, # NON nostro {"instrument": "BTC_USDC-PERPETUAL", "label": "usde-quota-0.64", "order_id": "R3"}, ]) out = C.cancella_residui(client) assert out == ["R1"] assert client.cancellati == ["R1"], "l'ordine con altra label o su altro strumento non si tocca" def test_cancella_residui_trovato_solo_sotto_USDE(): """Non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC: se lo elenca sotto la base (USDE) invece che sotto la quotazione (USDC), la sola lettura USDC non lo vedrebbe mai e il residuo si accumulerebbe per sempre.""" client = _ClientOrdini( open_orders=[], open_orders_usde=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R9"}], ) out = C.cancella_residui(client) assert out == ["R9"] assert client.cancellati == ["R9"] def test_cancella_residui_senza_residui_non_cancella_nulla(): client = _ClientOrdini(open_orders=[ {"instrument": U.SPOT, "label": "altra-cosa", "order_id": "R2"}, ]) assert C.cancella_residui(client) == [] assert client.cancellati == [] def test_cancella_residui_lettura_fallita_solleva(): """P5: non si ordina al buio. Se la LETTURA degli ordini aperti fallisce, la funzione deve sollevare invece di procedere come se non ci fosse nulla da cancellare.""" class _ClientRotto: def _post(self, path, payload): raise RuntimeError("gateway giu'") @staticmethod def _unwrap(resp): return resp with pytest.raises(Exception): C.cancella_residui(_ClientRotto()) def test_cancella_residui_un_residuo_che_non_si_cancella_solleva(): """Se non riusciamo a liberare un residuo NOSTRO, non si procede: si solleva (non si piazza un nuovo ordine sopra un residuo che non siamo riusciti a togliere).""" client = _ClientOrdini(open_orders=[ {"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "R1"}, ], boom_cancel={"R1"}) with pytest.raises(Exception): C.cancella_residui(client) # ============================================================================================= # _cancella — l'helper di basso livello usato sia da cancella_residui sia dal ciclo di main() # ============================================================================================= def test__cancella_ok(): client = _ClientOrdini() ok, motivo = C._cancella(client, "X1") assert ok and motivo == "" and client.cancellati == ["X1"] def test__cancella_errore_nel_payload_non_solleva(): client = _ClientOrdini(boom_cancel={"X1"}) ok, motivo = C._cancella(client, "X1") assert not ok and "rifiutata" in motivo def test__cancella_eccezione_di_rete_si_registra_non_si_propaga(): class _Rotto: def _post(self, path, payload): raise RuntimeError("timeout") @staticmethod def _unwrap(resp): return resp ok, motivo = C._cancella(_Rotto(), "X1") assert not ok and "timeout" in motivo # ============================================================================================= # main() con --esegui: cancellazione dei residui PRIMA del ciclo, e dopo ogni ordine parziale # ============================================================================================= def _piano_finto(**over): base = dict(ok=True, side="buy", amount=10.0, px=1.0, px_ordine=1.0000, motivi=[], quota_ora=0.20, quota_target=0.64, cap_usd=None, cap_frac=0.0, val_usde=500.0, equity_tot=1500.0, delta_usd=10.0, usdc_dopo=990.0, cuscino=300.0, cuscino_come="test") base.update(over) return base def _prepara_main(monkeypatch, client, tmp_path, argv_extra=()): monkeypatch.setattr(C, "DeribitRead", lambda: client) monkeypatch.setattr(C, "leggi_stato", lambda c: (1000.0, 500.0, 1.0, "test")) monkeypatch.setattr(C, "book_top", lambda *a, **k: (0.9999, 1.0001)) monkeypatch.setattr(C, "piano", lambda *a, **k: _piano_finto()) monkeypatch.setattr(C.time, "sleep", lambda s: None) monkeypatch.setattr(C, "LOG", tmp_path / "usde_convert.jsonl") monkeypatch.setattr(C, "ROOT", tmp_path) # solo per il print finale (LOG.relative_to(ROOT)) monkeypatch.setattr(sys, "argv", ["usde_convert.py", "--quota", "0.64", "--esegui", *argv_extra]) def test_ordine_con_filled_zero_viene_cancellato_e_il_ciclo_si_ferma(monkeypatch, tmp_path, capsys): client = _ClientOrdini( open_orders=[], risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "open", "filled_amount": 0}, "trades": []}}], ) _prepara_main(monkeypatch, client, tmp_path) rc = C.main() out = capsys.readouterr().out assert client.cancellati == ["O1"], "un ordine non fillato deve essere cancellato, non lasciato aperto" assert "residuo cancellato" in out assert rc == 1 # nessun fill -> esito negativo, comportamento invariato def test_cancel_che_fallisce_ferma_il_ciclo_e_non_manda_un_secondo_ordine(monkeypatch, tmp_path, capsys): client = _ClientOrdini( open_orders=[], risposte_place=[ {"result": {"order": {"order_id": "BOOM", "order_state": "open", "filled_amount": 0}, "trades": []}}, {"result": {"order": {"order_id": "O2", "order_state": "open", "filled_amount": 10}, "trades": []}}, # non deve mai partire ], boom_cancel={"BOOM"}, ) _prepara_main(monkeypatch, client, tmp_path) rc = C.main() out = capsys.readouterr().out assert client.cancellati == ["BOOM"] assert "FALLITA" in out and "mai un secondo ordine" in out assert len(client.risposte_place) == 1, "il secondo ordine non deve essere stato piazzato" assert rc == 1 def test_ordine_fillato_per_intero_non_si_cancella(monkeypatch, tmp_path, capsys): """filled == amt: nessun residuo, nessuna cancellazione (P3: non si spreca una chiamata su un ordine che non ha lasciato nulla aperto).""" client = _ClientOrdini( open_orders=[], risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled", "filled_amount": 10.0}, "trades": []}}], ) _prepara_main(monkeypatch, client, tmp_path) # piano() e' chiamato una volta PRIMA del ciclo (piano dry-run) e una volta per ogni giro: # le prime due chiamate (piano pre-ciclo + 1° giro) sono valide, la terza (2° giro) dice # 'gia' a bersaglio' per fermare il ciclo dopo il primo (e unico) ordine. chiamate = {"n": 0} def piano_poi_fine(*a, **k): chiamate["n"] += 1 if chiamate["n"] <= 2: return _piano_finto() return _piano_finto(ok=False, motivi=["gia' a bersaglio"]) monkeypatch.setattr(C, "piano", piano_poi_fine) C.main() out = capsys.readouterr().out assert client.cancellati == [] assert "residuo cancellato" not in out def test_residui_all_avvio_vengono_cancellati_prima_del_ciclo(monkeypatch, tmp_path, capsys): client = _ClientOrdini( open_orders=[{"instrument": U.SPOT, "label": "usde-quota-0.64", "order_id": "VECCHIO"}], risposte_place=[{"result": {"order": {"order_id": "O1", "order_state": "filled", "filled_amount": 10.0}, "trades": []}}], ) _prepara_main(monkeypatch, client, tmp_path) # piano() e' chiamato una volta PRIMA del ciclo e una volta per giro: le prime due chiamate # sono valide (piano pre-ciclo + 1° giro, che piazza l'unico ordine), la terza ferma il ciclo. chiamate = {"n": 0} def piano_poi_fine(*a, **k): chiamate["n"] += 1 return _piano_finto() if chiamate["n"] <= 2 else _piano_finto(ok=False, motivi=["fine"]) monkeypatch.setattr(C, "piano", piano_poi_fine) C.main() out = capsys.readouterr().out assert "VECCHIO" in client.cancellati assert "residui cancellati all'avvio: 1" in out # e il residuo pre-esistente e' stato cancellato PRIMA che partisse il primo place_order assert client.cancellati.index("VECCHIO") == 0