diff --git a/src/cerbero_mcp/routers/deribit.py b/src/cerbero_mcp/routers/deribit.py new file mode 100644 index 0000000..059e1c3 --- /dev/null +++ b/src/cerbero_mcp/routers/deribit.py @@ -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 diff --git a/tests/unit/exchanges/__init__.py b/tests/unit/exchanges/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/exchanges/deribit/__init__.py b/tests/unit/exchanges/deribit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/exchanges/deribit/test_client.py b/tests/unit/exchanges/deribit/test_client.py new file mode 100644 index 0000000..e46b20a --- /dev/null +++ b/tests/unit/exchanges/deribit/test_client.py @@ -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" diff --git a/tests/unit/exchanges/deribit/test_leverage_cap.py b/tests/unit/exchanges/deribit/test_leverage_cap.py new file mode 100644 index 0000000..7f15b23 --- /dev/null +++ b/tests/unit/exchanges/deribit/test_leverage_cap.py @@ -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