7c72308852
open_orders solleva RuntimeError se 'all' o 'trigger_all' falliscono, invece di ritornare []
(un bracket disaster-SL perso su 'trigger_all' e' lo stesso difetto di una posizione persa su
'all'). ensure_disaster_sl avvolge la lettura di posizione+open_orders in try/except: su
eccezione ritorna {"state": "unknown", "notes": ...} senza cancellare ne' piazzare, distinto
da "naked" (protezione tolta e non rimessa). shadow.shadow_report chiama open_orders gia'
dentro try/except (non toccato).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
225 lines
9.1 KiB
Python
225 lines
9.1 KiB
Python
"""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
|