feat(V2): unified /mcp interface; retire Bybit/Alpaca to old/
Add a common cross-exchange interface (/mcp) over the integrated venues
(deribit, hyperliquid):
- get_instruments: uniform schema where each row carries its own
`exchange`, `fees` (maker/taker, live from Deribit, null where the
venue has no per-instrument schedule) and `history_start` (listing
date, live from Deribit creation_timestamp), plus type/tick_size and a
lossless `native` blob. Optional `exchange` filter; fan-out otherwise.
- get_historical: generalized to {exchange, instrument, interval,
start_date, end_date}, returning a single chosen venue's candles.
Consensus merge stays available on /mcp-cross.
New: routers/unified.py, exchanges/cross/instruments.py (normalizers),
UnifiedClient in cross/client.py, schemas in cross/tools.py. Deribit
get_instruments now also surfaces maker/taker_commission and
creation_timestamp (additive).
Retire Bybit and Alpaca from the API surface: move clients, routers,
settings classes and their tests under old/ (history preserved via
git mv); drop them from the builder, /mcp-cross dispatch and symbol_map.
Bybit remains a public funding/OI data source in sentiment (not the
trading client). IBKR is intentionally excluded from /mcp for now.
Docs: rewrite API_REFERENCE.md (remove Bybit/Alpaca, document /mcp,
clarify that data_timestamp is injected globally by middleware).
Tests: add unified-interface coverage; update cross/settings/builder/boot
tests for the reduced venue set. Fix a pre-existing flaky assertion in
the Hyperliquid signing test (r/s use eth_utils.to_hex like the official
SDK, so a leading zero byte yields <66 chars ~1/256 of the time).
323 passed, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+124
-72
@@ -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
|
esposti dal container `cerbero-mcp`. Generato dall'analisi diretta dei
|
||||||
router FastAPI in `src/cerbero_mcp/routers/`.
|
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
|
## 1. Autenticazione
|
||||||
|
|
||||||
Tutte le chiamate ai namespace `/mcp-*` richiedono:
|
Tutte le chiamate ai namespace `/mcp*` richiedono:
|
||||||
|
|
||||||
| Header | Valore | Note |
|
| Header | Valore | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -45,20 +50,78 @@ richiede `X-Bot-Tag`.
|
|||||||
|
|
||||||
| Namespace | Tool | Tipo | Note |
|
| 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-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-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-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-macro/tools/*` | 11 | data provider (read-only) | yields, FRED, COT |
|
||||||
| `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange |
|
| `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange |
|
||||||
| `/mcp-cross/tools/*` | 1 | aggregator | storico consensus multi-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
|
### Info
|
||||||
- `is_testnet` — flag ambiente corrente
|
- `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/*`
|
## 7. `/mcp-hyperliquid/tools/*`
|
||||||
|
|
||||||
### Info
|
### Info
|
||||||
@@ -163,35 +194,16 @@ richiede `X-Bot-Tag`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. `/mcp-alpaca/tools/*`
|
## 8. `/mcp-ibkr/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/*`
|
|
||||||
|
|
||||||
Auth via OAuth 1.0a Self-Service con minting di session token
|
Auth via OAuth 1.0a Self-Service con minting di session token
|
||||||
unattended. Setup one-time per ambiente (paper + live) — vedi sezione
|
unattended. Setup one-time per ambiente (paper + live) — vedi sezione
|
||||||
"IBKR Setup" del README.
|
"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
|
### Info / clock
|
||||||
- `environment_info`, `get_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 |
|
| 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 |
|
| Tool | Fonte |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -251,9 +263,13 @@ unattended. Setup one-time per ambiente (paper + live) — vedi sezione
|
|||||||
| `get_liquidation_heatmap` | heatmap liquidazioni |
|
| `get_liquidation_heatmap` | heatmap liquidazioni |
|
||||||
| `get_cointegration_pairs` | screening coppie cointegrate |
|
| `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`
|
### `get_historical`
|
||||||
Aggregatore cross-exchange. Fan-out a tutti gli exchange che supportano
|
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
|
- `sources` = numero di exchange che hanno contribuito al bar
|
||||||
- `div_pct = (max - min) / median` → quality gate per i bot
|
- `div_pct = (max - min) / median` → quality gate per i bot
|
||||||
|
|
||||||
**Crypto** (`asset_class: "crypto"`): BTC, ETH, SOL via Bybit +
|
**Crypto** (`asset_class: "crypto"`): BTC, ETH via Hyperliquid + Deribit;
|
||||||
Hyperliquid + Deribit.
|
SOL via Hyperliquid.
|
||||||
**Stocks** (`asset_class: "stocks"`): AAPL, SPY, QQQ, TSLA, NVDA via
|
|
||||||
Alpaca.
|
|
||||||
|
|
||||||
In caso di fallimento parziale ritorna i bar disponibili più
|
In caso di fallimento parziale ritorna i bar disponibili più
|
||||||
`failed_sources: [...]`. Se *tutti* gli upstream falliscono → `502
|
`failed_sources: [...]`. Se *tutti* gli upstream falliscono → `502
|
||||||
@@ -275,7 +289,7 @@ Bad Gateway` retryable.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Observability
|
## 12. Observability
|
||||||
|
|
||||||
### Request log (`mcp.request`)
|
### Request log (`mcp.request`)
|
||||||
Una riga JSON per richiesta con: `request_id`, `method`, `path`,
|
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
|
Lo stesso `request_id` appare in `mcp.request`, `mcp.audit` e
|
||||||
nell'envelope di errore restituito al client.
|
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
|
```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 "Authorization: Bearer $MAINNET_TOKEN" \
|
||||||
-H "X-Bot-Tag: scanner-alpha-prod" \
|
-H "X-Bot-Tag: scanner-alpha-prod" \
|
||||||
-H "Content-Type: application/json" \
|
-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:
|
Per lo schema completo dei body di richiesta e risposta:
|
||||||
<http://localhost:9000/apidocs>.
|
<http://localhost:9000/apidocs>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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*: `<COIN>_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.
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ from cerbero_mcp.client_registry import ClientRegistry
|
|||||||
from cerbero_mcp.common.logging import configure_root_logging
|
from cerbero_mcp.common.logging import configure_root_logging
|
||||||
from cerbero_mcp.exchanges import build_client
|
from cerbero_mcp.exchanges import build_client
|
||||||
from cerbero_mcp.routers import (
|
from cerbero_mcp.routers import (
|
||||||
alpaca,
|
|
||||||
bybit,
|
|
||||||
cross,
|
cross,
|
||||||
deribit,
|
deribit,
|
||||||
hyperliquid,
|
hyperliquid,
|
||||||
ibkr,
|
ibkr,
|
||||||
macro,
|
macro,
|
||||||
sentiment,
|
sentiment,
|
||||||
|
unified,
|
||||||
)
|
)
|
||||||
from cerbero_mcp.server import build_app
|
from cerbero_mcp.server import build_app
|
||||||
from cerbero_mcp.settings import Settings
|
from cerbero_mcp.settings import Settings
|
||||||
@@ -66,13 +65,12 @@ def _make_app(settings: Settings) -> FastAPI:
|
|||||||
app.router.lifespan_context = lifespan
|
app.router.lifespan_context = lifespan
|
||||||
|
|
||||||
app.include_router(deribit.make_router())
|
app.include_router(deribit.make_router())
|
||||||
app.include_router(bybit.make_router())
|
|
||||||
app.include_router(hyperliquid.make_router())
|
app.include_router(hyperliquid.make_router())
|
||||||
app.include_router(alpaca.make_router())
|
|
||||||
app.include_router(ibkr.make_router())
|
app.include_router(ibkr.make_router())
|
||||||
app.include_router(macro.make_router())
|
app.include_router(macro.make_router())
|
||||||
app.include_router(sentiment.make_router())
|
app.include_router(sentiment.make_router())
|
||||||
app.include_router(cross.make_router())
|
app.include_router(cross.make_router())
|
||||||
|
app.include_router(unified.make_router())
|
||||||
app.include_router(admin.make_admin_router())
|
app.include_router(admin.make_admin_router())
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ async def build_client(
|
|||||||
testnet=(env == "testnet"),
|
testnet=(env == "testnet"),
|
||||||
base_url_override=url,
|
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":
|
if exchange == "hyperliquid":
|
||||||
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
||||||
|
|
||||||
@@ -43,16 +33,6 @@ async def build_client(
|
|||||||
api_wallet_address=settings.hyperliquid.api_wallet_address,
|
api_wallet_address=settings.hyperliquid.api_wallet_address,
|
||||||
base_url=url,
|
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":
|
if exchange == "macro":
|
||||||
# Read-only data provider — env ignored. Il registry
|
# Read-only data provider — env ignored. Il registry
|
||||||
# istanzia comunque 2 entry (testnet/mainnet); costo trascurabile
|
# istanzia comunque 2 entry (testnet/mainnet); costo trascurabile
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ a single consensus candle series with per-bar divergence metrics.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime as _dt
|
|
||||||
from typing import Any, Literal, Protocol
|
from typing import Any, Literal, Protocol
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from cerbero_mcp.exchanges.cross.consensus import merge_candles
|
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 (
|
from cerbero_mcp.exchanges.cross.symbol_map import (
|
||||||
get_sources,
|
get_sources,
|
||||||
supported_intervals,
|
supported_intervals,
|
||||||
@@ -20,6 +23,9 @@ from cerbero_mcp.exchanges.cross.symbol_map import (
|
|||||||
to_native_symbol,
|
to_native_symbol,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Venues exposed through the unified /mcp interface.
|
||||||
|
UNIFIED_EXCHANGES = ("deribit", "hyperliquid")
|
||||||
|
|
||||||
|
|
||||||
Environment = Literal["testnet", "mainnet"]
|
Environment = Literal["testnet", "mainnet"]
|
||||||
|
|
||||||
@@ -28,21 +34,6 @@ class _Registry(Protocol):
|
|||||||
async def get(self, exchange: str, env: Environment) -> Any: ...
|
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,
|
async def _call_hyperliquid(client: Any, sym: str, interval: str,
|
||||||
start: str, end: str) -> dict[str, Any]:
|
start: str, end: str) -> dict[str, Any]:
|
||||||
resp: dict[str, Any] = await client.get_historical(
|
resp: dict[str, Any] = await client.get_historical(
|
||||||
@@ -59,20 +50,9 @@ async def _call_deribit(client: Any, sym: str, interval: str,
|
|||||||
return resp
|
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 = {
|
_DISPATCH = {
|
||||||
"bybit": _call_bybit,
|
|
||||||
"hyperliquid": _call_hyperliquid,
|
"hyperliquid": _call_hyperliquid,
|
||||||
"deribit": _call_deribit,
|
"deribit": _call_deribit,
|
||||||
"alpaca": _call_alpaca,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -144,3 +124,92 @@ class CrossClient:
|
|||||||
"sources_used": sorted(by_source.keys()),
|
"sources_used": sorted(by_source.keys()),
|
||||||
"failed_sources": failed,
|
"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", []),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,40 +1,30 @@
|
|||||||
"""Routing table: canonical (asset_class, symbol, interval) → per-exchange native.
|
"""Routing table: canonical (asset_class, symbol, interval) → per-exchange native.
|
||||||
|
|
||||||
Crypto canonical symbols default to USD/USDT-quoted perpetuals on the most
|
Crypto canonical symbols default to USD-quoted perpetuals on the most liquid
|
||||||
liquid pair available. Equities currently route to Alpaca only — IBKR is
|
pair available. Only the integrated derivatives venues (Deribit, Hyperliquid)
|
||||||
omitted from the cross MVP because its bars endpoint takes a relative
|
participate in the cross-exchange consensus.
|
||||||
period instead of (start, end).
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
AssetClass = str
|
AssetClass = str
|
||||||
|
|
||||||
_CRYPTO_SYMBOLS: dict[str, dict[str, str]] = {
|
_CRYPTO_SYMBOLS: dict[str, dict[str, str]] = {
|
||||||
"BTC": {"bybit": "BTCUSDT", "hyperliquid": "BTC", "deribit": "BTC-PERPETUAL"},
|
"BTC": {"hyperliquid": "BTC", "deribit": "BTC-PERPETUAL"},
|
||||||
"ETH": {"bybit": "ETHUSDT", "hyperliquid": "ETH", "deribit": "ETH-PERPETUAL"},
|
"ETH": {"hyperliquid": "ETH", "deribit": "ETH-PERPETUAL"},
|
||||||
"SOL": {"bybit": "SOLUSDT", "hyperliquid": "SOL"},
|
"SOL": {"hyperliquid": "SOL"},
|
||||||
}
|
|
||||||
|
|
||||||
_STOCK_SYMBOLS: dict[str, dict[str, str]] = {
|
|
||||||
"AAPL": {"alpaca": "AAPL"},
|
|
||||||
"SPY": {"alpaca": "SPY"},
|
|
||||||
"QQQ": {"alpaca": "QQQ"},
|
|
||||||
"TSLA": {"alpaca": "TSLA"},
|
|
||||||
"NVDA": {"alpaca": "NVDA"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_SYMBOLS: dict[AssetClass, dict[str, dict[str, str]]] = {
|
_SYMBOLS: dict[AssetClass, dict[str, dict[str, str]]] = {
|
||||||
"crypto": _CRYPTO_SYMBOLS,
|
"crypto": _CRYPTO_SYMBOLS,
|
||||||
"stocks": _STOCK_SYMBOLS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_INTERVALS: dict[str, dict[str, str]] = {
|
_INTERVALS: dict[str, dict[str, str]] = {
|
||||||
"1m": {"bybit": "1", "hyperliquid": "1m", "deribit": "1m", "alpaca": "1m"},
|
"1m": {"hyperliquid": "1m", "deribit": "1m"},
|
||||||
"5m": {"bybit": "5", "hyperliquid": "5m", "deribit": "5m", "alpaca": "5m"},
|
"5m": {"hyperliquid": "5m", "deribit": "5m"},
|
||||||
"15m": {"bybit": "15", "hyperliquid": "15m", "deribit": "15m", "alpaca": "15m"},
|
"15m": {"hyperliquid": "15m", "deribit": "15m"},
|
||||||
"1h": {"bybit": "60", "hyperliquid": "1h", "deribit": "1h", "alpaca": "1h"},
|
"1h": {"hyperliquid": "1h", "deribit": "1h"},
|
||||||
"4h": {"bybit": "240", "hyperliquid": "4h", "deribit": "4h", "alpaca": "4h"},
|
"4h": {"hyperliquid": "4h", "deribit": "4h"},
|
||||||
"1d": {"bybit": "D", "hyperliquid": "1d", "deribit": "1d", "alpaca": "1d"},
|
"1d": {"hyperliquid": "1d", "deribit": "1d"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel
|
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):
|
class GetHistoricalReq(BaseModel):
|
||||||
@@ -26,3 +27,39 @@ async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict:
|
|||||||
start_date=params.start_date,
|
start_date=params.start_date,
|
||||||
end_date=params.end_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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -327,6 +327,10 @@ class DeribitClient:
|
|||||||
"tick_size": i.get("tick_size"),
|
"tick_size": i.get("tick_size"),
|
||||||
"min_trade_amount": i.get("min_trade_amount"),
|
"min_trade_amount": i.get("min_trade_amount"),
|
||||||
"open_interest": i.get("open_interest"),
|
"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
|
for i in page
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -61,20 +61,6 @@ class DeribitSettings(_Sub):
|
|||||||
return cid, csec.get_secret_value()
|
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):
|
class HyperliquidSettings(_Sub):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
@@ -90,20 +76,6 @@ class HyperliquidSettings(_Sub):
|
|||||||
max_leverage: int = 3
|
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):
|
class IBKRSettings(_Sub):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
@@ -216,9 +188,7 @@ class Settings(_Sub):
|
|||||||
mainnet_token: SecretStr
|
mainnet_token: SecretStr
|
||||||
|
|
||||||
deribit: DeribitSettings = Field(default_factory=lambda: DeribitSettings()) # type: ignore[call-arg]
|
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]
|
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]
|
ibkr: IBKRSettings = Field(default_factory=lambda: IBKRSettings()) # type: ignore[call-arg]
|
||||||
macro: MacroSettings = Field(default_factory=lambda: MacroSettings()) # 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]
|
sentiment: SentimentSettings = Field(default_factory=lambda: SentimentSettings()) # type: ignore[call-arg]
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ def test_app_boots_and_health_responds(monkeypatch):
|
|||||||
spec = r.json()
|
spec = r.json()
|
||||||
paths = spec["paths"].keys()
|
paths = spec["paths"].keys()
|
||||||
assert any(p.startswith("/mcp-deribit/") for p in paths)
|
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-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-macro/") for p in paths)
|
||||||
assert any(p.startswith("/mcp-sentiment/") 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):
|
def test_apidocs_available_after_boot(monkeypatch):
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
# ── Hyperliquid ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_hyperliquid_testnet_bearer(app, monkeypatch):
|
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 ──────────────────────────────────────────────────────────────
|
# ── Auth sanity ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_no_bearer_returns_401(app):
|
def test_no_bearer_returns_401(app):
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ class _Fake:
|
|||||||
self.calls.append(kwargs)
|
self.calls.append(kwargs)
|
||||||
return {"candles": list(self._candles)}
|
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:
|
class _FakeRegistry:
|
||||||
def __init__(self, clients: dict[str, _Fake]):
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_crypto_three_sources_aggregates():
|
async def test_crypto_two_sources_aggregates():
|
||||||
fakes = {
|
fakes = {
|
||||||
"bybit": _Fake([_c(1, 100), _c(2, 200)]),
|
|
||||||
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
|
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
|
||||||
"deribit": _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["symbol"] == "BTC"
|
||||||
assert out["asset_class"] == "crypto"
|
assert out["asset_class"] == "crypto"
|
||||||
assert len(out["candles"]) == 2
|
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 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"] == []
|
assert out["failed_sources"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_crypto_partial_failure_returns_partial_with_warning():
|
async def test_crypto_partial_failure_returns_partial_with_warning():
|
||||||
fakes = {
|
fakes = {
|
||||||
"bybit": _Fake([_c(1, 100)]),
|
|
||||||
"hyperliquid": _Fake([_c(1, 100)]),
|
"hyperliquid": _Fake([_c(1, 100)]),
|
||||||
"deribit": _Fake(raises=RuntimeError("upstream down")),
|
"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",
|
symbol="BTC", asset_class="crypto", interval="1h",
|
||||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
||||||
)
|
)
|
||||||
assert out["candles"][0]["sources"] == 2
|
assert out["candles"][0]["sources"] == 1
|
||||||
assert set(out["sources_used"]) == {"bybit", "hyperliquid"}
|
assert set(out["sources_used"]) == {"hyperliquid"}
|
||||||
assert len(out["failed_sources"]) == 1
|
assert len(out["failed_sources"]) == 1
|
||||||
assert out["failed_sources"][0]["exchange"] == "deribit"
|
assert out["failed_sources"][0]["exchange"] == "deribit"
|
||||||
assert "upstream down" in out["failed_sources"][0]["error"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_all_sources_fail_raises_502():
|
async def test_all_sources_fail_raises_502():
|
||||||
fakes = {
|
fakes = {
|
||||||
"bybit": _Fake(raises=RuntimeError("a")),
|
|
||||||
"hyperliquid": _Fake(raises=RuntimeError("b")),
|
"hyperliquid": _Fake(raises=RuntimeError("b")),
|
||||||
"deribit": _Fake(raises=RuntimeError("c")),
|
"deribit": _Fake(raises=RuntimeError("c")),
|
||||||
}
|
}
|
||||||
@@ -109,20 +100,6 @@ async def test_unsupported_symbol_raises_400():
|
|||||||
assert exc_info.value.status_code == 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
|
@pytest.mark.asyncio
|
||||||
async def test_unsupported_interval_raises_400():
|
async def test_unsupported_interval_raises_400():
|
||||||
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
||||||
|
|||||||
@@ -9,23 +9,22 @@ from cerbero_mcp.exchanges.cross.symbol_map import (
|
|||||||
|
|
||||||
|
|
||||||
def test_btc_crypto_sources():
|
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():
|
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():
|
def test_unknown_crypto_symbol_returns_empty():
|
||||||
assert get_sources("crypto", "DOGEFAKE") == []
|
assert get_sources("crypto", "DOGEFAKE") == []
|
||||||
|
|
||||||
|
|
||||||
def test_stocks_aapl_sources():
|
def test_unknown_asset_class_returns_empty():
|
||||||
assert set(get_sources("stocks", "AAPL")) == {"alpaca"}
|
assert get_sources("stocks", "AAPL") == []
|
||||||
|
|
||||||
|
|
||||||
def test_native_symbol_btc():
|
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", "hyperliquid") == "BTC"
|
||||||
assert to_native_symbol("crypto", "BTC", "deribit") == "BTC-PERPETUAL"
|
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():
|
def test_native_interval_1h():
|
||||||
assert to_native_interval("1h", "bybit") == "60"
|
|
||||||
assert to_native_interval("1h", "hyperliquid") == "1h"
|
assert to_native_interval("1h", "hyperliquid") == "1h"
|
||||||
assert to_native_interval("1h", "deribit") == "1h"
|
assert to_native_interval("1h", "deribit") == "1h"
|
||||||
assert to_native_interval("1h", "alpaca") == "1h"
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_interval_unknown_canonical_raises():
|
def test_native_interval_unknown_canonical_raises():
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
to_native_interval("3h", "bybit")
|
to_native_interval("3h", "deribit")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -277,8 +277,14 @@ async def test_place_order_limit(httpx_mock: HTTPXMock, client: HyperliquidClien
|
|||||||
assert order["t"] == {"limit": {"tif": "Gtc"}}
|
assert order["t"] == {"limit": {"tif": "Gtc"}}
|
||||||
sig = body["signature"]
|
sig = body["signature"]
|
||||||
assert set(sig.keys()) == {"r", "s", "v"}
|
assert set(sig.keys()) == {"r", "s", "v"}
|
||||||
assert sig["r"].startswith("0x") and len(sig["r"]) == 66
|
# r/s are serialized with eth_utils.to_hex (same as the Hyperliquid SDK),
|
||||||
assert sig["s"].startswith("0x") and len(sig["s"]) == 66
|
# 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)
|
assert sig["v"] in (27, 28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,27 +22,6 @@ async def test_build_client_deribit_returns_correct_url(monkeypatch):
|
|||||||
assert "test" not in c_live.base_url.lower()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_build_client_hyperliquid_returns_correct_env(monkeypatch):
|
async def test_build_client_hyperliquid_returns_correct_env(monkeypatch):
|
||||||
from tests.unit.test_settings import _minimal_env
|
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()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_build_client_macro_no_env_distinction(monkeypatch):
|
async def test_build_client_macro_no_env_distinction(monkeypatch):
|
||||||
from tests.unit.test_settings import _minimal_env
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_build_client_unknown_exchange_raises(monkeypatch):
|
async def test_build_client_unknown_exchange_raises(monkeypatch):
|
||||||
from tests.unit.test_settings import _minimal_env
|
from tests.unit.test_settings import _minimal_env
|
||||||
|
|||||||
@@ -14,19 +14,11 @@ def _minimal_env(**overrides) -> dict:
|
|||||||
"DERIBIT_CLIENT_SECRET": "secret",
|
"DERIBIT_CLIENT_SECRET": "secret",
|
||||||
"DERIBIT_URL_LIVE": "https://www.deribit.com/api/v2",
|
"DERIBIT_URL_LIVE": "https://www.deribit.com/api/v2",
|
||||||
"DERIBIT_URL_TESTNET": "https://test.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_WALLET_ADDRESS": "0xabc",
|
||||||
"HYPERLIQUID_API_WALLET_ADDRESS": "0xdef",
|
"HYPERLIQUID_API_WALLET_ADDRESS": "0xdef",
|
||||||
"HYPERLIQUID_PRIVATE_KEY": "0x123",
|
"HYPERLIQUID_PRIVATE_KEY": "0x123",
|
||||||
"HYPERLIQUID_URL_LIVE": "https://api.hyperliquid.xyz",
|
"HYPERLIQUID_URL_LIVE": "https://api.hyperliquid.xyz",
|
||||||
"HYPERLIQUID_URL_TESTNET": "https://api.hyperliquid-testnet.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",
|
"FRED_API_KEY": "x",
|
||||||
"FINNHUB_API_KEY": "y",
|
"FINNHUB_API_KEY": "y",
|
||||||
"CRYPTOPANIC_KEY": "z",
|
"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.testnet_token.get_secret_value() == "t_test_123"
|
||||||
assert s.mainnet_token.get_secret_value() == "t_live_456"
|
assert s.mainnet_token.get_secret_value() == "t_live_456"
|
||||||
assert s.deribit.url_testnet.endswith("test.deribit.com/api/v2")
|
assert s.deribit.url_testnet.endswith("test.deribit.com/api/v2")
|
||||||
assert s.bybit.max_leverage == 3
|
assert s.deribit.max_leverage == 3
|
||||||
assert s.alpaca.max_leverage == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_settings_missing_token_fails(monkeypatch, tmp_path):
|
def test_settings_missing_token_fails(monkeypatch, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user