Compare commits

..

9 Commits

Author SHA1 Message Date
Adriano 5ce33b1b3c docs(api): note Hyperliquid get_historical paging in API_REFERENCE
get_historical now pages+trims wide ranges on Hyperliquid too (fe7f8a1),
not just Deribit. Generalize the start_date semantics note accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:10:18 +00:00
Adriano fe7f8a152b fix(hyperliquid): page+trim get_historical past 5000-candle cap
candleSnapshot caps each response at ~5000 candles and keeps the newest
slice, silently dropping older candles when the range needs more — so a
wide start_date was not honored. Mirror Deribit's approach: page the range
in windows of interval_ms * 5000, trim each page back to [start_ms, end_ms],
dedupe by timestamp, with a 500-page hard stop. Resolutions without a known
interval fall back to the original single-call path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:07:02 +00:00
Adriano 9a74052dc5 feat(deribit): get_open_orders (limit resting + trigger non scattati)
Espone private/get_open_orders_by_currency sul router Deribit (pattern gia'
presente per Hyperliquid/IBKR). Serve al reconciler resting di PythagorasGoal:
TP/DSL attesi dai libri vs ordini realmente in book (caso MR02_BTC 2026-06-12:
TP resting fillato di notte + disaster-SL sparito, scoperti solo al close sim).
NB: per i trigger untriggered interrogare anche type='trigger_all' e fare
merge per order_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:23:01 +00:00
Adriano 350eeb4f76 chore(ops): add prune-acme-stale.sh to remove dead domains from Traefik acme.json
Stale certificate entries (mattermost/zar/registry.tielogic.xyz) in
Traefik's acme.json triggered repeated NXDOMAIN renewal errors. Script
atomically stops Traefik, backs up acme.json, filters out the listed
domains with JSON validation + auto-rollback, restores 600 root:adriano
perms, and restarts. Domain list is extensible via the STALE array.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:06:26 +00:00
Adriano ee799f3903 fix(deploy): health-check via docker network, not host port
The VPS doesn't publish the app port to the host (Traefik reaches it over
the docker network), so curl http://localhost:PORT/health always failed —
a false negative that could trigger an unwanted automatic rollback on a
successful deploy. Check /health from INSIDE the container via
`docker compose exec` instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:27:41 +00:00
Adriano 726984e4f1 chore: gitignore .claude/ session artifacts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:42:28 +00:00
Adriano 6fba410e6f docs(V2): move API_REFERENCE.md to repo root
git mv docs/API_REFERENCE.md → API_REFERENCE.md (history preserved) and
update the CLAUDE.md reference accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:09:02 +00:00
Adriano 8d7e2ca30f refactor(V2): get_instruments common-only on /mcp (enriched with Deribit filters)
Move get_instruments off the per-exchange Deribit router and onto the
unified interface, without losing the advanced Deribit filtering.

- /mcp/tools/get_instruments now accepts the Deribit-specific filters
  (currency, kind, expiry_from/to, strike_min/max, min_open_interest) and
  pagination (offset/limit). Venues that list everything (Hyperliquid)
  ignore them. Response adds `meta: {deribit: {total, offset, limit,
  has_more}}` surfacing per-source pagination.
- Remove /mcp-deribit/tools/get_instruments (endpoint + tool wrapper +
  GetInstrumentsReq schema). The DeribitClient.get_instruments METHOD
  stays — it's what the unified normalizer calls.

Tests: cover the enriched filters + meta passthrough; app-boot asserts
/mcp-deribit/tools/get_instruments is gone. Docs (API_REFERENCE/README/
CLAUDE) and smoke updated; also fixed the stale Hyperliquid tool count
(14 → 15) found during the doc check.

