"""Tool deribit V2: pydantic schemas + async functions. Ogni funzione prende (client: DeribitClient, params: ) e restituisce un dict (o un model Pydantic). Pure logica, no FastAPI dependency, no ACL. L'autenticazione bearer è gestita dal middleware in cerbero_mcp.auth; l'audit verrà cablato dal router via request.state.environment. """ from __future__ import annotations import contextlib from typing import Any from pydantic import BaseModel, field_validator, model_validator from cerbero_mcp.exchanges.deribit.client import DeribitClient from cerbero_mcp.exchanges.deribit.leverage_cap import ( enforce_leverage as _enforce_leverage, ) from cerbero_mcp.exchanges.deribit.leverage_cap import get_max_leverage # === Schemas === 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 OrderbookImbalanceReq(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 OptionFlowReq(BaseModel): """Body comune per indicatori option-flow (dealer gamma, vanna/charm, OI-weighted skew, smile asymmetry, ATM vs wings).""" currency: str expiry_from: str | None = None expiry_to: str | None = None top_n_strikes: int = 100 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) model_config = { "json_schema_extra": { "examples": [ { "summary": "Market buy 0.1 BTC perpetual", "value": { "instrument_name": "BTC-PERPETUAL", "type": "market", "amount": 0.1, "side": "buy", }, } ] } } class ComboLeg(BaseModel): instrument_name: str direction: str # "buy" | "sell" ratio: int = 1 class PlaceComboOrderReq(BaseModel): legs: list[ComboLeg] side: str # "buy" | "sell" amount: float type: str = "limit" price: float | None = None label: str | None = None leverage: int | None = None @model_validator(mode="after") def _at_least_two_legs(self): if len(self.legs) < 2: raise ValueError("combo requires at least 2 legs") return self 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 # === Tools (reads) === async def is_testnet(client: DeribitClient) -> dict: return client.is_testnet() async def environment_info( client: DeribitClient, *, creds: dict, env_info: Any | None = None ) -> dict: if env_info is None: return { "exchange": "deribit", "environment": "testnet" if client.is_testnet().get("testnet") else "mainnet", "source": "credentials", "env_value": None, "base_url": client.base_url, "max_leverage": get_max_leverage(creds), } return { "exchange": env_info.exchange, "environment": env_info.environment, "source": env_info.source, "env_value": env_info.env_value, "base_url": env_info.base_url, "max_leverage": get_max_leverage(creds), } async def get_ticker(client: DeribitClient, params: GetTickerReq) -> dict: assert params.instrument_name is not None # validator garantisce non-None return await client.get_ticker(params.instrument_name) async def get_ticker_batch(client: DeribitClient, params: GetTickerBatchReq) -> dict: assert params.instrument_names is not None # validator garantisce non-None return await client.get_ticker_batch(params.instrument_names) async def get_instruments(client: DeribitClient, params: GetInstrumentsReq) -> dict: return await client.get_instruments( currency=params.currency, kind=params.kind, expiry_from=params.expiry_from, expiry_to=params.expiry_to, strike_min=params.strike_min, strike_max=params.strike_max, min_open_interest=params.min_open_interest, limit=params.limit, offset=params.offset, ) async def get_orderbook(client: DeribitClient, params: GetOrderbookReq) -> dict: return await client.get_orderbook(params.instrument_name, params.depth) async def get_orderbook_imbalance( client: DeribitClient, params: OrderbookImbalanceReq ) -> dict: return await client.get_orderbook_imbalance(params.instrument_name, params.depth) async def get_positions(client: DeribitClient, params: GetPositionsReq) -> list: return await client.get_positions(params.currency) async def get_account_summary( client: DeribitClient, params: GetAccountSummaryReq ) -> dict: return await client.get_account_summary(params.currency) async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) -> list: return await client.get_trade_history(params.limit, params.instrument_name) async def get_historical(client: DeribitClient, params: GetHistoricalReq) -> dict: return await client.get_historical( params.instrument, params.start_date, params.end_date, params.resolution ) async def get_dvol(client: DeribitClient, params: GetDvolReq) -> dict: return await client.get_dvol( params.currency, params.start_date, params.end_date, params.resolution ) async def get_gex(client: DeribitClient, params: GetGexReq) -> dict: return await client.get_gex( params.currency, params.expiry_from, params.expiry_to, params.top_n_strikes ) async def get_dealer_gamma_profile( client: DeribitClient, params: OptionFlowReq ) -> dict: return await client.get_dealer_gamma_profile( params.currency, params.expiry_from, params.expiry_to, params.top_n_strikes ) async def get_vanna_charm(client: DeribitClient, params: OptionFlowReq) -> dict: return await client.get_vanna_charm( params.currency, params.expiry_from, params.expiry_to, params.top_n_strikes ) async def get_oi_weighted_skew(client: DeribitClient, params: OptionFlowReq) -> dict: return await client.get_oi_weighted_skew( params.currency, params.expiry_from, params.expiry_to, params.top_n_strikes ) async def get_smile_asymmetry(client: DeribitClient, params: OptionFlowReq) -> dict: return await client.get_smile_asymmetry( params.currency, params.expiry_from, params.expiry_to, params.top_n_strikes ) async def get_atm_vs_wings_vol(client: DeribitClient, params: OptionFlowReq) -> dict: return await client.get_atm_vs_wings_vol( params.currency, params.expiry_from, params.expiry_to, params.top_n_strikes ) async def get_pc_ratio(client: DeribitClient, params: GetPcRatioReq) -> dict: return await client.get_pc_ratio(params.currency) async def get_skew_25d(client: DeribitClient, params: GetSkew25dReq) -> dict: return await client.get_skew_25d(params.currency, params.expiry) async def get_term_structure( client: DeribitClient, params: GetTermStructureReq ) -> dict: return await client.get_term_structure(params.currency) async def run_backtest(client: DeribitClient, params: RunBacktestReq) -> dict: return await client.run_backtest( strategy_name=params.strategy_name, underlying=params.underlying, lookback_days=params.lookback_days, resolution=params.resolution, entry_rules=params.entry_rules, exit_rules=params.exit_rules, ) async def calculate_spread_payoff( client: DeribitClient, params: CalculateSpreadPayoffReq ) -> dict: return await client.calculate_spread_payoff(params.legs, params.quote_currency) async def find_by_delta(client: DeribitClient, params: FindByDeltaReq) -> dict: return await client.find_by_delta( currency=params.currency, expiry=params.expiry, target_delta=params.target_delta, option_type=params.option_type, max_results=params.max_results, min_open_interest=params.min_open_interest, min_volume_24h=params.min_volume_24h, ) async def get_iv_rank(client: DeribitClient, params: GetIvRankReq) -> dict: return await client.get_iv_rank(params.instrument) async def get_dvol_history(client: DeribitClient, params: GetDvolHistoryReq) -> dict: return await client.get_dvol_history(params.currency, params.lookback_days) async def get_realized_vol(client: DeribitClient, params: GetRealizedVolReq) -> dict: return await client.get_realized_vol(params.currency, params.windows) async def get_technical_indicators( client: DeribitClient, params: GetIndicatorsReq ) -> dict: return await client.get_technical_indicators( params.instrument, params.indicators, params.start_date, params.end_date, params.resolution, ) # === Tools (writes) === async def place_order( client: DeribitClient, params: PlaceOrderReq, *, creds: dict ) -> dict: cap_default = get_max_leverage(creds) lev = _enforce_leverage(params.leverage, creds=creds, exchange="deribit") if lev != cap_default: with contextlib.suppress(Exception): await client.set_leverage(params.instrument_name, lev) result = await client.place_order( instrument_name=params.instrument_name, side=params.side, amount=params.amount, type=params.type, price=params.price, reduce_only=params.reduce_only, post_only=params.post_only, label=params.label, ) # TODO V2: wire audit via request.state.environment in router return result async def place_combo_order( client: DeribitClient, params: PlaceComboOrderReq, *, creds: dict ) -> dict: cap_default = get_max_leverage(creds) lev = _enforce_leverage(params.leverage, creds=creds, exchange="deribit") if lev != cap_default: for leg in params.legs: with contextlib.suppress(Exception): await client.set_leverage(leg.instrument_name, lev) result = await client.place_combo_order( legs=[leg.model_dump() for leg in params.legs], side=params.side, amount=params.amount, type=params.type, price=params.price, label=params.label, ) # TODO V2: wire audit via request.state.environment in router return result async def cancel_order(client: DeribitClient, params: CancelOrderReq) -> dict: result = await client.cancel_order(params.order_id) # TODO V2: wire audit via request.state.environment in router return result async def set_stop_loss(client: DeribitClient, params: SetStopLossReq) -> dict: result = await client.set_stop_loss(params.order_id, params.stop_price) # TODO V2: wire audit via request.state.environment in router return result async def set_take_profit(client: DeribitClient, params: SetTakeProfitReq) -> dict: result = await client.set_take_profit(params.order_id, params.tp_price) # TODO V2: wire audit via request.state.environment in router return result async def close_position(client: DeribitClient, params: ClosePositionReq) -> dict: result = await client.close_position(params.instrument_name) # TODO V2: wire audit via request.state.environment in router return result