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