feat(V2): router deribit + test migrati

Router /mcp-deribit/* monta 34 tool (28 read + 6 write) come endpoint
POST /mcp-deribit/tools/{tool_name}, con DI per env (request.state) e
client (ClientRegistry). Write tools costruiscono creds minimale
{max_leverage, client_id} da settings per leverage cap enforcement.

Test deribit migrati: test_client.py + test_leverage_cap.py riassegnati
sotto tests/unit/exchanges/deribit/ con import rewrite mcp_* -> cerbero_mcp.*.
Skip dei legacy V1-only test_environment_info / test_server_acl / test_env_validation
(ACL e resolve_environment eliminati in V2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdrianoDev
2026-04-30 18:26:34 +02:00
parent daa4e02971
commit d3ec2ee588
5 changed files with 638 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
"""Router /mcp-deribit/* — DI per env, client e (write) creds.
Mappa 1:1 i tool di `cerbero_mcp.exchanges.deribit.tools` a endpoint
`POST /mcp-deribit/tools/{tool_name}`. L'autenticazione bearer è gestita
dal middleware in `cerbero_mcp.auth`; qui leggiamo solo `request.state.environment`.
"""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, Request
from cerbero_mcp.client_registry import ClientRegistry
from cerbero_mcp.exchanges.deribit import tools as t
from cerbero_mcp.exchanges.deribit.client import DeribitClient
Environment = Literal["testnet", "mainnet"]
def get_environment(request: Request) -> Environment:
return request.state.environment
async def get_deribit_client(
request: Request, env: Environment = Depends(get_environment)
) -> DeribitClient:
registry: ClientRegistry = request.app.state.registry
return await registry.get("deribit", env)
def _build_creds(request: Request) -> dict:
"""Costruisce dict `creds` minimale per leverage cap / metadata.
Le credenziali vere sono già iniettate nel client da ClientRegistry;
qui passiamo solo il cap di leverage e il client_id (metadata audit).
"""
settings = request.app.state.settings
return {
"max_leverage": settings.deribit.max_leverage,
"client_id": settings.deribit.client_id,
}
def make_router() -> APIRouter:
r = APIRouter(prefix="/mcp-deribit", tags=["deribit"])
# === READ tools ===
@r.post("/tools/is_testnet")
async def _is_testnet(client: DeribitClient = Depends(get_deribit_client)):
return await t.is_testnet(client)
@r.post("/tools/environment_info")
async def _environment_info(
request: Request,
client: DeribitClient = Depends(get_deribit_client),
):
creds = _build_creds(request)
return await t.environment_info(client, creds=creds)
@r.post("/tools/get_ticker")
async def _get_ticker(
params: t.GetTickerReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_ticker(client, params)
@r.post("/tools/get_ticker_batch")
async def _get_ticker_batch(
params: t.GetTickerBatchReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_ticker_batch(client, params)
@r.post("/tools/get_instruments")
async def _get_instruments(
params: t.GetInstrumentsReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_instruments(client, params)
@r.post("/tools/get_orderbook")
async def _get_orderbook(
params: t.GetOrderbookReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_orderbook(client, params)
@r.post("/tools/get_orderbook_imbalance")
async def _get_orderbook_imbalance(
params: t.OrderbookImbalanceReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_orderbook_imbalance(client, params)
@r.post("/tools/get_positions")
async def _get_positions(
params: t.GetPositionsReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_positions(client, params)
@r.post("/tools/get_account_summary")
async def _get_account_summary(
params: t.GetAccountSummaryReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_account_summary(client, params)
@r.post("/tools/get_trade_history")
async def _get_trade_history(
params: t.GetTradeHistoryReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_trade_history(client, params)
@r.post("/tools/get_historical")
async def _get_historical(
params: t.GetHistoricalReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_historical(client, params)
@r.post("/tools/get_dvol")
async def _get_dvol(
params: t.GetDvolReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_dvol(client, params)
@r.post("/tools/get_gex")
async def _get_gex(
params: t.GetGexReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_gex(client, params)
@r.post("/tools/get_dealer_gamma_profile")
async def _get_dealer_gamma_profile(
params: t.OptionFlowReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_dealer_gamma_profile(client, params)
@r.post("/tools/get_vanna_charm")
async def _get_vanna_charm(
params: t.OptionFlowReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_vanna_charm(client, params)
@r.post("/tools/get_oi_weighted_skew")
async def _get_oi_weighted_skew(
params: t.OptionFlowReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_oi_weighted_skew(client, params)
@r.post("/tools/get_smile_asymmetry")
async def _get_smile_asymmetry(
params: t.OptionFlowReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_smile_asymmetry(client, params)
@r.post("/tools/get_atm_vs_wings_vol")
async def _get_atm_vs_wings_vol(
params: t.OptionFlowReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_atm_vs_wings_vol(client, params)
@r.post("/tools/get_pc_ratio")
async def _get_pc_ratio(
params: t.GetPcRatioReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_pc_ratio(client, params)
@r.post("/tools/get_skew_25d")
async def _get_skew_25d(
params: t.GetSkew25dReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_skew_25d(client, params)
@r.post("/tools/get_term_structure")
async def _get_term_structure(
params: t.GetTermStructureReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_term_structure(client, params)
@r.post("/tools/run_backtest")
async def _run_backtest(
params: t.RunBacktestReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.run_backtest(client, params)
@r.post("/tools/calculate_spread_payoff")
async def _calculate_spread_payoff(
params: t.CalculateSpreadPayoffReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.calculate_spread_payoff(client, params)
@r.post("/tools/find_by_delta")
async def _find_by_delta(
params: t.FindByDeltaReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.find_by_delta(client, params)
@r.post("/tools/get_iv_rank")
async def _get_iv_rank(
params: t.GetIvRankReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_iv_rank(client, params)
@r.post("/tools/get_dvol_history")
async def _get_dvol_history(
params: t.GetDvolHistoryReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_dvol_history(client, params)
@r.post("/tools/get_realized_vol")
async def _get_realized_vol(
params: t.GetRealizedVolReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_realized_vol(client, params)
@r.post("/tools/get_technical_indicators")
async def _get_technical_indicators(
params: t.GetIndicatorsReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_technical_indicators(client, params)
# === WRITE tools (richiedono creds per leverage cap / audit) ===
@r.post("/tools/place_order")
async def _place_order(
params: t.PlaceOrderReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client),
):
creds = _build_creds(request)
return await t.place_order(client, params, creds=creds)
@r.post("/tools/place_combo_order")
async def _place_combo_order(
params: t.PlaceComboOrderReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client),
):
creds = _build_creds(request)
return await t.place_combo_order(client, params, creds=creds)
@r.post("/tools/cancel_order")
async def _cancel_order(
params: t.CancelOrderReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.cancel_order(client, params)
@r.post("/tools/set_stop_loss")
async def _set_stop_loss(
params: t.SetStopLossReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.set_stop_loss(client, params)
@r.post("/tools/set_take_profit")
async def _set_take_profit(
params: t.SetTakeProfitReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.set_take_profit(client, params)
@r.post("/tools/close_position")
async def _close_position(
params: t.ClosePositionReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.close_position(client, params)
return r
View File
+297
View File
@@ -0,0 +1,297 @@
from __future__ import annotations
import re
import pytest
from cerbero_mcp.exchanges.deribit.client import DeribitClient
from pytest_httpx import HTTPXMock
@pytest.fixture
def client():
return DeribitClient(client_id="cid", client_secret="csec", testnet=True)
AUTH_RESP = {"result": {"access_token": "tok", "expires_in": 3600}}
@pytest.mark.asyncio
async def test_get_ticker(httpx_mock: HTTPXMock, client: DeribitClient):
# public endpoint — no auth needed
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/ticker"),
json={
"result": {
"mark_price": 50000,
"last_price": 49900,
"best_bid_price": 49950,
"best_ask_price": 50050,
"instrument_name": "BTC-PERPETUAL",
"stats": {"volume": 1234.5},
"open_interest": 9999,
"greeks": None,
"mark_iv": None,
}
},
)
result = await client.get_ticker("BTC-PERPETUAL")
assert result["mark_price"] == 50000
assert result["bid"] == 49950
assert result["ask"] == 50050
# CER-003: perpetual returns conceptual greeks, not None
assert result["greeks"] == {"delta": 1.0, "gamma": 0.0, "vega": 0.0, "theta": 0.0, "rho": 0.0}
# CER-007: testnet flag present
assert result["testnet"] is True
@pytest.mark.asyncio
async def test_get_ticker_option_preserves_greeks(httpx_mock: HTTPXMock, client: DeribitClient):
real_greeks = {"delta": 0.42, "gamma": 0.001, "vega": 0.05, "theta": -0.02, "rho": 0.003}
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/ticker"),
json={
"result": {
"mark_price": 2500,
"last_price": 2500,
"best_bid_price": 2490,
"best_ask_price": 2510,
"instrument_name": "BTC-30APR26-75000-C",
"stats": {"volume": 5.0},
"open_interest": 100,
"greeks": real_greeks,
"mark_iv": 62.5,
}
},
)
result = await client.get_ticker("BTC-30APR26-75000-C")
assert result["greeks"] == real_greeks
assert result["mark_iv"] == 62.5
def test_is_testnet(client: DeribitClient):
info = client.is_testnet()
assert info["testnet"] is True
assert "test.deribit.com" in info["base_url"]
@pytest.mark.asyncio
async def test_get_instruments_pagination_and_filter(httpx_mock: HTTPXMock, client: DeribitClient):
items = []
for i, exp_ms in enumerate([1700000000000, 1776000000000, 1800000000000]):
items.append({
"instrument_name": f"BTC-inst-{i}",
"strike": 50000 + i * 10000,
"expiration_timestamp": exp_ms,
"option_type": "call",
"tick_size": 0.5,
"min_trade_amount": 0.1,
# CER-008: public/get_instruments non include OI in produzione
})
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_instruments"),
json={"result": items},
)
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_book_summary_by_currency"),
json={"result": [
{"instrument_name": "BTC-inst-0", "open_interest": 100.0},
{"instrument_name": "BTC-inst-1", "open_interest": 200.0},
{"instrument_name": "BTC-inst-2", "open_interest": 300.0},
]},
)
result = await client.get_instruments(
"BTC", kind="option", strike_min=55000, limit=1, offset=0
)
assert result["total"] == 2
assert len(result["instruments"]) == 1
assert result["has_more"] is True
assert result["testnet"] is True
assert result["instruments"][0]["strike"] >= 55000
# CER-008: OI merge da book_summary
assert result["instruments"][0]["open_interest"] in (200.0, 300.0)
@pytest.mark.asyncio
async def test_get_instruments_min_oi_filter(httpx_mock: HTTPXMock, client: DeribitClient):
"""CER-008: min_open_interest filtra server-side usando book_summary."""
items = [
{"instrument_name": "BTC-low-OI", "strike": 60000, "expiration_timestamp": 1800000000000,
"option_type": "call", "tick_size": 0.5, "min_trade_amount": 0.1},
{"instrument_name": "BTC-high-OI", "strike": 60000, "expiration_timestamp": 1800000000000,
"option_type": "call", "tick_size": 0.5, "min_trade_amount": 0.1},
]
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_instruments"),
json={"result": items},
)
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_book_summary_by_currency"),
json={"result": [
{"instrument_name": "BTC-low-OI", "open_interest": 5.0},
{"instrument_name": "BTC-high-OI", "open_interest": 500.0},
]},
)
result = await client.get_instruments("BTC", kind="option", min_open_interest=100)
assert result["total"] == 1
assert result["instruments"][0]["name"] == "BTC-high-OI"
assert result["instruments"][0]["open_interest"] == 500.0
@pytest.mark.asyncio
async def test_get_account_summary(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/get_account_summary"),
json={"result": {"equity": 1000.0, "balance": 900.0, "currency": "USDC",
"margin_balance": 800.0, "available_funds": 700.0,
"unrealized_pnl": 50.0, "total_pnl": 100.0}},
)
result = await client.get_account_summary("USDC")
assert result["equity"] == 1000.0
assert result["balance"] == 900.0
@pytest.mark.asyncio
async def test_place_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/buy"),
json={"result": {"order": {"order_id": "abc", "amount": 10, "order_state": "open"}, "trades": []}},
)
result = await client.place_order(
instrument_name="BTC-PERPETUAL",
side="buy",
amount=10,
type="limit",
price=50000,
)
assert result["order"]["order_id"] == "abc"
@pytest.mark.asyncio
async def test_get_positions(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/get_positions"),
json={"result": [
{
"instrument_name": "BTC-PERPETUAL",
"size": 100.0,
"average_price": 48000.0,
"mark_price": 50000.0,
"floating_profit_loss": 200.0,
"realized_profit_loss": 50.0,
"leverage": 10,
}
]},
)
result = await client.get_positions("USDC")
assert len(result) == 1
assert result[0]["instrument"] == "BTC-PERPETUAL"
assert result[0]["direction"] == "long"
@pytest.mark.asyncio
async def test_get_dvol(httpx_mock: HTTPXMock, client: DeribitClient):
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_volatility_index_data"),
json={
"result": {
"data": [
[1700000000000, 55.0, 58.0, 54.0, 57.0],
[1700086400000, 57.0, 60.0, 56.0, 59.5],
],
"continuation": None,
}
},
)
result = await client.get_dvol("btc", "2024-01-01", "2024-01-02", "1D")
assert result["currency"] == "BTC"
assert result["latest"] == 59.5
assert len(result["candles"]) == 2
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(
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/cancel"),
json={"result": {"order_id": "abc123", "order_state": "cancelled"}},
)
result = await client.cancel_order("abc123")
assert result["order_id"] == "abc123"
assert result["state"] == "cancelled"
@@ -0,0 +1,50 @@
from __future__ import annotations
import pytest
from fastapi import HTTPException
from cerbero_mcp.exchanges.deribit.leverage_cap import enforce_leverage, get_max_leverage
def test_get_max_leverage_returns_creds_value():
creds = {"max_leverage": 5}
assert get_max_leverage(creds) == 5
def test_get_max_leverage_default_when_missing():
"""Default 1 (cash) se il secret non ha max_leverage."""
assert get_max_leverage({}) == 1
def test_enforce_leverage_pass_under_cap():
creds = {"max_leverage": 3}
enforce_leverage(2, creds=creds, exchange="deribit") # no raise
def test_enforce_leverage_pass_at_cap():
creds = {"max_leverage": 3}
enforce_leverage(3, creds=creds, exchange="deribit") # no raise
def test_enforce_leverage_reject_over_cap():
creds = {"max_leverage": 3}
with pytest.raises(HTTPException) as exc:
enforce_leverage(10, creds=creds, exchange="deribit")
assert exc.value.status_code == 403
assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED"
assert exc.value.detail["exchange"] == "deribit"
assert exc.value.detail["requested"] == 10
assert exc.value.detail["max"] == 3
def test_enforce_leverage_reject_when_below_one():
creds = {"max_leverage": 3}
with pytest.raises(HTTPException) as exc:
enforce_leverage(0, creds=creds, exchange="deribit")
assert exc.value.status_code == 403
def test_enforce_leverage_default_when_none():
"""Se requested è None, applica il cap come default."""
creds = {"max_leverage": 3}
result = enforce_leverage(None, creds=creds, exchange="deribit")
assert result == 3