From f0193f013d4a41ec53a6869405c1a665e382950d Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:38:20 +0000 Subject: [PATCH] usde_convert: cancella i residui GTC delle proprie label, nel ciclo e all'avvio (#26) Il gateway cerbero-mcp non accetta time_in_force (PlaceOrderReq non ha il campo): un limit riempito solo in parte restava APERTO per sempre e si accumulava a ogni giro. cancella_residui() pulisce lo spot USDE_USDC dai residui con label "usde-quota-*" prima del ciclo; nel ciclo, un ordine con filled < amt viene cancellato subito dopo, e una cancellazione fallita ferma il ciclo (mai un secondo ordine con un residuo vivo). Co-Authored-By: Claude Sonnet 5 --- scripts/live/usde_convert.py | 59 +++++++++ tests/test_usde_u3.py | 239 +++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 tests/test_usde_u3.py diff --git a/scripts/live/usde_convert.py b/scripts/live/usde_convert.py index b62cd07..baeacf3 100644 --- a/scripts/live/usde_convert.py +++ b/scripts/live/usde_convert.py @@ -93,6 +93,50 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e # L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui. cuscino_richiesto_usd = U.cuscino_richiesto_usd +# issue #26: label dei NOSTRI ordini sullo spot, per riconoscere i residui che questo script +# stesso ha lasciato aperti (mai altri: un ordine con label diversa non e' nostro e non si tocca). +LABEL_PREFIX = "usde-quota-" + + +def _cancella(c, order_id: str) -> tuple[bool, str]: + """Cancella UN ordine. -> (ok, motivo se no). NON solleva: il chiamante decide (P3) — la + lettura degli ordini aperti solleva (P5), la cancellazione di un singolo ordine no, perche' + il chiamante deve poter fermarsi con un messaggio invece di un traceback.""" + try: + resp = c._unwrap(c._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {} + except Exception as e: # noqa: BLE001 — si registra (P3) + return False, f"{type(e).__name__}: {e}" + if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error": + return False, str(resp.get("error", resp)) if isinstance(resp, dict) else str(resp) + return True, "" + + +def cancella_residui(c) -> list[str]: + """issue #26 — GTC mai cancellati. 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 si accumula a ogni giro invece di sparire + con l'ordine. Qui si legge lo spot U.SPOT e si cancella SOLO cio' che porta la NOSTRA label + (`usde-quota-*`): un ordine con altra label non e' nostro e non si tocca. + + -> lista degli order_id cancellati. Se la LETTURA degli ordini aperti fallisce SOLLEVA: non + si piazza un nuovo ordine senza sapere cosa c'e' gia' aperto (P5, non si ordina al buio).""" + out = c._unwrap(c._post("/mcp-deribit/tools/get_open_orders", + {"currency": "USDC", "kind": "spot", "type": "all"})) + ordini = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) + cancellati = [] + for o in ordini: + if not isinstance(o, dict): + continue + instrument = o.get("instrument") or o.get("instrument_name") + label = o.get("label") or "" + if instrument == U.SPOT and label.startswith(LABEL_PREFIX): + oid = o.get("order_id") + ok, motivo = _cancella(c, oid) + if not ok: + raise RuntimeError(f"cancellazione del residuo {oid} ({label}) fallita: {motivo}") + cancellati.append(oid) + return cancellati + def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]: """Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non @@ -232,6 +276,9 @@ def main() -> int: return 0 LOG.parent.mkdir(parents=True, exist_ok=True) + residui = cancella_residui(c) + print(f" residui cancellati all'avvio: {len(residui)}" + + (f" ({', '.join(residui)})" if residui else "")) chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0 for _ in range(60): eq_usdc, eq_usde, px, _f = leggi_stato(c) @@ -274,6 +321,18 @@ def main() -> int: "amount": amt, "filled": filled}) print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}" f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})") + # issue #26: un GTC riempito solo in parte (o per niente) resta APERTO — il gateway non + # accetta time_in_force, quindi la cancellazione e' tutta qui. Se la cancel fallisce si + # FERMA il ciclo: mai un secondo ordine mentre il residuo del primo e' ancora vivo. + if filled < amt: + oid = order.get("order_id") + ok, motivo = _cancella(c, oid) + fatti[-1]["cancellato"] = True if ok else f"errore: {motivo}" + if not ok: + print(f" ✗ cancellazione del residuo {oid} FALLITA: {motivo}" + " — mi fermo (mai un secondo ordine con un residuo vivo)") + break + print(f" · residuo cancellato: order_id {oid} (filled {filled:,.0f}/{amt:,.0f})") if filled <= 0: print(" ✗ ordine accettato ma non fillato — mi fermo"); break time.sleep(a.pausa) diff --git a/tests/test_usde_u3.py b/tests/test_usde_u3.py new file mode 100644 index 0000000..6c526ed --- /dev/null +++ b/tests/test_usde_u3.py @@ -0,0 +1,239 @@ +"""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, risposte_place=None, boom_cancel=None): + self.open_orders = open_orders or [] + 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 + 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_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