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:
@@ -8,6 +8,11 @@ split documentato in `docs/superpowers/specs/2026-04-27-split-mcp-core-design.md
|
|||||||
## Servizi
|
## Servizi
|
||||||
- `mcp-alpaca`, `mcp-bybit`, `mcp-deribit`, `mcp-hyperliquid` — exchange
|
- `mcp-alpaca`, `mcp-bybit`, `mcp-deribit`, `mcp-hyperliquid` — exchange
|
||||||
con `place_order`, `environment_info`, leverage cap server-side
|
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
|
- `mcp-macro`, `mcp-sentiment` — read-only market data
|
||||||
|
|
||||||
## Avvio locale
|
## Avvio locale
|
||||||
|
|||||||
@@ -412,6 +412,51 @@ class BybitClient:
|
|||||||
"status": "submitted",
|
"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(
|
async def amend_order(
|
||||||
self,
|
self,
|
||||||
category: str,
|
category: str,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from mcp_common.auth import Principal, TokenStore, require_principal
|
|||||||
from mcp_common.environment import EnvironmentInfo
|
from mcp_common.environment import EnvironmentInfo
|
||||||
from mcp_common.mcp_bridge import mount_mcp_endpoint
|
from mcp_common.mcp_bridge import mount_mcp_endpoint
|
||||||
from mcp_common.server import build_app
|
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.client import BybitClient
|
||||||
from mcp_bybit.leverage_cap import enforce_leverage as _enforce_leverage
|
from mcp_bybit.leverage_cap import enforce_leverage as _enforce_leverage
|
||||||
@@ -114,6 +114,21 @@ class PlaceOrderReq(BaseModel):
|
|||||||
position_idx: int | None = None
|
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):
|
class AmendOrderReq(BaseModel):
|
||||||
category: str
|
category: str
|
||||||
symbol: str
|
symbol: str
|
||||||
@@ -306,6 +321,14 @@ def create_app(
|
|||||||
body.order_type, body.price, body.tif, body.reduce_only, body.position_idx,
|
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"])
|
@app.post("/tools/amend_order", tags=["writes"])
|
||||||
async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)):
|
async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)):
|
||||||
_check(principal, core=True)
|
_check(principal, core=True)
|
||||||
@@ -381,6 +404,7 @@ def create_app(
|
|||||||
{"name": "get_open_orders", "description": "Ordini pending."},
|
{"name": "get_open_orders", "description": "Ordini pending."},
|
||||||
{"name": "get_basis_spot_perp", "description": "Basis spot vs linear perp."},
|
{"name": "get_basis_spot_perp", "description": "Basis spot vs linear perp."},
|
||||||
{"name": "place_order", "description": "Invia ordine (CORE only)."},
|
{"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": "amend_order", "description": "Modifica ordine esistente."},
|
||||||
{"name": "cancel_order", "description": "Cancella ordine."},
|
{"name": "cancel_order", "description": "Cancella ordine."},
|
||||||
{"name": "cancel_all_orders", "description": "Cancella tutti ordini."},
|
{"name": "cancel_all_orders", "description": "Cancella tutti ordini."},
|
||||||
|
|||||||
@@ -423,6 +423,64 @@ async def test_place_order_linear_no_link_id(client, mock_http):
|
|||||||
assert "orderLinkId" not in kwargs
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_order(client, mock_http):
|
async def test_cancel_order(client, mock_http):
|
||||||
mock_http.cancel_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}}
|
mock_http.cancel_order.return_value = {"retCode": 0, "result": {"orderId": "ord1"}}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ def mock_client():
|
|||||||
c.set_leverage = AsyncMock(return_value={"status": "leverage_set"})
|
c.set_leverage = AsyncMock(return_value={"status": "leverage_set"})
|
||||||
c.switch_position_mode = AsyncMock(return_value={"status": "mode_switched"})
|
c.switch_position_mode = AsyncMock(return_value={"status": "mode_switched"})
|
||||||
c.transfer_asset = AsyncMock(return_value={"transfer_id": "tx"})
|
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
|
return c
|
||||||
|
|
||||||
|
|
||||||
@@ -88,9 +89,28 @@ WRITE_ENDPOINTS = [
|
|||||||
("/tools/set_leverage", {"category": "linear", "symbol": "BTCUSDT", "leverage": 5}),
|
("/tools/set_leverage", {"category": "linear", "symbol": "BTCUSDT", "leverage": 5}),
|
||||||
("/tools/switch_position_mode", {"category": "linear", "symbol": "BTCUSDT", "mode": "hedge"}),
|
("/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/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)
|
@pytest.mark.parametrize("path,payload", READ_ENDPOINTS)
|
||||||
def test_read_core_ok(http, path, payload):
|
def test_read_core_ok(http, path, payload):
|
||||||
r = http.post(path, json=payload, headers=CORE)
|
r = http.post(path, json=payload, headers=CORE)
|
||||||
|
|||||||
@@ -1401,6 +1401,38 @@ class DeribitClient:
|
|||||||
return {"error": raw.get("error", "unknown"), "state": "error"}
|
return {"error": raw.get("error", "unknown"), "state": "error"}
|
||||||
return r
|
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:
|
async def set_leverage(self, instrument_name: str, leverage: int) -> dict:
|
||||||
"""CER-016: pre-set account leverage per evitare default 50x testnet."""
|
"""CER-016: pre-set account leverage per evitare default 50x testnet."""
|
||||||
raw = await self._request(
|
raw = await self._request(
|
||||||
|
|||||||
@@ -188,6 +188,28 @@ class PlaceOrderReq(BaseModel):
|
|||||||
leverage: int | None = None # CER-016: None → default cap (3x)
|
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):
|
class CancelOrderReq(BaseModel):
|
||||||
order_id: str
|
order_id: str
|
||||||
|
|
||||||
@@ -477,6 +499,27 @@ def create_app(
|
|||||||
label=body.label,
|
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"])
|
@app.post("/tools/cancel_order", tags=["writes"])
|
||||||
async def t_cancel_order(
|
async def t_cancel_order(
|
||||||
body: CancelOrderReq, principal: Principal = Depends(require_principal)
|
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_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": "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_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": "cancel_order", "description": "Cancella ordine."},
|
||||||
{"name": "set_stop_loss", "description": "Setta stop loss su posizione."},
|
{"name": "set_stop_loss", "description": "Setta stop loss su posizione."},
|
||||||
{"name": "set_take_profit", "description": "Setta take profit su posizione."},
|
{"name": "set_take_profit", "description": "Setta take profit su posizione."},
|
||||||
|
|||||||
@@ -221,6 +221,67 @@ async def test_get_dvol(httpx_mock: HTTPXMock, client: DeribitClient):
|
|||||||
assert result["candles"][0]["close"] == 57.0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_order(httpx_mock: HTTPXMock, client: DeribitClient):
|
async def test_cancel_order(httpx_mock: HTTPXMock, client: DeribitClient):
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def mock_client():
|
|||||||
c.get_historical = AsyncMock(return_value={"candles": []})
|
c.get_historical = AsyncMock(return_value={"candles": []})
|
||||||
c.get_technical_indicators = AsyncMock(return_value={"rsi": 55.0})
|
c.get_technical_indicators = AsyncMock(return_value={"rsi": 55.0})
|
||||||
c.place_order = AsyncMock(return_value={"order_id": "x"})
|
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.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_stop_loss = AsyncMock(return_value={"order_id": "x", "stop_price": 45000})
|
||||||
c.set_take_profit = AsyncMock(return_value={"order_id": "x", "tp_price": 55000})
|
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
|
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):
|
def test_place_order_leverage_cap_enforced(http):
|
||||||
"""Reject leverage > max_leverage (da secret, default 3)."""
|
"""Reject leverage > max_leverage (da secret, default 3)."""
|
||||||
r = http.post(
|
r = http.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user