feat: import 6 MCP services + common workspace

This commit is contained in:
AdrianoDev
2026-04-27 17:34:14 +02:00
parent 9676f22a8e
commit 6fc3d1d94f
67 changed files with 10693 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[project]
name = "mcp-deribit"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"option-mcp-common",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"httpx>=0.27",
"pydantic>=2.6",
]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_deribit"]
[tool.uv.sources]
option-mcp-common = { workspace = true }
[project.scripts]
mcp-deribit = "mcp_deribit.__main__:main"
@@ -0,0 +1,49 @@
from __future__ import annotations
import json
import os
import uvicorn
from option_mcp_common.auth import load_token_store_from_files
from option_mcp_common.env_validation import (
fail_fast_if_missing,
require_env,
summarize,
)
from option_mcp_common.logging import configure_root_logging
from mcp_deribit.client import DeribitClient
from mcp_deribit.server import create_app
configure_root_logging() # CER-P5-009: JSON default, env LOG_FORMAT=text per dev
def main():
# CER-P5-010: fail-fast boot su env mandatory
fail_fast_if_missing(["CREDENTIALS_FILE"])
summarize(["CREDENTIALS_FILE", "CORE_TOKEN_FILE", "OBSERVER_TOKEN_FILE", "PORT", "HOST"])
creds_file = require_env("CREDENTIALS_FILE", "deribit credentials JSON path")
with open(creds_file) as f:
creds = json.load(f)
client = DeribitClient(
client_id=creds["client_id"],
client_secret=creds["client_secret"],
testnet=bool(creds.get("testnet", True)),
)
token_store = load_token_store_from_files(
core_token_file=os.environ.get("CORE_TOKEN_FILE"),
observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"),
)
app = create_app(client=client, token_store=token_store)
uvicorn.run(
app,
log_config=None, # CER-P5-009: delega al root JSON logger
host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "9011")),
)
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
from __future__ import annotations
import os
from fastapi import Depends, FastAPI, HTTPException
from option_mcp_common.auth import Principal, TokenStore, require_principal
from option_mcp_common.mcp_bridge import mount_mcp_endpoint
from option_mcp_common.risk_guard import (
enforce_aggregate,
enforce_leverage,
enforce_single_notional,
)
from option_mcp_common.server import build_app
from pydantic import BaseModel, field_validator, model_validator
from mcp_deribit.client import DeribitClient
# --- Body models ---
class GetTickerReq(BaseModel):
instrument_name: str | None = None
instrument: str | None = None
model_config = {"extra": "allow"}
@model_validator(mode="after")
def _normalize(self):
sym = self.instrument_name or self.instrument
if not sym:
raise ValueError("instrument_name (or instrument) is required")
self.instrument_name = sym
return self
class GetTickerBatchReq(BaseModel):
instrument_names: list[str] | None = None
instruments: list[str] | None = None
model_config = {"extra": "allow"}
@model_validator(mode="after")
def _normalize(self):
names = self.instrument_names or self.instruments
if not names:
raise ValueError("instrument_names (or instruments) is required")
self.instrument_names = names
return self
class GetInstrumentsReq(BaseModel):
currency: str
kind: str | None = None
expiry_from: str | None = None
expiry_to: str | None = None
strike_min: float | None = None
strike_max: float | None = None
min_open_interest: float | None = None
limit: int = 100
offset: int = 0
class GetOrderbookReq(BaseModel):
instrument_name: str
depth: int = 10
class GetPositionsReq(BaseModel):
currency: str = "USDC"
class GetAccountSummaryReq(BaseModel):
currency: str = "USDC"
class GetTradeHistoryReq(BaseModel):
limit: int = 100
instrument_name: str | None = None
class GetHistoricalReq(BaseModel):
instrument: str
start_date: str
end_date: str
resolution: str = "1h"
class GetDvolReq(BaseModel):
currency: str = "BTC"
start_date: str
end_date: str
resolution: str = "1D"
class GetDvolHistoryReq(BaseModel):
currency: str = "BTC"
lookback_days: int = 90
class GetIvRankReq(BaseModel):
instrument: str
class GetRealizedVolReq(BaseModel):
currency: str = "BTC"
windows: list[int] = [14, 30]
class GetGexReq(BaseModel):
currency: str
expiry_from: str | None = None
expiry_to: str | None = None
top_n_strikes: int = 50
class GetPcRatioReq(BaseModel):
currency: str
class GetSkew25dReq(BaseModel):
currency: str
expiry: str
class GetTermStructureReq(BaseModel):
currency: str
class CalculateSpreadPayoffReq(BaseModel):
legs: list[dict]
quote_currency: str = "USD"
class RunBacktestReq(BaseModel):
strategy_name: str
underlying: str = "BTC"
lookback_days: int = 30
resolution: str = "4h"
entry_rules: dict | None = None
exit_rules: dict | None = None
class FindByDeltaReq(BaseModel):
currency: str
expiry: str
target_delta: float
option_type: str
max_results: int = 3
min_open_interest: float = 100.0
min_volume_24h: float = 20.0
class GetIndicatorsReq(BaseModel):
instrument: str
indicators: list[str]
start_date: str
end_date: str
resolution: str = "1h"
@field_validator("indicators", mode="before")
@classmethod
def _coerce_indicators(cls, v):
if isinstance(v, str):
import json
s = v.strip()
if s.startswith("["):
try:
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()]
except json.JSONDecodeError:
pass
return [x.strip() for x in s.split(",") if x.strip()]
if isinstance(v, list):
return v
raise ValueError(
"indicators must be a list like ['rsi','atr','macd'] "
"or a comma-separated string like 'rsi,atr,macd'"
)
class PlaceOrderReq(BaseModel):
instrument_name: str
side: str # "buy" | "sell"
amount: float
type: str = "limit"
price: float | None = None
reduce_only: bool = False
post_only: bool = False
label: str | None = None
leverage: int | None = None # CER-016: None → default cap (3x)
class CancelOrderReq(BaseModel):
order_id: str
class SetStopLossReq(BaseModel):
order_id: str
stop_price: float
class SetTakeProfitReq(BaseModel):
order_id: str
tp_price: float
class ClosePositionReq(BaseModel):
instrument_name: str
# --- CER-016 notional helpers ---
async def _compute_notional_deribit(client: DeribitClient, body: PlaceOrderReq) -> float:
"""Stima notional in USD per un ordine Deribit.
- Perp USDC: contract size = 1 USD → amount è già notional USD.
- Options: amount è in base asset (BTC/ETH) → moltiplica per index price.
- Altri perp BTC/ETH: amount in USD notional.
"""
name = body.instrument_name.upper()
if name.endswith("-PERPETUAL"):
return float(body.amount)
ref_price: float | None = body.price
if ref_price is None:
try:
tk = await client.get_ticker(body.instrument_name)
ref_price = tk.get("mark_price") or tk.get("last_price")
except Exception:
ref_price = None
if not ref_price:
return float(body.amount)
return float(body.amount) * float(ref_price)
async def _current_aggregate_deribit(client: DeribitClient) -> float:
"""Somma notional posizioni aperte su Deribit (USDC)."""
try:
positions = await client.get_positions("USDC")
except Exception:
return 0.0
total = 0.0
for p in positions or []:
size = abs(float(p.get("size") or 0))
name = str(p.get("instrument") or "").upper()
if name.endswith("-PERPETUAL"):
total += size
else:
mark = float(p.get("mark_price") or 0)
total += size * mark
return total
# --- ACL helper ---
def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None:
allowed: set[str] = set()
if core:
allowed.add("core")
if observer:
allowed.add("observer")
if not (principal.capabilities & allowed):
raise HTTPException(403, f"capability required: {allowed}")
# --- App factory ---
def create_app(*, client: DeribitClient, token_store: TokenStore) -> FastAPI:
from contextlib import asynccontextmanager
# CER-016: pre-set leverage 3x su perp principali al boot (best-effort).
@asynccontextmanager
async def _lifespan(_app: FastAPI):
cap = enforce_leverage(None)
for inst in ("BTC-PERPETUAL", "ETH-PERPETUAL"):
try:
await client.set_leverage(inst, cap)
except Exception:
pass
yield
app = build_app(
name="mcp-deribit",
version="0.1.0",
token_store=token_store,
lifespan=_lifespan,
)
# --- Read tools: core + observer ---
@app.post("/tools/is_testnet", tags=["reads"])
async def t_is_testnet(principal: Principal = Depends(require_principal)):
_check(principal, core=True, observer=True)
return client.is_testnet()
@app.post("/tools/get_ticker", tags=["reads"])
async def t_get_ticker(
body: GetTickerReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_ticker(body.instrument_name)
@app.post("/tools/get_ticker_batch", tags=["reads"])
async def t_get_ticker_batch(
body: GetTickerBatchReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_ticker_batch(body.instrument_names)
@app.post("/tools/get_instruments", tags=["reads"])
async def t_get_instruments(
body: GetInstrumentsReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_instruments(
currency=body.currency,
kind=body.kind,
expiry_from=body.expiry_from,
expiry_to=body.expiry_to,
strike_min=body.strike_min,
strike_max=body.strike_max,
min_open_interest=body.min_open_interest,
limit=body.limit,
offset=body.offset,
)
@app.post("/tools/get_orderbook", tags=["reads"])
async def t_get_orderbook(
body: GetOrderbookReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_orderbook(body.instrument_name, body.depth)
@app.post("/tools/get_positions", tags=["reads"])
async def t_get_positions(
body: GetPositionsReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_positions(body.currency)
@app.post("/tools/get_account_summary", tags=["reads"])
async def t_get_account_summary(
body: GetAccountSummaryReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_account_summary(body.currency)
@app.post("/tools/get_trade_history", tags=["reads"])
async def t_get_trade_history(
body: GetTradeHistoryReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_trade_history(body.limit, body.instrument_name)
@app.post("/tools/get_historical", tags=["reads"])
async def t_get_historical(
body: GetHistoricalReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_historical(
body.instrument, body.start_date, body.end_date, body.resolution
)
@app.post("/tools/get_dvol", tags=["reads"])
async def t_get_dvol(
body: GetDvolReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_dvol(
body.currency, body.start_date, body.end_date, body.resolution
)
@app.post("/tools/get_gex", tags=["reads"])
async def t_get_gex(
body: GetGexReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_gex(
body.currency, body.expiry_from, body.expiry_to, body.top_n_strikes
)
@app.post("/tools/get_pc_ratio", tags=["reads"])
async def t_get_pc_ratio(
body: GetPcRatioReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_pc_ratio(body.currency)
@app.post("/tools/get_skew_25d", tags=["reads"])
async def t_get_skew_25d(
body: GetSkew25dReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_skew_25d(body.currency, body.expiry)
@app.post("/tools/get_term_structure", tags=["reads"])
async def t_get_term_structure(
body: GetTermStructureReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_term_structure(body.currency)
@app.post("/tools/run_backtest", tags=["writes"])
async def t_run_backtest(
body: RunBacktestReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.run_backtest(
strategy_name=body.strategy_name,
underlying=body.underlying,
lookback_days=body.lookback_days,
resolution=body.resolution,
entry_rules=body.entry_rules,
exit_rules=body.exit_rules,
)
@app.post("/tools/calculate_spread_payoff", tags=["writes"])
async def t_calculate_spread_payoff(
body: CalculateSpreadPayoffReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.calculate_spread_payoff(body.legs, body.quote_currency)
@app.post("/tools/find_by_delta", tags=["writes"])
async def t_find_by_delta(
body: FindByDeltaReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.find_by_delta(
currency=body.currency,
expiry=body.expiry,
target_delta=body.target_delta,
option_type=body.option_type,
max_results=body.max_results,
min_open_interest=body.min_open_interest,
min_volume_24h=body.min_volume_24h,
)
@app.post("/tools/get_iv_rank", tags=["reads"])
async def t_get_iv_rank(
body: GetIvRankReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_iv_rank(body.instrument)
@app.post("/tools/get_dvol_history", tags=["reads"])
async def t_get_dvol_history(
body: GetDvolHistoryReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_dvol_history(body.currency, body.lookback_days)
@app.post("/tools/get_realized_vol", tags=["reads"])
async def t_get_realized_vol(
body: GetRealizedVolReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_realized_vol(body.currency, body.windows)
@app.post("/tools/get_technical_indicators", tags=["reads"])
async def t_get_indicators(
body: GetIndicatorsReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_technical_indicators(
body.instrument,
body.indicators,
body.start_date,
body.end_date,
body.resolution,
)
# --- Write tools: core only ---
@app.post("/tools/place_order", tags=["writes"])
async def t_place_order(
body: PlaceOrderReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
lev = enforce_leverage(body.leverage)
if not body.reduce_only:
notional = await _compute_notional_deribit(client, body)
enforce_single_notional(
notional, exchange="deribit", instrument=body.instrument_name
)
agg = await _current_aggregate_deribit(client)
enforce_aggregate(agg, notional)
if lev != enforce_leverage(None):
try:
await client.set_leverage(body.instrument_name, lev)
except Exception:
pass
return await client.place_order(
instrument_name=body.instrument_name,
side=body.side,
amount=body.amount,
type=body.type,
price=body.price,
reduce_only=body.reduce_only,
post_only=body.post_only,
label=body.label,
)
@app.post("/tools/cancel_order", tags=["writes"])
async def t_cancel_order(
body: CancelOrderReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.cancel_order(body.order_id)
@app.post("/tools/set_stop_loss", tags=["writes"])
async def t_set_sl(
body: SetStopLossReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.set_stop_loss(body.order_id, body.stop_price)
@app.post("/tools/set_take_profit", tags=["writes"])
async def t_set_tp(
body: SetTakeProfitReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.set_take_profit(body.order_id, body.tp_price)
@app.post("/tools/close_position", tags=["writes"])
async def t_close_position(
body: ClosePositionReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.close_position(body.instrument_name)
# ───── MCP endpoint (/mcp) — bridge verso /tools/* ─────
port = int(os.environ.get("PORT", "9011"))
mount_mcp_endpoint(
app,
name="cerbero-deribit",
version="0.1.0",
token_store=token_store,
internal_base_url=f"http://localhost:{port}",
tools=[
{"name": "is_testnet", "description": "True se client Deribit è in modalità testnet."},
{"name": "get_ticker", "description": "Ticker di un instrument Deribit."},
{"name": "get_ticker_batch", "description": "Ticker per N instruments in parallelo (max 20)."},
{"name": "get_instruments", "description": "Lista instruments per currency."},
{"name": "get_orderbook", "description": "Orderbook L1/L2 per instrument."},
{"name": "get_positions", "description": "Posizioni aperte."},
{"name": "get_account_summary", "description": "Summary account (equity, balance)."},
{"name": "get_trade_history", "description": "Storia trade recenti."},
{"name": "get_historical", "description": "OHLCV storico."},
{"name": "get_dvol", "description": "Deribit Volatility Index (DVOL) OHLC per currency (BTC/ETH)."},
{"name": "get_dvol_history", "description": "DVOL time series + percentili su lookback_days."},
{"name": "get_iv_rank", "description": "IV rank 30/90/365d di un instrument vs DVOL storico della currency."},
{"name": "find_by_delta", "description": "Trova strike con delta più vicino a target, filtrato per liquidità (OI/vol)."},
{"name": "calculate_spread_payoff", "description": "Payoff/greci/max P-L/break-even/fee per struttura multi-leg."},
{"name": "run_backtest", "description": "Heuristic backtest RSI-based su storia OHLCV per threshold accept/marginal/reject."},
{"name": "get_term_structure", "description": "IV ATM per ogni expiry disponibile, detect contango/backwardation."},
{"name": "get_skew_25d", "description": "Skew 25-delta put/call IV + risk reversal + butterfly per expiry."},
{"name": "get_pc_ratio", "description": "Put/Call ratio aggregato su OI e volume 24h."},
{"name": "get_gex", "description": "Gamma exposure per strike + zero gamma level (top N strikes per OI)."},
{"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": "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."},
{"name": "close_position", "description": "Chiude posizione aperta."},
],
)
return app
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
import re
import pytest
from mcp_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_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,188 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
from mcp_deribit.server import create_app
from option_mcp_common.auth import Principal, TokenStore
@pytest.fixture
def mock_client():
c = MagicMock()
c.get_ticker = AsyncMock(return_value={"mark_price": 50000})
c.get_instruments = AsyncMock(return_value=[])
c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []})
c.get_positions = AsyncMock(return_value=[])
c.get_account_summary = AsyncMock(return_value={"equity": 1000})
c.get_trade_history = AsyncMock(return_value=[])
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.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})
c.close_position = AsyncMock(return_value={"closed": True})
c.set_leverage = AsyncMock(return_value={"state": "ok"})
return c
@pytest.fixture
def http(mock_client):
store = TokenStore(tokens={
"ct": Principal("core", {"core"}),
"ot": Principal("observer", {"observer"}),
})
app = create_app(client=mock_client, token_store=store)
return TestClient(app)
def test_health(http):
assert http.get("/health").status_code == 200
def test_get_ticker_core_ok(http):
r = http.post(
"/tools/get_ticker",
headers={"Authorization": "Bearer ct"},
json={"instrument_name": "BTC-PERPETUAL"},
)
assert r.status_code == 200
assert r.json()["mark_price"] == 50000
def test_get_ticker_observer_ok(http):
r = http.post(
"/tools/get_ticker",
headers={"Authorization": "Bearer ot"},
json={"instrument_name": "BTC-PERPETUAL"},
)
assert r.status_code == 200
def test_get_ticker_no_auth_401(http):
r = http.post("/tools/get_ticker", json={"instrument_name": "BTC-PERPETUAL"})
assert r.status_code == 401
def test_get_ticker_alias_instrument_ok(http, mock_client):
r = http.post(
"/tools/get_ticker",
headers={"Authorization": "Bearer ct"},
json={"instrument": "ETH"},
)
assert r.status_code == 200
mock_client.get_ticker.assert_awaited_with("ETH")
def test_place_order_core_ok(http):
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ct"},
json={"instrument_name": "BTC-PERPETUAL", "side": "buy", "amount": 10},
)
assert r.status_code == 200
def test_place_order_observer_forbidden(http):
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ot"},
json={"instrument_name": "BTC-PERPETUAL", "side": "buy", "amount": 10},
)
assert r.status_code == 403
def test_place_order_notional_cap_enforced(http):
"""CER-016: reject se notional > CERBERO_MAX_NOTIONAL (default 200)."""
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ct"},
json={
"instrument_name": "ETH-PERPETUAL",
"side": "buy",
"amount": 335, # USD — cap 200
},
)
assert r.status_code == 403
body = r.json()
assert body["error"]["code"] == "HARD_PROHIBITION"
def test_place_order_leverage_cap_enforced(http):
"""CER-016: reject leverage > 3x."""
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ct"},
json={
"instrument_name": "BTC-PERPETUAL",
"side": "buy",
"amount": 50,
"leverage": 50,
},
)
assert r.status_code == 403
body = r.json()
assert body["error"]["code"] == "HARD_PROHIBITION"
def test_place_order_reduce_only_skips_cap(http):
"""CER-016: reduce_only orders bypassano cap notional (è close)."""
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ct"},
json={
"instrument_name": "ETH-PERPETUAL",
"side": "sell",
"amount": 10000,
"reduce_only": True,
},
)
assert r.status_code == 200
def test_close_position_core_ok(http):
r = http.post(
"/tools/close_position",
headers={"Authorization": "Bearer ct"},
json={"instrument_name": "BTC-PERPETUAL"},
)
assert r.status_code == 200
def test_close_position_observer_forbidden(http):
r = http.post(
"/tools/close_position",
headers={"Authorization": "Bearer ot"},
json={"instrument_name": "BTC-PERPETUAL"},
)
assert r.status_code == 403
def test_cancel_order_observer_forbidden(http):
r = http.post(
"/tools/cancel_order",
headers={"Authorization": "Bearer ot"},
json={"order_id": "abc123"},
)
assert r.status_code == 403
def test_set_stop_loss_observer_forbidden(http):
r = http.post(
"/tools/set_stop_loss",
headers={"Authorization": "Bearer ot"},
json={"order_id": "abc123", "stop_price": 45000.0},
)
assert r.status_code == 403
def test_get_account_summary_observer_ok(http):
r = http.post(
"/tools/get_account_summary",
headers={"Authorization": "Bearer ot"},
json={"currency": "USDC"},
)
assert r.status_code == 200
assert r.json()["equity"] == 1000