merge lavoro/goldrake-u3 (revisione 27/09)

This commit is contained in:
2026-09-27 13:53:10 +00:00
9 changed files with 572 additions and 65 deletions
+51
View File
@@ -162,3 +162,54 @@ def test_un_fill_non_misurabile_e_None_non_zero():
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None
# fee nulla e' invece una misura legittima (ordine maker a rebate zero)
assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0
# ===========================================================================
# issue #29 (B8) — save_state SOLO se l'invio (quando serviva) e' riuscito
# ===========================================================================
# Fino a qui `save_state` girava SEMPRE dopo l'eventuale notify, anche se l'invio necessario
# falliva: il giro dopo `diff_vs` avrebbe confrontato lo stato nuovo con se stesso e il cambio
# sarebbe sparito senza essere mai stato detto.
_RIS_AZIONE = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=12.0, maker_bps=0.0, liq_bps=100.0)},
changes=["BTC_USDC-PERPETUAL taker: 5.00 -> 12.00 bps"], worst_taker_bps=12.0,
level="AZIONE", reason="taker 12.0bps > 10: rivedere il peso di SKH01", first_read=False,
)
_RIS_OK_SENZA_CAMBI = dict(
current={"BTC_USDC-PERPETUAL": dict(taker_bps=3.0, maker_bps=0.0, liq_bps=75.0)},
changes=[], worst_taker_bps=3.0, level="OK", reason="ok", first_read=False,
)
def _prepara(monkeypatch, risultato, esito_invio):
salvato, inviato = [], []
monkeypatch.setattr(FW, "run", lambda: risultato)
monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur))
monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio)
monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None)
monkeypatch.setattr(FW, "realized_fee_bps", lambda *a, **k: {})
return salvato, inviato
def test_invio_fallito_non_salva_lo_stato(monkeypatch):
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=False)
rc = FW.main()
assert len(inviato) == 1, "un cambio con livello AZIONE deve tentare l'invio"
assert salvato == [], "invio fallito -> non si salva, il giro dopo ritenta lo stesso cambio"
assert rc == 2
def test_invio_riuscito_salva_lo_stato(monkeypatch):
salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True)
FW.main()
assert len(inviato) == 1
assert len(salvato) == 1 and salvato[0] == _RIS_AZIONE["current"]
def test_nessun_invio_dovuto_salva_comunque(monkeypatch):
"""Senza cambi e a livello OK non c'e' niente da dire: si salva, e non si chiama notify."""
salvato, inviato = _prepara(monkeypatch, _RIS_OK_SENZA_CAMBI, esito_invio=False)
FW.main()
assert inviato == [], "senza changes e livello OK non deve tentare l'invio"
assert len(salvato) == 1
+259
View File
@@ -0,0 +1,259 @@
"""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
+44 -12
View File
@@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero():
assert r["apr"] is None and r["finestre"] == 0
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
from datetime import datetime, timezone
def q(giorno, v):
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
assert q("2026-08-31", "NON_IDONEO") is False
def test_quota_da_decidere_e_stata_rimossa():
"""#35 (B3/B4): dal 10/09 (issue #7) la quota e' DERIVATA dal cuscino USDC
(usde._nota_cuscino), non e' piu' una decisione dell'operatore rinviata da ricordare ogni
giorno finche' non la si toglie a mano."""
assert not hasattr(W, "quota_da_decidere")
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------
@@ -266,3 +260,41 @@ def test_i_trade_prima_della_lettura_precedente_non_contano():
rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}]
tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now)
assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9
# =============================================================================================
# issue #29 (B2) — un invio fallito non deve consumare la transizione (schema di cuscino_watch)
# =============================================================================================
def test_notify_fallito_la_condizione_si_ripresenta_come_nuova():
"""Il difetto: lo stato si salvava PRIMA dell'invio e il False di notify() non si leggeva ->
un invio fallito restava 'gia' detto' per l'episodio intero. Qui: la condizione era attiva
l'ultima volta ma NON e' fra le confermate (`allertati`) -> deve tornare 'nuova'."""
att = {"QUOTA_OVER"}
prev = dict(allertati=[]) # QUOTA_OVER era attivo, l'invio era FALLITO
assert W.nuove_condizioni(att, att, prev) == {"QUOTA_OVER"}
def test_notify_riuscito_non_si_ripete():
att = {"QUOTA_OVER"}
prev = dict(allertati=["QUOTA_OVER"]) # invio RIUSCITO l'ultima volta
assert W.nuove_condizioni(att, att, prev) == set()
def test_record_vecchio_senza_allertati_vale_come_tutto_allertato():
"""Compatibilita': un record scritto prima di questo fix non ha il campo `allertati`. Non
deve produrre un rispam retroattivo delle condizioni gia' viste."""
att = {"QUOTA_OVER", "BLIND"}
prev = dict(stato="OK") # nessun campo `allertati`
assert W.nuove_condizioni(att, att, prev) == set()
def test_senza_record_precedente_tutto_e_nuovo():
att = {"QUOTA_OVER", "DEPEG_WARN"}
assert W.nuove_condizioni(att, set(), None) == att
def test_una_condizione_appena_comparsa_e_sempre_nuova():
"""Anche con `allertati` presente, una condizione che non era in att_prev e' nuova per
definizione — l'insieme confermato non la riguarda."""
assert W.nuove_condizioni({"BLIND"}, set(), dict(allertati=["QUOTA_OVER"])) == {"BLIND"}
+81
View File
@@ -499,3 +499,84 @@ def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
assert "non tentato" in rep["invio"]
# =============================================================================================
# issue #31 — BLIND senza ALERT lasciava severity=None: il titolo del runner cadeva sul default
# piu' forte ("PRIMO PASSO: prelievo di prova") anche per un episodio che e' "non vedo", non
# "stress misurato". BLIND e' lo stato MORBIDO (docstring del modulo).
# =============================================================================================
def test_severity_blind_quando_un_asset_e_cieco_senza_alert(monkeypatch, tmp_path):
sp = tmp_path / "vw.json"
def fake():
return {"ts": 1, "platform_locked": False, "locked_raw": "false", "maintenance": False,
"assets": {a: dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None)
for a in VW.ASSETS},
"deribit_errors": []}
monkeypatch.setattr(VW, "observe", fake)
rep = None
for _ in range(VW.BLIND_ALERT_HOURS):
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
assert any(l == "BLIND" for l in rep["levels"].values())
assert not any(l == "ALERT" for l in rep["levels"].values())
assert rep["severity"] == "blind"
def test_severity_alert_prevale_su_blind(monkeypatch, tmp_path):
"""Se un asset e' cieco ma l'altro e' in ALERT vero, la gravita' massima vince (P9).
BTC resta senza referenze per tutto il giro (arriva a BLIND all'ora `BLIND_ALERT_HOURS`);
ETH parte harmless e passa a un vero streak sopra soglia SOLO negli ultimi `PERSIST_HOURS`
round, cosi' che il round in cui ETH scatta ALERT sia lo stesso in cui BTC e' gia' BLIND."""
sp = tmp_path / "vw.json"
letture = {"n": 0}
soglia_eth = VW.BLIND_ALERT_HOURS - VW.PERSIST_HOURS # da qui in poi ETH costruisce lo streak
def fake():
letture["n"] += 1
eth_bps = VW.THRESHOLD_BPS * 3 if letture["n"] > soglia_eth else 1.0
return {"ts": letture["n"], "platform_locked": False, "locked_raw": "false",
"maintenance": False,
"assets": {"BTC": dict(deribit=1.0, n_refs=1, ref_spread_bps=None, bps=None),
"ETH": dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=eth_bps)},
"deribit_errors": []}
monkeypatch.setattr(VW, "observe", fake)
rep = None
for _ in range(VW.BLIND_ALERT_HOURS):
rep = VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
assert rep["levels"]["BTC"] == "BLIND"
assert rep["levels"]["ETH"] == "ALERT"
assert rep["severity"] == "alert"
def _carica_script_venue_watch():
"""scripts/live/venue_watch.py (il runner) — via importlib, per non collidere col nome
del modulo `src.live.venue_watch` gia' importato sopra come `VW`."""
import importlib.util
sp = importlib.util.spec_from_file_location(
"venue_watch_script", ROOT / "scripts" / "live" / "venue_watch.py")
m = importlib.util.module_from_spec(sp)
sp.loader.exec_module(m)
return m
def test_titolo_blind_non_dice_prelievo():
SVW = _carica_script_venue_watch()
t = SVW._titolo({"severity": "blind"})
assert "prelievo" not in t.lower()
assert "CIECO" in t
def test_titolo_alert_dice_ancora_prelievo():
SVW = _carica_script_venue_watch()
assert "prelievo" in SVW._titolo({"severity": "alert"}).lower()
assert "prelievo" in SVW._titolo({"severity": None}).lower() # default: nessuna gravita' letta
def test_titolo_warn_resta_quello_di_prima():
SVW = _carica_script_venue_watch()
assert "Nessuna azione" in SVW._titolo({"severity": "warn"})