merge lavoro/goldrake-u1 (revisione 27/09)
This commit is contained in:
+22
-5
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user