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
+22 -5
View File
@@ -236,11 +236,22 @@ class DeribitRead:
return out.get("positions", []) if isinstance(out, dict) else []
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"]
for p in self.positions(cur):
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
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]:
"""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"]
seen: dict = {}
for typ in ("all", "trigger_all"):
try:
out = self._unwrap(self._post("/mcp-deribit/tools/get_open_orders",
{"currency": cur, "type": typ}))
except Exception as e: # noqa: BLE001 — si registra il tipo (P3), non si ingoia (#24)
raise RuntimeError(f"open_orders illeggibile: {type(e).__name__}: {e}") from e
lst = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
for o in lst:
if o.get("instrument_name") == instrument or o.get("instrument") == instrument:
seen[o.get("order_id")] = o
except Exception:
pass
return list(seen.values())
+48 -14
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
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
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).
GUARDRAIL: solo strumenti in ALLOWED; cap di size SOLO sulle APERTURE — `set_open_cap()` lo deriva
per-strumento dal cap di config del libro (config/live.json, #18); MAX_AMOUNT resta il tetto di
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).
Il deploy pieno di TP01 resta gated finche' il percorso live non e' abilitato esplicitamente.
⚠️ INVIA ORDINI REALI CON SOLDI VERI. Il libro (TP01+SKH01) e' ARMATO e LIVE su Deribit mainnet dal
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
@@ -17,11 +22,13 @@ from dataclasses import dataclass
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
# (~$630/$430 su un conto ~$600): backstop sopra il sizing di TP01, non il sizing operativo (quello
# lo decide config/live.json max_notional_per_asset_usd). Il micro-test invia comunque size fissa minima.
# Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di
# DEFAULT (~$630/$430 su un conto ~$600), usato SOLO quando nessuno ha chiamato set_open_cap() (il
# 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"}
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
@@ -55,6 +62,20 @@ def _avg_price(order: dict, trades: list[dict]) -> float | None:
class DeribitTrader(DeribitRead):
"""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,
label: str, order_type: str = "market", price: float | None = None) -> Fill:
if instrument not in ALLOWED:
@@ -62,7 +83,7 @@ class DeribitTrader(DeribitRead):
if side not in ("buy", "sell"):
raise GuardrailError(f"side non valido: {side}")
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:
raise GuardrailError(f"size {amount} fuori dal cap apertura (0, {cap}]")
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 {}
def ensure_disaster_sl(self, instrument: str, sl_pct: float) -> dict:
"""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;
- gia' coerente (1 bracket, amount~=, stop entro 5%) -> lascia com'e' (niente churn/gap).
"""Garantisce UN disaster-SL coerente con la posizione (lifecycle completo, idempotente).
Stati possibili (`dict["state"]`):
- `"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
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
misurarlo. Riparato il silenzio, non toccata la sequenza.
"""
try:
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:
for o in brackets:
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