diff --git a/CLAUDE.md b/CLAUDE.md index 2e9b911..ecf0f60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,13 @@ Nota: nel modulo `sentiment` "bybit" resta come **fonte dati pubblica** - **`/mcp` (comune)** — gli **unici** tool dati trasversali. `get_instruments` (schema uniforme: `exchange`, `fees`, `history_start`, `type`, `tick_size`, - `native`); `get_historical` e `get_indicators` con `exchange` opzionale → - singolo venue se valorizzato, **consenso** multi-exchange (mediana OHLC + - `div_pct`) se omesso. Supporta deribit + hyperliquid (IBKR non integrato). + `native`; filtri/paginazione Deribit + `meta` per-sorgente); `get_historical` + e `get_indicators` con `exchange` opzionale → singolo venue se valorizzato, + **consenso** multi-exchange (mediana OHLC + `div_pct`) se omesso. Supporta + deribit + hyperliquid (IBKR non integrato). - **`/mcp-{exchange}`** — router per-exchange per il resto (ticker, ordini, - options analytics, …). **NON** espongono più `get_historical`/`get_indicators`. + options analytics, …). **NON** espongono più + `get_instruments`/`get_historical`/`get_indicators`. Convenzione: `get_historical` ritorna la chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]` (validatore in diff --git a/README.md b/README.md index 3858328..4bd41d7 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,12 @@ DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol, find_by_delta, calculate_spread_payoff, get_dealer_gamma_profile, get_vanna_charm, get_oi_weighted_skew, get_smile_asymmetry, get_atm_vs_wings_vol, get_orderbook_imbalance, run_backtest, place_combo_order. -(Storico/indicatori → `/mcp`.) +(Strumenti/storico/indicatori → `/mcp`.) ### Hyperliquid Account summary, positions, orderbook, funding rate, basis spot/perp, -place_order, set_stop_loss, set_take_profit. (Storico/indicatori → `/mcp`.) +place_order, set_stop_loss, set_take_profit. +(Strumenti/storico/indicatori → `/mcp`.) ### IBKR (Interactive Brokers) Account, positions, activities, ticker, bars, snapshot, option chain, @@ -213,7 +214,9 @@ ancora integrato — usa `/mcp-ibkr`). sé `exchange`, `fees` (maker/taker, live da Deribit, `null` dove l'exchange non li espone), `history_start` (data inizio storico, live da Deribit), `type`, `tick_size` e un blocco `native` lossless. `exchange` opzionale → - filtra una sorgente; se omesso fan-out su tutti. + filtra una sorgente; se omesso fan-out su tutti. Filtri avanzati Deribit + (`kind`, `expiry_*`, `strike_*`, `min_open_interest`) e paginazione + (`offset`/`limit`, con `meta.deribit.has_more`). - **`get_historical`** — `{exchange?, instrument, interval, start_date, end_date}`. Con `exchange` ritorna il **singolo** exchange; **omettendolo** fa il **consenso** multi-exchange (mediana OHLC, `sources`, diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 7369908..010d0ba 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -51,15 +51,16 @@ richiede `X-Bot-Tag`. | Namespace | Tool | Tipo | Note | |---|---|---|---| | `/mcp/tools/*` | 3 | **interfaccia comune** | `get_instruments` + `get_historical` + `get_indicators` (single exchange o consensus) | -| `/mcp-deribit/tools/*` | 32 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff | -| `/mcp-hyperliquid/tools/*` | 14 | exchange (perp DEX) | L1 signing, leverage cap | +| `/mcp-deribit/tools/*` | 31 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff | +| `/mcp-hyperliquid/tools/*` | 15 | exchange (perp DEX) | L1 signing, leverage cap | | `/mcp-ibkr/tools/*` | 24 | exchange (multi-asset broker) | OAuth 1.0a, streaming WS, bracket/OCO/OTO | | `/mcp-macro/tools/*` | 11 | data provider (read-only) | yields, FRED, COT | | `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange | -`get_historical` e `get_indicators` vivono **solo** sull'interfaccia comune -`/mcp` (non più per-exchange). I restanti tool per-exchange (ticker, ordini, -analytics options, …) restano sui rispettivi router `/mcp-{exchange}`. +`get_instruments`, `get_historical` e `get_indicators` vivono **solo** +sull'interfaccia comune `/mcp` (non più per-exchange). I restanti tool +per-exchange (ticker, ordini, analytics options, …) restano sui rispettivi +router `/mcp-{exchange}`. --- @@ -76,10 +77,16 @@ Request body: | Campo | Tipo | Default | Note | |---|---|---|---| | `exchange` | `"deribit"\|"hyperliquid"` \| null | `null` | se omesso → fan-out su tutti gli exchange integrati | -| `currency` | str | `"BTC"` | solo Deribit | -| `kind` | str \| null | `"future"` | solo Deribit (`future`/`option`/`spot`) | +| `currency` | str | `"BTC"` | filtro Deribit | +| `kind` | str \| null | `"future"` | filtro Deribit (`future`/`option`/`spot`) | +| `expiry_from` / `expiry_to` | str \| null | `null` | filtro Deribit (data scadenza) | +| `strike_min` / `strike_max` | float \| null | `null` | filtro Deribit (opzioni) | +| `min_open_interest` | float \| null | `null` | filtro Deribit | +| `offset` / `limit` | int | `0` / `100` | paginazione Deribit | -Ogni elemento di `instruments[]` ha **schema uniforme**: +I filtri/paginazione si applicano a **Deribit**; gli exchange che elencano +tutto (es. Hyperliquid `get_markets`) li ignorano. Ogni elemento di +`instruments[]` ha **schema uniforme**: ```json { @@ -94,7 +101,9 @@ Ogni elemento di `instruments[]` ha **schema uniforme**: } ``` -Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}] }`. +Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}], +"meta": { "deribit": {total, offset, limit, has_more} } }`. Il blocco `meta` +riporta la paginazione per-sorgente quando disponibile (oggi: Deribit). > **Copertura `fees` / `history_start`** — popolati live dall'upstream dove > disponibili. Oggi: **Deribit** li espone (commissioni maker/taker + @@ -152,13 +161,12 @@ sources_used }` (+ `failed_sources` in modalità consenso). ### Market data - `get_ticker`, `get_ticker_batch` -- `get_instruments` - `get_orderbook`, `get_orderbook_imbalance` - `get_trade_history` -> Lo storico OHLCV e gli indicatori tecnici NON sono più qui: usa -> `/mcp/tools/get_historical` e `/mcp/tools/get_indicators` (sezione 5) con -> `exchange="deribit"`. +> Lista strumenti, storico OHLCV e indicatori tecnici NON sono più qui: usa +> `/mcp/tools/get_instruments`, `/mcp/tools/get_historical` e +> `/mcp/tools/get_indicators` (sezione 5) con `exchange="deribit"`. ### Account - `get_positions` @@ -333,15 +341,16 @@ uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`. ### Tool dati comuni (`/mcp`) | Tool | Request body | Risposta (campi principali) | |---|---|---| -| `get_instruments` | `{exchange?, currency:"BTC", kind:"future"}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` | +| `get_instruments` | `{exchange?, currency:"BTC", kind:"future", expiry_from?, expiry_to?, strike_min?, strike_max?, min_open_interest?, offset:0, limit:100}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` · `meta:{deribit:{total,offset,limit,has_more}}` | | `get_historical` | `{exchange?, instrument, interval, start_date, end_date, asset_class?}` | single: `{exchange, instrument, interval, candles, sources_used}` · consensus (no `exchange`): `+ div_pct`/`sources` per bar, `failed_sources` | | `get_indicators` | come `get_historical` + `indicators:["rsi","atr",…]` | `{exchange, instrument, interval, indicators:{…}, candles_used, sources_used}` | -`get_instruments` Deribit (sorgente grezza): `instruments[]` include `name`, +Sorgenti normalizzate dentro `get_instruments`: Deribit espone `name`, `expiry`, `option_type`, `tick_size`, `min_trade_amount`, `maker_commission`, -`taker_commission`, `creation_timestamp`; Hyperliquid `get_markets` (sorgente): -`{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}`. -Ogni risposta `/tools/*` riceve inoltre `data_timestamp` dal middleware. +`taker_commission`, `creation_timestamp` (→ `fees`/`history_start`); Hyperliquid +`get_markets` espone `{asset, mark_price, funding_rate, open_interest, +volume_24h, max_leverage}`. Ogni risposta `/tools/*` riceve inoltre +`data_timestamp` dal middleware. ### Convenzione simboli Deribit - **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL` diff --git a/src/cerbero_mcp/exchanges/cross/client.py b/src/cerbero_mcp/exchanges/cross/client.py index 32f077e..5b312c2 100644 --- a/src/cerbero_mcp/exchanges/cross/client.py +++ b/src/cerbero_mcp/exchanges/cross/client.py @@ -69,23 +69,29 @@ class UnifiedClient: # ── instruments ────────────────────────────────────────────────── async def _instruments_one( - self, exchange: str, currency: str, kind: str | None, - ) -> tuple[str, list[dict[str, Any]] | Exception]: + self, exchange: str, filters: dict[str, Any], + ) -> tuple[str, list[dict[str, Any]] | Exception, dict[str, Any] | None]: try: client = await self._registry.get(exchange, self._env) if exchange == "deribit": - resp = await client.get_instruments(currency=currency, kind=kind) - return exchange, normalize_deribit(resp.get("instruments", [])) + resp = await client.get_instruments(**filters) + meta = {k: resp[k] for k in ("total", "offset", "limit", "has_more") + if k in resp} + return exchange, normalize_deribit(resp.get("instruments", [])), meta if exchange == "hyperliquid": rows = await client.get_markets() - return exchange, normalize_hyperliquid(rows) + return exchange, normalize_hyperliquid(rows), None raise ValueError(f"no instrument normalizer for {exchange}") except Exception as e: # noqa: BLE001 - return exchange, e + return exchange, e, None async def get_instruments( self, *, exchange: str | None = None, currency: str = "BTC", kind: str | None = "future", + 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, + offset: int = 0, limit: int = 100, ) -> dict[str, Any]: if exchange is not None and exchange not in UNIFIED_EXCHANGES: raise HTTPException( @@ -94,18 +100,29 @@ class UnifiedClient: f"supported: {list(UNIFIED_EXCHANGES)}", ) targets = [exchange] if exchange else list(UNIFIED_EXCHANGES) + # Deribit-specific filters; venues that list everything ignore them. + deribit_filters = { + "currency": currency, "kind": kind, + "expiry_from": expiry_from, "expiry_to": expiry_to, + "strike_min": strike_min, "strike_max": strike_max, + "min_open_interest": min_open_interest, + "offset": offset, "limit": limit, + } results = await asyncio.gather( - *(self._instruments_one(ex, currency, kind) for ex in targets) + *(self._instruments_one(ex, deribit_filters) for ex in targets) ) instruments: list[dict[str, Any]] = [] failed: list[dict[str, str]] = [] - for ex, payload in results: + meta: dict[str, Any] = {} + for ex, payload, ex_meta in results: if isinstance(payload, Exception): failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"}) else: instruments.extend(payload) + if ex_meta: + meta[ex] = ex_meta if not instruments and failed: raise HTTPException( @@ -113,7 +130,7 @@ class UnifiedClient: detail={"error": "all sources failed", "failed_sources": failed}, ) - return {"instruments": instruments, "failed_sources": failed} + return {"instruments": instruments, "failed_sources": failed, "meta": meta} # ── historical ─────────────────────────────────────────────────── diff --git a/src/cerbero_mcp/exchanges/cross/tools.py b/src/cerbero_mcp/exchanges/cross/tools.py index d1317e0..93b945a 100644 --- a/src/cerbero_mcp/exchanges/cross/tools.py +++ b/src/cerbero_mcp/exchanges/cross/tools.py @@ -34,8 +34,16 @@ def _coerce_indicators(v): class GetInstrumentsReq(BaseModel): exchange: Exchange | None = None # None → fan-out over all integrated venues - currency: str = "BTC" # Deribit only - kind: str | None = "future" # Deribit only: future | option | spot + # Deribit-only filters (ignored by venues that list everything, e.g. Hyperliquid) + currency: str = "BTC" + kind: str | None = "future" # future | option | spot + 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 + offset: int = 0 + limit: int = 100 class GetHistoricalReq(BaseModel): @@ -69,6 +77,13 @@ async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> d exchange=params.exchange, 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, + offset=params.offset, + limit=params.limit, ) diff --git a/src/cerbero_mcp/exchanges/deribit/tools.py b/src/cerbero_mcp/exchanges/deribit/tools.py index 9452b19..6e04fdb 100644 --- a/src/cerbero_mcp/exchanges/deribit/tools.py +++ b/src/cerbero_mcp/exchanges/deribit/tools.py @@ -51,16 +51,6 @@ class GetTickerBatchReq(BaseModel): 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): @@ -268,20 +258,6 @@ async def get_ticker_batch(client: DeribitClient, params: GetTickerBatchReq) -> 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) diff --git a/src/cerbero_mcp/routers/deribit.py b/src/cerbero_mcp/routers/deribit.py index fe95cf2..5caf637 100644 --- a/src/cerbero_mcp/routers/deribit.py +++ b/src/cerbero_mcp/routers/deribit.py @@ -73,13 +73,6 @@ def make_router() -> APIRouter: ): 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, diff --git a/tests/integration/test_app_boot.py b/tests/integration/test_app_boot.py index fb39549..64757e1 100644 --- a/tests/integration/test_app_boot.py +++ b/tests/integration/test_app_boot.py @@ -30,7 +30,8 @@ def test_app_boots_and_health_responds(monkeypatch): assert "/mcp/tools/get_instruments" in paths assert "/mcp/tools/get_historical" in paths assert "/mcp/tools/get_indicators" in paths - # get_historical / get_indicators are common-only, not per-exchange + # get_instruments / get_historical / get_indicators are common-only + assert "/mcp-deribit/tools/get_instruments" not in paths assert "/mcp-deribit/tools/get_historical" not in paths assert "/mcp-deribit/tools/get_technical_indicators" not in paths assert "/mcp-hyperliquid/tools/get_historical" not in paths diff --git a/tests/smoke/run_unified.sh b/tests/smoke/run_unified.sh index 34d8945..6d2476a 100755 --- a/tests/smoke/run_unified.sh +++ b/tests/smoke/run_unified.sh @@ -38,14 +38,21 @@ post() { echo " OK ${path} → HTTP ${status}" } -echo "==> get_instruments exchange=deribit (fees + history_start live)" +echo "==> get_instruments exchange=deribit (fees + history_start live + meta)" post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"future"}' 200 \ - "assert d['instruments']; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; print(' ', i['symbol'], i['fees'], i['history_start'])" + "assert d['instruments']; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; assert 'has_more' in d['meta']['deribit']; print(' ', i['symbol'], i['fees'], i['history_start'], '| meta:', d['meta']['deribit'])" + +echo "==> get_instruments deribit option chain paginato (kind=option, limit=5)" +post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"option","limit":5,"offset":0}' 200 \ + "assert len(d['instruments'])<=5; assert d['meta']['deribit']['limit']==5; print(' got:', len(d['instruments']), '| total:', d['meta']['deribit'].get('total'), '| has_more:', d['meta']['deribit'].get('has_more'))" echo "==> get_instruments fan-out (deribit + hyperliquid)" post "/mcp/tools/get_instruments" '{}' 200 \ "exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs))" +echo "==> get_instruments NON più su /mcp-deribit → 404" +post "/mcp-deribit/tools/get_instruments" '{"currency":"BTC"}' 404 + echo "==> get_historical SINGLE deribit BTC-PERPETUAL 1h" post "/mcp/tools/get_historical" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \ "assert d['exchange']=='deribit'; assert d['candles']; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))" diff --git a/tests/unit/exchanges/cross/test_unified.py b/tests/unit/exchanges/cross/test_unified.py index 168645b..8abe4b9 100644 --- a/tests/unit/exchanges/cross/test_unified.py +++ b/tests/unit/exchanges/cross/test_unified.py @@ -68,11 +68,18 @@ class _FakeDeribit: self._raises = raises async def get_instruments(self, **kwargs: Any) -> dict[str, Any]: - return {"instruments": [{ - "name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5, - "maker_commission": 0.0, "taker_commission": 0.0005, - "creation_timestamp": 1534377600000, - }]} + self.inst_call = kwargs + return { + "instruments": [{ + "name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5, + "maker_commission": 0.0, "taker_commission": 0.0005, + "creation_timestamp": 1534377600000, + }], + "total": 1, + "offset": kwargs.get("offset", 0), + "limit": kwargs.get("limit", 100), + "has_more": False, + } async def get_historical(self, **kwargs: Any) -> dict[str, Any]: if self._raises: @@ -136,6 +143,27 @@ async def test_get_instruments_unsupported_exchange_raises_400(): assert exc.value.status_code == 400 +@pytest.mark.asyncio +async def test_get_instruments_deribit_filters_and_meta(): + fake = _FakeDeribit() + uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet") + out = await uc.get_instruments( + exchange="deribit", currency="ETH", kind="option", + strike_min=1000.0, expiry_to="2026-12-31", min_open_interest=5.0, + offset=5, limit=10, + ) + # advanced Deribit filters reach the client + assert fake.inst_call["currency"] == "ETH" + assert fake.inst_call["kind"] == "option" + assert fake.inst_call["strike_min"] == 1000.0 + assert fake.inst_call["expiry_to"] == "2026-12-31" + assert fake.inst_call["min_open_interest"] == 5.0 + assert fake.inst_call["offset"] == 5 and fake.inst_call["limit"] == 10 + # pagination metadata surfaced per source + assert out["meta"]["deribit"]["limit"] == 10 + assert out["meta"]["deribit"]["has_more"] is False + + @pytest.mark.asyncio async def test_get_instruments_partial_failure_reports_failed(): uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")