570 lines
20 KiB
Python
570 lines
20 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
from fastapi import Depends, FastAPI, HTTPException
|
||
from mcp_common.auth import Principal, TokenStore, require_principal
|
||
from mcp_common.mcp_bridge import mount_mcp_endpoint
|
||
from mcp_common.risk_guard import (
|
||
enforce_aggregate,
|
||
enforce_leverage,
|
||
enforce_single_notional,
|
||
)
|
||
from 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 IV−RV."},
|
||
{"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
|