From c2fd8330caa78530fef63a16d14acf60fb1f8848 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 27 Apr 2026 23:12:09 +0200 Subject: [PATCH] feat(mcp-deribit,mcp-bybit): add place_combo_order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 5 ++ services/mcp-bybit/src/mcp_bybit/client.py | 45 ++++++++++++ services/mcp-bybit/src/mcp_bybit/server.py | 26 ++++++- services/mcp-bybit/tests/test_client.py | 58 ++++++++++++++++ services/mcp-bybit/tests/test_server_acl.py | 20 ++++++ .../mcp-deribit/src/mcp_deribit/client.py | 32 +++++++++ .../mcp-deribit/src/mcp_deribit/server.py | 44 ++++++++++++ services/mcp-deribit/tests/test_client.py | 61 +++++++++++++++++ services/mcp-deribit/tests/test_server_acl.py | 68 +++++++++++++++++++ 9 files changed, 358 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d0fc98f..ff088cd 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ split documentato in `docs/superpowers/specs/2026-04-27-split-mcp-core-design.md ## Servizi - `mcp-alpaca`, `mcp-bybit`, `mcp-deribit`, `mcp-hyperliquid` — exchange con `place_order`, `environment_info`, leverage cap server-side +- `mcp-deribit` e `mcp-bybit` espongono inoltre `place_combo_order`: + - Deribit: `private/create_combo` + ordine sul combo → 1 sola crociata + di spread invece di N (slippage atteso ridotto su strutture liquide). + - Bybit: `place_batch_order` su `category=option` → multi-leg atomico + in un solo round-trip API (no sconto fee, solo atomicità + latenza). - `mcp-macro`, `mcp-sentiment` — read-only market data ## Avvio locale diff --git a/services/mcp-bybit/src/mcp_bybit/client.py b/services/mcp-bybit/src/mcp_bybit/client.py index 4412902..354c535 100644 --- a/services/mcp-bybit/src/mcp_bybit/client.py +++ b/services/mcp-bybit/src/mcp_bybit/client.py @@ -412,6 +412,51 @@ class BybitClient: "status": "submitted", }) + async def place_combo_order( + self, + category: str, + legs: list[dict[str, Any]], + ) -> dict: + """Atomic multi-leg via /v5/order/create-batch (Bybit option only). + + Bybit supporta batch_order solo su category='option'. Per perp/linear + usare loop di place_order (non atomic). + + legs: [{symbol, side, qty, order_type, price?, tif?, reduce_only?}]. + """ + if category != "option": + raise ValueError("place_combo_order: Bybit batch_order è disponibile solo su category='option'") + if len(legs) < 2: + raise ValueError("combo requires at least 2 legs") + + import uuid + request: list[dict[str, Any]] = [] + for leg in legs: + entry: dict[str, Any] = { + "symbol": leg["symbol"], + "side": leg["side"], + "qty": str(leg["qty"]), + "orderType": leg.get("order_type", "Limit"), + "timeInForce": leg.get("tif", "GTC"), + "reduceOnly": leg.get("reduce_only", False), + "orderLinkId": f"cerbero-{uuid.uuid4().hex[:16]}", + } + if leg.get("price") is not None: + entry["price"] = str(leg["price"]) + request.append(entry) + + resp = await self._run(self._http.place_batch_order, category=category, request=request) + result_list = (resp.get("result") or {}).get("list") or [] + orders = [ + { + "order_id": r.get("orderId"), + "order_link_id": r.get("orderLinkId"), + "status": "submitted", + } + for r in result_list + ] + return self._envelope(resp, {"orders": orders}) + async def amend_order( self, category: str, diff --git a/services/mcp-bybit/src/mcp_bybit/server.py b/services/mcp-bybit/src/mcp_bybit/server.py index 0efdbc5..b429194 100644 --- a/services/mcp-bybit/src/mcp_bybit/server.py +++ b/services/mcp-bybit/src/mcp_bybit/server.py @@ -7,7 +7,7 @@ from mcp_common.auth import Principal, TokenStore, require_principal from mcp_common.environment import EnvironmentInfo from mcp_common.mcp_bridge import mount_mcp_endpoint from mcp_common.server import build_app -from pydantic import BaseModel +from pydantic import BaseModel, Field from mcp_bybit.client import BybitClient from mcp_bybit.leverage_cap import enforce_leverage as _enforce_leverage @@ -114,6 +114,21 @@ class PlaceOrderReq(BaseModel): position_idx: int | None = None +class ComboLegReq(BaseModel): + symbol: str + side: str + qty: float + order_type: str = "Limit" + price: float | None = None + tif: str = "GTC" + reduce_only: bool = False + + +class PlaceComboOrderReq(BaseModel): + category: str = "option" + legs: list[ComboLegReq] = Field(..., min_length=2) + + class AmendOrderReq(BaseModel): category: str symbol: str @@ -306,6 +321,14 @@ def create_app( body.order_type, body.price, body.tif, body.reduce_only, body.position_idx, ) + @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) + return await client.place_combo_order( + category=body.category, + legs=[leg.model_dump() for leg in body.legs], + ) + @app.post("/tools/amend_order", tags=["writes"]) async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) @@ -381,6 +404,7 @@ def create_app( {"name": "get_open_orders", "description": "Ordini pending."}, {"name": "get_basis_spot_perp", "description": "Basis spot vs linear perp."}, {"name": "place_order", "description": "Invia ordine (CORE only)."}, + {"name": "place_combo_order", "description": "Multi-leg atomico via place_batch_order (solo category=option)."}, {"name": "amend_order", "description": "Modifica ordine esistente."}, {"name": "cancel_order", "description": "Cancella ordine."}, {"name": "cancel_all_orders", "description": "Cancella tutti ordini."}, diff --git a/services/mcp-bybit/tests/test_client.py b/services/mcp-bybit/tests/test_client.py index 91083f4..d88461b 100644 --- a/services/mcp-bybit/tests/test_client.py +++ b/services/mcp-bybit/tests/test_client.py @@ -423,6 +423,64 @@ async def test_place_order_linear_no_link_id(client, mock_http): assert "orderLinkId" not in kwargs +@pytest.mark.asyncio +async def test_place_combo_order_batch_option(client, mock_http): + """Combo order via place_batch_order su category=option (atomic, 1 round-trip).""" + mock_http.place_batch_order.return_value = { + "retCode": 0, + "result": { + "list": [ + {"orderId": "ord-1", "orderLinkId": "cerbero-leg1"}, + {"orderId": "ord-2", "orderLinkId": "cerbero-leg2"}, + ] + }, + } + legs = [ + {"symbol": "BTC-30APR26-75000-C-USDT", "side": "Buy", "qty": 0.01, "order_type": "Limit", "price": 5.0}, + {"symbol": "BTC-30APR26-80000-C-USDT", "side": "Sell", "qty": 0.01, "order_type": "Limit", "price": 3.0}, + ] + out = await client.place_combo_order(category="option", legs=legs) + assert len(out["orders"]) == 2 + assert out["orders"][0]["order_id"] == "ord-1" + kwargs = mock_http.place_batch_order.call_args.kwargs + assert kwargs["category"] == "option" + request = kwargs["request"] + assert len(request) == 2 + assert request[0]["symbol"] == "BTC-30APR26-75000-C-USDT" + assert request[0]["qty"] == "0.01" + assert request[0]["orderType"] == "Limit" + # CER: orderLinkId obbligatorio per option + assert "orderLinkId" in request[0] + + +@pytest.mark.asyncio +async def test_place_combo_order_error(client, mock_http): + mock_http.place_batch_order.return_value = {"retCode": 10001, "retMsg": "invalid leg"} + out = await client.place_combo_order( + category="option", + legs=[ + {"symbol": "X", "side": "Buy", "qty": 1, "order_type": "Limit", "price": 1.0}, + {"symbol": "Y", "side": "Sell", "qty": 1, "order_type": "Limit", "price": 1.0}, + ], + ) + assert out["error"] == "invalid leg" + assert out["code"] == 10001 + + +@pytest.mark.asyncio +async def test_place_combo_order_rejects_non_option(client, mock_http): + """Bybit batch_order è disponibile solo su option category.""" + import pytest as _pytest + with _pytest.raises(ValueError, match="option"): + await client.place_combo_order( + category="linear", + legs=[ + {"symbol": "BTCUSDT", "side": "Buy", "qty": 0.01, "order_type": "Market"}, + {"symbol": "ETHUSDT", "side": "Sell", "qty": 0.01, "order_type": "Market"}, + ], + ) + + @pytest.mark.asyncio async def test_cancel_order(client, mock_http): mock_http.cancel_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}} diff --git a/services/mcp-bybit/tests/test_server_acl.py b/services/mcp-bybit/tests/test_server_acl.py index c54fa90..f278911 100644 --- a/services/mcp-bybit/tests/test_server_acl.py +++ b/services/mcp-bybit/tests/test_server_acl.py @@ -47,6 +47,7 @@ def mock_client(): c.set_leverage = AsyncMock(return_value={"status": "leverage_set"}) c.switch_position_mode = AsyncMock(return_value={"status": "mode_switched"}) c.transfer_asset = AsyncMock(return_value={"transfer_id": "tx"}) + c.place_combo_order = AsyncMock(return_value={"orders": [{"order_id": "ord-1"}, {"order_id": "ord-2"}]}) return c @@ -88,9 +89,28 @@ WRITE_ENDPOINTS = [ ("/tools/set_leverage", {"category": "linear", "symbol": "BTCUSDT", "leverage": 5}), ("/tools/switch_position_mode", {"category": "linear", "symbol": "BTCUSDT", "mode": "hedge"}), ("/tools/transfer_asset", {"coin": "USDT", "amount": 10.0, "from_type": "UNIFIED", "to_type": "FUND"}), + ("/tools/place_combo_order", { + "category": "option", + "legs": [ + {"symbol": "BTC-30APR26-75000-C-USDT", "side": "Buy", "qty": 0.01, "order_type": "Limit", "price": 5.0}, + {"symbol": "BTC-30APR26-80000-C-USDT", "side": "Sell", "qty": 0.01, "order_type": "Limit", "price": 3.0}, + ], + }), ] +def test_place_combo_order_min_legs(http): + r = http.post( + "/tools/place_combo_order", + json={ + "category": "option", + "legs": [{"symbol": "X", "side": "Buy", "qty": 1, "order_type": "Limit", "price": 1.0}], + }, + headers=CORE, + ) + assert r.status_code == 422 + + @pytest.mark.parametrize("path,payload", READ_ENDPOINTS) def test_read_core_ok(http, path, payload): r = http.post(path, json=payload, headers=CORE) diff --git a/services/mcp-deribit/src/mcp_deribit/client.py b/services/mcp-deribit/src/mcp_deribit/client.py index 7f2646e..8d812da 100644 --- a/services/mcp-deribit/src/mcp_deribit/client.py +++ b/services/mcp-deribit/src/mcp_deribit/client.py @@ -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( diff --git a/services/mcp-deribit/src/mcp_deribit/server.py b/services/mcp-deribit/src/mcp_deribit/server.py index 4aa0757..1381c06 100644 --- a/services/mcp-deribit/src/mcp_deribit/server.py +++ b/services/mcp-deribit/src/mcp_deribit/server.py @@ -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 IV−RV."}, {"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."}, diff --git a/services/mcp-deribit/tests/test_client.py b/services/mcp-deribit/tests/test_client.py index c833f28..e2a5bbc 100644 --- a/services/mcp-deribit/tests/test_client.py +++ b/services/mcp-deribit/tests/test_client.py @@ -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( diff --git a/services/mcp-deribit/tests/test_server_acl.py b/services/mcp-deribit/tests/test_server_acl.py index f1059f1..e0e2660 100644 --- a/services/mcp-deribit/tests/test_server_acl.py +++ b/services/mcp-deribit/tests/test_server_acl.py @@ -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(