From 6faf21369d693825c1dc2bba632ccbcaaf091284 Mon Sep 17 00:00:00 2001 From: Adriano Date: Fri, 29 May 2026 10:51:21 +0000 Subject: [PATCH] refactor(V2): get_historical & get_indicators are common-only on /mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the cross-cutting data tools onto the unified interface and remove their per-exchange duplicates. - /mcp/tools/get_historical: single call, `exchange` now optional. Set → that venue's candles (native symbol); omitted → cross-exchange consensus (canonical symbol, median OHLC + div_pct). Folds in the former /mcp-cross consensus, whose router is deleted. - /mcp/tools/get_indicators: new common tool computing sma/rsi/atr/macd/adx over the single-or-consensus series. Computation centralized in common/indicators.py::compute_indicators (was duplicated per exchange). - Remove get_historical from /mcp-deribit and /mcp-hyperliquid; remove get_technical_indicators (deribit) and get_indicators (hyperliquid), including the now-dead client methods, Req schemas and tool wrappers. The client get_historical METHODS stay (used by the unified dispatch). Tests: rewrite cross tests into test_unified (instruments, historical single + consensus + failures, indicators single + consensus); drop the old CrossClient test; tighten app-boot surface assertions (/mcp owns the data tools; no /mcp-cross, no per-exchange historical/indicators). Docs: API_REFERENCE / README / CLAUDE updated; smoke run_unified.sh now covers consensus + indicators. 324 passed, ruff clean. Verified live against Deribit/Hyperliquid. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 19 +- README.md | 60 ++--- docs/API_REFERENCE.md | 136 ++++++----- src/cerbero_mcp/__main__.py | 2 - src/cerbero_mcp/common/indicators.py | 28 +++ src/cerbero_mcp/exchanges/cross/client.py | 211 ++++++++++-------- src/cerbero_mcp/exchanges/cross/tools.py | 80 ++++--- src/cerbero_mcp/exchanges/deribit/client.py | 32 --- src/cerbero_mcp/exchanges/deribit/tools.py | 57 +---- .../exchanges/hyperliquid/client.py | 33 --- .../exchanges/hyperliquid/tools.py | 110 +-------- src/cerbero_mcp/routers/cross.py | 36 --- src/cerbero_mcp/routers/deribit.py | 14 -- src/cerbero_mcp/routers/hyperliquid.py | 14 -- src/cerbero_mcp/routers/unified.py | 22 +- tests/integration/test_app_boot.py | 13 +- tests/smoke/README.md | 4 +- tests/smoke/run_unified.sh | 28 ++- tests/unit/exchanges/cross/test_client.py | 111 --------- tests/unit/exchanges/cross/test_unified.py | 203 ++++++++++++----- 20 files changed, 505 insertions(+), 708 deletions(-) delete mode 100644 src/cerbero_mcp/routers/cross.py delete mode 100644 tests/unit/exchanges/cross/test_client.py diff --git a/CLAUDE.md b/CLAUDE.md index 97a581a..2e9b911 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,18 +59,19 @@ Nota: nel modulo `sentiment` "bybit" resta come **fonte dati pubblica** ## Interfacce dati -- **`/mcp` (comune, raccomandata)** — `get_instruments` con schema uniforme - (ogni elemento porta `exchange`, `fees`, `history_start`, `type`, `tick_size`, - `native`); `get_historical` con `exchange` selezionabile (singolo venue). - Supporta deribit + hyperliquid (IBKR non ancora integrato). -- **`/mcp-cross`** — `get_historical` a consenso multi-exchange (mediana OHLC, - `sources`, `div_pct`). -- **`/mcp-{exchange}`** — router per-exchange legacy, ancora attivi. +- **`/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). +- **`/mcp-{exchange}`** — router per-exchange per il resto (ticker, ordini, + options analytics, …). **NON** espongono più `get_historical`/`get_indicators`. -Convenzione: ogni `get_historical` ritorna la chiave uniforme +Convenzione: `get_historical` ritorna la chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]` (validatore in `common/candles.py`). `fees`/`history_start` sono popolati **live dove -l'upstream li espone** (oggi: Deribit), altrimenti `null`. +l'upstream li espone** (oggi: Deribit), altrimenti `null`. Il calcolo indicatori +è centralizzato in `common/indicators.py::compute_indicators`. ## Convenzioni diff --git a/README.md b/README.md index 743d609..3858328 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ sul token bearer fornito dal client. read-only (Macro, Sentiment). Bybit e Alpaca sono stati ritirati dalla superficie API in V2 (codice archiviato in `old/`) - **Interfaccia comune `/mcp`** trasversale agli exchange: `get_instruments` - con schema uniforme (ogni elemento porta `exchange`, `fees`, - `history_start`) e `get_historical` per-exchange selezionabile + (schema uniforme con `exchange`/`fees`/`history_start`), `get_historical` e + `get_indicators` — singolo exchange o consenso multi-exchange. Storico e + indicatori vivono **solo** qui, non più per-exchange - **Switch testnet/mainnet per-request** tramite header `Authorization: Bearer `: lo stesso container serve entrambi gli ambienti senza riavvii @@ -23,9 +24,9 @@ sul token bearer fornito dal client. override-abili tramite variabili dedicate (`DERIBIT_URL_*`, `HYPERLIQUID_URL_*`, `IBKR_URL_*`) - **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs` -- **Endpoint cross-exchange a consenso** (`/mcp-cross/tools/get_historical`): - fan-out agli exchange che supportano (symbol, asset_class) e consensus - per-bar (mediana OHLC + `div_pct` + `sources`) +- **Consenso multi-exchange** integrato in `/mcp/tools/get_historical`: ometti + `exchange` per il fan-out e il merge per-bar (mediana OHLC + `div_pct` + + `sources`) - **Qualità verificata**: 323 test (unit + integration + smoke), mypy pulito, ruff pulito @@ -92,13 +93,13 @@ non è richiesto sugli endpoint pubblici (`/health`, `/apidocs`, | `GET /apidocs` | Swagger UI (no auth) | | `GET /openapi.json` | Schema OpenAPI 3.1 (no auth) | | `POST /mcp/tools/get_instruments` | Lista strumenti uniforme cross-exchange (deribit, hyperliquid) | -| `POST /mcp/tools/get_historical` | Storico di un singolo exchange selezionabile | +| `POST /mcp/tools/get_historical` | Storico comune: singolo exchange o consenso multi-exchange | +| `POST /mcp/tools/get_indicators` | Indicatori tecnici sulla stessa serie (single o consenso) | | `POST /mcp-deribit/tools/{tool}` | Tool exchange Deribit | | `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid | | `POST /mcp-ibkr/tools/{tool}` | Tool exchange Interactive Brokers | | `POST /mcp-macro/tools/{tool}` | Tool macro/market data | | `POST /mcp-sentiment/tools/{tool}` | Tool sentiment/news | -| `POST /mcp-cross/tools/get_historical` | Storico aggregato cross-exchange con consensus + divergenza | | `GET /admin/audit` | Query dell'audit log JSONL (bearer richiesto, no X-Bot-Tag) | ## Observability @@ -177,14 +178,14 @@ Tecnici (`sma`, `rsi`, `macd`, `atr`, `adx`), volatilità (`vol_cone`, ### Deribit DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol, -indicatori tecnici, 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, -place_combo_order. +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`.) ### Hyperliquid -Account summary, positions, orderbook, historical, indicators, funding -rate, basis spot/perp, place_order, set_stop_loss, set_take_profit. +Account summary, positions, orderbook, funding rate, basis spot/perp, +place_order, set_stop_loss, set_take_profit. (Storico/indicatori → `/mcp`.) ### IBKR (Interactive Brokers) Account, positions, activities, ticker, bars, snapshot, option chain, @@ -204,24 +205,23 @@ OI history, get_funding_arb_spread, get_liquidation_heatmap, get_cointegration_pairs. ### Unified (`/mcp`) — interfaccia comune -`get_instruments` ritorna una lista strumenti **uniforme**: ogni elemento -porta in sé `exchange`, `fees` (maker/taker, popolati live da Deribit, `null` -dove l'exchange non li espone per-strumento), `history_start` (data inizio -storico, live da Deribit), oltre a `type`, `tick_size` e un blocco `native` -lossless. Con `exchange` opzionale filtra una singola sorgente; se omesso fa -fan-out su tutti gli exchange integrati. `get_historical` è generalizzato a -`{exchange, instrument, interval, start_date, end_date}` e ritorna le candele -del **singolo exchange scelto**. Exchange supportati: `deribit`, `hyperliquid` -(IBKR non ancora integrato — usa `/mcp-ibkr`). +Espone gli **unici** tool dati trasversali (storico e indicatori vivono solo +qui, non per-exchange). Exchange supportati: `deribit`, `hyperliquid` (IBKR non +ancora integrato — usa `/mcp-ibkr`). -### Cross (storico a consenso) -`get_historical` aggrega le candele dello stesso simbolo da tutti gli -exchange che lo supportano e ritorna una serie consensus: la chiusura è -la mediana, `sources` è il numero di exchange che hanno contribuito al -bar e `div_pct = (max-min)/median` segnala il disaccordo tra fonti — un -quality gate per i bot. Crypto: BTC/ETH via Hyperliquid + Deribit, SOL via -Hyperliquid. In caso di fallimento parziale ritorna i dati disponibili più -`failed_sources`; se *tutti* gli upstream falliscono → HTTP 502 retryable. +- **`get_instruments`** — lista strumenti **uniforme**: ogni elemento porta in + 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. +- **`get_historical`** — `{exchange?, instrument, interval, start_date, + end_date}`. Con `exchange` ritorna il **singolo** exchange; **omettendolo** + fa il **consenso** multi-exchange (mediana OHLC, `sources`, + `div_pct=(max-min)/median` come quality gate) usando il simbolo canonico + (BTC/ETH via Hyperliquid+Deribit, SOL via Hyperliquid). Fallimento parziale → + `failed_sources`; tutti gli upstream giù → HTTP 502 retryable. +- **`get_indicators`** — stessa selezione sorgente; calcola sma/rsi/atr/macd/adx + sulle candele risultanti. ## Deploy su VPS con Traefik diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 2070c71..7369908 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -50,16 +50,16 @@ 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-hyperliquid/tools/*` | 16 | exchange (perp DEX) | L1 signing, leverage cap | +| `/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-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 | -I router per-exchange (`/mcp-deribit`, …) restano disponibili durante la -transizione; per nuovi consumatori è raccomandata l'interfaccia comune `/mcp`. +`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}`. --- @@ -101,23 +101,46 @@ Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}] }`. > `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 +### `get_historical` — unica chiamata storica comune -Request body: +`get_historical` esiste **solo qui** (non più per-exchange). Request body: | Campo | Tipo | Default | Note | |---|---|---|---| -| `exchange` | `"deribit"\|"hyperliquid"` | — | **obbligatorio**: sceglie la sorgente | -| `instrument` | str | — | simbolo nativo sull'exchange scelto | +| `exchange` | `"deribit"\|"hyperliquid"` \| null | `null` | set → singolo exchange · omesso → **consenso** multi-exchange | +| `instrument` | str | — | simbolo *nativo* (modalità singola) o *canonico* `BTC/ETH/SOL` (consenso) | | `interval` | str | `"1h"` | `1m`/`5m`/`15m`/`1h`/`4h`/`1d` | | `start_date` | str | — | `YYYY-MM-DD` o ISO datetime (UTC) | | `end_date` | str | — | idem | +| `asset_class` | str | `"crypto"` | routing del consenso (solo se `exchange` omesso) | -Risposta: `{ "exchange", "instrument", "interval", "candles": [...] }` con la -chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`. +- **Singolo** (`exchange` valorizzato) → `{ exchange, instrument, interval, candles, sources_used:[exchange] }`. +- **Consenso** (`exchange` omesso) → fan-out agli exchange che supportano il + simbolo + merge per-bar: `{ exchange:null, instrument, asset_class, interval, + candles, sources_used, failed_sources }`. Ogni bar consenso porta `sources` + (n. exchange) e `div_pct = (max-min)/median` come quality gate. Se *tutti* + gli upstream falliscono → `502` retryable. -Per il **consenso** multi-exchange (mediana cross-venue) usare invece -`/mcp-cross/tools/get_historical` (sezione 11). +Tutte le candele usano la chiave uniforme +`candles: [{timestamp(ms), open, high, low, close, volume}]`. + +> **Semantica `start_date`/`end_date`** (UTC). Date nude `YYYY-MM-DD`: +> `start_date` = `00:00:00`, `end_date` = `23:59:59.999` dello stesso giorno +> (giorno intero incluso → `end_date=oggi` dà l'intraday fino all'ultima candela +> chiusa). Timestamp con orario (`2026-05-28T14:00:00`) sono onorati così come +> sono (naive = UTC). Range ampi su Deribit sono paginati internamente (no cap +> ~5000), così `start_date` è sempre rispettato. + +### `get_indicators` — indicatori sulla stessa serie + +Stessa selezione sorgente di `get_historical` (single o consensus); calcola +indicatori tecnici sulle candele risultanti. Request body: i campi di +`get_historical` più `indicators` (lista o stringa CSV, es. `["rsi","atr"]` o +`"rsi,atr,macd"`; default `["rsi","atr","macd","adx"]`). Supportati: +`sma`, `rsi`, `atr`, `macd`, `adx` (nomi ignoti → `null`). + +Risposta: `{ exchange, instrument, interval, indicators:{...}, candles_used, +sources_used }` (+ `failed_sources` in modalità consenso). --- @@ -131,20 +154,11 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece - `get_ticker`, `get_ticker_batch` - `get_instruments` - `get_orderbook`, `get_orderbook_imbalance` -- `get_historical` (chiave `candles` uniforme) - `get_trade_history` -> **`get_historical` — semantica di `start_date` / `end_date`** (vale anche per -> `get_dvol` e `get_technical_indicators`). Tutti i timestamp sono in **UTC**. -> - Date nude `YYYY-MM-DD`: `start_date` = `00:00:00`, `end_date` = -> `23:59:59.999` dello stesso giorno (inclusivo dell'intero giorno). Quindi -> `end_date = oggi` restituisce l'intraday fino all'ultima candela chiusa, non -> solo fino a mezzanotte. -> - Sono accettati anche timestamp con orario (`2026-05-28T14:00:00`), onorati -> così come sono per finestre intraday precise; un valore *naive* è trattato -> come UTC. -> - Nessun cap a ~5000 righe: i range ampi sono **paginati** internamente -> finestra-per-finestra, così `start_date` è sempre rispettato. +> 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"`. ### Account - `get_positions` @@ -160,7 +174,6 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece - `find_by_delta`, `calculate_spread_payoff` ### Technicals -- `get_technical_indicators` - `run_backtest` ### Write (richiede leverage cap) @@ -178,15 +191,15 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece ### Market data - `get_markets`, `get_ticker`, `get_orderbook` -- `get_historical`, `get_trade_history` +- `get_trade_history` - `get_funding_rate`, `basis_spot_perp` +> Storico e indicatori: usa `/mcp/tools/get_historical` e +> `/mcp/tools/get_indicators` (sezione 5) con `exchange="hyperliquid"`. + ### Account - `get_positions`, `get_account_summary`, `get_open_orders` -### Technicals -- `get_indicators` - ### Write (L1 signing + leverage cap) - `place_order`, `cancel_order` - `set_stop_loss`, `set_take_profit` @@ -269,27 +282,7 @@ unattended. Setup one-time per ambiente (paper + live) — vedi sezione --- -## 11. `/mcp-cross/tools/*` - -### `get_historical` -Aggregatore cross-exchange. Fan-out a tutti gli exchange che supportano -`(symbol, asset_class)`, poi consensus per-bar: - -- `close` = mediana delle close -- `open`/`high`/`low` = mediana corrispondente -- `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 via Hyperliquid + Deribit; -SOL via Hyperliquid. - -In caso di fallimento parziale ritorna i bar disponibili più -`failed_sources: [...]`. Se *tutti* gli upstream falliscono → `502 -Bad Gateway` retryable. - ---- - -## 12. Observability +## 11. Observability ### Request log (`mcp.request`) Una riga JSON per richiesta con: `request_id`, `method`, `path`, @@ -310,15 +303,21 @@ Ogni risposta JSON sotto `/tools/*` riceve dal middleware un campo --- -## 13. Esempio chiamata +## 12. Esempio chiamata ```bash -# Interfaccia comune: storico Deribit +# Singolo exchange: 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 '{"exchange":"deribit","instrument":"BTC-PERPETUAL","interval":"1h","start_date":"2026-05-01","end_date":"2026-05-29"}' + +# Consenso multi-exchange: ometti "exchange", usa il simbolo canonico +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 '{"instrument":"BTC","interval":"1h","start_date":"2026-05-01","end_date":"2026-05-29"}' ``` Per lo schema completo dei body di richiesta e risposta: @@ -326,24 +325,23 @@ Per lo schema completo dei body di richiesta e risposta: --- -## 14. Discovery strumenti & schemi candele +## 13. 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}]`. +Schemi verificati sulle risposte live. `get_historical` ritorna 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 | +### Tool dati comuni (`/mcp`) +| Tool | Request body | Risposta (campi principali) | |---|---|---| -| `/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) | +| `get_instruments` | `{exchange?, currency:"BTC", kind:"future"}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` | +| `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`, +`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. ### Convenzione simboli Deribit - **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL` diff --git a/src/cerbero_mcp/__main__.py b/src/cerbero_mcp/__main__.py index f9a82d1..0f44054 100644 --- a/src/cerbero_mcp/__main__.py +++ b/src/cerbero_mcp/__main__.py @@ -21,7 +21,6 @@ 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 ( - cross, deribit, hyperliquid, ibkr, @@ -69,7 +68,6 @@ def _make_app(settings: Settings) -> FastAPI: 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()) diff --git a/src/cerbero_mcp/common/indicators.py b/src/cerbero_mcp/common/indicators.py index e4775ce..07d66db 100644 --- a/src/cerbero_mcp/common/indicators.py +++ b/src/cerbero_mcp/common/indicators.py @@ -414,3 +414,31 @@ def var_cvar(returns: list[float], confidences: list[float] | None = None) -> di out[f"var_{tag}"] = var out[f"cvar_{tag}"] = cvar return out + + +def compute_indicators( + candles: list[dict], names: list[str] +) -> dict[str, object]: + """Compute the requested technical indicators over an OHLCV candle list. + + Shared by the unified /mcp interface. Unknown indicator names map to None. + """ + closes = [c["close"] for c in candles] + highs = [c["high"] for c in candles] + lows = [c["low"] for c in candles] + result: dict[str, object] = {} + for indicator in names: + name = str(indicator).lower() + if name == "sma": + result["sma"] = sma(closes, 20) + elif name == "rsi": + result["rsi"] = rsi(closes) + elif name == "atr": + result["atr"] = atr(highs, lows, closes) + elif name == "macd": + result["macd"] = macd(closes) + elif name == "adx": + result["adx"] = adx(highs, lows, closes) + else: + result[name] = None + return result diff --git a/src/cerbero_mcp/exchanges/cross/client.py b/src/cerbero_mcp/exchanges/cross/client.py index 8c0a9bb..32f077e 100644 --- a/src/cerbero_mcp/exchanges/cross/client.py +++ b/src/cerbero_mcp/exchanges/cross/client.py @@ -1,8 +1,10 @@ -"""Cross-exchange historical aggregator. +"""Unified common interface over the integrated venues. -Fan-out a canonical (symbol, asset_class, interval, start, end) request to -every active exchange that supports the pair, then merge the results into -a single consensus candle series with per-bar divergence metrics. +A single client backs the `/mcp` router. `get_instruments` returns one +uniform instrument list (each row carries its own `exchange`, `fees` and +`history_start`). `get_historical` and `get_indicators` are exposed only +here (not per-exchange): pass `exchange` to target a single venue, or omit +it to get a cross-exchange consensus series (median OHLC + divergence). """ from __future__ import annotations @@ -11,6 +13,7 @@ from typing import Any, Literal, Protocol from fastapi import HTTPException +from cerbero_mcp.common.indicators import compute_indicators from cerbero_mcp.exchanges.cross.consensus import merge_candles from cerbero_mcp.exchanges.cross.instruments import ( normalize_deribit, @@ -56,89 +59,15 @@ _DISPATCH = { } -class CrossClient: - def __init__(self, registry: _Registry, *, env: Environment): - self._registry = registry - self._env = env - - async def _fetch_one( - self, exchange: str, native_sym: str, native_interval: str, - start: str, end: str, - ) -> tuple[str, list[dict[str, Any]] | Exception]: - try: - client = await self._registry.get(exchange, self._env) - resp = await _DISPATCH[exchange]( - client, native_sym, native_interval, start, end, - ) - return exchange, resp.get("candles", []) - except Exception as e: # noqa: BLE001 - return exchange, e - - async def get_historical( - self, *, symbol: str, asset_class: str, interval: str, - start_date: str, end_date: str, - ) -> dict[str, Any]: - sources = get_sources(asset_class, symbol) - if not sources: - raise HTTPException( - status_code=400, - detail=f"unsupported symbol/asset_class: {symbol} ({asset_class})", - ) - if interval not in supported_intervals(): - raise HTTPException( - status_code=400, - detail=f"unsupported interval: {interval}; " - f"supported: {supported_intervals()}", - ) - - tasks = [ - self._fetch_one( - ex, - to_native_symbol(asset_class, symbol, ex), - to_native_interval(interval, ex), - start_date, end_date, - ) - for ex in sources - ] - results = await asyncio.gather(*tasks) - - by_source: dict[str, 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: - by_source[ex] = payload - - if not by_source: - raise HTTPException( - status_code=502, - detail={"error": "all sources failed", "failed_sources": failed}, - ) - - return { - "symbol": symbol.upper(), - "asset_class": asset_class, - "interval": interval, - "candles": merge_candles(by_source), - "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`. - """ + """Single common interface over the integrated venues (deribit, hyperliquid).""" def __init__(self, registry: _Registry, *, env: Environment): self._registry = registry self._env = env + # ── instruments ────────────────────────────────────────────────── + async def _instruments_one( self, exchange: str, currency: str, kind: str | None, ) -> tuple[str, list[dict[str, Any]] | Exception]: @@ -186,8 +115,23 @@ class UnifiedClient: return {"instruments": instruments, "failed_sources": failed} - async def get_historical( - self, *, exchange: str, instrument: str, interval: str, + # ── historical ─────────────────────────────────────────────────── + + async def _fetch_one( + self, exchange: str, native_sym: str, native_interval: str, + start: str, end: str, + ) -> tuple[str, list[dict[str, Any]] | Exception]: + try: + client = await self._registry.get(exchange, self._env) + resp = await _DISPATCH[exchange]( + client, native_sym, native_interval, start, end, + ) + return exchange, resp.get("candles", []) + except Exception as e: # noqa: BLE001 + return exchange, e + + async def _historical_single( + self, exchange: str, instrument: str, interval: str, start_date: str, end_date: str, ) -> dict[str, Any]: if exchange not in _DISPATCH: @@ -196,12 +140,6 @@ class UnifiedClient: 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]( @@ -212,4 +150,99 @@ class UnifiedClient: "instrument": instrument, "interval": interval, "candles": resp.get("candles", []), + "sources_used": [exchange], } + + async def _historical_consensus( + self, instrument: str, asset_class: str, interval: str, + start_date: str, end_date: str, + ) -> dict[str, Any]: + sources = get_sources(asset_class, instrument) + if not sources: + raise HTTPException( + status_code=400, + detail=f"unsupported symbol/asset_class for consensus: " + f"{instrument} ({asset_class})", + ) + results = await asyncio.gather(*( + self._fetch_one( + ex, + to_native_symbol(asset_class, instrument, ex), + to_native_interval(interval, ex), + start_date, end_date, + ) + for ex in sources + )) + + by_source: dict[str, 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: + by_source[ex] = payload + + if not by_source: + raise HTTPException( + status_code=502, + detail={"error": "all sources failed", "failed_sources": failed}, + ) + + return { + "exchange": None, + "instrument": instrument.upper(), + "asset_class": asset_class, + "interval": interval, + "candles": merge_candles(by_source), + "sources_used": sorted(by_source.keys()), + "failed_sources": failed, + } + + async def get_historical( + self, *, exchange: str | None = None, instrument: str, interval: str, + start_date: str, end_date: str, asset_class: str = "crypto", + ) -> dict[str, Any]: + """Single common historical call. + + `exchange` set → that venue's candles (instrument = native symbol). + `exchange` omitted → cross-exchange consensus (instrument = canonical + symbol, e.g. BTC/ETH/SOL). + """ + if interval not in supported_intervals(): + raise HTTPException( + status_code=400, + detail=f"unsupported interval: {interval}; " + f"supported: {supported_intervals()}", + ) + if exchange is None: + return await self._historical_consensus( + instrument, asset_class, interval, start_date, end_date, + ) + return await self._historical_single( + exchange, instrument, interval, start_date, end_date, + ) + + # ── indicators ─────────────────────────────────────────────────── + + async def get_indicators( + self, *, indicators: list[str], exchange: str | None = None, + instrument: str, interval: str, start_date: str, end_date: str, + asset_class: str = "crypto", + ) -> dict[str, Any]: + """Technical indicators over the historical series of the chosen + source (single exchange) or the consensus series (exchange omitted).""" + hist = await self.get_historical( + exchange=exchange, instrument=instrument, interval=interval, + start_date=start_date, end_date=end_date, asset_class=asset_class, + ) + out: dict[str, Any] = { + "exchange": hist.get("exchange"), + "instrument": instrument, + "interval": interval, + "indicators": compute_indicators(hist["candles"], indicators), + "candles_used": len(hist["candles"]), + "sources_used": hist.get("sources_used", []), + } + if "failed_sources" in hist: + out["failed_sources"] = hist["failed_sources"] + return out diff --git a/src/cerbero_mcp/exchanges/cross/tools.py b/src/cerbero_mcp/exchanges/cross/tools.py index 3b9f682..d1317e0 100644 --- a/src/cerbero_mcp/exchanges/cross/tools.py +++ b/src/cerbero_mcp/exchanges/cross/tools.py @@ -1,48 +1,67 @@ -"""Pydantic schemas + thin tool wrappers for the /mcp-cross and /mcp routers.""" +"""Pydantic schemas + thin tool wrappers for the unified /mcp router.""" from __future__ import annotations from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, field_validator -from cerbero_mcp.exchanges.cross.client import CrossClient, UnifiedClient +from cerbero_mcp.exchanges.cross.client import UnifiedClient AssetClass = Literal["crypto"] Exchange = Literal["deribit", "hyperliquid"] -class GetHistoricalReq(BaseModel): - symbol: str - asset_class: AssetClass = "crypto" - interval: str = "1h" - start_date: str - end_date: str - - -async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict: - return await client.get_historical( - symbol=params.symbol, - asset_class=params.asset_class, - interval=params.interval, - start_date=params.start_date, - end_date=params.end_date, +def _coerce_indicators(v): + """Accept a list or a JSON/comma-separated string of indicator names.""" + 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'" ) -# ── 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 +class GetHistoricalReq(BaseModel): + # exchange set → single venue (instrument = native symbol) + # exchange omitted → cross-exchange consensus (instrument = canonical symbol) + exchange: Exchange | None = None instrument: str interval: str = "1h" start_date: str end_date: str + asset_class: AssetClass = "crypto" # consensus routing only + + +class GetIndicatorsReq(BaseModel): + exchange: Exchange | None = None + instrument: str + indicators: list[str] = ["rsi", "atr", "macd", "adx"] + interval: str = "1h" + start_date: str + end_date: str + asset_class: AssetClass = "crypto" + + @field_validator("indicators", mode="before") + @classmethod + def _indicators(cls, v): + return _coerce_indicators(v) async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> dict: @@ -53,13 +72,24 @@ async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> d ) -async def unified_get_historical( - client: UnifiedClient, params: UnifiedGetHistoricalReq -) -> dict: +async def get_historical(client: UnifiedClient, params: GetHistoricalReq) -> 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, + asset_class=params.asset_class, + ) + + +async def get_indicators(client: UnifiedClient, params: GetIndicatorsReq) -> dict: + return await client.get_indicators( + indicators=params.indicators, + exchange=params.exchange, + instrument=params.instrument, + interval=params.interval, + start_date=params.start_date, + end_date=params.end_date, + asset_class=params.asset_class, ) diff --git a/src/cerbero_mcp/exchanges/deribit/client.py b/src/cerbero_mcp/exchanges/deribit/client.py index 6654099..6ec42de 100644 --- a/src/cerbero_mcp/exchanges/deribit/client.py +++ b/src/cerbero_mcp/exchanges/deribit/client.py @@ -8,7 +8,6 @@ from typing import Any from fastapi import HTTPException -from cerbero_mcp.common import indicators as ind from cerbero_mcp.common import microstructure as micro from cerbero_mcp.common import options as opt from cerbero_mcp.common.candles import validate_candles @@ -1602,37 +1601,6 @@ class DeribitClient: "testnet": self.testnet, } - async def get_technical_indicators( - self, - instrument: str, - indicators: list[str], - start_date: str, - end_date: str, - resolution: str = "1h", - ) -> dict: - historical = await self.get_historical(instrument, start_date, end_date, resolution) - candles = historical.get("candles", []) - closes = [c["close"] for c in candles] - highs = [c["high"] for c in candles] - lows = [c["low"] for c in candles] - - result: dict[str, Any] = {} - for indicator in indicators: - name = indicator.lower() - if name == "sma": - result["sma"] = ind.sma(closes, 20) - elif name == "rsi": - result["rsi"] = ind.rsi(closes) - elif name == "atr": - result["atr"] = ind.atr(highs, lows, closes) - elif name == "macd": - result["macd"] = ind.macd(closes) - elif name == "adx": - result["adx"] = ind.adx(highs, lows, closes) - else: - result[name] = None - return result - # ── Write tools ────────────────────────────────────────────── async def place_order( diff --git a/src/cerbero_mcp/exchanges/deribit/tools.py b/src/cerbero_mcp/exchanges/deribit/tools.py index b92cba6..9452b19 100644 --- a/src/cerbero_mcp/exchanges/deribit/tools.py +++ b/src/cerbero_mcp/exchanges/deribit/tools.py @@ -10,7 +10,7 @@ from __future__ import annotations import contextlib from typing import Any -from pydantic import BaseModel, field_validator, model_validator +from pydantic import BaseModel, model_validator from cerbero_mcp.exchanges.deribit.client import DeribitClient from cerbero_mcp.exchanges.deribit.leverage_cap import ( @@ -86,13 +86,6 @@ class GetTradeHistoryReq(BaseModel): 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 @@ -168,36 +161,6 @@ class FindByDeltaReq(BaseModel): 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" @@ -343,12 +306,6 @@ async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) - 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 @@ -448,18 +405,6 @@ async def get_realized_vol(client: DeribitClient, params: GetRealizedVolReq) -> 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) === diff --git a/src/cerbero_mcp/exchanges/hyperliquid/client.py b/src/cerbero_mcp/exchanges/hyperliquid/client.py index ac62bcb..c88eaa6 100644 --- a/src/cerbero_mcp/exchanges/hyperliquid/client.py +++ b/src/cerbero_mcp/exchanges/hyperliquid/client.py @@ -26,7 +26,6 @@ from eth_account import Account from eth_account.messages import encode_typed_data from eth_utils import keccak, to_hex -from cerbero_mcp.common import indicators as ind from cerbero_mcp.common.candles import validate_candles from cerbero_mcp.common.http import async_client @@ -555,38 +554,6 @@ class HyperliquidClient: "history": history, } - async def get_indicators( - self, - instrument: str, - indicators: list[str], - start_date: str, - end_date: str, - resolution: str = "1h", - ) -> dict[str, Any]: - """Compute technical indicators over OHLCV data.""" - historical = await self.get_historical(instrument, start_date, end_date, resolution) - candles = historical.get("candles", []) - closes = [c["close"] for c in candles] - highs = [c["high"] for c in candles] - lows = [c["low"] for c in candles] - - result: dict[str, Any] = {} - for indicator in indicators: - name = indicator.lower() - if name == "sma": - result["sma"] = ind.sma(closes, 20) - elif name == "rsi": - result["rsi"] = ind.rsi(closes) - elif name == "atr": - result["atr"] = ind.atr(highs, lows, closes) - elif name == "macd": - result["macd"] = ind.macd(closes) - elif name == "adx": - result["adx"] = ind.adx(highs, lows, closes) - else: - result[name] = None - return result - # ── Write tools (signed) ─────────────────────────────────── @staticmethod diff --git a/src/cerbero_mcp/exchanges/hyperliquid/tools.py b/src/cerbero_mcp/exchanges/hyperliquid/tools.py index 4e25de4..f9b072d 100644 --- a/src/cerbero_mcp/exchanges/hyperliquid/tools.py +++ b/src/cerbero_mcp/exchanges/hyperliquid/tools.py @@ -9,7 +9,7 @@ from __future__ import annotations from typing import Any -from pydantic import BaseModel, field_validator, model_validator +from pydantic import BaseModel from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient from cerbero_mcp.exchanges.hyperliquid.leverage_cap import ( @@ -45,36 +45,6 @@ class GetTradeHistoryReq(BaseModel): limit: int = 100 -class GetHistoricalReq(BaseModel): - instrument: str | None = None - asset: str | None = None - start_date: str | None = None - end_date: str | None = None - resolution: str = "1h" - interval: str | None = None - limit: int = 50 - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def _normalize(self): - from datetime import UTC, datetime, timedelta - sym = self.instrument or self.asset - if not sym: - raise ValueError("instrument (or asset) is required") - self.instrument = sym - if self.interval: - self.resolution = self.interval - if not self.end_date: - self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - if not self.start_date: - days = max(1, self.limit // 6) - self.start_date = ( - datetime.now(UTC) - timedelta(days=days) - ).strftime("%Y-%m-%dT%H:%M:%S") - return self - - class GetOpenOrdersReq(BaseModel): pass @@ -87,58 +57,6 @@ class BasisSpotPerpReq(BaseModel): asset: str -class GetIndicatorsReq(BaseModel): - instrument: str | None = None - asset: str | None = None - indicators: list[str] = ["rsi", "atr", "macd", "adx"] - start_date: str | None = None - end_date: str | None = None - resolution: str = "1h" - interval: str | None = None - limit: int = 50 - - model_config = {"extra": "allow"} - - @model_validator(mode="after") - def _normalize(self): - from datetime import UTC, datetime, timedelta - sym = self.instrument or self.asset - if not sym: - raise ValueError("instrument (or asset) is required") - self.instrument = sym - if self.interval: - self.resolution = self.interval - if not self.end_date: - self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S") - if not self.start_date: - days = max(2, self.limit // 6) - self.start_date = ( - datetime.now(UTC) - timedelta(days=days) - ).strftime("%Y-%m-%dT%H:%M:%S") - return self - - @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'" - ) - - # === Schemas: writes === @@ -244,17 +162,6 @@ async def get_trade_history( return await client.get_trade_history(params.limit) -async def get_historical( - client: HyperliquidClient, params: GetHistoricalReq -) -> dict: - assert params.instrument is not None # validator garantisce non-None - assert params.start_date is not None # validator garantisce non-None - assert params.end_date is not None # validator garantisce non-None - return await client.get_historical( - params.instrument, params.start_date, params.end_date, params.resolution - ) - - async def get_open_orders( client: HyperliquidClient, params: GetOpenOrdersReq ) -> list[dict]: @@ -273,21 +180,6 @@ async def basis_spot_perp( return await client.basis_spot_perp(params.asset) -async def get_indicators( - client: HyperliquidClient, params: GetIndicatorsReq -) -> dict: - assert params.instrument is not None # validator garantisce non-None - assert params.start_date is not None # validator garantisce non-None - assert params.end_date is not None # validator garantisce non-None - return await client.get_indicators( - params.instrument, - params.indicators, - params.start_date, - params.end_date, - params.resolution, - ) - - # === Tools (writes) === diff --git a/src/cerbero_mcp/routers/cross.py b/src/cerbero_mcp/routers/cross.py deleted file mode 100644 index 16ab0e3..0000000 --- a/src/cerbero_mcp/routers/cross.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Router /mcp-cross/* — historical data with cross-exchange consensus.""" -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 CrossClient - -Environment = Literal["testnet", "mainnet"] - - -def get_environment(request: Request) -> Environment: - return cast(Environment, request.state.environment) - - -def get_cross_client( - request: Request, env: Environment = Depends(get_environment), -) -> CrossClient: - registry: ClientRegistry = request.app.state.registry - return CrossClient(registry, env=env) - - -def make_router() -> APIRouter: - r = APIRouter(prefix="/mcp-cross", tags=["cross"]) - - @r.post("/tools/get_historical") - async def _get_historical( - params: t.GetHistoricalReq, - client: CrossClient = Depends(get_cross_client), - ): - return await t.get_historical(client, params) - - return r diff --git a/src/cerbero_mcp/routers/deribit.py b/src/cerbero_mcp/routers/deribit.py index 62bdfea..fe95cf2 100644 --- a/src/cerbero_mcp/routers/deribit.py +++ b/src/cerbero_mcp/routers/deribit.py @@ -115,13 +115,6 @@ def make_router() -> APIRouter: ): return await t.get_trade_history(client, params) - @r.post("/tools/get_historical") - async def _get_historical( - params: t.GetHistoricalReq, - client: DeribitClient = Depends(get_deribit_client), - ): - return await t.get_historical(client, params) - @r.post("/tools/get_dvol") async def _get_dvol( params: t.GetDvolReq, @@ -234,13 +227,6 @@ def make_router() -> APIRouter: ): return await t.get_realized_vol(client, params) - @r.post("/tools/get_technical_indicators") - async def _get_technical_indicators( - params: t.GetIndicatorsReq, - client: DeribitClient = Depends(get_deribit_client), - ): - return await t.get_technical_indicators(client, params) - # === WRITE tools (richiedono creds per leverage cap / audit) === @r.post("/tools/place_order") diff --git a/src/cerbero_mcp/routers/hyperliquid.py b/src/cerbero_mcp/routers/hyperliquid.py index 268a483..f6f8176 100644 --- a/src/cerbero_mcp/routers/hyperliquid.py +++ b/src/cerbero_mcp/routers/hyperliquid.py @@ -93,13 +93,6 @@ def make_router() -> APIRouter: ): return await t.get_trade_history(client, params) - @r.post("/tools/get_historical") - async def _get_historical( - params: t.GetHistoricalReq, - client: HyperliquidClient = Depends(get_hyperliquid_client), - ): - return await t.get_historical(client, params) - @r.post("/tools/get_open_orders") async def _get_open_orders( params: t.GetOpenOrdersReq, @@ -121,13 +114,6 @@ def make_router() -> APIRouter: ): return await t.basis_spot_perp(client, params) - @r.post("/tools/get_indicators") - async def _get_indicators( - params: t.GetIndicatorsReq, - client: HyperliquidClient = Depends(get_hyperliquid_client), - ): - return await t.get_indicators(client, params) - # === WRITE tools (richiedono creds per leverage cap / audit) === @r.post("/tools/place_order") diff --git a/src/cerbero_mcp/routers/unified.py b/src/cerbero_mcp/routers/unified.py index c7411c4..7bc4bed 100644 --- a/src/cerbero_mcp/routers/unified.py +++ b/src/cerbero_mcp/routers/unified.py @@ -1,10 +1,11 @@ """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. +The data tools live here ONLY (not per-exchange): +- `get_instruments` — uniform instrument list (each row carries its own + `exchange`, `fees`, `history_start`). +- `get_historical` — candles from one chosen exchange, or cross-exchange + consensus when `exchange` is omitted. +- `get_indicators` — technical indicators over that same series. """ from __future__ import annotations @@ -42,9 +43,16 @@ def make_router() -> APIRouter: @r.post("/tools/get_historical") async def _get_historical( - params: t.UnifiedGetHistoricalReq, + params: t.GetHistoricalReq, client: UnifiedClient = Depends(get_unified_client), ): - return await t.unified_get_historical(client, params) + return await t.get_historical(client, params) + + @r.post("/tools/get_indicators") + async def _get_indicators( + params: t.GetIndicatorsReq, + client: UnifiedClient = Depends(get_unified_client), + ): + return await t.get_indicators(client, params) return r diff --git a/tests/integration/test_app_boot.py b/tests/integration/test_app_boot.py index 0a3393e..fb39549 100644 --- a/tests/integration/test_app_boot.py +++ b/tests/integration/test_app_boot.py @@ -26,8 +26,17 @@ def test_app_boots_and_health_responds(monkeypatch): assert any(p.startswith("/mcp-hyperliquid/") 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) + # Unified common interface owns the cross-cutting data tools + 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 + 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 + assert "/mcp-hyperliquid/tools/get_indicators" not in paths + # The standalone consensus router has been folded into /mcp + assert not any(p.startswith("/mcp-cross/") 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) diff --git a/tests/smoke/README.md b/tests/smoke/README.md index 4b0996a..2908e5b 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -15,7 +15,9 @@ testnet venga instradato (deribit `get_ticker BTC-PERPETUAL`). Verifica end-to-end contro Deribit/Hyperliquid (endpoint pubblici): - `get_instruments exchange=deribit` → `fees` e `history_start` popolati live - `get_instruments` fan-out → contribuiscono sia deribit sia hyperliquid -- `get_historical` deribit + hyperliquid → candele non vuote, schema uniforme +- `get_historical` singolo (deribit, hyperliquid) → candele non vuote +- `get_historical` consenso (senza `exchange`) → `sources_used` multipli + `div_pct` +- `get_indicators` singolo + consenso → indicatori calcolati - `get_historical exchange=bybit` → `422` (rifiutato a livello schema) Variabili di ambiente: diff --git a/tests/smoke/run_unified.sh b/tests/smoke/run_unified.sh index 59ba99e..34d8945 100755 --- a/tests/smoke/run_unified.sh +++ b/tests/smoke/run_unified.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Smoke test interfaccia comune /mcp contro un server vivo + upstream reali. -# get_instruments e get_historical di Deribit/Hyperliquid sono pubblici, ma il -# bearer (testnet o mainnet) e X-Bot-Tag sono comunque richiesti dall'auth. +# get_instruments/get_historical/get_indicators di Deribit/Hyperliquid sono +# pubblici, ma bearer (testnet o mainnet) e X-Bot-Tag sono comunque richiesti. set -euo pipefail PORT="${PORT:-9000}" @@ -40,19 +40,31 @@ post() { echo "==> get_instruments exchange=deribit (fees + history_start live)" post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"future"}' 200 \ - "assert d['instruments'], 'no 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; print(' ', i['symbol'], i['fees'], i['history_start'])" 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), '| failed:', d['failed_sources'])" + "exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs))" -echo "==> get_historical deribit BTC-PERPETUAL 1h" +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'], 'no candles'; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))" + "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']))" -echo "==> get_historical hyperliquid BTC 1h" +echo "==> get_historical SINGLE hyperliquid BTC 1h" post "/mcp/tools/get_historical" "{\"exchange\":\"hyperliquid\",\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \ - "assert d['exchange']=='hyperliquid'; assert d['candles'], 'no candles'; print(' candles:', len(d['candles']))" + "assert d['exchange']=='hyperliquid'; assert d['candles']; print(' candles:', len(d['candles']))" + +echo "==> get_historical CONSENSUS (no exchange) BTC 1h" +post "/mcp/tools/get_historical" "{\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \ + "assert d['exchange'] is None; assert {'deribit','hyperliquid'} <= set(d['sources_used']), d['sources_used']; assert 'div_pct' in d['candles'][0]; print(' sources:', d['sources_used'], '| candles:', len(d['candles']))" + +echo "==> get_indicators SINGLE deribit BTC-PERPETUAL" +post "/mcp/tools/get_indicators" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"indicators\":[\"rsi\",\"atr\",\"macd\"],\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \ + "assert set(d['indicators']) == {'rsi','atr','macd'}; print(' rsi:', d['indicators']['rsi'], '| candles_used:', d['candles_used'])" + +echo "==> get_indicators CONSENSUS BTC" +post "/mcp/tools/get_indicators" "{\"instrument\":\"BTC\",\"indicators\":\"rsi,atr\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \ + "assert d['exchange'] is None; assert 'rsi' in d['indicators']; print(' sources:', d['sources_used'])" echo "==> get_historical exchange non supportato → 422" post "/mcp/tools/get_historical" "{\"exchange\":\"bybit\",\"instrument\":\"BTCUSDT\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 422 diff --git a/tests/unit/exchanges/cross/test_client.py b/tests/unit/exchanges/cross/test_client.py deleted file mode 100644 index 46d2e1a..0000000 --- a/tests/unit/exchanges/cross/test_client.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest -from cerbero_mcp.exchanges.cross.client import CrossClient -from fastapi import HTTPException - - -class _Fake: - def __init__(self, candles: list[dict[str, Any]] | None = None, - *, raises: Exception | None = None): - self._candles = candles or [] - self._raises = raises - self.calls: list[dict[str, Any]] = [] - - async def get_historical(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]): - self._clients = clients - - async def get(self, exchange: str, env: str) -> _Fake: - if exchange not in self._clients: - raise KeyError(exchange) - return self._clients[exchange] - - -def _c(ts: int, close: float = 100.0) -> dict[str, Any]: - return {"timestamp": ts, "open": close, "high": close, "low": close, - "close": close, "volume": 1.0} - - -@pytest.mark.asyncio -async def test_crypto_two_sources_aggregates(): - fakes = { - "hyperliquid": _Fake([_c(1, 100), _c(2, 200)]), - "deribit": _Fake([_c(1, 100), _c(2, 200)]), - } - cc = CrossClient(_FakeRegistry(fakes), env="mainnet") - out = await cc.get_historical( - symbol="BTC", asset_class="crypto", interval="1h", - start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00", - ) - assert out["symbol"] == "BTC" - assert out["asset_class"] == "crypto" - assert len(out["candles"]) == 2 - assert out["candles"][0]["sources"] == 2 - assert out["candles"][0]["div_pct"] == 0.0 - 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 = { - "hyperliquid": _Fake([_c(1, 100)]), - "deribit": _Fake(raises=RuntimeError("upstream down")), - } - cc = CrossClient(_FakeRegistry(fakes), env="mainnet") - out = await cc.get_historical( - 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"] == 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"] - - -@pytest.mark.asyncio -async def test_all_sources_fail_raises_502(): - fakes = { - "hyperliquid": _Fake(raises=RuntimeError("b")), - "deribit": _Fake(raises=RuntimeError("c")), - } - cc = CrossClient(_FakeRegistry(fakes), env="mainnet") - with pytest.raises(HTTPException) as exc_info: - await cc.get_historical( - symbol="BTC", asset_class="crypto", interval="1h", - start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00", - ) - assert exc_info.value.status_code == 502 - - -@pytest.mark.asyncio -async def test_unsupported_symbol_raises_400(): - cc = CrossClient(_FakeRegistry({}), env="mainnet") - with pytest.raises(HTTPException) as exc_info: - await cc.get_historical( - symbol="NONEXISTENT", asset_class="crypto", interval="1h", - start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00", - ) - assert exc_info.value.status_code == 400 - - -@pytest.mark.asyncio -async def test_unsupported_interval_raises_400(): - cc = CrossClient(_FakeRegistry({}), env="mainnet") - with pytest.raises(HTTPException) as exc_info: - await cc.get_historical( - symbol="BTC", asset_class="crypto", interval="3h", - start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00", - ) - assert exc_info.value.status_code == 400 diff --git a/tests/unit/exchanges/cross/test_unified.py b/tests/unit/exchanges/cross/test_unified.py index 462e011..168645b 100644 --- a/tests/unit/exchanges/cross/test_unified.py +++ b/tests/unit/exchanges/cross/test_unified.py @@ -10,28 +10,24 @@ from cerbero_mcp.exchanges.cross.instruments import ( ) from fastapi import HTTPException + +def _c(ts: int, close: float = 100.0) -> dict[str, Any]: + return {"timestamp": ts, "open": close, "high": close, "low": close, + "close": close, "volume": 1.0} + + # ── 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, + "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] + inst = normalize_deribit(rows)[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" @@ -39,41 +35,39 @@ def test_normalize_deribit_populates_fees_and_history_start(): 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_dated_future_is_future(): + assert normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])[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_option(): + assert normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])[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 + inst = normalize_deribit([{"name": "X", "kind": "future"}])[0] + assert inst["fees"] is None and inst["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 + rows = [{"asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001, + "open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50}] + inst = normalize_hyperliquid(rows)[0] + assert inst["exchange"] == "hyperliquid" + assert inst["symbol"] == "BTC" + assert inst["type"] == "perpetual" + assert inst["fees"] is None and inst["history_start"] is None + assert inst["native"]["max_leverage"] == 50 -# ── UnifiedClient ──────────────────────────────────────────────────── +# ── Fakes ──────────────────────────────────────────────────────────── class _FakeDeribit: + def __init__(self, candles: list[dict[str, Any]] | None = None, + *, raises: Exception | None = None): + self._candles = candles or [_c(1, 100), _c(2, 200)] + self._raises = raises + 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, @@ -81,18 +75,26 @@ class _FakeDeribit: }]} async def get_historical(self, **kwargs: Any) -> dict[str, Any]: + if self._raises: + raise self._raises self.hist_call = kwargs - return {"candles": [{"timestamp": 1, "open": 1, "high": 1, - "low": 1, "close": 1, "volume": 1}]} + return {"candles": list(self._candles)} class _FakeHyperliquid: + def __init__(self, candles: list[dict[str, Any]] | None = None, + *, raises: Exception | None = None): + self._candles = candles or [_c(1, 100), _c(2, 200)] + self._raises = raises + 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": []} + if self._raises: + raise self._raises + return {"candles": list(self._candles)} class _FakeRegistry: @@ -105,47 +107,45 @@ class _FakeRegistry: return self._clients[exchange] -@pytest.mark.asyncio -async def test_get_instruments_fans_out_all_venues(): - uc = UnifiedClient( +def _both() -> UnifiedClient: + return 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"} + + +# ── get_instruments ────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_instruments_fans_out_all_venues(): + out = await _both().get_instruments() + assert {i["exchange"] for i in out["instruments"]} == {"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") + out = await _both().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") + await UnifiedClient(_FakeRegistry({}), env="mainnet").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", - ) + uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), 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"] +# ── get_historical: single exchange ────────────────────────────────── + @pytest.mark.asyncio async def test_get_historical_single_exchange(): fake = _FakeDeribit() @@ -156,16 +156,15 @@ async def test_get_historical_single_exchange(): ) assert out["exchange"] == "deribit" assert out["instrument"] == "BTC-PERPETUAL" - assert out["interval"] == "1h" - assert len(out["candles"]) == 1 + assert out["sources_used"] == ["deribit"] + assert len(out["candles"]) == 2 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( + await UnifiedClient(_FakeRegistry({}), env="mainnet").get_historical( exchange="bybit", instrument="BTCUSDT", interval="1h", start_date="2026-05-09", end_date="2026-05-10", ) @@ -174,10 +173,92 @@ async def test_get_historical_unsupported_exchange_raises_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( + await _both().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 + + +# ── get_historical: consensus (exchange omitted) ───────────────────── + +@pytest.mark.asyncio +async def test_get_historical_consensus_merges_sources(): + out = await _both().get_historical( + instrument="BTC", interval="1h", + start_date="2026-05-09", end_date="2026-05-10", + ) + assert out["exchange"] is None + assert out["instrument"] == "BTC" + assert set(out["sources_used"]) == {"deribit", "hyperliquid"} + assert out["candles"][0]["sources"] == 2 + assert out["failed_sources"] == [] + + +@pytest.mark.asyncio +async def test_get_historical_consensus_partial_failure(): + uc = UnifiedClient( + _FakeRegistry({"deribit": _FakeDeribit(raises=RuntimeError("down")), + "hyperliquid": _FakeHyperliquid()}), + env="mainnet", + ) + out = await uc.get_historical(instrument="BTC", interval="1h", + start_date="2026-05-09", end_date="2026-05-10") + assert out["sources_used"] == ["hyperliquid"] + assert [f["exchange"] for f in out["failed_sources"]] == ["deribit"] + + +@pytest.mark.asyncio +async def test_get_historical_consensus_all_fail_raises_502(): + uc = UnifiedClient( + _FakeRegistry({"deribit": _FakeDeribit(raises=RuntimeError("a")), + "hyperliquid": _FakeHyperliquid(raises=RuntimeError("b"))}), + env="mainnet", + ) + with pytest.raises(HTTPException) as exc: + await uc.get_historical(instrument="BTC", interval="1h", + start_date="2026-05-09", end_date="2026-05-10") + assert exc.value.status_code == 502 + + +@pytest.mark.asyncio +async def test_get_historical_consensus_unsupported_symbol_raises_400(): + with pytest.raises(HTTPException) as exc: + await _both().get_historical(instrument="NOPE", interval="1h", + start_date="2026-05-09", end_date="2026-05-10") + assert exc.value.status_code == 400 + + +# ── get_indicators ─────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_indicators_single_exchange(): + fake = _FakeDeribit(candles=[_c(i, 100 + i) for i in range(1, 40)]) + uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet") + out = await uc.get_indicators( + indicators=["rsi", "sma", "unknownX"], exchange="deribit", + instrument="BTC-PERPETUAL", interval="1h", + start_date="2026-05-01", end_date="2026-05-10", + ) + assert out["exchange"] == "deribit" + assert out["candles_used"] == 39 + assert set(out["indicators"].keys()) == {"rsi", "sma", "unknownx"} + assert out["indicators"]["unknownx"] is None + + +@pytest.mark.asyncio +async def test_get_indicators_consensus(): + candles = [_c(i, 100 + i) for i in range(1, 40)] + uc = UnifiedClient( + _FakeRegistry({"deribit": _FakeDeribit(candles=candles), + "hyperliquid": _FakeHyperliquid(candles=candles)}), + env="mainnet", + ) + out = await uc.get_indicators( + indicators=["rsi"], instrument="BTC", interval="1h", + start_date="2026-05-01", end_date="2026-05-10", + ) + assert out["exchange"] is None + assert set(out["sources_used"]) == {"deribit", "hyperliquid"} + assert "rsi" in out["indicators"]