Compare commits
12 Commits
eb5f0148ad
...
V2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce33b1b3c | |||
| fe7f8a152b | |||
| 9a74052dc5 | |||
| 350eeb4f76 | |||
| ee799f3903 | |||
| 726984e4f1 | |||
| 6fba410e6f | |||
| 8d7e2ca30f | |||
| 6faf21369d | |||
| 2272d5520b | |||
| 652e50ad99 | |||
| bc75d3980a |
@@ -39,3 +39,6 @@ config/*.env
|
|||||||
|
|
||||||
# Override locale compose (specifico macchina, fix daemon vecchi, ecc.)
|
# Override locale compose (specifico macchina, fix daemon vecchi, ecc.)
|
||||||
docker-compose.local.yml
|
docker-compose.local.yml
|
||||||
|
|
||||||
|
# Claude Code session artifacts (locali, non versionati)
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# Cerbero MCP — API Reference (V2.0.0)
|
||||||
|
|
||||||
|
Documento di riferimento completo per tutti gli endpoint HTTP e i tool MCP
|
||||||
|
esposti dal container `cerbero-mcp`. Generato dall'analisi diretta dei
|
||||||
|
router FastAPI in `src/cerbero_mcp/routers/`.
|
||||||
|
|
||||||
|
> **Nota V2** — Bybit e Alpaca sono stati ritirati dalla superficie API
|
||||||
|
> (codice archiviato in `old/`). Gli exchange di trading attivi sono
|
||||||
|
> **Deribit**, **Hyperliquid** e **IBKR**; **Macro** e **Sentiment** restano
|
||||||
|
> data-provider read-only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Autenticazione
|
||||||
|
|
||||||
|
Tutte le chiamate ai namespace `/mcp*` richiedono:
|
||||||
|
|
||||||
|
| Header | Valore | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `Authorization` | `Bearer <TOKEN>` | `TESTNET_TOKEN` → upstream testnet · `MAINNET_TOKEN` → upstream live · token sconosciuto → `401` |
|
||||||
|
| `X-Bot-Tag` | stringa ≤ 64 char | identifica il bot chiamante, loggato nell'audit · mancante → `400` |
|
||||||
|
|
||||||
|
Gli endpoint pubblici (`/health`, `/apidocs`, `/openapi.json`) non
|
||||||
|
richiedono auth. L'endpoint `/admin/audit` richiede bearer ma **non**
|
||||||
|
richiede `X-Bot-Tag`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Endpoint pubblici
|
||||||
|
|
||||||
|
| Path | Metodo | Descrizione |
|
||||||
|
|---|---|---|
|
||||||
|
| `/health` | GET | Liveness — sempre `200` finché il processo è vivo |
|
||||||
|
| `/health/ready` | GET | Readiness — itera i client registry, status `ready` / `degraded` / `not_ready`. Forza `503` se `READY_FAILS_ON_DEGRADED=true` |
|
||||||
|
| `/apidocs` | GET | Swagger UI interattiva |
|
||||||
|
| `/openapi.json` | GET | Schema OpenAPI 3.1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Endpoint admin
|
||||||
|
|
||||||
|
| Path | Metodo | Descrizione |
|
||||||
|
|---|---|---|
|
||||||
|
| `/admin/audit` | GET | Query del JSONL `AUDIT_LOG_FILE`. Filtri: `from`, `to`, `actor`, `exchange`, `action`, `bot_tag`, `limit` (default 1000, max 10000) |
|
||||||
|
| `/admin/ibkr/rotate-keys/confirm?env=` | POST | Swap atomico chiavi IBKR con auto-rollback su validazione fallita |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Namespace MCP — panoramica
|
||||||
|
|
||||||
|
| Namespace | Tool | Tipo | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/mcp/tools/*` | 3 | **interfaccia comune** | `get_instruments` + `get_historical` + `get_indicators` (single exchange o consensus) |
|
||||||
|
| `/mcp-deribit/tools/*` | 31 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff |
|
||||||
|
| `/mcp-hyperliquid/tools/*` | 15 | exchange (perp DEX) | L1 signing, leverage cap |
|
||||||
|
| `/mcp-ibkr/tools/*` | 24 | exchange (multi-asset broker) | OAuth 1.0a, streaming WS, bracket/OCO/OTO |
|
||||||
|
| `/mcp-macro/tools/*` | 11 | data provider (read-only) | yields, FRED, COT |
|
||||||
|
| `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange |
|
||||||
|
|
||||||
|
`get_instruments`, `get_historical` e `get_indicators` vivono **solo**
|
||||||
|
sull'interfaccia comune `/mcp` (non più per-exchange). I restanti tool
|
||||||
|
per-exchange (ticker, ordini, analytics options, …) restano sui rispettivi
|
||||||
|
router `/mcp-{exchange}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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"` | filtro Deribit |
|
||||||
|
| `kind` | str \| null | `"future"` | filtro Deribit (`future`/`option`/`spot`) |
|
||||||
|
| `expiry_from` / `expiry_to` | str \| null | `null` | filtro Deribit (data scadenza) |
|
||||||
|
| `strike_min` / `strike_max` | float \| null | `null` | filtro Deribit (opzioni) |
|
||||||
|
| `min_open_interest` | float \| null | `null` | filtro Deribit |
|
||||||
|
| `offset` / `limit` | int | `0` / `100` | paginazione Deribit |
|
||||||
|
|
||||||
|
I filtri/paginazione si applicano a **Deribit**; gli exchange che elencano
|
||||||
|
tutto (es. Hyperliquid `get_markets`) li ignorano. Ogni elemento di
|
||||||
|
`instruments[]` ha **schema uniforme**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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}],
|
||||||
|
"meta": { "deribit": {total, offset, limit, has_more} } }`. Il blocco `meta`
|
||||||
|
riporta la paginazione per-sorgente quando disponibile (oggi: Deribit).
|
||||||
|
|
||||||
|
> **Copertura `fees` / `history_start`** — popolati live dall'upstream dove
|
||||||
|
> disponibili. Oggi: **Deribit** li espone (commissioni maker/taker +
|
||||||
|
> `creation_timestamp` → `history_start`); **Hyperliquid** ha fee a livello
|
||||||
|
> account e nessuna data di listing per-strumento → entrambi `null`.
|
||||||
|
|
||||||
|
### `get_historical` — unica chiamata storica comune
|
||||||
|
|
||||||
|
`get_historical` esiste **solo qui** (non più per-exchange). Request body:
|
||||||
|
|
||||||
|
| Campo | Tipo | Default | Note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `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) |
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
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 e Hyperliquid sono paginati
|
||||||
|
> internamente in finestre da ~5000 candele e ritagliati al range, così
|
||||||
|
> `start_date` è sempre rispettato anche oltre il cap per-richiesta dell'upstream.
|
||||||
|
|
||||||
|
### `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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `/mcp-deribit/tools/*`
|
||||||
|
|
||||||
|
### Info
|
||||||
|
- `is_testnet` — flag ambiente corrente
|
||||||
|
- `environment_info` — stringa diagnostica completa
|
||||||
|
|
||||||
|
### Market data
|
||||||
|
- `get_ticker`, `get_ticker_batch`
|
||||||
|
- `get_orderbook`, `get_orderbook_imbalance`
|
||||||
|
- `get_trade_history`
|
||||||
|
|
||||||
|
> Lista strumenti, storico OHLCV e indicatori tecnici NON sono più qui: usa
|
||||||
|
> `/mcp/tools/get_instruments`, `/mcp/tools/get_historical` e
|
||||||
|
> `/mcp/tools/get_indicators` (sezione 5) con `exchange="deribit"`.
|
||||||
|
|
||||||
|
### Account
|
||||||
|
- `get_positions`
|
||||||
|
- `get_account_summary`
|
||||||
|
- `get_open_orders` — ordini aperti (`currency`, `kind?`, `type` default `all`;
|
||||||
|
per i trigger non scattati interrogare anche `type="trigger_all"` e fare
|
||||||
|
merge per `order_id`)
|
||||||
|
|
||||||
|
### Options analytics
|
||||||
|
- `get_dvol`, `get_dvol_history`
|
||||||
|
- `get_gex`, `get_dealer_gamma_profile`
|
||||||
|
- `get_vanna_charm`
|
||||||
|
- `get_oi_weighted_skew`, `get_smile_asymmetry`, `get_atm_vs_wings_vol`
|
||||||
|
- `get_pc_ratio`, `get_skew_25d`, `get_term_structure`
|
||||||
|
- `get_iv_rank`, `get_realized_vol`
|
||||||
|
- `find_by_delta`, `calculate_spread_payoff`
|
||||||
|
|
||||||
|
### Technicals
|
||||||
|
- `run_backtest`
|
||||||
|
|
||||||
|
### Write (richiede leverage cap)
|
||||||
|
- `place_order`, `place_combo_order`
|
||||||
|
- `cancel_order`
|
||||||
|
- `set_stop_loss`, `set_take_profit`
|
||||||
|
- `close_position`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `/mcp-hyperliquid/tools/*`
|
||||||
|
|
||||||
|
### Info
|
||||||
|
- `environment_info`
|
||||||
|
|
||||||
|
### Market data
|
||||||
|
- `get_markets`, `get_ticker`, `get_orderbook`
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
### Write (L1 signing + leverage cap)
|
||||||
|
- `place_order`, `cancel_order`
|
||||||
|
- `set_stop_loss`, `set_take_profit`
|
||||||
|
- `close_position`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `/mcp-ibkr/tools/*`
|
||||||
|
|
||||||
|
Auth via OAuth 1.0a Self-Service con minting di session token
|
||||||
|
unattended. Setup one-time per ambiente (paper + live) — vedi sezione
|
||||||
|
"IBKR Setup" del README.
|
||||||
|
|
||||||
|
> IBKR non è ancora integrato nell'interfaccia comune `/mcp` (il suo endpoint
|
||||||
|
> bars usa un `period` relativo invece di `(start, end)`); usare il router
|
||||||
|
> dedicato `/mcp-ibkr`.
|
||||||
|
|
||||||
|
### Info / clock
|
||||||
|
- `environment_info`, `get_clock`
|
||||||
|
|
||||||
|
### Account
|
||||||
|
- `get_account`, `get_positions`
|
||||||
|
- `get_open_orders`, `get_activities`
|
||||||
|
|
||||||
|
### Market data
|
||||||
|
- `get_ticker`, `get_bars`, `get_snapshot`
|
||||||
|
- `get_option_chain`, `search_contracts`
|
||||||
|
|
||||||
|
### Streaming (WebSocket singleton)
|
||||||
|
- `get_tick`, `get_depth`
|
||||||
|
- `subscribe_tick`, `unsubscribe`
|
||||||
|
|
||||||
|
### Write
|
||||||
|
- `place_order`, `amend_order`
|
||||||
|
- `cancel_order`, `cancel_all_orders`
|
||||||
|
- `close_position`, `close_all_positions`
|
||||||
|
|
||||||
|
### Complex orders
|
||||||
|
- `place_bracket_order` — entry + TP + SL atomico
|
||||||
|
- `place_oco_order` — One-Cancels-Other (OCA group)
|
||||||
|
- `place_oto_order` — One-Triggers-Other (parent → child)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `/mcp-macro/tools/*` (read-only)
|
||||||
|
|
||||||
|
| Tool | Sorgente |
|
||||||
|
|---|---|
|
||||||
|
| `get_economic_indicators` | FRED + Yahoo |
|
||||||
|
| `get_macro_calendar` | Finnhub + impact heuristic |
|
||||||
|
| `get_market_overview` | mix Yahoo + Deribit DVOL |
|
||||||
|
| `get_asset_price` | Yahoo Finance |
|
||||||
|
| `get_treasury_yields` | US Treasury XML |
|
||||||
|
| `get_equity_futures` | Yahoo |
|
||||||
|
| `get_yield_curve_slope` | derivato da treasury yields |
|
||||||
|
| `get_breakeven_inflation` | FRED (T10YIE / T5YIE) |
|
||||||
|
| `get_cot_tff` | CFTC TFF report |
|
||||||
|
| `get_cot_disaggregated` | CFTC disaggregated report |
|
||||||
|
| `get_cot_extreme_positioning` | percentili COT (gate ±5%) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `/mcp-sentiment/tools/*` (read-only)
|
||||||
|
|
||||||
|
| Tool | Fonte |
|
||||||
|
|---|---|
|
||||||
|
| `get_crypto_news` | CryptoPanic + CoinDesk + CryptoCompare + Messari (deduped) |
|
||||||
|
| `get_world_news` | aggregato generalista |
|
||||||
|
| `get_social_sentiment` | LunarCrush + Fear&Greed Index |
|
||||||
|
| `get_funding_rates` | per-asset multi-exchange |
|
||||||
|
| `get_cross_exchange_funding` | snapshot multi-asset multi-venue |
|
||||||
|
| `get_funding_arb_spread` | spread arbitraggio funding |
|
||||||
|
| `get_oi_history` | open interest serie storica |
|
||||||
|
| `get_liquidation_heatmap` | heatmap liquidazioni |
|
||||||
|
| `get_cointegration_pairs` | screening coppie cointegrate |
|
||||||
|
|
||||||
|
> Le fonti di funding/OI includono endpoint pubblici multi-venue (Binance,
|
||||||
|
> Bybit, OKX, Hyperliquid): qui Bybit è una **sorgente dati pubblica**, non il
|
||||||
|
> client di trading ritirato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Observability
|
||||||
|
|
||||||
|
### Request log (`mcp.request`)
|
||||||
|
Una riga JSON per richiesta con: `request_id`, `method`, `path`,
|
||||||
|
`status_code`, `duration_ms`, `actor`, `bot_tag`, `exchange`, `tool`,
|
||||||
|
`client_ip`, `user_agent`.
|
||||||
|
|
||||||
|
### Audit log (`mcp.audit`)
|
||||||
|
Persistito su `AUDIT_LOG_FILE` (JSONL) per le operazioni write.
|
||||||
|
Rotazione configurabile via `AUDIT_LOG_BACKUP_DAYS`.
|
||||||
|
|
||||||
|
### Correlation
|
||||||
|
Lo stesso `request_id` appare in `mcp.request`, `mcp.audit` e
|
||||||
|
nell'envelope di errore restituito al client.
|
||||||
|
|
||||||
|
### `data_timestamp`
|
||||||
|
Ogni risposta JSON sotto `/tools/*` riceve dal middleware un campo
|
||||||
|
`data_timestamp` (ISO UTC) al top level — oltre all'header `X-Data-Timestamp`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Esempio chiamata
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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:
|
||||||
|
<http://localhost:9000/apidocs>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Discovery strumenti & schemi candele
|
||||||
|
|
||||||
|
Schemi verificati sulle risposte live. `get_historical` ritorna la chiave
|
||||||
|
uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`.
|
||||||
|
|
||||||
|
### Tool dati comuni (`/mcp`)
|
||||||
|
| Tool | Request body | Risposta (campi principali) |
|
||||||
|
|---|---|---|
|
||||||
|
| `get_instruments` | `{exchange?, currency:"BTC", kind:"future", expiry_from?, expiry_to?, strike_min?, strike_max?, min_open_interest?, offset:0, limit:100}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` · `meta:{deribit:{total,offset,limit,has_more}}` |
|
||||||
|
| `get_historical` | `{exchange?, instrument, interval, start_date, end_date, asset_class?}` | single: `{exchange, instrument, interval, candles, sources_used}` · consensus (no `exchange`): `+ div_pct`/`sources` per bar, `failed_sources` |
|
||||||
|
| `get_indicators` | come `get_historical` + `indicators:["rsi","atr",…]` | `{exchange, instrument, interval, indicators:{…}, candles_used, sources_used}` |
|
||||||
|
|
||||||
|
Sorgenti normalizzate dentro `get_instruments`: Deribit espone `name`,
|
||||||
|
`expiry`, `option_type`, `tick_size`, `min_trade_amount`, `maker_commission`,
|
||||||
|
`taker_commission`, `creation_timestamp` (→ `fees`/`history_start`); Hyperliquid
|
||||||
|
`get_markets` espone `{asset, mark_price, funding_rate, open_interest,
|
||||||
|
volume_24h, max_leverage}`. Ogni risposta `/tools/*` riceve inoltre
|
||||||
|
`data_timestamp` dal middleware.
|
||||||
|
|
||||||
|
### Convenzione simboli Deribit
|
||||||
|
- **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL`
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guida per Claude Code che lavora su **Cerbero MCP** — server MCP multi-exchange
|
||||||
|
(FastAPI) distribuito come singola immagine Docker, con routing testnet/mainnet
|
||||||
|
per-request basato sul token bearer.
|
||||||
|
|
||||||
|
## Comandi
|
||||||
|
|
||||||
|
Gestione progetto con `uv` (Python ≥ 3.11):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # installa dipendenze (incl. dev)
|
||||||
|
uv run cerbero-mcp # avvia il server (porta da .env, default 9000)
|
||||||
|
uv run pytest # suite completa (~323 test attesi)
|
||||||
|
uv run pytest tests/unit -v # solo unit
|
||||||
|
uv run pytest tests/unit/exchanges/cross/test_unified.py::test_get_historical_single_exchange # singolo test
|
||||||
|
uv run ruff check src tests # lint (E,F,I,W,UP,B,SIM; line-length 100)
|
||||||
|
uv run mypy src/cerbero_mcp # type check (non-strict)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tutti e quattro (pytest, ruff, mypy) devono essere verdi prima di committare.
|
||||||
|
`testpaths = ["tests"]` → `old/` non viene mai raccolto dai test.
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
|
||||||
|
Flusso runtime:
|
||||||
|
|
||||||
|
```
|
||||||
|
Bot → Authorization: Bearer <TESTNET|MAINNET>_TOKEN (+ header X-Bot-Tag)
|
||||||
|
→ auth middleware → request.state.environment ∈ {testnet, mainnet}
|
||||||
|
→ Router (/mcp/... , /mcp-{exchange}/... , /mcp-cross/...)
|
||||||
|
→ ClientRegistry.get(exchange, env) → client cached lazy per (exchange, env)
|
||||||
|
→ tool function (logica pura) → exchange API
|
||||||
|
```
|
||||||
|
|
||||||
|
File chiave:
|
||||||
|
- `src/cerbero_mcp/__main__.py` — `_make_app()` registra tutti i router.
|
||||||
|
- `src/cerbero_mcp/server.py` — `build_app()`, Swagger, exception handler,
|
||||||
|
e `_TimestampInjectorMiddleware` che inietta `data_timestamp` (ISO UTC) nel
|
||||||
|
top level di **ogni** risposta JSON sotto `/tools/*` (+ header `X-Data-Timestamp`).
|
||||||
|
- `src/cerbero_mcp/auth.py` — bearer → env, e `X-Bot-Tag` obbligatorio (path
|
||||||
|
whitelisted: `/health`, `/health/ready`, `/apidocs`, `/openapi.json`).
|
||||||
|
- `src/cerbero_mcp/client_registry.py` — cache `{(exchange, env): client}`.
|
||||||
|
- `src/cerbero_mcp/exchanges/__init__.py` — `build_client()`: factory per exchange.
|
||||||
|
- `src/cerbero_mcp/settings.py` — Pydantic Settings da `.env`.
|
||||||
|
- `src/cerbero_mcp/routers/` — un file per exchange + `unified.py` (`/mcp`) +
|
||||||
|
`cross.py` (`/mcp-cross`).
|
||||||
|
- `src/cerbero_mcp/exchanges/<ex>/{client.py,tools.py}` — client HTTP + schemi
|
||||||
|
Pydantic / wrapper tool. `cross/` contiene aggregatore consensus, interfaccia
|
||||||
|
unificata, normalizzatori e symbol-map.
|
||||||
|
|
||||||
|
## Exchange attivi
|
||||||
|
|
||||||
|
Trading: **deribit, hyperliquid, ibkr**. Data-provider read-only: **macro,
|
||||||
|
sentiment**. **Bybit e Alpaca sono ritirati** dalla superficie API in V2:
|
||||||
|
codice archiviato in `old/` (fuori dal package, non importato, non testato).
|
||||||
|
Nota: nel modulo `sentiment` "bybit" resta come **fonte dati pubblica**
|
||||||
|
(funding/OI), non come client di trading.
|
||||||
|
|
||||||
|
## Interfacce dati
|
||||||
|
|
||||||
|
- **`/mcp` (comune)** — gli **unici** tool dati trasversali. `get_instruments`
|
||||||
|
(schema uniforme: `exchange`, `fees`, `history_start`, `type`, `tick_size`,
|
||||||
|
`native`; filtri/paginazione Deribit + `meta` per-sorgente); `get_historical`
|
||||||
|
e `get_indicators` con `exchange` opzionale → singolo venue se valorizzato,
|
||||||
|
**consenso** multi-exchange (mediana OHLC + `div_pct`) se omesso. Supporta
|
||||||
|
deribit + hyperliquid (IBKR non integrato).
|
||||||
|
- **`/mcp-{exchange}`** — router per-exchange per il resto (ticker, ordini,
|
||||||
|
options analytics, …). **NON** espongono più
|
||||||
|
`get_instruments`/`get_historical`/`get_indicators`.
|
||||||
|
|
||||||
|
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`. Il calcolo indicatori
|
||||||
|
è centralizzato in `common/indicators.py::compute_indicators`.
|
||||||
|
|
||||||
|
## Convenzioni
|
||||||
|
|
||||||
|
- Schemi richiesta = modelli Pydantic in `<ex>/tools.py`; il router fa solo
|
||||||
|
dependency-injection di env + client e chiama il wrapper del tool.
|
||||||
|
- Errori applicativi: sollevare `fastapi.HTTPException`; gli handler in
|
||||||
|
`server.py` li avvolgono in un envelope con `request_id`.
|
||||||
|
- Firme/crittografia: il signing L1 Hyperliquid usa `eth_utils.to_hex` come
|
||||||
|
l'SDK ufficiale → `r`/`s` hex possono essere < 66 char (byte alto a zero,
|
||||||
|
~1/256); non assumere lunghezza fissa.
|
||||||
|
- Branch di produzione: **`V2.0.0`** (non `main`). Deploy via
|
||||||
|
`scripts/deploy-vps.sh`.
|
||||||
|
|
||||||
|
## Documentazione
|
||||||
|
|
||||||
|
- `README.md` — overview, setup, deploy, migrazione V1→V2, IBKR OAuth.
|
||||||
|
- `API_REFERENCE.md` (root) — catalogo completo endpoint/tool. Aggiornarlo
|
||||||
|
quando si aggiungono/rimuovono tool o si cambiano gli schemi.
|
||||||
@@ -9,20 +9,25 @@ sul token bearer fornito dal client.
|
|||||||
|
|
||||||
- **Una singola immagine Docker** (`cerbero-mcp`) ospita tutti i router
|
- **Una singola immagine Docker** (`cerbero-mcp`) ospita tutti i router
|
||||||
exchange in un unico processo FastAPI
|
exchange in un unico processo FastAPI
|
||||||
- **Cinque exchange** (Deribit, Bybit, Hyperliquid, Alpaca, IBKR) e **due
|
- **Tre exchange** (Deribit, Hyperliquid, IBKR) e **due data provider**
|
||||||
data provider** read-only (Macro, Sentiment)
|
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`
|
||||||
|
(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
|
- **Switch testnet/mainnet per-request** tramite header
|
||||||
`Authorization: Bearer <TOKEN>`: lo stesso container serve entrambi gli
|
`Authorization: Bearer <TOKEN>`: lo stesso container serve entrambi gli
|
||||||
ambienti senza riavvii
|
ambienti senza riavvii
|
||||||
- **Configurazione interamente in `.env`**: nessun file JSON di credenziali
|
- **Configurazione interamente in `.env`**: nessun file JSON di credenziali
|
||||||
separato; le URL upstream (live/testnet) di ciascun exchange sono
|
separato; le URL upstream (live/testnet) di ciascun exchange sono
|
||||||
override-abili tramite variabili dedicate (`DERIBIT_URL_*`,
|
override-abili tramite variabili dedicate (`DERIBIT_URL_*`,
|
||||||
`BYBIT_URL_*`, `HYPERLIQUID_URL_*`, `ALPACA_URL_*`)
|
`HYPERLIQUID_URL_*`, `IBKR_URL_*`)
|
||||||
- **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs`
|
- **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs`
|
||||||
- **Endpoint cross-exchange unificato** (`/mcp-cross/tools/get_historical`):
|
- **Consenso multi-exchange** integrato in `/mcp/tools/get_historical`: ometti
|
||||||
fan-out a tutti gli exchange che supportano (symbol, asset_class) e
|
`exchange` per il fan-out e il merge per-bar (mediana OHLC + `div_pct` +
|
||||||
consensus per-bar (mediana OHLC + `div_pct` + `sources`)
|
`sources`)
|
||||||
- **Qualità verificata**: 399 test (unit + integration + smoke), mypy
|
- **Qualità verificata**: 323 test (unit + integration + smoke), mypy
|
||||||
pulito, ruff pulito
|
pulito, ruff pulito
|
||||||
|
|
||||||
## Avvio rapido (sviluppo, senza Docker)
|
## Avvio rapido (sviluppo, senza Docker)
|
||||||
@@ -87,14 +92,14 @@ non è richiesto sugli endpoint pubblici (`/health`, `/apidocs`,
|
|||||||
| `GET /health/ready` | Readiness check con ping client exchange (no auth) |
|
| `GET /health/ready` | Readiness check con ping client exchange (no auth) |
|
||||||
| `GET /apidocs` | Swagger UI (no auth) |
|
| `GET /apidocs` | Swagger UI (no auth) |
|
||||||
| `GET /openapi.json` | Schema OpenAPI 3.1 (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 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-deribit/tools/{tool}` | Tool exchange Deribit |
|
||||||
| `POST /mcp-bybit/tools/{tool}` | Tool exchange Bybit |
|
|
||||||
| `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid |
|
| `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid |
|
||||||
| `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca |
|
|
||||||
| `POST /mcp-ibkr/tools/{tool}` | Tool exchange Interactive Brokers |
|
| `POST /mcp-ibkr/tools/{tool}` | Tool exchange Interactive Brokers |
|
||||||
| `POST /mcp-macro/tools/{tool}` | Tool macro/market data |
|
| `POST /mcp-macro/tools/{tool}` | Tool macro/market data |
|
||||||
| `POST /mcp-sentiment/tools/{tool}` | Tool sentiment/news |
|
| `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) |
|
| `GET /admin/audit` | Query dell'audit log JSONL (bearer richiesto, no X-Bot-Tag) |
|
||||||
|
|
||||||
## Observability
|
## Observability
|
||||||
@@ -145,7 +150,7 @@ Parametri di query (tutti opzionali):
|
|||||||
|
|
||||||
- `from`, `to`: ISO 8601 datetime (es. `2026-05-01` o `2026-05-01T12:34:56Z`)
|
- `from`, `to`: ISO 8601 datetime (es. `2026-05-01` o `2026-05-01T12:34:56Z`)
|
||||||
- `actor`: `testnet` | `mainnet`
|
- `actor`: `testnet` | `mainnet`
|
||||||
- `exchange`: nome dell'exchange (`deribit`, `bybit`, `hyperliquid`, `alpaca`, `ibkr`)
|
- `exchange`: nome dell'exchange (`deribit`, `hyperliquid`, `ibkr`)
|
||||||
- `action`: nome del tool (es. `place_order`)
|
- `action`: nome del tool (es. `place_order`)
|
||||||
- `bot_tag`: identificatore del bot
|
- `bot_tag`: identificatore del bot
|
||||||
- `limit`: massimo record restituiti, default `1000`, massimo `10000`
|
- `limit`: massimo record restituiti, default `1000`, massimo `10000`
|
||||||
@@ -173,23 +178,15 @@ Tecnici (`sma`, `rsi`, `macd`, `atr`, `adx`), volatilità (`vol_cone`,
|
|||||||
|
|
||||||
### Deribit
|
### Deribit
|
||||||
DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol,
|
DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol,
|
||||||
indicatori tecnici, find_by_delta, calculate_spread_payoff,
|
find_by_delta, calculate_spread_payoff, get_dealer_gamma_profile,
|
||||||
get_dealer_gamma_profile, get_vanna_charm, get_oi_weighted_skew,
|
get_vanna_charm, get_oi_weighted_skew, get_smile_asymmetry,
|
||||||
get_smile_asymmetry, get_atm_vs_wings_vol, get_orderbook_imbalance,
|
get_atm_vs_wings_vol, get_orderbook_imbalance, run_backtest, place_combo_order.
|
||||||
place_combo_order.
|
(Strumenti/storico/indicatori → `/mcp`.)
|
||||||
|
|
||||||
### Bybit
|
|
||||||
Ticker, orderbook, OHLCV, funding rate, open interest, basis spot/perp,
|
|
||||||
indicatori tecnici, place_batch_order, get_orderbook_imbalance,
|
|
||||||
get_basis_term_structure.
|
|
||||||
|
|
||||||
### Hyperliquid
|
### Hyperliquid
|
||||||
Account summary, positions, orderbook, historical, indicators, funding
|
Account summary, positions, orderbook, funding rate, basis spot/perp,
|
||||||
rate, basis spot/perp, place_order, set_stop_loss, set_take_profit.
|
place_order, set_stop_loss, set_take_profit.
|
||||||
|
(Strumenti/storico/indicatori → `/mcp`.)
|
||||||
### Alpaca
|
|
||||||
Account, positions, bars, snapshot, option chain, place_order,
|
|
||||||
amend_order, cancel_order, close_position.
|
|
||||||
|
|
||||||
### IBKR (Interactive Brokers)
|
### IBKR (Interactive Brokers)
|
||||||
Account, positions, activities, ticker, bars, snapshot, option chain,
|
Account, positions, activities, ticker, bars, snapshot, option chain,
|
||||||
@@ -208,15 +205,26 @@ News (CryptoPanic/CoinDesk), social (LunarCrush), funding multi-exchange,
|
|||||||
OI history, get_funding_arb_spread, get_liquidation_heatmap,
|
OI history, get_funding_arb_spread, get_liquidation_heatmap,
|
||||||
get_cointegration_pairs.
|
get_cointegration_pairs.
|
||||||
|
|
||||||
### Cross (storico unificato)
|
### Unified (`/mcp`) — interfaccia comune
|
||||||
`get_historical` aggrega le candele dello stesso simbolo da tutti gli
|
Espone gli **unici** tool dati trasversali (storico e indicatori vivono solo
|
||||||
exchange che lo supportano e ritorna una serie consensus: la chiusura è
|
qui, non per-exchange). Exchange supportati: `deribit`, `hyperliquid` (IBKR non
|
||||||
la mediana, `sources` è il numero di exchange che hanno contribuito al
|
ancora integrato — usa `/mcp-ibkr`).
|
||||||
bar e `div_pct = (max-min)/median` segnala il disaccordo tra fonti — un
|
|
||||||
quality gate per i bot. Crypto: BTC/ETH/SOL via Bybit + Hyperliquid +
|
- **`get_instruments`** — lista strumenti **uniforme**: ogni elemento porta in
|
||||||
Deribit. Stocks: AAPL/SPY/QQQ/TSLA/NVDA via Alpaca. In caso di fallimento
|
sé `exchange`, `fees` (maker/taker, live da Deribit, `null` dove l'exchange
|
||||||
parziale ritorna i dati disponibili più `failed_sources`; se *tutti* gli
|
non li espone), `history_start` (data inizio storico, live da Deribit),
|
||||||
upstream falliscono → HTTP 502 retryable.
|
`type`, `tick_size` e un blocco `native` lossless. `exchange` opzionale →
|
||||||
|
filtra una sorgente; se omesso fan-out su tutti. Filtri avanzati Deribit
|
||||||
|
(`kind`, `expiry_*`, `strike_*`, `min_open_interest`) e paginazione
|
||||||
|
(`offset`/`limit`, con `meta.deribit.has_more`).
|
||||||
|
- **`get_historical`** — `{exchange?, instrument, interval, start_date,
|
||||||
|
end_date}`. Con `exchange` ritorna il **singolo** exchange; **omettendolo**
|
||||||
|
fa il **consenso** multi-exchange (mediana OHLC, `sources`,
|
||||||
|
`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
|
## Deploy su VPS con Traefik
|
||||||
|
|
||||||
@@ -302,7 +310,7 @@ PORT=9000 TESTNET_TOKEN="$TESTNET_TOKEN" bash tests/smoke/run.sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync
|
||||||
uv run pytest # tutta la suite (399 test attesi)
|
uv run pytest # tutta la suite (323 test attesi)
|
||||||
uv run pytest tests/unit -v # solo unit
|
uv run pytest tests/unit -v # solo unit
|
||||||
uv run pytest tests/integration -v
|
uv run pytest tests/integration -v
|
||||||
uv run ruff check src/ tests/
|
uv run ruff check src/ tests/
|
||||||
@@ -320,9 +328,11 @@ src/cerbero_mcp/
|
|||||||
├── auth.py # middleware bearer → request.state.environment
|
├── auth.py # middleware bearer → request.state.environment
|
||||||
├── server.py # build_app() + Swagger + middleware + handlers
|
├── server.py # build_app() + Swagger + middleware + handlers
|
||||||
├── client_registry.py # cache lazy {(exchange, env): client}
|
├── client_registry.py # cache lazy {(exchange, env): client}
|
||||||
├── routers/ # un file per exchange (deribit, bybit, ...)
|
├── routers/ # un file per exchange + unified.py (/mcp) + cross.py
|
||||||
├── exchanges/ # logica per-exchange: client + tools
|
├── exchanges/ # logica per-exchange: client + tools (+ cross/)
|
||||||
└── common/ # indicators, options, microstructure, stats, ...
|
└── common/ # indicators, options, microstructure, stats, ...
|
||||||
|
|
||||||
|
old/ # codice ritirato (bybit, alpaca) — fuori dal package
|
||||||
```
|
```
|
||||||
|
|
||||||
## Migrazione da V1 (1.x → 2.0.0)
|
## Migrazione da V1 (1.x → 2.0.0)
|
||||||
@@ -336,12 +346,13 @@ Per chi è in produzione su V1:
|
|||||||
| V1 (file JSON) | V2 (variabile `.env`) |
|
| V1 (file JSON) | V2 (variabile `.env`) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `secrets/deribit.json` `client_id` / `client_secret` | `DERIBIT_CLIENT_ID` / `DERIBIT_CLIENT_SECRET` |
|
| `secrets/deribit.json` `client_id` / `client_secret` | `DERIBIT_CLIENT_ID` / `DERIBIT_CLIENT_SECRET` |
|
||||||
| `secrets/bybit.json` `api_key` / `api_secret` | `BYBIT_API_KEY` / `BYBIT_API_SECRET` |
|
|
||||||
| `secrets/hyperliquid.json` `wallet_address` / `private_key` | `HYPERLIQUID_WALLET_ADDRESS` / `HYPERLIQUID_PRIVATE_KEY` |
|
| `secrets/hyperliquid.json` `wallet_address` / `private_key` | `HYPERLIQUID_WALLET_ADDRESS` / `HYPERLIQUID_PRIVATE_KEY` |
|
||||||
| `secrets/alpaca.json` `api_key_id` / `secret_key` | `ALPACA_API_KEY_ID` / `ALPACA_SECRET_KEY` |
|
|
||||||
| `secrets/macro.json` `fred_api_key` / `finnhub_api_key` | `FRED_API_KEY` / `FINNHUB_API_KEY` |
|
| `secrets/macro.json` `fred_api_key` / `finnhub_api_key` | `FRED_API_KEY` / `FINNHUB_API_KEY` |
|
||||||
| `secrets/sentiment.json` `cryptopanic_key` / `lunarcrush_key` | `CRYPTOPANIC_KEY` / `LUNARCRUSH_KEY` |
|
| `secrets/sentiment.json` `cryptopanic_key` / `lunarcrush_key` | `CRYPTOPANIC_KEY` / `LUNARCRUSH_KEY` |
|
||||||
|
|
||||||
|
> Bybit e Alpaca non sono più esposti in V2 (vedi `old/`); le rispettive
|
||||||
|
> variabili `BYBIT_*` / `ALPACA_*` non sono più lette.
|
||||||
|
|
||||||
4. Aggiornare i client bot:
|
4. Aggiornare i client bot:
|
||||||
- i path API restano identici (`/mcp-{exchange}/tools/{tool}`)
|
- i path API restano identici (`/mcp-{exchange}/tools/{tool}`)
|
||||||
- sostituire `core.token` / `observer.token` con `TESTNET_TOKEN` o
|
- sostituire `core.token` / `observer.token` con `TESTNET_TOKEN` o
|
||||||
@@ -378,10 +389,8 @@ Bot → Authorization: Bearer <TESTNET|MAINNET>_TOKEN
|
|||||||
### Override URL upstream
|
### Override URL upstream
|
||||||
|
|
||||||
L'override delle URL upstream da `.env` è completo per Deribit e
|
L'override delle URL upstream da `.env` è completo per Deribit e
|
||||||
Hyperliquid. Per Bybit funziona tramite l'attributo `endpoint` interno di
|
Hyperliquid (`DERIBIT_URL_*`, `HYPERLIQUID_URL_*`). IBKR usa
|
||||||
pybit (workaround documentato nel client). Per Alpaca l'override è
|
`IBKR_URL_*` / `IBKR_WS_URL_*`.
|
||||||
applicato al solo trading endpoint: gli endpoint dati
|
|
||||||
(`data.alpaca.markets`) restano quelli predefiniti dell'SDK.
|
|
||||||
|
|
||||||
## IBKR Setup
|
## IBKR Setup
|
||||||
|
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
# Cerbero MCP — API Reference (V2.0.0)
|
|
||||||
|
|
||||||
Documento di riferimento completo per tutti gli endpoint HTTP e i tool MCP
|
|
||||||
esposti dal container `cerbero-mcp`. Generato dall'analisi diretta dei
|
|
||||||
router FastAPI in `src/cerbero_mcp/routers/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Autenticazione
|
|
||||||
|
|
||||||
Tutte le chiamate ai namespace `/mcp-*` richiedono:
|
|
||||||
|
|
||||||
| Header | Valore | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| `Authorization` | `Bearer <TOKEN>` | `TESTNET_TOKEN` → upstream testnet · `MAINNET_TOKEN` → upstream live · token sconosciuto → `401` |
|
|
||||||
| `X-Bot-Tag` | stringa ≤ 64 char | identifica il bot chiamante, loggato nell'audit · mancante → `400` |
|
|
||||||
|
|
||||||
Gli endpoint pubblici (`/health`, `/apidocs`, `/openapi.json`) non
|
|
||||||
richiedono auth. L'endpoint `/admin/audit` richiede bearer ma **non**
|
|
||||||
richiede `X-Bot-Tag`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Endpoint pubblici
|
|
||||||
|
|
||||||
| Path | Metodo | Descrizione |
|
|
||||||
|---|---|---|
|
|
||||||
| `/health` | GET | Liveness — sempre `200` finché il processo è vivo |
|
|
||||||
| `/health/ready` | GET | Readiness — itera i client registry, status `ready` / `degraded` / `not_ready`. Forza `503` se `READY_FAILS_ON_DEGRADED=true` |
|
|
||||||
| `/apidocs` | GET | Swagger UI interattiva |
|
|
||||||
| `/openapi.json` | GET | Schema OpenAPI 3.1 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Endpoint admin
|
|
||||||
|
|
||||||
| Path | Metodo | Descrizione |
|
|
||||||
|---|---|---|
|
|
||||||
| `/admin/audit` | GET | Query del JSONL `AUDIT_LOG_FILE`. Filtri: `from`, `to`, `actor`, `exchange`, `action`, `bot_tag`, `limit` (default 1000, max 10000) |
|
|
||||||
| `/admin/ibkr/rotate-keys/confirm?env=` | POST | Swap atomico chiavi IBKR con auto-rollback su validazione fallita |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Namespace MCP — panoramica
|
|
||||||
|
|
||||||
| Namespace | Tool | Tipo | Note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/mcp-deribit/tools/*` | 33 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff |
|
|
||||||
| `/mcp-bybit/tools/*` | 30 | exchange (spot + perp + options) | basis, funding, batch orders |
|
|
||||||
| `/mcp-hyperliquid/tools/*` | 16 | exchange (perp DEX) | L1 signing, leverage cap |
|
|
||||||
| `/mcp-alpaca/tools/*` | 18 | exchange (US stocks + options) | clock/calendar, fractional |
|
|
||||||
| `/mcp-ibkr/tools/*` | 24 | exchange (multi-asset broker) | OAuth 1.0a, streaming WS, bracket/OCO/OTO |
|
|
||||||
| `/mcp-macro/tools/*` | 11 | data provider (read-only) | yields, FRED, COT |
|
|
||||||
| `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange |
|
|
||||||
| `/mcp-cross/tools/*` | 1 | aggregator | storico consensus multi-exchange |
|
|
||||||
|
|
||||||
**Totale**: ~142 tool MCP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. `/mcp-deribit/tools/*`
|
|
||||||
|
|
||||||
### Info
|
|
||||||
- `is_testnet` — flag ambiente corrente
|
|
||||||
- `environment_info` — stringa diagnostica completa
|
|
||||||
|
|
||||||
### Market data
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
### Account
|
|
||||||
- `get_positions`
|
|
||||||
- `get_account_summary`
|
|
||||||
|
|
||||||
### Options analytics
|
|
||||||
- `get_dvol`, `get_dvol_history`
|
|
||||||
- `get_gex`, `get_dealer_gamma_profile`
|
|
||||||
- `get_vanna_charm`
|
|
||||||
- `get_oi_weighted_skew`, `get_smile_asymmetry`, `get_atm_vs_wings_vol`
|
|
||||||
- `get_pc_ratio`, `get_skew_25d`, `get_term_structure`
|
|
||||||
- `get_iv_rank`, `get_realized_vol`
|
|
||||||
- `find_by_delta`, `calculate_spread_payoff`
|
|
||||||
|
|
||||||
### Technicals
|
|
||||||
- `get_technical_indicators`
|
|
||||||
- `run_backtest`
|
|
||||||
|
|
||||||
### Write (richiede leverage cap)
|
|
||||||
- `place_order`, `place_combo_order`
|
|
||||||
- `cancel_order`
|
|
||||||
- `set_stop_loss`, `set_take_profit`
|
|
||||||
- `close_position`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. `/mcp-bybit/tools/*`
|
|
||||||
|
|
||||||
### Info
|
|
||||||
- `environment_info`
|
|
||||||
|
|
||||||
### Market data
|
|
||||||
- `get_ticker`, `get_ticker_batch`
|
|
||||||
- `get_orderbook`, `get_orderbook_imbalance`
|
|
||||||
- `get_historical`
|
|
||||||
- `get_instruments`, `get_option_chain`
|
|
||||||
- `get_trade_history`
|
|
||||||
|
|
||||||
### Derivati
|
|
||||||
- `get_funding_rate`, `get_funding_history`
|
|
||||||
- `get_open_interest`
|
|
||||||
- `get_basis_spot_perp`, `get_basis_term_structure`
|
|
||||||
|
|
||||||
### Account
|
|
||||||
- `get_positions`, `get_account_summary`, `get_open_orders`
|
|
||||||
|
|
||||||
### Technicals
|
|
||||||
- `get_indicators`
|
|
||||||
|
|
||||||
### Write
|
|
||||||
- `place_order`, `place_combo_order`
|
|
||||||
- `amend_order`, `cancel_order`, `cancel_all_orders`
|
|
||||||
- `set_stop_loss`, `set_take_profit`, `close_position`
|
|
||||||
- `set_leverage`, `switch_position_mode`
|
|
||||||
- `transfer_asset`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. `/mcp-hyperliquid/tools/*`
|
|
||||||
|
|
||||||
### Info
|
|
||||||
- `environment_info`
|
|
||||||
|
|
||||||
### Market data
|
|
||||||
- `get_markets`, `get_ticker`, `get_orderbook`
|
|
||||||
- `get_historical`, `get_trade_history`
|
|
||||||
- `get_funding_rate`, `basis_spot_perp`
|
|
||||||
|
|
||||||
### 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`
|
|
||||||
- `close_position`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. `/mcp-alpaca/tools/*`
|
|
||||||
|
|
||||||
### Info / calendar
|
|
||||||
- `environment_info`, `get_clock`, `get_calendar`
|
|
||||||
|
|
||||||
### Market data
|
|
||||||
- `get_assets`, `get_ticker`
|
|
||||||
- `get_bars`, `get_snapshot`
|
|
||||||
- `get_option_chain`
|
|
||||||
|
|
||||||
### Account
|
|
||||||
- `get_account`, `get_positions`, `get_activities`, `get_open_orders`
|
|
||||||
|
|
||||||
### Write
|
|
||||||
- `place_order`, `amend_order`
|
|
||||||
- `cancel_order`, `cancel_all_orders`
|
|
||||||
- `close_position`, `close_all_positions`
|
|
||||||
|
|
||||||
> **Nota**: override URL applicato solo all'endpoint trading. Gli
|
|
||||||
> endpoint dati (`data.alpaca.markets`) restano i default SDK.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. `/mcp-ibkr/tools/*`
|
|
||||||
|
|
||||||
Auth via OAuth 1.0a Self-Service con minting di session token
|
|
||||||
unattended. Setup one-time per ambiente (paper + live) — vedi sezione
|
|
||||||
"IBKR Setup" del README.
|
|
||||||
|
|
||||||
### Info / clock
|
|
||||||
- `environment_info`, `get_clock`
|
|
||||||
|
|
||||||
### Account
|
|
||||||
- `get_account`, `get_positions`
|
|
||||||
- `get_open_orders`, `get_activities`
|
|
||||||
|
|
||||||
### Market data
|
|
||||||
- `get_ticker`, `get_bars`, `get_snapshot`
|
|
||||||
- `get_option_chain`, `search_contracts`
|
|
||||||
|
|
||||||
### Streaming (WebSocket singleton)
|
|
||||||
- `get_tick`, `get_depth`
|
|
||||||
- `subscribe_tick`, `unsubscribe`
|
|
||||||
|
|
||||||
### Write
|
|
||||||
- `place_order`, `amend_order`
|
|
||||||
- `cancel_order`, `cancel_all_orders`
|
|
||||||
- `close_position`, `close_all_positions`
|
|
||||||
|
|
||||||
### Complex orders
|
|
||||||
- `place_bracket_order` — entry + TP + SL atomico
|
|
||||||
- `place_oco_order` — One-Cancels-Other (OCA group)
|
|
||||||
- `place_oto_order` — One-Triggers-Other (parent → child)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. `/mcp-macro/tools/*` (read-only)
|
|
||||||
|
|
||||||
| Tool | Sorgente |
|
|
||||||
|---|---|
|
|
||||||
| `get_economic_indicators` | FRED + Yahoo |
|
|
||||||
| `get_macro_calendar` | Finnhub + impact heuristic |
|
|
||||||
| `get_market_overview` | mix Yahoo + Deribit DVOL |
|
|
||||||
| `get_asset_price` | Yahoo Finance |
|
|
||||||
| `get_treasury_yields` | US Treasury XML |
|
|
||||||
| `get_equity_futures` | Yahoo |
|
|
||||||
| `get_yield_curve_slope` | derivato da treasury yields |
|
|
||||||
| `get_breakeven_inflation` | FRED (T10YIE / T5YIE) |
|
|
||||||
| `get_cot_tff` | CFTC TFF report |
|
|
||||||
| `get_cot_disaggregated` | CFTC disaggregated report |
|
|
||||||
| `get_cot_extreme_positioning` | percentili COT (gate ±5%) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. `/mcp-sentiment/tools/*` (read-only)
|
|
||||||
|
|
||||||
| Tool | Fonte |
|
|
||||||
|---|---|
|
|
||||||
| `get_crypto_news` | CryptoPanic + CoinDesk + CryptoCompare + Messari (deduped) |
|
|
||||||
| `get_world_news` | aggregato generalista |
|
|
||||||
| `get_social_sentiment` | LunarCrush + Fear&Greed Index |
|
|
||||||
| `get_funding_rates` | per-asset multi-exchange |
|
|
||||||
| `get_cross_exchange_funding` | snapshot multi-asset multi-venue |
|
|
||||||
| `get_funding_arb_spread` | spread arbitraggio funding |
|
|
||||||
| `get_oi_history` | open interest serie storica |
|
|
||||||
| `get_liquidation_heatmap` | heatmap liquidazioni |
|
|
||||||
| `get_cointegration_pairs` | screening coppie cointegrate |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. `/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, SOL via Bybit +
|
|
||||||
Hyperliquid + Deribit.
|
|
||||||
**Stocks** (`asset_class: "stocks"`): AAPL, SPY, QQQ, TSLA, NVDA via
|
|
||||||
Alpaca.
|
|
||||||
|
|
||||||
In caso di fallimento parziale ritorna i bar disponibili più
|
|
||||||
`failed_sources: [...]`. Se *tutti* gli upstream falliscono → `502
|
|
||||||
Bad Gateway` retryable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Observability
|
|
||||||
|
|
||||||
### Request log (`mcp.request`)
|
|
||||||
Una riga JSON per richiesta con: `request_id`, `method`, `path`,
|
|
||||||
`status_code`, `duration_ms`, `actor`, `bot_tag`, `exchange`, `tool`,
|
|
||||||
`client_ip`, `user_agent`.
|
|
||||||
|
|
||||||
### Audit log (`mcp.audit`)
|
|
||||||
Persistito su `AUDIT_LOG_FILE` (JSONL) per le operazioni write.
|
|
||||||
Rotazione configurabile via `AUDIT_LOG_BACKUP_DAYS`.
|
|
||||||
|
|
||||||
### Correlation
|
|
||||||
Lo stesso `request_id` appare in `mcp.request`, `mcp.audit` e
|
|
||||||
nell'envelope di errore restituito al client.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Esempio chiamata
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:9000/mcp-bybit/tools/get_ticker \
|
|
||||||
-H "Authorization: Bearer $MAINNET_TOKEN" \
|
|
||||||
-H "X-Bot-Tag: scanner-alpha-prod" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"symbol":"BTCUSDT","category":"linear"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Per lo schema completo dei body di richiesta e risposta:
|
|
||||||
<http://localhost:9000/apidocs>.
|
|
||||||
+14
-3
@@ -42,7 +42,17 @@ if [[ -z "${PORT:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
PORT="${PORT:-9000}"
|
PORT="${PORT:-9000}"
|
||||||
HEALTH_URL="http://localhost:${PORT}/health"
|
# Health verificato DENTRO la rete del container: in produzione la porta NON è
|
||||||
|
# pubblicata sull'host (Traefik la raggiunge via rete docker), quindi un curl da
|
||||||
|
# localhost darebbe un falso negativo (e rischierebbe un rollback inutile).
|
||||||
|
HEALTH_URL="http://127.0.0.1:${PORT}/health"
|
||||||
|
|
||||||
|
# 0 se /health risponde 200 dall'interno del container.
|
||||||
|
app_health() {
|
||||||
|
docker compose exec -T "$SERVICE" python -c \
|
||||||
|
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('${HEALTH_URL}').status==200 else 1)" \
|
||||||
|
>/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Pre-check ───────────────────────────────────────────────────────────
|
# ─── Pre-check ───────────────────────────────────────────────────────────
|
||||||
command -v git >/dev/null || { echo "FATAL: git non installato"; exit 1; }
|
command -v git >/dev/null || { echo "FATAL: git non installato"; exit 1; }
|
||||||
@@ -125,10 +135,11 @@ docker compose up -d
|
|||||||
echo "==> attendo /health (timeout ${HEALTH_TIMEOUT_SECONDS}s, retry ogni ${HEALTH_INTERVAL}s)"
|
echo "==> attendo /health (timeout ${HEALTH_TIMEOUT_SECONDS}s, retry ogni ${HEALTH_INTERVAL}s)"
|
||||||
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECONDS ))
|
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECONDS ))
|
||||||
while [[ $(date +%s) -lt $deadline ]]; do
|
while [[ $(date +%s) -lt $deadline ]]; do
|
||||||
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
|
if app_health; then
|
||||||
echo
|
echo
|
||||||
echo "==> health OK"
|
echo "==> health OK"
|
||||||
curl -s "$HEALTH_URL"
|
docker compose exec -T "$SERVICE" python -c \
|
||||||
|
"import urllib.request; print(urllib.request.urlopen('${HEALTH_URL}').read().decode())" 2>/dev/null || true
|
||||||
echo
|
echo
|
||||||
echo
|
echo
|
||||||
echo "==> deploy DONE (SHA $CURRENT_SHA → $NEW_SHA, branch $BRANCH)"
|
echo "==> deploy DONE (SHA $CURRENT_SHA → $NEW_SHA, branch $BRANCH)"
|
||||||
|
|||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rimuove certificati stale da acme.json di Traefik (domini non più serviti).
|
||||||
|
# Idempotente e atomico: stop Traefik -> backup -> filtro+valida -> ripristino
|
||||||
|
# perms/owner (600 root:adriano) -> restart Traefik.
|
||||||
|
#
|
||||||
|
# Uso: sudo bash scripts/prune-acme-stale.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACME="/opt/docker/traefik/acme.json"
|
||||||
|
CONTAINER="traefik-traefik-1"
|
||||||
|
# Domini da rimuovere (match esatto su .domain.main). Estendibile.
|
||||||
|
STALE=("mattermost.tielogic.xyz" "zar.tielogic.xyz" "registry.tielogic.xyz")
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Deve girare come root (usa: sudo bash $0)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ -f "$ACME" ]] || { echo "Non trovo $ACME" >&2; exit 1; }
|
||||||
|
|
||||||
|
ts="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
backup="${ACME}.bak-${ts}"
|
||||||
|
cp -p "$ACME" "$backup"
|
||||||
|
echo "Backup: $backup"
|
||||||
|
|
||||||
|
echo "=== Domini attualmente in storage ==="
|
||||||
|
python3 - "$ACME" <<'PY'
|
||||||
|
import json,sys
|
||||||
|
d=json.load(open(sys.argv[1]))
|
||||||
|
for res,blk in d.items():
|
||||||
|
for c in (blk or {}).get("Certificates") or []:
|
||||||
|
print(f" [{res}] {c.get('domain',{}).get('main')}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Stop $CONTAINER (evita riscrittura concorrente)…"
|
||||||
|
docker stop "$CONTAINER" >/dev/null
|
||||||
|
|
||||||
|
# Filtro in Python: rimuove i cert il cui domain.main è nella lista STALE.
|
||||||
|
removed=$(python3 - "$ACME" "${STALE[@]}" <<'PY'
|
||||||
|
import json,sys,os,tempfile
|
||||||
|
path=sys.argv[1]; stale=set(sys.argv[2:])
|
||||||
|
d=json.load(open(path))
|
||||||
|
removed=[]
|
||||||
|
for res,blk in d.items():
|
||||||
|
certs=(blk or {}).get("Certificates")
|
||||||
|
if not certs: continue
|
||||||
|
kept=[]
|
||||||
|
for c in certs:
|
||||||
|
main=c.get("domain",{}).get("main")
|
||||||
|
if main in stale: removed.append(main)
|
||||||
|
else: kept.append(c)
|
||||||
|
blk["Certificates"]=kept
|
||||||
|
# scrittura atomica nella stessa dir, poi os.replace
|
||||||
|
dir_=os.path.dirname(path)
|
||||||
|
fd,tmp=tempfile.mkstemp(dir=dir_)
|
||||||
|
with os.fdopen(fd,"w") as f:
|
||||||
|
json.dump(d,f,indent=2) # ri-serializza valido
|
||||||
|
os.replace(tmp,path)
|
||||||
|
print("\n".join(removed))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ripristina perms/owner che Traefik esige (600) e ownership originale.
|
||||||
|
chmod 600 "$ACME"
|
||||||
|
chown root:adriano "$ACME"
|
||||||
|
|
||||||
|
# Validazione finale: deve essere JSON valido (altrimenti rollback dal backup).
|
||||||
|
if ! python3 -c "import json,sys; json.load(open('$ACME'))" 2>/dev/null; then
|
||||||
|
echo "JSON risultante NON valido — rollback dal backup." >&2
|
||||||
|
cp -p "$backup" "$ACME"; chmod 600 "$ACME"; chown root:adriano "$ACME"
|
||||||
|
docker start "$CONTAINER" >/dev/null
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restart $CONTAINER…"
|
||||||
|
docker start "$CONTAINER" >/dev/null
|
||||||
|
|
||||||
|
echo "=== Rimossi ==="
|
||||||
|
if [[ -n "${removed//[$'\n ']/}" ]]; then echo "$removed" | sed 's/^/ /'; else echo " (nessuno — già puliti)"; fi
|
||||||
|
echo "=== Permessi finali ==="
|
||||||
|
ls -la "$ACME"
|
||||||
|
echo "Fatto. Se qualcosa va storto, ripristina: sudo cp -p $backup $ACME && sudo chmod 600 $ACME && docker restart $CONTAINER"
|
||||||
@@ -21,14 +21,12 @@ 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,
|
|
||||||
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 +64,11 @@ 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(unified.make_router())
|
||||||
app.include_router(admin.make_admin_router())
|
app.include_router(admin.make_admin_router())
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -414,3 +414,31 @@ def var_cvar(returns: list[float], confidences: list[float] | None = None) -> di
|
|||||||
out[f"var_{tag}"] = var
|
out[f"var_{tag}"] = var
|
||||||
out[f"cvar_{tag}"] = cvar
|
out[f"cvar_{tag}"] = cvar
|
||||||
return out
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
"""Cross-exchange historical aggregator.
|
"""Unified common interface over the integrated venues.
|
||||||
|
|
||||||
Fan-out a canonical (symbol, asset_class, interval, start, end) request to
|
A single client backs the `/mcp` router. `get_instruments` returns one
|
||||||
every active exchange that supports the pair, then merge the results into
|
uniform instrument list (each row carries its own `exchange`, `fees` and
|
||||||
a single consensus candle series with per-bar divergence metrics.
|
`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
|
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.common.indicators import compute_indicators
|
||||||
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 +26,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 +37,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,28 +53,87 @@ 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class CrossClient:
|
class UnifiedClient:
|
||||||
|
"""Single common interface over the integrated venues (deribit, hyperliquid)."""
|
||||||
|
|
||||||
def __init__(self, registry: _Registry, *, env: Environment):
|
def __init__(self, registry: _Registry, *, env: Environment):
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
self._env = env
|
self._env = env
|
||||||
|
|
||||||
|
# ── instruments ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _instruments_one(
|
||||||
|
self, exchange: str, filters: dict[str, Any],
|
||||||
|
) -> tuple[str, list[dict[str, Any]] | Exception, dict[str, Any] | None]:
|
||||||
|
try:
|
||||||
|
client = await self._registry.get(exchange, self._env)
|
||||||
|
if exchange == "deribit":
|
||||||
|
resp = await client.get_instruments(**filters)
|
||||||
|
meta = {k: resp[k] for k in ("total", "offset", "limit", "has_more")
|
||||||
|
if k in resp}
|
||||||
|
return exchange, normalize_deribit(resp.get("instruments", [])), meta
|
||||||
|
if exchange == "hyperliquid":
|
||||||
|
rows = await client.get_markets()
|
||||||
|
return exchange, normalize_hyperliquid(rows), None
|
||||||
|
raise ValueError(f"no instrument normalizer for {exchange}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return exchange, e, None
|
||||||
|
|
||||||
|
async def get_instruments(
|
||||||
|
self, *, exchange: str | None = None,
|
||||||
|
currency: str = "BTC", kind: str | None = "future",
|
||||||
|
expiry_from: str | None = None, expiry_to: str | None = None,
|
||||||
|
strike_min: float | None = None, strike_max: float | None = None,
|
||||||
|
min_open_interest: float | None = None,
|
||||||
|
offset: int = 0, limit: int = 100,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if exchange is not None and exchange not in UNIFIED_EXCHANGES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"unsupported exchange: {exchange}; "
|
||||||
|
f"supported: {list(UNIFIED_EXCHANGES)}",
|
||||||
|
)
|
||||||
|
targets = [exchange] if exchange else list(UNIFIED_EXCHANGES)
|
||||||
|
# Deribit-specific filters; venues that list everything ignore them.
|
||||||
|
deribit_filters = {
|
||||||
|
"currency": currency, "kind": kind,
|
||||||
|
"expiry_from": expiry_from, "expiry_to": expiry_to,
|
||||||
|
"strike_min": strike_min, "strike_max": strike_max,
|
||||||
|
"min_open_interest": min_open_interest,
|
||||||
|
"offset": offset, "limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(self._instruments_one(ex, deribit_filters) for ex in targets)
|
||||||
|
)
|
||||||
|
|
||||||
|
instruments: list[dict[str, Any]] = []
|
||||||
|
failed: list[dict[str, str]] = []
|
||||||
|
meta: dict[str, Any] = {}
|
||||||
|
for ex, payload, ex_meta in results:
|
||||||
|
if isinstance(payload, Exception):
|
||||||
|
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
|
||||||
|
else:
|
||||||
|
instruments.extend(payload)
|
||||||
|
if ex_meta:
|
||||||
|
meta[ex] = ex_meta
|
||||||
|
|
||||||
|
if not instruments and failed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={"error": "all sources failed", "failed_sources": failed},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"instruments": instruments, "failed_sources": failed, "meta": meta}
|
||||||
|
|
||||||
|
# ── historical ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _fetch_one(
|
async def _fetch_one(
|
||||||
self, exchange: str, native_sym: str, native_interval: str,
|
self, exchange: str, native_sym: str, native_interval: str,
|
||||||
start: str, end: str,
|
start: str, end: str,
|
||||||
@@ -94,33 +147,49 @@ class CrossClient:
|
|||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return exchange, e
|
return exchange, e
|
||||||
|
|
||||||
async def get_historical(
|
async def _historical_single(
|
||||||
self, *, symbol: str, asset_class: str, interval: str,
|
self, exchange: str, instrument: str, interval: str,
|
||||||
start_date: str, end_date: str,
|
start_date: str, end_date: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
sources = get_sources(asset_class, symbol)
|
if exchange not in _DISPATCH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"unsupported exchange: {exchange}; "
|
||||||
|
f"supported: {list(_DISPATCH.keys())}",
|
||||||
|
)
|
||||||
|
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", []),
|
||||||
|
"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:
|
if not sources:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"unsupported symbol/asset_class: {symbol} ({asset_class})",
|
detail=f"unsupported symbol/asset_class for consensus: "
|
||||||
|
f"{instrument} ({asset_class})",
|
||||||
)
|
)
|
||||||
if interval not in supported_intervals():
|
results = await asyncio.gather(*(
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"unsupported interval: {interval}; "
|
|
||||||
f"supported: {supported_intervals()}",
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
self._fetch_one(
|
self._fetch_one(
|
||||||
ex,
|
ex,
|
||||||
to_native_symbol(asset_class, symbol, ex),
|
to_native_symbol(asset_class, instrument, ex),
|
||||||
to_native_interval(interval, ex),
|
to_native_interval(interval, ex),
|
||||||
start_date, end_date,
|
start_date, end_date,
|
||||||
)
|
)
|
||||||
for ex in sources
|
for ex in sources
|
||||||
]
|
))
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
by_source: dict[str, list[dict[str, Any]]] = {}
|
by_source: dict[str, list[dict[str, Any]]] = {}
|
||||||
failed: list[dict[str, str]] = []
|
failed: list[dict[str, str]] = []
|
||||||
@@ -137,10 +206,60 @@ class CrossClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"symbol": symbol.upper(),
|
"exchange": None,
|
||||||
|
"instrument": instrument.upper(),
|
||||||
"asset_class": asset_class,
|
"asset_class": asset_class,
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
"candles": merge_candles(by_source),
|
"candles": merge_candles(by_source),
|
||||||
"sources_used": sorted(by_source.keys()),
|
"sources_used": sorted(by_source.keys()),
|
||||||
"failed_sources": failed,
|
"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
|
||||||
|
|||||||
@@ -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,28 +1,110 @@
|
|||||||
"""Pydantic schemas + thin tool wrappers for the /mcp-cross router."""
|
"""Pydantic schemas + thin tool wrappers for the unified /mcp router."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from cerbero_mcp.exchanges.cross.client import CrossClient
|
from cerbero_mcp.exchanges.cross.client import UnifiedClient
|
||||||
|
|
||||||
AssetClass = Literal["crypto", "stocks"]
|
AssetClass = Literal["crypto"]
|
||||||
|
Exchange = Literal["deribit", "hyperliquid"]
|
||||||
|
|
||||||
|
|
||||||
|
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'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetInstrumentsReq(BaseModel):
|
||||||
|
exchange: Exchange | None = None # None → fan-out over all integrated venues
|
||||||
|
# Deribit-only filters (ignored by venues that list everything, e.g. Hyperliquid)
|
||||||
|
currency: str = "BTC"
|
||||||
|
kind: str | None = "future" # future | option | spot
|
||||||
|
expiry_from: str | None = None
|
||||||
|
expiry_to: str | None = None
|
||||||
|
strike_min: float | None = None
|
||||||
|
strike_max: float | None = None
|
||||||
|
min_open_interest: float | None = None
|
||||||
|
offset: int = 0
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
|
||||||
class GetHistoricalReq(BaseModel):
|
class GetHistoricalReq(BaseModel):
|
||||||
symbol: str
|
# exchange set → single venue (instrument = native symbol)
|
||||||
asset_class: AssetClass = "crypto"
|
# exchange omitted → cross-exchange consensus (instrument = canonical symbol)
|
||||||
|
exchange: Exchange | None = None
|
||||||
|
instrument: str
|
||||||
interval: str = "1h"
|
interval: str = "1h"
|
||||||
start_date: str
|
start_date: str
|
||||||
end_date: str
|
end_date: str
|
||||||
|
asset_class: AssetClass = "crypto" # consensus routing only
|
||||||
|
|
||||||
|
|
||||||
async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict:
|
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:
|
||||||
|
return await client.get_instruments(
|
||||||
|
exchange=params.exchange,
|
||||||
|
currency=params.currency,
|
||||||
|
kind=params.kind,
|
||||||
|
expiry_from=params.expiry_from,
|
||||||
|
expiry_to=params.expiry_to,
|
||||||
|
strike_min=params.strike_min,
|
||||||
|
strike_max=params.strike_max,
|
||||||
|
min_open_interest=params.min_open_interest,
|
||||||
|
offset=params.offset,
|
||||||
|
limit=params.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_historical(client: UnifiedClient, params: GetHistoricalReq) -> dict:
|
||||||
return await client.get_historical(
|
return await client.get_historical(
|
||||||
symbol=params.symbol,
|
exchange=params.exchange,
|
||||||
asset_class=params.asset_class,
|
instrument=params.instrument,
|
||||||
interval=params.interval,
|
interval=params.interval,
|
||||||
start_date=params.start_date,
|
start_date=params.start_date,
|
||||||
end_date=params.end_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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import HTTPException
|
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 microstructure as micro
|
||||||
from cerbero_mcp.common import options as opt
|
from cerbero_mcp.common import options as opt
|
||||||
from cerbero_mcp.common.candles import validate_candles
|
from cerbero_mcp.common.candles import validate_candles
|
||||||
@@ -327,6 +326,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
|
||||||
]
|
]
|
||||||
@@ -409,6 +412,37 @@ class DeribitClient:
|
|||||||
"testnet": self.testnet,
|
"testnet": self.testnet,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def get_open_orders(
|
||||||
|
self, currency: str = "USDC", kind: str | None = None, type: str = "all"
|
||||||
|
) -> list:
|
||||||
|
"""Ordini APERTI sul conto: limit resting e trigger non scattati.
|
||||||
|
NB Deribit: con type='all' gli untriggered possono essere omessi ->
|
||||||
|
chi vuole anche i bracket interroga type='trigger_all' e merge per
|
||||||
|
order_id (e' cio' che fa il reconciler di PythagorasGoal)."""
|
||||||
|
params: dict = {"currency": currency, "type": type}
|
||||||
|
if kind:
|
||||||
|
params["kind"] = kind
|
||||||
|
raw = await self._request("private/get_open_orders_by_currency", params)
|
||||||
|
result = raw.get("result") or []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"order_id": o.get("order_id"),
|
||||||
|
"instrument": o.get("instrument_name"),
|
||||||
|
"direction": o.get("direction"),
|
||||||
|
"order_type": o.get("order_type"),
|
||||||
|
"order_state": o.get("order_state"),
|
||||||
|
"amount": o.get("amount"),
|
||||||
|
"filled_amount": o.get("filled_amount"),
|
||||||
|
"price": o.get("price"),
|
||||||
|
"trigger_price": o.get("trigger_price"),
|
||||||
|
"reduce_only": o.get("reduce_only"),
|
||||||
|
"label": o.get("label"),
|
||||||
|
"creation_timestamp": o.get("creation_timestamp"),
|
||||||
|
}
|
||||||
|
for o in result
|
||||||
|
if isinstance(o, dict)
|
||||||
|
]
|
||||||
|
|
||||||
async def get_trade_history(
|
async def get_trade_history(
|
||||||
self, limit: int = 100, instrument_name: str | None = None
|
self, limit: int = 100, instrument_name: str | None = None
|
||||||
) -> list:
|
) -> list:
|
||||||
@@ -1598,37 +1632,6 @@ class DeribitClient:
|
|||||||
"testnet": self.testnet,
|
"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 ──────────────────────────────────────────────
|
# ── Write tools ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def place_order(
|
async def place_order(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
from typing import Any
|
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.client import DeribitClient
|
||||||
from cerbero_mcp.exchanges.deribit.leverage_cap import (
|
from cerbero_mcp.exchanges.deribit.leverage_cap import (
|
||||||
@@ -51,16 +51,6 @@ class GetTickerBatchReq(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class GetInstrumentsReq(BaseModel):
|
|
||||||
currency: str
|
|
||||||
kind: str | None = None
|
|
||||||
expiry_from: str | None = None
|
|
||||||
expiry_to: str | None = None
|
|
||||||
strike_min: float | None = None
|
|
||||||
strike_max: float | None = None
|
|
||||||
min_open_interest: float | None = None
|
|
||||||
limit: int = 100
|
|
||||||
offset: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class GetOrderbookReq(BaseModel):
|
class GetOrderbookReq(BaseModel):
|
||||||
@@ -81,18 +71,17 @@ class GetAccountSummaryReq(BaseModel):
|
|||||||
currency: str = "USDC"
|
currency: str = "USDC"
|
||||||
|
|
||||||
|
|
||||||
|
class GetOpenOrdersReq(BaseModel):
|
||||||
|
currency: str = "USDC"
|
||||||
|
kind: str | None = None
|
||||||
|
type: str = "all"
|
||||||
|
|
||||||
|
|
||||||
class GetTradeHistoryReq(BaseModel):
|
class GetTradeHistoryReq(BaseModel):
|
||||||
limit: int = 100
|
limit: int = 100
|
||||||
instrument_name: str | None = None
|
instrument_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GetHistoricalReq(BaseModel):
|
|
||||||
instrument: str
|
|
||||||
start_date: str
|
|
||||||
end_date: str
|
|
||||||
resolution: str = "1h"
|
|
||||||
|
|
||||||
|
|
||||||
class GetDvolReq(BaseModel):
|
class GetDvolReq(BaseModel):
|
||||||
currency: str = "BTC"
|
currency: str = "BTC"
|
||||||
start_date: str
|
start_date: str
|
||||||
@@ -168,36 +157,6 @@ class FindByDeltaReq(BaseModel):
|
|||||||
min_volume_24h: float = 20.0
|
min_volume_24h: float = 20.0
|
||||||
|
|
||||||
|
|
||||||
class GetIndicatorsReq(BaseModel):
|
|
||||||
instrument: str
|
|
||||||
indicators: list[str]
|
|
||||||
start_date: str
|
|
||||||
end_date: str
|
|
||||||
resolution: str = "1h"
|
|
||||||
|
|
||||||
@field_validator("indicators", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _coerce_indicators(cls, v):
|
|
||||||
if isinstance(v, str):
|
|
||||||
import json
|
|
||||||
|
|
||||||
s = v.strip()
|
|
||||||
if s.startswith("["):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(s)
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
return [str(x).strip() for x in parsed if str(x).strip()]
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
return [x.strip() for x in s.split(",") if x.strip()]
|
|
||||||
if isinstance(v, list):
|
|
||||||
return v
|
|
||||||
raise ValueError(
|
|
||||||
"indicators must be a list like ['rsi','atr','macd'] "
|
|
||||||
"or a comma-separated string like 'rsi,atr,macd'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlaceOrderReq(BaseModel):
|
class PlaceOrderReq(BaseModel):
|
||||||
instrument_name: str
|
instrument_name: str
|
||||||
side: str # "buy" | "sell"
|
side: str # "buy" | "sell"
|
||||||
@@ -305,20 +264,6 @@ async def get_ticker_batch(client: DeribitClient, params: GetTickerBatchReq) ->
|
|||||||
return await client.get_ticker_batch(params.instrument_names)
|
return await client.get_ticker_batch(params.instrument_names)
|
||||||
|
|
||||||
|
|
||||||
async def get_instruments(client: DeribitClient, params: GetInstrumentsReq) -> dict:
|
|
||||||
return await client.get_instruments(
|
|
||||||
currency=params.currency,
|
|
||||||
kind=params.kind,
|
|
||||||
expiry_from=params.expiry_from,
|
|
||||||
expiry_to=params.expiry_to,
|
|
||||||
strike_min=params.strike_min,
|
|
||||||
strike_max=params.strike_max,
|
|
||||||
min_open_interest=params.min_open_interest,
|
|
||||||
limit=params.limit,
|
|
||||||
offset=params.offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_orderbook(client: DeribitClient, params: GetOrderbookReq) -> dict:
|
async def get_orderbook(client: DeribitClient, params: GetOrderbookReq) -> dict:
|
||||||
return await client.get_orderbook(params.instrument_name, params.depth)
|
return await client.get_orderbook(params.instrument_name, params.depth)
|
||||||
|
|
||||||
@@ -339,16 +284,14 @@ async def get_account_summary(
|
|||||||
return await client.get_account_summary(params.currency)
|
return await client.get_account_summary(params.currency)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_open_orders(client: DeribitClient, params: GetOpenOrdersReq) -> list:
|
||||||
|
return await client.get_open_orders(params.currency, params.kind, params.type)
|
||||||
|
|
||||||
|
|
||||||
async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) -> list:
|
async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) -> list:
|
||||||
return await client.get_trade_history(params.limit, params.instrument_name)
|
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:
|
async def get_dvol(client: DeribitClient, params: GetDvolReq) -> dict:
|
||||||
return await client.get_dvol(
|
return await client.get_dvol(
|
||||||
params.currency, params.start_date, params.end_date, params.resolution
|
params.currency, params.start_date, params.end_date, params.resolution
|
||||||
@@ -448,18 +391,6 @@ async def get_realized_vol(client: DeribitClient, params: GetRealizedVolReq) ->
|
|||||||
return await client.get_realized_vol(params.currency, params.windows)
|
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) ===
|
# === Tools (writes) ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from eth_account import Account
|
|||||||
from eth_account.messages import encode_typed_data
|
from eth_account.messages import encode_typed_data
|
||||||
from eth_utils import keccak, to_hex
|
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.candles import validate_candles
|
||||||
from cerbero_mcp.common.http import async_client
|
from cerbero_mcp.common.http import async_client
|
||||||
|
|
||||||
@@ -42,6 +41,24 @@ RESOLUTION_MAP = {
|
|||||||
"1d": "1d",
|
"1d": "1d",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Hyperliquid's candleSnapshot returns at most ~5000 candles per call and, when
|
||||||
|
# the range needs more, keeps the newest slice and silently drops the oldest.
|
||||||
|
# We page through wide ranges in windows sized to this cap so start_date is honored.
|
||||||
|
_MAX_CANDLES_PER_REQUEST = 5000
|
||||||
|
|
||||||
|
# Hard stop on pagination so a tiny resolution over a huge span can't loop forever.
|
||||||
|
_MAX_HISTORY_PAGES = 500
|
||||||
|
|
||||||
|
# Candle interval in milliseconds, keyed by the Hyperliquid interval code.
|
||||||
|
_RESOLUTION_MS = {
|
||||||
|
"1m": 60_000,
|
||||||
|
"5m": 300_000,
|
||||||
|
"15m": 900_000,
|
||||||
|
"1h": 3_600_000,
|
||||||
|
"4h": 14_400_000,
|
||||||
|
"1d": 86_400_000,
|
||||||
|
}
|
||||||
|
|
||||||
# Slippage usato per market order / market_close (parità con SDK).
|
# Slippage usato per market order / market_close (parità con SDK).
|
||||||
DEFAULT_SLIPPAGE = 0.05
|
DEFAULT_SLIPPAGE = 0.05
|
||||||
|
|
||||||
@@ -394,10 +411,46 @@ class HyperliquidClient:
|
|||||||
async def get_historical(
|
async def get_historical(
|
||||||
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
|
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Get OHLCV candles for an asset."""
|
"""Get OHLCV candles for an asset.
|
||||||
|
|
||||||
|
``candleSnapshot`` caps each response at ``_MAX_CANDLES_PER_REQUEST``
|
||||||
|
candles and keeps the newest slice, so a wide range is paged in windows
|
||||||
|
sized to that cap and each page is trimmed back to ``[start_ms, end_ms]``
|
||||||
|
to keep start_date honored.
|
||||||
|
"""
|
||||||
start_ms = _to_ms(start_date)
|
start_ms = _to_ms(start_date)
|
||||||
end_ms = _to_ms(end_date)
|
end_ms = _to_ms(end_date)
|
||||||
interval = RESOLUTION_MAP.get(resolution, resolution)
|
interval = RESOLUTION_MAP.get(resolution, resolution)
|
||||||
|
interval_ms = _RESOLUTION_MS.get(interval)
|
||||||
|
|
||||||
|
by_ts: dict[int, dict[str, Any]] = {}
|
||||||
|
if interval_ms and end_ms >= start_ms:
|
||||||
|
window_span = interval_ms * _MAX_CANDLES_PER_REQUEST
|
||||||
|
cursor = start_ms
|
||||||
|
pages = 0
|
||||||
|
while cursor <= end_ms and pages < _MAX_HISTORY_PAGES:
|
||||||
|
window_end = min(cursor + window_span - interval_ms, end_ms)
|
||||||
|
for c in await self._fetch_candle_window(
|
||||||
|
instrument, interval, cursor, window_end
|
||||||
|
):
|
||||||
|
if start_ms <= c["timestamp"] <= end_ms:
|
||||||
|
by_ts[c["timestamp"]] = c
|
||||||
|
pages += 1
|
||||||
|
if window_end >= end_ms:
|
||||||
|
break
|
||||||
|
cursor = window_end + interval_ms
|
||||||
|
else:
|
||||||
|
for c in await self._fetch_candle_window(
|
||||||
|
instrument, interval, start_ms, end_ms
|
||||||
|
):
|
||||||
|
by_ts[c["timestamp"]] = c
|
||||||
|
|
||||||
|
# validate_candles sorts by timestamp, so insertion order is irrelevant.
|
||||||
|
return {"candles": validate_candles(list(by_ts.values()))}
|
||||||
|
|
||||||
|
async def _fetch_candle_window(
|
||||||
|
self, instrument: str, interval: str, start_ms: int, end_ms: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
data = await self._post_info(
|
data = await self._post_info(
|
||||||
{
|
{
|
||||||
"type": "candleSnapshot",
|
"type": "candleSnapshot",
|
||||||
@@ -409,7 +462,7 @@ class HyperliquidClient:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
candles = validate_candles([
|
return [
|
||||||
{
|
{
|
||||||
"timestamp": c.get("t"),
|
"timestamp": c.get("t"),
|
||||||
"open": c.get("o"),
|
"open": c.get("o"),
|
||||||
@@ -418,9 +471,8 @@ class HyperliquidClient:
|
|||||||
"close": c.get("c"),
|
"close": c.get("c"),
|
||||||
"volume": c.get("v"),
|
"volume": c.get("v"),
|
||||||
}
|
}
|
||||||
for c in data
|
for c in (data or [])
|
||||||
])
|
]
|
||||||
return {"candles": candles}
|
|
||||||
|
|
||||||
async def get_open_orders(self) -> list[dict[str, Any]]:
|
async def get_open_orders(self) -> list[dict[str, Any]]:
|
||||||
"""Get all open orders for the wallet."""
|
"""Get all open orders for the wallet."""
|
||||||
@@ -555,38 +607,6 @@ class HyperliquidClient:
|
|||||||
"history": history,
|
"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) ───────────────────────────────────
|
# ── Write tools (signed) ───────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
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.client import HyperliquidClient
|
||||||
from cerbero_mcp.exchanges.hyperliquid.leverage_cap import (
|
from cerbero_mcp.exchanges.hyperliquid.leverage_cap import (
|
||||||
@@ -45,36 +45,6 @@ class GetTradeHistoryReq(BaseModel):
|
|||||||
limit: int = 100
|
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):
|
class GetOpenOrdersReq(BaseModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -87,58 +57,6 @@ class BasisSpotPerpReq(BaseModel):
|
|||||||
asset: str
|
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 ===
|
# === Schemas: writes ===
|
||||||
|
|
||||||
|
|
||||||
@@ -244,17 +162,6 @@ async def get_trade_history(
|
|||||||
return await client.get_trade_history(params.limit)
|
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(
|
async def get_open_orders(
|
||||||
client: HyperliquidClient, params: GetOpenOrdersReq
|
client: HyperliquidClient, params: GetOpenOrdersReq
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -273,21 +180,6 @@ async def basis_spot_perp(
|
|||||||
return await client.basis_spot_perp(params.asset)
|
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) ===
|
# === Tools (writes) ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -73,13 +73,6 @@ def make_router() -> APIRouter:
|
|||||||
):
|
):
|
||||||
return await t.get_ticker_batch(client, params)
|
return await t.get_ticker_batch(client, params)
|
||||||
|
|
||||||
@r.post("/tools/get_instruments")
|
|
||||||
async def _get_instruments(
|
|
||||||
params: t.GetInstrumentsReq,
|
|
||||||
client: DeribitClient = Depends(get_deribit_client),
|
|
||||||
):
|
|
||||||
return await t.get_instruments(client, params)
|
|
||||||
|
|
||||||
@r.post("/tools/get_orderbook")
|
@r.post("/tools/get_orderbook")
|
||||||
async def _get_orderbook(
|
async def _get_orderbook(
|
||||||
params: t.GetOrderbookReq,
|
params: t.GetOrderbookReq,
|
||||||
@@ -108,6 +101,13 @@ def make_router() -> APIRouter:
|
|||||||
):
|
):
|
||||||
return await t.get_account_summary(client, params)
|
return await t.get_account_summary(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_open_orders")
|
||||||
|
async def _get_open_orders(
|
||||||
|
params: t.GetOpenOrdersReq,
|
||||||
|
client: DeribitClient = Depends(get_deribit_client),
|
||||||
|
):
|
||||||
|
return await t.get_open_orders(client, params)
|
||||||
|
|
||||||
@r.post("/tools/get_trade_history")
|
@r.post("/tools/get_trade_history")
|
||||||
async def _get_trade_history(
|
async def _get_trade_history(
|
||||||
params: t.GetTradeHistoryReq,
|
params: t.GetTradeHistoryReq,
|
||||||
@@ -115,13 +115,6 @@ def make_router() -> APIRouter:
|
|||||||
):
|
):
|
||||||
return await t.get_trade_history(client, params)
|
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")
|
@r.post("/tools/get_dvol")
|
||||||
async def _get_dvol(
|
async def _get_dvol(
|
||||||
params: t.GetDvolReq,
|
params: t.GetDvolReq,
|
||||||
@@ -234,13 +227,6 @@ def make_router() -> APIRouter:
|
|||||||
):
|
):
|
||||||
return await t.get_realized_vol(client, params)
|
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) ===
|
# === WRITE tools (richiedono creds per leverage cap / audit) ===
|
||||||
|
|
||||||
@r.post("/tools/place_order")
|
@r.post("/tools/place_order")
|
||||||
|
|||||||
@@ -93,13 +93,6 @@ def make_router() -> APIRouter:
|
|||||||
):
|
):
|
||||||
return await t.get_trade_history(client, params)
|
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")
|
@r.post("/tools/get_open_orders")
|
||||||
async def _get_open_orders(
|
async def _get_open_orders(
|
||||||
params: t.GetOpenOrdersReq,
|
params: t.GetOpenOrdersReq,
|
||||||
@@ -121,13 +114,6 @@ def make_router() -> APIRouter:
|
|||||||
):
|
):
|
||||||
return await t.basis_spot_perp(client, params)
|
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) ===
|
# === WRITE tools (richiedono creds per leverage cap / audit) ===
|
||||||
|
|
||||||
@r.post("/tools/place_order")
|
@r.post("/tools/place_order")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Router /mcp/* — unified common interface across integrated exchanges.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.GetHistoricalReq,
|
||||||
|
client: UnifiedClient = Depends(get_unified_client),
|
||||||
|
):
|
||||||
|
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
|
||||||
@@ -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,24 @@ 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 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_instruments / get_historical / get_indicators are common-only
|
||||||
|
assert "/mcp-deribit/tools/get_instruments" not in paths
|
||||||
|
assert "/mcp-deribit/tools/get_historical" not in paths
|
||||||
|
assert "/mcp-deribit/tools/get_technical_indicators" not in paths
|
||||||
|
assert "/mcp-hyperliquid/tools/get_historical" not in paths
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
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
-17
@@ -1,25 +1,28 @@
|
|||||||
# Smoke locale Cerbero_mcp
|
# Smoke locale Cerbero_mcp
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# da repo root Cerbero_mcp/
|
# da repo root, con il server attivo (docker compose up -d)
|
||||||
docker compose up -d
|
PORT=9000 TESTNET_TOKEN="$TESTNET_TOKEN" bash tests/smoke/run.sh
|
||||||
bash tests/smoke/run.sh
|
MAINNET_TOKEN="$MAINNET_TOKEN" bash tests/smoke/run_unified.sh
|
||||||
docker compose down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Il file `run.sh` verifica:
|
## `run.sh` — endpoint pubblici + un tool per-exchange
|
||||||
- `/health` di tutti i 6 MCP (atteso `200`)
|
Verifica `/health`, `/apidocs`, `/openapi.json` (schema bearer) e, se
|
||||||
- `environment_info` dei 4 exchange (atteso shape `{environment, source, env_value, base_url, max_leverage}`)
|
`TESTNET_TOKEN` è settato, che un tool senza bearer dia `401` e con bearer
|
||||||
- live tool check read-only contro upstream testnet:
|
testnet venga instradato (deribit `get_ticker BTC-PERPETUAL`).
|
||||||
- deribit `get_ticker BTC-PERPETUAL`
|
|
||||||
- bybit `get_ticker BTCUSDT` (linear)
|
## `run_unified.sh` — interfaccia comune `/mcp` (upstream reali)
|
||||||
- hyperliquid `get_ticker BTC`
|
Verifica end-to-end contro Deribit/Hyperliquid (endpoint pubblici):
|
||||||
- alpaca `get_clock` (richiede credenziali paper valide)
|
- `get_instruments exchange=deribit` → `fees` e `history_start` popolati live
|
||||||
- macro `get_treasury_yields`
|
- `get_instruments` fan-out → contribuiscono sia deribit sia hyperliquid
|
||||||
- sentiment `get_funding_rates BTC`
|
- `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:
|
Variabili di ambiente:
|
||||||
- `GATEWAY` — URL base gateway (default `http://localhost`; in produzione `https://cerbero-mcp.tielogic.xyz`)
|
- `PORT` (default `9000`) o `GATEWAY` (URL base, default `http://localhost:PORT`)
|
||||||
- `TOKEN_FILE` — path al token bearer di lettura (default `secrets/observer.token`)
|
- `MAINNET_TOKEN` / `TESTNET_TOKEN` — bearer (uno dei due basta; mainnet per dati reali)
|
||||||
|
- `BOT_TAG` (default `smoke`) — header `X-Bot-Tag`
|
||||||
|
|
||||||
Exit code 0 = tutto OK, 1 = uno o più check falliti.
|
Exit code 0 = tutto OK, 1 = uno o più check falliti, 0 con skip se nessun token.
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke test interfaccia comune /mcp contro un server vivo + upstream reali.
|
||||||
|
# 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}"
|
||||||
|
BASE="${GATEWAY:-http://localhost:${PORT}}"
|
||||||
|
TOKEN="${MAINNET_TOKEN:-${TESTNET_TOKEN:-}}"
|
||||||
|
TAG="${BOT_TAG:-smoke}"
|
||||||
|
|
||||||
|
if [[ -z "${TOKEN}" ]]; then
|
||||||
|
echo "==> skip: né MAINNET_TOKEN né TESTNET_TOKEN settati"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PY="$(command -v python3 || command -v python)"
|
||||||
|
auth=(-H "Authorization: Bearer ${TOKEN}" -H "X-Bot-Tag: ${TAG}" -H "Content-Type: application/json")
|
||||||
|
start="$(date -u -d '7 days ago' +%F)"
|
||||||
|
end="$(date -u +%F)"
|
||||||
|
|
||||||
|
# post PATH JSON_BODY EXPECTED_STATUS PY_ASSERT
|
||||||
|
post() {
|
||||||
|
local path="$1" body="$2" want="$3" assert="${4:-}"
|
||||||
|
local resp status out
|
||||||
|
resp="$(curl -s -w '\n%{http_code}' -X POST "${BASE}${path}" "${auth[@]}" -d "${body}")"
|
||||||
|
status="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
|
out="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
|
if [[ "${status}" != "${want}" ]]; then
|
||||||
|
echo " FAIL ${path}: atteso HTTP ${want}, got ${status}"
|
||||||
|
printf '%s\n' "${out}" | head -c 400; echo
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "${assert}" ]]; then
|
||||||
|
printf '%s' "${out}" | "${PY}" -c "import sys,json; d=json.load(sys.stdin); ${assert}" \
|
||||||
|
|| { echo " FAIL ${path}: assert"; exit 1; }
|
||||||
|
fi
|
||||||
|
echo " OK ${path} → HTTP ${status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> get_instruments exchange=deribit (fees + history_start live + meta)"
|
||||||
|
post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"future"}' 200 \
|
||||||
|
"assert d['instruments']; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; assert 'has_more' in d['meta']['deribit']; print(' ', i['symbol'], i['fees'], i['history_start'], '| meta:', d['meta']['deribit'])"
|
||||||
|
|
||||||
|
echo "==> get_instruments deribit option chain paginato (kind=option, limit=5)"
|
||||||
|
post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"option","limit":5,"offset":0}' 200 \
|
||||||
|
"assert len(d['instruments'])<=5; assert d['meta']['deribit']['limit']==5; print(' got:', len(d['instruments']), '| total:', d['meta']['deribit'].get('total'), '| has_more:', d['meta']['deribit'].get('has_more'))"
|
||||||
|
|
||||||
|
echo "==> get_instruments fan-out (deribit + hyperliquid)"
|
||||||
|
post "/mcp/tools/get_instruments" '{}' 200 \
|
||||||
|
"exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs))"
|
||||||
|
|
||||||
|
echo "==> get_instruments NON più su /mcp-deribit → 404"
|
||||||
|
post "/mcp-deribit/tools/get_instruments" '{"currency":"BTC"}' 404
|
||||||
|
|
||||||
|
echo "==> get_historical SINGLE deribit BTC-PERPETUAL 1h"
|
||||||
|
post "/mcp/tools/get_historical" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||||
|
"assert d['exchange']=='deribit'; assert d['candles']; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))"
|
||||||
|
|
||||||
|
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']; 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
|
||||||
|
|
||||||
|
echo "==> SMOKE /mcp OK"
|
||||||
@@ -1,134 +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)}
|
|
||||||
|
|
||||||
async def get_bars(self, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
if self._raises:
|
|
||||||
raise self._raises
|
|
||||||
self.calls.append(kwargs)
|
|
||||||
return {"candles": list(self._candles)}
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeRegistry:
|
|
||||||
def __init__(self, clients: dict[str, _Fake]):
|
|
||||||
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_three_sources_aggregates():
|
|
||||||
fakes = {
|
|
||||||
"bybit": _Fake([_c(1, 100), _c(2, 200)]),
|
|
||||||
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
|
|
||||||
"deribit": _Fake([_c(1, 100), _c(2, 200)]),
|
|
||||||
}
|
|
||||||
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"] == 3
|
|
||||||
assert out["candles"][0]["div_pct"] == 0.0
|
|
||||||
assert set(out["sources_used"]) == {"bybit", "hyperliquid", "deribit"}
|
|
||||||
assert out["failed_sources"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_crypto_partial_failure_returns_partial_with_warning():
|
|
||||||
fakes = {
|
|
||||||
"bybit": _Fake([_c(1, 100)]),
|
|
||||||
"hyperliquid": _Fake([_c(1, 100)]),
|
|
||||||
"deribit": _Fake(raises=RuntimeError("upstream down")),
|
|
||||||
}
|
|
||||||
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"] == 2
|
|
||||||
assert set(out["sources_used"]) == {"bybit", "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 = {
|
|
||||||
"bybit": _Fake(raises=RuntimeError("a")),
|
|
||||||
"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_stocks_routes_to_alpaca_only():
|
|
||||||
fake = _Fake([_c(1, 175.0)])
|
|
||||||
cc = CrossClient(_FakeRegistry({"alpaca": fake}), env="mainnet")
|
|
||||||
out = await cc.get_historical(
|
|
||||||
symbol="AAPL", asset_class="stocks", interval="1d",
|
|
||||||
start_date="2026-04-09T00:00:00", end_date="2026-04-10T00:00:00",
|
|
||||||
)
|
|
||||||
assert out["sources_used"] == ["alpaca"]
|
|
||||||
assert out["candles"][0]["close"] == 175.0
|
|
||||||
# Alpaca was called with native symbol
|
|
||||||
assert fake.calls[0]["symbol"] == "AAPL"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unsupported_interval_raises_400():
|
|
||||||
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
|
||||||
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
|
|
||||||
@@ -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,292 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"creation_timestamp": 1534377600000, # 2018-08-16
|
||||||
|
"open_interest": 123.0,
|
||||||
|
}]
|
||||||
|
inst = normalize_deribit(rows)[0]
|
||||||
|
assert inst["exchange"] == "deribit"
|
||||||
|
assert inst["symbol"] == "BTC-PERPETUAL"
|
||||||
|
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():
|
||||||
|
assert normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])[0]["type"] == "future"
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
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}]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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.inst_call = kwargs
|
||||||
|
return {
|
||||||
|
"instruments": [{
|
||||||
|
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
|
||||||
|
"maker_commission": 0.0, "taker_commission": 0.0005,
|
||||||
|
"creation_timestamp": 1534377600000,
|
||||||
|
}],
|
||||||
|
"total": 1,
|
||||||
|
"offset": kwargs.get("offset", 0),
|
||||||
|
"limit": kwargs.get("limit", 100),
|
||||||
|
"has_more": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
if self._raises:
|
||||||
|
raise self._raises
|
||||||
|
self.hist_call = kwargs
|
||||||
|
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]:
|
||||||
|
if self._raises:
|
||||||
|
raise self._raises
|
||||||
|
return {"candles": list(self._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]
|
||||||
|
|
||||||
|
|
||||||
|
def _both() -> UnifiedClient:
|
||||||
|
return UnifiedClient(
|
||||||
|
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
|
||||||
|
env="mainnet",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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():
|
||||||
|
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():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await UnifiedClient(_FakeRegistry({}), env="mainnet").get_instruments(exchange="bybit")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_instruments_deribit_filters_and_meta():
|
||||||
|
fake = _FakeDeribit()
|
||||||
|
uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet")
|
||||||
|
out = await uc.get_instruments(
|
||||||
|
exchange="deribit", currency="ETH", kind="option",
|
||||||
|
strike_min=1000.0, expiry_to="2026-12-31", min_open_interest=5.0,
|
||||||
|
offset=5, limit=10,
|
||||||
|
)
|
||||||
|
# advanced Deribit filters reach the client
|
||||||
|
assert fake.inst_call["currency"] == "ETH"
|
||||||
|
assert fake.inst_call["kind"] == "option"
|
||||||
|
assert fake.inst_call["strike_min"] == 1000.0
|
||||||
|
assert fake.inst_call["expiry_to"] == "2026-12-31"
|
||||||
|
assert fake.inst_call["min_open_interest"] == 5.0
|
||||||
|
assert fake.inst_call["offset"] == 5 and fake.inst_call["limit"] == 10
|
||||||
|
# pagination metadata surfaced per source
|
||||||
|
assert out["meta"]["deribit"]["limit"] == 10
|
||||||
|
assert out["meta"]["deribit"]["has_more"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_instruments_partial_failure_reports_failed():
|
||||||
|
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
|
||||||
|
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()
|
||||||
|
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["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():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await UnifiedClient(_FakeRegistry({}), env="mainnet").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():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
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"]
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
|
|
||||||
from pytest_httpx import HTTPXMock
|
from pytest_httpx import HTTPXMock
|
||||||
|
|
||||||
|
from cerbero_mcp.exchanges.hyperliquid.client import (
|
||||||
|
_MAX_CANDLES_PER_REQUEST,
|
||||||
|
_RESOLUTION_MS,
|
||||||
|
HyperliquidClient,
|
||||||
|
_to_ms,
|
||||||
|
)
|
||||||
|
|
||||||
# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write.
|
# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write.
|
||||||
DUMMY_PRIVATE_KEY = "0x" + "01" * 32
|
DUMMY_PRIVATE_KEY = "0x" + "01" * 32
|
||||||
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
|
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
|
||||||
@@ -201,10 +208,11 @@ async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient)
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
|
ts = _to_ms("2024-01-01") + _RESOLUTION_MS["1h"] # one candle inside the range
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
json=[
|
json=[
|
||||||
{"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"},
|
{"t": ts, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
|
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
|
||||||
@@ -212,6 +220,57 @@ async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
|||||||
assert result["candles"][0]["close"] == 50000.0
|
assert result["candles"][0]["close"] == 50000.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_historical_trims_out_of_range(
|
||||||
|
httpx_mock: HTTPXMock, client: HyperliquidClient
|
||||||
|
):
|
||||||
|
# candleSnapshot can return candles outside the requested window; trim them.
|
||||||
|
in_range = _to_ms("2024-01-01") + _RESOLUTION_MS["1h"]
|
||||||
|
before = _to_ms("2024-01-01") - _RESOLUTION_MS["1h"]
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=[
|
||||||
|
{"t": before, "o": "1", "h": "1", "l": "1", "c": "1", "v": "1"},
|
||||||
|
{"t": in_range, "o": "49000", "h": "51000", "l": "48500", "c": "50000",
|
||||||
|
"v": "100"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
|
||||||
|
assert [c["timestamp"] for c in result["candles"]] == [in_range]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_historical_pages_wide_range(
|
||||||
|
httpx_mock: HTTPXMock, client: HyperliquidClient
|
||||||
|
):
|
||||||
|
# A span wider than the per-call cap must be paged across multiple requests.
|
||||||
|
interval_ms = _RESOLUTION_MS["1h"]
|
||||||
|
window_span = interval_ms * _MAX_CANDLES_PER_REQUEST
|
||||||
|
start_ms = _to_ms("2024-01-01")
|
||||||
|
page1_ts = start_ms + interval_ms
|
||||||
|
page2_ts = start_ms + window_span # start of the second window
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=[
|
||||||
|
{"t": page1_ts, "o": "100", "h": "110", "l": "90", "c": "105", "v": "1"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
httpx_mock.add_response(
|
||||||
|
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
|
||||||
|
json=[
|
||||||
|
{"t": page2_ts, "o": "200", "h": "210", "l": "190", "c": "205", "v": "1"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# 2024 is a leap year (366 d = 8784 h > 5000), so the range spans 2 windows.
|
||||||
|
result = await client.get_historical("BTC", "2024-01-01", "2025-01-01", "1h")
|
||||||
|
|
||||||
|
requests = httpx_mock.get_requests()
|
||||||
|
assert len(requests) == 2
|
||||||
|
starts = [json.loads(r.read())["req"]["startTime"] for r in requests]
|
||||||
|
assert starts[1] > starts[0] # paging advanced the cursor
|
||||||
|
assert [c["timestamp"] for c in result["candles"]] == [page1_ts, page2_ts]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
|
||||||
httpx_mock.add_response(
|
httpx_mock.add_response(
|
||||||
@@ -277,8 +336,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