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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
|
||||||
cuscino_richiesto_usd = U.cuscino_richiesto_usd
|
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]:
|
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
|
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
|
||||||
@@ -232,6 +276,9 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
LOG.parent.mkdir(parents=True, exist_ok=True)
|
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
|
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
|
||||||
for _ in range(60):
|
for _ in range(60):
|
||||||
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
eq_usdc, eq_usde, px, _f = leggi_stato(c)
|
||||||
@@ -274,6 +321,18 @@ def main() -> int:
|
|||||||
"amount": amt, "filled": filled})
|
"amount": amt, "filled": filled})
|
||||||
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
|
||||||
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
|
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:
|
if filled <= 0:
|
||||||
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
|
||||||
time.sleep(a.pausa)
|
time.sleep(a.pausa)
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user