feat(mcp-deribit,mcp-bybit): add place_combo_order

Deribit: private/create_combo + place_order sul combo instrument → una
sola crociata di spread invece di N (slippage atteso ridotto su
strutture liquide). ACL core + leverage cap su tutti i leg.

Bybit: place_batch_order su category=option (atomic multi-leg, 1
round-trip API). Reject su category != option (perp/linear non
supportano batch nativo). orderLinkId auto-generato per leg.

Tutti i test: deribit 48/48, bybit 123/123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdrianoDev
2026-04-27 23:12:09 +02:00
parent bacd5aab33
commit c2fd8330ca
9 changed files with 358 additions and 1 deletions
@@ -1401,6 +1401,38 @@ class DeribitClient:
return {"error": raw.get("error", "unknown"), "state": "error"}
return r
async def place_combo_order(
self,
legs: list[dict[str, Any]],
side: str,
amount: float,
type: str = "limit",
price: float | None = None,
label: str | None = None,
) -> dict:
"""Crea un combo via private/create_combo poi piazza un singolo ordine
(buy/sell) sull'instrument_name del combo. Una sola crociata di spread
invece di N (uno per leg) → minor slippage su strutture liquide.
legs: [{instrument_name, direction: 'buy'|'sell', ratio: int}].
"""
combo_raw = await self._request("private/create_combo", {"trades": legs})
combo = combo_raw.get("result")
if combo is None:
return {"state": "error", "error": combo_raw.get("error", "unknown")}
combo_instrument = combo.get("instrument_name") or combo.get("id")
order = await self.place_order(
instrument_name=combo_instrument,
side=side,
amount=amount,
type=type,
price=price,
label=label,
)
if order.get("state") == "error":
return {"state": "error", "error": order.get("error"), "combo_instrument": combo_instrument}
return {"combo_instrument": combo_instrument, **order}
async def set_leverage(self, instrument_name: str, leverage: int) -> dict:
"""CER-016: pre-set account leverage per evitare default 50x testnet."""
raw = await self._request(
@@ -188,6 +188,28 @@ class PlaceOrderReq(BaseModel):
leverage: int | None = None # CER-016: None → default cap (3x)
class ComboLeg(BaseModel):
instrument_name: str
direction: str # "buy" | "sell"
ratio: int = 1
class PlaceComboOrderReq(BaseModel):
legs: list[ComboLeg]
side: str # "buy" | "sell"
amount: float
type: str = "limit"
price: float | None = None
label: str | None = None
leverage: int | None = None
@model_validator(mode="after")
def _at_least_two_legs(self):
if len(self.legs) < 2:
raise ValueError("combo requires at least 2 legs")
return self
class CancelOrderReq(BaseModel):
order_id: str
@@ -477,6 +499,27 @@ def create_app(
label=body.label,
)
@app.post("/tools/place_combo_order", tags=["writes"])
async def t_place_combo_order(
body: PlaceComboOrderReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
lev = _enforce_leverage(body.leverage, creds=creds, exchange="deribit")
if lev != cap_default:
for leg in body.legs:
try:
await client.set_leverage(leg.instrument_name, lev)
except Exception:
pass
return await client.place_combo_order(
legs=[leg.model_dump() for leg in body.legs],
side=body.side,
amount=body.amount,
type=body.type,
price=body.price,
label=body.label,
)
@app.post("/tools/cancel_order", tags=["writes"])
async def t_cancel_order(
body: CancelOrderReq, principal: Principal = Depends(require_principal)
@@ -537,6 +580,7 @@ def create_app(
{"name": "get_technical_indicators", "description": "Indicatori tecnici (RSI, MACD, ATR, ADX)."},
{"name": "get_realized_vol", "description": "Volatilità realizzata annualizzata (log-return std) BTC/ETH + spread IVRV."},
{"name": "place_order", "description": "Invia ordine (CORE only, testnet)."},
{"name": "place_combo_order", "description": "Crea combo via private/create_combo + piazza ordine sul combo (1 cross spread invece di N)."},
{"name": "cancel_order", "description": "Cancella ordine."},
{"name": "set_stop_loss", "description": "Setta stop loss su posizione."},
{"name": "set_take_profit", "description": "Setta take profit su posizione."},
+61
View File
@@ -221,6 +221,67 @@ async def test_get_dvol(httpx_mock: HTTPXMock, client: DeribitClient):
assert result["candles"][0]["close"] == 57.0
@pytest.mark.asyncio
async def test_place_combo_order(httpx_mock: HTTPXMock, client: DeribitClient):
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
json=AUTH_RESP,
)
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/private/create_combo"),
json={
"result": {
"id": "BTC-COMBO-1",
"instrument_name": "BTC-COMBO-1",
"state": "active",
}
},
)
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/private/buy"),
json={
"result": {
"order": {"order_id": "ord-1", "order_state": "open"},
"trades": [],
}
},
)
legs = [
{"instrument_name": "BTC-30APR26-75000-C", "direction": "buy", "ratio": 1},
{"instrument_name": "BTC-30APR26-80000-C", "direction": "sell", "ratio": 1},
]
result = await client.place_combo_order(
legs=legs,
side="buy",
amount=1,
type="limit",
price=0.05,
label="vert-spread",
)
assert result["combo_instrument"] == "BTC-COMBO-1"
assert result["order"]["order_id"] == "ord-1"
@pytest.mark.asyncio
async def test_place_combo_order_create_combo_error(httpx_mock: HTTPXMock, client: DeribitClient):
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
json=AUTH_RESP,
)
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/private/create_combo"),
json={"error": {"code": -32602, "message": "Invalid leg"}},
)
result = await client.place_combo_order(
legs=[{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}],
side="buy",
amount=1,
type="market",
)
assert result["state"] == "error"
assert "Invalid leg" in str(result.get("error"))
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client: DeribitClient):
httpx_mock.add_response(
@@ -20,6 +20,7 @@ def mock_client():
c.get_historical = AsyncMock(return_value={"candles": []})
c.get_technical_indicators = AsyncMock(return_value={"rsi": 55.0})
c.place_order = AsyncMock(return_value={"order_id": "x"})
c.place_combo_order = AsyncMock(return_value={"combo_instrument": "BTC-COMBO-1", "order": {"order_id": "x"}})
c.cancel_order = AsyncMock(return_value={"order_id": "x", "state": "cancelled"})
c.set_stop_loss = AsyncMock(return_value={"order_id": "x", "stop_price": 45000})
c.set_take_profit = AsyncMock(return_value={"order_id": "x", "tp_price": 55000})
@@ -94,6 +95,73 @@ def test_place_order_observer_forbidden(http):
assert r.status_code == 403
def test_place_combo_order_core_ok(http):
r = http.post(
"/tools/place_combo_order",
headers={"Authorization": "Bearer ct"},
json={
"legs": [
{"instrument_name": "BTC-30APR26-75000-C", "direction": "buy", "ratio": 1},
{"instrument_name": "BTC-30APR26-80000-C", "direction": "sell", "ratio": 1},
],
"side": "buy",
"amount": 1,
"type": "limit",
"price": 0.05,
},
)
assert r.status_code == 200
assert r.json()["combo_instrument"] == "BTC-COMBO-1"
def test_place_combo_order_observer_forbidden(http):
r = http.post(
"/tools/place_combo_order",
headers={"Authorization": "Bearer ot"},
json={
"legs": [
{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1},
{"instrument_name": "BTC-Y", "direction": "sell", "ratio": 1},
],
"side": "buy",
"amount": 1,
},
)
assert r.status_code == 403
def test_place_combo_order_min_legs(http):
r = http.post(
"/tools/place_combo_order",
headers={"Authorization": "Bearer ct"},
json={
"legs": [{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1}],
"side": "buy",
"amount": 1,
},
)
assert r.status_code == 422
def test_place_combo_order_leverage_cap_enforced(http):
r = http.post(
"/tools/place_combo_order",
headers={"Authorization": "Bearer ct"},
json={
"legs": [
{"instrument_name": "BTC-X", "direction": "buy", "ratio": 1},
{"instrument_name": "BTC-Y", "direction": "sell", "ratio": 1},
],
"side": "buy",
"amount": 1,
"leverage": 50,
},
)
assert r.status_code == 403
err = r.json()["error"]
assert err["code"] == "LEVERAGE_CAP_EXCEEDED"
def test_place_order_leverage_cap_enforced(http):
"""Reject leverage > max_leverage (da secret, default 3)."""
r = http.post(