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

This commit is contained in:
2026-09-27 13:53:10 +00:00
3 changed files with 300 additions and 25 deletions
+26 -9
View File
@@ -236,11 +236,22 @@ class DeribitRead:
return out.get("positions", []) if isinstance(out, dict) else [] return out.get("positions", []) if isinstance(out, dict) else []
def position_usd(self, instrument: str) -> float: def position_usd(self, instrument: str) -> float:
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.""" """Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat.
⚠️ Il gateway manda `size`/`size_currency` in ASSOLUTO e il segno a parte in `direction`
(#17): letta com'era, uno short risultava un long, e close()/ensure_disaster_sl decidevano
lato ordine e prezzo dello stop dal segno sbagliato. `direction` assente o `"zero"` -> ci si
fida di `size` cosi' com'e' (compatibile con un gateway che un giorno mandasse gia' il segno)."""
cur = _CONTRACT[instrument]["settle"] cur = _CONTRACT[instrument]["settle"]
for p in self.positions(cur): for p in self.positions(cur):
if p.get("instrument_name") == instrument or p.get("instrument") == instrument: if p.get("instrument_name") == instrument or p.get("instrument") == instrument:
return float(p.get("size") or p.get("size_currency") or 0.0) s = float(p.get("size") or p.get("size_currency") or 0.0)
direction = str(p.get("direction") or "").lower()
if direction in ("sell", "short"):
return -abs(s)
if direction in ("buy", "long"):
return abs(s)
return s
return 0.0 return 0.0
def trade_history(self, instrument: str, limit: int = 20) -> list[dict]: def trade_history(self, instrument: str, limit: int = 20) -> list[dict]:
@@ -251,17 +262,23 @@ class DeribitRead:
def open_orders(self, instrument: str) -> list[dict]: def open_orders(self, instrument: str) -> list[dict]:
"""Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger """Ordini APERTI su `instrument` (limit resting + trigger). Deribit puo' omettere i trigger
untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.""" untriggered da type='all' -> interroga anche 'trigger_all' e fa merge per order_id.
⚠️ Se UNA delle due chiamate solleva, NON si ingoia (#24): prima ritornava [] anche a meta'
letta, e un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione
persa su 'all' — il chiamante (ensure_disaster_sl) deve saperlo, non decidere su dati a
meta'. Il chiamante che vuole restare in piedi comunque (shadow.shadow_report) si mette il
try/except da solo."""
cur = _CONTRACT[instrument]["settle"] cur = _CONTRACT[instrument]["settle"]
seen: dict = {} seen: dict = {}
for typ in ("all", "trigger_all"): for typ in ("all", "trigger_all"):
try: try:
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders", out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
{"currency": cur, "type": typ})) {"currency": cur, "type": typ}))
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else []) except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
for o in lst: raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
if o.get("instrument_name") == instrument or o.get("instrument") == instrument: lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
seen[o.get("order_id")] = o for o in lst:
except Exception: if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
pass seen[o.get("order_id")] = o
return list(seen.values()) return list(seen.values())
+50 -16
View File
@@ -4,12 +4,17 @@ Estende DeribitRead (sola lettura) coi metodi di trading, con la logica PROVATA
(Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee (Old/src/live/execution.py): entrata market verificata (state=='filled' + trade riscontrati, fill/fee
reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only. reali, filled_amount autorevole), uscita market reduce_only, disaster-bracket STOP_MARKET reduce_only.
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE (MAX_AMOUNT). Le CHIUSURE si GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
tentano SEMPRE senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
Nessun parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE). DEFAULT per chi non lo chiama (il micro-test, size fissa minima). Le CHIUSURE si tentano SEMPRE
senza cap (principio di sicurezza di Old: si deve poter uscire da qualunque posizione). Nessun
parametro di leva (Deribit non la accetta per-ordine: l'esposizione la decide la SIZE).
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Finestra d'uso attuale: micro-test (scripts/live/microtest.py). ⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente. 2026-06-23 (commit db738bc): questo NON e' un percorso di micro-test, e' l'esecuzione di produzione
usata da `scripts/live/book_execute.py` (cron del book). Il micro-test
(scripts/live/microtest.py) resta un percorso a parte, sotto lo stesso guardrail, col cap di
DEFAULT (MAX_AMOUNT).
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,11 +22,13 @@ from dataclasses import dataclass
from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger # Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello # DEFAULT (~$630/$430 su un conto ~$600), usato SOLO quando nessuno ha chiamato set_open_cap() (il
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima. # micro-test, che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva
# set_open_cap() dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop
# sopra il sizing di TP01/book, non il sizing operativo.
ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"}
MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap()
FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat
@@ -55,6 +62,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
class DeribitTrader(DeribitRead): class DeribitTrader(DeribitRead):
"""Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre.""" """Trading minimo e verificato. Apre/chiude solo entro i guardrail; le chiusure sempre."""
def __init__(self) -> None:
super().__init__()
self._open_cap: dict[str, float] = {} # per-strumento, da set_open_cap() (#18)
def set_open_cap(self, instrument: str, usd_cap: float, mark: float) -> None:
"""Tetto di apertura per `instrument`, derivato dal cap di config del libro (#18):
amount_max = 1,10 * usd_cap / mark — 10% sopra il cap nozionale per non rifiutare
un'apertura legittima per un arrotondamento di sizing/mark. Backstop anti-fat-finger
sopra il sizing di TP01/book, non il sizing operativo (quello resta config/live.json).
Senza chiamata, `_submit` resta sul DEFAULT MAX_AMOUNT (il micro-test)."""
if usd_cap <= 0 or mark <= 0:
raise ValueError(f"set_open_cap richiede usd_cap>0 e mark>0 (usd_cap={usd_cap}, mark={mark})")
self._open_cap[instrument] = 1.10 * usd_cap / mark
def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool, def _submit(self, instrument: str, side: str, amount: float, *, reduce_only: bool,
label: str, order_type: str = "market", price: float | None = None) -> Fill: label: str, order_type: str = "market", price: float | None = None) -> Fill:
if instrument not in ALLOWED: if instrument not in ALLOWED:
@@ -62,7 +83,7 @@ class DeribitTrader(DeribitRead):
if side not in ("buy", "sell"): if side not in ("buy", "sell"):
raise GuardrailError(f"side non valido: {side}") raise GuardrailError(f"side non valido: {side}")
if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre if not reduce_only: # cap SOLO sulle aperture; le chiusure si tentano sempre
cap = MAX_AMOUNT.get(instrument, 0.0) cap = self._open_cap.get(instrument, MAX_AMOUNT[instrument])
if amount <= 0 or amount > cap: if amount <= 0 or amount > cap:
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]") raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
if amount <= 0: if amount <= 0:
@@ -194,10 +215,20 @@ class DeribitTrader(DeribitRead):
return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {} return self._unwrap(self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict: def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente): """Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente).
- flat -> cancella eventuali bracket orfani;
- long -> assicura UN solo STOP_MARKET reduce_only a ~-sl_pct, size = posizione; Stati possibili (`dict["state"]`):
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap). - `"flat"` -> nessuna posizione: cancella eventuali bracket orfani;
- `"ok"` -> gia' coerente (1 bracket, amount~=, stop entro 5%): lascia com'e';
- `"placed"` -> ricostruito (cancellato l'incoerente e ripiazzato) al primo o secondo
tentativo, e il piazzamento e' verificato;
- `"place-failed"` -> il ripiazzamento e' partito ma NON e' verificato (state/trade non
confermano): l'ordine potrebbe non essere a book;
- `"naked"` -> il bracket incoerente e' stato cancellato e il ripiazzamento e'
fallito (eccezione) 2 volte di fila: la posizione e' SENZA stop;
- `"unknown"` -> posizione o open_orders illeggibili (#24): NON cancella ne' piazza —
decidere alla cieca su meta' dei dati e' peggio che aspettare il giro
dopo. `notes` porta tipo ed errore della lettura fallita.
⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima ⚠️ LA FINESTRA SCOPERTA (riparata 2026-08-25). Il ramo di ricostruzione **cancella prima
e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il e ripiazza dopo**: fra le due chiamate la posizione e' senza alcuno stop. Finche' il
@@ -216,8 +247,11 @@ class DeribitTrader(DeribitRead):
non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza non basta per cambiare il ciclo di vita dei bracket su un percorso con soldi veri senza
misurarlo. Riparato il silenzio, non toccata la sequenza. misurarlo. Riparato il silenzio, non toccata la sequenza.
""" """
pos = self.position_usd(instrument) try:
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL] pos = self.position_usd(instrument)
brackets = [o for o in self.open_orders(instrument) if (o.get("label") or "") == DISASTER_LABEL]
except Exception as e: # noqa: BLE001 — si registra il motivo (P3); non si cancella ne' piazza (#24)
return {"state": "unknown", "notes": f"{type(e).__name__}: {e}"}
if abs(pos) < FLAT_USD: if abs(pos) < FLAT_USD:
for o in brackets: for o in brackets:
self.cancel_order(o.get("order_id")) self.cancel_order(o.get("order_id"))
+224
View File
@@ -0,0 +1,224 @@
"""Tre riparazioni sul percorso con soldi veri (unita' U1, 2026-09-27), provate contro il codice
che dovrebbe farle — non discusse.
#17. `DeribitRead.position_usd` leggeva `size`/`size_currency` in ASSOLUTO e ignorava
`direction`: uno short risultava un long, e close()/ensure_disaster_sl decidevano lato
ordine e prezzo dello stop dal segno sbagliato.
#18. Tetto per ordine d'apertura: `MAX_AMOUNT` era un tetto FISSO uguale per tutti i conti;
ora il libro deriva il proprio tetto dal cap di config via `set_open_cap()`.
#24. `DeribitRead.open_orders` ingoiava gli errori di ENTRAMBE le chiamate ('all' e
'trigger_all') e ritornava []: un bracket disaster-SL perso passava per "nessun bracket".
Ora solleva, e `ensure_disaster_sl` distingue lo stato "unknown" (letture fallite, non si
cancella ne' si piazza) da "naked" (protezione tolta e non rimessa).
Nessun test tocca la rete.
"""
from __future__ import annotations
import pytest
from src.live.deribit import DeribitRead
from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, GuardrailError
ETH = "ETH_USDC-PERPETUAL"
# =================================================================================================
# #17 — position_usd: il segno viene da `direction`, non assunto dal solo `size`.
# =================================================================================================
class _FakeRead(DeribitRead):
"""DeribitRead senza rete: `positions()` finto, per esercitare `position_usd` con la forma
REALE del gateway (size in assoluto + direction a parte)."""
def __init__(self, raw: list[dict]) -> None:
self._raw = raw
def positions(self, currency):
return self._raw
def test_position_usd_sell_e_negativo():
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
assert r.position_usd(ETH) == -560.0
def test_position_usd_buy_e_positivo():
r = _FakeRead([{"instrument_name": ETH, "size": 560, "direction": "buy"}])
assert r.position_usd(ETH) == 560.0
def test_position_usd_senza_direction_si_fida_del_segno_di_size():
"""Compatibile con un gateway che un giorno mandasse gia' il segno in `size`."""
r = _FakeRead([{"instrument_name": ETH, "size": -3}])
assert r.position_usd(ETH) == -3.0
# =================================================================================================
# Verifica a valle di #17: close() e ensure_disaster_sl() decidono lato/stop da `pos > 0`.
# Si mocka `positions()` (dati grezzi del gateway), NON `position_usd()`: cosi' si esercita
# davvero la pipeline positions() -> position_usd() -> close()/ensure_disaster_sl().
# =================================================================================================
class _FakeTraderShort(DeribitTrader):
"""DeribitTrader senza rete: posizione SHORT letta come il gateway la manda davvero, mark
fisso, `_submit` registra la chiamata invece di piazzare l'ordine."""
def __init__(self, raw_positions: list[dict], mark: float = 2694.0) -> None:
self._open_cap: dict[str, float] = {}
self._raw = raw_positions
self._mark = mark
self.submitted: list[dict] = []
self.cancelled: list[str] = []
def positions(self, currency):
return self._raw
def mark_price(self, instrument):
return self._mark
def open_orders(self, instrument):
return []
def cancel_order(self, order_id):
self.cancelled.append(order_id)
return {}
def _submit(self, instrument, side, amount, *, reduce_only, label,
order_type="market", price=None):
self.submitted.append(dict(instrument=instrument, side=side, amount=amount,
reduce_only=reduce_only, label=label,
order_type=order_type, price=price))
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
fee_usdc=0.0, order_id="1", state="filled", verified=True)
def _short_560():
return _FakeTraderShort([{"instrument_name": ETH, "size": 560, "direction": "sell"}])
def test_close_su_short_usa_side_buy_reduce_only():
t = _short_560()
f = t.close(ETH)
assert f is not None
assert len(t.submitted) == 1
call = t.submitted[0]
assert call["side"] == "buy" and call["reduce_only"] is True
assert call["label"] == "tp01-close" and call["amount"] > 0
def test_ensure_disaster_sl_su_short_mette_lo_stop_sopra_il_mark_lato_buy():
t = _short_560()
ds = t.ensure_disaster_sl(ETH, 0.30)
assert ds["state"] == "placed"
assert len(t.submitted) == 1
call = t.submitted[0]
assert call["side"] == "buy"
assert call["order_type"] == "stop_market"
assert call["price"] > t._mark # stop SOPRA il mark per uno short
# =================================================================================================
# #18 — set_open_cap(): tetto di apertura per strumento, derivato dal cap di config.
# =================================================================================================
class _FakeTraderCap(DeribitTrader):
"""DeribitTrader senza rete: `_post` risponde con un ordine fillato finto, cosi' la parte di
_submit DOPO il guardrail (che qui non ci interessa) non tocca la rete."""
def __init__(self) -> None:
self._open_cap: dict[str, float] = {}
def _post(self, path, payload):
amt = payload["amount"]
return {"order": {"order_id": "1", "order_state": "filled", "filled_amount": amt},
"trades": [{"price": 1.0, "amount": amt, "fee": 0.0}]}
def test_set_open_cap_valida_gli_argomenti():
t = _FakeTraderCap()
with pytest.raises(ValueError):
t.set_open_cap(ETH, 0.0, 2694.0)
with pytest.raises(ValueError):
t.set_open_cap(ETH, 2291.5, 0.0)
def test_set_open_cap_apertura_entro_il_tetto_non_solleva():
"""Equity 4583, cap/asset 2291.5, ETH mark 2694 -> apertura di 0,2807 ETH e' ben dentro il
tetto (1,10 * 2291.5 / 2694 ~= 0,9355 ETH)."""
t = _FakeTraderCap()
t.set_open_cap(ETH, 2291.5, 2694.0)
t._submit(ETH, "buy", 0.2807, reduce_only=False, label="book-open") # non deve solllevare
def test_set_open_cap_3x_il_tetto_solleva_guardrail():
t = _FakeTraderCap()
t.set_open_cap(ETH, 2291.5, 2694.0)
cap = t._open_cap[ETH]
with pytest.raises(GuardrailError):
t._submit(ETH, "buy", cap * 3, reduce_only=False, label="book-open")
def test_senza_set_open_cap_resta_il_default_max_amount():
t = _FakeTraderCap()
dentro = MAX_AMOUNT[ETH] * 0.5
t._submit(ETH, "buy", dentro, reduce_only=False, label="book-open") # non deve solllevare
with pytest.raises(GuardrailError):
t._submit(ETH, "buy", MAX_AMOUNT[ETH] * 3, reduce_only=False, label="book-open")
# =================================================================================================
# #24 — open_orders non ingoia gli errori; ensure_disaster_sl diventa "unknown", non a caso.
# =================================================================================================
class _FakeOpenOrders(DeribitRead):
"""DeribitRead senza rete: 'all' risponde vuoto, 'trigger_all' solleva — replica un bracket
disaster-SL perso su un solo dei due tipi di chiamata."""
def __init__(self) -> None:
pass # niente token, niente rete
def _post(self, path, payload):
if payload.get("type") == "trigger_all":
raise RuntimeError("502 Server Error: Bad Gateway")
return {"result": []}
def test_open_orders_trigger_all_solleva_runtime_error():
r = _FakeOpenOrders()
with pytest.raises(RuntimeError, match="open_orders illeggibile"):
r.open_orders(ETH)
class _FakeTraderOpenOrdersRompe(DeribitTrader):
"""position_usd legge una posizione REALE, ma open_orders solleva: ensure_disaster_sl non
deve decidere su meta' dei dati."""
def __init__(self) -> None:
self._open_cap: dict[str, float] = {}
self.submitted: list[dict] = []
self.cancelled: list[str] = []
def positions(self, currency):
return [{"instrument_name": ETH, "size": 560, "direction": "sell"}]
def mark_price(self, instrument):
return 2694.0
def open_orders(self, instrument):
raise RuntimeError("open_orders illeggibile: RuntimeError: 502 Server Error: Bad Gateway")
def cancel_order(self, order_id):
self.cancelled.append(order_id)
return {}
def _submit(self, instrument, side, amount, *, reduce_only, label,
order_type="market", price=None):
self.submitted.append(dict(side=side, reduce_only=reduce_only))
return Fill(instrument=instrument, side=side, amount=amount, filled=amount, price=price,
fee_usdc=0.0, order_id="1", state="filled", verified=True)
def test_ensure_disaster_sl_con_open_orders_che_solleva_diventa_unknown_senza_agire():
t = _FakeTraderOpenOrdersRompe()
ds = t.ensure_disaster_sl(ETH, 0.30)
assert ds["state"] == "unknown"
assert "502" in ds["notes"]
assert t.cancelled == [] # nessuna cancel
assert t.submitted == [] # nessun piazzamento