diff --git a/src/live/execution.py b/src/live/execution.py index 37bec30..5ca9c80 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -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: diff --git a/tests/test_execution_u1.py b/tests/test_execution_u1.py index 5e266ea..7fdef8f 100644 --- a/tests/test_execution_u1.py +++ b/tests/test_execution_u1.py @@ -4,13 +4,17 @@ 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()`. Nessun test tocca la rete. """ from __future__ import annotations +import pytest + from src.live.deribit import DeribitRead -from src.live.execution import DeribitTrader, Fill +from src.live.execution import MAX_AMOUNT, DeribitTrader, Fill, GuardrailError ETH = "ETH_USDC-PERPETUAL" @@ -106,3 +110,51 @@ def test_ensure_disaster_sl_su_short_mette_lo_stop_sopra_il_mark_lato_buy(): 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")