refactor(V2): get_historical & get_indicators are common-only on /mcp
Consolidate the cross-cutting data tools onto the unified interface and remove their per-exchange duplicates. - /mcp/tools/get_historical: single call, `exchange` now optional. Set → that venue's candles (native symbol); omitted → cross-exchange consensus (canonical symbol, median OHLC + div_pct). Folds in the former /mcp-cross consensus, whose router is deleted. - /mcp/tools/get_indicators: new common tool computing sma/rsi/atr/macd/adx over the single-or-consensus series. Computation centralized in common/indicators.py::compute_indicators (was duplicated per exchange). - Remove get_historical from /mcp-deribit and /mcp-hyperliquid; remove get_technical_indicators (deribit) and get_indicators (hyperliquid), including the now-dead client methods, Req schemas and tool wrappers. The client get_historical METHODS stay (used by the unified dispatch). Tests: rewrite cross tests into test_unified (instruments, historical single + consensus + failures, indicators single + consensus); drop the old CrossClient test; tighten app-boot surface assertions (/mcp owns the data tools; no /mcp-cross, no per-exchange historical/indicators). Docs: API_REFERENCE / README / CLAUDE updated; smoke run_unified.sh now covers consensus + indicators. 324 passed, ruff clean. Verified live against Deribit/Hyperliquid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,18 +59,19 @@ Nota: nel modulo `sentiment` "bybit" resta come **fonte dati pubblica**
|
|||||||
|
|
||||||
## Interfacce dati
|
## Interfacce dati
|
||||||
|
|
||||||
- **`/mcp` (comune, raccomandata)** — `get_instruments` con schema uniforme
|
- **`/mcp` (comune)** — gli **unici** tool dati trasversali. `get_instruments`
|
||||||
(ogni elemento porta `exchange`, `fees`, `history_start`, `type`, `tick_size`,
|
(schema uniforme: `exchange`, `fees`, `history_start`, `type`, `tick_size`,
|
||||||
`native`); `get_historical` con `exchange` selezionabile (singolo venue).
|
`native`); `get_historical` e `get_indicators` con `exchange` opzionale →
|
||||||
Supporta deribit + hyperliquid (IBKR non ancora integrato).
|
singolo venue se valorizzato, **consenso** multi-exchange (mediana OHLC +
|
||||||
- **`/mcp-cross`** — `get_historical` a consenso multi-exchange (mediana OHLC,
|
`div_pct`) se omesso. Supporta deribit + hyperliquid (IBKR non integrato).
|
||||||
`sources`, `div_pct`).
|
- **`/mcp-{exchange}`** — router per-exchange per il resto (ticker, ordini,
|
||||||
- **`/mcp-{exchange}`** — router per-exchange legacy, ancora attivi.
|
options analytics, …). **NON** espongono più `get_historical`/`get_indicators`.
|
||||||
|
|
||||||
Convenzione: ogni `get_historical` ritorna la chiave uniforme
|
Convenzione: `get_historical` ritorna la chiave uniforme
|
||||||
`candles: [{timestamp(ms), open, high, low, close, volume}]` (validatore in
|
`candles: [{timestamp(ms), open, high, low, close, volume}]` (validatore in
|
||||||
`common/candles.py`). `fees`/`history_start` sono popolati **live dove
|
`common/candles.py`). `fees`/`history_start` sono popolati **live dove
|
||||||
l'upstream li espone** (oggi: Deribit), altrimenti `null`.
|
l'upstream li espone** (oggi: Deribit), altrimenti `null`. Il calcolo indicatori
|
||||||
|
è centralizzato in `common/indicators.py::compute_indicators`.
|
||||||
|
|
||||||
## Convenzioni
|
## Convenzioni
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ sul token bearer fornito dal client.
|
|||||||
read-only (Macro, Sentiment). Bybit e Alpaca sono stati ritirati dalla
|
read-only (Macro, Sentiment). Bybit e Alpaca sono stati ritirati dalla
|
||||||
superficie API in V2 (codice archiviato in `old/`)
|
superficie API in V2 (codice archiviato in `old/`)
|
||||||
- **Interfaccia comune `/mcp`** trasversale agli exchange: `get_instruments`
|
- **Interfaccia comune `/mcp`** trasversale agli exchange: `get_instruments`
|
||||||
con schema uniforme (ogni elemento porta `exchange`, `fees`,
|
(schema uniforme con `exchange`/`fees`/`history_start`), `get_historical` e
|
||||||
`history_start`) e `get_historical` per-exchange selezionabile
|
`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
|
||||||
@@ -23,9 +24,9 @@ sul token bearer fornito dal client.
|
|||||||
override-abili tramite variabili dedicate (`DERIBIT_URL_*`,
|
override-abili tramite variabili dedicate (`DERIBIT_URL_*`,
|
||||||
`HYPERLIQUID_URL_*`, `IBKR_URL_*`)
|
`HYPERLIQUID_URL_*`, `IBKR_URL_*`)
|
||||||
- **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs`
|
- **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs`
|
||||||
- **Endpoint cross-exchange a consenso** (`/mcp-cross/tools/get_historical`):
|
- **Consenso multi-exchange** integrato in `/mcp/tools/get_historical`: ometti
|
||||||
fan-out agli exchange che supportano (symbol, asset_class) e consensus
|
`exchange` per il fan-out e il merge per-bar (mediana OHLC + `div_pct` +
|
||||||
per-bar (mediana OHLC + `div_pct` + `sources`)
|
`sources`)
|
||||||
- **Qualità verificata**: 323 test (unit + integration + smoke), mypy
|
- **Qualità verificata**: 323 test (unit + integration + smoke), mypy
|
||||||
pulito, ruff pulito
|
pulito, ruff pulito
|
||||||
|
|
||||||
@@ -92,13 +93,13 @@ non è richiesto sugli endpoint pubblici (`/health`, `/apidocs`,
|
|||||||
| `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_instruments` | Lista strumenti uniforme cross-exchange (deribit, hyperliquid) |
|
||||||
| `POST /mcp/tools/get_historical` | Storico di un singolo exchange selezionabile |
|
| `POST /mcp/tools/get_historical` | Storico comune: singolo exchange o consenso multi-exchange |
|
||||||
|
| `POST /mcp/tools/get_indicators` | Indicatori tecnici sulla stessa serie (single o consenso) |
|
||||||
| `POST /mcp-deribit/tools/{tool}` | Tool exchange Deribit |
|
| `POST /mcp-deribit/tools/{tool}` | Tool exchange Deribit |
|
||||||
| `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid |
|
| `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid |
|
||||||
| `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
|
||||||
@@ -177,14 +178,14 @@ 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.
|
(Storico/indicatori → `/mcp`.)
|
||||||
|
|
||||||
### 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. (Storico/indicatori → `/mcp`.)
|
||||||
|
|
||||||
### IBKR (Interactive Brokers)
|
### IBKR (Interactive Brokers)
|
||||||
Account, positions, activities, ticker, bars, snapshot, option chain,
|
Account, positions, activities, ticker, bars, snapshot, option chain,
|
||||||
@@ -204,24 +205,23 @@ OI history, get_funding_arb_spread, get_liquidation_heatmap,
|
|||||||
get_cointegration_pairs.
|
get_cointegration_pairs.
|
||||||
|
|
||||||
### Unified (`/mcp`) — interfaccia comune
|
### Unified (`/mcp`) — interfaccia comune
|
||||||
`get_instruments` ritorna una lista strumenti **uniforme**: ogni elemento
|
Espone gli **unici** tool dati trasversali (storico e indicatori vivono solo
|
||||||
porta in sé `exchange`, `fees` (maker/taker, popolati live da Deribit, `null`
|
qui, non per-exchange). Exchange supportati: `deribit`, `hyperliquid` (IBKR non
|
||||||
dove l'exchange non li espone per-strumento), `history_start` (data inizio
|
ancora integrato — usa `/mcp-ibkr`).
|
||||||
storico, live da Deribit), oltre a `type`, `tick_size` e un blocco `native`
|
|
||||||
lossless. Con `exchange` opzionale filtra una singola sorgente; se omesso fa
|
|
||||||
fan-out su tutti gli exchange integrati. `get_historical` è generalizzato a
|
|
||||||
`{exchange, instrument, interval, start_date, end_date}` e ritorna le candele
|
|
||||||
del **singolo exchange scelto**. Exchange supportati: `deribit`, `hyperliquid`
|
|
||||||
(IBKR non ancora integrato — usa `/mcp-ibkr`).
|
|
||||||
|
|
||||||
### Cross (storico a consenso)
|
- **`get_instruments`** — lista strumenti **uniforme**: ogni elemento porta in
|
||||||
`get_historical` aggrega le candele dello stesso simbolo da tutti gli
|
sé `exchange`, `fees` (maker/taker, live da Deribit, `null` dove l'exchange
|
||||||
exchange che lo supportano e ritorna una serie consensus: la chiusura è
|
non li espone), `history_start` (data inizio storico, live da Deribit),
|
||||||
la mediana, `sources` è il numero di exchange che hanno contribuito al
|
`type`, `tick_size` e un blocco `native` lossless. `exchange` opzionale →
|
||||||
bar e `div_pct = (max-min)/median` segnala il disaccordo tra fonti — un
|
filtra una sorgente; se omesso fan-out su tutti.
|
||||||
quality gate per i bot. Crypto: BTC/ETH via Hyperliquid + Deribit, SOL via
|
- **`get_historical`** — `{exchange?, instrument, interval, start_date,
|
||||||
Hyperliquid. In caso di fallimento parziale ritorna i dati disponibili più
|
end_date}`. Con `exchange` ritorna il **singolo** exchange; **omettendolo**
|
||||||
`failed_sources`; se *tutti* gli upstream falliscono → HTTP 502 retryable.
|
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
|
||||||
|
|
||||||
|
|||||||
+67
-69
@@ -50,16 +50,16 @@ richiede `X-Bot-Tag`.
|
|||||||
|
|
||||||
| Namespace | Tool | Tipo | Note |
|
| Namespace | Tool | Tipo | Note |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/mcp/tools/*` | 2 | **interfaccia comune** | schema strumenti uniforme + storico per-exchange |
|
| `/mcp/tools/*` | 3 | **interfaccia comune** | `get_instruments` + `get_historical` + `get_indicators` (single exchange o consensus) |
|
||||||
| `/mcp-deribit/tools/*` | 33 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff |
|
| `/mcp-deribit/tools/*` | 32 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff |
|
||||||
| `/mcp-hyperliquid/tools/*` | 16 | exchange (perp DEX) | L1 signing, leverage cap |
|
| `/mcp-hyperliquid/tools/*` | 14 | exchange (perp DEX) | L1 signing, leverage cap |
|
||||||
| `/mcp-ibkr/tools/*` | 24 | exchange (multi-asset broker) | OAuth 1.0a, streaming WS, bracket/OCO/OTO |
|
| `/mcp-ibkr/tools/*` | 24 | exchange (multi-asset broker) | OAuth 1.0a, streaming WS, bracket/OCO/OTO |
|
||||||
| `/mcp-macro/tools/*` | 11 | data provider (read-only) | yields, FRED, COT |
|
| `/mcp-macro/tools/*` | 11 | data provider (read-only) | yields, FRED, COT |
|
||||||
| `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange |
|
| `/mcp-sentiment/tools/*` | 9 | data provider (read-only) | news, social, funding cross-exchange |
|
||||||
| `/mcp-cross/tools/*` | 1 | aggregator | storico consensus multi-exchange |
|
|
||||||
|
|
||||||
I router per-exchange (`/mcp-deribit`, …) restano disponibili durante la
|
`get_historical` e `get_indicators` vivono **solo** sull'interfaccia comune
|
||||||
transizione; per nuovi consumatori è raccomandata l'interfaccia comune `/mcp`.
|
`/mcp` (non più per-exchange). I restanti tool per-exchange (ticker, ordini,
|
||||||
|
analytics options, …) restano sui rispettivi router `/mcp-{exchange}`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,23 +101,46 @@ Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}] }`.
|
|||||||
> `creation_timestamp` → `history_start`); **Hyperliquid** ha fee a livello
|
> `creation_timestamp` → `history_start`); **Hyperliquid** ha fee a livello
|
||||||
> account e nessuna data di listing per-strumento → entrambi `null`.
|
> account e nessuna data di listing per-strumento → entrambi `null`.
|
||||||
|
|
||||||
### `get_historical` — storico di un singolo exchange
|
### `get_historical` — unica chiamata storica comune
|
||||||
|
|
||||||
Request body:
|
`get_historical` esiste **solo qui** (non più per-exchange). Request body:
|
||||||
|
|
||||||
| Campo | Tipo | Default | Note |
|
| Campo | Tipo | Default | Note |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `exchange` | `"deribit"\|"hyperliquid"` | — | **obbligatorio**: sceglie la sorgente |
|
| `exchange` | `"deribit"\|"hyperliquid"` \| null | `null` | set → singolo exchange · omesso → **consenso** multi-exchange |
|
||||||
| `instrument` | str | — | simbolo nativo sull'exchange scelto |
|
| `instrument` | str | — | simbolo *nativo* (modalità singola) o *canonico* `BTC/ETH/SOL` (consenso) |
|
||||||
| `interval` | str | `"1h"` | `1m`/`5m`/`15m`/`1h`/`4h`/`1d` |
|
| `interval` | str | `"1h"` | `1m`/`5m`/`15m`/`1h`/`4h`/`1d` |
|
||||||
| `start_date` | str | — | `YYYY-MM-DD` o ISO datetime (UTC) |
|
| `start_date` | str | — | `YYYY-MM-DD` o ISO datetime (UTC) |
|
||||||
| `end_date` | str | — | idem |
|
| `end_date` | str | — | idem |
|
||||||
|
| `asset_class` | str | `"crypto"` | routing del consenso (solo se `exchange` omesso) |
|
||||||
|
|
||||||
Risposta: `{ "exchange", "instrument", "interval", "candles": [...] }` con la
|
- **Singolo** (`exchange` valorizzato) → `{ exchange, instrument, interval, candles, sources_used:[exchange] }`.
|
||||||
chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`.
|
- **Consenso** (`exchange` omesso) → fan-out agli exchange che supportano il
|
||||||
|
simbolo + merge per-bar: `{ exchange:null, instrument, asset_class, interval,
|
||||||
|
candles, sources_used, failed_sources }`. Ogni bar consenso porta `sources`
|
||||||
|
(n. exchange) e `div_pct = (max-min)/median` come quality gate. Se *tutti*
|
||||||
|
gli upstream falliscono → `502` retryable.
|
||||||
|
|
||||||
Per il **consenso** multi-exchange (mediana cross-venue) usare invece
|
Tutte le candele usano la chiave uniforme
|
||||||
`/mcp-cross/tools/get_historical` (sezione 11).
|
`candles: [{timestamp(ms), open, high, low, close, volume}]`.
|
||||||
|
|
||||||
|
> **Semantica `start_date`/`end_date`** (UTC). Date nude `YYYY-MM-DD`:
|
||||||
|
> `start_date` = `00:00:00`, `end_date` = `23:59:59.999` dello stesso giorno
|
||||||
|
> (giorno intero incluso → `end_date=oggi` dà l'intraday fino all'ultima candela
|
||||||
|
> chiusa). Timestamp con orario (`2026-05-28T14:00:00`) sono onorati così come
|
||||||
|
> sono (naive = UTC). Range ampi su Deribit sono paginati internamente (no cap
|
||||||
|
> ~5000), così `start_date` è sempre rispettato.
|
||||||
|
|
||||||
|
### `get_indicators` — indicatori sulla stessa serie
|
||||||
|
|
||||||
|
Stessa selezione sorgente di `get_historical` (single o consensus); calcola
|
||||||
|
indicatori tecnici sulle candele risultanti. Request body: i campi di
|
||||||
|
`get_historical` più `indicators` (lista o stringa CSV, es. `["rsi","atr"]` o
|
||||||
|
`"rsi,atr,macd"`; default `["rsi","atr","macd","adx"]`). Supportati:
|
||||||
|
`sma`, `rsi`, `atr`, `macd`, `adx` (nomi ignoti → `null`).
|
||||||
|
|
||||||
|
Risposta: `{ exchange, instrument, interval, indicators:{...}, candles_used,
|
||||||
|
sources_used }` (+ `failed_sources` in modalità consenso).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,20 +154,11 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece
|
|||||||
- `get_ticker`, `get_ticker_batch`
|
- `get_ticker`, `get_ticker_batch`
|
||||||
- `get_instruments`
|
- `get_instruments`
|
||||||
- `get_orderbook`, `get_orderbook_imbalance`
|
- `get_orderbook`, `get_orderbook_imbalance`
|
||||||
- `get_historical` (chiave `candles` uniforme)
|
|
||||||
- `get_trade_history`
|
- `get_trade_history`
|
||||||
|
|
||||||
> **`get_historical` — semantica di `start_date` / `end_date`** (vale anche per
|
> Lo storico OHLCV e gli indicatori tecnici NON sono più qui: usa
|
||||||
> `get_dvol` e `get_technical_indicators`). Tutti i timestamp sono in **UTC**.
|
> `/mcp/tools/get_historical` e `/mcp/tools/get_indicators` (sezione 5) con
|
||||||
> - Date nude `YYYY-MM-DD`: `start_date` = `00:00:00`, `end_date` =
|
> `exchange="deribit"`.
|
||||||
> `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
|
### Account
|
||||||
- `get_positions`
|
- `get_positions`
|
||||||
@@ -160,7 +174,6 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece
|
|||||||
- `find_by_delta`, `calculate_spread_payoff`
|
- `find_by_delta`, `calculate_spread_payoff`
|
||||||
|
|
||||||
### Technicals
|
### Technicals
|
||||||
- `get_technical_indicators`
|
|
||||||
- `run_backtest`
|
- `run_backtest`
|
||||||
|
|
||||||
### Write (richiede leverage cap)
|
### Write (richiede leverage cap)
|
||||||
@@ -178,15 +191,15 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece
|
|||||||
|
|
||||||
### Market data
|
### Market data
|
||||||
- `get_markets`, `get_ticker`, `get_orderbook`
|
- `get_markets`, `get_ticker`, `get_orderbook`
|
||||||
- `get_historical`, `get_trade_history`
|
- `get_trade_history`
|
||||||
- `get_funding_rate`, `basis_spot_perp`
|
- `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
|
### Account
|
||||||
- `get_positions`, `get_account_summary`, `get_open_orders`
|
- `get_positions`, `get_account_summary`, `get_open_orders`
|
||||||
|
|
||||||
### Technicals
|
|
||||||
- `get_indicators`
|
|
||||||
|
|
||||||
### Write (L1 signing + leverage cap)
|
### Write (L1 signing + leverage cap)
|
||||||
- `place_order`, `cancel_order`
|
- `place_order`, `cancel_order`
|
||||||
- `set_stop_loss`, `set_take_profit`
|
- `set_stop_loss`, `set_take_profit`
|
||||||
@@ -269,27 +282,7 @@ unattended. Setup one-time per ambiente (paper + live) — vedi sezione
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. `/mcp-cross/tools/*`
|
## 11. Observability
|
||||||
|
|
||||||
### `get_historical`
|
|
||||||
Aggregatore cross-exchange. Fan-out a tutti gli exchange che supportano
|
|
||||||
`(symbol, asset_class)`, poi consensus per-bar:
|
|
||||||
|
|
||||||
- `close` = mediana delle close
|
|
||||||
- `open`/`high`/`low` = mediana corrispondente
|
|
||||||
- `sources` = numero di exchange che hanno contribuito al bar
|
|
||||||
- `div_pct = (max - min) / median` → quality gate per i bot
|
|
||||||
|
|
||||||
**Crypto** (`asset_class: "crypto"`): BTC, ETH via Hyperliquid + Deribit;
|
|
||||||
SOL via Hyperliquid.
|
|
||||||
|
|
||||||
In caso di fallimento parziale ritorna i bar disponibili più
|
|
||||||
`failed_sources: [...]`. Se *tutti* gli upstream falliscono → `502
|
|
||||||
Bad Gateway` retryable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Observability
|
|
||||||
|
|
||||||
### Request log (`mcp.request`)
|
### Request log (`mcp.request`)
|
||||||
Una riga JSON per richiesta con: `request_id`, `method`, `path`,
|
Una riga JSON per richiesta con: `request_id`, `method`, `path`,
|
||||||
@@ -310,15 +303,21 @@ Ogni risposta JSON sotto `/tools/*` riceve dal middleware un campo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Esempio chiamata
|
## 12. Esempio chiamata
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Interfaccia comune: storico Deribit
|
# Singolo exchange: storico Deribit
|
||||||
curl -X POST http://localhost:9000/mcp/tools/get_historical \
|
curl -X POST http://localhost:9000/mcp/tools/get_historical \
|
||||||
-H "Authorization: Bearer $MAINNET_TOKEN" \
|
-H "Authorization: Bearer $MAINNET_TOKEN" \
|
||||||
-H "X-Bot-Tag: scanner-alpha-prod" \
|
-H "X-Bot-Tag: scanner-alpha-prod" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"exchange":"deribit","instrument":"BTC-PERPETUAL","interval":"1h","start_date":"2026-05-01","end_date":"2026-05-29"}'
|
-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:
|
Per lo schema completo dei body di richiesta e risposta:
|
||||||
@@ -326,24 +325,23 @@ Per lo schema completo dei body di richiesta e risposta:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Discovery strumenti & schemi candele
|
## 13. Discovery strumenti & schemi candele
|
||||||
|
|
||||||
Schemi verificati sulle risposte live. Tutti i `get_historical` ritornano la
|
Schemi verificati sulle risposte live. `get_historical` ritorna la chiave
|
||||||
chiave uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`.
|
uniforme `candles: [{timestamp(ms), open, high, low, close, volume}]`.
|
||||||
|
|
||||||
### Lista strumenti
|
### Tool dati comuni (`/mcp`)
|
||||||
| Interfaccia | Tool | Request body | Risposta (campi principali) |
|
| Tool | Request body | Risposta (campi principali) |
|
||||||
|---|---|---|---|
|
|
||||||
| `/mcp` (comune) | `get_instruments` | `{exchange?, currency:"BTC", kind:"future"}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` |
|
|
||||||
| Deribit (`/mcp-deribit`) | `get_instruments` | `{currency:"BTC"\|…, kind:"future", offset:int, limit:100}` — **paginato** (`has_more`) | `instruments[]`: `name`, `expiry`, `option_type`, `tick_size`, `min_trade_amount`, `maker_commission`, `taker_commission`, `creation_timestamp` · meta: `total`, `offset`, `limit`, `has_more`, `testnet`, `data_timestamp` |
|
|
||||||
| Hyperliquid (`/mcp-hyperliquid`) | `get_markets` | `{}` | lista `{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}` |
|
|
||||||
|
|
||||||
### `get_historical` — body per exchange
|
|
||||||
| Interfaccia | Body | Note risoluzione |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/mcp` (comune) | `{exchange, instrument, interval, start_date, end_date}` | `1m`/`5m`/`15m`/`1h`/`4h`/`1d` · ritorna il singolo exchange |
|
| `get_instruments` | `{exchange?, currency:"BTC", kind:"future"}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` |
|
||||||
| Deribit | `{instrument, start_date:"YYYY-MM-DD", end_date, resolution}` | `1`/`5`/`15`/`60`/`1D` (paginazione interna, no cap) |
|
| `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` |
|
||||||
| Hyperliquid | `{asset\|instrument, start_date, end_date, resolution, interval?, limit:50}` | `1m`/`5m`/`15m`/`1h`/`1d` · `limit` default **50** (alzare per finestre lunghe) |
|
| `get_indicators` | come `get_historical` + `indicators:["rsi","atr",…]` | `{exchange, instrument, interval, indicators:{…}, candles_used, sources_used}` |
|
||||||
|
|
||||||
|
`get_instruments` Deribit (sorgente grezza): `instruments[]` include `name`,
|
||||||
|
`expiry`, `option_type`, `tick_size`, `min_trade_amount`, `maker_commission`,
|
||||||
|
`taker_commission`, `creation_timestamp`; Hyperliquid `get_markets` (sorgente):
|
||||||
|
`{asset, mark_price, funding_rate, open_interest, volume_24h, max_leverage}`.
|
||||||
|
Ogni risposta `/tools/*` riceve inoltre `data_timestamp` dal middleware.
|
||||||
|
|
||||||
### Convenzione simboli Deribit
|
### Convenzione simboli Deribit
|
||||||
- **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL`
|
- **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL`
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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 (
|
||||||
cross,
|
|
||||||
deribit,
|
deribit,
|
||||||
hyperliquid,
|
hyperliquid,
|
||||||
ibkr,
|
ibkr,
|
||||||
@@ -69,7 +68,6 @@ def _make_app(settings: Settings) -> FastAPI:
|
|||||||
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(unified.make_router())
|
||||||
app.include_router(admin.make_admin_router())
|
app.include_router(admin.make_admin_router())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Cross-exchange historical aggregator.
|
"""Unified common interface over the integrated venues.
|
||||||
|
|
||||||
Fan-out a canonical (symbol, asset_class, interval, start, end) request to
|
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
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ 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 (
|
from cerbero_mcp.exchanges.cross.instruments import (
|
||||||
normalize_deribit,
|
normalize_deribit,
|
||||||
@@ -56,89 +59,15 @@ _DISPATCH = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class CrossClient:
|
|
||||||
def __init__(self, registry: _Registry, *, env: Environment):
|
|
||||||
self._registry = registry
|
|
||||||
self._env = env
|
|
||||||
|
|
||||||
async def _fetch_one(
|
|
||||||
self, exchange: str, native_sym: str, native_interval: str,
|
|
||||||
start: str, end: str,
|
|
||||||
) -> tuple[str, list[dict[str, Any]] | Exception]:
|
|
||||||
try:
|
|
||||||
client = await self._registry.get(exchange, self._env)
|
|
||||||
resp = await _DISPATCH[exchange](
|
|
||||||
client, native_sym, native_interval, start, end,
|
|
||||||
)
|
|
||||||
return exchange, resp.get("candles", [])
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return exchange, e
|
|
||||||
|
|
||||||
async def get_historical(
|
|
||||||
self, *, symbol: str, asset_class: str, interval: str,
|
|
||||||
start_date: str, end_date: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
sources = get_sources(asset_class, symbol)
|
|
||||||
if not sources:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"unsupported symbol/asset_class: {symbol} ({asset_class})",
|
|
||||||
)
|
|
||||||
if interval not in supported_intervals():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"unsupported interval: {interval}; "
|
|
||||||
f"supported: {supported_intervals()}",
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
self._fetch_one(
|
|
||||||
ex,
|
|
||||||
to_native_symbol(asset_class, symbol, ex),
|
|
||||||
to_native_interval(interval, ex),
|
|
||||||
start_date, end_date,
|
|
||||||
)
|
|
||||||
for ex in sources
|
|
||||||
]
|
|
||||||
results = await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
by_source: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
failed: list[dict[str, str]] = []
|
|
||||||
for ex, payload in results:
|
|
||||||
if isinstance(payload, Exception):
|
|
||||||
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
|
|
||||||
else:
|
|
||||||
by_source[ex] = payload
|
|
||||||
|
|
||||||
if not by_source:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=502,
|
|
||||||
detail={"error": "all sources failed", "failed_sources": failed},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"symbol": symbol.upper(),
|
|
||||||
"asset_class": asset_class,
|
|
||||||
"interval": interval,
|
|
||||||
"candles": merge_candles(by_source),
|
|
||||||
"sources_used": sorted(by_source.keys()),
|
|
||||||
"failed_sources": failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedClient:
|
class UnifiedClient:
|
||||||
"""Single common interface over the integrated venues.
|
"""Single common interface over the integrated venues (deribit, hyperliquid)."""
|
||||||
|
|
||||||
`get_instruments` returns one uniform instrument list (each row carries
|
|
||||||
its own `exchange`, `fees` and `history_start`); `get_historical`
|
|
||||||
returns candles from one explicitly chosen exchange. Cross-exchange
|
|
||||||
consensus stays available separately via `CrossClient`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, registry: _Registry, *, env: Environment):
|
def __init__(self, registry: _Registry, *, env: Environment):
|
||||||
self._registry = registry
|
self._registry = registry
|
||||||
self._env = env
|
self._env = env
|
||||||
|
|
||||||
|
# ── instruments ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _instruments_one(
|
async def _instruments_one(
|
||||||
self, exchange: str, currency: str, kind: str | None,
|
self, exchange: str, currency: str, kind: str | None,
|
||||||
) -> tuple[str, list[dict[str, Any]] | Exception]:
|
) -> tuple[str, list[dict[str, Any]] | Exception]:
|
||||||
@@ -186,8 +115,23 @@ class UnifiedClient:
|
|||||||
|
|
||||||
return {"instruments": instruments, "failed_sources": failed}
|
return {"instruments": instruments, "failed_sources": failed}
|
||||||
|
|
||||||
async def get_historical(
|
# ── historical ───────────────────────────────────────────────────
|
||||||
self, *, exchange: str, instrument: str, interval: str,
|
|
||||||
|
async def _fetch_one(
|
||||||
|
self, exchange: str, native_sym: str, native_interval: str,
|
||||||
|
start: str, end: str,
|
||||||
|
) -> tuple[str, list[dict[str, Any]] | Exception]:
|
||||||
|
try:
|
||||||
|
client = await self._registry.get(exchange, self._env)
|
||||||
|
resp = await _DISPATCH[exchange](
|
||||||
|
client, native_sym, native_interval, start, end,
|
||||||
|
)
|
||||||
|
return exchange, resp.get("candles", [])
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return exchange, e
|
||||||
|
|
||||||
|
async def _historical_single(
|
||||||
|
self, exchange: str, instrument: str, interval: str,
|
||||||
start_date: str, end_date: str,
|
start_date: str, end_date: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if exchange not in _DISPATCH:
|
if exchange not in _DISPATCH:
|
||||||
@@ -196,12 +140,6 @@ class UnifiedClient:
|
|||||||
detail=f"unsupported exchange: {exchange}; "
|
detail=f"unsupported exchange: {exchange}; "
|
||||||
f"supported: {list(_DISPATCH.keys())}",
|
f"supported: {list(_DISPATCH.keys())}",
|
||||||
)
|
)
|
||||||
if interval not in supported_intervals():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"unsupported interval: {interval}; "
|
|
||||||
f"supported: {supported_intervals()}",
|
|
||||||
)
|
|
||||||
native_interval = to_native_interval(interval, exchange)
|
native_interval = to_native_interval(interval, exchange)
|
||||||
client = await self._registry.get(exchange, self._env)
|
client = await self._registry.get(exchange, self._env)
|
||||||
resp = await _DISPATCH[exchange](
|
resp = await _DISPATCH[exchange](
|
||||||
@@ -212,4 +150,99 @@ class UnifiedClient:
|
|||||||
"instrument": instrument,
|
"instrument": instrument,
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
"candles": resp.get("candles", []),
|
"candles": resp.get("candles", []),
|
||||||
|
"sources_used": [exchange],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _historical_consensus(
|
||||||
|
self, instrument: str, asset_class: str, interval: str,
|
||||||
|
start_date: str, end_date: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sources = get_sources(asset_class, instrument)
|
||||||
|
if not sources:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"unsupported symbol/asset_class for consensus: "
|
||||||
|
f"{instrument} ({asset_class})",
|
||||||
|
)
|
||||||
|
results = await asyncio.gather(*(
|
||||||
|
self._fetch_one(
|
||||||
|
ex,
|
||||||
|
to_native_symbol(asset_class, instrument, ex),
|
||||||
|
to_native_interval(interval, ex),
|
||||||
|
start_date, end_date,
|
||||||
|
)
|
||||||
|
for ex in sources
|
||||||
|
))
|
||||||
|
|
||||||
|
by_source: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
failed: list[dict[str, str]] = []
|
||||||
|
for ex, payload in results:
|
||||||
|
if isinstance(payload, Exception):
|
||||||
|
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
|
||||||
|
else:
|
||||||
|
by_source[ex] = payload
|
||||||
|
|
||||||
|
if not by_source:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={"error": "all sources failed", "failed_sources": failed},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exchange": None,
|
||||||
|
"instrument": instrument.upper(),
|
||||||
|
"asset_class": asset_class,
|
||||||
|
"interval": interval,
|
||||||
|
"candles": merge_candles(by_source),
|
||||||
|
"sources_used": sorted(by_source.keys()),
|
||||||
|
"failed_sources": failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_historical(
|
||||||
|
self, *, exchange: str | None = None, instrument: str, interval: str,
|
||||||
|
start_date: str, end_date: str, asset_class: str = "crypto",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Single common historical call.
|
||||||
|
|
||||||
|
`exchange` set → that venue's candles (instrument = native symbol).
|
||||||
|
`exchange` omitted → cross-exchange consensus (instrument = canonical
|
||||||
|
symbol, e.g. BTC/ETH/SOL).
|
||||||
|
"""
|
||||||
|
if interval not in supported_intervals():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"unsupported interval: {interval}; "
|
||||||
|
f"supported: {supported_intervals()}",
|
||||||
|
)
|
||||||
|
if exchange is None:
|
||||||
|
return await self._historical_consensus(
|
||||||
|
instrument, asset_class, interval, start_date, end_date,
|
||||||
|
)
|
||||||
|
return await self._historical_single(
|
||||||
|
exchange, instrument, interval, start_date, end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── indicators ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_indicators(
|
||||||
|
self, *, indicators: list[str], exchange: str | None = None,
|
||||||
|
instrument: str, interval: str, start_date: str, end_date: str,
|
||||||
|
asset_class: str = "crypto",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Technical indicators over the historical series of the chosen
|
||||||
|
source (single exchange) or the consensus series (exchange omitted)."""
|
||||||
|
hist = await self.get_historical(
|
||||||
|
exchange=exchange, instrument=instrument, interval=interval,
|
||||||
|
start_date=start_date, end_date=end_date, asset_class=asset_class,
|
||||||
|
)
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"exchange": hist.get("exchange"),
|
||||||
|
"instrument": instrument,
|
||||||
|
"interval": interval,
|
||||||
|
"indicators": compute_indicators(hist["candles"], indicators),
|
||||||
|
"candles_used": len(hist["candles"]),
|
||||||
|
"sources_used": hist.get("sources_used", []),
|
||||||
|
}
|
||||||
|
if "failed_sources" in hist:
|
||||||
|
out["failed_sources"] = hist["failed_sources"]
|
||||||
|
return out
|
||||||
|
|||||||
@@ -1,48 +1,67 @@
|
|||||||
"""Pydantic schemas + thin tool wrappers for the /mcp-cross and /mcp routers."""
|
"""Pydantic schemas + thin tool wrappers for the unified /mcp router."""
|
||||||
from __future__ import annotations
|
from __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, UnifiedClient
|
from cerbero_mcp.exchanges.cross.client import UnifiedClient
|
||||||
|
|
||||||
AssetClass = Literal["crypto"]
|
AssetClass = Literal["crypto"]
|
||||||
Exchange = Literal["deribit", "hyperliquid"]
|
Exchange = Literal["deribit", "hyperliquid"]
|
||||||
|
|
||||||
|
|
||||||
class GetHistoricalReq(BaseModel):
|
def _coerce_indicators(v):
|
||||||
symbol: str
|
"""Accept a list or a JSON/comma-separated string of indicator names."""
|
||||||
asset_class: AssetClass = "crypto"
|
if isinstance(v, str):
|
||||||
interval: str = "1h"
|
import json
|
||||||
start_date: str
|
s = v.strip()
|
||||||
end_date: str
|
if s.startswith("["):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(s)
|
||||||
async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict:
|
if isinstance(parsed, list):
|
||||||
return await client.get_historical(
|
return [str(x).strip() for x in parsed if str(x).strip()]
|
||||||
symbol=params.symbol,
|
except json.JSONDecodeError:
|
||||||
asset_class=params.asset_class,
|
pass
|
||||||
interval=params.interval,
|
return [x.strip() for x in s.split(",") if x.strip()]
|
||||||
start_date=params.start_date,
|
if isinstance(v, list):
|
||||||
end_date=params.end_date,
|
return v
|
||||||
|
raise ValueError(
|
||||||
|
"indicators must be a list like ['rsi','atr','macd'] "
|
||||||
|
"or a comma-separated string like 'rsi,atr,macd'"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Unified /mcp interface ────────────────────────────────────────────
|
|
||||||
|
|
||||||
class GetInstrumentsReq(BaseModel):
|
class GetInstrumentsReq(BaseModel):
|
||||||
exchange: Exchange | None = None # None → fan-out over all integrated venues
|
exchange: Exchange | None = None # None → fan-out over all integrated venues
|
||||||
currency: str = "BTC" # Deribit only
|
currency: str = "BTC" # Deribit only
|
||||||
kind: str | None = "future" # Deribit only: future | option | spot
|
kind: str | None = "future" # Deribit only: future | option | spot
|
||||||
|
|
||||||
|
|
||||||
class UnifiedGetHistoricalReq(BaseModel):
|
class GetHistoricalReq(BaseModel):
|
||||||
exchange: Exchange
|
# exchange set → single venue (instrument = native symbol)
|
||||||
|
# exchange omitted → cross-exchange consensus (instrument = canonical symbol)
|
||||||
|
exchange: Exchange | None = None
|
||||||
instrument: str
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> dict:
|
||||||
@@ -53,13 +72,24 @@ async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> d
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def unified_get_historical(
|
async def get_historical(client: UnifiedClient, params: GetHistoricalReq) -> dict:
|
||||||
client: UnifiedClient, params: UnifiedGetHistoricalReq
|
|
||||||
) -> dict:
|
|
||||||
return await client.get_historical(
|
return await client.get_historical(
|
||||||
exchange=params.exchange,
|
exchange=params.exchange,
|
||||||
instrument=params.instrument,
|
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
|
||||||
@@ -1602,37 +1601,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 (
|
||||||
@@ -86,13 +86,6 @@ class GetTradeHistoryReq(BaseModel):
|
|||||||
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 +161,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"
|
||||||
@@ -343,12 +306,6 @@ async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) -
|
|||||||
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 +405,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
|
||||||
|
|
||||||
@@ -555,38 +554,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
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Router /mcp/* — unified common interface across integrated exchanges.
|
"""Router /mcp/* — unified common interface across integrated exchanges.
|
||||||
|
|
||||||
`get_instruments` returns one uniform instrument list (each row carries its
|
The data tools live here ONLY (not per-exchange):
|
||||||
own `exchange`, `fees` and `history_start`); `get_historical` returns candles
|
- `get_instruments` — uniform instrument list (each row carries its own
|
||||||
from one explicitly chosen exchange. This is the forward-looking interface;
|
`exchange`, `fees`, `history_start`).
|
||||||
the per-exchange routers (/mcp-deribit, …) and the consensus aggregator
|
- `get_historical` — candles from one chosen exchange, or cross-exchange
|
||||||
(/mcp-cross) remain available during the transition.
|
consensus when `exchange` is omitted.
|
||||||
|
- `get_indicators` — technical indicators over that same series.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -42,9 +43,16 @@ def make_router() -> APIRouter:
|
|||||||
|
|
||||||
@r.post("/tools/get_historical")
|
@r.post("/tools/get_historical")
|
||||||
async def _get_historical(
|
async def _get_historical(
|
||||||
params: t.UnifiedGetHistoricalReq,
|
params: t.GetHistoricalReq,
|
||||||
client: UnifiedClient = Depends(get_unified_client),
|
client: UnifiedClient = Depends(get_unified_client),
|
||||||
):
|
):
|
||||||
return await t.unified_get_historical(client, params)
|
return await t.get_historical(client, params)
|
||||||
|
|
||||||
|
@r.post("/tools/get_indicators")
|
||||||
|
async def _get_indicators(
|
||||||
|
params: t.GetIndicatorsReq,
|
||||||
|
client: UnifiedClient = Depends(get_unified_client),
|
||||||
|
):
|
||||||
|
return await t.get_indicators(client, params)
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -26,8 +26,17 @@ def test_app_boots_and_health_responds(monkeypatch):
|
|||||||
assert any(p.startswith("/mcp-hyperliquid/") for p in paths)
|
assert any(p.startswith("/mcp-hyperliquid/") 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
|
# Unified common interface owns the cross-cutting data tools
|
||||||
assert any(p.startswith("/mcp/tools/") for p in paths)
|
assert "/mcp/tools/get_instruments" in paths
|
||||||
|
assert "/mcp/tools/get_historical" in paths
|
||||||
|
assert "/mcp/tools/get_indicators" in paths
|
||||||
|
# get_historical / get_indicators are common-only, not per-exchange
|
||||||
|
assert "/mcp-deribit/tools/get_historical" not in paths
|
||||||
|
assert "/mcp-deribit/tools/get_technical_indicators" not in paths
|
||||||
|
assert "/mcp-hyperliquid/tools/get_historical" not in paths
|
||||||
|
assert "/mcp-hyperliquid/tools/get_indicators" not in paths
|
||||||
|
# The standalone consensus router has been folded into /mcp
|
||||||
|
assert not any(p.startswith("/mcp-cross/") for p in paths)
|
||||||
# Bybit and Alpaca have been retired from the API surface
|
# 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-bybit/") for p in paths)
|
||||||
assert not any(p.startswith("/mcp-alpaca/") for p in paths)
|
assert not any(p.startswith("/mcp-alpaca/") for p in paths)
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ testnet venga instradato (deribit `get_ticker BTC-PERPETUAL`).
|
|||||||
Verifica end-to-end contro Deribit/Hyperliquid (endpoint pubblici):
|
Verifica end-to-end contro Deribit/Hyperliquid (endpoint pubblici):
|
||||||
- `get_instruments exchange=deribit` → `fees` e `history_start` popolati live
|
- `get_instruments exchange=deribit` → `fees` e `history_start` popolati live
|
||||||
- `get_instruments` fan-out → contribuiscono sia deribit sia hyperliquid
|
- `get_instruments` fan-out → contribuiscono sia deribit sia hyperliquid
|
||||||
- `get_historical` deribit + hyperliquid → candele non vuote, schema uniforme
|
- `get_historical` singolo (deribit, hyperliquid) → candele non vuote
|
||||||
|
- `get_historical` consenso (senza `exchange`) → `sources_used` multipli + `div_pct`
|
||||||
|
- `get_indicators` singolo + consenso → indicatori calcolati
|
||||||
- `get_historical exchange=bybit` → `422` (rifiutato a livello schema)
|
- `get_historical exchange=bybit` → `422` (rifiutato a livello schema)
|
||||||
|
|
||||||
Variabili di ambiente:
|
Variabili di ambiente:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Smoke test interfaccia comune /mcp contro un server vivo + upstream reali.
|
# Smoke test interfaccia comune /mcp contro un server vivo + upstream reali.
|
||||||
# get_instruments e get_historical di Deribit/Hyperliquid sono pubblici, ma il
|
# get_instruments/get_historical/get_indicators di Deribit/Hyperliquid sono
|
||||||
# bearer (testnet o mainnet) e X-Bot-Tag sono comunque richiesti dall'auth.
|
# pubblici, ma bearer (testnet o mainnet) e X-Bot-Tag sono comunque richiesti.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PORT="${PORT:-9000}"
|
PORT="${PORT:-9000}"
|
||||||
@@ -40,19 +40,31 @@ post() {
|
|||||||
|
|
||||||
echo "==> get_instruments exchange=deribit (fees + history_start live)"
|
echo "==> get_instruments exchange=deribit (fees + history_start live)"
|
||||||
post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"future"}' 200 \
|
post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"future"}' 200 \
|
||||||
"assert d['instruments'], 'no instruments'; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; print(' ', i['symbol'], i['fees'], i['history_start'])"
|
"assert d['instruments']; i=d['instruments'][0]; assert i['exchange']=='deribit'; assert 'fees' in i and 'history_start' in i; print(' ', i['symbol'], i['fees'], i['history_start'])"
|
||||||
|
|
||||||
echo "==> get_instruments fan-out (deribit + hyperliquid)"
|
echo "==> get_instruments fan-out (deribit + hyperliquid)"
|
||||||
post "/mcp/tools/get_instruments" '{}' 200 \
|
post "/mcp/tools/get_instruments" '{}' 200 \
|
||||||
"exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs), '| failed:', d['failed_sources'])"
|
"exs={i['exchange'] for i in d['instruments']}; assert {'deribit','hyperliquid'} <= exs, exs; print(' venues:', sorted(exs))"
|
||||||
|
|
||||||
echo "==> get_historical deribit BTC-PERPETUAL 1h"
|
echo "==> get_historical SINGLE deribit BTC-PERPETUAL 1h"
|
||||||
post "/mcp/tools/get_historical" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
post "/mcp/tools/get_historical" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||||
"assert d['exchange']=='deribit'; assert d['candles'], 'no candles'; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))"
|
"assert d['exchange']=='deribit'; assert d['candles']; c=d['candles'][0]; assert {'timestamp','open','high','low','close','volume'} <= set(c); print(' candles:', len(d['candles']))"
|
||||||
|
|
||||||
echo "==> get_historical hyperliquid BTC 1h"
|
echo "==> get_historical SINGLE hyperliquid BTC 1h"
|
||||||
post "/mcp/tools/get_historical" "{\"exchange\":\"hyperliquid\",\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
post "/mcp/tools/get_historical" "{\"exchange\":\"hyperliquid\",\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||||
"assert d['exchange']=='hyperliquid'; assert d['candles'], 'no candles'; print(' candles:', len(d['candles']))"
|
"assert d['exchange']=='hyperliquid'; assert d['candles']; print(' candles:', len(d['candles']))"
|
||||||
|
|
||||||
|
echo "==> get_historical CONSENSUS (no exchange) BTC 1h"
|
||||||
|
post "/mcp/tools/get_historical" "{\"instrument\":\"BTC\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||||
|
"assert d['exchange'] is None; assert {'deribit','hyperliquid'} <= set(d['sources_used']), d['sources_used']; assert 'div_pct' in d['candles'][0]; print(' sources:', d['sources_used'], '| candles:', len(d['candles']))"
|
||||||
|
|
||||||
|
echo "==> get_indicators SINGLE deribit BTC-PERPETUAL"
|
||||||
|
post "/mcp/tools/get_indicators" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"indicators\":[\"rsi\",\"atr\",\"macd\"],\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||||
|
"assert set(d['indicators']) == {'rsi','atr','macd'}; print(' rsi:', d['indicators']['rsi'], '| candles_used:', d['candles_used'])"
|
||||||
|
|
||||||
|
echo "==> get_indicators CONSENSUS BTC"
|
||||||
|
post "/mcp/tools/get_indicators" "{\"instrument\":\"BTC\",\"indicators\":\"rsi,atr\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \
|
||||||
|
"assert d['exchange'] is None; assert 'rsi' in d['indicators']; print(' sources:', d['sources_used'])"
|
||||||
|
|
||||||
echo "==> get_historical exchange non supportato → 422"
|
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
|
post "/mcp/tools/get_historical" "{\"exchange\":\"bybit\",\"instrument\":\"BTCUSDT\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 422
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from cerbero_mcp.exchanges.cross.client import CrossClient
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
|
|
||||||
class _Fake:
|
|
||||||
def __init__(self, candles: list[dict[str, Any]] | None = None,
|
|
||||||
*, raises: Exception | None = None):
|
|
||||||
self._candles = candles or []
|
|
||||||
self._raises = raises
|
|
||||||
self.calls: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
|
||||||
if self._raises:
|
|
||||||
raise self._raises
|
|
||||||
self.calls.append(kwargs)
|
|
||||||
return {"candles": list(self._candles)}
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeRegistry:
|
|
||||||
def __init__(self, clients: dict[str, _Fake]):
|
|
||||||
self._clients = clients
|
|
||||||
|
|
||||||
async def get(self, exchange: str, env: str) -> _Fake:
|
|
||||||
if exchange not in self._clients:
|
|
||||||
raise KeyError(exchange)
|
|
||||||
return self._clients[exchange]
|
|
||||||
|
|
||||||
|
|
||||||
def _c(ts: int, close: float = 100.0) -> dict[str, Any]:
|
|
||||||
return {"timestamp": ts, "open": close, "high": close, "low": close,
|
|
||||||
"close": close, "volume": 1.0}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_crypto_two_sources_aggregates():
|
|
||||||
fakes = {
|
|
||||||
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
|
|
||||||
"deribit": _Fake([_c(1, 100), _c(2, 200)]),
|
|
||||||
}
|
|
||||||
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
|
|
||||||
out = await cc.get_historical(
|
|
||||||
symbol="BTC", asset_class="crypto", interval="1h",
|
|
||||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
|
||||||
)
|
|
||||||
assert out["symbol"] == "BTC"
|
|
||||||
assert out["asset_class"] == "crypto"
|
|
||||||
assert len(out["candles"]) == 2
|
|
||||||
assert out["candles"][0]["sources"] == 2
|
|
||||||
assert out["candles"][0]["div_pct"] == 0.0
|
|
||||||
assert set(out["sources_used"]) == {"hyperliquid", "deribit"}
|
|
||||||
assert out["failed_sources"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_crypto_partial_failure_returns_partial_with_warning():
|
|
||||||
fakes = {
|
|
||||||
"hyperliquid": _Fake([_c(1, 100)]),
|
|
||||||
"deribit": _Fake(raises=RuntimeError("upstream down")),
|
|
||||||
}
|
|
||||||
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
|
|
||||||
out = await cc.get_historical(
|
|
||||||
symbol="BTC", asset_class="crypto", interval="1h",
|
|
||||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
|
||||||
)
|
|
||||||
assert out["candles"][0]["sources"] == 1
|
|
||||||
assert set(out["sources_used"]) == {"hyperliquid"}
|
|
||||||
assert len(out["failed_sources"]) == 1
|
|
||||||
assert out["failed_sources"][0]["exchange"] == "deribit"
|
|
||||||
assert "upstream down" in out["failed_sources"][0]["error"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_all_sources_fail_raises_502():
|
|
||||||
fakes = {
|
|
||||||
"hyperliquid": _Fake(raises=RuntimeError("b")),
|
|
||||||
"deribit": _Fake(raises=RuntimeError("c")),
|
|
||||||
}
|
|
||||||
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await cc.get_historical(
|
|
||||||
symbol="BTC", asset_class="crypto", interval="1h",
|
|
||||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
|
||||||
)
|
|
||||||
assert exc_info.value.status_code == 502
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unsupported_symbol_raises_400():
|
|
||||||
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await cc.get_historical(
|
|
||||||
symbol="NONEXISTENT", asset_class="crypto", interval="1h",
|
|
||||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
|
||||||
)
|
|
||||||
assert exc_info.value.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unsupported_interval_raises_400():
|
|
||||||
cc = CrossClient(_FakeRegistry({}), env="mainnet")
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
|
||||||
await cc.get_historical(
|
|
||||||
symbol="BTC", asset_class="crypto", interval="3h",
|
|
||||||
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
|
|
||||||
)
|
|
||||||
assert exc_info.value.status_code == 400
|
|
||||||
@@ -10,28 +10,24 @@ from cerbero_mcp.exchanges.cross.instruments import (
|
|||||||
)
|
)
|
||||||
from fastapi import HTTPException
|
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 ──────────────────────────────────────────────────────
|
# ── Normalizers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_normalize_deribit_populates_fees_and_history_start():
|
def test_normalize_deribit_populates_fees_and_history_start():
|
||||||
rows = [{
|
rows = [{
|
||||||
"name": "BTC-PERPETUAL",
|
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
|
||||||
"kind": "future",
|
"maker_commission": 0.0, "taker_commission": 0.0005,
|
||||||
"tick_size": 0.5,
|
|
||||||
"maker_commission": 0.0,
|
|
||||||
"taker_commission": 0.0005,
|
|
||||||
"creation_timestamp": 1534377600000, # 2018-08-16
|
"creation_timestamp": 1534377600000, # 2018-08-16
|
||||||
"strike": None,
|
|
||||||
"expiry": None,
|
|
||||||
"option_type": None,
|
|
||||||
"min_trade_amount": 10,
|
|
||||||
"open_interest": 123.0,
|
"open_interest": 123.0,
|
||||||
}]
|
}]
|
||||||
out = normalize_deribit(rows)
|
inst = normalize_deribit(rows)[0]
|
||||||
assert len(out) == 1
|
|
||||||
inst = out[0]
|
|
||||||
assert inst["exchange"] == "deribit"
|
assert inst["exchange"] == "deribit"
|
||||||
assert inst["symbol"] == "BTC-PERPETUAL"
|
assert inst["symbol"] == "BTC-PERPETUAL"
|
||||||
assert inst["asset_class"] == "crypto"
|
|
||||||
assert inst["type"] == "perpetual"
|
assert inst["type"] == "perpetual"
|
||||||
assert inst["fees"] == {"maker": 0.0, "taker": 0.0005}
|
assert inst["fees"] == {"maker": 0.0, "taker": 0.0005}
|
||||||
assert inst["history_start"] == "2018-08-16"
|
assert inst["history_start"] == "2018-08-16"
|
||||||
@@ -39,41 +35,39 @@ def test_normalize_deribit_populates_fees_and_history_start():
|
|||||||
assert inst["native"]["open_interest"] == 123.0
|
assert inst["native"]["open_interest"] == 123.0
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_deribit_dated_future_is_future_not_perpetual():
|
def test_normalize_deribit_dated_future_is_future():
|
||||||
out = normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])
|
assert normalize_deribit([{"name": "BTC-27JUN25", "kind": "future"}])[0]["type"] == "future"
|
||||||
assert out[0]["type"] == "future"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_deribit_option_type():
|
def test_normalize_deribit_option():
|
||||||
out = normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])
|
assert normalize_deribit([{"name": "BTC-27JUN25-100000-C", "kind": "option"}])[0]["type"] == "option"
|
||||||
assert out[0]["type"] == "option"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_deribit_fees_none_when_absent():
|
def test_normalize_deribit_fees_none_when_absent():
|
||||||
out = normalize_deribit([{"name": "X", "kind": "future"}])
|
inst = normalize_deribit([{"name": "X", "kind": "future"}])[0]
|
||||||
assert out[0]["fees"] is None
|
assert inst["fees"] is None and inst["history_start"] is None
|
||||||
assert out[0]["history_start"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_hyperliquid_leaves_fees_and_history_none():
|
def test_normalize_hyperliquid_leaves_fees_and_history_none():
|
||||||
rows = [{
|
rows = [{"asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001,
|
||||||
"asset": "BTC", "mark_price": 100.0, "funding_rate": 0.0001,
|
"open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50}]
|
||||||
"open_interest": 5.0, "volume_24h": 9.0, "max_leverage": 50,
|
inst = normalize_hyperliquid(rows)[0]
|
||||||
}]
|
assert inst["exchange"] == "hyperliquid"
|
||||||
out = normalize_hyperliquid(rows)
|
assert inst["symbol"] == "BTC"
|
||||||
assert out[0]["exchange"] == "hyperliquid"
|
assert inst["type"] == "perpetual"
|
||||||
assert out[0]["symbol"] == "BTC"
|
assert inst["fees"] is None and inst["history_start"] is None
|
||||||
assert out[0]["type"] == "perpetual"
|
assert inst["native"]["max_leverage"] == 50
|
||||||
assert out[0]["fees"] is None
|
|
||||||
assert out[0]["history_start"] is None
|
|
||||||
assert out[0]["native"]["max_leverage"] == 50
|
|
||||||
|
|
||||||
|
|
||||||
# ── UnifiedClient ────────────────────────────────────────────────────
|
# ── Fakes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _FakeDeribit:
|
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]:
|
async def get_instruments(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
self.last_call = kwargs
|
|
||||||
return {"instruments": [{
|
return {"instruments": [{
|
||||||
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
|
"name": "BTC-PERPETUAL", "kind": "future", "tick_size": 0.5,
|
||||||
"maker_commission": 0.0, "taker_commission": 0.0005,
|
"maker_commission": 0.0, "taker_commission": 0.0005,
|
||||||
@@ -81,18 +75,26 @@ class _FakeDeribit:
|
|||||||
}]}
|
}]}
|
||||||
|
|
||||||
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
if self._raises:
|
||||||
|
raise self._raises
|
||||||
self.hist_call = kwargs
|
self.hist_call = kwargs
|
||||||
return {"candles": [{"timestamp": 1, "open": 1, "high": 1,
|
return {"candles": list(self._candles)}
|
||||||
"low": 1, "close": 1, "volume": 1}]}
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeHyperliquid:
|
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]]:
|
async def get_markets(self) -> list[dict[str, Any]]:
|
||||||
return [{"asset": "BTC", "mark_price": 1.0, "funding_rate": 0.0,
|
return [{"asset": "BTC", "mark_price": 1.0, "funding_rate": 0.0,
|
||||||
"open_interest": 1.0, "volume_24h": 1.0, "max_leverage": 50}]
|
"open_interest": 1.0, "volume_24h": 1.0, "max_leverage": 50}]
|
||||||
|
|
||||||
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
return {"candles": []}
|
if self._raises:
|
||||||
|
raise self._raises
|
||||||
|
return {"candles": list(self._candles)}
|
||||||
|
|
||||||
|
|
||||||
class _FakeRegistry:
|
class _FakeRegistry:
|
||||||
@@ -105,47 +107,45 @@ class _FakeRegistry:
|
|||||||
return self._clients[exchange]
|
return self._clients[exchange]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def _both() -> UnifiedClient:
|
||||||
async def test_get_instruments_fans_out_all_venues():
|
return UnifiedClient(
|
||||||
uc = UnifiedClient(
|
|
||||||
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
|
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
|
||||||
env="mainnet",
|
env="mainnet",
|
||||||
)
|
)
|
||||||
out = await uc.get_instruments()
|
|
||||||
venues = {i["exchange"] for i in out["instruments"]}
|
|
||||||
assert venues == {"deribit", "hyperliquid"}
|
# ── get_instruments ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_instruments_fans_out_all_venues():
|
||||||
|
out = await _both().get_instruments()
|
||||||
|
assert {i["exchange"] for i in out["instruments"]} == {"deribit", "hyperliquid"}
|
||||||
assert out["failed_sources"] == []
|
assert out["failed_sources"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_instruments_single_exchange_filter():
|
async def test_get_instruments_single_exchange_filter():
|
||||||
uc = UnifiedClient(
|
out = await _both().get_instruments(exchange="deribit")
|
||||||
_FakeRegistry({"deribit": _FakeDeribit(), "hyperliquid": _FakeHyperliquid()}),
|
|
||||||
env="mainnet",
|
|
||||||
)
|
|
||||||
out = await uc.get_instruments(exchange="deribit")
|
|
||||||
assert {i["exchange"] for i in out["instruments"]} == {"deribit"}
|
assert {i["exchange"] for i in out["instruments"]} == {"deribit"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_instruments_unsupported_exchange_raises_400():
|
async def test_get_instruments_unsupported_exchange_raises_400():
|
||||||
uc = UnifiedClient(_FakeRegistry({}), env="mainnet")
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
await uc.get_instruments(exchange="bybit")
|
await UnifiedClient(_FakeRegistry({}), env="mainnet").get_instruments(exchange="bybit")
|
||||||
assert exc.value.status_code == 400
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_instruments_partial_failure_reports_failed():
|
async def test_get_instruments_partial_failure_reports_failed():
|
||||||
uc = UnifiedClient(
|
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
|
||||||
_FakeRegistry({"deribit": _FakeDeribit()}), # hyperliquid missing → KeyError
|
|
||||||
env="mainnet",
|
|
||||||
)
|
|
||||||
out = await uc.get_instruments()
|
out = await uc.get_instruments()
|
||||||
assert {i["exchange"] for i in out["instruments"]} == {"deribit"}
|
assert {i["exchange"] for i in out["instruments"]} == {"deribit"}
|
||||||
assert [f["exchange"] for f in out["failed_sources"]] == ["hyperliquid"]
|
assert [f["exchange"] for f in out["failed_sources"]] == ["hyperliquid"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_historical: single exchange ──────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_historical_single_exchange():
|
async def test_get_historical_single_exchange():
|
||||||
fake = _FakeDeribit()
|
fake = _FakeDeribit()
|
||||||
@@ -156,16 +156,15 @@ async def test_get_historical_single_exchange():
|
|||||||
)
|
)
|
||||||
assert out["exchange"] == "deribit"
|
assert out["exchange"] == "deribit"
|
||||||
assert out["instrument"] == "BTC-PERPETUAL"
|
assert out["instrument"] == "BTC-PERPETUAL"
|
||||||
assert out["interval"] == "1h"
|
assert out["sources_used"] == ["deribit"]
|
||||||
assert len(out["candles"]) == 1
|
assert len(out["candles"]) == 2
|
||||||
assert fake.hist_call["instrument"] == "BTC-PERPETUAL"
|
assert fake.hist_call["instrument"] == "BTC-PERPETUAL"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_historical_unsupported_exchange_raises_400():
|
async def test_get_historical_unsupported_exchange_raises_400():
|
||||||
uc = UnifiedClient(_FakeRegistry({}), env="mainnet")
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
await uc.get_historical(
|
await UnifiedClient(_FakeRegistry({}), env="mainnet").get_historical(
|
||||||
exchange="bybit", instrument="BTCUSDT", interval="1h",
|
exchange="bybit", instrument="BTCUSDT", interval="1h",
|
||||||
start_date="2026-05-09", end_date="2026-05-10",
|
start_date="2026-05-09", end_date="2026-05-10",
|
||||||
)
|
)
|
||||||
@@ -174,10 +173,92 @@ async def test_get_historical_unsupported_exchange_raises_400():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_historical_unsupported_interval_raises_400():
|
async def test_get_historical_unsupported_interval_raises_400():
|
||||||
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
|
|
||||||
with pytest.raises(HTTPException) as exc:
|
with pytest.raises(HTTPException) as exc:
|
||||||
await uc.get_historical(
|
await _both().get_historical(
|
||||||
exchange="deribit", instrument="BTC-PERPETUAL", interval="3h",
|
exchange="deribit", instrument="BTC-PERPETUAL", interval="3h",
|
||||||
start_date="2026-05-09", end_date="2026-05-10",
|
start_date="2026-05-09", end_date="2026-05-10",
|
||||||
)
|
)
|
||||||
assert exc.value.status_code == 400
|
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user