325 passed, ruff clean. Verified live: kind=option limit=5 → 5/940
has_more=true; /mcp-deribit/tools/get_instruments → 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:06:09 +00:00
Adriano 6faf21369d 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>
2026-05-29 10:51:21 +00:00
24 changed files with 878 additions and 771 deletions
+3
View File
@@ -39,3 +39,6 @@ config/*.env
# Override locale compose (specifico macchina, fix daemon vecchi, ecc.) # Override locale compose (specifico macchina, fix daemon vecchi, ecc.)
docker-compose.local.yml docker-compose.local.yml
# Claude Code session artifacts (locali, non versionati)
.claude/
+85 -74
View File
@@ -50,16 +50,17 @@ 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/*` | 31 | exchange (options-first) | DVOL, GEX, dealer gamma, spread payoff |
| `/mcp-hyperliquid/tools/*` | 16 | exchange (perp DEX) | L1 signing, leverage cap | | `/mcp-hyperliquid/tools/*` | 15 | exchange (perp DEX) | L1 signing, leverage cap |
| `/mcp-ibkr/tools/*` | 24 | exchange (multi-asset broker) | OAuth 1.0a, streaming WS, bracket/OCO/OTO | | `/mcp-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_instruments`, `get_historical` e `get_indicators` vivono **solo**
transizione; per nuovi consumatori è raccomandata l'interfaccia comune `/mcp`. sull'interfaccia comune `/mcp` (non più per-exchange). I restanti tool
per-exchange (ticker, ordini, analytics options, …) restano sui rispettivi
router `/mcp-{exchange}`.
--- ---
@@ -76,10 +77,16 @@ Request body:
| Campo | Tipo | Default | Note | | Campo | Tipo | Default | Note |
|---|---|---|---| |---|---|---|---|
| `exchange` | `"deribit"\|"hyperliquid"` \| null | `null` | se omesso → fan-out su tutti gli exchange integrati | | `exchange` | `"deribit"\|"hyperliquid"` \| null | `null` | se omesso → fan-out su tutti gli exchange integrati |
| `currency` | str | `"BTC"` | solo Deribit | | `currency` | str | `"BTC"` | filtro Deribit |
| `kind` | str \| null | `"future"` | solo Deribit (`future`/`option`/`spot`) | | `kind` | str \| null | `"future"` | filtro Deribit (`future`/`option`/`spot`) |
| `expiry_from` / `expiry_to` | str \| null | `null` | filtro Deribit (data scadenza) |
| `strike_min` / `strike_max` | float \| null | `null` | filtro Deribit (opzioni) |
| `min_open_interest` | float \| null | `null` | filtro Deribit |
| `offset` / `limit` | int | `0` / `100` | paginazione Deribit |
Ogni elemento di `instruments[]` ha **schema uniforme**: I filtri/paginazione si applicano a **Deribit**; gli exchange che elencano
tutto (es. Hyperliquid `get_markets`) li ignorano. Ogni elemento di
`instruments[]` ha **schema uniforme**:
```json ```json
{ {
@@ -94,30 +101,56 @@ Ogni elemento di `instruments[]` ha **schema uniforme**:
} }
``` ```
Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}] }`. Risposta: `{ "instruments": [...], "failed_sources": [{exchange, error}],
"meta": { "deribit": {total, offset, limit, has_more} } }`. Il blocco `meta`
riporta la paginazione per-sorgente quando disponibile (oggi: Deribit).
> **Copertura `fees` / `history_start`** — popolati live dall'upstream dove > **Copertura `fees` / `history_start`** — popolati live dall'upstream dove
> disponibili. Oggi: **Deribit** li espone (commissioni maker/taker + > disponibili. Oggi: **Deribit** li espone (commissioni maker/taker +
> `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 e Hyperliquid sono paginati
> internamente in finestre da ~5000 candele e ritagliati al range, così
> `start_date` è sempre rispettato anche oltre il cap per-richiesta dell'upstream.
### `get_indicators` — indicatori sulla stessa serie
Stessa selezione sorgente di `get_historical` (single o consensus); calcola
indicatori tecnici sulle candele risultanti. Request body: i campi di
`get_historical` più `indicators` (lista o stringa CSV, es. `["rsi","atr"]` o
`"rsi,atr,macd"`; default `["rsi","atr","macd","adx"]`). Supportati:
`sma`, `rsi`, `atr`, `macd`, `adx` (nomi ignoti → `null`).
Risposta: `{ exchange, instrument, interval, indicators:{...}, candles_used,
sources_used }` (+ `failed_sources` in modalità consenso).
--- ---
@@ -129,26 +162,19 @@ Per il **consenso** multi-exchange (mediana cross-venue) usare invece
### Market data ### Market data
- `get_ticker`, `get_ticker_batch` - `get_ticker`, `get_ticker_batch`
- `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 > Lista strumenti, storico OHLCV e indicatori tecnici NON sono più qui: usa
> `get_dvol` e `get_technical_indicators`). Tutti i timestamp sono in **UTC**. > `/mcp/tools/get_instruments`, `/mcp/tools/get_historical` e
> - Date nude `YYYY-MM-DD`: `start_date` = `00:00:00`, `end_date` = > `/mcp/tools/get_indicators` (sezione 5) con `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`
- `get_account_summary` - `get_account_summary`
- `get_open_orders` — ordini aperti (`currency`, `kind?`, `type` default `all`;
per i trigger non scattati interrogare anche `type="trigger_all"` e fare
merge per `order_id`)
### Options analytics ### Options analytics
- `get_dvol`, `get_dvol_history` - `get_dvol`, `get_dvol_history`
@@ -160,7 +186,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 +203,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 +294,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 +315,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 +337,24 @@ 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", expiry_from?, expiry_to?, strike_min?, strike_max?, min_open_interest?, offset:0, limit:100}` | `instruments[]` uniforme: `exchange`, `symbol`, `asset_class`, `type`, `fees`, `history_start`, `tick_size`, `native` · `failed_sources[]` · `meta:{deribit:{total,offset,limit,has_more}}` |
| 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}` |
Sorgenti normalizzate dentro `get_instruments`: Deribit espone `name`,
`expiry`, `option_type`, `tick_size`, `min_trade_amount`, `maker_commission`,
`taker_commission`, `creation_timestamp` (→ `fees`/`history_start`); Hyperliquid
`get_markets` espone `{asset, mark_price, funding_rate, open_interest,
volume_24h, max_leverage}`. Ogni risposta `/tools/*` riceve inoltre
`data_timestamp` dal middleware.
### Convenzione simboli Deribit ### Convenzione simboli Deribit
- **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL` - **BTC/ETH** → perpetui *inverse*: `BTC-PERPETUAL`, `ETH-PERPETUAL`
+13 -10
View File
@@ -59,18 +59,21 @@ 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`; filtri/paginazione Deribit + `meta` per-sorgente); `get_historical`
Supporta deribit + hyperliquid (IBKR non ancora integrato). e `get_indicators` con `exchange` opzionale → singolo venue se valorizzato,
- **`/mcp-cross`** — `get_historical` a consenso multi-exchange (mediana OHLC, **consenso** multi-exchange (mediana OHLC + `div_pct`) se omesso. Supporta
`sources`, `div_pct`). deribit + hyperliquid (IBKR non integrato).
- **`/mcp-{exchange}`** — router per-exchange legacy, ancora attivi. - **`/mcp-{exchange}`** — router per-exchange per il resto (ticker, ordini,
options analytics, …). **NON** espongono più
`get_instruments`/`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
@@ -87,5 +90,5 @@ l'upstream li espone** (oggi: Deribit), altrimenti `null`.
## Documentazione ## Documentazione
- `README.md` — overview, setup, deploy, migrazione V1→V2, IBKR OAuth. - `README.md` — overview, setup, deploy, migrazione V1→V2, IBKR OAuth.
- `docs/API_REFERENCE.md` — catalogo completo endpoint/tool. Aggiornarlo - `API_REFERENCE.md` (root) — catalogo completo endpoint/tool. Aggiornarlo
quando si aggiungono/rimuovono tool o si cambiano gli schemi. quando si aggiungono/rimuovono tool o si cambiano gli schemi.
+33 -30
View File
@@ -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,15 @@ Tecnici (`sma`, `rsi`, `macd`, `atr`, `adx`), volatilità (`vol_cone`,
### Deribit ### Deribit
DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol, DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol,
indicatori tecnici, find_by_delta, calculate_spread_payoff, find_by_delta, calculate_spread_payoff, get_dealer_gamma_profile,
get_dealer_gamma_profile, get_vanna_charm, get_oi_weighted_skew, get_vanna_charm, get_oi_weighted_skew, get_smile_asymmetry,
get_smile_asymmetry, get_atm_vs_wings_vol, get_orderbook_imbalance, get_atm_vs_wings_vol, get_orderbook_imbalance, run_backtest, place_combo_order.
place_combo_order. (Strumenti/storico/indicatori → `/mcp`.)
### Hyperliquid ### Hyperliquid
Account summary, positions, orderbook, historical, indicators, funding Account summary, positions, orderbook, funding rate, basis spot/perp,
rate, basis spot/perp, place_order, set_stop_loss, set_take_profit. place_order, set_stop_loss, set_take_profit.
(Strumenti/storico/indicatori → `/mcp`.)
### IBKR (Interactive Brokers) ### IBKR (Interactive Brokers)
Account, positions, activities, ticker, bars, snapshot, option chain, Account, positions, activities, ticker, bars, snapshot, option chain,
@@ -204,24 +206,25 @@ 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. Filtri avanzati Deribit
quality gate per i bot. Crypto: BTC/ETH via Hyperliquid + Deribit, SOL via (`kind`, `expiry_*`, `strike_*`, `min_open_interest`) e paginazione
Hyperliquid. In caso di fallimento parziale ritorna i dati disponibili più (`offset`/`limit`, con `meta.deribit.has_more`).
`failed_sources`; se *tutti* gli upstream falliscono → HTTP 502 retryable. - **`get_historical`** — `{exchange?, instrument, interval, start_date,
end_date}`. Con `exchange` ritorna il **singolo** exchange; **omettendolo**
fa il **consenso** multi-exchange (mediana OHLC, `sources`,
`div_pct=(max-min)/median` come quality gate) usando il simbolo canonico
(BTC/ETH via Hyperliquid+Deribit, SOL via Hyperliquid). Fallimento parziale →
`failed_sources`; tutti gli upstream giù → HTTP 502 retryable.
- **`get_indicators`** — stessa selezione sorgente; calcola sma/rsi/atr/macd/adx
sulle candele risultanti.
## Deploy su VPS con Traefik ## Deploy su VPS con Traefik
+14 -3
View File
@@ -42,7 +42,17 @@ if [[ -z "${PORT:-}" ]]; then
fi fi
fi fi
PORT="${PORT:-9000}" PORT="${PORT:-9000}"
HEALTH_URL="http://localhost:${PORT}/health" # Health verificato DENTRO la rete del container: in produzione la porta NON è
# pubblicata sull'host (Traefik la raggiunge via rete docker), quindi un curl da
# localhost darebbe un falso negativo (e rischierebbe un rollback inutile).
HEALTH_URL="http://127.0.0.1:${PORT}/health"
# 0 se /health risponde 200 dall'interno del container.
app_health() {
docker compose exec -T "$SERVICE" python -c \
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('${HEALTH_URL}').status==200 else 1)" \
>/dev/null 2>&1
}
# ─── Pre-check ─────────────────────────────────────────────────────────── # ─── Pre-check ───────────────────────────────────────────────────────────
command -v git >/dev/null || { echo "FATAL: git non installato"; exit 1; } command -v git >/dev/null || { echo "FATAL: git non installato"; exit 1; }
@@ -125,10 +135,11 @@ docker compose up -d
echo "==> attendo /health (timeout ${HEALTH_TIMEOUT_SECONDS}s, retry ogni ${HEALTH_INTERVAL}s)" echo "==> attendo /health (timeout ${HEALTH_TIMEOUT_SECONDS}s, retry ogni ${HEALTH_INTERVAL}s)"
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECONDS )) deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECONDS ))
while [[ $(date +%s) -lt $deadline ]]; do while [[ $(date +%s) -lt $deadline ]]; do
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then if app_health; then
echo echo
echo "==> health OK" echo "==> health OK"
curl -s "$HEALTH_URL" docker compose exec -T "$SERVICE" python -c \
"import urllib.request; print(urllib.request.urlopen('${HEALTH_URL}').read().decode())" 2>/dev/null || true
echo echo
echo echo
echo "==> deploy DONE (SHA $CURRENT_SHA$NEW_SHA, branch $BRANCH)" echo "==> deploy DONE (SHA $CURRENT_SHA$NEW_SHA, branch $BRANCH)"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Rimuove certificati stale da acme.json di Traefik (domini non più serviti).
# Idempotente e atomico: stop Traefik -> backup -> filtro+valida -> ripristino
# perms/owner (600 root:adriano) -> restart Traefik.
#
# Uso: sudo bash scripts/prune-acme-stale.sh
set -euo pipefail
ACME="/opt/docker/traefik/acme.json"
CONTAINER="traefik-traefik-1"
# Domini da rimuovere (match esatto su .domain.main). Estendibile.
STALE=("mattermost.tielogic.xyz" "zar.tielogic.xyz" "registry.tielogic.xyz")
if [[ $EUID -ne 0 ]]; then
echo "Deve girare come root (usa: sudo bash $0)" >&2
exit 1
fi
[[ -f "$ACME" ]] || { echo "Non trovo $ACME" >&2; exit 1; }
ts="$(date +%Y%m%d-%H%M%S)"
backup="${ACME}.bak-${ts}"
cp -p "$ACME" "$backup"
echo "Backup: $backup"
echo "=== Domini attualmente in storage ==="
python3 - "$ACME" <<'PY'
import json,sys
d=json.load(open(sys.argv[1]))
for res,blk in d.items():
for c in (blk or {}).get("Certificates") or []:
print(f" [{res}] {c.get('domain',{}).get('main')}")
PY
echo "Stop $CONTAINER (evita riscrittura concorrente)…"
docker stop "$CONTAINER" >/dev/null
# Filtro in Python: rimuove i cert il cui domain.main è nella lista STALE.
removed=$(python3 - "$ACME" "${STALE[@]}" <<'PY'
import json,sys,os,tempfile
path=sys.argv[1]; stale=set(sys.argv[2:])
d=json.load(open(path))
removed=[]
for res,blk in d.items():
certs=(blk or {}).get("Certificates")
if not certs: continue
kept=[]
for c in certs:
main=c.get("domain",{}).get("main")
if main in stale: removed.append(main)
else: kept.append(c)
blk["Certificates"]=kept
# scrittura atomica nella stessa dir, poi os.replace
dir_=os.path.dirname(path)
fd,tmp=tempfile.mkstemp(dir=dir_)
with os.fdopen(fd,"w") as f:
json.dump(d,f,indent=2) # ri-serializza valido
os.replace(tmp,path)
print("\n".join(removed))
PY
)
# Ripristina perms/owner che Traefik esige (600) e ownership originale.
chmod 600 "$ACME"
chown root:adriano "$ACME"
# Validazione finale: deve essere JSON valido (altrimenti rollback dal backup).
if ! python3 -c "import json,sys; json.load(open('$ACME'))" 2>/dev/null; then
echo "JSON risultante NON valido — rollback dal backup." >&2
cp -p "$backup" "$ACME"; chmod 600 "$ACME"; chown root:adriano "$ACME"
docker start "$CONTAINER" >/dev/null
exit 1
fi
echo "Restart $CONTAINER"
docker start "$CONTAINER" >/dev/null
echo "=== Rimossi ==="
if [[ -n "${removed//[$'\n ']/}" ]]; then echo "$removed" | sed 's/^/ /'; else echo " (nessuno — già puliti)"; fi
echo "=== Permessi finali ==="
ls -la "$ACME"
echo "Fatto. Se qualcosa va storto, ripristina: sudo cp -p $backup $ACME && sudo chmod 600 $ACME && docker restart $CONTAINER"
-2
View File
@@ -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())
+28
View File
@@ -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
+147 -97
View File
@@ -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,11 +59,81 @@ _DISPATCH = {
} }
class CrossClient: class UnifiedClient:
"""Single common interface over the integrated venues (deribit, hyperliquid)."""
def __init__(self, registry: _Registry, *, env: Environment): def __init__(self, registry: _Registry, *, env: Environment):
self._registry = registry self._registry = registry
self._env = env self._env = env
# ── instruments ──────────────────────────────────────────────────
async def _instruments_one(
self, exchange: str, filters: dict[str, Any],
) -> tuple[str, list[dict[str, Any]] | Exception, dict[str, Any] | None]:
try:
client = await self._registry.get(exchange, self._env)
if exchange == "deribit":
resp = await client.get_instruments(**filters)
meta = {k: resp[k] for k in ("total", "offset", "limit", "has_more")
if k in resp}
return exchange, normalize_deribit(resp.get("instruments", [])), meta
if exchange == "hyperliquid":
rows = await client.get_markets()
return exchange, normalize_hyperliquid(rows), None
raise ValueError(f"no instrument normalizer for {exchange}")
except Exception as e: # noqa: BLE001
return exchange, e, None
async def get_instruments(
self, *, exchange: str | None = None,
currency: str = "BTC", kind: str | None = "future",
expiry_from: str | None = None, expiry_to: str | None = None,
strike_min: float | None = None, strike_max: float | None = None,
min_open_interest: float | None = None,
offset: int = 0, limit: int = 100,
) -> dict[str, Any]:
if exchange is not None and exchange not in UNIFIED_EXCHANGES:
raise HTTPException(
status_code=400,
detail=f"unsupported exchange: {exchange}; "
f"supported: {list(UNIFIED_EXCHANGES)}",
)
targets = [exchange] if exchange else list(UNIFIED_EXCHANGES)
# Deribit-specific filters; venues that list everything ignore them.
deribit_filters = {
"currency": currency, "kind": kind,
"expiry_from": expiry_from, "expiry_to": expiry_to,
"strike_min": strike_min, "strike_max": strike_max,
"min_open_interest": min_open_interest,
"offset": offset, "limit": limit,
}
results = await asyncio.gather(
*(self._instruments_one(ex, deribit_filters) for ex in targets)
)
instruments: list[dict[str, Any]] = []
failed: list[dict[str, str]] = []
meta: dict[str, Any] = {}
for ex, payload, ex_meta in results:
if isinstance(payload, Exception):
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
else:
instruments.extend(payload)
if ex_meta:
meta[ex] = ex_meta
if not instruments and failed:
raise HTTPException(
status_code=502,
detail={"error": "all sources failed", "failed_sources": failed},
)
return {"instruments": instruments, "failed_sources": failed, "meta": meta}
# ── historical ───────────────────────────────────────────────────
async def _fetch_one( async def _fetch_one(
self, exchange: str, native_sym: str, native_interval: str, self, exchange: str, native_sym: str, native_interval: str,
start: str, end: str, start: str, end: str,
@@ -74,33 +147,49 @@ class CrossClient:
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return exchange, e return exchange, e
async def get_historical( async def _historical_single(
self, *, symbol: str, asset_class: str, interval: str, self, exchange: str, instrument: str, interval: str,
start_date: str, end_date: str, start_date: str, end_date: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
sources = get_sources(asset_class, symbol) if exchange not in _DISPATCH:
raise HTTPException(
status_code=400,
detail=f"unsupported exchange: {exchange}; "
f"supported: {list(_DISPATCH.keys())}",
)
native_interval = to_native_interval(interval, exchange)
client = await self._registry.get(exchange, self._env)
resp = await _DISPATCH[exchange](
client, instrument, native_interval, start_date, end_date,
)
return {
"exchange": exchange,
"instrument": instrument,
"interval": interval,
"candles": resp.get("candles", []),
"sources_used": [exchange],
}
async def _historical_consensus(
self, instrument: str, asset_class: str, interval: str,
start_date: str, end_date: str,
) -> dict[str, Any]:
sources = get_sources(asset_class, instrument)
if not sources: if not sources:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"unsupported symbol/asset_class: {symbol} ({asset_class})", detail=f"unsupported symbol/asset_class for consensus: "
f"{instrument} ({asset_class})",
) )
if interval not in supported_intervals(): results = await asyncio.gather(*(
raise HTTPException(
status_code=400,
detail=f"unsupported interval: {interval}; "
f"supported: {supported_intervals()}",
)
tasks = [
self._fetch_one( self._fetch_one(
ex, ex,
to_native_symbol(asset_class, symbol, ex), to_native_symbol(asset_class, instrument, ex),
to_native_interval(interval, ex), to_native_interval(interval, ex),
start_date, end_date, start_date, end_date,
) )
for ex in sources for ex in sources
] ))
results = await asyncio.gather(*tasks)
by_source: dict[str, list[dict[str, Any]]] = {} by_source: dict[str, list[dict[str, Any]]] = {}
failed: list[dict[str, str]] = [] failed: list[dict[str, str]] = []
@@ -117,7 +206,8 @@ class CrossClient:
) )
return { return {
"symbol": symbol.upper(), "exchange": None,
"instrument": instrument.upper(),
"asset_class": asset_class, "asset_class": asset_class,
"interval": interval, "interval": interval,
"candles": merge_candles(by_source), "candles": merge_candles(by_source),
@@ -125,91 +215,51 @@ class CrossClient:
"failed_sources": failed, "failed_sources": failed,
} }
class UnifiedClient:
"""Single common interface over the integrated venues.
`get_instruments` returns one uniform instrument list (each row carries
its own `exchange`, `fees` and `history_start`); `get_historical`
returns candles from one explicitly chosen exchange. Cross-exchange
consensus stays available separately via `CrossClient`.
"""
def __init__(self, registry: _Registry, *, env: Environment):
self._registry = registry
self._env = env
async def _instruments_one(
self, exchange: str, currency: str, kind: str | None,
) -> tuple[str, list[dict[str, Any]] | Exception]:
try:
client = await self._registry.get(exchange, self._env)
if exchange == "deribit":
resp = await client.get_instruments(currency=currency, kind=kind)
return exchange, normalize_deribit(resp.get("instruments", []))
if exchange == "hyperliquid":
rows = await client.get_markets()
return exchange, normalize_hyperliquid(rows)
raise ValueError(f"no instrument normalizer for {exchange}")
except Exception as e: # noqa: BLE001
return exchange, e
async def get_instruments(
self, *, exchange: str | None = None,
currency: str = "BTC", kind: str | None = "future",
) -> dict[str, Any]:
if exchange is not None and exchange not in UNIFIED_EXCHANGES:
raise HTTPException(
status_code=400,
detail=f"unsupported exchange: {exchange}; "
f"supported: {list(UNIFIED_EXCHANGES)}",
)
targets = [exchange] if exchange else list(UNIFIED_EXCHANGES)
results = await asyncio.gather(
*(self._instruments_one(ex, currency, kind) for ex in targets)
)
instruments: list[dict[str, Any]] = []
failed: list[dict[str, str]] = []
for ex, payload in results:
if isinstance(payload, Exception):
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
else:
instruments.extend(payload)
if not instruments and failed:
raise HTTPException(
status_code=502,
detail={"error": "all sources failed", "failed_sources": failed},
)
return {"instruments": instruments, "failed_sources": failed}
async def get_historical( async def get_historical(
self, *, exchange: str, instrument: str, interval: str, self, *, exchange: str | None = None, instrument: str, interval: str,
start_date: str, end_date: str, start_date: str, end_date: str, asset_class: str = "crypto",
) -> dict[str, Any]: ) -> dict[str, Any]:
if exchange not in _DISPATCH: """Single common historical call.
raise HTTPException(
status_code=400, `exchange` set → that venue's candles (instrument = native symbol).
detail=f"unsupported exchange: {exchange}; " `exchange` omitted → cross-exchange consensus (instrument = canonical
f"supported: {list(_DISPATCH.keys())}", symbol, e.g. BTC/ETH/SOL).
) """
if interval not in supported_intervals(): if interval not in supported_intervals():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"unsupported interval: {interval}; " detail=f"unsupported interval: {interval}; "
f"supported: {supported_intervals()}", f"supported: {supported_intervals()}",
) )
native_interval = to_native_interval(interval, exchange) if exchange is None:
client = await self._registry.get(exchange, self._env) return await self._historical_consensus(
resp = await _DISPATCH[exchange]( instrument, asset_class, interval, start_date, end_date,
client, instrument, native_interval, start_date, end_date,
) )
return { return await self._historical_single(
"exchange": exchange, 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, "instrument": instrument,
"interval": interval, "interval": interval,
"candles": resp.get("candles", []), "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
+72 -27
View File
@@ -1,48 +1,75 @@
"""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 # Deribit-only filters (ignored by venues that list everything, e.g. Hyperliquid)
kind: str | None = "future" # Deribit only: future | option | spot currency: str = "BTC"
kind: str | None = "future" # future | option | spot
expiry_from: str | None = None
expiry_to: str | None = None
strike_min: float | None = None
strike_max: float | None = None
min_open_interest: float | None = None
offset: int = 0
limit: int = 100
class 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:
@@ -50,16 +77,34 @@ async def get_instruments(client: UnifiedClient, params: GetInstrumentsReq) -> d
exchange=params.exchange, exchange=params.exchange,
currency=params.currency, currency=params.currency,
kind=params.kind, kind=params.kind,
expiry_from=params.expiry_from,
expiry_to=params.expiry_to,
strike_min=params.strike_min,
strike_max=params.strike_max,
min_open_interest=params.min_open_interest,
offset=params.offset,
limit=params.limit,
) )
async def 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,
) )
+31 -32
View File
@@ -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
@@ -413,6 +412,37 @@ class DeribitClient:
"testnet": self.testnet, "testnet": self.testnet,
} }
async def get_open_orders(
self, currency: str = "USDC", kind: str | None = None, type: str = "all"
) -> list:
"""Ordini APERTI sul conto: limit resting e trigger non scattati.
NB Deribit: con type='all' gli untriggered possono essere omessi ->
chi vuole anche i bracket interroga type='trigger_all' e merge per
order_id (e' cio' che fa il reconciler di PythagorasGoal)."""
params: dict = {"currency": currency, "type": type}
if kind:
params["kind"] = kind
raw = await self._request("private/get_open_orders_by_currency", params)
result = raw.get("result") or []
return [
{
"order_id": o.get("order_id"),
"instrument": o.get("instrument_name"),
"direction": o.get("direction"),
"order_type": o.get("order_type"),
"order_state": o.get("order_state"),
"amount": o.get("amount"),
"filled_amount": o.get("filled_amount"),
"price": o.get("price"),
"trigger_price": o.get("trigger_price"),
"reduce_only": o.get("reduce_only"),
"label": o.get("label"),
"creation_timestamp": o.get("creation_timestamp"),
}
for o in result
if isinstance(o, dict)
]
async def get_trade_history( async def get_trade_history(
self, limit: int = 100, instrument_name: str | None = None self, limit: int = 100, instrument_name: str | None = None
) -> list: ) -> list:
@@ -1602,37 +1632,6 @@ class DeribitClient:
"testnet": self.testnet, "testnet": self.testnet,
} }
async def get_technical_indicators(
self,
instrument: str,
indicators: list[str],
start_date: str,
end_date: str,
resolution: str = "1h",
) -> dict:
historical = await self.get_historical(instrument, start_date, end_date, resolution)
candles = historical.get("candles", [])
closes = [c["close"] for c in candles]
highs = [c["high"] for c in candles]
lows = [c["low"] for c in candles]
result: dict[str, Any] = {}
for indicator in indicators:
name = indicator.lower()
if name == "sma":
result["sma"] = ind.sma(closes, 20)
elif name == "rsi":
result["rsi"] = ind.rsi(closes)
elif name == "atr":
result["atr"] = ind.atr(highs, lows, closes)
elif name == "macd":
result["macd"] = ind.macd(closes)
elif name == "adx":
result["adx"] = ind.adx(highs, lows, closes)
else:
result[name] = None
return result
# ── Write tools ────────────────────────────────────────────── # ── Write tools ──────────────────────────────────────────────
async def place_order( async def place_order(
+11 -80
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import contextlib import contextlib
from typing import Any from typing import Any
from pydantic import BaseModel, field_validator, model_validator from pydantic import BaseModel, model_validator
from cerbero_mcp.exchanges.deribit.client import DeribitClient from cerbero_mcp.exchanges.deribit.client import DeribitClient
from cerbero_mcp.exchanges.deribit.leverage_cap import ( from cerbero_mcp.exchanges.deribit.leverage_cap import (
@@ -51,16 +51,6 @@ class GetTickerBatchReq(BaseModel):
return self return self
class GetInstrumentsReq(BaseModel):
currency: str
kind: str | None = None
expiry_from: str | None = None
expiry_to: str | None = None
strike_min: float | None = None
strike_max: float | None = None
min_open_interest: float | None = None
limit: int = 100
offset: int = 0
class GetOrderbookReq(BaseModel): class GetOrderbookReq(BaseModel):
@@ -81,18 +71,17 @@ class GetAccountSummaryReq(BaseModel):
currency: str = "USDC" currency: str = "USDC"
class GetOpenOrdersReq(BaseModel):
currency: str = "USDC"
kind: str | None = None
type: str = "all"
class GetTradeHistoryReq(BaseModel): class GetTradeHistoryReq(BaseModel):
limit: int = 100 limit: int = 100
instrument_name: str | None = None instrument_name: str | None = None
class GetHistoricalReq(BaseModel):
instrument: str
start_date: str
end_date: str
resolution: str = "1h"
class GetDvolReq(BaseModel): class GetDvolReq(BaseModel):
currency: str = "BTC" currency: str = "BTC"
start_date: str start_date: str
@@ -168,36 +157,6 @@ class FindByDeltaReq(BaseModel):
min_volume_24h: float = 20.0 min_volume_24h: float = 20.0
class GetIndicatorsReq(BaseModel):
instrument: str
indicators: list[str]
start_date: str
end_date: str
resolution: str = "1h"
@field_validator("indicators", mode="before")
@classmethod
def _coerce_indicators(cls, v):
if isinstance(v, str):
import json
s = v.strip()
if s.startswith("["):
try:
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()]
except json.JSONDecodeError:
pass
return [x.strip() for x in s.split(",") if x.strip()]
if isinstance(v, list):
return v
raise ValueError(
"indicators must be a list like ['rsi','atr','macd'] "
"or a comma-separated string like 'rsi,atr,macd'"
)
class PlaceOrderReq(BaseModel): class PlaceOrderReq(BaseModel):
instrument_name: str instrument_name: str
side: str # "buy" | "sell" side: str # "buy" | "sell"
@@ -305,20 +264,6 @@ async def get_ticker_batch(client: DeribitClient, params: GetTickerBatchReq) ->
return await client.get_ticker_batch(params.instrument_names) return await client.get_ticker_batch(params.instrument_names)
async def get_instruments(client: DeribitClient, params: GetInstrumentsReq) -> dict:
return await client.get_instruments(
currency=params.currency,
kind=params.kind,
expiry_from=params.expiry_from,
expiry_to=params.expiry_to,
strike_min=params.strike_min,
strike_max=params.strike_max,
min_open_interest=params.min_open_interest,
limit=params.limit,
offset=params.offset,
)
async def get_orderbook(client: DeribitClient, params: GetOrderbookReq) -> dict: async def get_orderbook(client: DeribitClient, params: GetOrderbookReq) -> dict:
return await client.get_orderbook(params.instrument_name, params.depth) return await client.get_orderbook(params.instrument_name, params.depth)
@@ -339,16 +284,14 @@ async def get_account_summary(
return await client.get_account_summary(params.currency) return await client.get_account_summary(params.currency)
async def get_open_orders(client: DeribitClient, params: GetOpenOrdersReq) -> list:
return await client.get_open_orders(params.currency, params.kind, params.type)
async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) -> list: async def get_trade_history(client: DeribitClient, params: GetTradeHistoryReq) -> list:
return await client.get_trade_history(params.limit, params.instrument_name) return await client.get_trade_history(params.limit, params.instrument_name)
async def get_historical(client: DeribitClient, params: GetHistoricalReq) -> dict:
return await client.get_historical(
params.instrument, params.start_date, params.end_date, params.resolution
)
async def get_dvol(client: DeribitClient, params: GetDvolReq) -> dict: async def get_dvol(client: DeribitClient, params: GetDvolReq) -> dict:
return await client.get_dvol( return await client.get_dvol(
params.currency, params.start_date, params.end_date, params.resolution params.currency, params.start_date, params.end_date, params.resolution
@@ -448,18 +391,6 @@ async def get_realized_vol(client: DeribitClient, params: GetRealizedVolReq) ->
return await client.get_realized_vol(params.currency, params.windows) return await client.get_realized_vol(params.currency, params.windows)
async def get_technical_indicators(
client: DeribitClient, params: GetIndicatorsReq
) -> dict:
return await client.get_technical_indicators(
params.instrument,
params.indicators,
params.start_date,
params.end_date,
params.resolution,
)
# === Tools (writes) === # === Tools (writes) ===
+58 -38
View File
@@ -26,7 +26,6 @@ from eth_account import Account
from eth_account.messages import encode_typed_data from eth_account.messages import encode_typed_data
from eth_utils import keccak, to_hex from eth_utils import keccak, to_hex
from cerbero_mcp.common import indicators as ind
from cerbero_mcp.common.candles import validate_candles from cerbero_mcp.common.candles import validate_candles
from cerbero_mcp.common.http import async_client from cerbero_mcp.common.http import async_client
@@ -42,6 +41,24 @@ RESOLUTION_MAP = {
"1d": "1d", "1d": "1d",
} }
# Hyperliquid's candleSnapshot returns at most ~5000 candles per call and, when
# the range needs more, keeps the newest slice and silently drops the oldest.
# We page through wide ranges in windows sized to this cap so start_date is honored.
_MAX_CANDLES_PER_REQUEST = 5000
# Hard stop on pagination so a tiny resolution over a huge span can't loop forever.
_MAX_HISTORY_PAGES = 500
# Candle interval in milliseconds, keyed by the Hyperliquid interval code.
_RESOLUTION_MS = {
"1m": 60_000,
"5m": 300_000,
"15m": 900_000,
"1h": 3_600_000,
"4h": 14_400_000,
"1d": 86_400_000,
}
# Slippage usato per market order / market_close (parità con SDK). # Slippage usato per market order / market_close (parità con SDK).
DEFAULT_SLIPPAGE = 0.05 DEFAULT_SLIPPAGE = 0.05
@@ -394,10 +411,46 @@ class HyperliquidClient:
async def get_historical( async def get_historical(
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h" self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Get OHLCV candles for an asset.""" """Get OHLCV candles for an asset.
``candleSnapshot`` caps each response at ``_MAX_CANDLES_PER_REQUEST``
candles and keeps the newest slice, so a wide range is paged in windows
sized to that cap and each page is trimmed back to ``[start_ms, end_ms]``
to keep start_date honored.
"""
start_ms = _to_ms(start_date) start_ms = _to_ms(start_date)
end_ms = _to_ms(end_date) end_ms = _to_ms(end_date)
interval = RESOLUTION_MAP.get(resolution, resolution) interval = RESOLUTION_MAP.get(resolution, resolution)
interval_ms = _RESOLUTION_MS.get(interval)
by_ts: dict[int, dict[str, Any]] = {}
if interval_ms and end_ms >= start_ms:
window_span = interval_ms * _MAX_CANDLES_PER_REQUEST
cursor = start_ms
pages = 0
while cursor <= end_ms and pages < _MAX_HISTORY_PAGES:
window_end = min(cursor + window_span - interval_ms, end_ms)
for c in await self._fetch_candle_window(
instrument, interval, cursor, window_end
):
if start_ms <= c["timestamp"] <= end_ms:
by_ts[c["timestamp"]] = c
pages += 1
if window_end >= end_ms:
break
cursor = window_end + interval_ms
else:
for c in await self._fetch_candle_window(
instrument, interval, start_ms, end_ms
):
by_ts[c["timestamp"]] = c
# validate_candles sorts by timestamp, so insertion order is irrelevant.
return {"candles": validate_candles(list(by_ts.values()))}
async def _fetch_candle_window(
self, instrument: str, interval: str, start_ms: int, end_ms: int
) -> list[dict[str, Any]]:
data = await self._post_info( data = await self._post_info(
{ {
"type": "candleSnapshot", "type": "candleSnapshot",
@@ -409,7 +462,7 @@ class HyperliquidClient:
}, },
} }
) )
candles = validate_candles([ return [
{ {
"timestamp": c.get("t"), "timestamp": c.get("t"),
"open": c.get("o"), "open": c.get("o"),
@@ -418,9 +471,8 @@ class HyperliquidClient:
"close": c.get("c"), "close": c.get("c"),
"volume": c.get("v"), "volume": c.get("v"),
} }
for c in data for c in (data or [])
]) ]
return {"candles": candles}
async def get_open_orders(self) -> list[dict[str, Any]]: async def get_open_orders(self) -> list[dict[str, Any]]:
"""Get all open orders for the wallet.""" """Get all open orders for the wallet."""
@@ -555,38 +607,6 @@ class HyperliquidClient:
"history": history, "history": history,
} }
async def get_indicators(
self,
instrument: str,
indicators: list[str],
start_date: str,
end_date: str,
resolution: str = "1h",
) -> dict[str, Any]:
"""Compute technical indicators over OHLCV data."""
historical = await self.get_historical(instrument, start_date, end_date, resolution)
candles = historical.get("candles", [])
closes = [c["close"] for c in candles]
highs = [c["high"] for c in candles]
lows = [c["low"] for c in candles]
result: dict[str, Any] = {}
for indicator in indicators:
name = indicator.lower()
if name == "sma":
result["sma"] = ind.sma(closes, 20)
elif name == "rsi":
result["rsi"] = ind.rsi(closes)
elif name == "atr":
result["atr"] = ind.atr(highs, lows, closes)
elif name == "macd":
result["macd"] = ind.macd(closes)
elif name == "adx":
result["adx"] = ind.adx(highs, lows, closes)
else:
result[name] = None
return result
# ── Write tools (signed) ─────────────────────────────────── # ── Write tools (signed) ───────────────────────────────────
@staticmethod @staticmethod
+1 -109
View File
@@ -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) ===
-36
View File
@@ -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
+7 -21
View File
@@ -73,13 +73,6 @@ def make_router() -> APIRouter:
): ):
return await t.get_ticker_batch(client, params) return await t.get_ticker_batch(client, params)
@r.post("/tools/get_instruments")
async def _get_instruments(
params: t.GetInstrumentsReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_instruments(client, params)
@r.post("/tools/get_orderbook") @r.post("/tools/get_orderbook")
async def _get_orderbook( async def _get_orderbook(
params: t.GetOrderbookReq, params: t.GetOrderbookReq,
@@ -108,6 +101,13 @@ def make_router() -> APIRouter:
): ):
return await t.get_account_summary(client, params) return await t.get_account_summary(client, params)
@r.post("/tools/get_open_orders")
async def _get_open_orders(
params: t.GetOpenOrdersReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_open_orders(client, params)
@r.post("/tools/get_trade_history") @r.post("/tools/get_trade_history")
async def _get_trade_history( async def _get_trade_history(
params: t.GetTradeHistoryReq, params: t.GetTradeHistoryReq,
@@ -115,13 +115,6 @@ def make_router() -> APIRouter:
): ):
return await t.get_trade_history(client, params) return await t.get_trade_history(client, params)
@r.post("/tools/get_historical")
async def _get_historical(
params: t.GetHistoricalReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_historical(client, params)
@r.post("/tools/get_dvol") @r.post("/tools/get_dvol")
async def _get_dvol( async def _get_dvol(
params: t.GetDvolReq, params: t.GetDvolReq,
@@ -234,13 +227,6 @@ def make_router() -> APIRouter:
): ):
return await t.get_realized_vol(client, params) return await t.get_realized_vol(client, params)
@r.post("/tools/get_technical_indicators")
async def _get_technical_indicators(
params: t.GetIndicatorsReq,
client: DeribitClient = Depends(get_deribit_client),
):
return await t.get_technical_indicators(client, params)
# === WRITE tools (richiedono creds per leverage cap / audit) === # === WRITE tools (richiedono creds per leverage cap / audit) ===
@r.post("/tools/place_order") @r.post("/tools/place_order")
-14
View File
@@ -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")
+15 -7
View File
@@ -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
+12 -2
View File
@@ -26,8 +26,18 @@ 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_instruments / get_historical / get_indicators are common-only
assert "/mcp-deribit/tools/get_instruments" not in paths
assert "/mcp-deribit/tools/get_historical" not in paths
assert "/mcp-deribit/tools/get_technical_indicators" not in paths
assert "/mcp-hyperliquid/tools/get_historical" not in paths
assert "/mcp-hyperliquid/tools/get_indicators" not in paths
# The standalone consensus router has been folded into /mcp
assert not any(p.startswith("/mcp-cross/") for p in paths)
# Bybit and Alpaca have been retired from the API surface # 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)
+3 -1
View File
@@ -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:
+28 -9
View File
@@ -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}"
@@ -38,21 +38,40 @@ post() {
echo " OK ${path} → HTTP ${status}" echo " OK ${path} → HTTP ${status}"
} }
echo "==> get_instruments exchange=deribit (fees + history_start live)" echo "==> get_instruments exchange=deribit (fees + history_start live + meta)"
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; assert 'has_more' in d['meta']['deribit']; print(' ', i['symbol'], i['fees'], i['history_start'], '| meta:', d['meta']['deribit'])"
echo "==> get_instruments deribit option chain paginato (kind=option, limit=5)"
post "/mcp/tools/get_instruments" '{"exchange":"deribit","currency":"BTC","kind":"option","limit":5,"offset":0}' 200 \
"assert len(d['instruments'])<=5; assert d['meta']['deribit']['limit']==5; print(' got:', len(d['instruments']), '| total:', d['meta']['deribit'].get('total'), '| has_more:', d['meta']['deribit'].get('has_more'))"
echo "==> get_instruments fan-out (deribit + hyperliquid)" 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_instruments NON più su /mcp-deribit → 404"
post "/mcp-deribit/tools/get_instruments" '{"currency":"BTC"}' 404
echo "==> get_historical SINGLE deribit BTC-PERPETUAL 1h"
post "/mcp/tools/get_historical" "{\"exchange\":\"deribit\",\"instrument\":\"BTC-PERPETUAL\",\"interval\":\"1h\",\"start_date\":\"${start}\",\"end_date\":\"${end}\"}" 200 \ 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
-111
View File
@@ -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
+172 -63
View File
@@ -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,60 +35,73 @@ 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 self.inst_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,
"creation_timestamp": 1534377600000, "creation_timestamp": 1534377600000,
}]} }],
"total": 1,
"offset": kwargs.get("offset", 0),
"limit": kwargs.get("limit", 100),
"has_more": False,
}
async def get_historical(self, **kwargs: Any) -> dict[str, Any]: 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 +114,66 @@ 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_deribit_filters_and_meta():
uc = UnifiedClient( fake = _FakeDeribit()
_FakeRegistry({"deribit": _FakeDeribit()}), # hyperliquid missing → KeyError uc = UnifiedClient(_FakeRegistry({"deribit": fake}), env="mainnet")
env="mainnet", out = await uc.get_instruments(
exchange="deribit", currency="ETH", kind="option",
strike_min=1000.0, expiry_to="2026-12-31", min_open_interest=5.0,
offset=5, limit=10,
) )
# advanced Deribit filters reach the client
assert fake.inst_call["currency"] == "ETH"
assert fake.inst_call["kind"] == "option"
assert fake.inst_call["strike_min"] == 1000.0
assert fake.inst_call["expiry_to"] == "2026-12-31"
assert fake.inst_call["min_open_interest"] == 5.0
assert fake.inst_call["offset"] == 5 and fake.inst_call["limit"] == 10
# pagination metadata surfaced per source
assert out["meta"]["deribit"]["limit"] == 10
assert out["meta"]["deribit"]["has_more"] is False
@pytest.mark.asyncio
async def test_get_instruments_partial_failure_reports_failed():
uc = UnifiedClient(_FakeRegistry({"deribit": _FakeDeribit()}), env="mainnet")
out = await uc.get_instruments() 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 +184,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 +201,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"]
@@ -1,11 +1,18 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
import pytest import pytest
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
from cerbero_mcp.exchanges.hyperliquid.client import (
_MAX_CANDLES_PER_REQUEST,
_RESOLUTION_MS,
HyperliquidClient,
_to_ms,
)
# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write. # Chiave privata fissa: rende deterministica la firma EIP-712 per i test write.
DUMMY_PRIVATE_KEY = "0x" + "01" * 32 DUMMY_PRIVATE_KEY = "0x" + "01" * 32
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
@@ -201,10 +208,11 @@ async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient): async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
ts = _to_ms("2024-01-01") + _RESOLUTION_MS["1h"] # one candle inside the range
httpx_mock.add_response( httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"), url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[ json=[
{"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"}, {"t": ts, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"},
], ],
) )
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h") result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
@@ -212,6 +220,57 @@ async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
assert result["candles"][0]["close"] == 50000.0 assert result["candles"][0]["close"] == 50000.0
@pytest.mark.asyncio
async def test_get_historical_trims_out_of_range(
httpx_mock: HTTPXMock, client: HyperliquidClient
):
# candleSnapshot can return candles outside the requested window; trim them.
in_range = _to_ms("2024-01-01") + _RESOLUTION_MS["1h"]
before = _to_ms("2024-01-01") - _RESOLUTION_MS["1h"]
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": before, "o": "1", "h": "1", "l": "1", "c": "1", "v": "1"},
{"t": in_range, "o": "49000", "h": "51000", "l": "48500", "c": "50000",
"v": "100"},
],
)
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
assert [c["timestamp"] for c in result["candles"]] == [in_range]
@pytest.mark.asyncio
async def test_get_historical_pages_wide_range(
httpx_mock: HTTPXMock, client: HyperliquidClient
):
# A span wider than the per-call cap must be paged across multiple requests.
interval_ms = _RESOLUTION_MS["1h"]
window_span = interval_ms * _MAX_CANDLES_PER_REQUEST
start_ms = _to_ms("2024-01-01")
page1_ts = start_ms + interval_ms
page2_ts = start_ms + window_span # start of the second window
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": page1_ts, "o": "100", "h": "110", "l": "90", "c": "105", "v": "1"},
],
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": page2_ts, "o": "200", "h": "210", "l": "190", "c": "205", "v": "1"},
],
)
# 2024 is a leap year (366 d = 8784 h > 5000), so the range spans 2 windows.
result = await client.get_historical("BTC", "2024-01-01", "2025-01-01", "1h")
requests = httpx_mock.get_requests()
assert len(requests) == 2
starts = [json.loads(r.read())["req"]["startTime"] for r in requests]
assert starts[1] > starts[0] # paging advanced the cursor
assert [c["timestamp"] for c in result["candles"]] == [page1_ts, page2_ts]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient): async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response( httpx_mock.add_response(