diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2a5849a..2070c71 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -4,11 +4,16 @@ Documento di riferimento completo per tutti gli endpoint HTTP e i tool MCP esposti dal container `cerbero-mcp`. Generato dall'analisi diretta dei router FastAPI in `src/cerbero_mcp/routers/`. +> **Nota V2** — Bybit e Alpaca sono stati ritirati dalla superficie API +> (codice archiviato in `old/`). Gli exchange di trading attivi sono +> **Deribit**, **Hyperliquid** e **IBKR**; **Macro** e **Sentiment** restano +> data-provider read-only. + --- ## 1. Autenticazione -Tutte le chiamate ai namespace `/mcp-*` richiedono: +Tutte le chiamate ai namespace `/mcp*` richiedono: | Header | Valore | Note | |---|---|---| @@ -45,20 +50,78 @@ richiede `X-Bot-Tag`. | Namespace | Tool | Tipo | Note | |---|---|---|---| +| `/mcp/tools/*` | 2 | **interfaccia comune** | schema strumenti uniforme + storico per-exchange | | `/mcp-deribit/tools/*` | 33 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff | -| `/mcp-bybit/tools/*` | 30 | exchange (spot + perp + options) | basis, funding, batch orders | | `/mcp-hyperliquid/tools/*` | 16 | exchange (perp DEX) | L1 signing, leverage cap | -| `/mcp-alpaca/tools/*` | 18 | exchange (US stocks + options) | clock/calendar, fractional | | `/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 | | `/mcp-cross/tools/*` | 1 | aggregator | storico consensus multi-exchange | -**Totale**: ~142 tool MCP. +I router per-exchange (`/mcp-deribit`, …) restano disponibili durante la +transizione; per nuovi consumatori è raccomandata l'interfaccia comune `/mcp`. --- -## 5. `/mcp-deribit/tools/*` +## 5. `/mcp/tools/*` — interfaccia comune (raccomandata) + +Interfaccia unica trasversale agli exchange integrati: il **campo `exchange` +è dentro ogni risultato**, non nel path. Exchange supportati: `deribit`, +`hyperliquid`. + +### `get_instruments` — schema strumenti uniforme + +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`) | + +Ogni elemento di `instruments[]` ha **schema uniforme**: + +```json +{ + "exchange": "deribit", + "symbol": "BTC-PERPETUAL", + "asset_class": "crypto", + "type": "perpetual", // perpetual | future | option | spot + "fees": {"maker": 0.0, "taker": 0.0005}, // null se l'exchange non lo espone + "history_start": "2018-08-13", // data inizio storico, null se ignota + "tick_size": 0.5, + "native": { /* campi specifici dell'exchange, lossless */ } +} +``` + +Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}] }`. + +> **Copertura `fees` / `history_start`** — popolati live dall'upstream dove +> disponibili. Oggi: **Deribit** li espone (commissioni maker/taker + +> `creation_timestamp` → `history_start`); **Hyperliquid** ha fee a livello +> account e nessuna data di listing per-strumento → entrambi `null`. + +### `get_historical` — storico di un singolo exchange + +Request body: + +| Campo | Tipo | Default | Note | +|---|---|---|---| +| `exchange` | `"deribit"\|"hyperliquid"` | — | **obbligatorio**: sceglie la sorgente | +| `instrument` | str | — | simbolo nativo sull'exchange scelto | +| `interval` | str | `"1h"` | `1m`/`5m`/`15m`/`1h`/`4h`/`1d` | +| `start_date` | str | — | `YYYY-MM-DD` o ISO datetime (UTC) | +| `end_date` | str | — | idem | + +Risposta: `{ "exchange", "instrument", "interval", "candles": [...] }` con la +chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`. + +Per il **consenso** multi-exchange (mediana cross-venue) usare invece +`/mcp-cross/tools/get_historical` (sezione 11). + +--- + +## 6. `/mcp-deribit/tools/*` ### Info - `is_testnet` — flag ambiente corrente @@ -108,38 +171,6 @@ richiede `X-Bot-Tag`. --- -## 6. `/mcp-bybit/tools/*` - -### Info -- `environment_info` - -### Market data -- `get_ticker`, `get_ticker_batch` -- `get_orderbook`, `get_orderbook_imbalance` -- `get_historical` -- `get_instruments`, `get_option_chain` -- `get_trade_history` - -### Derivati -- `get_funding_rate`, `get_funding_history` -- `get_open_interest` -- `get_basis_spot_perp`, `get_basis_term_structure` - -### Account -- `get_positions`, `get_account_summary`, `get_open_orders` - -### Technicals -- `get_indicators` - -### Write -- `place_order`, `place_combo_order` -- `amend_order`, `cancel_order`, `cancel_all_orders` -- `set_stop_loss`, `set_take_profit`, `close_position` -- `set_leverage`, `switch_position_mode` -- `transfer_asset` - ---- - ## 7. `/mcp-hyperliquid/tools/*` ### Info @@ -163,35 +194,16 @@ richiede `X-Bot-Tag`. --- -## 8. `/mcp-alpaca/tools/*` - -### Info / calendar -- `environment_info`, `get_clock`, `get_calendar` - -### Market data -- `get_assets`, `get_ticker` -- `get_bars`, `get_snapshot` -- `get_option_chain` - -### Account -- `get_account`, `get_positions`, `get_activities`, `get_open_orders` - -### Write -- `place_order`, `amend_order` -- `cancel_order`, `cancel_all_orders` -- `close_position`, `close_all_positions` - -> **Nota**: override URL applicato solo all'endpoint trading. Gli -> endpoint dati (`data.alpaca.markets`) restano i default SDK. - ---- - -## 9. `/mcp-ibkr/tools/*` +## 8. `/mcp-ibkr/tools/*` Auth via OAuth 1.0a Self-Service con minting di session token unattended. Setup one-time per ambiente (paper + live) — vedi sezione "IBKR Setup" del README. +> IBKR non è ancora integrato nell'interfaccia comune `/mcp` (il suo endpoint +> bars usa un `period` relativo invece di `(start, end)`); usare il router +> dedicato `/mcp-ibkr`. + ### Info / clock - `environment_info`, `get_clock` @@ -219,7 +231,7 @@ unattended. Setup one-time per ambiente (paper + live) — vedi sezione --- -## 10. `/mcp-macro/tools/*` (read-only) +## 9. `/mcp-macro/tools/*` (read-only) | Tool | Sorgente | |---|---| @@ -237,7 +249,7 @@ unattended. Setup one-time per ambiente (paper + live) — vedi sezione --- -## 11. `/mcp-sentiment/tools/*` (read-only) +## 10. `/mcp-sentiment/tools/*` (read-only) | Tool | Fonte | |---|---| @@ -251,9 +263,13 @@ unattended. Setup one-time per ambiente (paper + live) — vedi sezione | `get_liquidation_heatmap` | heatmap liquidazioni | | `get_cointegration_pairs` | screening coppie cointegrate | +> Le fonti di funding/OI includono endpoint pubblici multi-venue (Binance, +> Bybit, OKX, Hyperliquid): qui Bybit è una **sorgente dati pubblica**, non il +> client di trading ritirato. + --- -## 12. `/mcp-cross/tools/*` +## 11. `/mcp-cross/tools/*` ### `get_historical` Aggregatore cross-exchange. Fan-out a tutti gli exchange che supportano @@ -264,10 +280,8 @@ Aggregatore cross-exchange. Fan-out a tutti gli exchange che supportano - `sources` = numero di exchange che hanno contribuito al bar - `div_pct = (max - min) / median` → quality gate per i bot -**Crypto** (`asset_class: "crypto"`): BTC, ETH, SOL via Bybit + -Hyperliquid + Deribit. -**Stocks** (`asset_class: "stocks"`): AAPL, SPY, QQQ, TSLA, NVDA via -Alpaca. +**Crypto** (`asset_class: "crypto"`): BTC, ETH via Hyperliquid + Deribit; +SOL via Hyperliquid. In caso di fallimento parziale ritorna i bar disponibili più `failed_sources: [...]`. Se *tutti* gli upstream falliscono → `502 @@ -275,7 +289,7 @@ Bad Gateway` retryable. --- -## 13. Observability +## 12. Observability ### Request log (`mcp.request`) Una riga JSON per richiesta con: `request_id`, `method`, `path`, @@ -290,17 +304,55 @@ Rotazione configurabile via `AUDIT_LOG_BACKUP_DAYS`. Lo stesso `request_id` appare in `mcp.request`, `mcp.audit` e nell'envelope di errore restituito al client. +### `data_timestamp` +Ogni risposta JSON sotto `/tools/*` riceve dal middleware un campo +`data_timestamp` (ISO UTC) al top level — oltre all'header `X-Data-Timestamp`. + --- -## 14. Esempio chiamata +## 13. Esempio chiamata ```bash -curl -X POST http://localhost:9000/mcp-bybit/tools/get_ticker \ +# Interfaccia comune: storico Deribit +curl -X POST http://localhost:9000/mcp/tools/get_historical \ -H "Authorization: Bearer $MAINNET_TOKEN" \ -H "X-Bot-Tag: scanner-alpha-prod" \ -H "Content-Type: application/json" \ - -d '{"symbol":"BTCUSDT","category":"linear"}' + -d '{"exchange":"deribit","instrument":"BTC-PERPETUAL","interval":"1h","start_date":"2026-05-01","end_date":"2026-05-29"}' ``` Per lo schema completo dei body di richiesta e risposta: . + +--- + +## 14. Discovery strumenti & schemi candele + +Schemi verificati sulle risposte live. Tutti i `get_historical` ritornano la +chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`. + +### Lista strumenti +| Interfaccia | Tool | Request body | Risposta (campi principali) | +|---|---|---|---| +| `/mcp` (comune) | `get_instruments` | `{exchange?, currency:"BTC", kind:"future"}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` | +| Deribit (`/mcp-deribit`) | `get_instruments` | `{currency:"BTC"\|…, kind:"future", offset:int, limit:100}` — **paginato** (`has_more`) | `instruments[]`: `name`, `expiry`, `option_type`, `tick_size`, `min_trade_amount`, `maker_commission`, `taker_commission`, `creation_timestamp` · meta: `total`, `offset`, `limit`, `has_more`, `testnet`, `data_timestamp` | +| Hyperliquid (`/mcp-hyperliquid`) | `get_markets` | `{}` | lista `{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}` | + +### `get_historical` — body per exchange +| Interfaccia | Body | Note risoluzione | +|---|---|---| +| `/mcp` (comune) | `{exchange, instrument, interval, start_date, end_date}` | `1m`/`5m`/`15m`/`1h`/`4h`/`1d` · ritorna il singolo exchange | +| Deribit | `{instrument, start_date:"YYYY-MM-DD", end_date, resolution}` | `1`/`5`/`15`/`60`/`1D` (paginazione interna, no cap) | +| Hyperliquid | `{asset\|instrument, start_date, end_date, resolution, interval?, limit:50}` | `1m`/`5m`/`15m`/`1h`/`1d` · `limit` default **50** (alzare per finestre lunghe) | + +### Convenzione simboli Deribit +- **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL` +- **Altcoin** → perpetui *lineari USDC*: `_USDC-PERPETUAL` (es. `SOL_USDC-PERPETUAL`), storia da ~2022 +- Esistono anche `BTC_USDC-PERPETUAL` / `ETH_USDC-PERPETUAL` (varianti lineari) +- ⚠️ `LTC-PERPETUAL` / `ADA-PERPETUAL` (inverse) **non esistono** (0 candele); `SOL-PERPETUAL` è elencato ma è un contratto vuoto/stale (prezzo ~9.6 vs SOL reale ~82) → usare `SOL_USDC-PERPETUAL` + +### Nota testnet +Con `TESTNET_TOKEN` le risposte includono `"testnet": true` (ticker, +get_instruments, …) e i prezzi possono divergere dal mainnet. Su testnet +alcuni feed sono inaffidabili: validare la congruenza cross-exchange prima di +usare i dati per backtest/trading. diff --git a/src/cerbero_mcp/exchanges/alpaca/__init__.py b/old/src/exchanges/alpaca/__init__.py similarity index 100% rename from src/cerbero_mcp/exchanges/alpaca/__init__.py rename to old/src/exchanges/alpaca/__init__.py diff --git a/src/cerbero_mcp/exchanges/alpaca/client.py b/old/src/exchanges/alpaca/client.py similarity index 100% rename from src/cerbero_mcp/exchanges/alpaca/client.py rename to old/src/exchanges/alpaca/client.py diff --git a/src/cerbero_mcp/exchanges/alpaca/leverage_cap.py b/old/src/exchanges/alpaca/leverage_cap.py similarity index 100% rename from src/cerbero_mcp/exchanges/alpaca/leverage_cap.py rename to old/src/exchanges/alpaca/leverage_cap.py diff --git a/src/cerbero_mcp/exchanges/alpaca/tools.py b/old/src/exchanges/alpaca/tools.py similarity index 100% rename from src/cerbero_mcp/exchanges/alpaca/tools.py rename to old/src/exchanges/alpaca/tools.py diff --git a/src/cerbero_mcp/exchanges/bybit/__init__.py b/old/src/exchanges/bybit/__init__.py similarity index 100% rename from src/cerbero_mcp/exchanges/bybit/__init__.py rename to old/src/exchanges/bybit/__init__.py diff --git a/src/cerbero_mcp/exchanges/bybit/client.py b/old/src/exchanges/bybit/client.py similarity index 100% rename from src/cerbero_mcp/exchanges/bybit/client.py rename to old/src/exchanges/bybit/client.py diff --git a/src/cerbero_mcp/exchanges/bybit/leverage_cap.py b/old/src/exchanges/bybit/leverage_cap.py similarity index 100% rename from src/cerbero_mcp/exchanges/bybit/leverage_cap.py rename to old/src/exchanges/bybit/leverage_cap.py diff --git a/src/cerbero_mcp/exchanges/bybit/tools.py b/old/src/exchanges/bybit/tools.py similarity index 100% rename from src/cerbero_mcp/exchanges/bybit/tools.py rename to old/src/exchanges/bybit/tools.py diff --git a/src/cerbero_mcp/routers/alpaca.py b/old/src/routers/alpaca.py similarity index 100% rename from src/cerbero_mcp/routers/alpaca.py rename to old/src/routers/alpaca.py diff --git a/src/cerbero_mcp/routers/bybit.py b/old/src/routers/bybit.py similarity index 100% rename from src/cerbero_mcp/routers/bybit.py rename to old/src/routers/bybit.py diff --git a/tests/unit/exchanges/alpaca/__init__.py b/old/tests/alpaca/__init__.py similarity index 100% rename from tests/unit/exchanges/alpaca/__init__.py rename to old/tests/alpaca/__init__.py diff --git a/tests/unit/exchanges/alpaca/conftest.py b/old/tests/alpaca/conftest.py similarity index 100% rename from tests/unit/exchanges/alpaca/conftest.py rename to old/tests/alpaca/conftest.py diff --git a/tests/unit/exchanges/alpaca/test_client.py b/old/tests/alpaca/test_client.py similarity index 100% rename from tests/unit/exchanges/alpaca/test_client.py rename to old/tests/alpaca/test_client.py diff --git a/tests/unit/exchanges/alpaca/test_leverage_cap.py b/old/tests/alpaca/test_leverage_cap.py similarity index 100% rename from tests/unit/exchanges/alpaca/test_leverage_cap.py rename to old/tests/alpaca/test_leverage_cap.py diff --git a/tests/unit/exchanges/bybit/__init__.py b/old/tests/bybit/__init__.py similarity index 100% rename from tests/unit/exchanges/bybit/__init__.py rename to old/tests/bybit/__init__.py diff --git a/tests/unit/exchanges/bybit/conftest.py b/old/tests/bybit/conftest.py similarity index 100% rename from tests/unit/exchanges/bybit/conftest.py rename to old/tests/bybit/conftest.py diff --git a/tests/unit/exchanges/bybit/test_client.py b/old/tests/bybit/test_client.py similarity index 100% rename from tests/unit/exchanges/bybit/test_client.py rename to old/tests/bybit/test_client.py diff --git a/tests/unit/exchanges/bybit/test_leverage_cap.py b/old/tests/bybit/test_leverage_cap.py similarity index 100% rename from tests/unit/exchanges/bybit/test_leverage_cap.py rename to old/tests/bybit/test_leverage_cap.py diff --git a/src/cerbero_mcp/__main__.py b/src/cerbero_mcp/__main__.py index de704e1..f9a82d1 100644 --- a/src/cerbero_mcp/__main__.py +++ b/src/cerbero_mcp/__main__.py @@ -21,14 +21,13 @@ from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.common.logging import configure_root_logging from cerbero_mcp.exchanges import build_client from cerbero_mcp.routers import ( - alpaca, - bybit, cross, deribit, hyperliquid, ibkr, macro, sentiment, + unified, ) from cerbero_mcp.server import build_app from cerbero_mcp.settings import Settings @@ -66,13 +65,12 @@ def _make_app(settings: Settings) -> FastAPI: app.router.lifespan_context = lifespan app.include_router(deribit.make_router()) - app.include_router(bybit.make_router()) app.include_router(hyperliquid.make_router()) - app.include_router(alpaca.make_router()) app.include_router(ibkr.make_router()) app.include_router(macro.make_router()) app.include_router(sentiment.make_router()) app.include_router(cross.make_router()) + app.include_router(unified.make_router()) app.include_router(admin.make_admin_router()) return app diff --git a/src/cerbero_mcp/exchanges/__init__.py b/src/cerbero_mcp/exchanges/__init__.py index 616a33d..7079e1a 100644 --- a/src/cerbero_mcp/exchanges/__init__.py +++ b/src/cerbero_mcp/exchanges/__init__.py @@ -22,16 +22,6 @@ async def build_client( testnet=(env == "testnet"), base_url_override=url, ) - if exchange == "bybit": - from cerbero_mcp.exchanges.bybit.client import BybitClient - - url = settings.bybit.url_testnet if env == "testnet" else settings.bybit.url_live - return BybitClient( - api_key=settings.bybit.api_key, - api_secret=settings.bybit.api_secret.get_secret_value(), - testnet=(env == "testnet"), - base_url=url, - ) if exchange == "hyperliquid": from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient @@ -43,16 +33,6 @@ async def build_client( api_wallet_address=settings.hyperliquid.api_wallet_address, base_url=url, ) - if exchange == "alpaca": - from cerbero_mcp.exchanges.alpaca.client import AlpacaClient - - url = settings.alpaca.url_testnet if env == "testnet" else settings.alpaca.url_live - return AlpacaClient( - api_key=settings.alpaca.api_key_id, - secret_key=settings.alpaca.secret_key.get_secret_value(), - paper=(env == "testnet"), - base_url=url, - ) if exchange == "macro": # Read-only data provider — env ignored. Il registry # istanzia comunque 2 entry (testnet/mainnet); costo trascurabile diff --git a/src/cerbero_mcp/exchanges/cross/client.py b/src/cerbero_mcp/exchanges/cross/client.py index 0168cad..8c0a9bb 100644 --- a/src/cerbero_mcp/exchanges/cross/client.py +++ b/src/cerbero_mcp/exchanges/cross/client.py @@ -7,12 +7,15 @@ a single consensus candle series with per-bar divergence metrics. from __future__ import annotations import asyncio -import datetime as _dt from typing import Any, Literal, Protocol from fastapi import HTTPException from cerbero_mcp.exchanges.cross.consensus import merge_candles +from cerbero_mcp.exchanges.cross.instruments import ( + normalize_deribit, + normalize_hyperliquid, +) from cerbero_mcp.exchanges.cross.symbol_map import ( get_sources, supported_intervals, @@ -20,6 +23,9 @@ from cerbero_mcp.exchanges.cross.symbol_map import ( to_native_symbol, ) +# Venues exposed through the unified /mcp interface. +UNIFIED_EXCHANGES = ("deribit", "hyperliquid") + Environment = Literal["testnet", "mainnet"] @@ -28,21 +34,6 @@ class _Registry(Protocol): async def get(self, exchange: str, env: Environment) -> Any: ... -def _iso_to_ms(s: str) -> int: - return int(_dt.datetime.fromisoformat( - s.replace("Z", "+00:00") - ).timestamp() * 1000) - - -async def _call_bybit(client: Any, sym: str, interval: str, - start: str, end: str) -> dict[str, Any]: - resp: dict[str, Any] = await client.get_historical( - symbol=sym, category="linear", interval=interval, - start=_iso_to_ms(start), end=_iso_to_ms(end), - ) - return resp - - async def _call_hyperliquid(client: Any, sym: str, interval: str, start: str, end: str) -> dict[str, Any]: resp: dict[str, Any] = await client.get_historical( @@ -59,20 +50,9 @@ async def _call_deribit(client: Any, sym: str, interval: str, return resp -async def _call_alpaca(client: Any, sym: str, interval: str, - start: str, end: str) -> dict[str, Any]: - resp: dict[str, Any] = await client.get_bars( - symbol=sym, asset_class="stocks", interval=interval, - start=start, end=end, - ) - return resp - - _DISPATCH = { - "bybit": _call_bybit, "hyperliquid": _call_hyperliquid, "deribit": _call_deribit, - "alpaca": _call_alpaca, } @@ -144,3 +124,92 @@ class CrossClient: "sources_used": sorted(by_source.keys()), "failed_sources": failed, } + + +class UnifiedClient: + """Single common interface over the integrated venues. + + `get_instruments` returns one uniform instrument list (each row carries + its own `exchange`, `fees` and `history_start`); `get_historical` + returns candles from one explicitly chosen exchange. Cross-exchange + consensus stays available separately via `CrossClient`. + """ + + def __init__(self, registry: _Registry, *, env: Environment): + self._registry = registry + self._env = env + + async def _instruments_one( + self, exchange: str, currency: str, kind: str | None, + ) -> tuple[str, list[dict[str, Any]] | Exception]: + 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", [])) + if exchange == "hyperliquid": + rows = await client.get_markets() + return exchange, normalize_hyperliquid(rows) + raise ValueError(f"no instrument normalizer for {exchange}") + except Exception as e: # noqa: BLE001 + return exchange, e + + async def get_instruments( + self, *, exchange: str | None = None, + currency: str = "BTC", kind: str | None = "future", + ) -> dict[str, Any]: + if exchange is not None and exchange not in UNIFIED_EXCHANGES: + raise HTTPException( + status_code=400, + detail=f"unsupported exchange: {exchange}; " + f"supported: {list(UNIFIED_EXCHANGES)}", + ) + targets = [exchange] if exchange else list(UNIFIED_EXCHANGES) + + results = await asyncio.gather( + *(self._instruments_one(ex, currency, kind) for ex in targets) + ) + + instruments: list[dict[str, Any]] = [] + failed: list[dict[str, str]] = [] + for ex, payload in results: + if isinstance(payload, Exception): + failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"}) + else: + instruments.extend(payload) + + if not instruments and failed: + raise HTTPException( + status_code=502, + detail={"error": "all sources failed", "failed_sources": failed}, + ) + + return {"instruments": instruments, "failed_sources": failed} + + async def get_historical( + self, *, exchange: str, instrument: str, interval: str, + start_date: str, end_date: str, + ) -> dict[str, Any]: + if exchange not in _DISPATCH: + raise HTTPException( + status_code=400, + detail=f"unsupported exchange: {exchange}; " + f"supported: {list(_DISPATCH.keys())}", + ) + if interval not in supported_intervals(): + raise HTTPException( + status_code=400, + detail=f"unsupported interval: {interval}; " + f"supported: {supported_intervals()}", + ) + native_interval = to_native_interval(interval, exchange) + client = await self._registry.get(exchange, self._env) + resp = await _DISPATCH[exchange]( + client, instrument, native_interval, start_date, end_date, + ) + return { + "exchange": exchange, + "instrument": instrument, + "interval": interval, + "candles": resp.get("candles", []), + } diff --git a/src/cerbero_mcp/exchanges/cross/instruments.py b/src/cerbero_mcp/exchanges/cross/instruments.py new file mode 100644 index 0000000..3b58682 --- /dev/null +++ b/src/cerbero_mcp/exchanges/cross/instruments.py @@ -0,0 +1,109 @@ +"""Normalizers: per-exchange instrument listings → a uniform schema. + +Every integrated venue exposes its own instrument shape. This module maps +each native row onto a single `UnifiedInstrument` dict so that callers see +one consistent contract regardless of exchange: + + { + "exchange": "deribit", # which venue this row came from + "symbol": "BTC-PERPETUAL", # native symbol on that venue + "asset_class": "crypto", + "type": "perpetual", # perpetual | future | option | spot + "fees": {"maker": 0.0, "taker": 0.0005} | None, # None if unknown + "history_start": "2018-08-13" | None, # None if unknown + "tick_size": 0.5 | None, + "native": { ...venue-specific fields, lossless... }, + } + +`fees` and `history_start` are populated live where the upstream API +provides them (today: Deribit), otherwise left None. +""" +from __future__ import annotations + +import datetime as _dt +from typing import Any + + +def _ms_to_iso_date(ms: Any) -> str | None: + if ms is None: + return None + try: + ts = int(ms) / 1000 + except (TypeError, ValueError): + return None + return _dt.datetime.fromtimestamp(ts, tz=_dt.UTC).date().isoformat() + + +def _as_float(v: Any) -> float | None: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _deribit_type(kind: Any, name: str) -> str: + if kind == "option": + return "option" + if kind == "spot": + return "spot" + if kind == "future": + return "perpetual" if str(name).upper().endswith("PERPETUAL") else "future" + return str(kind or "unknown") + + +def normalize_deribit(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Map Deribit get_instruments rows onto the uniform schema.""" + out: list[dict[str, Any]] = [] + for i in rows: + name = i.get("name") + maker = _as_float(i.get("maker_commission")) + taker = _as_float(i.get("taker_commission")) + fees = {"maker": maker, "taker": taker} if ( + maker is not None or taker is not None + ) else None + out.append({ + "exchange": "deribit", + "symbol": name, + "asset_class": "crypto", + "type": _deribit_type(i.get("kind"), name or ""), + "fees": fees, + "history_start": _ms_to_iso_date(i.get("creation_timestamp")), + "tick_size": _as_float(i.get("tick_size")), + "native": { + "strike": i.get("strike"), + "expiry": i.get("expiry"), + "option_type": i.get("option_type"), + "min_trade_amount": i.get("min_trade_amount"), + "open_interest": i.get("open_interest"), + }, + }) + return out + + +def normalize_hyperliquid(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Map Hyperliquid get_markets rows onto the uniform schema. + + Hyperliquid charges account-tiered fees (no per-instrument schedule) and + exposes no listing date, so `fees` and `history_start` stay None. + """ + out: list[dict[str, Any]] = [] + for m in rows: + out.append({ + "exchange": "hyperliquid", + "symbol": m.get("asset"), + "asset_class": "crypto", + "type": "perpetual", + "fees": None, + "history_start": None, + "tick_size": None, + "native": { + "mark_price": m.get("mark_price"), + "funding_rate": m.get("funding_rate"), + "open_interest": m.get("open_interest"), + "volume_24h": m.get("volume_24h"), + "max_leverage": m.get("max_leverage"), + }, + }) + return out diff --git a/src/cerbero_mcp/exchanges/cross/symbol_map.py b/src/cerbero_mcp/exchanges/cross/symbol_map.py index 07f3e0f..1ed7e1e 100644 --- a/src/cerbero_mcp/exchanges/cross/symbol_map.py +++ b/src/cerbero_mcp/exchanges/cross/symbol_map.py @@ -1,40 +1,30 @@ """Routing table: canonical (asset_class, symbol, interval) → per-exchange native. -Crypto canonical symbols default to USD/USDT-quoted perpetuals on the most -liquid pair available. Equities currently route to Alpaca only — IBKR is -omitted from the cross MVP because its bars endpoint takes a relative -period instead of (start, end). +Crypto canonical symbols default to USD-quoted perpetuals on the most liquid +pair available. Only the integrated derivatives venues (Deribit, Hyperliquid) +participate in the cross-exchange consensus. """ from __future__ import annotations AssetClass = str _CRYPTO_SYMBOLS: dict[str, dict[str, str]] = { - "BTC": {"bybit": "BTCUSDT", "hyperliquid": "BTC", "deribit": "BTC-PERPETUAL"}, - "ETH": {"bybit": "ETHUSDT", "hyperliquid": "ETH", "deribit": "ETH-PERPETUAL"}, - "SOL": {"bybit": "SOLUSDT", "hyperliquid": "SOL"}, -} - -_STOCK_SYMBOLS: dict[str, dict[str, str]] = { - "AAPL": {"alpaca": "AAPL"}, - "SPY": {"alpaca": "SPY"}, - "QQQ": {"alpaca": "QQQ"}, - "TSLA": {"alpaca": "TSLA"}, - "NVDA": {"alpaca": "NVDA"}, + "BTC": {"hyperliquid": "BTC", "deribit": "BTC-PERPETUAL"}, + "ETH": {"hyperliquid": "ETH", "deribit": "ETH-PERPETUAL"}, + "SOL": {"hyperliquid": "SOL"}, } _SYMBOLS: dict[AssetClass, dict[str, dict[str, str]]] = { "crypto": _CRYPTO_SYMBOLS, - "stocks": _STOCK_SYMBOLS, } _INTERVALS: dict[str, dict[str, str]] = { - "1m": {"bybit": "1", "hyperliquid": "1m", "deribit": "1m", "alpaca": "1m"}, - "5m": {"bybit": "5", "hyperliquid": "5m", "deribit": "5m", "alpaca": "5m"}, - "15m": {"bybit": "15", "hyperliquid": "15m", "deribit": "15m", "alpaca": "15m"}, - "1h": {"bybit": "60", "hyperliquid": "1h", "deribit": "1h", "alpaca": "1h"}, - "4h": {"bybit": "240", "hyperliquid": "4h", "deribit": "4h", "alpaca": "4h"}, - "1d": {"bybit": "D", "hyperliquid": "1d", "deribit": "1d", "alpaca": "1d"}, + "1m": {"hyperliquid": "1m", "deribit": "1m"}, + "5m": {"hyperliquid": "5m", "deribit": "5m"}, + "15m": {"hyperliquid": "15m", "deribit": "15m"}, + "1h": {"hyperliquid": "1h", "deribit": "1h"}, + "4h": {"hyperliquid": "4h", "deribit": "4h"}, + "1d": {"hyperliquid": "1d", "deribit": "1d"}, } diff --git a/src/cerbero_mcp/exchanges/cross/tools.py b/src/cerbero_mcp/exchanges/cross/tools.py index 0be42ca..3b9f682 100644 --- a/src/cerbero_mcp/exchanges/cross/tools.py +++ b/src/cerbero_mcp/exchanges/cross/tools.py @@ -1,13 +1,14 @@ -"""Pydantic schemas + thin tool wrappers for the /mcp-cross router.""" +"""Pydantic schemas + thin tool wrappers for the /mcp-cross and /mcp routers.""" from __future__ import annotations from typing import Literal from pydantic import BaseModel -from cerbero_mcp.exchanges.cross.client import CrossClient +from cerbero_mcp.exchanges.cross.client import CrossClient, UnifiedClient -AssetClass = Literal["crypto", "stocks"] +AssetClass = Literal["crypto"] +Exchange = Literal["deribit", "hyperliquid"] class GetHistoricalReq(BaseModel): @@ -26,3 +27,39 @@ async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict: start_date=params.start_date, end_date=params.end_date, ) + + +# ── Unified /mcp interface ──────────────────────────────────────────── + +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 + + +class UnifiedGetHistoricalReq(BaseModel): + exchange: Exchange + instrument: str + interval: str = "1h" + start_date: str + end_date: str + + +async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> dict: + return await client.get_instruments( + exchange=params.exchange, + currency=params.currency, + kind=params.kind, + ) + + +async def unified_get_historical( + client: UnifiedClient, params: UnifiedGetHistoricalReq +) -> dict: + return await client.get_historical( + exchange=params.exchange, + instrument=params.instrument, + interval=params.interval, + start_date=params.start_date, + end_date=params.end_date, + ) diff --git a/src/cerbero_mcp/exchanges/deribit/client.py b/src/cerbero_mcp/exchanges/deribit/client.py index 8f72bef..6654099 100644 --- a/src/cerbero_mcp/exchanges/deribit/client.py +++ b/src/cerbero_mcp/exchanges/deribit/client.py @@ -327,6 +327,10 @@ class DeribitClient: "tick_size": i.get("tick_size"), "min_trade_amount": i.get("min_trade_amount"), "open_interest": i.get("open_interest"), + "kind": i.get("kind"), + "maker_commission": i.get("maker_commission"), + "taker_commission": i.get("taker_commission"), + "creation_timestamp": i.get("creation_timestamp"), } for i in page ] diff --git a/src/cerbero_mcp/routers/unified.py b/src/cerbero_mcp/routers/unified.py new file mode 100644 index 0000000..c7411c4 --- /dev/null +++ b/src/cerbero_mcp/routers/unified.py @@ -0,0 +1,50 @@ +"""Router /mcp/* — unified common interface across integrated exchanges. + +`get_instruments` returns one uniform instrument list (each row carries its +own `exchange`, `fees` and `history_start`); `get_historical` returns candles +from one explicitly chosen exchange. This is the forward-looking interface; +the per-exchange routers (/mcp-deribit, …) and the consensus aggregator +(/mcp-cross) remain available during the transition. +""" +from __future__ import annotations + +from typing import Literal, cast + +from fastapi import APIRouter, Depends, Request + +from cerbero_mcp.client_registry import ClientRegistry +from cerbero_mcp.exchanges.cross import tools as t +from cerbero_mcp.exchanges.cross.client import UnifiedClient + +Environment = Literal["testnet", "mainnet"] + + +def get_environment(request: Request) -> Environment: + return cast(Environment, request.state.environment) + + +def get_unified_client( + request: Request, env: Environment = Depends(get_environment), +) -> UnifiedClient: + registry: ClientRegistry = request.app.state.registry + return UnifiedClient(registry, env=env) + + +def make_router() -> APIRouter: + r = APIRouter(prefix="/mcp", tags=["unified"]) + + @r.post("/tools/get_instruments") + async def _get_instruments( + params: t.GetInstrumentsReq, + client: UnifiedClient = Depends(get_unified_client), + ): + return await t.get_instruments(client, params) + + @r.post("/tools/get_historical") + async def _get_historical( + params: t.UnifiedGetHistoricalReq, + client: UnifiedClient = Depends(get_unified_client), + ): + return await t.unified_get_historical(client, params) + + return r diff --git a/src/cerbero_mcp/settings.py b/src/cerbero_mcp/settings.py index d3ea7e7..9a5f14c 100644 --- a/src/cerbero_mcp/settings.py +++ b/src/cerbero_mcp/settings.py @@ -61,20 +61,6 @@ class DeribitSettings(_Sub): return cid, csec.get_secret_value() -class BybitSettings(_Sub): - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - env_prefix="BYBIT_", - extra="ignore", - ) - api_key: str - api_secret: SecretStr - url_live: str - url_testnet: str - max_leverage: int = 3 - - class HyperliquidSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", @@ -90,20 +76,6 @@ class HyperliquidSettings(_Sub): max_leverage: int = 3 -class AlpacaSettings(_Sub): - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - env_prefix="ALPACA_", - extra="ignore", - ) - api_key_id: str - secret_key: SecretStr - url_live: str - url_testnet: str - max_leverage: int = 1 - - class IBKRSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", @@ -216,9 +188,7 @@ class Settings(_Sub): mainnet_token: SecretStr deribit: DeribitSettings = Field(default_factory=lambda: DeribitSettings()) # type: ignore[call-arg] - bybit: BybitSettings = Field(default_factory=lambda: BybitSettings()) # type: ignore[call-arg] hyperliquid: HyperliquidSettings = Field(default_factory=lambda: HyperliquidSettings()) # type: ignore[call-arg] - alpaca: AlpacaSettings = Field(default_factory=lambda: AlpacaSettings()) # type: ignore[call-arg] ibkr: IBKRSettings = Field(default_factory=lambda: IBKRSettings()) # type: ignore[call-arg] macro: MacroSettings = Field(default_factory=lambda: MacroSettings()) # type: ignore[call-arg] sentiment: SentimentSettings = Field(default_factory=lambda: SentimentSettings()) # type: ignore[call-arg] diff --git a/tests/integration/test_app_boot.py b/tests/integration/test_app_boot.py index 65c510b..0a3393e 100644 --- a/tests/integration/test_app_boot.py +++ b/tests/integration/test_app_boot.py @@ -23,11 +23,14 @@ def test_app_boots_and_health_responds(monkeypatch): spec = r.json() paths = spec["paths"].keys() assert any(p.startswith("/mcp-deribit/") for p in paths) - assert any(p.startswith("/mcp-bybit/") for p in paths) assert any(p.startswith("/mcp-hyperliquid/") for p in paths) - assert any(p.startswith("/mcp-alpaca/") for p in paths) assert any(p.startswith("/mcp-macro/") for p in paths) assert any(p.startswith("/mcp-sentiment/") for p in paths) + # Unified common interface + assert any(p.startswith("/mcp/tools/") for p in paths) + # Bybit and Alpaca have been retired from the API surface + assert not any(p.startswith("/mcp-bybit/") for p in paths) + assert not any(p.startswith("/mcp-alpaca/") for p in paths) def test_apidocs_available_after_boot(monkeypatch): diff --git a/tests/integration/test_env_routing.py b/tests/integration/test_env_routing.py index 4036c12..ffc845e 100644 --- a/tests/integration/test_env_routing.py +++ b/tests/integration/test_env_routing.py @@ -98,40 +98,6 @@ def test_deribit_mainnet_bearer_constructs_mainnet_client(app, monkeypatch): ) -# ── Bybit ──────────────────────────────────────────────────────────────────── - -def test_bybit_testnet_bearer(app, monkeypatch): - capture = {} - _spy_constructor(monkeypatch, - "cerbero_mcp.exchanges.bybit.client", "BybitClient", - capture) - _force_rebuild(app) - - c = TestClient(app, raise_server_exceptions=False) - c.post("/mcp-bybit/tools/environment_info", headers=_bearer_test()) - - assert capture, "BybitClient constructor non chiamato" - assert capture["kwargs"].get("testnet") is True, ( - f"atteso testnet=True, kwargs={capture['kwargs']}" - ) - - -def test_bybit_mainnet_bearer(app, monkeypatch): - capture = {} - _spy_constructor(monkeypatch, - "cerbero_mcp.exchanges.bybit.client", "BybitClient", - capture) - _force_rebuild(app) - - c = TestClient(app, raise_server_exceptions=False) - c.post("/mcp-bybit/tools/environment_info", headers=_bearer_live()) - - assert capture, "BybitClient constructor non chiamato" - assert capture["kwargs"].get("testnet") is False, ( - f"atteso testnet=False, kwargs={capture['kwargs']}" - ) - - # ── Hyperliquid ────────────────────────────────────────────────────────────── def test_hyperliquid_testnet_bearer(app, monkeypatch): @@ -166,41 +132,6 @@ def test_hyperliquid_mainnet_bearer(app, monkeypatch): ) -# ── Alpaca ─────────────────────────────────────────────────────────────────── - -def test_alpaca_testnet_bearer_uses_paper(app, monkeypatch): - """Alpaca usa paper=True al posto di testnet=True.""" - capture = {} - _spy_constructor(monkeypatch, - "cerbero_mcp.exchanges.alpaca.client", "AlpacaClient", - capture) - _force_rebuild(app) - - c = TestClient(app, raise_server_exceptions=False) - c.post("/mcp-alpaca/tools/environment_info", headers=_bearer_test()) - - assert capture, "AlpacaClient constructor non chiamato" - assert capture["kwargs"].get("paper") is True, ( - f"atteso paper=True, kwargs={capture['kwargs']}" - ) - - -def test_alpaca_mainnet_bearer_uses_paper_false(app, monkeypatch): - capture = {} - _spy_constructor(monkeypatch, - "cerbero_mcp.exchanges.alpaca.client", "AlpacaClient", - capture) - _force_rebuild(app) - - c = TestClient(app, raise_server_exceptions=False) - c.post("/mcp-alpaca/tools/environment_info", headers=_bearer_live()) - - assert capture, "AlpacaClient constructor non chiamato" - assert capture["kwargs"].get("paper") is False, ( - f"atteso paper=False, kwargs={capture['kwargs']}" - ) - - # ── Auth sanity ────────────────────────────────────────────────────────────── def test_no_bearer_returns_401(app): diff --git a/tests/unit/exchanges/cross/test_client.py b/tests/unit/exchanges/cross/test_client.py index 8ffa590..46d2e1a 100644 --- a/tests/unit/exchanges/cross/test_client.py +++ b/tests/unit/exchanges/cross/test_client.py @@ -20,12 +20,6 @@ class _Fake: self.calls.append(kwargs) return {"candles": list(self._candles)} - async def get_bars(self, **kwargs: Any) -> dict[str, Any]: - if self._raises: - raise self._raises - self.calls.append(kwargs) - return {"candles": list(self._candles)} - class _FakeRegistry: def __init__(self, clients: dict[str, _Fake]): @@ -43,9 +37,8 @@ def _c(ts: int, close: float = 100.0) -> dict[str, Any]: @pytest.mark.asyncio -async def test_crypto_three_sources_aggregates(): +async def test_crypto_two_sources_aggregates(): fakes = { - "bybit": _Fake([_c(1, 100), _c(2, 200)]), "hyperliquid": _Fake([_c(1, 100), _c(2, 200)]), "deribit": _Fake([_c(1, 100), _c(2, 200)]), } @@ -57,16 +50,15 @@ async def test_crypto_three_sources_aggregates(): assert out["symbol"] == "BTC" assert out["asset_class"] == "crypto" assert len(out["candles"]) == 2 - assert out["candles"][0]["sources"] == 3 + assert out["candles"][0]["sources"] == 2 assert out["candles"][0]["div_pct"] == 0.0 - assert set(out["sources_used"]) == {"bybit", "hyperliquid", "deribit"} + assert set(out["sources_used"]) == {"hyperliquid", "deribit"} assert out["failed_sources"] == [] @pytest.mark.asyncio async def test_crypto_partial_failure_returns_partial_with_warning(): fakes = { - "bybit": _Fake([_c(1, 100)]), "hyperliquid": _Fake([_c(1, 100)]), "deribit": _Fake(raises=RuntimeError("upstream down")), } @@ -75,8 +67,8 @@ async def test_crypto_partial_failure_returns_partial_with_warning(): symbol="BTC", asset_class="crypto", interval="1h", start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00", ) - assert out["candles"][0]["sources"] == 2 - assert set(out["sources_used"]) == {"bybit", "hyperliquid"} + assert out["candles"][0]["sources"] == 1 + assert set(out["sources_used"]) == {"hyperliquid"} assert len(out["failed_sources"]) == 1 assert out["failed_sources"][0]["exchange"] == "deribit" assert "upstream down" in out["failed_sources"][0]["error"] @@ -85,7 +77,6 @@ async def test_crypto_partial_failure_returns_partial_with_warning(): @pytest.mark.asyncio async def test_all_sources_fail_raises_502(): fakes = { - "bybit": _Fake(raises=RuntimeError("a")), "hyperliquid": _Fake(raises=RuntimeError("b")), "deribit": _Fake(raises=RuntimeError("c")), } @@ -109,20 +100,6 @@ async def test_unsupported_symbol_raises_400(): assert exc_info.value.status_code == 400 -@pytest.mark.asyncio -async def test_stocks_routes_to_alpaca_only(): - fake = _Fake([_c(1, 175.0)]) - cc = CrossClient(_FakeRegistry({"alpaca": fake}), env="mainnet") - out = await cc.get_historical( - symbol="AAPL", asset_class="stocks", interval="1d", - start_date="2026-04-09T00:00:00", end_date="2026-04-10T00:00:00", - ) - assert out["sources_used"] == ["alpaca"] - assert out["candles"][0]["close"] == 175.0 - # Alpaca was called with native symbol - assert fake.calls[0]["symbol"] == "AAPL" - - @pytest.mark.asyncio async def test_unsupported_interval_raises_400(): cc = CrossClient(_FakeRegistry({}), env="mainnet") diff --git a/tests/unit/exchanges/cross/test_symbol_map.py b/tests/unit/exchanges/cross/test_symbol_map.py index 5c369b7..252ba27 100644 --- a/tests/unit/exchanges/cross/test_symbol_map.py +++ b/tests/unit/exchanges/cross/test_symbol_map.py @@ -9,23 +9,22 @@ from cerbero_mcp.exchanges.cross.symbol_map import ( def test_btc_crypto_sources(): - assert set(get_sources("crypto", "BTC")) == {"bybit", "hyperliquid", "deribit"} + assert set(get_sources("crypto", "BTC")) == {"hyperliquid", "deribit"} def test_eth_crypto_sources(): - assert set(get_sources("crypto", "ETH")) == {"bybit", "hyperliquid", "deribit"} + assert set(get_sources("crypto", "ETH")) == {"hyperliquid", "deribit"} def test_unknown_crypto_symbol_returns_empty(): assert get_sources("crypto", "DOGEFAKE") == [] -def test_stocks_aapl_sources(): - assert set(get_sources("stocks", "AAPL")) == {"alpaca"} +def test_unknown_asset_class_returns_empty(): + assert get_sources("stocks", "AAPL") == [] def test_native_symbol_btc(): - assert to_native_symbol("crypto", "BTC", "bybit") == "BTCUSDT" assert to_native_symbol("crypto", "BTC", "hyperliquid") == "BTC" assert to_native_symbol("crypto", "BTC", "deribit") == "BTC-PERPETUAL" @@ -36,12 +35,10 @@ def test_native_symbol_unsupported_pair_raises(): def test_native_interval_1h(): - assert to_native_interval("1h", "bybit") == "60" assert to_native_interval("1h", "hyperliquid") == "1h" assert to_native_interval("1h", "deribit") == "1h" - assert to_native_interval("1h", "alpaca") == "1h" def test_native_interval_unknown_canonical_raises(): with pytest.raises(KeyError): - to_native_interval("3h", "bybit") + to_native_interval("3h", "deribit") diff --git a/tests/unit/exchanges/cross/test_unified.py b/tests/unit/exchanges/cross/test_unified.py new file mode 100644 index 0000000..462e011 --- /dev/null +++ b/tests/unit/exchanges/cross/test_unified.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from cerbero_mcp.exchanges.cross.client import UnifiedClient +from cerbero_mcp.exchanges.cross.instruments import ( + normalize_deribit, + normalize_hyperliquid, +) +from fastapi import HTTPException + +# ── Normalizers ────────────────────────────────────────────────────── + +def test_normalize_deribit_populates_fees_and_history_start(): + rows = [{ + "name": "BTC-PERPETUAL", + "kind": "future", + "tick_size": 0.5, + "maker_commission": 0.0, + "taker_commission": 0.0005, + "creation_timestamp": 1534377600000, # 2018-08-16 + "strike": None, + "expiry": None, + "option_type": None, + "min_trade_amount": 10, + "open_interest": 123.0, + }] + out = normalize_deribit(rows) + assert len(out) == 1 + inst = out[0] + assert inst["exchange"] == "deribit" + assert inst["symbol"] == "BTC-PERPETUAL" + assert inst["asset_class"] == "crypto" + assert inst["type"] == "perpetual" + assert inst["fees"] == {"maker": 0.0, "taker": 0.0005} + assert inst["history_start"] == "2018-08-16" + assert inst["tick_size"] == 0.5 + assert inst["native"]["open_interest"] == 123.0 + + +def test_normalize_deribit_dated_future_is_future_not_perpetual(): + out = normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}]) + assert out[0]["type"] == "future" + + +def test_normalize_deribit_option_type(): + out = normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}]) + assert out[0]["type"] == "option" + + +def test_normalize_deribit_fees_none_when_absent(): + out = normalize_deribit([{"name": "X", "kind": "future"}]) + assert out[0]["fees"] is None + assert out[0]["history_start"] is None + + +def test_normalize_hyperliquid_leaves_fees_and_history_none(): + rows = [{ + "asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001, + "open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50, + }] + out = normalize_hyperliquid(rows) + assert out[0]["exchange"] == "hyperliquid" + assert out[0]["symbol"] == "BTC" + assert out[0]["type"] == "perpetual" + assert out[0]["fees"] is None + assert out[0]["history_start"] is None + assert out[0]["native"]["max_leverage"] == 50 + + +# ── UnifiedClient ──────────────────────────────────────────────────── + +class _FakeDeribit: + async def get_instruments(self, **kwargs: Any) -> dict[str, Any]: + self.last_call = kwargs + return {"instruments": [{ + "name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5, + "maker_commission": 0.0, "taker_commission": 0.0005, + "creation_timestamp": 1534377600000, + }]} + + async def get_historical(self, **kwargs: Any) -> dict[str, Any]: + self.hist_call = kwargs + return {"candles": [{"timestamp": 1, "open": 1, "high": 1, + "low": 1, "close": 1, "volume": 1}]} + + +class _FakeHyperliquid: + async def get_markets(self) -> list[dict[str, Any]]: + return [{"asset": "BTC", "mark_price": 1.0, "funding_rate": 0.0, + "open_interest": 1.0, "volume_24h": 1.0, "max_leverage": 50}] + + async def get_historical(self, **kwargs: Any) -> dict[str, Any]: + return {"candles": []} + + +class _FakeRegistry: + def __init__(self, clients: dict[str, Any]): + self._clients = clients + + async def get(self, exchange: str, env: str) -> Any: + if exchange not in self._clients: + raise KeyError(exchange) + return self._clients[exchange] + + +@pytest.mark.asyncio +async def test_get_instruments_fans_out_all_venues(): + uc = UnifiedClient( + _FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}), + env="mainnet", + ) + out = await uc.get_instruments() + venues = {i["exchange"] for i in out["instruments"]} + assert venues == {"deribit", "hyperliquid"} + assert out["failed_sources"] == [] + + +@pytest.mark.asyncio +async def test_get_instruments_single_exchange_filter(): + uc = UnifiedClient( + _FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}), + env="mainnet", + ) + out = await uc.get_instruments(exchange="deribit") + assert {i["exchange"] for i in out["instruments"]} == {"deribit"} + + +@pytest.mark.asyncio +async def test_get_instruments_unsupported_exchange_raises_400(): + uc = UnifiedClient(_FakeRegistry({}), env="mainnet") + with pytest.raises(HTTPException) as exc: + await uc.get_instruments(exchange="bybit") + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_instruments_partial_failure_reports_failed(): + uc = UnifiedClient( + _FakeRegistry({"deribit": _FakeDeribit()}), # hyperliquid missing → KeyError + env="mainnet", + ) + out = await uc.get_instruments() + assert {i["exchange"] for i in out["instruments"]} == {"deribit"} + assert [f["exchange"] for f in out["failed_sources"]] == ["hyperliquid"] + + +@pytest.mark.asyncio +async def test_get_historical_single_exchange(): + fake = _FakeDeribit() + uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet") + out = await uc.get_historical( + exchange="deribit", instrument="BTC-PERPETUAL", interval="1h", + start_date="2026-05-09", end_date="2026-05-10", + ) + assert out["exchange"] == "deribit" + assert out["instrument"] == "BTC-PERPETUAL" + assert out["interval"] == "1h" + assert len(out["candles"]) == 1 + assert fake.hist_call["instrument"] == "BTC-PERPETUAL" + + +@pytest.mark.asyncio +async def test_get_historical_unsupported_exchange_raises_400(): + uc = UnifiedClient(_FakeRegistry({}), env="mainnet") + with pytest.raises(HTTPException) as exc: + await uc.get_historical( + exchange="bybit", instrument="BTCUSDT", interval="1h", + start_date="2026-05-09", end_date="2026-05-10", + ) + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_historical_unsupported_interval_raises_400(): + uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet") + with pytest.raises(HTTPException) as exc: + await uc.get_historical( + exchange="deribit", instrument="BTC-PERPETUAL", interval="3h", + start_date="2026-05-09", end_date="2026-05-10", + ) + assert exc.value.status_code == 400 diff --git a/tests/unit/exchanges/hyperliquid/test_client.py b/tests/unit/exchanges/hyperliquid/test_client.py index 40d6680..f7ed7d5 100644 --- a/tests/unit/exchanges/hyperliquid/test_client.py +++ b/tests/unit/exchanges/hyperliquid/test_client.py @@ -277,8 +277,14 @@ async def test_place_order_limit(httpx_mock: HTTPXMock, client: HyperliquidClien assert order["t"] == {"limit": {"tif": "Gtc"}} sig = body["signature"] assert set(sig.keys()) == {"r", "s", "v"} - assert sig["r"].startswith("0x") and len(sig["r"]) == 66 - assert sig["s"].startswith("0x") and len(sig["s"]) == 66 + # r/s are serialized with eth_utils.to_hex (same as the Hyperliquid SDK), + # which emits minimal-length hex: a leading zero byte yields < 66 chars + # (~1/256 of signatures). Assert a valid <= 32-byte big-endian int, not a + # fixed length. + assert sig["r"].startswith("0x") and 2 < len(sig["r"]) <= 66 + assert sig["s"].startswith("0x") and 2 < len(sig["s"]) <= 66 + assert 0 < int(sig["r"], 16) < 2**256 + assert 0 < int(sig["s"], 16) < 2**256 assert sig["v"] in (27, 28) diff --git a/tests/unit/test_exchanges_builder.py b/tests/unit/test_exchanges_builder.py index dfde514..c90d679 100644 --- a/tests/unit/test_exchanges_builder.py +++ b/tests/unit/test_exchanges_builder.py @@ -22,27 +22,6 @@ async def test_build_client_deribit_returns_correct_url(monkeypatch): assert "test" not in c_live.base_url.lower() -@pytest.mark.asyncio -async def test_build_client_bybit_returns_correct_env(monkeypatch): - from tests.unit.test_settings import _minimal_env - - for k, v in _minimal_env().items(): - monkeypatch.setenv(k, v) - - # BybitClient costruisce internamente httpx.AsyncClient: nessuna - # connessione reale finché non si invoca un metodo di rete. - from cerbero_mcp.exchanges import build_client - from cerbero_mcp.settings import Settings - - s = Settings() - c_test = await build_client(s, "bybit", "testnet") - c_live = await build_client(s, "bybit", "mainnet") - - assert c_test is not c_live - assert c_test.testnet is True - assert c_live.testnet is False - - @pytest.mark.asyncio async def test_build_client_hyperliquid_returns_correct_env(monkeypatch): from tests.unit.test_settings import _minimal_env @@ -64,31 +43,6 @@ async def test_build_client_hyperliquid_returns_correct_env(monkeypatch): assert "test" not in c_live.base_url.lower() -@pytest.mark.asyncio -async def test_build_client_alpaca_returns_correct_env(monkeypatch): - from tests.unit.test_settings import _minimal_env - - for k, v in _minimal_env().items(): - monkeypatch.setenv(k, v) - - # AlpacaClient (V2) usa httpx puro: il costruttore non apre connessioni - # reali (httpx.AsyncClient è lazy fino alla prima request), quindi nessuno - # stub SDK è necessario. - from cerbero_mcp.exchanges import build_client - from cerbero_mcp.settings import Settings - - s = Settings() - c_test = await build_client(s, "alpaca", "testnet") - c_live = await build_client(s, "alpaca", "mainnet") - try: - assert c_test is not c_live - assert c_test.paper is True - assert c_live.paper is False - finally: - await c_test.aclose() - await c_live.aclose() - - @pytest.mark.asyncio async def test_build_client_macro_no_env_distinction(monkeypatch): from tests.unit.test_settings import _minimal_env @@ -187,45 +141,6 @@ async def test_hyperliquid_url_from_env_overrides_default(monkeypatch): assert c._base_url_override == "https://hl-custom.example.com" -@pytest.mark.asyncio -async def test_bybit_url_from_env_overrides_default(monkeypatch): - """Bybit (httpx): override BYBIT_URL_TESTNET applica direttamente a - `self.base_url`, usato come base di ogni richiesta REST V5.""" - from tests.unit.test_settings import _minimal_env - - env = _minimal_env(BYBIT_URL_TESTNET="https://bybit-custom.example.com") - for k, v in env.items(): - monkeypatch.setenv(k, v) - - from cerbero_mcp.exchanges import build_client - from cerbero_mcp.settings import Settings - - s = Settings() - c = await build_client(s, "bybit", "testnet") - assert c.base_url == "https://bybit-custom.example.com" - - -@pytest.mark.asyncio -async def test_alpaca_url_from_env_overrides_default(monkeypatch): - """Alpaca V2 (httpx): `base_url` override applica al solo trading - endpoint; data endpoints (data.alpaca.markets) restano hardcoded.""" - from tests.unit.test_settings import _minimal_env - - env = _minimal_env(ALPACA_URL_TESTNET="https://alpaca-custom.example.com") - for k, v in env.items(): - monkeypatch.setenv(k, v) - - from cerbero_mcp.exchanges import build_client - from cerbero_mcp.settings import Settings - - s = Settings() - c = await build_client(s, "alpaca", "testnet") - try: - assert c.base_url == "https://alpaca-custom.example.com" - finally: - await c.aclose() - - @pytest.mark.asyncio async def test_build_client_unknown_exchange_raises(monkeypatch): from tests.unit.test_settings import _minimal_env diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 76c775e..749c792 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -14,19 +14,11 @@ def _minimal_env(**overrides) -> dict: "DERIBIT_CLIENT_SECRET": "secret", "DERIBIT_URL_LIVE": "https://www.deribit.com/api/v2", "DERIBIT_URL_TESTNET": "https://test.deribit.com/api/v2", - "BYBIT_API_KEY": "k", - "BYBIT_API_SECRET": "s", - "BYBIT_URL_LIVE": "https://api.bybit.com", - "BYBIT_URL_TESTNET": "https://api-testnet.bybit.com", "HYPERLIQUID_WALLET_ADDRESS": "0xabc", "HYPERLIQUID_API_WALLET_ADDRESS": "0xdef", "HYPERLIQUID_PRIVATE_KEY": "0x123", "HYPERLIQUID_URL_LIVE": "https://api.hyperliquid.xyz", "HYPERLIQUID_URL_TESTNET": "https://api.hyperliquid-testnet.xyz", - "ALPACA_API_KEY_ID": "k", - "ALPACA_SECRET_KEY": "s", - "ALPACA_URL_LIVE": "https://api.alpaca.markets", - "ALPACA_URL_TESTNET": "https://paper-api.alpaca.markets", "FRED_API_KEY": "x", "FINNHUB_API_KEY": "y", "CRYPTOPANIC_KEY": "z", @@ -49,8 +41,7 @@ def test_settings_load_minimal(monkeypatch): assert s.testnet_token.get_secret_value() == "t_test_123" assert s.mainnet_token.get_secret_value() == "t_live_456" assert s.deribit.url_testnet.endswith("test.deribit.com/api/v2") - assert s.bybit.max_leverage == 3 - assert s.alpaca.max_leverage == 1 + assert s.deribit.max_leverage == 3 def test_settings_missing_token_fails(monkeypatch, tmp_path):