Compare commits

..

46 Commits

Author SHA1 Message Date
root 91aadaea6a docs(V2): document /mcp-cross historical aggregator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:42:20 +00:00
root 0ba5a05219 feat(V2): /mcp-cross/tools/get_historical with cross-exchange consensus
Add a unified historical endpoint that fans out to every exchange
supporting the requested (asset_class, symbol) pair, then merges the
results into a single consensus candle series with per-bar divergence
metrics:
  - candles[i].close = median across sources
  - candles[i].sources = count of contributing exchanges
  - candles[i].div_pct = (max-min)/median for that bar's close

Crypto routes BTC/ETH/SOL across Bybit + Hyperliquid + Deribit; equities
route to Alpaca for now (IBKR omitted from MVP because its bars endpoint
takes a relative period instead of start/end). Partial failures return a
warning envelope (failed_sources) instead of failing the whole request;
all sources failing → HTTP 502.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:41:18 +00:00
root c94312d79f feat(V2): shared Candle validator + uniform 'candles' response key
Introduce common/candles.py with a Pydantic Candle model enforcing OHLC
consistency (high≥max, low≤min), non-negative volume and positive
timestamp. validate_candles() coerces upstream rows, sorts by timestamp
and raises HTTPException(502) on malformed data — surfacing upstream
data corruption as a retryable envelope instead of silently returning
nonsense.

Wired into all five exchange historical endpoints (Bybit, Hyperliquid,
Deribit, Alpaca, IBKR). BREAKING: Alpaca get_bars and IBKR get_bars now
return 'candles' (was 'bars') to align with the other exchanges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:19:20 +00:00
root 110ca7f5cf docs(V2): update README for IBKR integration
Add IBKR to the exchange list, endpoint table, audit filter values, and
Tool disponibili. Bump test count to 366 and reorder IBKR Setup before
Licenza.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:54:54 +00:00
root a56baad3dd fix(V2): hoist _IBKRRotateConfirmReq to module level
Defining the Pydantic body model inside make_admin_router() leaves an
unresolved forward reference under `from __future__ import annotations`,
which breaks /openapi.json generation with PydanticUserError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:45:20 +00:00
root f8fb50cb83 fix(V2): map Deribit upstream 5xx / non-JSON to clean HTTPException 502
Cloudflare 5xx pages from Deribit testnet were leaking through the JSON
parser as JSONDecodeError → UNHANDLED_EXCEPTION. Wrap response parsing so
upstream errors surface as a retryable HTTP_502 envelope instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:27:33 +00:00
root 880faa7fd4 refactor(V2): IBKR final review fixes — WS shutdown, conid match, clock note
Final code-review fixes:
- __main__: lifespan stops IBKRWebSocket singletons before registry close
- close_position: resolve symbol→conid first, match positions on conid
  (was matching contractDesc which is a long display string, not ticker)
- close_all_positions: prefer ticker field, fallback to contractDesc
- get_clock: explicit approximate=true + note about US holidays/half-days

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:46:11 +00:00
root cddf88afb4 feat(V2): IBKR OAuth setup script + docker secrets mount + docs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:40:06 +00:00
root 55bfeca88e feat(V2): IBKR key rotation admin endpoints + health probe
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:37:29 +00:00
root bea37fd734 feat(V2): IBKR router wiring + build_client + WS singleton DI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:35:28 +00:00
root 6940e2865b feat(V2): IBKR key rotation manager with auto-rollback
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:32:25 +00:00
root bdc40929d4 feat(V2): IBKR complex order tools (bracket/OCO/OTO)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:30:05 +00:00
root 9bbc8c05f1 feat(V2): IBKR complex order payload builders (bracket/OCO/OTO)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:27:26 +00:00
root 3510605fdd feat(V2): IBKR simple write tools (place/amend/cancel/close)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:25:34 +00:00
root 8914d613ec feat(V2): IBKR streaming tools (tick/depth/subscribe)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:23:39 +00:00
root 531b7b019c feat(V2): IBKR read tool schemas + dispatch functions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:21:24 +00:00
root 6266708e15 refactor(V2): IBKR WebSocket — fix stop/start cycle, guard rails, log disconnect
Code review fixes (commit 17700d2):
- _stopped reset on start() (was stuck True after stop→start)
- _require_started guard on subscribe_*/unsubscribe (clear WSError vs AttributeError)
- _reader_loop logs disconnect via logger.warning + sets _ws=None for `connected` signal
- Class docstring documents stale-snapshot behavior + deferred reconnect
- New tests: subscribe-before-start, stop→start cycle resumption

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:18:57 +00:00
root 17700d27a0 feat(V2): IBKR WebSocket layer + tick/depth snapshot cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:15:25 +00:00
root 12002642e5 refactor(V2): IBKR client — remove dead whitelist + max_cycles test
Code review polish (commit b9c58a3):
- Remove unused _AUTO_CONFIRM_WHITELIST (was scaffolding, never wired)
- Replace with policy comment documenting auto-confirm behavior
- New test: test_place_order_too_many_confirmations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:37:37 +00:00
root b9c58a376f feat(V2): IBKR write methods + auto-confirm warning flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:34:43 +00:00
root ded4414b32 refactor(V2): IBKR client read methods — defensive conid + sec_type DRY
Code review fixes (commit 611a269):
- resolve_conid validates conid key presence (was raw KeyError on malformed)
- _SEC_TYPE_MAP module constant — reused in get_ticker + get_bars
  (also fixes get_bars previously missing "forex": "CASH")
- New tests: empty response + malformed response error paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:32:50 +00:00
root 611a2695a9 feat(V2): IBKR client read methods + conid LRU cache
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:29:11 +00:00
root f4f4e4efd7 refactor(V2): IBKR client — log tickle, type _http, retry once on 401
Code review fixes (commit 0c74691):
- _maybe_tickle logs failures via logger.debug instead of silent pass
- _http typed as httpx.AsyncClient | None
- 30s timeout commented (matches Alpaca, IBKR gateway latency)
- _request retries once on 401 with forced LST refresh (spec §4 IBKR_AUTH_FAILED)
- New tests: test_request_retries_once_on_401, test_request_raises_on_persistent_401

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:27:37 +00:00
root 0c74691e7c feat(V2): IBKR client base + auth header + tickle keep-alive
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:22:51 +00:00
root b49b2b36e0 refactor(V2): IBKR OAuth — named constants, explicit raises, lifted import
Code review fixes (commit 92da6aa):
- LST refresh buffer / fallback TTL extracted as named module constants
- Replace `assert` with explicit `if/raise` (asserts stripped under -O)
- Move IBKRAuthError above OAuth1aSigner (forward declaration)
- async_client import lifted to module level
- Test uses actual prime (23) instead of composite (255)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:20:40 +00:00
root 92da6aa842 feat(V2): IBKR live session token mint via DH key exchange
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:15:17 +00:00
root a90c5c4d6f refactor(V2): IBKR OAuth signer — type tightening + verify-based test
Code review polish:
- _signature_key/_encryption_key typed as RSAPrivateKey | None
- sign() uses assert instead of type: ignore
- test_oauth_signer_signs_with_rsa verifies signature against public key
- Clarifying comments on %3D/%26 manual encoding and Task 3 imports

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:12:48 +00:00
root ae63aaf69a feat(V2): IBKR OAuth1a signer + RSA-SHA256 signature
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:08:15 +00:00
root 92cc45c896 refactor(V2): IBKR settings — TypedDict return + docstrings
Code review polish:
- credentials() returns IBKRCredentials TypedDict (was bare dict)
- Method docstring matching Deribit pattern
- Inline comment explaining account_id env-only design

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:04:08 +00:00
root 3a85ff05e6 feat(V2): IBKR settings + env-specific credentials
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:00:15 +00:00
root 391f2c02e0 docs(V2): IBKR integration implementation plan
16 task TDD-disciplinati con 94 step checkbox, riferimento allo spec
2026-05-03. Ogni task: red-green-commit con codice completo nello step.
Copre: settings, OAuth1a signer + DH LST mint, IBKRClient REST + conid
cache + tickle, IBKRWebSocket tick/depth snapshot-on-demand, simple +
complex orders (bracket/OCO/OTO), KeyRotationManager con auto-rollback,
admin endpoints, router wiring, OAuth setup script, docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:55:38 +00:00
root 109b8e4686 docs(V2): IBKR integration design spec
Approach A2 (Client Portal Web API + OAuth 1.0a Self-Service): fully
unattended REST con setup iniziale one-time, niente sidecar Java.
Scope V1 include: simple+complex orders (bracket/OCO/OTO), WebSocket
streaming snapshot-on-demand (tick+depth), key rotation semi-automatica
con auto-rollback. 8-commit plan atomico, ~6-8 giorni dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:23:08 +00:00
root 1ca1687c9b feat(V2): Deribit credenziali per env (CLIENT_ID/SECRET _TESTNET / _LIVE)
DeribitSettings ora supporta coppie credenziali distinte per testnet e
mainnet via DERIBIT_CLIENT_ID_TESTNET/_LIVE e DERIBIT_CLIENT_SECRET_TESTNET/_LIVE.
Le coppie env-specifiche prevalgono sulla coppia base
DERIBIT_CLIENT_ID/DERIBIT_CLIENT_SECRET (mantenuta per backward compat).

build_client risolve la coppia giusta tramite settings.deribit.credentials(env);
ValueError esplicito se nessuna coppia configurata per l'env richiesto.

+4 test (legacy single, per-env, override, missing). Fix anche isolation
da .env reale via monkeypatch.chdir(tmp_path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:46:47 +00:00
root 8a0f37ebc2 fix(V2): get_account_summary error path → numeric fields None invece di 0
Su errore di Deribit (auth fallita, ecc.) i campi equity/balance/margin/
available/unrealized_pnl/total_pnl ora sono None: signal chiaro di "valore
ignoto" vs "saldo realmente a zero". Risolve ambiguità lato client che
leggevano equity=0 senza accorgersi del campo error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:00:57 +00:00
root 6640ede3df fix(V2): Deribit _authenticate gestisce error envelope (no più KeyError 'result')
Quando Deribit risponde con {"error": {...}} su public/auth (creds errate,
scope mancante, env mismatch), il client esplodeva con KeyError: 'result' →
500 UNHANDLED_EXCEPTION sui tool privati (get_account_summary, get_positions).
Ora _authenticate solleva DeribitAuthError tipizzata, _request la converte
in error envelope coerente con il resto del flusso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:52:43 +00:00
root d8136713b9 feat(V2): integrazione Traefik con TLS + watchtower, rimosso port mapping diretto
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:21:52 +00:00
AdrianoDev 9e7b98579b chore(V2): branch V2.0.0 come default deploy (no merge in main)
deploy-vps.sh: BRANCH default V2.0.0 invece di main.
README: clone con -b V2.0.0, nota che il branch in produzione è V2.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:31:58 +02:00
AdrianoDev 51081f4e18 feat(V2): deploy-vps.sh per deploy via clone (no registry)
Il deploy ora avviene clonando il repo direttamente sul VPS, costruendo
l'immagine in loco e riavviando il container. Sostituisce il workflow
build & push verso registry + Watchtower.

Lo script automatizza:
- git fetch + reset --hard origin/<branch>
- docker compose build
- restart graceful (down 15s + up -d)
- attesa healthcheck con timeout configurabile
- rollback automatico al SHA precedente se /health fallisce

Variabili: BRANCH, PORT, HEALTH_TIMEOUT_SECONDS, FORCE, SKIP_ROLLBACK.

Rimosso scripts/build-push.sh (workflow registry abbandonato).
README aggiornato con la nuova procedura.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:05:26 +02:00
AdrianoDev 8ecc1a24a9 feat(V2): /health/ready con ping client + middleware request log strutturato + request_id correlation
- /health/ready: ping di tutti i client (exchange, env) cached con
  timeout 2s, status ready|degraded|not_ready, opt-in 503 via
  READY_FAILS_ON_DEGRADED.
- Middleware mcp.request: 1 riga JSON per HTTP request con request_id,
  method, path, status_code, duration_ms, actor, bot_tag, exchange,
  tool, client_ip, user_agent.
- request_id propagato in request.state, audit log e error envelope per
  correlazione cross-cutting.
- Aggiunto async health() come probe minimo a bybit/alpaca/macro/
  sentiment/deribit (hyperliquid lo aveva già).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:03:28 +02:00
AdrianoDev 9afd087152 docs(V2): aggiorna conteggio test 259 → 310 nel README
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:52:11 +02:00
AdrianoDev 69ac878893 feat(V2): X-Bot-Tag header obbligatorio + endpoint /admin/audit con filtri
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:51:40 +02:00
AdrianoDev bd6b03ce43 feat(V2): cabla audit logging nei write endpoint dei 4 router exchange
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 08:44:28 +02:00
AdrianoDev 43bf8fc461 chore(V2): rimuovi SDK obsoleti (pybit, alpaca-py, hyperliquid-python-sdk)
Sweep finale del task #14: tutti e 4 i client exchange ora usano httpx
puro (deribit già lo era; bybit/alpaca/hyperliquid riscritti nei commit
precedenti). Rimosso anche l'override mypy per i moduli SDK che non
servono più.

Quality gate finale:
- 292 test passano (tutti)
- mypy: 0 issues
- ruff: clean
- Nessun import SDK in src/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:39:53 +02:00
AdrianoDev c0b4cb5d5c refactor(V2): hyperliquid client da SDK a httpx + eth-account EIP-712 (parità V1)
Riscritto interamente HyperliquidClient su httpx puro + eth-account per la
firma EIP-712 L1 (chainId 1337, phantom agent source 'a'/'b' per
mainnet/testnet). Bit-parity verificata contro hyperliquid.utils.signing
in test_signing_parity_with_canonical_sdk.

16 metodi pubblici, 26 test passanti. Aggiunte deps: eth-account, msgpack,
eth-utils. hyperliquid-python-sdk ancora presente nel pyproject; rimossa
nel sweep finale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:39:23 +02:00
AdrianoDev 44c7a18d3e refactor(V2): alpaca client da alpaca-py a httpx puro (parità V1)
Riscrive `AlpacaClient` su `httpx.AsyncClient` rimuovendo ogni dipendenza
runtime da `alpaca-py`. 4 endpoint base distinti (trading paper/live,
stock data, crypto data, options data) gestiti via helper `_request`
con header `APCA-API-KEY-ID` / `APCA-API-SECRET-KEY`. Firma costruttore
e attributi pubblici (`paper`, `base_url`) invariati; `base_url` override
applica al solo trading endpoint. Nuovo `aclose()` per cleanup connessioni.

Test riscritti su `pytest-httpx` (29 test alpaca + leverage cap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:38:23 +02:00
AdrianoDev 6097dde4e4 refactor(V2): bybit client da pybit a httpx puro (parità V1) 2026-05-01 01:35:26 +02:00
74 changed files with 12431 additions and 1368 deletions
+39
View File
@@ -17,8 +17,14 @@ TESTNET_TOKEN=
MAINNET_TOKEN= MAINNET_TOKEN=
# ─── EXCHANGE — DERIBIT ─────────────────────────────────── # ─── EXCHANGE — DERIBIT ───────────────────────────────────
# Coppia singola (usata sia per testnet sia per mainnet):
DERIBIT_CLIENT_ID= DERIBIT_CLIENT_ID=
DERIBIT_CLIENT_SECRET= DERIBIT_CLIENT_SECRET=
# Oppure coppie distinte per env (prevalgono se valorizzate):
# DERIBIT_CLIENT_ID_TESTNET=
# DERIBIT_CLIENT_SECRET_TESTNET=
# DERIBIT_CLIENT_ID_LIVE=
# DERIBIT_CLIENT_SECRET_LIVE=
DERIBIT_URL_LIVE=https://www.deribit.com/api/v2 DERIBIT_URL_LIVE=https://www.deribit.com/api/v2
DERIBIT_URL_TESTNET=https://test.deribit.com/api/v2 DERIBIT_URL_TESTNET=https://test.deribit.com/api/v2
DERIBIT_MAX_LEVERAGE=3 DERIBIT_MAX_LEVERAGE=3
@@ -45,6 +51,39 @@ ALPACA_URL_LIVE=https://api.alpaca.markets
ALPACA_URL_TESTNET=https://paper-api.alpaca.markets ALPACA_URL_TESTNET=https://paper-api.alpaca.markets
ALPACA_MAX_LEVERAGE=1 ALPACA_MAX_LEVERAGE=1
# ─── EXCHANGE — IBKR ──────────────────────────────────────
# Setup OAuth: vedi README "IBKR Setup" + scripts/ibkr_oauth_setup.py.
# Le RSA keys (PEM) NON vanno nel .env: monta come file e referenzia il path.
IBKR_CONSUMER_KEY=
IBKR_ACCESS_TOKEN=
IBKR_ACCESS_TOKEN_SECRET=
IBKR_SIGNATURE_KEY_PATH=/secrets/ibkr_signature.pem
IBKR_ENCRYPTION_KEY_PATH=/secrets/ibkr_encryption.pem
IBKR_DH_PRIME=
# Coppie env-specific (prevalgono):
# IBKR_CONSUMER_KEY_TESTNET=
# IBKR_ACCESS_TOKEN_TESTNET=
# IBKR_ACCESS_TOKEN_SECRET_TESTNET=
# IBKR_SIGNATURE_KEY_PATH_TESTNET=/secrets/ibkr_signature_paper.pem
# IBKR_ENCRYPTION_KEY_PATH_TESTNET=/secrets/ibkr_encryption_paper.pem
# IBKR_ACCOUNT_ID_TESTNET=DU1234567
# IBKR_CONSUMER_KEY_LIVE=
# IBKR_ACCESS_TOKEN_LIVE=
# IBKR_ACCESS_TOKEN_SECRET_LIVE=
# IBKR_SIGNATURE_KEY_PATH_LIVE=/secrets/ibkr_signature_live.pem
# IBKR_ENCRYPTION_KEY_PATH_LIVE=/secrets/ibkr_encryption_live.pem
# IBKR_ACCOUNT_ID_LIVE=U1234567
IBKR_URL_LIVE=https://api.ibkr.com/v1/api
IBKR_URL_TESTNET=https://api.ibkr.com/v1/api
IBKR_WS_URL_LIVE=wss://api.ibkr.com/v1/api/ws
IBKR_WS_URL_TESTNET=wss://api.ibkr.com/v1/api/ws
IBKR_MAX_LEVERAGE=4
IBKR_WS_MAX_SUBSCRIPTIONS=80
IBKR_WS_IDLE_TIMEOUT_S=300
# ─── DATA PROVIDERS — MACRO ─────────────────────────────── # ─── DATA PROVIDERS — MACRO ───────────────────────────────
FRED_API_KEY= FRED_API_KEY=
FINNHUB_API_KEY= FINNHUB_API_KEY=
+217 -12
View File
@@ -9,8 +9,8 @@ sul token bearer fornito dal client.
- **Una singola immagine Docker** (`cerbero-mcp`) ospita tutti i router - **Una singola immagine Docker** (`cerbero-mcp`) ospita tutti i router
exchange in un unico processo FastAPI exchange in un unico processo FastAPI
- **Quattro exchange** (Deribit, Bybit, Hyperliquid, Alpaca) e **due data - **Cinque exchange** (Deribit, Bybit, Hyperliquid, Alpaca, IBKR) e **due
provider** read-only (Macro, Sentiment) data provider** read-only (Macro, Sentiment)
- **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
@@ -19,7 +19,10 @@ sul token bearer fornito dal client.
override-abili tramite variabili dedicate (`DERIBIT_URL_*`, override-abili tramite variabili dedicate (`DERIBIT_URL_*`,
`BYBIT_URL_*`, `HYPERLIQUID_URL_*`, `ALPACA_URL_*`) `BYBIT_URL_*`, `HYPERLIQUID_URL_*`, `ALPACA_URL_*`)
- **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs` - **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs`
- **Qualità verificata**: 259 test (unit + integration + smoke), mypy - **Endpoint cross-exchange unificato** (`/mcp-cross/tools/get_historical`):
fan-out a tutti gli exchange che supportano (symbol, asset_class) e
consensus per-bar (mediana OHLC + `div_pct` + `sources`)
- **Qualità verificata**: 399 test (unit + integration + smoke), mypy
pulito, ruff pulito pulito, ruff pulito
## Avvio rapido (sviluppo, senza Docker) ## Avvio rapido (sviluppo, senza Docker)
@@ -62,19 +65,101 @@ Le tool puramente read-only (`/mcp-macro/*` e `/mcp-sentiment/*`)
richiedono comunque un bearer valido, ma il valore (testnet o mainnet) è richiedono comunque un bearer valido, ma il valore (testnet o mainnet) è
indifferente perché non hanno endpoint testnet. indifferente perché non hanno endpoint testnet.
### Header X-Bot-Tag (identificazione bot)
Tutte le chiamate a `/mcp-*` richiedono inoltre l'header `X-Bot-Tag` con
una stringa identificativa del bot chiamante (massimo 64 caratteri). Il
valore viene loggato negli audit record per tracciare quale bot ha
eseguito ogni operazione write. Esempio di richiesta:
Authorization: Bearer $MAINNET_TOKEN
X-Bot-Tag: scanner-alpha-prod
Se l'header è assente o vuoto la risposta è `400 BAD_REQUEST`. L'header
non è richiesto sugli endpoint pubblici (`/health`, `/apidocs`,
`/openapi.json`) né sull'endpoint admin `/admin/audit`.
## Endpoint principali ## Endpoint principali
| Path | Descrizione | | Path | Descrizione |
|---|---| |---|---|
| `GET /health` | Healthcheck (no auth) | | `GET /health` | Liveness check (no auth) |
| `GET /health/ready` | Readiness check con ping client exchange (no auth) |
| `GET /apidocs` | Swagger UI (no auth) | | `GET /apidocs` | Swagger UI (no auth) |
| `GET /openapi.json` | Schema OpenAPI 3.1 (no auth) | | `GET /openapi.json` | Schema OpenAPI 3.1 (no auth) |
| `POST /mcp-deribit/tools/{tool}` | Tool exchange Deribit | | `POST /mcp-deribit/tools/{tool}` | Tool exchange Deribit |
| `POST /mcp-bybit/tools/{tool}` | Tool exchange Bybit | | `POST /mcp-bybit/tools/{tool}` | Tool exchange Bybit |
| `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid | | `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid |
| `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca | | `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca |
| `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) |
## Observability
### Health check
L'applicazione espone due endpoint distinti per il monitoring:
- `GET /health` — liveness check semplice. Non richiede autenticazione e
ritorna sempre HTTP 200 finché il processo è vivo. Ideale per la
liveness probe di Kubernetes o per il pinger di Traefik.
- `GET /health/ready` — readiness check evoluto. Itera tutti i client
exchange presenti nel registry e per ciascuno tenta una probe leggera
(`health()` se disponibile, fallback su `is_testnet()`), con timeout
di 2 secondi per client. La risposta contiene il campo `status` con
uno dei valori `ready` (tutti i client rispondono), `degraded` (almeno
uno fallisce) o `not_ready` (registry vuoto) ed un array `clients` con
un record per ogni coppia `(exchange, env)` cached. Per default
l'endpoint risponde sempre con HTTP 200; impostando la variabile
d'ambiente `READY_FAILS_ON_DEGRADED=true` si forza HTTP 503 quando lo
stato non è `ready`, comportamento utile per la readiness probe di
Kubernetes.
### Request log
Ogni richiesta HTTP attraversa un middleware che emette una riga JSON
sul logger `mcp.request` con i seguenti campi: `request_id`, `method`,
`path`, `status_code`, `duration_ms`, `actor` (`testnet` o `mainnet`,
solo se autenticato), `bot_tag` (header `X-Bot-Tag` se presente),
`exchange` (estratto dal path `/mcp-{exchange}/...`), `tool` (nome del
tool quando il path è `/mcp-X/tools/Y`), `client_ip`, `user_agent`. Lo
stesso `request_id` viene incluso anche nei record dell'audit log
`mcp.audit` e nell'envelope di errore restituito al client, in modo da
poter correlare le tre tracce a parità di richiesta.
### Audit log
Vedi la sezione "Audit query" qui sotto per la consultazione del log
strutturato delle operazioni di scrittura.
## Audit query
`GET /admin/audit` legge il file JSONL puntato da `AUDIT_LOG_FILE` e
restituisce i record filtrati. Richiede un bearer valido (testnet o
mainnet); non richiede l'header `X-Bot-Tag`.
Parametri di query (tutti opzionali):
- `from`, `to`: ISO 8601 datetime (es. `2026-05-01` o `2026-05-01T12:34:56Z`)
- `actor`: `testnet` | `mainnet`
- `exchange`: nome dell'exchange (`deribit`, `bybit`, `hyperliquid`, `alpaca`, `ibkr`)
- `action`: nome del tool (es. `place_order`)
- `bot_tag`: identificatore del bot
- `limit`: massimo record restituiti, default `1000`, massimo `10000`
Esempio di chiamata:
curl -H "Authorization: Bearer $MAINNET_TOKEN" \
"http://localhost:9000/admin/audit?from=2026-05-01&actor=mainnet&action=place_order&limit=100"
Se `AUDIT_LOG_FILE` non è configurata l'endpoint risponde `count: 0` con
un campo `warning`. Per abilitare il sink persistente impostare nel `.env`:
AUDIT_LOG_FILE=/var/log/cerbero-mcp/audit.jsonl
AUDIT_LOG_BACKUP_DAYS=30
## Tool disponibili ## Tool disponibili
@@ -106,6 +191,13 @@ rate, basis spot/perp, place_order, set_stop_loss, set_take_profit.
Account, positions, bars, snapshot, option chain, place_order, Account, positions, bars, snapshot, option chain, place_order,
amend_order, cancel_order, close_position. amend_order, cancel_order, close_position.
### IBKR (Interactive Brokers)
Account, positions, activities, ticker, bars, snapshot, option chain,
search_contracts, clock, streaming (tick + depth via WebSocket
singleton), place_order, amend_order, cancel_order, close_position,
bracket/OCO/OTO orders. Auth via OAuth 1.0a Self-Service con minting
session token unattended (vedi sezione "IBKR Setup" più sotto).
### Macro ### Macro
Treasury yields, FRED indicators, equity futures, asset prices, calendar, Treasury yields, FRED indicators, equity futures, asset prices, calendar,
get_yield_curve_slope, get_breakeven_inflation, get_cot_tff, get_yield_curve_slope, get_breakeven_inflation, get_cot_tff,
@@ -116,6 +208,16 @@ News (CryptoPanic/CoinDesk), social (LunarCrush), funding multi-exchange,
OI history, get_funding_arb_spread, get_liquidation_heatmap, OI history, get_funding_arb_spread, get_liquidation_heatmap,
get_cointegration_pairs. get_cointegration_pairs.
### Cross (storico unificato)
`get_historical` aggrega le candele dello stesso simbolo da tutti gli
exchange che lo supportano e ritorna una serie consensus: la chiusura è
la mediana, `sources` è il numero di exchange che hanno contribuito al
bar e `div_pct = (max-min)/median` segnala il disaccordo tra fonti — un
quality gate per i bot. Crypto: BTC/ETH/SOL via Bybit + Hyperliquid +
Deribit. Stocks: AAPL/SPY/QQQ/TSLA/NVDA via Alpaca. In caso di fallimento
parziale ritorna i dati disponibili più `failed_sources`; se *tutti* gli
upstream falliscono → HTTP 502 retryable.
## Deploy su VPS con Traefik ## Deploy su VPS con Traefik
Sul VPS la rete pubblica (TLS, allowlist IP, rate limit) è gestita da Sul VPS la rete pubblica (TLS, allowlist IP, rate limit) è gestita da
@@ -139,18 +241,58 @@ labels:
## Build & deploy pipeline ## Build & deploy pipeline
Build dell'immagine eseguita sulla macchina di sviluppo: Il deploy su VPS avviene **per clone diretto del repo**, senza passare per
un container registry. Lo script `scripts/deploy-vps.sh` automatizza
l'intero flusso: pull del ramo target, rebuild dell'immagine sulla
macchina VPS, restart del servizio, healthcheck e rollback automatico in
caso di fallimento.
### Setup iniziale sul VPS (una sola volta)
```bash ```bash
export GITEA_PAT='<PAT con scope write:package>' # Sul VPS:
./scripts/build-push.sh sudo mkdir -p /opt/cerbero-mcp
sudo chown -R "$USER":"$USER" /opt/cerbero-mcp
cd /opt/cerbero-mcp
git clone -b V2.0.0 ssh://git@git.tielogic.xyz:222/Adriano/Cerbero-mcp.git .
cp .env.example .env
# editare .env con i token e le credenziali reali
``` ```
Lo script tagga `:2.0.0`, `:latest` e `:sha-<short>` per rollback puntuali Il branch in produzione è `V2.0.0` (non `main`). Lo script `deploy-vps.sh`
e pubblica al registry Gitea. Sul VPS Watchtower polla `:latest` e fa default su questo ramo.
aggiorna il container automaticamente.
Smoke test post-deploy: ### Deploy ricorrente
Da qualunque macchina con accesso SSH al VPS:
```bash
ssh user@vps 'cd /opt/cerbero-mcp && bash scripts/deploy-vps.sh'
```
Oppure direttamente dal VPS:
```bash
cd /opt/cerbero-mcp
bash scripts/deploy-vps.sh
```
Lo script:
1. verifica che il working tree sia pulito e che `.env` sia presente;
2. esegue `git fetch + reset --hard origin/V2.0.0`;
3. se la SHA non è cambiata, esce senza fare nulla (override con
`FORCE=1`);
4. ricostruisce l'immagine Docker (`docker compose build`);
5. restart graceful del container (`docker compose down --timeout 15`
seguito da `docker compose up -d`);
6. attende `/health` (timeout 30 s di default);
7. se l'health fallisce, esegue rollback automatico al SHA precedente.
Variabili d'ambiente accettate: `BRANCH` (default `V2.0.0`), `PORT`
(default letto da `.env`), `HEALTH_TIMEOUT_SECONDS`, `FORCE`,
`SKIP_ROLLBACK`.
### Smoke test post-deploy
```bash ```bash
PORT=9000 TESTNET_TOKEN="$TESTNET_TOKEN" bash tests/smoke/run.sh PORT=9000 TESTNET_TOKEN="$TESTNET_TOKEN" bash tests/smoke/run.sh
@@ -160,7 +302,7 @@ PORT=9000 TESTNET_TOKEN="$TESTNET_TOKEN" bash tests/smoke/run.sh
```bash ```bash
uv sync uv sync
uv run pytest # tutta la suite (259 test attesi) uv run pytest # tutta la suite (399 test attesi)
uv run pytest tests/unit -v # solo unit uv run pytest tests/unit -v # solo unit
uv run pytest tests/integration -v uv run pytest tests/integration -v
uv run ruff check src/ tests/ uv run ruff check src/ tests/
@@ -241,6 +383,69 @@ pybit (workaround documentato nel client). Per Alpaca l'override è
applicato al solo trading endpoint: gli endpoint dati applicato al solo trading endpoint: gli endpoint dati
(`data.alpaca.markets`) restano quelli predefiniti dell'SDK. (`data.alpaca.markets`) restano quelli predefiniti dell'SDK.
## IBKR Setup
IBKR uses OAuth 1.0a Self-Service for fully unattended runtime auth. Setup is
manual one-time per account (paper + live), then the container mints live
session tokens autonomously.
### One-time setup
1. Login to https://www.interactivebrokers.com → User Settings → Self-Service OAuth
2. Generate keypairs locally:
```bash
uv run python scripts/ibkr_oauth_setup.py --env testnet
```
This writes RSA keys under `secrets/` and prints SHA-256 fingerprints.
3. Register the two fingerprints in the IBKR portal. Receive a `consumer_key`.
4. Get a request token + authorization URL:
```bash
uv run python scripts/ibkr_oauth_setup.py --env testnet \
--consumer-key <K> --request-token
```
5. Open the URL, authorize, copy the `verifier_code`.
6. Exchange verifier for long-lived access token (~5 years validity):
```bash
uv run python scripts/ibkr_oauth_setup.py --env testnet --verifier <V>
```
7. Copy the printed values into `.env`:
- `IBKR_CONSUMER_KEY_TESTNET`
- `IBKR_ACCESS_TOKEN_TESTNET`
- `IBKR_ACCESS_TOKEN_SECRET_TESTNET`
- `IBKR_SIGNATURE_KEY_PATH_TESTNET`
- `IBKR_ENCRYPTION_KEY_PATH_TESTNET`
- `IBKR_ACCOUNT_ID_TESTNET` (e.g., `DU1234567` for paper)
- `IBKR_DH_PRIME` (hex from portal; shared paper/live)
8. Repeat with `--env mainnet` for live trading.
### Smoke test
```bash
curl https://cerbero-mcp.<dom>/mcp-ibkr/tools/get_account \
-H "Authorization: Bearer <TESTNET_TOKEN>" -X POST -d '{}'
```
### Key rotation
```bash
# 1. Generate new keypairs alongside existing
uv run python scripts/ibkr_oauth_setup.py --env testnet --rotate
# 2. Register new fingerprints in IBKR portal, get new consumer_key + tokens
# 3. Confirm rotation (atomic swap with auto-rollback on validation fail)
curl -X POST "https://cerbero-mcp.<dom>/admin/ibkr/rotate-keys/confirm?env=testnet" \
-H "Authorization: Bearer <ADMIN_TOKEN>" -H "Content-Type: application/json" \
-d '{"new_consumer_key":"...","new_access_token":"...","new_access_token_secret":"..."}'
```
## Licenza ## Licenza
Privato. Privato.
+17 -2
View File
@@ -3,9 +3,9 @@ services:
image: cerbero-mcp:2.0.0 image: cerbero-mcp:2.0.0
build: . build: .
container_name: cerbero-mcp container_name: cerbero-mcp
ports:
- "${PORT:-9000}:${PORT:-9000}"
env_file: .env env_file: .env
volumes:
- ./secrets:/secrets:ro
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@@ -16,3 +16,18 @@ services:
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
networks:
- traefik
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- "traefik.http.routers.cerbero-mcp.rule=Host(`cerbero-mcp.${DOMAIN_NAME:-tielogic.xyz}`)"
- traefik.http.routers.cerbero-mcp.tls=true
- traefik.http.routers.cerbero-mcp.entrypoints=websecure
- traefik.http.routers.cerbero-mcp.tls.certresolver=mytlschallenge
- "traefik.http.services.cerbero-mcp.loadbalancer.server.port=${PORT:-9000}"
- "com.centurylinklabs.watchtower.enable=true"
networks:
traefik:
external: true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,530 @@
# IBKR Integration — Design Spec
**Date:** 2026-05-03
**Branch:** V2.0.0
**Status:** Approved (pending implementation plan)
**Approach chosen:** A2 — Client Portal Web API with OAuth 1.0a Self-Service (fully unattended)
## 1. Goals & Non-Goals
### Goals
Aggiungere `ibkr` come exchange supportato in `cerbero-mcp`, riutilizzando il pattern consolidato (Alpaca/Deribit) per:
- account / positions / activities (read)
- ordini simple: market, limit, stop, stop-limit (read + write)
- ordini complex: bracket (entry + SL + TP con OCA), OCO (N legs OCA type=1), OTO (parent → child sequenziale)
- market data: snapshot REST + tick/depth real-time via WebSocket (snapshot-on-demand)
- options chain via OCC symbol
- key rotation semi-automatica via admin endpoint con auto-rollback
- routing testnet (paper account) / mainnet (live account) via bearer token, come gli altri exchange
### Non-Goals (V1)
- Server-Sent Events / streaming HTTP response (snapshot-on-demand è sufficiente)
- Multi-account dinamico (un solo `account_id` per env, configurato in settings)
- Trailing stop, IF-touched, conditional advanced orders (solo bracket fisso, OCO, OTO)
- Rotazione completamente automatica del consumer registration (il passo portale IBKR non è automatizzabile)
- Streaming WebSocket esposto direttamente al bot (resta interno al server, esposto come polling REST)
- TWS API socket protocol via `ib_insync` (rejected: richiede gateway desktop con Xvfb, fragile)
- Flex Web Service (rejected: read-only su report storici, fuori scope)
### Success criteria
1. `POST /mcp-ibkr/tools/get_account` con bearer testnet ritorna saldo paper account reale
2. `POST /mcp-ibkr/tools/place_order` (1 share AAPL market) → ordine fillato in paper, audit log presente
3. `POST /mcp-ibkr/tools/place_bracket_order` → 3 ordini collegati via OCA group, primo fill cancella gli altri
4. `POST /mcp-ibkr/tools/get_depth` → 5 livelli depth con dati < 1s di latenza
5. `POST /admin/ibkr/rotate-keys/{start,confirm}` → swap atomico, rollback automatico su validation fail
6. Container restart → primo `get_account` < 5s (OAuth flow + first call), zero input umano
7. Test suite verde: 90% coverage su `oauth.py`/`client.py`/`ws.py`/`key_rotation.py`, 85% su `tools.py`/`orders_complex.py`
8. `/health/ready` segnala IBKR sano per entrambi gli env
## 2. Architecture
```
┌──────┐ Bearer testnet|mainnet ┌──────────────────┐
│ Bot │ ─────────────────────────▶│ cerbero-mcp │
└──────┘ │ (single FastAPI)│
│ │
│ IBKRClient │ ──HTTPS OAuth1a──▶ api.ibkr.com/v1/api
│ IBKRWebSocket │ ──WSS LST───────▶ api.ibkr.com/v1/api/ws
└──────────────────┘
```
**Decisioni chiave:**
- **OAuth 1.0a Self-Service:** firma RSA-SHA256 + DH key exchange per mintare live session token (24h TTL) in autonomia. Setup iniziale manuale una-tantum sul portale IBKR; runtime fully unattended.
- **Container singolo:** niente sidecar Java (era considerato per CP Gateway non-OAuth, scartato perché richiedeva login interattivo).
- **WebSocket interno snapshot-on-demand:** un singleton `IBKRWebSocket` per env mantiene sub attive in background; i tool REST `get_tick`/`get_depth` ritornano l'ultimo snapshot in cache. Bot polling-based, niente streaming verso il bot.
- **Paper vs live = account separati, stesso host:** IBKR usa `api.ibkr.com` per entrambi; i test sono fatti su account paper (con suo OAuth bundle), live su account live. Due set di credenziali in settings (pattern Deribit `_TESTNET` / `_LIVE`).
- **Conid cache:** IBKR identifica strumenti via `conid` numerico; symbol→conid lookup cached (LRU 1024, TTL 1h) per evitare round-trip ripetuti.
- **Two-level keep-alive:** brokerage session muore in 5min idle (richiede `POST /tickle`); live session token muore in 24h (richiede DH re-mint). Gestiti separatamente dal client.
## 3. Components (file layout)
```
src/cerbero_mcp/exchanges/ibkr/
├── __init__.py
├── client.py # IBKRClient: REST httpx + tickle + conid cache
├── oauth.py # OAuth1aSigner: RSA sig + DH live session token mint/refresh
├── ws.py # IBKRWebSocket: persistent WSS, smd/sbd subs, snapshot cache
├── orders_complex.py # bracket/OCO/OTO payload builders
├── key_rotation.py # KeyRotationManager: stage/confirm/abort/rollback
├── tools.py # Pydantic schemas + async tool functions (read + write + complex + streaming)
└── leverage_cap.py # get_max_leverage(creds) — copia 1:1 da alpaca
src/cerbero_mcp/routers/ibkr.py # POST /mcp-ibkr/tools/*
scripts/ibkr_oauth_setup.py # one-shot: generate keypair, walkthrough portale, --rotate flag
tests/unit/exchanges/ibkr/
├── __init__.py
├── test_oauth.py
├── test_client.py
├── test_ws.py
├── test_orders_complex.py
├── test_key_rotation.py
└── test_tools.py
```
**File esistenti modificati:**
- `src/cerbero_mcp/settings.py` — aggiungo `IBKRSettings`
- `src/cerbero_mcp/exchanges/__init__.py` — branch `if exchange == "ibkr"` in `build_client`
- `src/cerbero_mcp/__main__.py``app.include_router(ibkr.make_router())`
- `src/cerbero_mcp/admin.py` — endpoints `/admin/ibkr/rotate-*` + `/admin/ibkr/health`
- `.env.example` — sezione `# ─── EXCHANGE — IBKR ───`
- `pyproject.toml` — aggiungo `cryptography>=43` (RSA + DH; potrebbe essere già transitiva)
- `docker-compose.yml` — bind mount `./secrets:/secrets:ro`
- `README.md` — sezione "IBKR Setup"
**Module boundaries:**
- `oauth.py` espone `OAuth1aSigner.get_live_session_token() -> str` (cached). Non sa nulla di endpoint applicativi; conosce solo l'endpoint `/oauth/live_session_token`.
- `client.py` riceve un `OAuth1aSigner` come dipendenza, non costruisce keys. Non sa nulla di WebSocket.
- `ws.py` riceve un `OAuth1aSigner`, gestisce WSS in modo indipendente. Espone metodi async `subscribe_tick(conid)`, `subscribe_depth(conid, rows)`, `get_tick_snapshot(conid)`, `get_depth_snapshot(conid)`. Non sa nulla di REST.
- `orders_complex.py` è un set di funzioni **pure** che producono payload JSON IBKR-ready. Niente HTTP. Test deterministici.
- `key_rotation.py` opera su filesystem + `OAuth1aSigner` factory; non tocca routing FastAPI direttamente.
## 4. Settings & OAuth flow
### Pydantic settings
```python
class IBKRSettings(_Sub):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8",
env_prefix="IBKR_", extra="ignore",
)
# Coppia singola fallback (legacy / dev)
consumer_key: str | None = None
access_token: str | None = None
access_token_secret: SecretStr | None = None
signature_key_path: str | None = None
encryption_key_path: str | None = None
dh_prime: SecretStr | None = None
# Coppie env-specific (prevalgono se valorizzate)
consumer_key_testnet: str | None = None
access_token_testnet: str | None = None
access_token_secret_testnet: SecretStr | None = None
signature_key_path_testnet: str | None = None
encryption_key_path_testnet: str | None = None
account_id_testnet: str | None = None
consumer_key_live: str | None = None
access_token_live: str | None = None
access_token_secret_live: SecretStr | None = None
signature_key_path_live: str | None = None
encryption_key_path_live: str | None = None
account_id_live: str | None = None
# URLs (paper e live condividono host)
url_live: str = "https://api.ibkr.com/v1/api"
url_testnet: str = "https://api.ibkr.com/v1/api"
ws_url_live: str = "wss://api.ibkr.com/v1/api/ws"
ws_url_testnet: str = "wss://api.ibkr.com/v1/api/ws"
# Limits
max_leverage: int = 4 # Reg-T default
ws_max_subscriptions: int = 80
ws_idle_timeout_s: int = 300
def credentials(self, env: str) -> dict:
"""Ritorna dict completo OAuth per env. ValueError su campi mancanti.
Per ogni campo: prefer `<field>_<env>`; fallback a `<field>` (legacy);
ValueError se entrambi assenti per i campi required
(consumer_key, access_token, access_token_secret, signature_key_path,
encryption_key_path, account_id, dh_prime).
Pattern identico a DeribitSettings.credentials().
"""
```
`dh_prime` è una stringa hex emessa da IBKR al setup, **costante** per consumer (condivisa paper/live), non duplicata per env.
### `.env.example`
```env
# ─── EXCHANGE — IBKR ──────────────────────────────────────
# Setup OAuth: vedi README "IBKR Setup" + scripts/ibkr_oauth_setup.py.
# Le RSA keys (PEM) NON vanno nel .env: monta come file e referenzia il path.
IBKR_CONSUMER_KEY=
IBKR_ACCESS_TOKEN=
IBKR_ACCESS_TOKEN_SECRET=
IBKR_SIGNATURE_KEY_PATH=/secrets/ibkr_signature.pem
IBKR_ENCRYPTION_KEY_PATH=/secrets/ibkr_encryption.pem
IBKR_DH_PRIME=
# Coppie env-specific (prevalgono):
# IBKR_CONSUMER_KEY_TESTNET=
# IBKR_ACCESS_TOKEN_TESTNET=
# IBKR_ACCESS_TOKEN_SECRET_TESTNET=
# IBKR_SIGNATURE_KEY_PATH_TESTNET=/secrets/ibkr_signature_paper.pem
# IBKR_ENCRYPTION_KEY_PATH_TESTNET=/secrets/ibkr_encryption_paper.pem
# IBKR_ACCOUNT_ID_TESTNET=DU1234567
# IBKR_CONSUMER_KEY_LIVE=
# IBKR_ACCESS_TOKEN_LIVE=
# IBKR_ACCESS_TOKEN_SECRET_LIVE=
# IBKR_SIGNATURE_KEY_PATH_LIVE=/secrets/ibkr_signature_live.pem
# IBKR_ENCRYPTION_KEY_PATH_LIVE=/secrets/ibkr_encryption_live.pem
# IBKR_ACCOUNT_ID_LIVE=U1234567
IBKR_URL_LIVE=https://api.ibkr.com/v1/api
IBKR_URL_TESTNET=https://api.ibkr.com/v1/api
IBKR_WS_URL_LIVE=wss://api.ibkr.com/v1/api/ws
IBKR_WS_URL_TESTNET=wss://api.ibkr.com/v1/api/ws
IBKR_MAX_LEVERAGE=4
IBKR_WS_MAX_SUBSCRIPTIONS=80
IBKR_WS_IDLE_TIMEOUT_S=300
```
### Setup OAuth one-shot (manuale, una-tantum per account)
1. Login portale `https://www.interactivebrokers.com` → "User Settings" → "Self-Service OAuth"
2. `python scripts/ibkr_oauth_setup.py --env testnet` → genera 2 RSA keypair + stampa SHA-256 fingerprint
3. Sul portale: registra le 2 public key, ottieni `consumer_key`
4. `python scripts/ibkr_oauth_setup.py --consumer-key <K> --request-token` → ottiene request token + URL autorizzazione
5. Aprire URL nel browser, autorizzare, copiare `verifier_code`
6. `python scripts/ibkr_oauth_setup.py --verifier <V>` → scambia per `access_token` (long-lived ~5 anni) + `access_token_secret`
7. Copiare 3 valori in `.env`. Ripetere per env live.
### Runtime flow (fully unattended)
- Container avvia → `IBKRClient` lazy-instantiated alla prima request
- `OAuth1aSigner` carica RSA private keys da disk (path da settings)
- Prima request privata → `_get_live_session_token()`:
1. Genera nonce + timestamp
2. Firma `POST /oauth/live_session_token` con RSA-SHA256
3. Diffie-Hellman key exchange con `dh_prime`
4. Riceve `lst` (live session token, valido 24h)
5. Cache in memory con scadenza `now + 86000s`
- Request successive: HMAC-SHA256 dei params con `lst` come key
- `lst` scaduto → mint nuovo automatico, retry once. Mai input umano runtime.
- Ogni request privata: se ultima call > 4min fa, chiama `POST /tickle` (brokerage session keep-alive) prima.
### Errori → error envelope
| Trigger | Code | retryable |
|---|---|---|
| RSA key file mancante | `IBKR_KEY_NOT_FOUND` | false |
| RSA key file illeggibile | `IBKR_KEY_INVALID` | false |
| Consumer revocato dal portale | `IBKR_CONSUMER_REVOKED` | false |
| Access token scaduto (~5 anni) | `IBKR_ACCESS_TOKEN_EXPIRED` | false |
| LST mint fallito (network) | `IBKR_SESSION_MINT_FAILED` | true |
| `401` su request firmata | `IBKR_AUTH_FAILED` | true (forza refresh LST + retry once) |
| Rate limit `429` | `IBKR_RATE_LIMITED` | true |
| Manutenzione domenicale | `IBKR_MAINTENANCE` | true |
| Account configurato non in `/iserver/accounts` | `IBKR_ACCOUNT_NOT_FOUND` | false |
| Subscription market data assente | `IBKR_NO_MARKET_DATA_SUBSCRIPTION` | false |
| Order warning critico (margin/suitability) | `IBKR_ORDER_REJECTED_WARNING` | false |
| WS sub limit superato | `IBKR_WS_SUB_LIMIT` | false |
| `get_tick` timeout cache vuota dopo 3s | `IBKR_TICK_TIMEOUT` | true |
| OTO seconda POST fallita dopo trigger placed | `IBKR_OTO_PARTIAL_FAILURE` | false |
| Rotation validation fallita | `IBKR_ROTATION_VALIDATION_FAILED` | false (rollback automatico) |
## 5. Tool API surface
Pattern simmetrico ad Alpaca dove l'astrazione regge: stesso tool name → bot riusa logica cross-exchange.
### Reads (12 tool)
| Tool | IBKR endpoint |
|---|---|
| `environment_info` | locale (env, paper, base_url, max_leverage) |
| `get_account` | `GET /portfolio/{accountId}/summary` |
| `get_positions` | `GET /portfolio/{accountId}/positions/0` (loop se >30) |
| `get_activities` | `GET /iserver/account/trades?days=N` (default 7, cap 90) |
| `get_assets` | `GET /trsrv/secdef/search?symbol=...` (richiede symbol) |
| `get_ticker` | `GET /iserver/marketdata/snapshot?conids=X&fields=31,84,86,7295,7296` |
| `get_bars` | `GET /iserver/marketdata/history?conid=X&period=...&bar=...` |
| `get_snapshot` | `GET /iserver/marketdata/snapshot` (full fields) |
| `get_option_chain` | `GET /iserver/secdef/strikes` + `/info` |
| `get_open_orders` | `GET /iserver/account/orders?filters=Submitted,PreSubmitted` |
| `get_clock` | locale (now + market hours statiche) |
| `search_contracts` | `GET /trsrv/secdef/search` (IBKR-specific: symbol+secType → conid) |
### Streaming (4 tool, snapshot-on-demand)
| Tool | Behavior |
|---|---|
| `get_tick` | Ultimo tick in cache (last/bid/ask/size/timestamp). Se non subscribed: sub lazy + attesa primo tick (timeout 3s) |
| `get_depth` | Order book depth (default 5 livelli, max 10). IBKR `sbd+{conid}+{exchange}+{rows}` |
| `subscribe_tick` | Mantiene sub attiva anche senza polling. Auto-unsub dopo `ws_idle_timeout_s` |
| `unsubscribe` | Forza chiusura sub per liberare slot |
### Writes simple (6 tool)
| Tool | Audit field |
|---|---|
| `place_order` | `symbol` |
| `amend_order` | `order_id` |
| `cancel_order` | `order_id` |
| `cancel_all_orders` | — (loop) |
| `close_position` | `symbol` |
| `close_all_positions` | — (loop) |
### Writes complex (3 tool)
| Tool | Schema essenziale | Endpoint |
|---|---|---|
| `place_bracket_order` | `symbol, side, qty, entry_price, stop_loss, take_profit, tif="gtc"` | `POST /iserver/account/{id}/orders` array `[parent, sl_child, tp_child]` con OCA group auto |
| `place_oco_order` | `legs: list[OrderLeg]` (2-N orders) | Stessa POST con `oca_group` + `oca_type=1` su ogni leg |
| `place_oto_order` | `trigger: OrderLeg, child: OrderLeg` | POST sequenziali: trigger prima, poi child con `parent_id=<trigger.order_id>`. **Non atomico:** se la seconda POST fallisce dopo che la prima è andata a buon fine, il tool cancella il trigger via `cancel_order` (best-effort) e ritorna `IBKR_OTO_PARTIAL_FAILURE` con `details.trigger_order_id` per audit |
**Audit:** complex orders tracciano `target_field=symbol` + `details.legs_count` + `details.oca_group` (se applicabile).
**Leverage cap su complex:** applicato sul **net notional** della struttura. Bracket = entry only (i child non aprono nuova esposizione). OCO = max(leg.notional). OTO = trigger + child se entrambi long, altrimenti max.
### IBKR-specifiche (interne al client)
1. **`conid` resolution:** `place_order(symbol="AAPL")` → lookup `GET /trsrv/secdef/search?symbol=AAPL&secType=STK` → primo match → cache LRU. Per options: parse OCC (`AAPL 240119C00190000`) → `/iserver/secdef/info`.
2. **`accountId` validation:** al boot, `GET /iserver/accounts` → verifica `account_id_<env>` presente. Altrimenti `IBKR_ACCOUNT_NOT_FOUND`.
3. **Order confirmation flow:** IBKR ritorna warnings array, richiede secondo POST con `confirmed: true`. Auto-confirm per default (max 3 cicli), ma filtra warning critici (margin, suitability, hard rejects) → error envelope.
4. **`tickle` keep-alive:** automatico se ultima request > 4min fa. Indipendente dal LST.
5. **Empty market data → error envelope:** snapshot vuoto = subscription mancante; ritorniamo `IBKR_NO_MARKET_DATA_SUBSCRIPTION` invece di dict vuoto silenzioso.
6. **Leverage cap:** IBKR non accetta `leverage` per-order. Calcoliamo `notional / equity``max_leverage` pre-submit chiamando `get_account` per equity. Pattern asincrono ma cached 30s.
### `PlaceOrderReq` schema
```python
class PlaceOrderReq(BaseModel):
symbol: str # "AAPL" o OCC-format per options
side: str # "buy" | "sell"
qty: float
order_type: str = "market" # "market" | "limit" | "stop" | "stop_limit"
limit_price: float | None = None
stop_price: float | None = None
tif: str = "day" # "day" | "gtc" | "ioc"
asset_class: str = "stocks" # "stocks" | "options" | "futures" | "forex"
sec_type: str | None = None # IBKR override (STK/OPT/FUT/CASH); inferito da asset_class
exchange: str = "SMART" # IBKR routing
outside_rth: bool = False
```
## 6. WebSocket layer
### Pattern
Singleton `IBKRWebSocket` per env, lazy-start alla prima sub. Una connessione WSS condivisa per tutte le sub.
### Lifecycle
1. **Boot:** non connette finché un tool streaming non viene chiamato.
2. **First sub call:** apre WSS, autentica con LST corrente (header `Cookie: api=<lst>`), invia subscribe message (`smd+{conid}+{fields}` o `sbd+{conid}+{exchange}+{rows}`).
3. **Message dispatch:** ogni messaggio `smd-...` aggiorna `dict[conid, TickSnapshot]`; ogni `sbd-...` aggiorna `dict[conid, DepthSnapshot]`.
4. **Heartbeat:** ping ogni 30s; se nessun pong in 60s → forza reconnect.
5. **Reconnect:** backoff esponenziale 1s, 2s, 4s, max 30s. Su reconnect: re-subscribe automatico a tutti i conid attivi.
6. **Idle unsub:** track `last_polled_at[conid]`; se > `ws_idle_timeout_s` (default 300s) → invia unsub, libera slot. Sub forzata via `subscribe_tick` non scade fino a `unsubscribe` esplicito.
7. **Sub limit:** se sub attive ≥ `ws_max_subscriptions` (default 80) → error envelope `IBKR_WS_SUB_LIMIT` su nuova sub.
### Cache invariant
- Snapshot rappresenta **sempre** l'ultimo update ricevuto. No buffering storico.
- Su disconnect: cache di un conid invalidata se reconnect non riesce in <5s.
- `get_tick(conid)` se cache vuota: aspetta primo tick fino a 3s, poi `IBKR_TICK_TIMEOUT`.
### Health probe
`/health/ready` interroga `IBKRWebSocket.connected` per ogni env. Stato `degraded` se ws disconnesso ma client REST ok.
### Feature flag
`IBKR_WS_ENABLED=false` (env var) disabilita layer WS a runtime; tool streaming fallback a HTTP `/marketdata/snapshot` (single shot, niente depth). Mitigation per emergenze prod.
## 7. Key rotation
### Endpoint admin
```
POST /admin/ibkr/rotate-keys/start?env=testnet
→ genera signature_key.pem.new + encryption_key.pem.new (RSA 2048)
→ ritorna {fingerprints: {sig: "SHA256:...", enc: "SHA256:..."},
expires_at: <now+24h>}
→ user incolla fingerprint nel portale IBKR, ottiene new_consumer_key
POST /admin/ibkr/rotate-keys/confirm?env=testnet
body: {new_consumer_key, new_access_token, new_access_token_secret}
→ atomic swap: .new → primary, primary → secrets/.archive/<timestamp>/
→ probe: GET /iserver/auth/status con nuove credenziali
→ ok: ritorna {rotated_at, old_archived_at}
→ ko: rollback automatico (swap inverso), ritorna 500 IBKR_ROTATION_VALIDATION_FAILED
POST /admin/ibkr/rotate-keys/abort?env=testnet
→ cancella .new files, no-op se start non eseguito o confirm già eseguito
```
### Authorization
Endpoints protetti dal middleware `auth.py` esistente, richiedono `X-Bot-Tag: admin` (header già supportato per admin router).
### Atomic swap
Implementato come:
1. Lock filesystem-level via `fcntl.flock` su `secrets/.lock`
2. Rename `signature.pem``secrets/.archive/<ts>/signature.pem.old`
3. Rename `signature.pem.new``signature.pem`
4. Stesso per `encryption.pem`
5. Aggiorna `IBKRSettings` in-memory tramite `app.state.settings.ibkr.consumer_key_<env> = new_consumer_key` (settings live, no restart)
6. Probe `GET /iserver/auth/status`
7. Su KO: rollback (swap inverso), ripristina settings precedenti, alza eccezione
### Scheduled health check
Task asyncio creato in lifespan startup:
- Ogni 6h: `GET /iserver/auth/status` su entrambi gli env
- Se `competing=true` o `authenticated=false` per >2 cicli consecutivi: log warning + `/admin/ibkr/health` espone state degraded
- Auto-trigger `/tickle` su degraded prima di fallire
### Encryption-at-rest
Key files su disco con permessi `0600`. Bind mount Docker `:ro` su `/secrets`. Rotation preserva permessi e leggibilità solo per UID processo container.
## 8. Testing
### Unit tests
| File | Coverage critica |
|---|---|
| `test_oauth.py` | RSA-SHA256 signature deterministica (vector noto IBKR docs); DH key exchange su prime test; LST mint con httpx mock; refresh prima di scadenza; error path key mancante/illeggibile; 401 su consumer revocato |
| `test_client.py` | Authorization header construction; conid lookup + cache hit/miss; tickle keep-alive timing (last_request_at < 4min skip, > 4min trigger); place_order warning auto-confirmation flow + warning critico → error envelope; leverage cap pre-flight; account validation al boot; error mapping (401/429/maintenance/no-mkt-data) |
| `test_ws.py` | Mock `websockets.connect`; subscribe ack flow; message dispatch in cache; reconnect dopo disconnect; idle timeout unsub; sub limit (>80 → IBKR_WS_SUB_LIMIT); feature flag disabled → HTTP fallback |
| `test_orders_complex.py` | Bracket: payload shape (3 orders, OCA group uguale, parent/child relation). OCO: N legs, OCA type=1 ovunque. OTO: due POST sequenziali, secondo con parent_id corretto. Leverage cap su net notional |
| `test_key_rotation.py` | start genera keypair valido; confirm swap atomico; validation probe success/fail; rollback automatico su fail; abort pulisce .new files; archived .old conserva permessi |
| `test_tools.py` | Schema validation (qty obbligatoria, side enum, OCC format options); default values; leverage cap enforcement |
| `test_settings.py` (estendi) | `IBKRSettings.credentials("testnet")` prefer testnet → fallback base → ValueError se entrambi mancanti. Test isolation con `monkeypatch.delenv` ricorsivo per evitare `.env` pollution (pattern Deribit) |
### Coverage target
- `oauth.py`, `client.py`, `ws.py`, `key_rotation.py`: 90%
- `orders_complex.py`, `tools.py`: 85%
- `routers/ibkr.py`, `admin.py` IBKR section: 75%
### Integration smoke (manuale post-deploy)
Non in CI (richiede credenziali reali). Documentato in README:
```bash
curl https://cerbero-mcp.<dom>/mcp-ibkr/tools/get_account \
-H "Authorization: Bearer <TESTNET_TOKEN>" -X POST -d '{}'
# → saldo paper account
curl .../mcp-ibkr/tools/place_order \
-H "Authorization: Bearer <TESTNET_TOKEN>" -X POST \
-d '{"symbol":"AAPL","side":"buy","qty":1,"order_type":"market"}'
# → order_id
curl .../mcp-ibkr/tools/place_bracket_order \
-d '{"symbol":"AAPL","side":"buy","qty":1,"entry_price":150,"stop_loss":145,"take_profit":160}'
# → 3 order_ids con stesso oca_group
curl .../mcp-ibkr/tools/get_depth \
-d '{"symbol":"AAPL","rows":5}'
# → order book 5 livelli
```
### Verification gate (pre-merge)
- [ ] `uv run pytest tests/unit/exchanges/ibkr/ tests/unit/test_settings.py -v` verde
- [ ] `uv run ruff check src/cerbero_mcp/exchanges/ibkr/ src/cerbero_mcp/routers/ibkr.py` no warning
- [ ] `uv run python -c "from cerbero_mcp.settings import Settings; Settings()"` no validation error con `.env` esempio
- [ ] `docker compose build && docker compose up -d` healthy < 60s
- [ ] `curl /health/ready -H "Authorization: Bearer <TESTNET>"` ritorna `ibkr` probato
- [ ] Smoke manuale completo (lista sopra) su account paper reale
## 9. Deploy & ops
- **Branch:** `V2.0.0` (default deploy, no merge in main)
- **Pipeline:** stessa pattern del fix Deribit di settimana scorsa: commit + push → watchtower aggiorna container in <2min sul VPS
- **Traefik:** nessuna modifica (stessa Host rule)
- **Secrets:** RSA keys trasferite manualmente in `/opt/docker/cerbero-mcp/secrets/` sul VPS, mode `0600`, ownership UID container; bind mount `./secrets:/secrets:ro` aggiunto in `docker-compose.yml`
- **Rollback:** `git revert <commit>` di un singolo step lascia gli altri exchange operativi (commit atomici per design)
## 10. Commit plan (8 commit atomici)
```
1. feat(V2): IBKR settings + OAuth signer scaffolding
- settings.py: IBKRSettings con env-specific credentials
- exchanges/ibkr/oauth.py: OAuth1aSigner + tests
- .env.example: sezione IBKR
- pyproject.toml: cryptography>=43
2. feat(V2): IBKR client httpx + conid cache + tickle
- exchanges/ibkr/client.py: IBKRClient base
- exchanges/ibkr/leverage_cap.py: copia da alpaca
- tests/unit/exchanges/ibkr/test_client.py
3. feat(V2): IBKR WebSocket layer + tick/depth snapshot cache
- exchanges/ibkr/ws.py: IBKRWebSocket singleton + reconnect
- tests/unit/exchanges/ibkr/test_ws.py
4. feat(V2): IBKR read tools (account/positions/marketdata/streaming)
- exchanges/ibkr/tools.py: schemas + read functions
- tests/unit/exchanges/ibkr/test_tools.py (read paths)
5. feat(V2): IBKR write tools simple (place/amend/cancel/close)
- exchanges/ibkr/tools.py: schemas + write functions
- tests/unit/exchanges/ibkr/test_tools.py (write paths + leverage cap)
6. feat(V2): IBKR complex orders (bracket/OCO/OTO)
- exchanges/ibkr/orders_complex.py
- exchanges/ibkr/tools.py: complex tool functions
- tests/unit/exchanges/ibkr/test_orders_complex.py
7. feat(V2): IBKR key rotation admin endpoints + scheduled health
- exchanges/ibkr/key_rotation.py: KeyRotationManager
- admin.py: rotate-keys/start|confirm|abort + ibkr/health
- tests/unit/exchanges/ibkr/test_key_rotation.py
8. feat(V2): IBKR router wiring + docker secrets + setup script + docs
- routers/ibkr.py
- exchanges/__init__.py: build_client branch ibkr
- __main__.py: include_router
- scripts/ibkr_oauth_setup.py
- docker-compose.yml: bind mount secrets
- README.md: sezione IBKR Setup
```
Ogni commit lascia repo verde (test passing + container buildable). `git revert` di un commit non rompe gli altri exchange.
## 11. Risks & mitigations
| Risk | Likelihood | Mitigation |
|---|---|---|
| WebSocket reconnect instabile in prod | Media | Feature flag `IBKR_WS_ENABLED=false` + HTTP snapshot fallback |
| IBKR rate limit superato durante conid lookup burst | Bassa | LRU cache 1h + retry con backoff |
| Live session token mint fallisce per network blip | Media | Retry 3x con backoff esponenziale; circuit breaker su 5 fail consecutivi |
| Order auto-confirmation conferma erroneamente warning critico | Bassa | Whitelist esplicita warning auto-confermabili (RTH, no-mkt-data); tutto il resto → error envelope |
| Key rotation lascia sistema in stato inconsistente | Bassa | Filesystem lock + atomic swap + auto-rollback su validation fail |
| Setup OAuth iniziale troppo complesso per ops team | Media | Script `ibkr_oauth_setup.py` interattivo + sezione README dettagliata + checklist |
| Leverage cap calcolato su equity stale | Bassa | Cache equity 30s, refresh forzato pre-submit ordini > 10% equity |
## 12. Estimate
- Dev: **6-8 giorni** (era 3-4 nella V0; complex orders + WS + rotation aggiungono ~3 giorni)
- Test: incluso nei commit (TDD-friendly)
- Deploy + smoke: 0.5 giorni
- Documentation: 0.5 giorni
- **Totale:** ~7-9 giorni di lavoro effettivo
+5 -4
View File
@@ -16,9 +16,10 @@ dependencies = [
"scipy>=1.13", "scipy>=1.13",
"statsmodels>=0.14", "statsmodels>=0.14",
"pandas>=2.2", "pandas>=2.2",
"pybit>=5.7", "eth-account>=0.13.7",
"alpaca-py>=0.30", "msgpack>=1.1.2",
"hyperliquid-python-sdk>=0.6", "eth-utils>=5.3.1",
"cryptography>=43",
] ]
[project.scripts] [project.scripts]
@@ -70,7 +71,7 @@ check_untyped_defs = true
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["pybit.*", "alpaca.*", "hyperliquid.*", "pythonjsonlogger.*"] module = ["pythonjsonlogger.*"]
ignore_missing_imports = true ignore_missing_imports = true
[dependency-groups] [dependency-groups]
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bash
# Cerbero MCP — build & push immagine unica V2.0.0 al registry Gitea.
#
# Pre-requisiti:
# - docker
# - PAT Gitea con scope `write:package` in env $GITEA_PAT
# - $GITEA_USER (default: adriano)
#
# Uso:
# ./scripts/build-push.sh
# VERSION=2.0.1 ./scripts/build-push.sh
set -euo pipefail
REGISTRY="${REGISTRY:-git.tielogic.xyz}"
IMAGE_PREFIX="${IMAGE_PREFIX:-$REGISTRY/adriano/cerbero-mcp}"
GITEA_USER="${GITEA_USER:-adriano}"
VERSION="${VERSION:-2.0.0}"
SHA="$(git rev-parse --short HEAD)"
command -v docker >/dev/null || { echo "FATAL: docker non installato"; exit 1; }
# Login solo se non già autenticato sul registry.
if grep -q "\"$REGISTRY\"" ~/.docker/config.json 2>/dev/null; then
echo "=== docker già loggato su $REGISTRY (skip login) ==="
elif [ -n "${GITEA_PAT:-}" ]; then
echo "=== docker login $REGISTRY ==="
echo "$GITEA_PAT" | docker login "$REGISTRY" -u "$GITEA_USER" --password-stdin
else
echo "FATAL: non autenticato su $REGISTRY e GITEA_PAT non settata."
echo " Esegui una volta: docker login $REGISTRY -u $GITEA_USER"
exit 1
fi
TAG_VERSION="$IMAGE_PREFIX:$VERSION"
TAG_LATEST="$IMAGE_PREFIX:latest"
TAG_SHA="$IMAGE_PREFIX:sha-$SHA"
echo "=== build cerbero-mcp:$VERSION ==="
docker build -t "$TAG_VERSION" -t "$TAG_LATEST" -t "$TAG_SHA" .
echo "=== push ==="
for tag in "$TAG_VERSION" "$TAG_LATEST" "$TAG_SHA"; do
docker push "$tag"
echo " pushed: $tag"
done
echo
echo "=== Done (commit $SHA, version $VERSION) ==="
echo "VPS Watchtower farà pull entro WATCHTOWER_POLL_INTERVAL (default 5min)."
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# deploy-vps.sh — deploy Cerbero MCP V2 sul VPS senza passare per registry.
#
# Workflow:
# 1. git fetch + reset al ramo target
# 2. docker compose build (rebuild immagine se SHA è cambiata)
# 3. docker compose down (graceful, max 15s)
# 4. docker compose up -d
# 5. attesa healthcheck su /health
# 6. rollback automatico al SHA precedente se health fallisce
#
# Eseguito ON THE VPS, dentro la directory del repo (es. /opt/cerbero-mcp).
#
# Uso (sul VPS):
# cd /opt/cerbero-mcp
# bash scripts/deploy-vps.sh
#
# Uso (da macchina dev, via SSH):
# ssh user@vps 'cd /opt/cerbero-mcp && bash scripts/deploy-vps.sh'
#
# Variabili env (opzionali):
# BRANCH ramo git da deployare (default: V2.0.0)
# SERVICE nome servizio docker compose (default: cerbero-mcp)
# PORT porta /health da pingare (default: dal .env, fallback 9000)
# HEALTH_TIMEOUT_SECONDS attesa max health (default: 30)
# HEALTH_INTERVAL secondi tra retry health (default: 2)
# FORCE se "1", rebuild + restart anche se SHA invariata
# SKIP_ROLLBACK se "1", non fare rollback su health fail (per debug)
set -euo pipefail
# ─── Config ──────────────────────────────────────────────────────────────
BRANCH="${BRANCH:-V2.0.0}"
SERVICE="${SERVICE:-cerbero-mcp}"
HEALTH_TIMEOUT_SECONDS="${HEALTH_TIMEOUT_SECONDS:-30}"
HEALTH_INTERVAL="${HEALTH_INTERVAL:-2}"
# Risolvi PORT da .env se non passata
if [[ -z "${PORT:-}" ]]; then
if [[ -f .env ]] && grep -q '^PORT=' .env; then
PORT="$(grep '^PORT=' .env | head -1 | cut -d= -f2 | tr -d '[:space:]"')"
fi
fi
PORT="${PORT:-9000}"
HEALTH_URL="http://localhost:${PORT}/health"
# ─── Pre-check ───────────────────────────────────────────────────────────
command -v git >/dev/null || { echo "FATAL: git non installato"; exit 1; }
command -v docker >/dev/null || { echo "FATAL: docker non installato"; exit 1; }
command -v curl >/dev/null || { echo "FATAL: curl non installato"; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "FATAL: docker compose non disponibile"; exit 1; }
if [[ ! -f .env ]]; then
echo "FATAL: .env non trovato in $(pwd)."
echo " Copia .env.example → .env e compila i valori prima del primo deploy."
exit 1
fi
if [[ ! -f docker-compose.yml ]]; then
echo "FATAL: docker-compose.yml non trovato in $(pwd)."
exit 1
fi
# Verifica working tree pulito
if [[ -n "$(git status --porcelain)" ]]; then
echo "FATAL: working tree non pulito. Modifiche locali non gestite:"
git status --short
echo " Risolvi prima di deployare (es. git stash o git reset)."
exit 1
fi
# ─── Stato corrente ──────────────────────────────────────────────────────
CURRENT_SHA="$(git rev-parse --short HEAD)"
echo "==> SHA attuale (rollback target): $CURRENT_SHA"
echo "==> branch: $BRANCH"
echo "==> port: $PORT"
# ─── Fetch + reset ───────────────────────────────────────────────────────
echo "==> git fetch + reset --hard origin/${BRANCH}"
git fetch --prune origin
git reset --hard "origin/${BRANCH}"
NEW_SHA="$(git rev-parse --short HEAD)"
echo "==> SHA nuovo: $NEW_SHA"
if [[ "$CURRENT_SHA" == "$NEW_SHA" ]] && [[ "${FORCE:-0}" != "1" ]]; then
echo "==> Già aggiornato a $NEW_SHA. Nessun deploy necessario."
echo " (esporta FORCE=1 per riavviare comunque)"
exit 0
fi
if [[ "$CURRENT_SHA" == "$NEW_SHA" ]]; then
echo "==> FORCE=1 → rebuild e restart anche se SHA invariata"
fi
# ─── Funzione di rollback ────────────────────────────────────────────────
rollback() {
if [[ "${SKIP_ROLLBACK:-0}" == "1" ]]; then
echo "==> SKIP_ROLLBACK=1 → niente rollback automatico"
return
fi
if [[ "$CURRENT_SHA" == "$NEW_SHA" ]]; then
echo "==> SHA invariata, niente da rollbackare"
return
fi
echo "==> ROLLBACK a $CURRENT_SHA"
git reset --hard "$CURRENT_SHA"
docker compose build "$SERVICE"
docker compose up -d --force-recreate "$SERVICE"
echo "==> rollback eseguito. Verifica manualmente lo stato."
}
# ─── Build ───────────────────────────────────────────────────────────────
echo "==> docker compose build $SERVICE"
docker compose build "$SERVICE"
# ─── Down + up ───────────────────────────────────────────────────────────
echo "==> docker compose down --timeout 15"
docker compose down --timeout 15
echo "==> docker compose up -d"
docker compose up -d
# ─── Health check ────────────────────────────────────────────────────────
echo "==> attendo /health (timeout ${HEALTH_TIMEOUT_SECONDS}s, retry ogni ${HEALTH_INTERVAL}s)"
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECONDS ))
while [[ $(date +%s) -lt $deadline ]]; do
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
echo
echo "==> health OK"
curl -s "$HEALTH_URL"
echo
echo
echo "==> deploy DONE (SHA $CURRENT_SHA$NEW_SHA, branch $BRANCH)"
exit 0
fi
printf "."
sleep "$HEALTH_INTERVAL"
done
echo
echo "==> FAIL: /health non risponde dopo ${HEALTH_TIMEOUT_SECONDS}s"
echo "==> log container (ultime 40 righe):"
docker compose logs --tail 40 "$SERVICE" || true
rollback
exit 1
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""IBKR OAuth 1.0a Self-Service setup helper.
Phases (run in order, providing flags as you progress):
1. python scripts/ibkr_oauth_setup.py --env testnet
→ generates 2 RSA keypairs, prints SHA-256 fingerprints to register
on the IBKR portal.
2. (manual) Login at https://www.interactivebrokers.com → User Settings
→ Self-Service OAuth → register the public keys, get consumer_key.
3. python scripts/ibkr_oauth_setup.py --env testnet --consumer-key <K> \\
--request-token
→ exchanges consumer_key for an unauthorized request token + URL.
4. (manual) Open the URL, approve, copy the verifier code.
5. python scripts/ibkr_oauth_setup.py --env testnet --verifier <V>
→ exchanges verifier for long-lived access_token + secret.
Copy the printed values into .env.
Repeat for --env mainnet using your live IBKR account.
"""
from __future__ import annotations
import argparse
import hashlib
import sys
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def _gen_keypair(out: Path) -> str:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
out.write_bytes(pem)
out.chmod(0o600)
pub = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
pub_path = out.with_suffix(out.suffix + ".pub")
pub_path.write_bytes(pub)
return f"SHA256:{hashlib.sha256(pub).hexdigest()}"
def cmd_init(env: str, secrets_dir: Path) -> int:
secrets_dir.mkdir(parents=True, exist_ok=True)
sig = secrets_dir / f"ibkr_signature_{env}.pem"
enc = secrets_dir / f"ibkr_encryption_{env}.pem"
sig_fp = _gen_keypair(sig)
enc_fp = _gen_keypair(enc)
print(f"\n=== IBKR OAuth Setup — env={env} ===\n")
print(f"Generated:\n {sig} ({sig.stat().st_size} bytes)")
print(f" {enc} ({enc.stat().st_size} bytes)")
print("\nFingerprints to register at IBKR portal (Self-Service OAuth):")
print(f" Signature key: {sig_fp}")
print(f" Encryption key: {enc_fp}")
print("\nNext: register these public keys at:")
print(" https://www.interactivebrokers.com (User Settings → OAuth)")
print("\nAlso paste in .env:")
print(f" IBKR_SIGNATURE_KEY_PATH_{env.upper()}={sig}")
print(f" IBKR_ENCRYPTION_KEY_PATH_{env.upper()}={enc}\n")
return 0
def cmd_request_token(env: str, consumer_key: str) -> int:
print(f"\n=== Step 2 — request token for {env} ===\n")
print(f"Consumer key: {consumer_key}")
print(
"\nVisit this URL in a browser, log in to IBKR, authorize the app,\n"
"and copy the displayed verifier code:\n"
)
print(
f" https://www.interactivebrokers.com/sso/Authenticator?"
f"oauth_consumer_key={consumer_key}&action=request_token\n"
)
print("Then re-run with: --verifier <code>\n")
return 0
def cmd_verifier(env: str, verifier: str) -> int:
print(f"\n=== Step 3 — exchange verifier for {env} ===\n")
print(f"Verifier received: {verifier[:8]}...")
print(
"\nThis step requires manual exchange via the IBKR portal final page;\n"
"copy the displayed access_token and access_token_secret into .env:\n"
)
print(f" IBKR_ACCESS_TOKEN_{env.upper()}=<paste from portal>")
print(f" IBKR_ACCESS_TOKEN_SECRET_{env.upper()}=<paste from portal>\n")
print("Also set:")
print(f" IBKR_CONSUMER_KEY_{env.upper()}=<the consumer key from step 1>")
print(" IBKR_DH_PRIME=<paste DH prime hex from portal>\n")
return 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--env", choices=["testnet", "mainnet"], required=True)
p.add_argument("--secrets-dir", default="secrets")
p.add_argument("--consumer-key")
p.add_argument("--request-token", action="store_true")
p.add_argument("--verifier")
p.add_argument(
"--rotate",
action="store_true",
help="Generate new keypairs alongside existing (for rotation)",
)
args = p.parse_args()
sec_dir = Path(args.secrets_dir)
if args.verifier:
return cmd_verifier(args.env, args.verifier)
if args.consumer_key and args.request_token:
return cmd_request_token(args.env, args.consumer_key)
if args.rotate:
for kind in ("signature", "encryption"):
new = sec_dir / f"ibkr_{kind}_{args.env}.pem.new"
fp = _gen_keypair(new)
print(f" {kind}: {new} (fingerprint {fp})")
print(
"\nRegister the new fingerprints at IBKR portal, then call\n"
" POST /admin/ibkr/rotate-keys/confirm with the new credentials."
)
return 0
return cmd_init(args.env, sec_dir)
if __name__ == "__main__":
sys.exit(main())
+12
View File
@@ -9,20 +9,24 @@ Boot:
""" """
from __future__ import annotations from __future__ import annotations
import contextlib
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Literal, cast from typing import Literal, cast
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from cerbero_mcp import admin
from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.client_registry import ClientRegistry
from cerbero_mcp.common.logging import configure_root_logging from cerbero_mcp.common.logging import configure_root_logging
from cerbero_mcp.exchanges import build_client from cerbero_mcp.exchanges import build_client
from cerbero_mcp.routers import ( from cerbero_mcp.routers import (
alpaca, alpaca,
bybit, bybit,
cross,
deribit, deribit,
hyperliquid, hyperliquid,
ibkr,
macro, macro,
sentiment, sentiment,
) )
@@ -52,6 +56,11 @@ def _make_app(settings: Settings) -> FastAPI:
try: try:
yield yield
finally: finally:
# Stop any IBKR WebSocket singletons before closing client registry
ibkr_ws_dict = getattr(app.state, "ibkr_ws", {}) or {}
for ws in ibkr_ws_dict.values():
with contextlib.suppress(Exception):
await ws.stop()
await app.state.registry.aclose() await app.state.registry.aclose()
app.router.lifespan_context = lifespan app.router.lifespan_context = lifespan
@@ -60,8 +69,11 @@ def _make_app(settings: Settings) -> FastAPI:
app.include_router(bybit.make_router()) app.include_router(bybit.make_router())
app.include_router(hyperliquid.make_router()) app.include_router(hyperliquid.make_router())
app.include_router(alpaca.make_router()) app.include_router(alpaca.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(admin.make_admin_router())
return app return app
+244
View File
@@ -0,0 +1,244 @@
"""Endpoint admin: query audit log con filtri."""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, SecretStr
from cerbero_mcp.exchanges.ibkr.key_rotation import KeyRotationManager
MAX_RECORDS = 10000
DEFAULT_LIMIT = 1000
class _IBKRRotateConfirmReq(BaseModel):
new_consumer_key: str
new_access_token: str
new_access_token_secret: str
def _parse_iso(value: str | None) -> datetime | None:
if not value:
return None
try:
# supporta sia "2026-05-01" sia "2026-05-01T12:34:56Z"
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as e:
raise HTTPException(400, f"invalid datetime: {value}") from e
def _record_timestamp(rec: dict[str, Any]) -> datetime | None:
"""Estrae il timestamp da un record audit. JsonFormatter mette 'asctime'
in formato '2026-05-01 12:34:56,789'. Lo parsiamo come UTC.
"""
ts = rec.get("asctime") or rec.get("timestamp")
if not ts:
return None
try:
# asctime format default: 'YYYY-MM-DD HH:MM:SS,mmm'
ts_clean = ts.replace(",", ".")
return datetime.fromisoformat(ts_clean)
except ValueError:
return None
def _matches_filters(
rec: dict[str, Any],
*,
from_dt: datetime | None,
to_dt: datetime | None,
actor: str | None,
exchange: str | None,
action: str | None,
bot_tag: str | None,
) -> bool:
if rec.get("audit_event") != "write_op":
return False
if actor is not None and rec.get("actor") != actor:
return False
if exchange is not None and rec.get("exchange") != exchange:
return False
if action is not None and rec.get("action") != action:
return False
if bot_tag is not None and rec.get("bot_tag") != bot_tag:
return False
if from_dt is not None or to_dt is not None:
rec_ts = _record_timestamp(rec)
if rec_ts is None:
return False
if from_dt is not None and rec_ts < from_dt:
return False
if to_dt is not None and rec_ts > to_dt:
return False
return True
def _read_audit_records(file_path: Path) -> list[dict[str, Any]]:
if not file_path.exists():
return []
out: list[dict[str, Any]] = []
with file_path.open("r", encoding="utf-8") as f:
for line in f:
stripped = line.strip()
if not stripped:
continue
try:
out.append(json.loads(stripped))
except json.JSONDecodeError:
continue
return out
def make_admin_router() -> APIRouter:
r = APIRouter(prefix="/admin", tags=["admin"])
@r.get("/audit")
async def query_audit(
request: Request,
from_: str | None = Query(None, alias="from"),
to: str | None = Query(None),
actor: Literal["testnet", "mainnet"] | None = Query(None),
exchange: str | None = Query(None),
action: str | None = Query(None),
bot_tag: str | None = Query(None),
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_RECORDS),
) -> dict[str, Any]:
"""Restituisce i record audit_write_op filtrati.
Param query (tutti opzionali):
- from / to: ISO 8601 datetime (es. 2026-05-01 oppure 2026-05-01T12:34:56)
- actor: testnet | mainnet
- exchange: deribit | bybit | hyperliquid | alpaca
- action: nome del tool (es. place_order)
- bot_tag: identificatore bot
- limit: max record da ritornare (default 1000, max 10000)
Source: AUDIT_LOG_FILE (env var). Se non settata, ritorna lista vuota
con warning.
"""
from_dt = _parse_iso(from_)
to_dt = _parse_iso(to)
file_str = os.environ.get("AUDIT_LOG_FILE", "").strip()
if not file_str:
return {
"records": [],
"count": 0,
"warning": "AUDIT_LOG_FILE not configured; no persistent audit log to query",
"from": from_,
"to": to,
}
file_path = Path(file_str)
all_records = _read_audit_records(file_path)
filtered = [
rec for rec in all_records
if _matches_filters(
rec,
from_dt=from_dt, to_dt=to_dt,
actor=actor, exchange=exchange, action=action,
bot_tag=bot_tag,
)
]
# sort desc per timestamp (ultimi prima) + limit
filtered.sort(
key=lambda rec: _record_timestamp(rec) or datetime.min,
reverse=True,
)
if len(filtered) > limit:
filtered = filtered[:limit]
return {
"records": filtered,
"count": len(filtered),
"from": from_,
"to": to,
"filters": {
"actor": actor, "exchange": exchange,
"action": action, "bot_tag": bot_tag,
},
}
@r.post("/ibkr/rotate-keys/start")
async def _ibkr_rotate_start(env: str, request: Request):
if env not in ("testnet", "mainnet"):
raise HTTPException(400, detail={"error": "invalid env"})
settings = request.app.state.settings
creds = settings.ibkr.credentials(env)
mgr = KeyRotationManager(
signature_key_path=creds["signature_key_path"],
encryption_key_path=creds["encryption_key_path"],
)
rotations = getattr(request.app.state, "ibkr_rotations", None)
if rotations is None:
rotations = {}
request.app.state.ibkr_rotations = rotations
rotations[env] = mgr
return await mgr.start()
@r.post("/ibkr/rotate-keys/confirm")
async def _ibkr_rotate_confirm(
env: str, body: _IBKRRotateConfirmReq, request: Request,
):
if env not in ("testnet", "mainnet"):
raise HTTPException(400, detail={"error": "invalid env"})
rotations = getattr(request.app.state, "ibkr_rotations", {}) or {}
mgr = rotations.get(env)
if mgr is None:
raise HTTPException(409, detail={"error": "rotation not started"})
settings = request.app.state.settings
if env == "testnet":
settings.ibkr.consumer_key_testnet = body.new_consumer_key
settings.ibkr.access_token_testnet = body.new_access_token
settings.ibkr.access_token_secret_testnet = SecretStr(body.new_access_token_secret)
else:
settings.ibkr.consumer_key_live = body.new_consumer_key
settings.ibkr.access_token_live = body.new_access_token
settings.ibkr.access_token_secret_live = SecretStr(body.new_access_token_secret)
registry = request.app.state.registry
registry._clients.pop(("ibkr", env), None)
async def _validate() -> bool:
try:
client = await registry.get("ibkr", env)
await client._request("GET", "/iserver/auth/status", skip_tickle=True)
return True
except Exception:
return False
try:
return await mgr.confirm(validate=_validate)
finally:
rotations.pop(env, None)
@r.post("/ibkr/rotate-keys/abort")
async def _ibkr_rotate_abort(env: str, request: Request):
rotations = getattr(request.app.state, "ibkr_rotations", {}) or {}
mgr = rotations.pop(env, None)
if mgr is None:
return {"aborted": False, "reason": "no rotation in progress"}
return await mgr.abort()
@r.post("/ibkr/health")
async def _ibkr_health(request: Request):
registry = request.app.state.registry
out: dict[str, Any] = {}
for env in ("testnet", "mainnet"):
try:
client = await registry.get("ibkr", env)
status = await client._request(
"GET", "/iserver/auth/status", skip_tickle=True
)
out[env] = {"healthy": True, "status": status}
except Exception as e:
out[env] = {"healthy": False, "error": str(e)[:200]}
return out
return r
+50 -4
View File
@@ -1,4 +1,8 @@
"""Bearer auth middleware: bearer token → request.state.environment.""" """Bearer auth middleware: bearer token → request.state.environment.
Inoltre richiede header `X-Bot-Tag` su tutte le chiamate non whitelisted,
così che l'audit log identifichi il bot chiamante.
"""
from __future__ import annotations from __future__ import annotations
import secrets import secrets
@@ -9,7 +13,24 @@ from fastapi.responses import JSONResponse
Environment = Literal["testnet", "mainnet"] Environment = Literal["testnet", "mainnet"]
WHITELIST_PATHS = frozenset({"/health", "/apidocs", "/openapi.json", "/docs", "/redoc"}) # Path che bypassano sia bearer auth sia bot_tag check.
PATH_WHITELIST_FULL = frozenset(
{
"/health",
"/health/ready",
"/apidocs",
"/openapi.json",
"/docs",
"/redoc",
}
)
# Path che richiedono bearer ma NON il bot_tag (admin endpoint).
PATH_WHITELIST_BOT_TAG_ONLY = frozenset({"/admin/audit"})
# Backward-compat alias (vecchi import).
WHITELIST_PATHS = PATH_WHITELIST_FULL
MAX_BOT_TAG_LEN = 64
def _extract_bearer(auth_header: str) -> str | None: def _extract_bearer(auth_header: str) -> str | None:
@@ -35,13 +56,17 @@ def install_auth_middleware(
testnet_token: str, testnet_token: str,
mainnet_token: str, mainnet_token: str,
) -> None: ) -> None:
"""Registra middleware di auth bearer sull'app FastAPI.""" """Registra middleware di auth bearer + bot_tag sull'app FastAPI."""
@app.middleware("http") @app.middleware("http")
async def auth_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next):
if request.url.path in WHITELIST_PATHS: path = request.url.path
# 1. Whitelist totale: nessun check.
if path in PATH_WHITELIST_FULL:
return await call_next(request) return await call_next(request)
# 2. Bearer auth (sempre richiesto).
token = _extract_bearer(request.headers.get("Authorization", "")) token = _extract_bearer(request.headers.get("Authorization", ""))
if token is None: if token is None:
return JSONResponse( return JSONResponse(
@@ -57,4 +82,25 @@ def install_auth_middleware(
"message": "invalid token"}}, "message": "invalid token"}},
) )
request.state.environment = env request.state.environment = env
# 3. Whitelist parziale (admin): bearer ok, no bot_tag check.
if path in PATH_WHITELIST_BOT_TAG_ONLY:
return await call_next(request)
# 4. X-Bot-Tag obbligatorio.
raw_tag = request.headers.get("X-Bot-Tag", "")
tag = raw_tag.strip() if raw_tag else ""
if not tag:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"error": {"code": "BAD_REQUEST",
"message": "missing X-Bot-Tag header"}},
)
if len(tag) > MAX_BOT_TAG_LEN:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"error": {"code": "BAD_REQUEST",
"message": "X-Bot-Tag too long"}},
)
request.state.bot_tag = tag
return await call_next(request) return await call_next(request)
+8
View File
@@ -67,23 +67,28 @@ def _configure_audit_sink() -> None:
def audit_write_op( def audit_write_op(
*, *,
actor: str | None = None, actor: str | None = None,
bot_tag: str | None = None,
action: str, action: str,
exchange: str, exchange: str,
target: str | None = None, target: str | None = None,
payload: dict[str, Any] | None = None, payload: dict[str, Any] | None = None,
result: dict[str, Any] | None = None, result: dict[str, Any] | None = None,
error: str | None = None, error: str | None = None,
request_id: str | None = None,
) -> None: ) -> None:
"""Emit a structured audit log record per write operation. """Emit a structured audit log record per write operation.
actor: identificatore di chi ha invocato (es. "testnet", "mainnet", actor: identificatore di chi ha invocato (es. "testnet", "mainnet",
oppure None per logging anonimo). oppure None per logging anonimo).
bot_tag: identificatore del bot chiamante (header X-Bot-Tag).
action: nome del tool (es. "place_order", "cancel_order"). action: nome del tool (es. "place_order", "cancel_order").
exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid). exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid).
target: instrument/symbol/order_id su cui si agisce. target: instrument/symbol/order_id su cui si agisce.
payload: input non-sensibile (qty, side, leverage, ecc.). payload: input non-sensibile (qty, side, leverage, ecc.).
result: output del client (order_id, status, ecc.). result: output del client (order_id, status, ecc.).
error: stringa errore se l'operazione ha fallito. error: stringa errore se l'operazione ha fallito.
request_id: id propagato dal middleware request log per correlazione
tra audit log e request log.
""" """
_configure_audit_sink() _configure_audit_sink()
record: dict[str, Any] = { record: dict[str, Any] = {
@@ -91,9 +96,12 @@ def audit_write_op(
"action": action, "action": action,
"exchange": exchange, "exchange": exchange,
"actor": actor, "actor": actor,
"bot_tag": bot_tag,
"target": target, "target": target,
"payload": payload or {}, "payload": payload or {},
} }
if request_id is not None:
record["request_id"] = request_id
if result is not None: if result is not None:
record["result"] = _summarize_result(result) record["result"] = _summarize_result(result)
if error is not None: if error is not None:
+100
View File
@@ -0,0 +1,100 @@
"""Helper per cablare audit_write_op nei router.
Pattern uso nel router::
@r.post("/tools/place_order")
async def _place_order(
params: t.PlaceOrderReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client),
):
return await audit_call(
request=request,
exchange="deribit",
action="place_order",
target_field="instrument_name",
params=params,
tool_fn=lambda: t.place_order(client, params, creds=...),
)
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import Request
from pydantic import BaseModel
from cerbero_mcp.common.audit import audit_write_op
def _extract_target(params: BaseModel | None, target_field: str | None) -> str | None:
if params is None or target_field is None:
return None
val = getattr(params, target_field, None)
if val is None:
return None
return str(val)
def _safe_dump(params: BaseModel | None) -> dict[str, Any]:
if params is None:
return {}
try:
return params.model_dump(mode="json", exclude_none=True)
except Exception:
return {}
async def audit_call(
*,
request: Request,
exchange: str,
action: str,
tool_fn: Callable[[], Awaitable[Any]],
params: BaseModel | None = None,
target_field: str | None = None,
) -> Any:
"""Esegue tool_fn e logga audit (success o error). Riraisola eccezioni."""
actor = getattr(request.state, "environment", None)
bot_tag = getattr(request.state, "bot_tag", None)
request_id = getattr(request.state, "request_id", None)
target = _extract_target(params, target_field)
payload = _safe_dump(params)
try:
result = await tool_fn()
except Exception as e:
audit_write_op(
actor=actor,
bot_tag=bot_tag,
action=action,
exchange=exchange,
target=target,
payload=payload,
error=f"{type(e).__name__}: {e}",
request_id=request_id,
)
raise
# Se result è dict, passa raw; altrimenti tenta serializzazione
audit_result: dict[str, Any] | None = None
if isinstance(result, dict):
audit_result = result
elif hasattr(result, "model_dump"):
try:
audit_result = result.model_dump(mode="json")
except Exception:
audit_result = None
audit_write_op(
actor=actor,
bot_tag=bot_tag,
action=action,
exchange=exchange,
target=target,
payload=payload,
result=audit_result,
request_id=request_id,
)
return result
+53
View File
@@ -0,0 +1,53 @@
"""Shared OHLCV candle model + validator for exchange historical endpoints."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
class Candle(BaseModel):
model_config = ConfigDict(extra="ignore")
timestamp: int
open: float
high: float
low: float
close: float
volume: float
@model_validator(mode="after")
def _check(self) -> Candle:
if self.timestamp <= 0:
raise ValueError(f"timestamp must be > 0, got {self.timestamp}")
if self.volume < 0:
raise ValueError(f"volume must be >= 0, got {self.volume}")
if self.high < max(self.open, self.close, self.low):
raise ValueError(
f"high {self.high} < max(open={self.open}, "
f"close={self.close}, low={self.low})"
)
if self.low > min(self.open, self.close, self.high):
raise ValueError(
f"low {self.low} > min(open={self.open}, "
f"close={self.close}, high={self.high})"
)
return self
def validate_candles(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Coerce upstream rows into validated candle dicts, sorted by timestamp.
Raises HTTPException(502) if any row violates OHLC consistency or schema —
upstream data corruption is mapped to a retryable error envelope.
"""
try:
candles = [Candle.model_validate(row) for row in raw]
except ValidationError as e:
raise HTTPException(
status_code=502,
detail=f"upstream returned malformed candle: {e.errors()[0]['msg']}",
) from e
candles.sort(key=lambda c: c.timestamp)
return [c.model_dump() for c in candles]
+104
View File
@@ -0,0 +1,104 @@
"""Middleware: structured JSON request log per ogni HTTP request.
Emette una riga JSON sul logger ``mcp.request`` con campi correlabili
all'audit log via ``request_id``. Espone anche ``request_id`` su
``request.state`` così che handler/exception handler downstream possano
includerlo nei propri payload.
"""
from __future__ import annotations
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from fastapi import FastAPI, Request
from starlette.responses import Response
from cerbero_mcp.common.logging import get_json_logger
_logger = get_json_logger("mcp.request", level=logging.INFO)
def _extract_exchange(path: str) -> str | None:
"""Estrae il nome dell'exchange dal path se è un ``/mcp-{exchange}/...``."""
if not path.startswith("/mcp-"):
return None
rest = path[len("/mcp-"):]
end = rest.find("/")
if end < 0:
return rest or None
return rest[:end] or None
def _extract_tool(path: str) -> str | None:
"""Estrae nome tool dal path ``/mcp-X/tools/Y``."""
parts = path.split("/")
# ["", "mcp-deribit", "tools", "place_order"]
if len(parts) >= 4 and parts[2] == "tools":
return parts[3] or None
return None
def install_request_log_middleware(app: FastAPI) -> None:
"""Aggiunge un middleware HTTP che logga JSON per ogni request."""
@app.middleware("http")
async def request_log(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
request_id = uuid.uuid4().hex
# Espone request_id per uso downstream (audit, error envelope)
request.state.request_id = request_id
t0 = time.perf_counter()
status_code = 500
error: str | None = None
response: Response | None = None
try:
response = await call_next(request)
status_code = response.status_code
except Exception as e:
error = f"{type(e).__name__}: {str(e)[:200]}"
raise
finally:
dur_ms = (time.perf_counter() - t0) * 1000
path = request.url.path
payload: dict[str, Any] = {
"event": "request",
"request_id": request_id,
"method": request.method,
"path": path,
"status_code": status_code,
"duration_ms": round(dur_ms, 2),
"timestamp": datetime.now(UTC).isoformat(),
}
ua = request.headers.get("user-agent")
if ua:
payload["user_agent"] = ua[:200]
client = request.client
if client is not None:
payload["client_ip"] = client.host
actor = getattr(request.state, "environment", None)
if actor:
payload["actor"] = actor
bot_tag = getattr(request.state, "bot_tag", None)
if bot_tag:
payload["bot_tag"] = bot_tag
exchange = _extract_exchange(path)
if exchange:
payload["exchange"] = exchange
tool = _extract_tool(path)
if tool:
payload["tool"] = tool
if error:
payload["error"] = error
_logger.error("request", extra=payload)
else:
_logger.info("request", extra=payload)
# response è settato se non c'è stata eccezione (altrimenti
# l'eccezione è stata già rilanciata dal blocco except).
assert response is not None
return response
+23 -2
View File
@@ -15,9 +15,10 @@ async def build_client(
from cerbero_mcp.exchanges.deribit.client import DeribitClient from cerbero_mcp.exchanges.deribit.client import DeribitClient
url = settings.deribit.url_testnet if env == "testnet" else settings.deribit.url_live url = settings.deribit.url_testnet if env == "testnet" else settings.deribit.url_live
cid, csec = settings.deribit.credentials(env)
return DeribitClient( return DeribitClient(
client_id=settings.deribit.client_id, client_id=cid,
client_secret=settings.deribit.client_secret.get_secret_value(), client_secret=csec,
testnet=(env == "testnet"), testnet=(env == "testnet"),
base_url_override=url, base_url_override=url,
) )
@@ -71,4 +72,24 @@ async def build_client(
cryptopanic_key=settings.sentiment.cryptopanic_key.get_secret_value(), cryptopanic_key=settings.sentiment.cryptopanic_key.get_secret_value(),
lunarcrush_key=settings.sentiment.lunarcrush_key.get_secret_value(), lunarcrush_key=settings.sentiment.lunarcrush_key.get_secret_value(),
) )
if exchange == "ibkr":
from cerbero_mcp.exchanges.ibkr.client import IBKRClient
from cerbero_mcp.exchanges.ibkr.oauth import OAuth1aSigner
creds = settings.ibkr.credentials(env)
url = settings.ibkr.url_testnet if env == "testnet" else settings.ibkr.url_live
signer = OAuth1aSigner(
consumer_key=creds["consumer_key"],
access_token=creds["access_token"],
access_token_secret=creds["access_token_secret"],
signature_key_path=creds["signature_key_path"],
encryption_key_path=creds["encryption_key_path"],
dh_prime=creds["dh_prime"],
)
return IBKRClient(
signer=signer,
account_id=creds["account_id"],
paper=(env == "testnet"),
base_url=url,
)
raise ValueError(f"unsupported exchange: {exchange}") raise ValueError(f"unsupported exchange: {exchange}")
+341 -217
View File
@@ -1,204 +1,253 @@
"""Alpaca client su httpx puro (V2.0.0).
Riscrittura full-REST del client `alpaca-py` originale: 4 endpoint base
(trading, stock data, crypto data, options data), auth via header
APCA-API-KEY-ID / APCA-API-SECRET-KEY, parità completa con la versione V1
(stesse firme, stessa shape dei dict ritornati).
- `base_url` parametro override applica SOLO al trading endpoint
(coerente con `url_override` di alpaca-py.TradingClient). Gli endpoint
data restano hardcoded su `https://data.alpaca.markets`.
- I metodi ritornano `dict` / `list[dict]` direttamente dal JSON REST
(al posto dei modelli pydantic alpaca-py serializzati). Le chiavi sono
quelle restituite dall'API Alpaca; equivalgono al `model_dump()` dei
modelli SDK precedenti.
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime as _dt import datetime as _dt
from typing import Any from typing import Any
from alpaca.data.historical import ( import httpx
CryptoHistoricalDataClient,
OptionHistoricalDataClient,
StockHistoricalDataClient,
)
from alpaca.data.requests import (
CryptoBarsRequest,
CryptoLatestQuoteRequest,
CryptoLatestTradeRequest,
OptionBarsRequest,
OptionChainRequest,
OptionLatestQuoteRequest,
StockBarsRequest,
StockLatestQuoteRequest,
StockLatestTradeRequest,
StockSnapshotRequest,
)
from alpaca.data.timeframe import TimeFrame, TimeFrameUnit
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import (
AssetClass,
OrderSide,
QueryOrderStatus,
TimeInForce,
)
from alpaca.trading.requests import (
ClosePositionRequest,
GetAssetsRequest,
GetOrdersRequest,
LimitOrderRequest,
MarketOrderRequest,
ReplaceOrderRequest,
StopOrderRequest,
)
from cerbero_mcp.common.candles import validate_candles
from cerbero_mcp.common.http import async_client
# ── Endpoint base ────────────────────────────────────────────────
_TRADING_LIVE = "https://api.alpaca.markets"
_TRADING_PAPER = "https://paper-api.alpaca.markets"
_DATA = "https://data.alpaca.markets"
# ── Mappa timeframe → query param Alpaca ─────────────────────────
# Alpaca v2 bars: timeframe = "1Min" / "5Min" / "15Min" / "30Min" / "1Hour" / "1Day" / "1Week"
_TF_MAP = { _TF_MAP = {
"1min": TimeFrame(1, TimeFrameUnit.Minute), "1min": "1Min",
"5min": TimeFrame(5, TimeFrameUnit.Minute), "5min": "5Min",
"15min": TimeFrame(15, TimeFrameUnit.Minute), "15min": "15Min",
"30min": TimeFrame(30, TimeFrameUnit.Minute), "30min": "30Min",
"1h": TimeFrame(1, TimeFrameUnit.Hour), "1h": "1Hour",
"1d": TimeFrame(1, TimeFrameUnit.Day), "1d": "1Day",
"1w": TimeFrame(1, TimeFrameUnit.Week), "1w": "1Week",
} }
_ASSET_CLASSES = {"stocks", "crypto", "options"} _ASSET_CLASS_MAP = {
"stocks": "us_equity",
"crypto": "crypto",
"options": "us_option",
}
def _tf(interval: str) -> TimeFrame: def _tf(interval: str) -> str:
if interval in _TF_MAP: if interval in _TF_MAP:
return _TF_MAP[interval] return _TF_MAP[interval]
raise ValueError(f"unsupported timeframe: {interval}") raise ValueError(f"unsupported timeframe: {interval}")
def _asset_class_enum(ac: str) -> AssetClass: def _asset_class_param(ac: str) -> str:
ac = ac.lower() ac = ac.lower()
if ac == "stocks": if ac in _ASSET_CLASS_MAP:
return AssetClass.US_EQUITY return _ASSET_CLASS_MAP[ac]
if ac == "crypto":
return AssetClass.CRYPTO
if ac == "options":
return AssetClass.US_OPTION
raise ValueError(f"invalid asset_class: {ac}") raise ValueError(f"invalid asset_class: {ac}")
def _serialize(obj: Any) -> Any: def _iso(value: _dt.datetime | _dt.date | None) -> str | None:
"""Recursively convert pydantic/datetime objects → json-safe.""" if value is None:
if obj is None or isinstance(obj, str | int | float | bool): return None
return obj return value.isoformat()
if isinstance(obj, _dt.datetime | _dt.date):
return obj.isoformat()
if isinstance(obj, dict):
return {k: _serialize(v) for k, v in obj.items()}
if isinstance(obj, list | tuple):
return [_serialize(v) for v in obj]
if hasattr(obj, "model_dump"):
return _serialize(obj.model_dump())
if hasattr(obj, "__dict__"):
return _serialize(vars(obj))
return str(obj)
class AlpacaClient: class AlpacaClient:
"""Client httpx-based per Alpaca REST API v2.
Auth via header `APCA-API-KEY-ID` / `APCA-API-SECRET-KEY`.
"""
def __init__( def __init__(
self, self,
api_key: str, api_key: str,
secret_key: str, secret_key: str,
paper: bool = True, paper: bool = True,
base_url: str | None = None, base_url: str | None = None,
trading: Any | None = None, http: httpx.AsyncClient | None = None,
stock_data: Any | None = None,
crypto_data: Any | None = None,
option_data: Any | None = None,
) -> None: ) -> None:
self.api_key = api_key self.api_key = api_key
self.secret_key = secret_key self.secret_key = secret_key
self.paper = paper self.paper = paper
# `base_url` mantenuto come attributo pubblico (test/build_client lo
# leggono). Override del solo endpoint trading; data endpoints sono
# sempre `data.alpaca.markets` (Alpaca non offre paper data feed).
self.base_url = base_url self.base_url = base_url
# alpaca-py TradingClient accetta `url_override` per override URL trading. if base_url:
# Data clients (Stock/Crypto/Option) non supportano url_override sul costruttore; self._trading_base = base_url
# usano endpoint dati separati (data.alpaca.markets) — `base_url` è ignorato per essi. else:
if trading is None: self._trading_base = _TRADING_PAPER if paper else _TRADING_LIVE
trading_kwargs: dict[str, Any] = { self._data_base = _DATA
"api_key": api_key, "secret_key": secret_key, "paper": paper, # Single long-lived AsyncClient → reuse connection pool.
} self._http = http or async_client(timeout=30.0)
if base_url:
trading_kwargs["url_override"] = base_url
trading = TradingClient(**trading_kwargs)
self._trading = trading
self._stock = stock_data or StockHistoricalDataClient(
api_key=api_key, secret_key=secret_key
)
self._crypto = crypto_data or CryptoHistoricalDataClient(
api_key=api_key, secret_key=secret_key
)
self._option = option_data or OptionHistoricalDataClient(
api_key=api_key, secret_key=secret_key
)
async def _run(self, fn, /, *args, **kwargs): async def aclose(self) -> None:
return await asyncio.to_thread(fn, *args, **kwargs) """Chiudi connessioni HTTP. Idempotente."""
if not self._http.is_closed:
await self._http.aclose()
async def health(self) -> dict[str, Any]:
"""Probe minimo per /health/ready: nessuna chiamata di rete."""
return {"status": "ok", "paper": self.paper}
# ── Helpers ──────────────────────────────────────────────────
@property
def _headers(self) -> dict[str, str]:
return {
"APCA-API-KEY-ID": self.api_key,
"APCA-API-SECRET-KEY": self.secret_key,
"Accept": "application/json",
}
async def _request(
self,
method: str,
base: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> Any:
"""Esegue una richiesta HTTP autenticata e ritorna il JSON parsato.
Per response body vuoto (es. DELETE 204) ritorna `{}`.
Solleva `httpx.HTTPStatusError` su 4xx/5xx tramite raise_for_status.
"""
url = f"{base}{path}"
# httpx scarta i query params con valore None automaticamente solo se
# passati come list of tuples; con dict dobbiamo filtrare a monte.
clean_params: dict[str, Any] | None = None
if params is not None:
clean_params = {k: v for k, v in params.items() if v is not None}
if not clean_params:
clean_params = None
resp = await self._http.request(
method,
url,
params=clean_params,
json=json_body,
headers=self._headers,
)
resp.raise_for_status()
if not resp.content:
return {}
return resp.json()
# ── Account / positions ────────────────────────────────────── # ── Account / positions ──────────────────────────────────────
async def get_account(self) -> dict: async def get_account(self) -> dict:
acc = await self._run(self._trading.get_account) data = await self._request("GET", self._trading_base, "/v2/account")
return _serialize(acc) # type: ignore[no-any-return] return dict(data) if data else {}
async def get_positions(self) -> list[dict]: async def get_positions(self) -> list[dict]:
pos = await self._run(self._trading.get_all_positions) data = await self._request("GET", self._trading_base, "/v2/positions")
return [_serialize(p) for p in pos] return list(data) if data else []
async def get_activities(self, limit: int = 50) -> list[dict]: async def get_activities(self, limit: int = 50) -> list[dict]:
acts = await self._run(self._trading.get_account_activities) # type: ignore[union-attr] data = await self._request(
data = [_serialize(a) for a in acts] "GET",
return data[:limit] self._trading_base,
"/v2/account/activities",
params={"page_size": limit},
)
items = list(data) if data else []
return items[:limit]
# ── Assets ────────────────────────────────────────────────── # ── Assets ──────────────────────────────────────────────────
async def get_assets( async def get_assets(
self, asset_class: str = "stocks", status: str = "active" self, asset_class: str = "stocks", status: str = "active"
) -> list[dict]: ) -> list[dict]:
req = GetAssetsRequest( data = await self._request(
asset_class=_asset_class_enum(asset_class), "GET",
status=status, # type: ignore[arg-type] self._trading_base,
"/v2/assets",
params={
"status": status,
"asset_class": _asset_class_param(asset_class),
},
) )
assets = await self._run(self._trading.get_all_assets, req) items = list(data) if data else []
return [_serialize(a) for a in assets[:500]] return items[:500]
# ── Market data ───────────────────────────────────────────── # ── Market data ─────────────────────────────────────────────
async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict: async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict:
ac = asset_class.lower() ac = asset_class.lower()
if ac == "stocks": if ac == "stocks":
req = StockLatestTradeRequest(symbol_or_symbols=symbol) trade_resp = await self._request(
data = await self._run(self._stock.get_stock_latest_trade, req) "GET",
trade = data.get(symbol) self._data_base,
q_req = StockLatestQuoteRequest(symbol_or_symbols=symbol) f"/v2/stocks/{symbol}/trades/latest",
qdata = await self._run(self._stock.get_stock_latest_quote, q_req) )
quote = qdata.get(symbol) quote_resp = await self._request(
"GET",
self._data_base,
f"/v2/stocks/{symbol}/quotes/latest",
)
trade = (trade_resp or {}).get("trade") or {}
quote = (quote_resp or {}).get("quote") or {}
return { return {
"symbol": symbol, "symbol": symbol,
"asset_class": "stocks", "asset_class": "stocks",
"last_price": getattr(trade, "price", None), "last_price": trade.get("p"),
"bid": getattr(quote, "bid_price", None), "bid": quote.get("bp"),
"ask": getattr(quote, "ask_price", None), "ask": quote.get("ap"),
"bid_size": getattr(quote, "bid_size", None), "bid_size": quote.get("bs"),
"ask_size": getattr(quote, "ask_size", None), "ask_size": quote.get("as"),
"timestamp": _serialize(getattr(trade, "timestamp", None)), "timestamp": trade.get("t"),
} }
if ac == "crypto": if ac == "crypto":
req = CryptoLatestTradeRequest(symbol_or_symbols=symbol) # type: ignore[assignment] trade_resp = await self._request(
data = await self._run(self._crypto.get_crypto_latest_trade, req) "GET",
trade = data.get(symbol) self._data_base,
q_req = CryptoLatestQuoteRequest(symbol_or_symbols=symbol) # type: ignore[assignment] "/v1beta3/crypto/us/latest/trades",
qdata = await self._run(self._crypto.get_crypto_latest_quote, q_req) params={"symbols": symbol},
quote = qdata.get(symbol) )
quote_resp = await self._request(
"GET",
self._data_base,
"/v1beta3/crypto/us/latest/quotes",
params={"symbols": symbol},
)
trade = ((trade_resp or {}).get("trades") or {}).get(symbol) or {}
quote = ((quote_resp or {}).get("quotes") or {}).get(symbol) or {}
return { return {
"symbol": symbol, "symbol": symbol,
"asset_class": "crypto", "asset_class": "crypto",
"last_price": getattr(trade, "price", None), "last_price": trade.get("p"),
"bid": getattr(quote, "bid_price", None), "bid": quote.get("bp"),
"ask": getattr(quote, "ask_price", None), "ask": quote.get("ap"),
"timestamp": _serialize(getattr(trade, "timestamp", None)), "timestamp": trade.get("t"),
} }
if ac == "options": if ac == "options":
req = OptionLatestQuoteRequest(symbol_or_symbols=symbol) # type: ignore[assignment] quote_resp = await self._request(
data = await self._run(self._option.get_option_latest_quote, req) "GET",
quote = data.get(symbol) self._data_base,
f"/v1beta1/options/{symbol}/quotes/latest",
)
quote = (quote_resp or {}).get("quote") or {}
return { return {
"symbol": symbol, "symbol": symbol,
"asset_class": "options", "asset_class": "options",
"bid": getattr(quote, "bid_price", None), "bid": quote.get("bp"),
"ask": getattr(quote, "ask_price", None), "ask": quote.get("ap"),
"timestamp": _serialize(getattr(quote, "timestamp", None)), "timestamp": quote.get("t"),
} }
raise ValueError(f"invalid asset_class: {asset_class}") raise ValueError(f"invalid asset_class: {asset_class}")
@@ -212,73 +261,125 @@ class AlpacaClient:
limit: int = 1000, limit: int = 1000,
) -> dict: ) -> dict:
tf = _tf(interval) tf = _tf(interval)
start_dt = _dt.datetime.fromisoformat(start) if start else ( start_dt = (
_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=30) _dt.datetime.fromisoformat(start)
if start
else (_dt.datetime.now(_dt.UTC) - _dt.timedelta(days=30))
) )
end_dt = _dt.datetime.fromisoformat(end) if end else _dt.datetime.now(_dt.UTC) end_dt = _dt.datetime.fromisoformat(end) if end else _dt.datetime.now(_dt.UTC)
ac = asset_class.lower() ac = asset_class.lower()
params: dict[str, Any] = {
"symbols": symbol,
"timeframe": tf,
"start": _iso(start_dt),
"end": _iso(end_dt),
"limit": limit,
}
if ac == "stocks": if ac == "stocks":
req = StockBarsRequest( # IEX feed di default — coerente con default alpaca-py free tier.
symbol_or_symbols=symbol, timeframe=tf, params["feed"] = "iex"
start=start_dt, end=end_dt, limit=limit, data = await self._request(
"GET", self._data_base, "/v2/stocks/bars", params=params
) )
data = await self._run(self._stock.get_stock_bars, req)
elif ac == "crypto": elif ac == "crypto":
req = CryptoBarsRequest( # type: ignore[assignment] data = await self._request(
symbol_or_symbols=symbol, timeframe=tf, "GET",
start=start_dt, end=end_dt, limit=limit, self._data_base,
"/v1beta3/crypto/us/bars",
params=params,
) )
data = await self._run(self._crypto.get_crypto_bars, req)
elif ac == "options": elif ac == "options":
req = OptionBarsRequest( # type: ignore[assignment] data = await self._request(
symbol_or_symbols=symbol, timeframe=tf, "GET",
start=start_dt, end=end_dt, limit=limit, self._data_base,
"/v1beta1/options/bars",
params=params,
) )
data = await self._run(self._option.get_option_bars, req)
else: else:
raise ValueError(f"invalid asset_class: {asset_class}") raise ValueError(f"invalid asset_class: {asset_class}")
bars_dict = getattr(data, "data", {}) or {}
rows = bars_dict.get(symbol, []) or [] bars_dict = (data or {}).get("bars") or {}
bars = [ rows = bars_dict.get(symbol) or []
def _iso_to_ms(ts: str | int | None) -> int | None:
if ts is None or isinstance(ts, int):
return ts
return int(_dt.datetime.fromisoformat(
ts.replace("Z", "+00:00")
).timestamp() * 1000)
candles = validate_candles([
{ {
"timestamp": _serialize(getattr(b, "timestamp", None)), "timestamp": _iso_to_ms(b.get("t")),
"open": getattr(b, "open", None), "open": b.get("o"),
"high": getattr(b, "high", None), "high": b.get("h"),
"low": getattr(b, "low", None), "low": b.get("l"),
"close": getattr(b, "close", None), "close": b.get("c"),
"volume": getattr(b, "volume", None), "volume": b.get("v"),
} }
for b in rows for b in rows
] ])
return {"symbol": symbol, "asset_class": ac, "interval": interval, "bars": bars} return {
"symbol": symbol,
"asset_class": ac,
"interval": interval,
"candles": candles,
}
async def get_snapshot(self, symbol: str) -> dict: async def get_snapshot(self, symbol: str) -> dict:
req = StockSnapshotRequest(symbol_or_symbols=symbol) data = await self._request(
data = await self._run(self._stock.get_stock_snapshot, req) "GET",
return _serialize(data.get(symbol)) # type: ignore[no-any-return] self._data_base,
"/v2/stocks/snapshots",
params={"symbols": symbol},
)
# API ritorna {"AAPL": {snapshot}} o {"snapshots": {...}} — gestiamo
# entrambi i formati; v2/stocks/snapshots ritorna dict top-level
# symbol→snapshot.
if data is None:
return {}
if symbol in data:
return data[symbol] or {}
snaps = data.get("snapshots") or {}
return snaps.get(symbol) or {}
async def get_option_chain( async def get_option_chain(
self, self,
underlying: str, underlying: str,
expiry: str | None = None, expiry: str | None = None,
) -> dict: ) -> dict:
kwargs: dict[str, Any] = {"underlying_symbol": underlying} params: dict[str, Any] = {}
if expiry: if expiry:
kwargs["expiration_date"] = _dt.date.fromisoformat(expiry) # Validazione date (solleva ValueError su input invalido,
req = OptionChainRequest(**kwargs) # parità con V1 che usava _dt.date.fromisoformat).
data = await self._run(self._option.get_option_chain, req) _dt.date.fromisoformat(expiry)
params["expiration_date_gte"] = expiry
params["expiration_date_lte"] = expiry
data = await self._request(
"GET",
self._data_base,
f"/v1beta1/options/snapshots/{underlying}",
params=params or None,
)
contracts = (data or {}).get("snapshots") if data else None
return { return {
"underlying": underlying, "underlying": underlying,
"expiry": expiry, "expiry": expiry,
"contracts": _serialize(data), "contracts": contracts if contracts is not None else (data or {}),
} }
# ── Orders ────────────────────────────────────────────────── # ── Orders ──────────────────────────────────────────────────
async def get_open_orders(self, limit: int = 50) -> list[dict]: async def get_open_orders(self, limit: int = 50) -> list[dict]:
req = GetOrdersRequest(status=QueryOrderStatus.OPEN, limit=limit) data = await self._request(
orders = await self._run(self._trading.get_orders, filter=req) "GET",
return [_serialize(o) for o in orders] self._trading_base,
"/v2/orders",
params={"status": "open", "limit": limit},
)
return list(data) if data else []
async def place_order( async def place_order(
self, self,
@@ -292,32 +393,39 @@ class AlpacaClient:
tif: str = "day", tif: str = "day",
asset_class: str = "stocks", asset_class: str = "stocks",
) -> dict: ) -> dict:
side_enum = OrderSide.BUY if side.lower() == "buy" else OrderSide.SELL
tif_enum = TimeInForce(tif.lower())
ot = order_type.lower() ot = order_type.lower()
common = { body: dict[str, Any] = {
"symbol": symbol, "symbol": symbol,
"side": side_enum, "side": side.lower(),
"time_in_force": tif_enum, "type": ot,
"time_in_force": tif.lower(),
} }
if qty is not None: if qty is not None:
common["qty"] = qty # type: ignore[assignment] body["qty"] = str(qty)
if notional is not None: if notional is not None:
common["notional"] = notional # type: ignore[assignment] body["notional"] = str(notional)
if ot == "market": if ot == "market":
req = MarketOrderRequest(**common) pass
elif ot == "limit": elif ot == "limit":
if limit_price is None: if limit_price is None:
raise ValueError("limit_price required for limit order") raise ValueError("limit_price required for limit order")
req = LimitOrderRequest(**common, limit_price=limit_price) # type: ignore[assignment] body["limit_price"] = str(limit_price)
elif ot == "stop": elif ot == "stop":
if stop_price is None: if stop_price is None:
raise ValueError("stop_price required for stop order") raise ValueError("stop_price required for stop order")
req = StopOrderRequest(**common, stop_price=stop_price) # type: ignore[assignment] body["stop_price"] = str(stop_price)
else: else:
raise ValueError(f"unsupported order_type: {order_type}") raise ValueError(f"unsupported order_type: {order_type}")
order = await self._run(self._trading.submit_order, req) # `asset_class` non è un parametro REST; mantenuto in firma per parità
return _serialize(order) # type: ignore[no-any-return] # con V1 (era usato solo da SDK per scegliere il request model).
_ = asset_class
data = await self._request(
"POST",
self._trading_base,
"/v2/orders",
json_body=body,
)
return dict(data) if data else {}
async def amend_order( async def amend_order(
self, self,
@@ -327,69 +435,85 @@ class AlpacaClient:
stop_price: float | None = None, stop_price: float | None = None,
tif: str | None = None, tif: str | None = None,
) -> dict: ) -> dict:
kwargs: dict[str, Any] = {} body: dict[str, Any] = {}
if qty is not None: if qty is not None:
kwargs["qty"] = qty body["qty"] = str(qty)
if limit_price is not None: if limit_price is not None:
kwargs["limit_price"] = limit_price body["limit_price"] = str(limit_price)
if stop_price is not None: if stop_price is not None:
kwargs["stop_price"] = stop_price body["stop_price"] = str(stop_price)
if tif is not None: if tif is not None:
kwargs["time_in_force"] = TimeInForce(tif.lower()) body["time_in_force"] = tif.lower()
req = ReplaceOrderRequest(**kwargs) data = await self._request(
order = await self._run(self._trading.replace_order_by_id, order_id, req) "PATCH",
return _serialize(order) # type: ignore[no-any-return] self._trading_base,
f"/v2/orders/{order_id}",
json_body=body,
)
return dict(data) if data else {}
async def cancel_order(self, order_id: str) -> dict: async def cancel_order(self, order_id: str) -> dict:
await self._run(self._trading.cancel_order_by_id, order_id) # DELETE /v2/orders/{id} → 204 No Content su success.
await self._request(
"DELETE", self._trading_base, f"/v2/orders/{order_id}"
)
return {"order_id": order_id, "canceled": True} return {"order_id": order_id, "canceled": True}
async def cancel_all_orders(self) -> list[dict]: async def cancel_all_orders(self) -> list[dict]:
resp = await self._run(self._trading.cancel_orders) # DELETE /v2/orders → 207 Multi-Status con array di {id, status}
return [_serialize(r) for r in resp] data = await self._request(
"DELETE", self._trading_base, "/v2/orders"
)
return list(data) if data else []
# ── Position close ────────────────────────────────────────── # ── Position close ──────────────────────────────────────────
async def close_position( async def close_position(
self, symbol: str, qty: float | None = None, percentage: float | None = None self, symbol: str, qty: float | None = None, percentage: float | None = None
) -> dict: ) -> dict:
req = None # DELETE /v2/positions/{symbol}?qty=... oppure ?percentage=...
if qty is not None or percentage is not None: params: dict[str, Any] = {}
kwargs: dict[str, Any] = {} if qty is not None:
if qty is not None: params["qty"] = str(qty)
kwargs["qty"] = str(qty) if percentage is not None:
if percentage is not None: params["percentage"] = str(percentage)
kwargs["percentage"] = str(percentage) data = await self._request(
req = ClosePositionRequest(**kwargs) "DELETE",
order = await self._run( self._trading_base,
self._trading.close_position, symbol, close_options=req f"/v2/positions/{symbol}",
params=params or None,
) )
return _serialize(order) # type: ignore[no-any-return] return dict(data) if data else {}
async def close_all_positions(self, cancel_orders: bool = True) -> list[dict]: async def close_all_positions(self, cancel_orders: bool = True) -> list[dict]:
resp = await self._run( data = await self._request(
self._trading.close_all_positions, cancel_orders=cancel_orders "DELETE",
self._trading_base,
"/v2/positions",
params={"cancel_orders": "true" if cancel_orders else "false"},
) )
return [_serialize(r) for r in resp] return list(data) if data else []
# ── Clock / calendar ──────────────────────────────────────── # ── Clock / calendar ────────────────────────────────────────
async def get_clock(self) -> dict: async def get_clock(self) -> dict:
clock = await self._run(self._trading.get_clock) data = await self._request("GET", self._trading_base, "/v2/clock")
return _serialize(clock) # type: ignore[no-any-return] return dict(data) if data else {}
async def get_calendar( async def get_calendar(
self, start: str | None = None, end: str | None = None self, start: str | None = None, end: str | None = None
) -> list[dict]: ) -> list[dict]:
from alpaca.trading.requests import GetCalendarRequest params: dict[str, Any] = {}
kwargs: dict[str, Any] = {}
if start: if start:
kwargs["start"] = _dt.date.fromisoformat(start) _dt.date.fromisoformat(start) # validazione, parità V1
params["start"] = start
if end: if end:
kwargs["end"] = _dt.date.fromisoformat(end) _dt.date.fromisoformat(end)
req = GetCalendarRequest(**kwargs) if kwargs else None params["end"] = end
cal = await self._run( data = await self._request(
self._trading.get_calendar, filters=req "GET",
) if req else await self._run(self._trading.get_calendar) self._trading_base,
return [_serialize(c) for c in cal] "/v2/calendar",
params=params or None,
)
return list(data) if data else []
+439 -217
View File
@@ -1,12 +1,33 @@
"""Bybit V5 REST API client (httpx puro, no SDK).
Implementazione diretta su `httpx.AsyncClient` per i tool Cerbero MCP V2.
Mantiene parità di interfaccia con la versione precedente basata su
`pybit.unified_trading.HTTP` per non rompere `tools.py` né i router.
Auth Bybit V5:
Header X-BAPI-SIGN = HMAC_SHA256(secret,
timestamp + api_key + recv_window + (body_json | querystring))
"""
from __future__ import annotations from __future__ import annotations
import asyncio import hashlib
import hmac
import json
import time
import uuid
from typing import Any from typing import Any
from urllib.parse import urlencode
from pybit.unified_trading import HTTP import httpx
from cerbero_mcp.common import indicators as ind 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.candles import validate_candles
BASE_MAINNET = "https://api.bybit.com"
BASE_TESTNET = "https://api-testnet.bybit.com"
DEFAULT_RECV_WINDOW = "5000"
DEFAULT_TIMEOUT = 15.0
def _f(v: Any) -> float | None: def _f(v: Any) -> float | None:
@@ -23,37 +44,147 @@ def _i(v: Any) -> int | None:
return None return None
class BybitAPIError(RuntimeError):
"""Errore di trasporto Bybit V5 (non gestito a livello envelope)."""
class BybitClient: class BybitClient:
"""Async REST client per Bybit V5 (linear/inverse/spot/option)."""
def __init__( def __init__(
self, self,
api_key: str, api_key: str,
api_secret: str, api_secret: str,
testnet: bool = True, testnet: bool = True,
http: Any | None = None, http: httpx.AsyncClient | None = None,
base_url: str | None = None, base_url: str | None = None,
) -> None: ) -> None:
self.api_key = api_key self.api_key = api_key
self.api_secret = api_secret self.api_secret = api_secret
self.testnet = testnet self.testnet = testnet
# pybit HTTP non accetta `endpoint` come kwarg (vedi _V5HTTPManager.__init__: self.base_url = base_url or (BASE_TESTNET if testnet else BASE_MAINNET)
# solo `domain`/`tld`/`testnet`). Override URL applicato post-init self.recv_window = DEFAULT_RECV_WINDOW
# sovrascrivendo l'attributo `endpoint` dell'istanza HTTP. # `http` injection è usato dai test per montare un AsyncClient con
self.base_url = base_url # `httpx.MockTransport`. In produzione creiamo un client dedicato.
if http is None: self._owns_http = http is None
http = HTTP( self._http: httpx.AsyncClient = http or httpx.AsyncClient(
api_key=api_key, timeout=DEFAULT_TIMEOUT
api_secret=api_secret, )
testnet=testnet,
)
if base_url:
http.endpoint = base_url
self._http = http
async def _run(self, fn, /, **kwargs): async def aclose(self) -> None:
return await asyncio.to_thread(fn, **kwargs) """Chiude l'AsyncClient httpx se di nostra proprietà."""
if self._owns_http:
await self._http.aclose()
async def health(self) -> dict[str, Any]:
"""Probe minimo per /health/ready: nessuna chiamata di rete."""
return {"status": "ok", "testnet": self.testnet}
# ── auth helpers ───────────────────────────────────────────
def _timestamp_ms(self) -> str:
return str(int(time.time() * 1000))
def _sign(self, timestamp: str, payload: str) -> str:
msg = timestamp + self.api_key + self.recv_window + payload
return hmac.new(
self.api_secret.encode("utf-8"),
msg.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _signed_headers(self, payload: str) -> dict[str, str]:
ts = self._timestamp_ms()
sig = self._sign(ts, payload)
return {
"X-BAPI-API-KEY": self.api_key,
"X-BAPI-TIMESTAMP": ts,
"X-BAPI-RECV-WINDOW": self.recv_window,
"X-BAPI-SIGN": sig,
"Content-Type": "application/json",
}
@staticmethod @staticmethod
def _parse_ticker(row: dict) -> dict: def _clean_params(params: dict[str, Any] | None) -> dict[str, Any]:
if not params:
return {}
return {k: v for k, v in params.items() if v is not None}
@staticmethod
def _querystring(params: dict[str, Any]) -> str:
# Bybit accetta querystring nell'ordine in cui viene serializzata la
# request. Per la signature usiamo lo stesso urlencode (ordine
# inserzione dict). In Python 3.7+ dict mantiene insertion order:
# mantenere coerenza tra signature payload e URL effettivo.
return urlencode(params)
# ── request primitives ─────────────────────────────────────
async def _request_public(
self,
method: str,
path: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
clean = self._clean_params(params)
url = self.base_url + path
resp = await self._http.request(
method, url, params=clean if clean else None
)
return self._parse_response(resp)
async def _request_signed(
self,
method: str,
path: str,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
url = self.base_url + path
method = method.upper()
if method == "GET":
clean = self._clean_params(params)
qs = self._querystring(clean)
headers = self._signed_headers(qs)
resp = await self._http.request(
method, url, params=clean if clean else None, headers=headers
)
else:
payload_body = body or {}
body_json = json.dumps(payload_body, separators=(",", ":"))
headers = self._signed_headers(body_json)
resp = await self._http.request(
method, url, content=body_json, headers=headers
)
return self._parse_response(resp)
@staticmethod
def _parse_response(resp: httpx.Response) -> dict[str, Any]:
try:
data = resp.json()
except Exception as e: # pragma: no cover - difficilmente raggiungibile
raise BybitAPIError(
f"invalid JSON from Bybit (status={resp.status_code}): {resp.text[:200]}"
) from e
if resp.status_code >= 500:
raise BybitAPIError(
f"bybit server error {resp.status_code}: "
f"{data.get('retMsg', resp.text[:200])}"
)
if not isinstance(data, dict):
raise BybitAPIError(f"unexpected payload type: {type(data).__name__}")
return data
def _envelope(self, resp: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
code = resp.get("retCode", 0)
if code != 0:
return {"error": resp.get("retMsg", "bybit_error"), "code": code}
return payload
# ── parsers shared ─────────────────────────────────────────
@staticmethod
def _parse_ticker(row: dict[str, Any]) -> dict[str, Any]:
return { return {
"symbol": row.get("symbol"), "symbol": row.get("symbol"),
"last_price": _f(row.get("lastPrice")), "last_price": _f(row.get("lastPrice")),
@@ -66,9 +197,13 @@ class BybitClient:
"open_interest": _f(row.get("openInterest")), "open_interest": _f(row.get("openInterest")),
} }
# ── market data (public) ───────────────────────────────────
async def get_ticker(self, symbol: str, category: str = "linear") -> dict: async def get_ticker(self, symbol: str, category: str = "linear") -> dict:
resp = await self._run( resp = await self._request_public(
self._http.get_tickers, category=category, symbol=symbol "GET",
"/v5/market/tickers",
params={"category": category, "symbol": symbol},
) )
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
if not rows: if not rows:
@@ -86,8 +221,10 @@ class BybitClient:
async def get_orderbook( async def get_orderbook(
self, symbol: str, category: str = "linear", limit: int = 50 self, symbol: str, category: str = "linear", limit: int = 50
) -> dict: ) -> dict:
resp = await self._run( resp = await self._request_public(
self._http.get_orderbook, category=category, symbol=symbol, limit=limit "GET",
"/v5/market/orderbook",
params={"category": category, "symbol": symbol, "limit": limit},
) )
r = resp.get("result") or {} r = resp.get("result") or {}
return { return {
@@ -106,30 +243,29 @@ class BybitClient:
end: int | None = None, end: int | None = None,
limit: int = 1000, limit: int = 1000,
) -> dict: ) -> dict:
kwargs = dict( params: dict[str, Any] = {
category=category, "category": category,
symbol=symbol, "symbol": symbol,
interval=interval, "interval": interval,
limit=limit, "limit": limit,
) }
if start is not None: if start is not None:
kwargs["start"] = start params["start"] = start
if end is not None: if end is not None:
kwargs["end"] = end params["end"] = end
resp = await self._run(self._http.get_kline, **kwargs) resp = await self._request_public("GET", "/v5/market/kline", params=params)
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
rows_sorted = sorted(rows, key=lambda r: int(r[0])) candles = validate_candles([
candles = [
{ {
"timestamp": int(r[0]), "timestamp": int(r[0]),
"open": float(r[1]), "open": r[1],
"high": float(r[2]), "high": r[2],
"low": float(r[3]), "low": r[3],
"close": float(r[4]), "close": r[4],
"volume": float(r[5]), "volume": r[5],
} }
for r in rows_sorted for r in rows
] ])
return {"symbol": symbol, "candles": candles} return {"symbol": symbol, "candles": candles}
async def get_indicators( async def get_indicators(
@@ -168,8 +304,10 @@ class BybitClient:
return out return out
async def get_funding_rate(self, symbol: str, category: str = "linear") -> dict: async def get_funding_rate(self, symbol: str, category: str = "linear") -> dict:
resp = await self._run( resp = await self._request_public(
self._http.get_tickers, category=category, symbol=symbol "GET",
"/v5/market/tickers",
params={"category": category, "symbol": symbol},
) )
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
if not rows: if not rows:
@@ -184,9 +322,10 @@ class BybitClient:
async def get_funding_history( async def get_funding_history(
self, symbol: str, category: str = "linear", limit: int = 100 self, symbol: str, category: str = "linear", limit: int = 100
) -> dict: ) -> dict:
resp = await self._run( resp = await self._request_public(
self._http.get_funding_rate_history, "GET",
category=category, symbol=symbol, limit=limit, "/v5/market/funding/history",
params={"category": category, "symbol": symbol, "limit": limit},
) )
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
hist = [ hist = [
@@ -205,9 +344,15 @@ class BybitClient:
interval: str = "5min", interval: str = "5min",
limit: int = 288, limit: int = 288,
) -> dict: ) -> dict:
resp = await self._run( resp = await self._request_public(
self._http.get_open_interest, "GET",
category=category, symbol=symbol, intervalTime=interval, limit=limit, "/v5/market/open-interest",
params={
"category": category,
"symbol": symbol,
"intervalTime": interval,
"limit": limit,
},
) )
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
points = [ points = [
@@ -226,71 +371,88 @@ class BybitClient:
"points": points, "points": points,
} }
async def get_instruments(self, category: str = "linear", symbol: str | None = None) -> dict: async def get_instruments(
kwargs: dict[str, Any] = {"category": category} self, category: str = "linear", symbol: str | None = None
) -> dict:
params: dict[str, Any] = {"category": category}
if symbol: if symbol:
kwargs["symbol"] = symbol params["symbol"] = symbol
resp = await self._run(self._http.get_instruments_info, **kwargs) resp = await self._request_public(
"GET", "/v5/market/instruments-info", params=params
)
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
instruments = [] instruments = []
for r in rows: for r in rows:
pf = r.get("priceFilter") or {} pf = r.get("priceFilter") or {}
lf = r.get("lotSizeFilter") or {} lf = r.get("lotSizeFilter") or {}
instruments.append({ instruments.append(
"symbol": r.get("symbol"), {
"status": r.get("status"), "symbol": r.get("symbol"),
"base_coin": r.get("baseCoin"), "status": r.get("status"),
"quote_coin": r.get("quoteCoin"), "base_coin": r.get("baseCoin"),
"tick_size": _f(pf.get("tickSize")), "quote_coin": r.get("quoteCoin"),
"qty_step": _f(lf.get("qtyStep")), "tick_size": _f(pf.get("tickSize")),
"min_qty": _f(lf.get("minOrderQty")), "qty_step": _f(lf.get("qtyStep")),
}) "min_qty": _f(lf.get("minOrderQty")),
}
)
return {"category": category, "instruments": instruments} return {"category": category, "instruments": instruments}
async def get_option_chain(self, base_coin: str, expiry: str | None = None) -> dict: async def get_option_chain(self, base_coin: str, expiry: str | None = None) -> dict:
kwargs: dict[str, Any] = {"category": "option", "baseCoin": base_coin.upper()} resp = await self._request_public(
resp = await self._run(self._http.get_instruments_info, **kwargs) "GET",
"/v5/market/instruments-info",
params={"category": "option", "baseCoin": base_coin.upper()},
)
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
options = [] options = []
for r in rows: for r in rows:
delivery = r.get("deliveryTime") delivery = r.get("deliveryTime")
if expiry and expiry not in r.get("symbol", ""): if expiry and expiry not in r.get("symbol", ""):
continue continue
options.append({ options.append(
"symbol": r.get("symbol"), {
"base_coin": r.get("baseCoin"), "symbol": r.get("symbol"),
"settle_coin": r.get("settleCoin"), "base_coin": r.get("baseCoin"),
"type": r.get("optionsType"), "settle_coin": r.get("settleCoin"),
"launch_time": int(r.get("launchTime", 0)), "type": r.get("optionsType"),
"delivery_time": int(delivery) if delivery else None, "launch_time": int(r.get("launchTime", 0)),
}) "delivery_time": int(delivery) if delivery else None,
}
)
return {"base_coin": base_coin.upper(), "options": options} return {"base_coin": base_coin.upper(), "options": options}
# ── account / positions / orders (signed) ─────────────────
async def get_positions( async def get_positions(
self, category: str = "linear", settle_coin: str = "USDT" self, category: str = "linear", settle_coin: str = "USDT"
) -> list[dict]: ) -> list[dict]:
kwargs: dict[str, Any] = {"category": category} params: dict[str, Any] = {"category": category}
if category in ("linear", "inverse"): if category in ("linear", "inverse"):
kwargs["settleCoin"] = settle_coin params["settleCoin"] = settle_coin
resp = await self._run(self._http.get_positions, **kwargs) resp = await self._request_signed("GET", "/v5/position/list", params=params)
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
out = [] out = []
for r in rows: for r in rows:
out.append({ out.append(
"symbol": r.get("symbol"), {
"side": r.get("side"), "symbol": r.get("symbol"),
"size": _f(r.get("size")), "side": r.get("side"),
"entry_price": _f(r.get("avgPrice")), "size": _f(r.get("size")),
"unrealized_pnl": _f(r.get("unrealisedPnl")), "entry_price": _f(r.get("avgPrice")),
"leverage": _f(r.get("leverage")), "unrealized_pnl": _f(r.get("unrealisedPnl")),
"liquidation_price": _f(r.get("liqPrice")), "leverage": _f(r.get("leverage")),
"position_value": _f(r.get("positionValue")), "liquidation_price": _f(r.get("liqPrice")),
}) "position_value": _f(r.get("positionValue")),
}
)
return out return out
async def get_account_summary(self, account_type: str = "UNIFIED") -> dict: async def get_account_summary(self, account_type: str = "UNIFIED") -> dict:
resp = await self._run( resp = await self._request_signed(
self._http.get_wallet_balance, accountType=account_type "GET",
"/v5/account/wallet-balance",
params={"accountType": account_type},
) )
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
if not rows: if not rows:
@@ -298,11 +460,13 @@ class BybitClient:
a = rows[0] a = rows[0]
coins = [] coins = []
for c in a.get("coin") or []: for c in a.get("coin") or []:
coins.append({ coins.append(
"coin": c.get("coin"), {
"wallet_balance": _f(c.get("walletBalance")), "coin": c.get("coin"),
"equity": _f(c.get("equity")), "wallet_balance": _f(c.get("walletBalance")),
}) "equity": _f(c.get("equity")),
}
)
return { return {
"account_type": a.get("accountType"), "account_type": a.get("accountType"),
"equity": _f(a.get("totalEquity")), "equity": _f(a.get("totalEquity")),
@@ -316,8 +480,10 @@ class BybitClient:
async def get_trade_history( async def get_trade_history(
self, category: str = "linear", limit: int = 50 self, category: str = "linear", limit: int = 50
) -> list[dict]: ) -> list[dict]:
resp = await self._run( resp = await self._request_signed(
self._http.get_executions, category=category, limit=limit "GET",
"/v5/execution/list",
params={"category": category, "limit": limit},
) )
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
return [ return [
@@ -339,12 +505,14 @@ class BybitClient:
symbol: str | None = None, symbol: str | None = None,
settle_coin: str = "USDT", settle_coin: str = "USDT",
) -> list[dict]: ) -> list[dict]:
kwargs: dict[str, Any] = {"category": category} params: dict[str, Any] = {"category": category}
if category in ("linear", "inverse") and not symbol: if category in ("linear", "inverse") and not symbol:
kwargs["settleCoin"] = settle_coin params["settleCoin"] = settle_coin
if symbol: if symbol:
kwargs["symbol"] = symbol params["symbol"] = symbol
resp = await self._run(self._http.get_open_orders, **kwargs) resp = await self._request_signed(
"GET", "/v5/order/realtime", params=params
)
rows = (resp.get("result") or {}).get("list") or [] rows = (resp.get("result") or {}).get("list") or []
return [ return [
{ {
@@ -360,15 +528,20 @@ class BybitClient:
for r in rows for r in rows
] ]
# ── microstructure / basis ─────────────────────────────────
async def get_orderbook_imbalance( async def get_orderbook_imbalance(
self, self,
symbol: str, symbol: str,
category: str = "linear", category: str = "linear",
depth: int = 10, depth: int = 10,
) -> dict: ) -> dict:
"""Microstructure: bid/ask imbalance ratio + microprice + slope.""" ob = await self.get_orderbook(
ob = await self.get_orderbook(symbol=symbol, category=category, limit=max(depth, 50)) symbol=symbol, category=category, limit=max(depth, 50)
result = micro.orderbook_imbalance(ob.get("bids") or [], ob.get("asks") or [], depth=depth) )
result = micro.orderbook_imbalance(
ob.get("bids") or [], ob.get("asks") or [], depth=depth
)
return { return {
"symbol": symbol, "symbol": symbol,
"category": category, "category": category,
@@ -378,9 +551,6 @@ class BybitClient:
} }
async def get_basis_term_structure(self, asset: str) -> dict: async def get_basis_term_structure(self, asset: str) -> dict:
"""Basis curve futures (dated) vs perp + spot. Filtra contratti future
BTCUSDT / ETHUSDT con scadenza, calcola annualized basis per ognuno.
"""
import datetime as _dt import datetime as _dt
asset = asset.upper() asset = asset.upper()
@@ -389,12 +559,13 @@ class BybitClient:
sp = spot.get("last_price") sp = spot.get("last_price")
pp = perp.get("last_price") pp = perp.get("last_price")
# Lista futures dated (linear/inverse)
instr = await self.get_instruments(category="linear") instr = await self.get_instruments(category="linear")
items = (instr.get("instruments") or []) items = instr.get("instruments") or []
futures = [ futures = [
x for x in items x
if x.get("symbol", "").startswith(f"{asset}-") or x.get("symbol", "").startswith(f"{asset}USDT-") for x in items
if x.get("symbol", "").startswith(f"{asset}-")
or x.get("symbol", "").startswith(f"{asset}USDT-")
] ]
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
@@ -409,21 +580,25 @@ class BybitClient:
days = max((int(expiry_ms) - now_ms) / 86_400_000, 1) days = max((int(expiry_ms) - now_ms) / 86_400_000, 1)
basis_pct = 100.0 * (fp - sp) / sp basis_pct = 100.0 * (fp - sp) / sp
annualized = basis_pct * 365.0 / days annualized = basis_pct * 365.0 / days
rows.append({ rows.append(
"symbol": f["symbol"], {
"expiry_ms": int(expiry_ms), "symbol": f["symbol"],
"days_to_expiry": round(days, 2), "expiry_ms": int(expiry_ms),
"future_price": fp, "days_to_expiry": round(days, 2),
"basis_pct": round(basis_pct, 4), "future_price": fp,
"annualized_basis_pct": round(annualized, 4), "basis_pct": round(basis_pct, 4),
}) "annualized_basis_pct": round(annualized, 4),
}
)
rows.sort(key=lambda r: r["days_to_expiry"]) rows.sort(key=lambda r: r["days_to_expiry"])
return { return {
"asset": asset, "asset": asset,
"spot_price": sp, "spot_price": sp,
"perp_price": pp, "perp_price": pp,
"perp_basis_pct": round(100.0 * (pp - sp) / sp, 4) if (sp and pp) else None, "perp_basis_pct": round(100.0 * (pp - sp) / sp, 4)
if (sp and pp)
else None,
"term_structure": rows, "term_structure": rows,
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(), "data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
} }
@@ -449,11 +624,7 @@ class BybitClient:
"funding_rate": perp.get("funding_rate"), "funding_rate": perp.get("funding_rate"),
} }
def _envelope(self, resp: dict, payload: dict) -> dict: # ── trading (signed, write) ────────────────────────────────
code = resp.get("retCode", 0)
if code != 0:
return {"error": resp.get("retMsg", "bybit_error"), "code": code}
return payload
async def place_order( async def place_order(
self, self,
@@ -467,7 +638,7 @@ class BybitClient:
reduce_only: bool = False, reduce_only: bool = False,
position_idx: int | None = None, position_idx: int | None = None,
) -> dict: ) -> dict:
kwargs: dict[str, Any] = { body: dict[str, Any] = {
"category": category, "category": category,
"symbol": symbol, "symbol": symbol,
"side": side, "side": side,
@@ -477,38 +648,34 @@ class BybitClient:
"reduceOnly": reduce_only, "reduceOnly": reduce_only,
} }
if price is not None: if price is not None:
kwargs["price"] = str(price) body["price"] = str(price)
if position_idx is not None: if position_idx is not None:
kwargs["positionIdx"] = position_idx body["positionIdx"] = position_idx
if category == "option": if category == "option":
import uuid body["orderLinkId"] = f"cerbero-{uuid.uuid4().hex[:16]}"
kwargs["orderLinkId"] = f"cerbero-{uuid.uuid4().hex[:16]}" resp = await self._request_signed("POST", "/v5/order/create", body=body)
resp = await self._run(self._http.place_order, **kwargs)
r = resp.get("result") or {} r = resp.get("result") or {}
return self._envelope(resp, { return self._envelope(
"order_id": r.get("orderId"), resp,
"order_link_id": r.get("orderLinkId"), {
"status": "submitted", "order_id": r.get("orderId"),
}) "order_link_id": r.get("orderLinkId"),
"status": "submitted",
},
)
async def place_combo_order( async def place_combo_order(
self, self,
category: str, category: str,
legs: list[dict[str, Any]], legs: list[dict[str, Any]],
) -> dict: ) -> dict:
"""Atomic multi-leg via /v5/order/create-batch (Bybit option only).
Bybit supporta batch_order solo su category='option'. Per perp/linear
usare loop di place_order (non atomic).
legs: [{symbol, side, qty, order_type, price?, tif?, reduce_only?}].
"""
if category != "option": if category != "option":
raise ValueError("place_combo_order: Bybit batch_order è disponibile solo su category='option'") raise ValueError(
"place_combo_order: Bybit batch_order è disponibile solo su category='option'"
)
if len(legs) < 2: if len(legs) < 2:
raise ValueError("combo requires at least 2 legs") raise ValueError("combo requires at least 2 legs")
import uuid
request: list[dict[str, Any]] = [] request: list[dict[str, Any]] = []
for leg in legs: for leg in legs:
entry: dict[str, Any] = { entry: dict[str, Any] = {
@@ -524,7 +691,10 @@ class BybitClient:
entry["price"] = str(leg["price"]) entry["price"] = str(leg["price"])
request.append(entry) request.append(entry)
resp = await self._run(self._http.place_batch_order, category=category, request=request) body = {"category": category, "request": request}
resp = await self._request_signed(
"POST", "/v5/order/create-batch", body=body
)
result_list = (resp.get("result") or {}).get("list") or [] result_list = (resp.get("result") or {}).get("list") or []
orders = [ orders = [
{ {
@@ -544,80 +714,112 @@ class BybitClient:
new_qty: float | None = None, new_qty: float | None = None,
new_price: float | None = None, new_price: float | None = None,
) -> dict: ) -> dict:
kwargs: dict[str, Any] = { body: dict[str, Any] = {
"category": category, "category": category,
"symbol": symbol, "symbol": symbol,
"orderId": order_id, "orderId": order_id,
} }
if new_qty is not None: if new_qty is not None:
kwargs["qty"] = str(new_qty) body["qty"] = str(new_qty)
if new_price is not None: if new_price is not None:
kwargs["price"] = str(new_price) body["price"] = str(new_price)
resp = await self._run(self._http.amend_order, **kwargs) resp = await self._request_signed("POST", "/v5/order/amend", body=body)
r = resp.get("result") or {} r = resp.get("result") or {}
return self._envelope(resp, { return self._envelope(
"order_id": r.get("orderId", order_id), resp,
"status": "amended", {
}) "order_id": r.get("orderId", order_id),
"status": "amended",
async def cancel_order( },
self, category: str, symbol: str, order_id: str
) -> dict:
resp = await self._run(
self._http.cancel_order,
category=category, symbol=symbol, orderId=order_id,
) )
async def cancel_order(self, category: str, symbol: str, order_id: str) -> dict:
body = {"category": category, "symbol": symbol, "orderId": order_id}
resp = await self._request_signed("POST", "/v5/order/cancel", body=body)
r = resp.get("result") or {} r = resp.get("result") or {}
return self._envelope(resp, { return self._envelope(
"order_id": r.get("orderId", order_id), resp,
"status": "cancelled", {
}) "order_id": r.get("orderId", order_id),
"status": "cancelled",
},
)
async def cancel_all_orders( async def cancel_all_orders(
self, category: str, symbol: str | None = None self, category: str, symbol: str | None = None
) -> dict: ) -> dict:
kwargs: dict[str, Any] = {"category": category} body: dict[str, Any] = {"category": category}
if symbol: if symbol:
kwargs["symbol"] = symbol body["symbol"] = symbol
resp = await self._run(self._http.cancel_all_orders, **kwargs) resp = await self._request_signed(
"POST", "/v5/order/cancel-all", body=body
)
r = resp.get("result") or {} r = resp.get("result") or {}
ids = [x.get("orderId") for x in (r.get("list") or [])] ids = [x.get("orderId") for x in (r.get("list") or [])]
return self._envelope(resp, { return self._envelope(
"cancelled_ids": ids, resp,
"count": len(ids), {
}) "cancelled_ids": ids,
"count": len(ids),
},
)
async def set_stop_loss( async def set_stop_loss(
self, category: str, symbol: str, stop_loss: float, self,
category: str,
symbol: str,
stop_loss: float,
position_idx: int = 0, position_idx: int = 0,
) -> dict: ) -> dict:
resp = await self._run( body = {
self._http.set_trading_stop, "category": category,
category=category, symbol=symbol, "symbol": symbol,
stopLoss=str(stop_loss), positionIdx=position_idx, "stopLoss": str(stop_loss),
"positionIdx": position_idx,
}
resp = await self._request_signed(
"POST", "/v5/position/trading-stop", body=body
)
return self._envelope(
resp,
{
"symbol": symbol,
"stop_loss": stop_loss,
"status": "stop_loss_set",
},
) )
return self._envelope(resp, {
"symbol": symbol, "stop_loss": stop_loss,
"status": "stop_loss_set",
})
async def set_take_profit( async def set_take_profit(
self, category: str, symbol: str, take_profit: float, self,
category: str,
symbol: str,
take_profit: float,
position_idx: int = 0, position_idx: int = 0,
) -> dict: ) -> dict:
resp = await self._run( body = {
self._http.set_trading_stop, "category": category,
category=category, symbol=symbol, "symbol": symbol,
takeProfit=str(take_profit), positionIdx=position_idx, "takeProfit": str(take_profit),
"positionIdx": position_idx,
}
resp = await self._request_signed(
"POST", "/v5/position/trading-stop", body=body
)
return self._envelope(
resp,
{
"symbol": symbol,
"take_profit": take_profit,
"status": "take_profit_set",
},
) )
return self._envelope(resp, {
"symbol": symbol, "take_profit": take_profit,
"status": "take_profit_set",
})
async def close_position(self, category: str, symbol: str) -> dict: async def close_position(self, category: str, symbol: str) -> dict:
positions = await self.get_positions(category=category) positions = await self.get_positions(category=category)
target = next((p for p in positions if p["symbol"] == symbol and (p["size"] or 0) > 0), None) target = next(
(p for p in positions if p["symbol"] == symbol and (p["size"] or 0) > 0),
None,
)
if not target: if not target:
return {"error": "no_open_position", "symbol": symbol} return {"error": "no_open_position", "symbol": symbol}
close_side = "Sell" if target["side"] == "Buy" else "Buy" close_side = "Sell" if target["side"] == "Buy" else "Buy"
@@ -634,28 +836,44 @@ class BybitClient:
async def set_leverage( async def set_leverage(
self, category: str, symbol: str, leverage: int self, category: str, symbol: str, leverage: int
) -> dict: ) -> dict:
resp = await self._run( body = {
self._http.set_leverage, "category": category,
category=category, symbol=symbol, "symbol": symbol,
buyLeverage=str(leverage), sellLeverage=str(leverage), "buyLeverage": str(leverage),
"sellLeverage": str(leverage),
}
resp = await self._request_signed(
"POST", "/v5/position/set-leverage", body=body
)
return self._envelope(
resp,
{
"symbol": symbol,
"leverage": leverage,
"status": "leverage_set",
},
) )
return self._envelope(resp, {
"symbol": symbol, "leverage": leverage,
"status": "leverage_set",
})
async def switch_position_mode( async def switch_position_mode(
self, category: str, symbol: str, mode: str self, category: str, symbol: str, mode: str
) -> dict: ) -> dict:
mode_code = 3 if mode.lower() == "hedge" else 0 mode_code = 3 if mode.lower() == "hedge" else 0
resp = await self._run( body = {
self._http.switch_position_mode, "category": category,
category=category, symbol=symbol, mode=mode_code, "symbol": symbol,
"mode": mode_code,
}
resp = await self._request_signed(
"POST", "/v5/position/switch-mode", body=body
)
return self._envelope(
resp,
{
"symbol": symbol,
"mode": mode,
"status": "mode_switched",
},
) )
return self._envelope(resp, {
"symbol": symbol, "mode": mode,
"status": "mode_switched",
})
async def transfer_asset( async def transfer_asset(
self, self,
@@ -664,19 +882,23 @@ class BybitClient:
from_type: str, from_type: str,
to_type: str, to_type: str,
) -> dict: ) -> dict:
import uuid body = {
resp = await self._run( "transferId": str(uuid.uuid4()),
self._http.create_internal_transfer, "coin": coin,
transferId=str(uuid.uuid4()), "amount": str(amount),
coin=coin, "fromAccountType": from_type,
amount=str(amount), "toAccountType": to_type,
fromAccountType=from_type, }
toAccountType=to_type, resp = await self._request_signed(
"POST", "/v5/asset/transfer/inter-transfer", body=body
) )
r = resp.get("result") or {} r = resp.get("result") or {}
return self._envelope(resp, { return self._envelope(
"transfer_id": r.get("transferId"), resp,
"coin": coin, {
"amount": amount, "transfer_id": r.get("transferId"),
"status": "submitted", "coin": coin,
}) "amount": amount,
"status": "submitted",
},
)
-2
View File
@@ -359,7 +359,6 @@ async def place_order(
reduce_only=params.reduce_only, reduce_only=params.reduce_only,
position_idx=params.position_idx, position_idx=params.position_idx,
) )
# TODO V2: wire audit via request.state.environment in router
return result return result
@@ -370,7 +369,6 @@ async def place_combo_order(
category=params.category, category=params.category,
legs=[leg.model_dump() for leg in params.legs], legs=[leg.model_dump() for leg in params.legs],
) )
# TODO V2: wire audit via request.state.environment in router
return result return result
+146
View File
@@ -0,0 +1,146 @@
"""Cross-exchange historical aggregator.
Fan-out a canonical (symbol, asset_class, interval, start, end) request to
every active exchange that supports the pair, then merge the results into
a single consensus candle series with per-bar divergence metrics.
"""
from __future__ import annotations
import asyncio
import datetime as _dt
from typing import Any, Literal, Protocol
from fastapi import HTTPException
from cerbero_mcp.exchanges.cross.consensus import merge_candles
from cerbero_mcp.exchanges.cross.symbol_map import (
get_sources,
supported_intervals,
to_native_interval,
to_native_symbol,
)
Environment = Literal["testnet", "mainnet"]
class _Registry(Protocol):
async def get(self, exchange: str, env: Environment) -> Any: ...
def _iso_to_ms(s: str) -> int:
return int(_dt.datetime.fromisoformat(
s.replace("Z", "+00:00")
).timestamp() * 1000)
async def _call_bybit(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_historical(
symbol=sym, category="linear", interval=interval,
start=_iso_to_ms(start), end=_iso_to_ms(end),
)
return resp
async def _call_hyperliquid(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_historical(
instrument=sym, start_date=start, end_date=end, resolution=interval,
)
return resp
async def _call_deribit(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_historical(
instrument=sym, start_date=start, end_date=end, resolution=interval,
)
return resp
async def _call_alpaca(client: Any, sym: str, interval: str,
start: str, end: str) -> dict[str, Any]:
resp: dict[str, Any] = await client.get_bars(
symbol=sym, asset_class="stocks", interval=interval,
start=start, end=end,
)
return resp
_DISPATCH = {
"bybit": _call_bybit,
"hyperliquid": _call_hyperliquid,
"deribit": _call_deribit,
"alpaca": _call_alpaca,
}
class CrossClient:
def __init__(self, registry: _Registry, *, env: Environment):
self._registry = registry
self._env = env
async def _fetch_one(
self, exchange: str, native_sym: str, native_interval: str,
start: str, end: str,
) -> tuple[str, list[dict[str, Any]] | Exception]:
try:
client = await self._registry.get(exchange, self._env)
resp = await _DISPATCH[exchange](
client, native_sym, native_interval, start, end,
)
return exchange, resp.get("candles", [])
except Exception as e: # noqa: BLE001
return exchange, e
async def get_historical(
self, *, symbol: str, asset_class: str, interval: str,
start_date: str, end_date: str,
) -> dict[str, Any]:
sources = get_sources(asset_class, symbol)
if not sources:
raise HTTPException(
status_code=400,
detail=f"unsupported symbol/asset_class: {symbol} ({asset_class})",
)
if interval not in supported_intervals():
raise HTTPException(
status_code=400,
detail=f"unsupported interval: {interval}; "
f"supported: {supported_intervals()}",
)
tasks = [
self._fetch_one(
ex,
to_native_symbol(asset_class, symbol, ex),
to_native_interval(interval, ex),
start_date, end_date,
)
for ex in sources
]
results = await asyncio.gather(*tasks)
by_source: dict[str, list[dict[str, Any]]] = {}
failed: list[dict[str, str]] = []
for ex, payload in results:
if isinstance(payload, Exception):
failed.append({"exchange": ex, "error": f"{type(payload).__name__}: {payload}"})
else:
by_source[ex] = payload
if not by_source:
raise HTTPException(
status_code=502,
detail={"error": "all sources failed", "failed_sources": failed},
)
return {
"symbol": symbol.upper(),
"asset_class": asset_class,
"interval": interval,
"candles": merge_candles(by_source),
"sources_used": sorted(by_source.keys()),
"failed_sources": failed,
}
@@ -0,0 +1,37 @@
"""Pure consensus aggregation: merge per-source OHLCV candles by timestamp.
The output is a single time-series with the median OHLC across sources,
mean volume, the contributing source count, and a divergence % computed
on the close range. div_pct gives a quick quality signal: 0 means full
agreement, > X% means at least one source is suspect.
"""
from __future__ import annotations
from collections import defaultdict
from statistics import median
from typing import Any
def merge_candles(by_source: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
for candles in by_source.values():
for c in candles:
grouped[int(c["timestamp"])].append(c)
out: list[dict[str, Any]] = []
for ts in sorted(grouped):
rows = grouped[ts]
closes = [float(r["close"]) for r in rows]
med_close = float(median(closes))
div_pct = (max(closes) - min(closes)) / med_close if med_close else 0.0
out.append({
"timestamp": ts,
"open": float(median(float(r["open"]) for r in rows)),
"high": float(median(float(r["high"]) for r in rows)),
"low": float(median(float(r["low"]) for r in rows)),
"close": med_close,
"volume": sum(float(r["volume"]) for r in rows) / len(rows),
"sources": len(rows),
"div_pct": div_pct,
})
return out
@@ -0,0 +1,60 @@
"""Routing table: canonical (asset_class, symbol, interval) → per-exchange native.
Crypto canonical symbols default to USD/USDT-quoted perpetuals on the most
liquid pair available. Equities currently route to Alpaca only — IBKR is
omitted from the cross MVP because its bars endpoint takes a relative
period instead of (start, end).
"""
from __future__ import annotations
AssetClass = str
_CRYPTO_SYMBOLS: dict[str, dict[str, str]] = {
"BTC": {"bybit": "BTCUSDT", "hyperliquid": "BTC", "deribit": "BTC-PERPETUAL"},
"ETH": {"bybit": "ETHUSDT", "hyperliquid": "ETH", "deribit": "ETH-PERPETUAL"},
"SOL": {"bybit": "SOLUSDT", "hyperliquid": "SOL"},
}
_STOCK_SYMBOLS: dict[str, dict[str, str]] = {
"AAPL": {"alpaca": "AAPL"},
"SPY": {"alpaca": "SPY"},
"QQQ": {"alpaca": "QQQ"},
"TSLA": {"alpaca": "TSLA"},
"NVDA": {"alpaca": "NVDA"},
}
_SYMBOLS: dict[AssetClass, dict[str, dict[str, str]]] = {
"crypto": _CRYPTO_SYMBOLS,
"stocks": _STOCK_SYMBOLS,
}
_INTERVALS: dict[str, dict[str, str]] = {
"1m": {"bybit": "1", "hyperliquid": "1m", "deribit": "1m", "alpaca": "1m"},
"5m": {"bybit": "5", "hyperliquid": "5m", "deribit": "5m", "alpaca": "5m"},
"15m": {"bybit": "15", "hyperliquid": "15m", "deribit": "15m", "alpaca": "15m"},
"1h": {"bybit": "60", "hyperliquid": "1h", "deribit": "1h", "alpaca": "1h"},
"4h": {"bybit": "240", "hyperliquid": "4h", "deribit": "4h", "alpaca": "4h"},
"1d": {"bybit": "D", "hyperliquid": "1d", "deribit": "1d", "alpaca": "1d"},
}
def get_sources(asset_class: AssetClass, symbol: str) -> list[str]:
table = _SYMBOLS.get(asset_class, {})
mapping = table.get(symbol.upper())
if mapping is None:
return []
return list(mapping.keys())
def to_native_symbol(
asset_class: AssetClass, symbol: str, exchange: str
) -> str:
return _SYMBOLS[asset_class][symbol.upper()][exchange]
def to_native_interval(interval: str, exchange: str) -> str:
return _INTERVALS[interval][exchange]
def supported_intervals() -> list[str]:
return list(_INTERVALS.keys())
+28
View File
@@ -0,0 +1,28 @@
"""Pydantic schemas + thin tool wrappers for the /mcp-cross router."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
from cerbero_mcp.exchanges.cross.client import CrossClient
AssetClass = Literal["crypto", "stocks"]
class GetHistoricalReq(BaseModel):
symbol: str
asset_class: AssetClass = "crypto"
interval: str = "1h"
start_date: str
end_date: str
async def get_historical(client: CrossClient, params: GetHistoricalReq) -> dict:
return await client.get_historical(
symbol=params.symbol,
asset_class=params.asset_class,
interval=params.interval,
start_date=params.start_date,
end_date=params.end_date,
)
+64 -24
View File
@@ -1,15 +1,37 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import json
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from fastapi import HTTPException
from cerbero_mcp.common import indicators as ind 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.http import async_client from cerbero_mcp.common.http import async_client
def _parse_deribit_response(resp: Any) -> dict[str, Any]:
"""Map Deribit upstream errors to a clean HTTP 502 (retryable) instead of
leaking JSONDecodeError when the body is HTML (e.g. Cloudflare 5xx page)."""
if resp.status_code >= 500:
raise HTTPException(
status_code=502,
detail=f"Deribit upstream HTTP {resp.status_code}",
)
try:
data: dict[str, Any] = resp.json()
return data
except json.JSONDecodeError as e:
raise HTTPException(
status_code=502,
detail=f"Deribit upstream returned non-JSON (status {resp.status_code})",
) from e
BASE_LIVE = "https://www.deribit.com/api/v2" BASE_LIVE = "https://www.deribit.com/api/v2"
BASE_TESTNET = "https://test.deribit.com/api/v2" BASE_TESTNET = "https://test.deribit.com/api/v2"
@@ -23,6 +45,10 @@ RESOLUTION_MAP = {
} }
class DeribitAuthError(Exception):
"""Deribit auth failed (bad credentials, missing scope, env mismatch)."""
@dataclass @dataclass
class DeribitClient: class DeribitClient:
client_id: str client_id: str
@@ -49,7 +75,14 @@ class DeribitClient:
} }
async with async_client(timeout=15.0) as http: async with async_client(timeout=15.0) as http:
resp = await http.get(url, params=params) resp = await http.get(url, params=params)
data = resp.json() data = _parse_deribit_response(resp)
if "result" not in data:
error = data.get("error", {})
msg = error.get("message", str(data)) if isinstance(error, dict) else str(error)
code = error.get("code") if isinstance(error, dict) else None
raise DeribitAuthError(
f"Deribit auth failed (code={code}, env={'testnet' if self.testnet else 'mainnet'}): {msg}"
)
result = data["result"] result = data["result"]
self._token = result["access_token"] self._token = result["access_token"]
self._token_expires_at = time.monotonic() + result.get("expires_in", 900) - 30 self._token_expires_at = time.monotonic() + result.get("expires_in", 900) - 30
@@ -63,7 +96,10 @@ class DeribitClient:
async def _request(self, method: str, params: dict[str, Any] | None = None) -> dict: async def _request(self, method: str, params: dict[str, Any] | None = None) -> dict:
is_private = method.startswith("private/") is_private = method.startswith("private/")
if is_private: if is_private:
await self._get_token() try:
await self._get_token()
except DeribitAuthError as e:
return {"result": None, "error": str(e)}
url = f"{self.base_url}/{method}" url = f"{self.base_url}/{method}"
request_params = dict(params) if params else {} request_params = dict(params) if params else {}
@@ -73,7 +109,7 @@ class DeribitClient:
async with async_client(timeout=15.0) as http: async with async_client(timeout=15.0) as http:
resp = await http.get(url, params=request_params, headers=headers) resp = await http.get(url, params=request_params, headers=headers)
data = resp.json() data = _parse_deribit_response(resp)
if "result" not in data: if "result" not in data:
error = data.get("error", {}) error = data.get("error", {})
@@ -85,18 +121,22 @@ class DeribitClient:
await self._authenticate() await self._authenticate()
headers["Authorization"] = f"Bearer {self._token}" headers["Authorization"] = f"Bearer {self._token}"
resp = await http.get(url, params=request_params, headers=headers) resp = await http.get(url, params=request_params, headers=headers)
data = resp.json() data = _parse_deribit_response(resp)
if "result" in data: if "result" in data:
return data # type: ignore[no-any-return] return data
return {"result": None, "error": error_msg} return {"result": None, "error": error_msg}
return data # type: ignore[no-any-return] return data
# ── Read tools ─────────────────────────────────────────────── # ── Read tools ───────────────────────────────────────────────
def is_testnet(self) -> dict: def is_testnet(self) -> dict:
return {"testnet": self.testnet, "base_url": self.base_url} return {"testnet": self.testnet, "base_url": self.base_url}
async def health(self) -> dict:
"""Probe minimo per /health/ready: nessuna chiamata di rete."""
return {"status": "ok", "testnet": self.testnet}
async def get_ticker(self, instrument_name: str) -> dict: async def get_ticker(self, instrument_name: str) -> dict:
import datetime as _dt import datetime as _dt
raw = await self._request("public/ticker", {"instrument_name": instrument_name}) raw = await self._request("public/ticker", {"instrument_name": instrument_name})
@@ -303,12 +343,12 @@ class DeribitClient:
r = raw.get("result") r = raw.get("result")
if not r: if not r:
return { return {
"equity": 0, "equity": None,
"balance": 0, "balance": None,
"margin_balance": 0, "margin_balance": None,
"available_funds": 0, "available_funds": None,
"unrealized_pnl": 0, "unrealized_pnl": None,
"total_pnl": 0, "total_pnl": None,
"testnet": self.testnet, "testnet": self.testnet,
"error": raw.get("error", "no result"), "error": raw.get("error", "no result"),
} }
@@ -380,24 +420,24 @@ class DeribitClient:
}, },
) )
r = raw.get("result") or {} r = raw.get("result") or {}
candles = []
ticks = r.get("ticks", []) or [] ticks = r.get("ticks", []) or []
opens = r.get("open", []) or [] opens = r.get("open", []) or []
highs = r.get("high", []) or [] highs = r.get("high", []) or []
lows = r.get("low", []) or [] lows = r.get("low", []) or []
closes = r.get("close", []) or [] closes = r.get("close", []) or []
volumes = r.get("volume", []) or [] volumes = r.get("volume", []) or []
for idx, ts in enumerate(ticks): n = min(len(ticks), len(opens), len(highs), len(lows), len(closes), len(volumes))
if idx >= min(len(opens), len(highs), len(lows), len(closes), len(volumes)): candles = validate_candles([
break {
candles.append({ "timestamp": ticks[i],
"timestamp": ts, "open": opens[i],
"open": opens[idx], "high": highs[i],
"high": highs[idx], "low": lows[i],
"low": lows[idx], "close": closes[i],
"close": closes[idx], "volume": volumes[i],
"volume": volumes[idx], }
}) for i in range(n)
])
return {"candles": candles} return {"candles": candles}
async def get_dvol( async def get_dvol(
@@ -481,7 +481,6 @@ async def place_order(
post_only=params.post_only, post_only=params.post_only,
label=params.label, label=params.label,
) )
# TODO V2: wire audit via request.state.environment in router
return result return result
@@ -502,29 +501,24 @@ async def place_combo_order(
price=params.price, price=params.price,
label=params.label, label=params.label,
) )
# TODO V2: wire audit via request.state.environment in router
return result return result
async def cancel_order(client: DeribitClient, params: CancelOrderReq) -> dict: async def cancel_order(client: DeribitClient, params: CancelOrderReq) -> dict:
result = await client.cancel_order(params.order_id) result = await client.cancel_order(params.order_id)
# TODO V2: wire audit via request.state.environment in router
return result return result
async def set_stop_loss(client: DeribitClient, params: SetStopLossReq) -> dict: async def set_stop_loss(client: DeribitClient, params: SetStopLossReq) -> dict:
result = await client.set_stop_loss(params.order_id, params.stop_price) result = await client.set_stop_loss(params.order_id, params.stop_price)
# TODO V2: wire audit via request.state.environment in router
return result return result
async def set_take_profit(client: DeribitClient, params: SetTakeProfitReq) -> dict: async def set_take_profit(client: DeribitClient, params: SetTakeProfitReq) -> dict:
result = await client.set_take_profit(params.order_id, params.tp_price) result = await client.set_take_profit(params.order_id, params.tp_price)
# TODO V2: wire audit via request.state.environment in router
return result return result
async def close_position(client: DeribitClient, params: ClosePositionReq) -> dict: async def close_position(client: DeribitClient, params: ClosePositionReq) -> dict:
result = await client.close_position(params.instrument_name) result = await client.close_position(params.instrument_name)
# TODO V2: wire audit via request.state.environment in router
return result return result
+347 -122
View File
@@ -1,12 +1,33 @@
"""Hyperliquid REST API client for perpetual futures trading.""" """Hyperliquid REST API client for perpetual futures trading.
Pure ``httpx`` + ``eth-account`` implementation: no dependency on
``hyperliquid-python-sdk``. Read endpoints hit ``POST /info`` (no auth);
write endpoints hit ``POST /exchange`` and require an EIP-712 L1 signature.
The signing scheme is bit-for-bit equivalent to the canonical SDK:
action_hash = keccak( msgpack(action) || nonce[u64 BE] || vault_marker
|| (expires_after marker || expires_after[u64 BE])? )
phantom = {"source": "a"|"b", "connectionId": action_hash} # a=mainnet, b=testnet
EIP-712 domain: name="Exchange", version="1", chainId=1337,
verifyingContract=0x0
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import datetime as _dt import datetime as _dt
import time as _time
from decimal import Decimal
from typing import Any from typing import Any
import httpx
import msgpack
from eth_account import Account
from eth_account.messages import encode_typed_data
from eth_utils import keccak, to_hex
from cerbero_mcp.common import indicators as ind from cerbero_mcp.common import indicators as ind
from cerbero_mcp.common.candles import validate_candles
from cerbero_mcp.common.http import async_client from cerbero_mcp.common.http import async_client
BASE_LIVE = "https://api.hyperliquid.xyz" BASE_LIVE = "https://api.hyperliquid.xyz"
@@ -21,14 +42,8 @@ RESOLUTION_MAP = {
"1d": "1d", "1d": "1d",
} }
try: # Slippage usato per market order / market_close (parità con SDK).
from eth_account import Account DEFAULT_SLIPPAGE = 0.05
from hyperliquid.exchange import Exchange
from hyperliquid.utils import constants as hl_constants
_SDK_AVAILABLE = True
except ImportError: # pragma: no cover
_SDK_AVAILABLE = False
def _to_ms(date_str: str) -> int: def _to_ms(date_str: str) -> int:
@@ -39,11 +54,91 @@ def _to_ms(date_str: str) -> int:
return int(dt.timestamp() * 1000) return int(dt.timestamp() * 1000)
class HyperliquidClient: def _float_to_wire(x: float) -> str:
"""Async client for the Hyperliquid API. """Convert a price/size float to Hyperliquid wire string format.
Read operations use direct HTTP calls via httpx against /info. 8 decimal places, no trailing zeros (matching SDK ``float_to_wire``).
Write operations delegate to hyperliquid-python-sdk for EIP-712 signing. """
rounded = f"{x:.8f}"
if abs(float(rounded) - x) >= 1e-12:
raise ValueError("float_to_wire causes rounding", x)
if rounded == "-0":
rounded = "0"
normalized = Decimal(rounded).normalize()
return f"{normalized:f}"
def _address_to_bytes(address: str) -> bytes:
return bytes.fromhex(address.removeprefix("0x"))
def _action_hash(
action: Any,
vault_address: str | None,
nonce: int,
expires_after: int | None,
) -> bytes:
"""Deterministic action hash (msgpack + nonce + vault + expires)."""
data = msgpack.packb(action)
data += nonce.to_bytes(8, "big")
if vault_address is None:
data += b"\x00"
else:
data += b"\x01"
data += _address_to_bytes(vault_address)
if expires_after is not None:
data += b"\x00"
data += expires_after.to_bytes(8, "big")
return keccak(data)
def _l1_payload(phantom_agent: dict[str, Any]) -> dict[str, Any]:
return {
"domain": {
"chainId": 1337,
"name": "Exchange",
"verifyingContract": "0x0000000000000000000000000000000000000000",
"version": "1",
},
"types": {
"Agent": [
{"name": "source", "type": "string"},
{"name": "connectionId", "type": "bytes32"},
],
"EIP712Domain": [
{"name": "name", "type": "string"},
{"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"},
],
},
"primaryType": "Agent",
"message": phantom_agent,
}
def _sign_l1_action(
private_key: str,
action: Any,
vault_address: str | None,
nonce: int,
expires_after: int | None,
is_mainnet: bool,
) -> dict[str, Any]:
h = _action_hash(action, vault_address, nonce, expires_after)
phantom_agent = {"source": "a" if is_mainnet else "b", "connectionId": h}
payload = _l1_payload(phantom_agent)
encoded = encode_typed_data(full_message=payload)
signed = Account.from_key(private_key).sign_message(encoded)
return {"r": to_hex(signed["r"]), "s": to_hex(signed["s"]), "v": signed["v"]}
class HyperliquidClient:
"""Async client for the Hyperliquid REST API.
Read operations call ``POST /info`` directly via ``httpx``.
Write operations build an EIP-712 L1 signature in-process (no SDK)
and call ``POST /exchange``.
""" """
def __init__( def __init__(
@@ -63,53 +158,99 @@ class HyperliquidClient:
self.base_url = base_url self.base_url = base_url
else: else:
self.base_url = BASE_TESTNET if testnet else BASE_LIVE self.base_url = BASE_TESTNET if testnet else BASE_LIVE
self._exchange: Any | None = None self._is_mainnet = self.base_url == BASE_LIVE
self.vault_address: str | None = None
# Persistent async client (riutilizzato per /exchange e /info).
self._http: httpx.AsyncClient | None = None
# Cache name → asset id (perp universe).
self._name_to_asset: dict[str, int] | None = None
# ── SDK exchange (lazy) ──────────────────────────────────── async def aclose(self) -> None:
"""Close the underlying HTTP client (if any)."""
def _get_exchange(self) -> Any: if self._http is not None:
"""Return (and cache) an SDK Exchange instance for write ops.""" await self._http.aclose()
if not _SDK_AVAILABLE: self._http = None
raise RuntimeError(
"hyperliquid-python-sdk is not installed; write operations unavailable."
)
if self._exchange is None:
account = Account.from_key(self.private_key)
if self._base_url_override:
sdk_base_url = self._base_url_override
else:
sdk_base_url = (
hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL
)
empty_spot_meta: dict[str, Any] = {"universe": [], "tokens": []}
self._exchange = Exchange(
account,
sdk_base_url,
account_address=self.wallet_address,
spot_meta=empty_spot_meta,
)
return self._exchange
# ── Internal helpers ─────────────────────────────────────── # ── Internal helpers ───────────────────────────────────────
async def _post(self, payload: dict[str, Any]) -> Any: async def _post_info(self, payload: dict[str, Any]) -> Any:
"""POST JSON to the /info endpoint.""" """POST a JSON payload to ``/info`` (read-only, no auth)."""
async with async_client(timeout=15.0) as http: async with async_client(timeout=15.0) as http:
resp = await http.post(f"{self.base_url}/info", json=payload) resp = await http.post(f"{self.base_url}/info", json=payload)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
# backward-compat alias (interno).
async def _post(self, payload: dict[str, Any]) -> Any:
return await self._post_info(payload)
async def _post_exchange(
self,
action: dict[str, Any],
nonce: int | None = None,
vault_address: str | None = None,
) -> Any:
"""Sign and POST an action to ``/exchange``."""
if nonce is None:
nonce = int(_time.time() * 1000)
vault = vault_address if vault_address is not None else self.vault_address
signature = _sign_l1_action(
self.private_key,
action,
vault,
nonce,
None, # expires_after: not used here
self._is_mainnet,
)
payload: dict[str, Any] = {
"action": action,
"nonce": nonce,
"signature": signature,
"vaultAddress": vault,
"expiresAfter": None,
}
async with async_client(timeout=15.0) as http:
resp = await http.post(f"{self.base_url}/exchange", json=payload)
resp.raise_for_status()
return resp.json()
async def _name_to_asset_id(self, name: str) -> int:
"""Resolve a perp coin name (e.g. ``BTC``) to its asset id.
The asset id is the index in the ``meta.universe`` array. Cached
per-client; refreshed if the requested name is missing.
"""
upper = name.upper()
if self._name_to_asset is None or upper not in self._name_to_asset:
meta = await self._post_info({"type": "meta"})
universe = meta.get("universe", [])
self._name_to_asset = {
m["name"].upper(): idx for idx, m in enumerate(universe)
}
if upper not in self._name_to_asset:
raise ValueError(f"Unknown asset: {name}")
return self._name_to_asset[upper]
@staticmethod @staticmethod
async def _run_sync(func: Any, *args: Any, **kwargs: Any) -> Any: def _order_type_to_wire(order_type: dict[str, Any]) -> dict[str, Any]:
"""Run a synchronous SDK call in the default executor.""" if "limit" in order_type:
loop = asyncio.get_event_loop() return {"limit": order_type["limit"]}
return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) if "trigger" in order_type:
t = order_type["trigger"]
return {
"trigger": {
"isMarket": t["isMarket"],
"triggerPx": _float_to_wire(float(t["triggerPx"])),
"tpsl": t["tpsl"],
}
}
raise ValueError("Invalid order type", order_type)
# ── Read tools ───────────────────────────────────────────── # ── Read tools ─────────────────────────────────────────────
async def get_markets(self) -> list[dict[str, Any]]: async def get_markets(self) -> list[dict[str, Any]]:
"""List all perp markets with metadata and current stats.""" """List all perp markets with metadata and current stats."""
data = await self._post({"type": "metaAndAssetCtxs"}) data = await self._post_info({"type": "metaAndAssetCtxs"})
universe = data[0]["universe"] universe = data[0]["universe"]
ctx_list = data[1] ctx_list = data[1]
markets = [] markets = []
@@ -144,7 +285,7 @@ class HyperliquidClient:
async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]: async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]:
"""Get L2 order book for an asset.""" """Get L2 order book for an asset."""
data = await self._post({"type": "l2Book", "coin": instrument.upper()}) data = await self._post_info({"type": "l2Book", "coin": instrument.upper()})
levels = data.get("levels", [[], []]) levels = data.get("levels", [[], []])
bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]] bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]]
asks = [{"price": float(a["px"]), "size": float(a["sz"])} for a in levels[1][:depth]] asks = [{"price": float(a["px"]), "size": float(a["sz"])} for a in levels[1][:depth]]
@@ -152,7 +293,7 @@ class HyperliquidClient:
async def get_positions(self) -> list[dict[str, Any]]: async def get_positions(self) -> list[dict[str, Any]]:
"""Get open positions for the wallet.""" """Get open positions for the wallet."""
data = await self._post( data = await self._post_info(
{"type": "clearinghouseState", "user": self.wallet_address} {"type": "clearinghouseState", "user": self.wallet_address}
) )
positions = [] positions = []
@@ -184,9 +325,9 @@ class HyperliquidClient:
"""Get account summary (equity, balance, margin) including spot balances. """Get account summary (equity, balance, margin) including spot balances.
Con Unified Account, spot USDC e perps condividono collaterale. Con Unified Account, spot USDC e perps condividono collaterale.
`spot_fetch_ok` / `perps_fetch_ok` indicano se i due lati sono stati ``spot_fetch_ok`` / ``perps_fetch_ok`` indicano se i due lati sono stati
letti correttamente: se uno dei due è False il chiamante dovrebbe letti correttamente: se uno dei due è False il chiamante dovrebbe
considerare `equity`/`available_balance` un lower bound. considerare ``equity``/``available_balance`` un lower bound.
""" """
perps_fetch_ok = True perps_fetch_ok = True
perps_equity = 0.0 perps_equity = 0.0
@@ -194,7 +335,7 @@ class HyperliquidClient:
margin_used = 0.0 margin_used = 0.0
unrealized_pnl = 0.0 unrealized_pnl = 0.0
try: try:
data = await self._post( data = await self._post_info(
{"type": "clearinghouseState", "user": self.wallet_address} {"type": "clearinghouseState", "user": self.wallet_address}
) )
margin = data.get("marginSummary") or {} margin = data.get("marginSummary") or {}
@@ -208,7 +349,7 @@ class HyperliquidClient:
spot_fetch_ok = True spot_fetch_ok = True
spot_usdc = 0.0 spot_usdc = 0.0
try: try:
spot_data = await self._post( spot_data = await self._post_info(
{"type": "spotClearinghouseState", "user": self.wallet_address} {"type": "spotClearinghouseState", "user": self.wallet_address}
) )
for b in spot_data.get("balances", []) or []: for b in spot_data.get("balances", []) or []:
@@ -233,7 +374,9 @@ class HyperliquidClient:
async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]: async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]:
"""Get recent trade fills.""" """Get recent trade fills."""
data = await self._post({"type": "userFills", "user": self.wallet_address}) data = await self._post_info(
{"type": "userFills", "user": self.wallet_address}
)
trades = [] trades = []
for t in data[:limit]: for t in data[:limit]:
trades.append( trades.append(
@@ -255,7 +398,7 @@ class HyperliquidClient:
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)
data = await self._post( data = await self._post_info(
{ {
"type": "candleSnapshot", "type": "candleSnapshot",
"req": { "req": {
@@ -266,23 +409,24 @@ class HyperliquidClient:
}, },
} }
) )
candles = [] candles = validate_candles([
for c in data: {
candles.append( "timestamp": c.get("t"),
{ "open": c.get("o"),
"timestamp": c.get("t", 0), "high": c.get("h"),
"open": float(c.get("o", 0)), "low": c.get("l"),
"high": float(c.get("h", 0)), "close": c.get("c"),
"low": float(c.get("l", 0)), "volume": c.get("v"),
"close": float(c.get("c", 0)), }
"volume": float(c.get("v", 0)), for c in data
} ])
)
return {"candles": candles} 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."""
data = await self._post({"type": "openOrders", "user": self.wallet_address}) data = await self._post_info(
{"type": "openOrders", "user": self.wallet_address}
)
orders = [] orders = []
for o in data: for o in data:
orders.append( orders.append(
@@ -326,7 +470,7 @@ class HyperliquidClient:
# Perp price + funding from HL # Perp price + funding from HL
try: try:
ctx = await self._post({"type": "metaAndAssetCtxs"}) ctx = await self._post_info({"type": "metaAndAssetCtxs"})
universe = ctx[0]["universe"] universe = ctx[0]["universe"]
ctx_list = ctx[1] ctx_list = ctx[1]
perp_price = None perp_price = None
@@ -375,7 +519,7 @@ class HyperliquidClient:
async def get_funding_rate(self, instrument: str) -> dict[str, Any]: async def get_funding_rate(self, instrument: str) -> dict[str, Any]:
"""Get current and recent historical funding rates for an asset.""" """Get current and recent historical funding rates for an asset."""
data = await self._post({"type": "metaAndAssetCtxs"}) data = await self._post_info({"type": "metaAndAssetCtxs"})
universe = data[0]["universe"] universe = data[0]["universe"]
ctx_list = data[1] ctx_list = data[1]
current_rate = None current_rate = None
@@ -389,7 +533,7 @@ class HyperliquidClient:
# Fetch funding history (last 7 days) # Fetch funding history (last 7 days)
end_ms = int(_dt.datetime.utcnow().timestamp() * 1000) end_ms = int(_dt.datetime.utcnow().timestamp() * 1000)
start_ms = end_ms - 7 * 24 * 3600 * 1000 start_ms = end_ms - 7 * 24 * 3600 * 1000
history_data = await self._post( history_data = await self._post_info(
{ {
"type": "fundingHistory", "type": "fundingHistory",
"coin": instrument.upper(), "coin": instrument.upper(),
@@ -443,44 +587,10 @@ class HyperliquidClient:
result[name] = None result[name] = None
return result return result
# ── Write tools (via SDK) ────────────────────────────────── # ── Write tools (signed) ──────────────────────────────────
async def place_order(
self,
instrument: str,
side: str,
amount: float,
type: str = "limit",
price: float | None = None,
reduce_only: bool = False,
) -> dict[str, Any]:
"""Place an order on Hyperliquid using the SDK for EIP-712 signing."""
exchange = self._get_exchange()
is_buy = side.lower() in ("buy", "long")
coin = instrument.upper()
if type == "market":
ot: dict[str, Any] = {"limit": {"tif": "Ioc"}}
if price is None:
ticker = await self.get_ticker(coin)
mark = ticker.get("mark_price", 0)
price = round(mark * 1.03, 1) if is_buy else round(mark * 0.97, 1)
elif type in ("stop_market", "stop_loss"):
assert price is not None
ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "sl"}}
elif type == "take_profit":
assert price is not None
ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "tp"}}
else:
ot = {"limit": {"tif": "Gtc"}}
if price is None:
return {"error": "price is required for limit orders"}
result = await self._run_sync(
exchange.order, coin, is_buy, amount, price, ot, reduce_only
)
@staticmethod
def _parse_order_response(result: dict[str, Any]) -> dict[str, Any]:
status = result.get("status", "unknown") status = result.get("status", "unknown")
response = result.get("response", {}) response = result.get("response", {})
if isinstance(response, str): if isinstance(response, str):
@@ -491,7 +601,6 @@ class HyperliquidClient:
"filled_size": 0, "filled_size": 0,
"avg_fill_price": 0, "avg_fill_price": 0,
} }
statuses = response.get("data", {}).get("statuses", [{}]) statuses = response.get("data", {}).get("statuses", [{}])
first = statuses[0] if statuses else {} first = statuses[0] if statuses else {}
if isinstance(first, str): if isinstance(first, str):
@@ -511,12 +620,95 @@ class HyperliquidClient:
"avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)), "avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)),
} }
async def place_order(
self,
instrument: str,
side: str,
amount: float,
type: str = "limit",
price: float | None = None,
reduce_only: bool = False,
) -> dict[str, Any]:
"""Place an order on Hyperliquid (signed via EIP-712)."""
is_buy = side.lower() in ("buy", "long")
coin = instrument.upper()
if type == "market":
order_type: dict[str, Any] = {"limit": {"tif": "Ioc"}}
if price is None:
ticker = await self.get_ticker(coin)
mark = ticker.get("mark_price", 0)
price = round(mark * 1.03, 1) if is_buy else round(mark * 0.97, 1)
elif type in ("stop_market", "stop_loss"):
assert price is not None
order_type = {
"trigger": {
"triggerPx": float(price),
"isMarket": True,
"tpsl": "sl",
}
}
elif type == "take_profit":
assert price is not None
order_type = {
"trigger": {
"triggerPx": float(price),
"isMarket": True,
"tpsl": "tp",
}
}
else:
order_type = {"limit": {"tif": "Gtc"}}
if price is None:
return {"error": "price is required for limit orders"}
try:
asset_id = await self._name_to_asset_id(coin)
except ValueError as exc:
return {"error": str(exc), "order_id": "", "filled_size": 0, "avg_fill_price": 0}
order_wire: dict[str, Any] = {
"a": asset_id,
"b": is_buy,
"p": _float_to_wire(float(price)),
"s": _float_to_wire(float(amount)),
"r": reduce_only,
"t": self._order_type_to_wire(order_type),
}
action: dict[str, Any] = {
"type": "order",
"orders": [order_wire],
"grouping": "na",
}
try:
result = await self._post_exchange(action)
except httpx.HTTPError as exc:
return {
"status": "error",
"error": str(exc),
"order_id": "",
"filled_size": 0,
"avg_fill_price": 0,
}
return self._parse_order_response(result)
async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]: async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]:
"""Cancel an existing order using the SDK.""" """Cancel an existing order via signed ``cancel`` action."""
exchange = self._get_exchange() try:
result = await self._run_sync( asset_id = await self._name_to_asset_id(instrument)
exchange.cancel, instrument.upper(), int(order_id) except ValueError as exc:
) return {"order_id": order_id, "status": "error", "error": str(exc)}
action: dict[str, Any] = {
"type": "cancel",
"cancels": [{"a": asset_id, "o": int(order_id)}],
}
try:
result = await self._post_exchange(action)
except httpx.HTTPError as exc:
return {"order_id": order_id, "status": "error", "error": str(exc)}
status = result.get("status", "unknown") status = result.get("status", "unknown")
response = result.get("response", "") response = result.get("response", "")
if isinstance(response, str) and status == "err": if isinstance(response, str) and status == "err":
@@ -526,8 +718,7 @@ class HyperliquidClient:
async def set_stop_loss( async def set_stop_loss(
self, instrument: str, stop_price: float, size: float self, instrument: str, stop_price: float, size: float
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Set a stop-loss trigger order.""" """Set a stop-loss trigger order (reduce-only)."""
# Determine direction by checking open position
positions = await self.get_positions() positions = await self.get_positions()
direction = "sell" # default: assume long direction = "sell" # default: assume long
for pos in positions: for pos in positions:
@@ -548,7 +739,7 @@ class HyperliquidClient:
async def set_take_profit( async def set_take_profit(
self, instrument: str, tp_price: float, size: float self, instrument: str, tp_price: float, size: float
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Set a take-profit trigger order.""" """Set a take-profit trigger order (reduce-only)."""
positions = await self.get_positions() positions = await self.get_positions()
direction = "sell" # default: assume long direction = "sell" # default: assume long
for pos in positions: for pos in positions:
@@ -567,21 +758,55 @@ class HyperliquidClient:
) )
async def close_position(self, instrument: str) -> dict[str, Any]: async def close_position(self, instrument: str) -> dict[str, Any]:
"""Close an open position for the given asset using market_close.""" """Close an open position using an aggressive IOC reduce-only order."""
exchange = self._get_exchange() coin = instrument.upper()
try: try:
result = await self._run_sync(exchange.market_close, instrument.upper()) data = await self._post_info(
{"type": "clearinghouseState", "user": self.wallet_address}
)
target = None
for ap in data.get("assetPositions", []):
pos = ap.get("position", {})
if (pos.get("coin") or "").upper() == coin:
target = pos
break
if target is None:
return {"error": f"No open position for {instrument}", "asset": instrument}
szi = float(target.get("szi", 0) or 0)
if szi == 0:
return {"error": f"No open position for {instrument}", "asset": instrument}
sz = abs(szi)
is_buy = szi < 0 # short → buy to close
# Slippage price: usa mark price * (1±slippage), arrotonda a 5 sig figs.
ticker = await self.get_ticker(coin)
mark = float(ticker.get("mark_price", 0) or 0)
if mark <= 0:
return {"error": "missing mark price for slippage calc", "asset": instrument}
px = mark * (1 + DEFAULT_SLIPPAGE) if is_buy else mark * (1 - DEFAULT_SLIPPAGE)
px = round(float(f"{px:.5g}"), 6)
result = await self.place_order(
instrument=coin,
side="buy" if is_buy else "sell",
amount=sz,
type="limit",
price=px,
reduce_only=True,
)
return { return {
"status": result.get("status", "unknown"), "status": result.get("status", "ok"),
"asset": instrument, "asset": instrument,
**{k: v for k, v in result.items() if k != "status"},
} }
except Exception as exc: except Exception as exc:
return {"error": str(exc), "asset": instrument} return {"error": str(exc), "asset": instrument}
async def health(self) -> dict[str, Any]: async def health(self) -> dict[str, Any]:
"""Health check — ping /info for server status.""" """Health check — ping ``/info`` for server status."""
try: try:
await self._post({"type": "meta"}) await self._post_info({"type": "meta"})
return {"status": "ok", "testnet": self.testnet} return {"status": "ok", "testnet": self.testnet}
except Exception as exc: except Exception as exc:
return {"status": "error", "error": str(exc)} return {"status": "error", "error": str(exc)}
@@ -303,7 +303,6 @@ async def place_order(
price=params.price, price=params.price,
reduce_only=params.reduce_only, reduce_only=params.reduce_only,
) )
# TODO V2: wire audit via request.state.environment in router
return result return result
+433
View File
@@ -0,0 +1,433 @@
"""IBKR Client Portal Web API client (REST httpx + OAuth1a)."""
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any
import httpx
from cerbero_mcp.common.candles import validate_candles
from cerbero_mcp.common.http import async_client
from cerbero_mcp.exchanges.ibkr.oauth import (
IBKRAuthError,
OAuth1aSigner,
_percent_encode,
)
logger = logging.getLogger(__name__)
class IBKRError(Exception):
"""Generic IBKR API error (non-auth)."""
_TICKLE_INTERVAL_S = 240.0 # tickle if last call > 4min ago
# Mapping asset_class (cerbero/MCP convention) → IBKR secType.
_SEC_TYPE_MAP: dict[str, str] = {
"stocks": "STK",
"options": "OPT",
"futures": "FUT",
"forex": "CASH",
}
@dataclass
class IBKRClient:
signer: OAuth1aSigner
account_id: str
paper: bool = True
base_url: str = "https://api.ibkr.com/v1/api"
_conid_cache: OrderedDict[str, int] = field(
default_factory=OrderedDict, init=False, repr=False
)
_last_request_at: float = field(default=0.0, init=False, repr=False)
_http: httpx.AsyncClient | None = field(default=None, init=False, repr=False)
_CONID_CACHE_MAX = 1024
def __post_init__(self) -> None:
# IBKR Client Portal gateway latency is higher than crypto exchanges
# (lookup roundtrip + session validation); 30s matches Alpaca's choice.
self._http = async_client(timeout=30.0)
async def aclose(self) -> None:
if self._http and not self._http.is_closed:
await self._http.aclose()
async def health(self) -> dict[str, Any]:
return {"status": "ok", "paper": self.paper}
def is_testnet(self) -> dict[str, Any]:
return {"testnet": self.paper, "base_url": self.base_url}
async def _build_auth_header(self, method: str, url: str) -> str:
await self.signer.get_live_session_token(base_url=self.base_url)
params = self.signer.make_oauth_params()
params["oauth_signature_method"] = "HMAC-SHA256"
sig = self.signer.sign_with_lst(method, url, params)
params["oauth_signature"] = sig
return "OAuth realm=\"limited_poa\", " + ", ".join(
f'{k}="{_percent_encode(v)}"' for k, v in sorted(params.items())
)
async def _maybe_tickle(self) -> None:
if time.monotonic() - self._last_request_at < _TICKLE_INTERVAL_S:
return
if self._http is None: # pragma: no cover
return
try:
url = f"{self.base_url}/tickle"
auth = await self._build_auth_header("POST", url)
await self._http.post(url, headers={"Authorization": auth})
except Exception as exc:
# Best-effort: failure shouldn't block the real request, but log
# so misconfigured signer / dead session aren't invisible.
logger.debug("ibkr tickle best-effort failed: %s", exc)
async def _force_lst_refresh(self) -> None:
"""Invalidate cached LST and remint on next call."""
self.signer._live_session_token = None
self.signer._lst_expires_at = 0.0
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
skip_tickle: bool = False,
_retried_auth: bool = False,
) -> Any:
if self._http is None: # pragma: no cover — set in __post_init__
raise IBKRError("http client not initialized")
if not skip_tickle:
await self._maybe_tickle()
url = f"{self.base_url}{path}"
auth = await self._build_auth_header(method, url)
clean_params = (
{k: v for k, v in params.items() if v is not None}
if params else None
)
resp = await self._http.request(
method, url,
params=clean_params or None,
json=json_body,
headers={"Authorization": auth, "User-Agent": "cerbero-mcp/2.0"},
)
self._last_request_at = time.monotonic()
if resp.status_code == 401 and not _retried_auth:
# Retry once with fresh LST (per spec: IBKR_AUTH_FAILED retryable)
logger.info("ibkr 401 on %s %s — refreshing LST and retrying once", method, path)
await self._force_lst_refresh()
return await self._request(
method, path, params=params, json_body=json_body,
skip_tickle=skip_tickle, _retried_auth=True,
)
if resp.status_code == 401:
raise IBKRAuthError(f"401 on {method} {path} (after retry): {resp.text[:200]}")
if resp.status_code == 429:
raise IBKRError(f"IBKR_RATE_LIMITED: {resp.text[:200]}")
if resp.status_code >= 500:
raise IBKRError(f"IBKR_SERVER_ERROR status={resp.status_code}")
if resp.status_code >= 400:
raise IBKRError(f"IBKR_HTTP_{resp.status_code}: {resp.text[:300]}")
if not resp.content:
return {}
return resp.json()
async def get_account(self) -> dict:
return await self._request("GET", f"/portfolio/{self.account_id}/summary")
# ── Conid resolution ────────────────────────────────────────
async def resolve_conid(self, symbol: str, sec_type: str = "STK") -> int:
key = f"{sec_type}:{symbol}"
if key in self._conid_cache:
self._conid_cache.move_to_end(key)
return self._conid_cache[key]
result = await self._request(
"GET", "/trsrv/secdef/search",
params={"symbol": symbol, "secType": sec_type},
)
if not result or not isinstance(result, list):
raise IBKRError(f"IBKR_CONID_NOT_FOUND: {symbol}/{sec_type}")
first = result[0]
if not isinstance(first, dict) or "conid" not in first:
raise IBKRError(
f"IBKR_CONID_NOT_FOUND: {symbol}/{sec_type} (malformed response)"
)
conid = int(first["conid"])
self._conid_cache[key] = conid
if len(self._conid_cache) > self._CONID_CACHE_MAX:
self._conid_cache.popitem(last=False)
return conid
# ── Positions / orders / activities ─────────────────────────
async def get_positions(self, page: int = 0) -> list[dict]:
data = await self._request(
"GET", f"/portfolio/{self.account_id}/positions/{page}"
)
return list(data) if isinstance(data, list) else []
async def get_open_orders(self) -> list[dict]:
data = await self._request(
"GET", "/iserver/account/orders",
params={"filters": "Submitted,PreSubmitted"},
)
if isinstance(data, dict):
return list(data.get("orders") or [])
return list(data) if isinstance(data, list) else []
async def get_activities(self, days: int = 7) -> list[dict]:
days = max(1, min(days, 90))
data = await self._request(
"GET", "/iserver/account/trades", params={"days": days},
)
return list(data) if isinstance(data, list) else []
# ── Market data ─────────────────────────────────────────────
_SNAPSHOT_FIELDS = "31,84,86,7295,7296" # last,bid,ask,bid_size,ask_size
async def get_ticker(self, symbol: str, asset_class: str = "stocks") -> dict:
sec_type = _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
conid = await self.resolve_conid(symbol, sec_type)
data = await self._request(
"GET", "/iserver/marketdata/snapshot",
params={"conids": str(conid), "fields": self._SNAPSHOT_FIELDS},
)
if not data or not isinstance(data, list):
raise IBKRError("IBKR_NO_MARKET_DATA_SUBSCRIPTION")
row = data[0]
def _f(k: str) -> float | None:
v = row.get(k)
try:
return float(v) if v not in (None, "") else None
except (TypeError, ValueError):
return None
return {
"symbol": symbol,
"asset_class": asset_class,
"last_price": _f("31"),
"bid": _f("84"),
"ask": _f("86"),
"bid_size": _f("7295"),
"ask_size": _f("7296"),
}
async def get_bars(
self, symbol: str, asset_class: str = "stocks",
period: str = "1d", bar: str = "5min",
) -> dict:
sec_type = _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
conid = await self.resolve_conid(symbol, sec_type)
data = await self._request(
"GET", "/iserver/marketdata/history",
params={"conid": str(conid), "period": period, "bar": bar},
)
rows = (data or {}).get("data") or []
candles = validate_candles([
{
"timestamp": r.get("t"),
"open": r.get("o"),
"high": r.get("h"),
"low": r.get("l"),
"close": r.get("c"),
"volume": r.get("v"),
}
for r in rows
])
return {
"symbol": symbol,
"asset_class": asset_class,
"interval": bar,
"candles": candles,
}
async def get_option_chain(
self, underlying: str, expiry: str | None = None
) -> dict:
conid = await self.resolve_conid(underlying, "STK")
params: dict[str, Any] = {"conid": str(conid), "secType": "OPT"}
if expiry:
params["month"] = expiry # IBKR format: "JAN26"
strikes = await self._request(
"GET", "/iserver/secdef/strikes", params=params,
)
return {
"underlying": underlying,
"expiry": expiry,
"strikes": strikes,
}
async def search_contracts(
self, symbol: str, sec_type: str = "STK"
) -> list[dict]:
data = await self._request(
"GET", "/trsrv/secdef/search",
params={"symbol": symbol, "secType": sec_type},
)
return list(data) if isinstance(data, list) else []
# ── Order writes ────────────────────────────────────────────
# Auto-confirm policy: any IBKR warning that is NOT in _CRITICAL_WARNINGS
# is auto-confirmed up to _AUTO_CONFIRM_MAX_CYCLES times. Hardening to a
# strict whitelist (allow-list) is deferred — V1 trades safety for UX.
_CRITICAL_WARNINGS = (
"margin", "suitability", "credit", "rejected", "insufficient",
)
_AUTO_CONFIRM_MAX_CYCLES = 3
async def place_order(
self, *,
symbol: str, side: str, qty: float,
order_type: str = "market",
limit_price: float | None = None,
stop_price: float | None = None,
tif: str = "day",
asset_class: str = "stocks",
sec_type: str | None = None,
exchange: str = "SMART",
outside_rth: bool = False,
) -> dict:
st = sec_type or _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
conid = await self.resolve_conid(symbol, st)
order: dict[str, Any] = {
"conid": conid,
"secType": f"{conid}:{st}",
"orderType": _ibkr_order_type(order_type),
"side": side.upper(),
"quantity": qty,
"tif": tif.upper(),
"outsideRTH": outside_rth,
"listingExchange": exchange,
}
if limit_price is not None:
order["price"] = limit_price
if stop_price is not None:
order["auxPrice"] = stop_price
return await self._submit_order_with_confirmation({"orders": [order]})
async def _submit_order_with_confirmation(
self, payload: dict, *, cycles: int = 0
) -> dict:
path = f"/iserver/account/{self.account_id}/orders"
result = await self._request("POST", path, json_body=payload)
return await self._handle_order_response(result, cycles)
async def _handle_order_response(
self, result: Any, cycles: int
) -> dict:
if not isinstance(result, list) or not result:
raise IBKRError(f"IBKR_ORDER_UNEXPECTED_RESPONSE: {result!r}")
first = result[0]
if "id" in first and "message" in first:
messages = first.get("message") or []
joined = " ".join(messages).lower()
if any(crit in joined for crit in self._CRITICAL_WARNINGS):
raise IBKRError(
f"IBKR_ORDER_REJECTED_WARNING: {messages}"
)
if cycles >= self._AUTO_CONFIRM_MAX_CYCLES:
raise IBKRError(
f"IBKR_ORDER_TOO_MANY_CONFIRMATIONS: {messages}"
)
reply = await self._request(
"POST", f"/iserver/reply/{first['id']}",
json_body={"confirmed": True},
)
return await self._handle_order_response(reply, cycles + 1)
if "order_id" in first:
return {"order_id": first["order_id"], "status": first.get("order_status")}
raise IBKRError(f"IBKR_ORDER_UNEXPECTED_RESPONSE: {first!r}")
async def amend_order(
self, order_id: str, *,
qty: float | None = None,
limit_price: float | None = None,
stop_price: float | None = None,
tif: str | None = None,
) -> dict:
body: dict[str, Any] = {}
if qty is not None:
body["quantity"] = qty
if limit_price is not None:
body["price"] = limit_price
if stop_price is not None:
body["auxPrice"] = stop_price
if tif is not None:
body["tif"] = tif.upper()
path = f"/iserver/account/{self.account_id}/order/{order_id}"
result = await self._request("POST", path, json_body=body)
return await self._handle_order_response(result, cycles=0)
async def cancel_order(self, order_id: str) -> dict:
path = f"/iserver/account/{self.account_id}/order/{order_id}"
await self._request("DELETE", path)
return {"order_id": order_id, "canceled": True}
async def cancel_all_orders(self) -> list[dict]:
orders = await self.get_open_orders()
results = []
for o in orders:
oid = o.get("orderId") or o.get("order_id")
if not oid:
continue
try:
results.append(await self.cancel_order(str(oid)))
except Exception as e:
results.append({"order_id": str(oid), "canceled": False, "error": str(e)})
return results
async def close_position(
self, symbol: str, qty: float | None = None
) -> dict:
# Resolve symbol → conid, then match positions on conid (positions
# return `contractDesc` as a long display string, not ticker).
sec_type = _SEC_TYPE_MAP.get("stocks", "STK")
conid = await self.resolve_conid(symbol, sec_type)
positions = await self.get_positions()
target = next(
(p for p in positions if int(p.get("conid", 0)) == conid),
None,
)
if not target:
raise IBKRError(f"IBKR_NO_POSITION: {symbol} (conid={conid})")
position_qty = float(target.get("position", 0))
close_qty = abs(qty if qty is not None else position_qty)
side = "SELL" if position_qty > 0 else "BUY"
return await self.place_order(
symbol=symbol, side=side, qty=close_qty, order_type="market",
)
async def close_all_positions(self) -> list[dict]:
positions = await self.get_positions()
results = []
for p in positions:
sym = p.get("ticker") or p.get("contractDesc")
if not sym:
continue
try:
results.append(await self.close_position(sym))
except Exception as e:
results.append({"symbol": sym, "error": str(e)})
return results
def _ibkr_order_type(t: str) -> str:
m = {"market": "MKT", "limit": "LMT", "stop": "STP", "stop_limit": "STP_LMT"}
if t.lower() not in m:
raise IBKRError(f"unsupported order_type: {t}")
return m[t.lower()]
@@ -0,0 +1,106 @@
"""IBKR RSA key rotation: stage/confirm/abort with auto-rollback."""
from __future__ import annotations
import datetime as _dt
import hashlib
import os
import shutil
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
def _sha256_fingerprint(pem_path: Path) -> str:
digest = hashlib.sha256(pem_path.read_bytes()).hexdigest()
return f"SHA256:{digest}"
@dataclass
class KeyRotationManager:
signature_key_path: str
encryption_key_path: str
_started: bool = field(default=False, init=False)
def _sig(self) -> Path:
return Path(self.signature_key_path)
def _enc(self) -> Path:
return Path(self.encryption_key_path)
async def start(self) -> dict:
sig_new = self._sig().with_suffix(self._sig().suffix + ".new")
enc_new = self._enc().with_suffix(self._enc().suffix + ".new")
for p in (sig_new, enc_new):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
p.write_bytes(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
os.chmod(p, 0o600)
self._started = True
return {
"fingerprints": {
"sig": _sha256_fingerprint(sig_new),
"enc": _sha256_fingerprint(enc_new),
},
"expires_at": (
_dt.datetime.now(_dt.UTC) + _dt.timedelta(hours=24)
).isoformat(),
}
async def confirm(
self, *, validate: Callable[[], Awaitable[bool]],
) -> dict:
sig = self._sig()
enc = self._enc()
sig_new = sig.with_suffix(sig.suffix + ".new")
enc_new = enc.with_suffix(enc.suffix + ".new")
if not (sig_new.exists() and enc_new.exists()):
raise RuntimeError("IBKR_ROTATION_NOT_STARTED")
archive = sig.parent / ".archive" / _dt.datetime.now(_dt.UTC).strftime("%Y%m%dT%H%M%S")
archive.mkdir(parents=True, exist_ok=True)
shutil.move(str(sig), str(archive / sig.name))
shutil.move(str(enc), str(archive / enc.name))
shutil.move(str(sig_new), str(sig))
shutil.move(str(enc_new), str(enc))
err: BaseException | None = None
try:
ok = await validate()
except Exception as e:
ok = False
err = e
if not ok:
shutil.move(str(sig), str(sig.with_suffix(sig.suffix + ".new")))
shutil.move(str(enc), str(enc.with_suffix(enc.suffix + ".new")))
shutil.move(str(archive / sig.name), str(sig))
shutil.move(str(archive / enc.name), str(enc))
raise RuntimeError(
f"IBKR_ROTATION_VALIDATION_FAILED: {err}" if err
else "IBKR_ROTATION_VALIDATION_FAILED"
)
self._started = False
return {
"rotated_at": _dt.datetime.now(_dt.UTC).isoformat(),
"old_archived_at": str(archive),
}
async def abort(self) -> dict:
sig_new = self._sig().with_suffix(self._sig().suffix + ".new")
enc_new = self._enc().with_suffix(self._enc().suffix + ".new")
for p in (sig_new, enc_new):
if p.exists():
p.unlink()
self._started = False
return {"aborted": True}
@@ -0,0 +1,57 @@
"""Leverage cap server-side per place_order (IBKR Reg-T context).
Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente.
IBKR margin accounts default a 4x intraday / 2x overnight (Reg-T).
"""
from __future__ import annotations
from fastapi import HTTPException
def get_max_leverage(creds: dict) -> int:
"""Legge max_leverage dal secret. Default 1 se mancante."""
raw = creds.get("max_leverage", 1)
try:
value = int(raw)
except (TypeError, ValueError):
value = 1
return max(1, value)
def enforce_leverage(
requested: int | float | None,
*,
creds: dict,
exchange: str,
) -> int:
"""Verifica e applica leverage cap. Ritorna leverage applicabile.
Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap.
Se requested is None, applica il cap come default.
"""
cap = get_max_leverage(creds)
if requested is None:
return cap
lev = int(requested)
if lev < 1:
raise HTTPException(
status_code=403,
detail={
"error": "LEVERAGE_CAP_EXCEEDED",
"exchange": exchange,
"requested": lev,
"max": cap,
"reason": "leverage must be >= 1",
},
)
if lev > cap:
raise HTTPException(
status_code=403,
detail={
"error": "LEVERAGE_CAP_EXCEEDED",
"exchange": exchange,
"requested": lev,
"max": cap,
},
)
return lev
+181
View File
@@ -0,0 +1,181 @@
"""OAuth 1.0a Self-Service signer for IBKR Client Portal Web API.
Reference: https://www.interactivebrokers.com/api/doc.html (Self-Service OAuth)
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass, field
from urllib.parse import quote
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cerbero_mcp.common.http import async_client
# Refresh LST 9h before its 24h expiry (very conservative; trades token
# lifetime for safety margin against clock drift and slow re-auth flows).
_LST_REFRESH_BUFFER_S = 9 * 3600 # 32_400
_LST_FALLBACK_TTL_S = 15 * 3600 # 54_000 — used when server omits expiration
def _percent_encode(value: str) -> str:
"""RFC 3986 percent-encoding for OAuth (no `+` for space)."""
return quote(str(value), safe="")
def build_signature_base_string(
method: str, url: str, params: dict[str, str]
) -> str:
"""Costruisce signature base string OAuth 1.0a:
`<METHOD>&<encoded-url>&<encoded-sorted-params>`
"""
sorted_params = sorted(params.items())
encoded_pairs = [
f"{_percent_encode(k)}%3D{_percent_encode(v)}"
for k, v in sorted_params
]
# Manual %3D / %26 = double-encoding of '=' and '&' per RFC 5849 §3.4.1.3.2
params_str = "%26".join(encoded_pairs)
return f"{method.upper()}&{_percent_encode(url)}&{params_str}"
class IBKRAuthError(Exception):
"""OAuth flow failed (key invalid, consumer revoked, mint failed)."""
@dataclass
class OAuth1aSigner:
consumer_key: str
access_token: str
access_token_secret: str
signature_key_path: str
encryption_key_path: str
dh_prime: str # hex string
_signature_key: RSAPrivateKey | None = field(default=None, init=False, repr=False)
_encryption_key: RSAPrivateKey | None = field(default=None, init=False, repr=False)
_live_session_token: str | None = field(default=None, init=False, repr=False)
_lst_expires_at: float = field(default=0.0, init=False, repr=False)
def __post_init__(self) -> None:
with open(self.signature_key_path, "rb") as f:
self._signature_key = serialization.load_pem_private_key(
f.read(), password=None
)
with open(self.encryption_key_path, "rb") as f:
self._encryption_key = serialization.load_pem_private_key(
f.read(), password=None
)
def sign(self, method: str, url: str, params: dict[str, str]) -> str:
"""Firma RSA-SHA256 della signature base string. Ritorna base64."""
assert self._signature_key is not None # set in __post_init__
base = build_signature_base_string(method, url, params)
signature = self._signature_key.sign(
base.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(signature).decode("ascii")
def make_oauth_params(self) -> dict[str, str]:
"""Genera oauth_nonce/timestamp/version standard."""
return {
"oauth_consumer_key": self.consumer_key,
"oauth_token": self.access_token,
"oauth_nonce": secrets.token_hex(16),
"oauth_timestamp": str(int(time.time())),
"oauth_signature_method": "RSA-SHA256",
"oauth_version": "1.0",
}
async def get_live_session_token(self, *, base_url: str) -> str:
"""Restituisce LST cached, riminta se mancante o vicino a scadenza."""
if self._live_session_token and time.monotonic() < self._lst_expires_at:
return self._live_session_token
return await self._mint_live_session_token(base_url)
async def _mint_live_session_token(self, base_url: str) -> str:
"""DH key exchange + RSA-signed POST /oauth/live_session_token.
1. Generate random dh_random
2. Compute dh_challenge = 2^dh_random mod dh_prime
3. Decrypt access_token_secret via encryption RSA key
4. POST signed request with diffie_hellman_challenge
5. shared = dh_response^dh_random mod dh_prime
6. LST = HMAC-SHA1(shared, decrypted_secret), base64
"""
url = f"{base_url}/oauth/live_session_token"
prime = int(self.dh_prime, 16)
dh_random = secrets.randbits(256)
dh_challenge = pow(2, dh_random, prime)
dh_challenge_hex = format(dh_challenge, "x")
if self._encryption_key is None: # pragma: no cover — set in __post_init__
raise IBKRAuthError("encryption key not loaded")
try:
encrypted = bytes.fromhex(self.access_token_secret)
decrypted_secret = self._encryption_key.decrypt(
encrypted, padding.PKCS1v15()
)
except Exception as e: # narrow to crypto/value errors; broad on purpose
# Intentionally broad: covers ValueError (bad hex), cryptography
# errors (InvalidKey, padding decoding), and any RSA backend issue
# — all map to the same user-facing failure ("bad credentials").
raise IBKRAuthError(f"access_token_secret decrypt failed: {e}") from e
oauth_params = self.make_oauth_params()
oauth_params["diffie_hellman_challenge"] = dh_challenge_hex
signature = self.sign("POST", url, oauth_params)
oauth_params["oauth_signature"] = signature
auth_header = "OAuth " + ", ".join(
f'{k}="{_percent_encode(v)}"' for k, v in sorted(oauth_params.items())
)
async with async_client(timeout=15.0) as http:
resp = await http.post(
url,
headers={"Authorization": auth_header, "User-Agent": "cerbero-mcp/2.0"},
)
if resp.status_code != 200:
raise IBKRAuthError(
f"LST mint failed status={resp.status_code} body={resp.text[:300]}"
)
data = resp.json()
dh_response = int(data["diffie_hellman_response"], 16)
expires_ms = data.get("live_session_token_expiration", 0)
shared = pow(dh_response, dh_random, prime)
shared_bytes = shared.to_bytes((shared.bit_length() + 7) // 8, "big")
if shared_bytes and shared_bytes[0] & 0x80:
shared_bytes = b"\x00" + shared_bytes
lst_raw = hmac.new(shared_bytes, decrypted_secret, hashlib.sha1).digest()
lst = base64.b64encode(lst_raw).decode("ascii")
self._live_session_token = lst
if expires_ms:
ttl = max(60.0, (expires_ms / 1000) - time.time() - _LST_REFRESH_BUFFER_S)
else:
ttl = float(_LST_FALLBACK_TTL_S)
# `expires_ms` is wall clock; convert to a monotonic deadline so the
# cache check is unaffected by future clock adjustments.
self._lst_expires_at = time.monotonic() + ttl
return lst
def sign_with_lst(self, method: str, url: str, params: dict[str, str]) -> str:
"""Firma HMAC-SHA256 con LST come key (per request post-mint)."""
if not self._live_session_token:
raise IBKRAuthError("LST not minted yet; call get_live_session_token first")
base = build_signature_base_string(method, url, params)
lst_bytes = base64.b64decode(self._live_session_token)
sig = hmac.new(lst_bytes, base.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(sig).decode("ascii")
@@ -0,0 +1,101 @@
"""Pure-function payload builders for IBKR complex orders (bracket/OCO/OTO).
No HTTP. Tests are deterministic.
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from typing import Literal
@dataclass
class OrderSpec:
conid: int
sec_type: str # "STK" | "OPT" | "FUT" | "CASH"
side: Literal["BUY", "SELL"]
qty: float
order_type: Literal["MKT", "LMT", "STP", "STP_LMT"]
price: float | None = None # limit price
aux_price: float | None = None # stop price
tif: str = "GTC"
exchange: str = "SMART"
def _to_order_dict(spec: OrderSpec, *, oca_group: str | None = None,
oca_type: int | None = None,
parent_id: str | None = None) -> dict:
o: dict = {
"conid": spec.conid,
"secType": f"{spec.conid}:{spec.sec_type}",
"orderType": spec.order_type,
"side": spec.side,
"quantity": spec.qty,
"tif": spec.tif,
"listingExchange": spec.exchange,
}
if spec.price is not None:
o["price"] = spec.price
if spec.aux_price is not None:
o["auxPrice"] = spec.aux_price
if oca_group:
o["ocaGroup"] = oca_group
if oca_type is not None:
o["ocaType"] = oca_type
if parent_id:
o["parentId"] = parent_id
return o
def _new_oca_group() -> str:
return f"oca-{secrets.token_hex(4)}"
def build_bracket_payload(
*, conid: int, sec_type: str, side: str, qty: float,
entry_price: float, stop_loss: float, take_profit: float,
tif: str = "GTC", exchange: str = "SMART",
) -> dict:
"""Bracket: parent LMT entry + child STP (loss) + child LMT (profit), OCA-linked."""
side = side.upper()
opposite = "SELL" if side == "BUY" else "BUY"
oca = _new_oca_group()
parent = OrderSpec(conid=conid, sec_type=sec_type, side=side, qty=qty, # type: ignore[arg-type]
order_type="LMT", price=entry_price,
tif=tif, exchange=exchange)
sl = OrderSpec(conid=conid, sec_type=sec_type, side=opposite, qty=qty, # type: ignore[arg-type]
order_type="STP", aux_price=stop_loss,
tif=tif, exchange=exchange)
tp = OrderSpec(conid=conid, sec_type=sec_type, side=opposite, qty=qty, # type: ignore[arg-type]
order_type="LMT", price=take_profit,
tif=tif, exchange=exchange)
return {
"orders": [
_to_order_dict(parent, oca_group=oca, oca_type=2),
_to_order_dict(sl, oca_group=oca, oca_type=2),
_to_order_dict(tp, oca_group=oca, oca_type=2),
]
}
def build_oco_payload(legs: list[OrderSpec]) -> dict:
"""OCO: N legs, all sharing same ocaGroup with ocaType=1 (one-cancels-all)."""
if len(legs) < 2:
raise ValueError("OCO requires at least 2 legs")
oca = _new_oca_group()
return {
"orders": [
_to_order_dict(l, oca_group=oca, oca_type=1) for l in legs
]
}
def build_oto_first_payload(trigger: OrderSpec) -> dict:
"""OTO step 1: place trigger as standalone."""
return {"orders": [_to_order_dict(trigger)]}
def build_oto_child_payload(child: OrderSpec, parent_order_id: str) -> dict:
"""OTO step 2: child references parentId from step-1 order_id."""
return {"orders": [_to_order_dict(child, parent_id=parent_order_id)]}
+453
View File
@@ -0,0 +1,453 @@
"""IBKR tool functions: Pydantic schemas + async dispatch to client/ws."""
from __future__ import annotations
import asyncio
import contextlib
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel
from cerbero_mcp.exchanges.ibkr.client import _SEC_TYPE_MAP, IBKRClient, IBKRError
from cerbero_mcp.exchanges.ibkr.leverage_cap import get_max_leverage
from cerbero_mcp.exchanges.ibkr.orders_complex import (
OrderSpec,
build_bracket_payload,
build_oco_payload,
build_oto_child_payload,
build_oto_first_payload,
)
from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket
# === Schemas: reads ===
class GetAccountReq(BaseModel):
pass
class GetPositionsReq(BaseModel):
pass
class GetOpenOrdersReq(BaseModel):
pass
class GetActivitiesReq(BaseModel):
days: int = 7
class GetTickerReq(BaseModel):
symbol: str
asset_class: str = "stocks"
class GetBarsReq(BaseModel):
symbol: str
asset_class: str = "stocks"
period: str = "1d"
bar: str = "5min"
class GetSnapshotReq(BaseModel):
symbol: str
asset_class: str = "stocks"
class GetOptionChainReq(BaseModel):
underlying: str
expiry: str | None = None
class SearchContractsReq(BaseModel):
symbol: str
sec_type: str = "STK"
class GetClockReq(BaseModel):
pass
# === Schemas: streaming ===
class GetTickReq(BaseModel):
symbol: str
asset_class: str = "stocks"
class GetDepthReq(BaseModel):
symbol: str
asset_class: str = "stocks"
rows: int = 5
exchange: str = "SMART"
class SubscribeTickReq(BaseModel):
symbol: str
asset_class: str = "stocks"
class UnsubscribeReq(BaseModel):
symbol: str
asset_class: str = "stocks"
# === Schemas: writes simple ===
class PlaceOrderReq(BaseModel):
symbol: str
side: str
qty: float
order_type: str = "market"
limit_price: float | None = None
stop_price: float | None = None
tif: str = "day"
asset_class: str = "stocks"
sec_type: str | None = None
exchange: str = "SMART"
outside_rth: bool = False
class AmendOrderReq(BaseModel):
order_id: str
qty: float | None = None
limit_price: float | None = None
stop_price: float | None = None
tif: str | None = None
class CancelOrderReq(BaseModel):
order_id: str
class CancelAllOrdersReq(BaseModel):
pass
class ClosePositionReq(BaseModel):
symbol: str
qty: float | None = None
class CloseAllPositionsReq(BaseModel):
pass
# === Schemas: writes complex ===
class PlaceBracketOrderReq(BaseModel):
symbol: str
side: str
qty: float
entry_price: float
stop_loss: float
take_profit: float
tif: str = "gtc"
asset_class: str = "stocks"
exchange: str = "SMART"
class OrderLeg(BaseModel):
symbol: str
side: str
qty: float
order_type: str = "limit"
limit_price: float | None = None
stop_price: float | None = None
tif: str = "gtc"
asset_class: str = "stocks"
class PlaceOcoOrderReq(BaseModel):
legs: list[OrderLeg]
class PlaceOtoOrderReq(BaseModel):
trigger: OrderLeg
child: OrderLeg
# === Read tools ===
async def environment_info(
client: IBKRClient, *, creds: dict, env_info: Any | None = None
) -> dict:
return {
"exchange": "ibkr",
"environment": "testnet" if client.paper else "mainnet",
"paper": client.paper,
"base_url": client.base_url,
"max_leverage": get_max_leverage(creds),
}
async def get_account(client: IBKRClient, params: GetAccountReq) -> dict:
return await client.get_account()
async def get_positions(client: IBKRClient, params: GetPositionsReq) -> dict:
return {"positions": await client.get_positions()}
async def get_open_orders(client: IBKRClient, params: GetOpenOrdersReq) -> dict:
return {"orders": await client.get_open_orders()}
async def get_activities(client: IBKRClient, params: GetActivitiesReq) -> dict:
return {"activities": await client.get_activities(params.days)}
async def get_ticker(client: IBKRClient, params: GetTickerReq) -> dict:
return await client.get_ticker(params.symbol, params.asset_class)
async def get_bars(client: IBKRClient, params: GetBarsReq) -> dict:
return await client.get_bars(
params.symbol, params.asset_class, params.period, params.bar,
)
async def get_snapshot(client: IBKRClient, params: GetSnapshotReq) -> dict:
return await client.get_ticker(params.symbol, params.asset_class)
async def get_option_chain(client: IBKRClient, params: GetOptionChainReq) -> dict:
return await client.get_option_chain(params.underlying, params.expiry)
async def search_contracts(client: IBKRClient, params: SearchContractsReq) -> dict:
return {"contracts": await client.search_contracts(params.symbol, params.sec_type)}
async def get_clock(client: IBKRClient, params: GetClockReq) -> dict:
import datetime as _dt
now = _dt.datetime.now(_dt.UTC)
return {
"timestamp": now.isoformat(),
"is_open": _dt.time(13, 30) <= now.time() <= _dt.time(20, 0)
and now.weekday() < 5,
"approximate": True,
"note": (
"is_open is a UTC-based approximation; does not account for "
"US market holidays or half-days. Use IBKR /trsrv/marketdata/calendar "
"for authoritative schedule."
),
}
# === Streaming tools ===
def _sec_type_for(asset_class: str) -> str:
return _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
async def get_tick(
client: IBKRClient, params: GetTickReq,
*, ws: IBKRWebSocket, timeout_s: float = 3.0,
) -> dict:
sec = _sec_type_for(params.asset_class)
conid = await client.resolve_conid(params.symbol, sec)
snap = ws.get_tick_snapshot(conid)
if snap:
return {**snap, "symbol": params.symbol}
await ws.subscribe_tick(conid)
deadline = asyncio.get_event_loop().time() + timeout_s
while asyncio.get_event_loop().time() < deadline:
snap = ws.get_tick_snapshot(conid)
if snap:
return {**snap, "symbol": params.symbol}
await asyncio.sleep(0.05)
raise IBKRError(f"IBKR_TICK_TIMEOUT: {params.symbol}")
async def get_depth(
client: IBKRClient, params: GetDepthReq,
*, ws: IBKRWebSocket, timeout_s: float = 3.0,
) -> dict:
sec = _sec_type_for(params.asset_class)
conid = await client.resolve_conid(params.symbol, sec)
snap = ws.get_depth_snapshot(conid)
if snap:
return {**snap, "symbol": params.symbol}
await ws.subscribe_depth(conid, exchange=params.exchange, rows=params.rows)
deadline = asyncio.get_event_loop().time() + timeout_s
while asyncio.get_event_loop().time() < deadline:
snap = ws.get_depth_snapshot(conid)
if snap:
return {**snap, "symbol": params.symbol}
await asyncio.sleep(0.05)
raise IBKRError(f"IBKR_DEPTH_TIMEOUT: {params.symbol}")
async def subscribe_tick(
client: IBKRClient, params: SubscribeTickReq, *, ws: IBKRWebSocket,
) -> dict:
sec = _sec_type_for(params.asset_class)
conid = await client.resolve_conid(params.symbol, sec)
await ws.subscribe_tick(conid, forced=True)
return {"symbol": params.symbol, "conid": conid, "subscribed": True}
async def unsubscribe(
client: IBKRClient, params: UnsubscribeReq, *, ws: IBKRWebSocket,
) -> dict:
sec = _sec_type_for(params.asset_class)
conid = await client.resolve_conid(params.symbol, sec)
await ws.unsubscribe(conid)
return {"symbol": params.symbol, "conid": conid, "unsubscribed": True}
# === Write tools: simple ===
async def place_order(
client: IBKRClient, params: PlaceOrderReq,
*, creds: dict, last_price: float | None = None,
) -> dict:
cap = get_max_leverage(creds)
if last_price is None:
try:
ticker = await client.get_ticker(params.symbol, params.asset_class)
last_price = ticker.get("last_price") or ticker.get("ask")
except Exception:
last_price = None
if last_price:
notional = params.qty * float(last_price)
try:
account = await client.get_account()
equity = float(
(account.get("netliquidation") or {}).get("amount") or 0
)
except Exception:
equity = 0.0
if equity > 0 and notional / equity > cap:
raise HTTPException(
status_code=403,
detail={
"error": "LEVERAGE_CAP_EXCEEDED",
"exchange": "ibkr",
"requested_ratio": notional / equity,
"max": cap,
},
)
return await client.place_order(
symbol=params.symbol,
side=params.side,
qty=params.qty,
order_type=params.order_type,
limit_price=params.limit_price,
stop_price=params.stop_price,
tif=params.tif,
asset_class=params.asset_class,
sec_type=params.sec_type,
exchange=params.exchange,
outside_rth=params.outside_rth,
)
async def amend_order(client: IBKRClient, params: AmendOrderReq) -> dict:
return await client.amend_order(
params.order_id,
qty=params.qty,
limit_price=params.limit_price,
stop_price=params.stop_price,
tif=params.tif,
)
async def cancel_order(client: IBKRClient, params: CancelOrderReq) -> dict:
return await client.cancel_order(params.order_id)
async def cancel_all_orders(
client: IBKRClient, params: CancelAllOrdersReq
) -> dict:
return {"canceled": await client.cancel_all_orders()}
async def close_position(
client: IBKRClient, params: ClosePositionReq
) -> dict:
return await client.close_position(params.symbol, params.qty)
async def close_all_positions(
client: IBKRClient, params: CloseAllPositionsReq
) -> dict:
return {"closed": await client.close_all_positions()}
# === Write tools: complex orders ===
def _leg_to_spec(leg: OrderLeg, conid: int) -> OrderSpec:
return OrderSpec(
conid=conid,
sec_type=_sec_type_for(leg.asset_class),
side=leg.side.upper(), # type: ignore[arg-type]
qty=leg.qty,
order_type={
"market": "MKT", "limit": "LMT",
"stop": "STP", "stop_limit": "STP_LMT",
}[leg.order_type.lower()], # type: ignore[arg-type]
price=leg.limit_price,
aux_price=leg.stop_price,
tif=leg.tif.upper(),
)
async def place_bracket_order(
client: IBKRClient, params: PlaceBracketOrderReq, *, creds: dict,
) -> dict:
sec = _sec_type_for(params.asset_class)
conid = await client.resolve_conid(params.symbol, sec)
cap = get_max_leverage(creds)
notional = params.qty * params.entry_price
try:
account = await client.get_account()
equity = float((account.get("netliquidation") or {}).get("amount") or 0)
except Exception:
equity = 0.0
if equity > 0 and notional / equity > cap:
raise HTTPException(
status_code=403,
detail={"error": "LEVERAGE_CAP_EXCEEDED", "exchange": "ibkr",
"requested_ratio": notional / equity, "max": cap},
)
payload = build_bracket_payload(
conid=conid, sec_type=sec, side=params.side.upper(), qty=params.qty,
entry_price=params.entry_price, stop_loss=params.stop_loss,
take_profit=params.take_profit, tif=params.tif.upper(),
exchange=params.exchange,
)
return await client._submit_order_with_confirmation(payload)
async def place_oco_order(
client: IBKRClient, params: PlaceOcoOrderReq, *, creds: dict,
) -> dict:
if len(params.legs) < 2:
raise HTTPException(400, detail={"error": "OCO requires >=2 legs"})
cap = get_max_leverage(creds)
leg_notional = max(
l.qty * (l.limit_price or l.stop_price or 0) for l in params.legs
)
try:
account = await client.get_account()
equity = float((account.get("netliquidation") or {}).get("amount") or 0)
except Exception:
equity = 0.0
if equity > 0 and leg_notional / equity > cap:
raise HTTPException(
status_code=403,
detail={"error": "LEVERAGE_CAP_EXCEEDED", "exchange": "ibkr",
"requested_ratio": leg_notional / equity, "max": cap},
)
specs = []
for l in params.legs:
sec = _sec_type_for(l.asset_class)
conid = await client.resolve_conid(l.symbol, sec)
specs.append(_leg_to_spec(l, conid))
payload = build_oco_payload(specs)
return await client._submit_order_with_confirmation(payload)
async def place_oto_order(
client: IBKRClient, params: PlaceOtoOrderReq, *, creds: dict,
) -> dict:
sec_t = _sec_type_for(params.trigger.asset_class)
sec_c = _sec_type_for(params.child.asset_class)
conid_t = await client.resolve_conid(params.trigger.symbol, sec_t)
conid_c = await client.resolve_conid(params.child.symbol, sec_c)
trig_spec = _leg_to_spec(params.trigger, conid_t)
child_spec = _leg_to_spec(params.child, conid_c)
trig_payload = build_oto_first_payload(trig_spec)
trig_res = await client._submit_order_with_confirmation(trig_payload)
trigger_order_id = trig_res.get("order_id")
if not trigger_order_id:
raise IBKRError(f"IBKR_OTO_TRIGGER_NO_ID: {trig_res!r}")
try:
child_payload = build_oto_child_payload(child_spec, str(trigger_order_id))
child_res = await client._submit_order_with_confirmation(child_payload)
except Exception as e:
with contextlib.suppress(Exception):
await client.cancel_order(str(trigger_order_id))
raise IBKRError(
f"IBKR_OTO_PARTIAL_FAILURE: trigger={trigger_order_id} reason={e}"
) from e
return {
"trigger_order_id": trigger_order_id,
"child_order_id": child_res.get("order_id"),
}
+247
View File
@@ -0,0 +1,247 @@
"""IBKR Client Portal WebSocket — persistent WSS, smd/sbd subs, snapshot cache."""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any
from websockets import connect as websockets_connect # exposed for tests
from cerbero_mcp.exchanges.ibkr.oauth import OAuth1aSigner
logger = logging.getLogger(__name__)
class WSError(Exception):
"""WebSocket layer error."""
@dataclass
class TickSnapshot:
last_price: float | None
bid: float | None
ask: float | None
bid_size: float | None
ask_size: float | None
timestamp_ms: int
@dataclass
class DepthSnapshot:
bids: list[dict]
asks: list[dict]
timestamp_ms: int
_SMD_FIELDS = ["31", "84", "86", "7295", "7296"]
@dataclass
class IBKRWebSocket:
"""Persistent WSS to IBKR Client Portal with smd/sbd subs.
Snapshot lifetime: each (tick|depth) cache entry is overwritten on every
incoming message. On disconnect, the reader loop logs and exits leaving
the existing cache intact. Consumers should check `connected` before
trusting a stale snapshot, or compare `timestamp_ms` against wall clock.
Automatic reconnect is deferred to a follow-up; V1 surfaces disconnects
via `connected=False` so the higher-level tool layer can rebuild the WS.
"""
signer: OAuth1aSigner
ws_url: str
base_url: str
max_subs: int = 80
idle_timeout_s: int = 300
_ws: Any = field(default=None, init=False, repr=False)
_tick_cache: dict[int, TickSnapshot] = field(default_factory=dict, init=False)
_depth_cache: dict[int, DepthSnapshot] = field(default_factory=dict, init=False)
_subs: set[int] = field(default_factory=set, init=False)
_depth_subs: set[int] = field(default_factory=set, init=False)
_last_polled_at: dict[int, float] = field(default_factory=dict, init=False)
_forced_subs: set[int] = field(default_factory=set, init=False)
_reader_task: asyncio.Task | None = field(default=None, init=False)
_idle_task: asyncio.Task | None = field(default=None, init=False)
_stopped: bool = field(default=False, init=False)
@property
def connected(self) -> bool:
return self._ws is not None and not getattr(self._ws, "closed", True)
async def start(self) -> None:
if self.connected:
return
self._stopped = False # reset on every start (supports stop→start cycles)
lst = await self.signer.get_live_session_token(base_url=self.base_url)
self._ws = await websockets_connect(
self.ws_url,
additional_headers={"Cookie": f"api={lst}"},
)
self._reader_task = asyncio.create_task(self._reader_loop())
self._idle_task = asyncio.create_task(self._idle_sweeper())
async def stop(self) -> None:
self._stopped = True
if self._idle_task:
self._idle_task.cancel()
with contextlib.suppress(BaseException):
await self._idle_task
if self._reader_task:
self._reader_task.cancel()
with contextlib.suppress(BaseException):
await self._reader_task
if self._ws:
with contextlib.suppress(Exception):
await self._ws.close()
self._ws = None
async def subscribe_tick(self, conid: int, *, forced: bool = False) -> None:
self._require_started()
await self._ensure_capacity(conid)
if conid in self._subs:
self._last_polled_at[conid] = time.monotonic()
if forced:
self._forced_subs.add(conid)
return
msg = "smd+" + str(conid) + "+" + json.dumps({"fields": _SMD_FIELDS})
await self._ws.send(msg)
self._subs.add(conid)
self._last_polled_at[conid] = time.monotonic()
if forced:
self._forced_subs.add(conid)
async def subscribe_depth(
self, conid: int, *, exchange: str = "SMART", rows: int = 5
) -> None:
self._require_started()
await self._ensure_capacity(conid)
if conid in self._depth_subs:
self._last_polled_at[conid] = time.monotonic()
return
msg = f"sbd+{conid}+{exchange}+{rows}"
await self._ws.send(msg)
self._depth_subs.add(conid)
self._last_polled_at[conid] = time.monotonic()
async def unsubscribe(self, conid: int) -> None:
self._require_started()
if conid in self._subs:
await self._ws.send(f"umd+{conid}+{{}}")
self._subs.discard(conid)
if conid in self._depth_subs:
await self._ws.send(f"ubd+{conid}")
self._depth_subs.discard(conid)
self._tick_cache.pop(conid, None)
self._depth_cache.pop(conid, None)
self._last_polled_at.pop(conid, None)
self._forced_subs.discard(conid)
def get_tick_snapshot(self, conid: int) -> dict | None:
snap = self._tick_cache.get(conid)
if not snap:
return None
self._last_polled_at[conid] = time.monotonic()
return {
"conid": conid,
"last_price": snap.last_price,
"bid": snap.bid,
"ask": snap.ask,
"bid_size": snap.bid_size,
"ask_size": snap.ask_size,
"timestamp_ms": snap.timestamp_ms,
}
def get_depth_snapshot(self, conid: int) -> dict | None:
snap = self._depth_cache.get(conid)
if not snap:
return None
self._last_polled_at[conid] = time.monotonic()
return {
"conid": conid,
"bids": snap.bids,
"asks": snap.asks,
"timestamp_ms": snap.timestamp_ms,
}
def _require_started(self) -> None:
if self._ws is None:
raise WSError("IBKR_WS_NOT_STARTED: call start() first")
async def _ensure_capacity(self, conid: int) -> None:
if (conid in self._subs) or (conid in self._depth_subs):
return
active = len(self._subs) + len(self._depth_subs)
if active >= self.max_subs:
raise WSError(f"IBKR_WS_SUB_LIMIT: {active}/{self.max_subs}")
async def _reader_loop(self) -> None:
try:
while not self._stopped and self._ws:
raw = await self._ws.recv()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
topic = msg.get("topic", "")
if topic.startswith("smd+"):
self._on_tick(topic, msg)
elif topic.startswith("sbd+"):
self._on_depth(topic, msg)
except asyncio.CancelledError:
raise
except Exception as exc:
# Disconnect / parse error / network — leave cache as-is, mark dead.
# V1: no automatic reconnect; consumers detect via stale timestamp_ms.
logger.warning("ibkr ws reader exited: %s", exc)
self._ws = None
return
def _on_tick(self, topic: str, msg: dict) -> None:
try:
conid = int(topic.split("+", 1)[1])
except (ValueError, IndexError):
return
def _f(k: str) -> float | None:
v = msg.get(k)
try:
return float(v) if v not in (None, "") else None
except (TypeError, ValueError):
return None
self._tick_cache[conid] = TickSnapshot(
last_price=_f("31"), bid=_f("84"), ask=_f("86"),
bid_size=_f("7295"), ask_size=_f("7296"),
timestamp_ms=int(time.time() * 1000),
)
def _on_depth(self, topic: str, msg: dict) -> None:
try:
conid = int(topic.split("+", 1)[1])
except (ValueError, IndexError):
return
self._depth_cache[conid] = DepthSnapshot(
bids=msg.get("bids") or [],
asks=msg.get("asks") or [],
timestamp_ms=int(time.time() * 1000),
)
async def _idle_sweeper(self) -> None:
try:
while not self._stopped:
await asyncio.sleep(30)
now = time.monotonic()
expired = [
c for c in list(self._subs | self._depth_subs)
if c not in self._forced_subs
and now - self._last_polled_at.get(c, now) > self.idle_timeout_s
]
for c in expired:
with contextlib.suppress(Exception):
await self.unsubscribe(c)
except asyncio.CancelledError:
raise
@@ -8,6 +8,8 @@ istanziato dal `ClientRegistry`.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any
class MacroClient: class MacroClient:
"""Wrapper credenziali FRED/Finnhub. Stateless, no HTTP session.""" """Wrapper credenziali FRED/Finnhub. Stateless, no HTTP session."""
@@ -18,3 +20,7 @@ class MacroClient:
async def aclose(self) -> None: # pragma: no cover - no-op, no resources async def aclose(self) -> None: # pragma: no cover - no-op, no resources
return None return None
async def health(self) -> dict[str, Any]:
"""Probe minimo per /health/ready: nessuna chiamata di rete."""
return {"status": "ok"}
@@ -9,6 +9,8 @@ e per essere istanziato dal `ClientRegistry`.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any
class SentimentClient: class SentimentClient:
"""Wrapper credenziali CryptoPanic/LunarCrush. Stateless, no HTTP session.""" """Wrapper credenziali CryptoPanic/LunarCrush. Stateless, no HTTP session."""
@@ -19,3 +21,7 @@ class SentimentClient:
async def aclose(self) -> None: # pragma: no cover - no-op, no resources async def aclose(self) -> None: # pragma: no cover - no-op, no resources
return None return None
async def health(self) -> dict[str, Any]:
"""Probe minimo per /health/ready: nessuna chiamata di rete."""
return {"status": "ok"}
+52 -6
View File
@@ -11,6 +11,7 @@ from typing import Literal, cast
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.client_registry import ClientRegistry
from cerbero_mcp.common.audit_helpers import audit_call
from cerbero_mcp.exchanges.alpaca import tools as t from cerbero_mcp.exchanges.alpaca import tools as t
from cerbero_mcp.exchanges.alpaca.client import AlpacaClient from cerbero_mcp.exchanges.alpaca.client import AlpacaClient
@@ -136,41 +137,86 @@ def make_router() -> APIRouter:
client: AlpacaClient = Depends(get_alpaca_client), client: AlpacaClient = Depends(get_alpaca_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.place_order(client, params, creds=creds) return await audit_call(
request=request,
exchange="alpaca",
action="place_order",
target_field="symbol",
params=params,
tool_fn=lambda: t.place_order(client, params, creds=creds),
)
@r.post("/tools/amend_order") @r.post("/tools/amend_order")
async def _amend_order( async def _amend_order(
params: t.AmendOrderReq, params: t.AmendOrderReq,
request: Request,
client: AlpacaClient = Depends(get_alpaca_client), client: AlpacaClient = Depends(get_alpaca_client),
): ):
return await t.amend_order(client, params) return await audit_call(
request=request,
exchange="alpaca",
action="amend_order",
target_field="order_id",
params=params,
tool_fn=lambda: t.amend_order(client, params),
)
@r.post("/tools/cancel_order") @r.post("/tools/cancel_order")
async def _cancel_order( async def _cancel_order(
params: t.CancelOrderReq, params: t.CancelOrderReq,
request: Request,
client: AlpacaClient = Depends(get_alpaca_client), client: AlpacaClient = Depends(get_alpaca_client),
): ):
return await t.cancel_order(client, params) return await audit_call(
request=request,
exchange="alpaca",
action="cancel_order",
target_field="order_id",
params=params,
tool_fn=lambda: t.cancel_order(client, params),
)
@r.post("/tools/cancel_all_orders") @r.post("/tools/cancel_all_orders")
async def _cancel_all_orders( async def _cancel_all_orders(
params: t.CancelAllOrdersReq, params: t.CancelAllOrdersReq,
request: Request,
client: AlpacaClient = Depends(get_alpaca_client), client: AlpacaClient = Depends(get_alpaca_client),
): ):
return await t.cancel_all_orders(client, params) return await audit_call(
request=request,
exchange="alpaca",
action="cancel_all_orders",
params=params,
tool_fn=lambda: t.cancel_all_orders(client, params),
)
@r.post("/tools/close_position") @r.post("/tools/close_position")
async def _close_position( async def _close_position(
params: t.ClosePositionReq, params: t.ClosePositionReq,
request: Request,
client: AlpacaClient = Depends(get_alpaca_client), client: AlpacaClient = Depends(get_alpaca_client),
): ):
return await t.close_position(client, params) return await audit_call(
request=request,
exchange="alpaca",
action="close_position",
target_field="symbol",
params=params,
tool_fn=lambda: t.close_position(client, params),
)
@r.post("/tools/close_all_positions") @r.post("/tools/close_all_positions")
async def _close_all_positions( async def _close_all_positions(
params: t.CloseAllPositionsReq, params: t.CloseAllPositionsReq,
request: Request,
client: AlpacaClient = Depends(get_alpaca_client), client: AlpacaClient = Depends(get_alpaca_client),
): ):
return await t.close_all_positions(client, params) return await audit_call(
request=request,
exchange="alpaca",
action="close_all_positions",
params=params,
tool_fn=lambda: t.close_all_positions(client, params),
)
return r return r
+96 -11
View File
@@ -11,6 +11,7 @@ from typing import Literal, cast
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.client_registry import ClientRegistry
from cerbero_mcp.common.audit_helpers import audit_call
from cerbero_mcp.exchanges.bybit import tools as t from cerbero_mcp.exchanges.bybit import tools as t
from cerbero_mcp.exchanges.bybit.client import BybitClient from cerbero_mcp.exchanges.bybit.client import BybitClient
@@ -182,7 +183,14 @@ def make_router() -> APIRouter:
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.place_order(client, params, creds=creds) return await audit_call(
request=request,
exchange="bybit",
action="place_order",
target_field="symbol",
params=params,
tool_fn=lambda: t.place_order(client, params, creds=creds),
)
@r.post("/tools/place_combo_order") @r.post("/tools/place_combo_order")
async def _place_combo_order( async def _place_combo_order(
@@ -191,49 +199,103 @@ def make_router() -> APIRouter:
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.place_combo_order(client, params, creds=creds) return await audit_call(
request=request,
exchange="bybit",
action="place_combo_order",
params=params,
tool_fn=lambda: t.place_combo_order(client, params, creds=creds),
)
@r.post("/tools/amend_order") @r.post("/tools/amend_order")
async def _amend_order( async def _amend_order(
params: t.AmendOrderReq, params: t.AmendOrderReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.amend_order(client, params) return await audit_call(
request=request,
exchange="bybit",
action="amend_order",
target_field="symbol",
params=params,
tool_fn=lambda: t.amend_order(client, params),
)
@r.post("/tools/cancel_order") @r.post("/tools/cancel_order")
async def _cancel_order( async def _cancel_order(
params: t.CancelOrderReq, params: t.CancelOrderReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.cancel_order(client, params) return await audit_call(
request=request,
exchange="bybit",
action="cancel_order",
target_field="order_id",
params=params,
tool_fn=lambda: t.cancel_order(client, params),
)
@r.post("/tools/cancel_all_orders") @r.post("/tools/cancel_all_orders")
async def _cancel_all_orders( async def _cancel_all_orders(
params: t.CancelAllReq, params: t.CancelAllReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.cancel_all_orders(client, params) return await audit_call(
request=request,
exchange="bybit",
action="cancel_all_orders",
target_field="symbol",
params=params,
tool_fn=lambda: t.cancel_all_orders(client, params),
)
@r.post("/tools/set_stop_loss") @r.post("/tools/set_stop_loss")
async def _set_stop_loss( async def _set_stop_loss(
params: t.SetStopLossReq, params: t.SetStopLossReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.set_stop_loss(client, params) return await audit_call(
request=request,
exchange="bybit",
action="set_stop_loss",
target_field="symbol",
params=params,
tool_fn=lambda: t.set_stop_loss(client, params),
)
@r.post("/tools/set_take_profit") @r.post("/tools/set_take_profit")
async def _set_take_profit( async def _set_take_profit(
params: t.SetTakeProfitReq, params: t.SetTakeProfitReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.set_take_profit(client, params) return await audit_call(
request=request,
exchange="bybit",
action="set_take_profit",
target_field="symbol",
params=params,
tool_fn=lambda: t.set_take_profit(client, params),
)
@r.post("/tools/close_position") @r.post("/tools/close_position")
async def _close_position( async def _close_position(
params: t.ClosePositionReq, params: t.ClosePositionReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.close_position(client, params) return await audit_call(
request=request,
exchange="bybit",
action="close_position",
target_field="symbol",
params=params,
tool_fn=lambda: t.close_position(client, params),
)
@r.post("/tools/set_leverage") @r.post("/tools/set_leverage")
async def _set_leverage( async def _set_leverage(
@@ -242,20 +304,43 @@ def make_router() -> APIRouter:
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.set_leverage(client, params, creds=creds) return await audit_call(
request=request,
exchange="bybit",
action="set_leverage",
target_field="symbol",
params=params,
tool_fn=lambda: t.set_leverage(client, params, creds=creds),
)
@r.post("/tools/switch_position_mode") @r.post("/tools/switch_position_mode")
async def _switch_position_mode( async def _switch_position_mode(
params: t.SwitchModeReq, params: t.SwitchModeReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.switch_position_mode(client, params) return await audit_call(
request=request,
exchange="bybit",
action="switch_position_mode",
target_field="symbol",
params=params,
tool_fn=lambda: t.switch_position_mode(client, params),
)
@r.post("/tools/transfer_asset") @r.post("/tools/transfer_asset")
async def _transfer_asset( async def _transfer_asset(
params: t.TransferReq, params: t.TransferReq,
request: Request,
client: BybitClient = Depends(get_bybit_client), client: BybitClient = Depends(get_bybit_client),
): ):
return await t.transfer_asset(client, params) return await audit_call(
request=request,
exchange="bybit",
action="transfer_asset",
target_field="coin",
params=params,
tool_fn=lambda: t.transfer_asset(client, params),
)
return r return r
+36
View File
@@ -0,0 +1,36 @@
"""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
+52 -6
View File
@@ -11,6 +11,7 @@ from typing import Literal, cast
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.client_registry import ClientRegistry
from cerbero_mcp.common.audit_helpers import audit_call
from cerbero_mcp.exchanges.deribit import tools as t from cerbero_mcp.exchanges.deribit import tools as t
from cerbero_mcp.exchanges.deribit.client import DeribitClient from cerbero_mcp.exchanges.deribit.client import DeribitClient
@@ -249,7 +250,14 @@ def make_router() -> APIRouter:
client: DeribitClient = Depends(get_deribit_client), client: DeribitClient = Depends(get_deribit_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.place_order(client, params, creds=creds) return await audit_call(
request=request,
exchange="deribit",
action="place_order",
target_field="instrument_name",
params=params,
tool_fn=lambda: t.place_order(client, params, creds=creds),
)
@r.post("/tools/place_combo_order") @r.post("/tools/place_combo_order")
async def _place_combo_order( async def _place_combo_order(
@@ -258,34 +266,72 @@ def make_router() -> APIRouter:
client: DeribitClient = Depends(get_deribit_client), client: DeribitClient = Depends(get_deribit_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.place_combo_order(client, params, creds=creds) return await audit_call(
request=request,
exchange="deribit",
action="place_combo_order",
params=params,
tool_fn=lambda: t.place_combo_order(client, params, creds=creds),
)
@r.post("/tools/cancel_order") @r.post("/tools/cancel_order")
async def _cancel_order( async def _cancel_order(
params: t.CancelOrderReq, params: t.CancelOrderReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client), client: DeribitClient = Depends(get_deribit_client),
): ):
return await t.cancel_order(client, params) return await audit_call(
request=request,
exchange="deribit",
action="cancel_order",
target_field="order_id",
params=params,
tool_fn=lambda: t.cancel_order(client, params),
)
@r.post("/tools/set_stop_loss") @r.post("/tools/set_stop_loss")
async def _set_stop_loss( async def _set_stop_loss(
params: t.SetStopLossReq, params: t.SetStopLossReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client), client: DeribitClient = Depends(get_deribit_client),
): ):
return await t.set_stop_loss(client, params) return await audit_call(
request=request,
exchange="deribit",
action="set_stop_loss",
target_field="order_id",
params=params,
tool_fn=lambda: t.set_stop_loss(client, params),
)
@r.post("/tools/set_take_profit") @r.post("/tools/set_take_profit")
async def _set_take_profit( async def _set_take_profit(
params: t.SetTakeProfitReq, params: t.SetTakeProfitReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client), client: DeribitClient = Depends(get_deribit_client),
): ):
return await t.set_take_profit(client, params) return await audit_call(
request=request,
exchange="deribit",
action="set_take_profit",
target_field="order_id",
params=params,
tool_fn=lambda: t.set_take_profit(client, params),
)
@r.post("/tools/close_position") @r.post("/tools/close_position")
async def _close_position( async def _close_position(
params: t.ClosePositionReq, params: t.ClosePositionReq,
request: Request,
client: DeribitClient = Depends(get_deribit_client), client: DeribitClient = Depends(get_deribit_client),
): ):
return await t.close_position(client, params) return await audit_call(
request=request,
exchange="deribit",
action="close_position",
target_field="instrument_name",
params=params,
tool_fn=lambda: t.close_position(client, params),
)
return r return r
+45 -5
View File
@@ -11,6 +11,7 @@ from typing import Literal, cast
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.client_registry import ClientRegistry
from cerbero_mcp.common.audit_helpers import audit_call
from cerbero_mcp.exchanges.hyperliquid import tools as t from cerbero_mcp.exchanges.hyperliquid import tools as t
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
@@ -136,34 +137,73 @@ def make_router() -> APIRouter:
client: HyperliquidClient = Depends(get_hyperliquid_client), client: HyperliquidClient = Depends(get_hyperliquid_client),
): ):
creds = _build_creds(request) creds = _build_creds(request)
return await t.place_order(client, params, creds=creds) return await audit_call(
request=request,
exchange="hyperliquid",
action="place_order",
target_field="instrument",
params=params,
tool_fn=lambda: t.place_order(client, params, creds=creds),
)
@r.post("/tools/cancel_order") @r.post("/tools/cancel_order")
async def _cancel_order( async def _cancel_order(
params: t.CancelOrderReq, params: t.CancelOrderReq,
request: Request,
client: HyperliquidClient = Depends(get_hyperliquid_client), client: HyperliquidClient = Depends(get_hyperliquid_client),
): ):
return await t.cancel_order(client, params) return await audit_call(
request=request,
exchange="hyperliquid",
action="cancel_order",
target_field="order_id",
params=params,
tool_fn=lambda: t.cancel_order(client, params),
)
@r.post("/tools/set_stop_loss") @r.post("/tools/set_stop_loss")
async def _set_stop_loss( async def _set_stop_loss(
params: t.SetStopLossReq, params: t.SetStopLossReq,
request: Request,
client: HyperliquidClient = Depends(get_hyperliquid_client), client: HyperliquidClient = Depends(get_hyperliquid_client),
): ):
return await t.set_stop_loss(client, params) return await audit_call(
request=request,
exchange="hyperliquid",
action="set_stop_loss",
target_field="instrument",
params=params,
tool_fn=lambda: t.set_stop_loss(client, params),
)
@r.post("/tools/set_take_profit") @r.post("/tools/set_take_profit")
async def _set_take_profit( async def _set_take_profit(
params: t.SetTakeProfitReq, params: t.SetTakeProfitReq,
request: Request,
client: HyperliquidClient = Depends(get_hyperliquid_client), client: HyperliquidClient = Depends(get_hyperliquid_client),
): ):
return await t.set_take_profit(client, params) return await audit_call(
request=request,
exchange="hyperliquid",
action="set_take_profit",
target_field="instrument",
params=params,
tool_fn=lambda: t.set_take_profit(client, params),
)
@r.post("/tools/close_position") @r.post("/tools/close_position")
async def _close_position( async def _close_position(
params: t.ClosePositionReq, params: t.ClosePositionReq,
request: Request,
client: HyperliquidClient = Depends(get_hyperliquid_client), client: HyperliquidClient = Depends(get_hyperliquid_client),
): ):
return await t.close_position(client, params) return await audit_call(
request=request,
exchange="hyperliquid",
action="close_position",
target_field="instrument",
params=params,
tool_fn=lambda: t.close_position(client, params),
)
return r return r
+248
View File
@@ -0,0 +1,248 @@
"""Router /mcp-ibkr/* — DI per env, client e (write) creds."""
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.common.audit_helpers import audit_call
from cerbero_mcp.exchanges.ibkr import tools as t
from cerbero_mcp.exchanges.ibkr.client import IBKRClient
from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket
Environment = Literal["testnet", "mainnet"]
def get_environment(request: Request) -> Environment:
return cast(Environment, request.state.environment)
async def get_ibkr_client(
request: Request, env: Environment = Depends(get_environment),
) -> IBKRClient:
registry: ClientRegistry = request.app.state.registry
return cast(IBKRClient, await registry.get("ibkr", env))
async def get_ibkr_ws(
request: Request, env: Environment = Depends(get_environment),
) -> IBKRWebSocket:
"""Lazy-create singleton WS per env on first streaming call."""
ws_dict = getattr(request.app.state, "ibkr_ws", None)
if ws_dict is None:
ws_dict = {}
request.app.state.ibkr_ws = ws_dict
if env not in ws_dict:
client = await get_ibkr_client(request, env)
settings = request.app.state.settings
ws_url = (
settings.ibkr.ws_url_testnet if env == "testnet"
else settings.ibkr.ws_url_live
)
ws = IBKRWebSocket(
signer=client.signer,
ws_url=ws_url,
base_url=client.base_url,
max_subs=settings.ibkr.ws_max_subscriptions,
idle_timeout_s=settings.ibkr.ws_idle_timeout_s,
)
await ws.start()
ws_dict[env] = ws
return ws_dict[env]
def _build_creds(request: Request) -> dict:
settings = request.app.state.settings
return {"max_leverage": settings.ibkr.max_leverage}
def make_router() -> APIRouter:
r = APIRouter(prefix="/mcp-ibkr", tags=["ibkr"])
# === READ tools ===
@r.post("/tools/environment_info")
async def _ei(request: Request, client: IBKRClient = Depends(get_ibkr_client)):
return await t.environment_info(client, creds=_build_creds(request))
@r.post("/tools/get_account")
async def _ga(params: t.GetAccountReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_account(client, params)
@r.post("/tools/get_positions")
async def _gp(params: t.GetPositionsReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_positions(client, params)
@r.post("/tools/get_open_orders")
async def _goo(params: t.GetOpenOrdersReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_open_orders(client, params)
@r.post("/tools/get_activities")
async def _gact(params: t.GetActivitiesReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_activities(client, params)
@r.post("/tools/get_ticker")
async def _gt(params: t.GetTickerReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_ticker(client, params)
@r.post("/tools/get_bars")
async def _gb(params: t.GetBarsReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_bars(client, params)
@r.post("/tools/get_snapshot")
async def _gs(params: t.GetSnapshotReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_snapshot(client, params)
@r.post("/tools/get_option_chain")
async def _goc(params: t.GetOptionChainReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_option_chain(client, params)
@r.post("/tools/search_contracts")
async def _sc(params: t.SearchContractsReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.search_contracts(client, params)
@r.post("/tools/get_clock")
async def _gc(params: t.GetClockReq, client: IBKRClient = Depends(get_ibkr_client)):
return await t.get_clock(client, params)
# === STREAMING tools ===
@r.post("/tools/get_tick")
async def _gtk(
params: t.GetTickReq,
client: IBKRClient = Depends(get_ibkr_client),
ws: IBKRWebSocket = Depends(get_ibkr_ws),
):
return await t.get_tick(client, params, ws=ws)
@r.post("/tools/get_depth")
async def _gd(
params: t.GetDepthReq,
client: IBKRClient = Depends(get_ibkr_client),
ws: IBKRWebSocket = Depends(get_ibkr_ws),
):
return await t.get_depth(client, params, ws=ws)
@r.post("/tools/subscribe_tick")
async def _st(
params: t.SubscribeTickReq,
client: IBKRClient = Depends(get_ibkr_client),
ws: IBKRWebSocket = Depends(get_ibkr_ws),
):
return await t.subscribe_tick(client, params, ws=ws)
@r.post("/tools/unsubscribe")
async def _us(
params: t.UnsubscribeReq,
client: IBKRClient = Depends(get_ibkr_client),
ws: IBKRWebSocket = Depends(get_ibkr_ws),
):
return await t.unsubscribe(client, params, ws=ws)
# === WRITE simple ===
@r.post("/tools/place_order")
async def _po(
params: t.PlaceOrderReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
creds = _build_creds(request)
return await audit_call(
request=request, exchange="ibkr", action="place_order",
target_field="symbol", params=params,
tool_fn=lambda: t.place_order(client, params, creds=creds),
)
@r.post("/tools/amend_order")
async def _ao(
params: t.AmendOrderReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
return await audit_call(
request=request, exchange="ibkr", action="amend_order",
target_field="order_id", params=params,
tool_fn=lambda: t.amend_order(client, params),
)
@r.post("/tools/cancel_order")
async def _co(
params: t.CancelOrderReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
return await audit_call(
request=request, exchange="ibkr", action="cancel_order",
target_field="order_id", params=params,
tool_fn=lambda: t.cancel_order(client, params),
)
@r.post("/tools/cancel_all_orders")
async def _cao(
params: t.CancelAllOrdersReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
return await audit_call(
request=request, exchange="ibkr", action="cancel_all_orders",
params=params, tool_fn=lambda: t.cancel_all_orders(client, params),
)
@r.post("/tools/close_position")
async def _cp(
params: t.ClosePositionReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
return await audit_call(
request=request, exchange="ibkr", action="close_position",
target_field="symbol", params=params,
tool_fn=lambda: t.close_position(client, params),
)
@r.post("/tools/close_all_positions")
async def _cap(
params: t.CloseAllPositionsReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
return await audit_call(
request=request, exchange="ibkr", action="close_all_positions",
params=params, tool_fn=lambda: t.close_all_positions(client, params),
)
# === WRITE complex ===
@r.post("/tools/place_bracket_order")
async def _pbo(
params: t.PlaceBracketOrderReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
creds = _build_creds(request)
return await audit_call(
request=request, exchange="ibkr", action="place_bracket_order",
target_field="symbol", params=params,
tool_fn=lambda: t.place_bracket_order(client, params, creds=creds),
)
@r.post("/tools/place_oco_order")
async def _poco(
params: t.PlaceOcoOrderReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
creds = _build_creds(request)
return await audit_call(
request=request, exchange="ibkr", action="place_oco_order",
params=params,
tool_fn=lambda: t.place_oco_order(client, params, creds=creds),
)
@r.post("/tools/place_oto_order")
async def _poto(
params: t.PlaceOtoOrderReq, request: Request,
client: IBKRClient = Depends(get_ibkr_client),
):
creds = _build_creds(request)
return await audit_call(
request=request, exchange="ibkr", action="place_oto_order",
params=params,
tool_fn=lambda: t.place_oto_order(client, params, creds=creds),
)
return r
+84
View File
@@ -1,7 +1,9 @@
"""Factory FastAPI app con middleware, swagger, exception handlers.""" """Factory FastAPI app con middleware, swagger, exception handlers."""
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import os
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -18,6 +20,7 @@ from cerbero_mcp.common.errors import (
RETRYABLE_STATUSES, RETRYABLE_STATUSES,
error_envelope, error_envelope,
) )
from cerbero_mcp.common.request_log import install_request_log_middleware
class _TimestampInjectorMiddleware(BaseHTTPMiddleware): class _TimestampInjectorMiddleware(BaseHTTPMiddleware):
@@ -99,6 +102,11 @@ def build_app(
app, testnet_token=testnet_token, mainnet_token=mainnet_token app, testnet_token=testnet_token, mainnet_token=mainnet_token
) )
# Request log middleware: registrato DOPO auth → starlette esegue
# i middleware in ordine inverso (LIFO) → request_log è outermost,
# auth è interno e popola request.state.* prima del ritorno.
install_request_log_middleware(app)
app.add_middleware(_TimestampInjectorMiddleware) app.add_middleware(_TimestampInjectorMiddleware)
@app.middleware("http") @app.middleware("http")
@@ -128,6 +136,7 @@ def build_app(
content=error_envelope( content=error_envelope(
type_="http_error", code=code, message=message, type_="http_error", code=code, message=message,
retryable=retryable, details=details, retryable=retryable, details=details,
request_id=getattr(request.state, "request_id", None),
), ),
) )
@@ -155,6 +164,7 @@ def build_app(
message=f"request body validation failed on {first_loc}", message=f"request body validation failed on {first_loc}",
retryable=False, suggested_fix=suggestion, retryable=False, suggested_fix=suggestion,
details={"errors": safe_errs}, details={"errors": safe_errs},
request_id=getattr(request.state, "request_id", None),
), ),
) )
@@ -166,6 +176,7 @@ def build_app(
type_="internal_error", code="UNHANDLED_EXCEPTION", type_="internal_error", code="UNHANDLED_EXCEPTION",
message=f"{type(exc).__name__}: {str(exc)[:300]}", message=f"{type(exc).__name__}: {str(exc)[:300]}",
retryable=True, retryable=True,
request_id=getattr(request.state, "request_id", None),
), ),
) )
@@ -179,6 +190,79 @@ def build_app(
"data_timestamp": datetime.now(UTC).isoformat(), "data_timestamp": datetime.now(UTC).isoformat(),
} }
@app.get("/health/ready", tags=["system"])
async def health_ready():
"""Readiness check: ping ogni client exchange cached.
- Itera ``app.state.registry._clients`` (se presente).
- Per ogni client prova ``health()`` (preferito) o ``is_testnet()``.
In assenza di metodo, marca con ``note: no probe method``.
- Timeout di 2s per client tramite ``asyncio.wait_for``.
- Stato globale: ``ready`` se tutti ok, ``degraded`` se almeno
uno fallisce, ``not_ready`` se registry vuoto.
- HTTP 200 di default; con ``READY_FAILS_ON_DEGRADED=true`` ritorna
503 quando lo stato non è ``ready`` (utile per probe k8s).
"""
registry = getattr(app.state, "registry", None)
clients_status: list[dict[str, Any]] = []
if registry is not None:
for (exchange, env), client in registry._clients.items():
t0 = time.perf_counter()
ping = (
getattr(client, "health", None)
or getattr(client, "is_testnet", None)
)
if ping is None:
clients_status.append({
"exchange": exchange,
"env": env,
"healthy": True,
"note": "no probe method",
})
continue
try:
res = ping()
if asyncio.iscoroutine(res):
await asyncio.wait_for(res, timeout=2.0)
dur = (time.perf_counter() - t0) * 1000
clients_status.append({
"exchange": exchange,
"env": env,
"healthy": True,
"duration_ms": round(dur, 2),
})
except Exception as e:
clients_status.append({
"exchange": exchange,
"env": env,
"healthy": False,
"error": f"{type(e).__name__}: {str(e)[:200]}",
})
if not clients_status:
status_label = "not_ready"
elif all(c["healthy"] for c in clients_status):
status_label = "ready"
else:
status_label = "degraded"
fail_on_degraded = os.environ.get(
"READY_FAILS_ON_DEGRADED", "false"
).lower() in ("1", "true", "yes")
http_code = 200
if fail_on_degraded and status_label != "ready":
http_code = 503
body = {
"status": status_label,
"name": title,
"version": version,
"uptime_seconds": int(time.time() - app.state.boot_at),
"data_timestamp": datetime.now(UTC).isoformat(),
"clients": clients_status,
}
return JSONResponse(status_code=http_code, content=body)
def _custom_openapi() -> dict[str, Any]: def _custom_openapi() -> dict[str, Any]:
if app.openapi_schema: if app.openapi_schema:
return app.openapi_schema return app.openapi_schema
+119 -2
View File
@@ -1,10 +1,22 @@
"""Pydantic Settings: legge .env e variabili d'ambiente.""" """Pydantic Settings: legge .env e variabili d'ambiente."""
from __future__ import annotations from __future__ import annotations
from typing import TypedDict
from pydantic import Field, SecretStr from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class IBKRCredentials(TypedDict):
consumer_key: str
access_token: str
access_token_secret: str
signature_key_path: str
encryption_key_path: str
account_id: str
dh_prime: str
class _Sub(BaseSettings): class _Sub(BaseSettings):
"""Base per sub-settings, condivide model_config con env_file.""" """Base per sub-settings, condivide model_config con env_file."""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -21,12 +33,33 @@ class DeribitSettings(_Sub):
env_prefix="DERIBIT_", env_prefix="DERIBIT_",
extra="ignore", extra="ignore",
) )
client_id: str client_id: str | None = None
client_secret: SecretStr client_secret: SecretStr | None = None
client_id_testnet: str | None = None
client_secret_testnet: SecretStr | None = None
client_id_live: str | None = None
client_secret_live: SecretStr | None = None
url_live: str url_live: str
url_testnet: str url_testnet: str
max_leverage: int = 3 max_leverage: int = 3
def credentials(self, env: str) -> tuple[str, str]:
"""Return (client_id, client_secret) for the given env.
Prefers env-specific (_TESTNET / _LIVE) pair; falls back to base
(DERIBIT_CLIENT_ID / DERIBIT_CLIENT_SECRET) for legacy single-pair setups.
"""
if env == "testnet":
cid = self.client_id_testnet or self.client_id
csec = self.client_secret_testnet or self.client_secret
elif env == "mainnet":
cid = self.client_id_live or self.client_id
csec = self.client_secret_live or self.client_secret
else:
raise ValueError(f"unknown deribit env: {env}")
if not cid or csec is None:
raise ValueError(f"Deribit credentials not configured for env={env}")
return cid, csec.get_secret_value()
class BybitSettings(_Sub): class BybitSettings(_Sub):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -71,6 +104,89 @@ class AlpacaSettings(_Sub):
max_leverage: int = 1 max_leverage: int = 1
class IBKRSettings(_Sub):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="IBKR_",
extra="ignore",
)
consumer_key: str | None = None
access_token: str | None = None
access_token_secret: SecretStr | None = None
signature_key_path: str | None = None
encryption_key_path: str | None = None
dh_prime: SecretStr | None = None
consumer_key_testnet: str | None = None
access_token_testnet: str | None = None
access_token_secret_testnet: SecretStr | None = None
signature_key_path_testnet: str | None = None
encryption_key_path_testnet: str | None = None
# account_id has no base variant: paper and live accounts are always distinct
account_id_testnet: str | None = None
consumer_key_live: str | None = None
access_token_live: str | None = None
access_token_secret_live: SecretStr | None = None
signature_key_path_live: str | None = None
encryption_key_path_live: str | None = None
account_id_live: str | None = None
url_live: str = "https://api.ibkr.com/v1/api"
url_testnet: str = "https://api.ibkr.com/v1/api"
ws_url_live: str = "wss://api.ibkr.com/v1/api/ws"
ws_url_testnet: str = "wss://api.ibkr.com/v1/api/ws"
max_leverage: int = 4
ws_max_subscriptions: int = 80
ws_idle_timeout_s: int = 300
def credentials(self, env: str) -> IBKRCredentials:
"""Return credential dict for given env.
Prefers env-specific (_TESTNET / _LIVE) values; falls back to base
(IBKR_CONSUMER_KEY etc.) for legacy single-pair setups.
ValueError if any required field missing.
"""
if env == "testnet":
ck = self.consumer_key_testnet or self.consumer_key
at = self.access_token_testnet or self.access_token
ats = self.access_token_secret_testnet or self.access_token_secret
sigp = self.signature_key_path_testnet or self.signature_key_path
encp = self.encryption_key_path_testnet or self.encryption_key_path
acct = self.account_id_testnet
elif env == "mainnet":
ck = self.consumer_key_live or self.consumer_key
at = self.access_token_live or self.access_token
ats = self.access_token_secret_live or self.access_token_secret
sigp = self.signature_key_path_live or self.signature_key_path
encp = self.encryption_key_path_live or self.encryption_key_path
acct = self.account_id_live
else:
raise ValueError(f"unknown ibkr env: {env}")
missing = [
n for n, v in [
("consumer_key", ck), ("access_token", at),
("access_token_secret", ats), ("signature_key_path", sigp),
("encryption_key_path", encp), ("account_id", acct),
("dh_prime", self.dh_prime),
] if not v
]
if missing:
raise ValueError(
f"IBKR credentials not configured for env={env}: missing {missing}"
)
return {
"consumer_key": ck,
"access_token": at,
"access_token_secret": ats.get_secret_value(), # type: ignore[union-attr]
"signature_key_path": sigp,
"encryption_key_path": encp,
"account_id": acct,
"dh_prime": self.dh_prime.get_secret_value(), # type: ignore[union-attr]
}
class MacroSettings(_Sub): class MacroSettings(_Sub):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
@@ -103,5 +219,6 @@ class Settings(_Sub):
bybit: BybitSettings = Field(default_factory=lambda: BybitSettings()) # type: ignore[call-arg] bybit: BybitSettings = Field(default_factory=lambda: BybitSettings()) # type: ignore[call-arg]
hyperliquid: HyperliquidSettings = Field(default_factory=lambda: HyperliquidSettings()) # type: ignore[call-arg] hyperliquid: HyperliquidSettings = Field(default_factory=lambda: HyperliquidSettings()) # type: ignore[call-arg]
alpaca: AlpacaSettings = Field(default_factory=lambda: AlpacaSettings()) # type: ignore[call-arg] alpaca: AlpacaSettings = Field(default_factory=lambda: AlpacaSettings()) # type: ignore[call-arg]
ibkr: IBKRSettings = Field(default_factory=lambda: IBKRSettings()) # type: ignore[call-arg]
macro: MacroSettings = Field(default_factory=lambda: MacroSettings()) # type: ignore[call-arg] macro: MacroSettings = Field(default_factory=lambda: MacroSettings()) # type: ignore[call-arg]
sentiment: SentimentSettings = Field(default_factory=lambda: SentimentSettings()) # type: ignore[call-arg] sentiment: SentimentSettings = Field(default_factory=lambda: SentimentSettings()) # type: ignore[call-arg]
+2 -2
View File
@@ -29,11 +29,11 @@ def app(monkeypatch):
def _bearer_test(): def _bearer_test():
return {"Authorization": "Bearer t_test_123"} return {"Authorization": "Bearer t_test_123", "X-Bot-Tag": "test-bot"}
def _bearer_live(): def _bearer_live():
return {"Authorization": "Bearer t_live_456"} return {"Authorization": "Bearer t_live_456", "X-Bot-Tag": "test-bot"}
# ── Spy helpers ────────────────────────────────────────────────────────────── # ── Spy helpers ──────────────────────────────────────────────────────────────
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import pytest
from pydantic import BaseModel
class FakeReq(BaseModel):
instrument_name: str
qty: float
@pytest.mark.asyncio
async def test_audit_call_logs_success(monkeypatch):
from cerbero_mcp.common.audit_helpers import audit_call
logged = []
def fake_audit(**kw):
logged.append(kw)
monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit)
class FakeRequest:
class _State:
environment = "testnet"
state = _State()
async def tool_fn():
return {"order_id": "abc123", "state": "filled"}
result = await audit_call(
request=FakeRequest(), # type: ignore[arg-type]
exchange="deribit",
action="place_order",
target_field="instrument_name",
params=FakeReq(instrument_name="BTC-PERPETUAL", qty=0.1),
tool_fn=tool_fn,
)
assert result == {"order_id": "abc123", "state": "filled"}
assert len(logged) == 1
rec = logged[0]
assert rec["actor"] == "testnet"
assert rec["exchange"] == "deribit"
assert rec["action"] == "place_order"
assert rec["target"] == "BTC-PERPETUAL"
assert rec["payload"]["qty"] == 0.1
assert rec["result"]["order_id"] == "abc123"
assert "error" not in rec or rec.get("error") is None
@pytest.mark.asyncio
async def test_audit_call_logs_error_and_reraises(monkeypatch):
from cerbero_mcp.common.audit_helpers import audit_call
logged = []
def fake_audit(**kw):
logged.append(kw)
monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit)
class FakeRequest:
class _State:
environment = "mainnet"
state = _State()
async def tool_fn():
raise RuntimeError("upstream timeout")
with pytest.raises(RuntimeError, match="upstream timeout"):
await audit_call(
request=FakeRequest(), # type: ignore[arg-type]
exchange="deribit",
action="cancel_order",
target_field="instrument_name",
params=FakeReq(instrument_name="BTC-PERPETUAL", qty=0.0),
tool_fn=tool_fn,
)
assert len(logged) == 1
rec = logged[0]
assert rec["actor"] == "mainnet"
assert "RuntimeError: upstream timeout" in rec["error"]
@pytest.mark.asyncio
async def test_audit_call_no_params_no_target():
from cerbero_mcp.common.audit_helpers import audit_call
class FakeRequest:
class _State:
environment = "testnet"
state = _State()
async def tool_fn():
return {"ok": True}
result = await audit_call(
request=FakeRequest(), # type: ignore[arg-type]
exchange="bybit",
action="cancel_all_orders",
tool_fn=tool_fn,
)
assert result == {"ok": True}
@pytest.mark.asyncio
async def test_audit_call_propagates_bot_tag(monkeypatch):
"""bot_tag letto da request.state e propagato a audit_write_op."""
from cerbero_mcp.common.audit_helpers import audit_call
logged = []
def fake_audit(**kw):
logged.append(kw)
monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit)
class FakeRequest:
class _State:
environment = "testnet"
bot_tag = "scanner-alpha"
state = _State()
async def tool_fn():
return {"order_id": "abc"}
await audit_call(
request=FakeRequest(), # type: ignore[arg-type]
exchange="deribit",
action="place_order",
target_field="instrument_name",
params=FakeReq(instrument_name="BTC-PERPETUAL", qty=0.1),
tool_fn=tool_fn,
)
assert len(logged) == 1
assert logged[0]["bot_tag"] == "scanner-alpha"
assert logged[0]["actor"] == "testnet"
@pytest.mark.asyncio
async def test_audit_call_bot_tag_none_when_missing(monkeypatch):
"""Se request.state.bot_tag non esiste, audit riceve None senza errore."""
from cerbero_mcp.common.audit_helpers import audit_call
logged = []
def fake_audit(**kw):
logged.append(kw)
monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit)
class FakeRequest:
class _State:
environment = "testnet"
state = _State()
async def tool_fn():
return {"ok": True}
await audit_call(
request=FakeRequest(), # type: ignore[arg-type]
exchange="bybit",
action="cancel_all_orders",
tool_fn=tool_fn,
)
assert len(logged) == 1
assert logged[0]["bot_tag"] is None
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
import pytest
from cerbero_mcp.common.candles import Candle, validate_candles
from fastapi import HTTPException
def test_valid_candle():
c = Candle(timestamp=1_700_000_000_000, open=100.0, high=110.0,
low=95.0, close=105.0, volume=12.5)
assert c.high == 110.0
def test_high_below_close_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=90, low=80, close=95, volume=1)
def test_high_below_open_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=90, low=80, close=85, volume=1)
def test_low_above_close_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=110, low=105, close=102, volume=1)
def test_low_above_open_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=95, high=110, low=100, close=105, volume=1)
def test_negative_volume_rejected():
with pytest.raises(ValueError):
Candle(timestamp=1, open=100, high=110, low=90, close=105, volume=-1)
def test_non_positive_timestamp_rejected():
with pytest.raises(ValueError):
Candle(timestamp=0, open=100, high=110, low=90, close=105, volume=1)
def test_validate_candles_sorts_by_timestamp():
raw = [
{"timestamp": 3, "open": 1, "high": 2, "low": 1, "close": 1, "volume": 0},
{"timestamp": 1, "open": 1, "high": 2, "low": 1, "close": 1, "volume": 0},
{"timestamp": 2, "open": 1, "high": 2, "low": 1, "close": 1, "volume": 0},
]
out = validate_candles(raw)
assert [c["timestamp"] for c in out] == [1, 2, 3]
def test_validate_candles_coerces_string_numerics():
raw = [{"timestamp": "1", "open": "100", "high": "110",
"low": "90", "close": "105", "volume": "10"}]
out = validate_candles(raw)
assert out[0]["open"] == 100.0
assert isinstance(out[0]["volume"], float)
def test_validate_candles_malformed_raises_http_502():
raw = [{"timestamp": 1, "open": 100, "high": 50, "low": 90,
"close": 105, "volume": 1}]
with pytest.raises(HTTPException) as exc_info:
validate_candles(raw)
assert exc_info.value.status_code == 502
assert "candle" in str(exc_info.value.detail).lower()
def test_validate_candles_empty_list():
assert validate_candles([]) == []
+13 -29
View File
@@ -1,39 +1,23 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock
import pytest import pytest
from cerbero_mcp.exchanges.alpaca.client import AlpacaClient from cerbero_mcp.exchanges.alpaca.client import AlpacaClient
@pytest.fixture @pytest.fixture
def mock_trading(): async def client():
return MagicMock(name="alpaca_TradingClient") """AlpacaClient paper su httpx mock (gestito da pytest-httpx)."""
c = AlpacaClient(api_key="test_key", secret_key="test_secret", paper=True)
try:
yield c
finally:
await c.aclose()
@pytest.fixture @pytest.fixture
def mock_stock(): async def client_live():
return MagicMock(name="alpaca_StockHistoricalDataClient") c = AlpacaClient(api_key="test_key", secret_key="test_secret", paper=False)
try:
yield c
@pytest.fixture finally:
def mock_crypto(): await c.aclose()
return MagicMock(name="alpaca_CryptoHistoricalDataClient")
@pytest.fixture
def mock_option():
return MagicMock(name="alpaca_OptionHistoricalDataClient")
@pytest.fixture
def client(mock_trading, mock_stock, mock_crypto, mock_option):
return AlpacaClient(
api_key="test_key",
secret_key="test_secret",
paper=True,
trading=mock_trading,
stock_data=mock_stock,
crypto_data=mock_crypto,
option_data=mock_option,
)
+366 -29
View File
@@ -1,80 +1,417 @@
"""Test AlpacaClient httpx-based (V2.0.0).
Mockano gli endpoint REST tramite pytest-httpx. Coprono account/positions,
ordini (place/cancel/limit-error), close position, clock, asset class
invalida → ValueError.
"""
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock import re
import pytest import pytest
from cerbero_mcp.exchanges.alpaca.client import AlpacaClient
from pytest_httpx import HTTPXMock
PAPER = "https://paper-api.alpaca.markets"
DATA = "https://data.alpaca.markets"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_init_paper_mode(client, mock_trading): async def test_init_paper_mode(client: AlpacaClient):
assert client.paper is True assert client.paper is True
assert client._trading is mock_trading assert client.base_url is None
assert client._trading_base == PAPER
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_account_calls_trading(client, mock_trading): async def test_init_live_mode(client_live: AlpacaClient):
mock_trading.get_account.return_value = MagicMock( assert client_live.paper is False
model_dump=lambda: {"equity": 100000, "cash": 50000} assert client_live._trading_base == "https://api.alpaca.markets"
@pytest.mark.asyncio
async def test_init_base_url_override():
c = AlpacaClient(
api_key="k",
secret_key="s",
paper=True,
base_url="https://alpaca-custom.example.com",
)
try:
assert c.base_url == "https://alpaca-custom.example.com"
assert c._trading_base == "https://alpaca-custom.example.com"
# Data endpoint NON viene overridato
assert c._data_base == DATA
finally:
await c.aclose()
@pytest.mark.asyncio
async def test_get_account(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=f"{PAPER}/v2/account",
json={"id": "abc", "equity": "100000.00", "buying_power": "200000.00"},
) )
result = await client.get_account() result = await client.get_account()
mock_trading.get_account.assert_called_once() assert result["id"] == "abc"
assert result["equity"] == 100000 assert result["equity"] == "100000.00"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_positions_returns_list(client, mock_trading): async def test_get_account_sends_auth_headers(
pos_mock = MagicMock(model_dump=lambda: {"symbol": "AAPL", "qty": 10}) httpx_mock: HTTPXMock, client: AlpacaClient
mock_trading.get_all_positions.return_value = [pos_mock] ):
httpx_mock.add_response(url=f"{PAPER}/v2/account", json={"id": "x"})
await client.get_account()
req = httpx_mock.get_requests()[0]
assert req.headers["APCA-API-KEY-ID"] == "test_key"
assert req.headers["APCA-API-SECRET-KEY"] == "test_secret"
assert req.headers["Accept"] == "application/json"
@pytest.mark.asyncio
async def test_get_positions_returns_list(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
url=f"{PAPER}/v2/positions",
json=[{"symbol": "AAPL", "qty": "10", "side": "long"}],
)
result = await client.get_positions() result = await client.get_positions()
assert len(result) == 1 assert len(result) == 1
assert result[0]["symbol"] == "AAPL" assert result[0]["symbol"] == "AAPL"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_place_market_order_stocks(client, mock_trading): async def test_place_market_order_stocks(
order_mock = MagicMock(model_dump=lambda: {"id": "o123", "symbol": "AAPL"}) httpx_mock: HTTPXMock, client: AlpacaClient
mock_trading.submit_order.return_value = order_mock ):
httpx_mock.add_response(
method="POST",
url=f"{PAPER}/v2/orders",
json={"id": "o123", "symbol": "AAPL", "status": "accepted"},
)
result = await client.place_order( result = await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="market", asset_class="stocks", symbol="AAPL", side="buy", qty=1, order_type="market", asset_class="stocks"
) )
assert result["id"] == "o123" assert result["id"] == "o123"
assert mock_trading.submit_order.called # body POST corretto
req = httpx_mock.get_requests()[0]
import json as _j
body = _j.loads(req.content)
assert body["symbol"] == "AAPL"
assert body["side"] == "buy"
assert body["type"] == "market"
assert body["qty"] == "1"
assert body["time_in_force"] == "day"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_place_limit_order_requires_price(client): async def test_place_limit_order_requires_price(client: AlpacaClient):
with pytest.raises(ValueError, match="limit_price"): with pytest.raises(ValueError, match="limit_price"):
await client.place_order( await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="limit", symbol="AAPL", side="buy", qty=1, order_type="limit"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_order(client, mock_trading): async def test_place_stop_order_requires_price(client: AlpacaClient):
mock_trading.cancel_order_by_id.return_value = None with pytest.raises(ValueError, match="stop_price"):
await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="stop"
)
@pytest.mark.asyncio
async def test_place_unsupported_order_type(client: AlpacaClient):
with pytest.raises(ValueError, match="unsupported order_type"):
await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="trailing_stop"
)
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client: AlpacaClient):
# 204 No Content su success
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/orders/o1",
status_code=204,
)
result = await client.cancel_order("o1") result = await client.cancel_order("o1")
mock_trading.cancel_order_by_id.assert_called_once_with("o1")
assert result == {"order_id": "o1", "canceled": True} assert result == {"order_id": "o1", "canceled": True}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_position_no_options(client, mock_trading): async def test_cancel_all_orders(httpx_mock: HTTPXMock, client: AlpacaClient):
order_mock = MagicMock(model_dump=lambda: {"id": "close-1"}) httpx_mock.add_response(
mock_trading.close_position.return_value = order_mock method="DELETE",
url=f"{PAPER}/v2/orders",
json=[
{"id": "a", "status": 200},
{"id": "b", "status": 200},
],
)
result = await client.cancel_all_orders()
assert len(result) == 2
@pytest.mark.asyncio
async def test_close_position_no_options(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/positions/AAPL",
json={"id": "close-1", "symbol": "AAPL"},
)
result = await client.close_position("AAPL") result = await client.close_position("AAPL")
assert mock_trading.close_position.called
assert result["id"] == "close-1" assert result["id"] == "close-1"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_clock(client, mock_trading): async def test_close_position_with_qty(
clock_mock = MagicMock(model_dump=lambda: {"is_open": True, "next_close": "2026-04-21T20:00:00Z"}) httpx_mock: HTTPXMock, client: AlpacaClient
mock_trading.get_clock.return_value = clock_mock ):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/positions/AAPL?qty=5.0",
json={"id": "close-2"},
)
result = await client.close_position("AAPL", qty=5.0)
assert result["id"] == "close-2"
@pytest.mark.asyncio
async def test_close_all_positions(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
method="DELETE",
url=f"{PAPER}/v2/positions?cancel_orders=true",
json=[{"symbol": "AAPL", "status": 200}],
)
result = await client.close_all_positions(cancel_orders=True)
assert len(result) == 1
assert result[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_get_clock(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=f"{PAPER}/v2/clock",
json={"is_open": True, "next_close": "2026-04-21T20:00:00Z"},
)
result = await client.get_clock() result = await client.get_clock()
assert result["is_open"] is True assert result["is_open"] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_asset_class(client): async def test_invalid_asset_class(client: AlpacaClient):
with pytest.raises(ValueError, match="invalid asset_class"): with pytest.raises(ValueError, match="invalid asset_class"):
await client.get_ticker("AAPL", asset_class="forex") await client.get_ticker("AAPL", asset_class="forex")
@pytest.mark.asyncio
async def test_get_ticker_stocks(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=f"{DATA}/v2/stocks/AAPL/trades/latest",
json={
"symbol": "AAPL",
"trade": {"p": 175.50, "s": 100, "t": "2026-04-18T15:30:00Z"},
},
)
httpx_mock.add_response(
url=f"{DATA}/v2/stocks/AAPL/quotes/latest",
json={
"symbol": "AAPL",
"quote": {
"bp": 175.40,
"ap": 175.55,
"bs": 50,
"as": 25,
"t": "2026-04-18T15:30:00Z",
},
},
)
result = await client.get_ticker("AAPL", asset_class="stocks")
assert result["asset_class"] == "stocks"
assert result["last_price"] == 175.50
assert result["bid"] == 175.40
assert result["ask"] == 175.55
@pytest.mark.asyncio
async def test_get_bars_stocks(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{DATA}/v2/stocks/bars\?.*"),
json={
"bars": {
"AAPL": [
{
"t": "2026-04-17T00:00:00Z",
"o": 170.0,
"h": 176.0,
"l": 169.5,
"c": 175.0,
"v": 1000000,
}
]
}
},
)
result = await client.get_bars(
symbol="AAPL",
asset_class="stocks",
interval="1d",
start="2026-04-17T00:00:00",
end="2026-04-18T00:00:00",
limit=10,
)
assert result["symbol"] == "AAPL"
assert result["interval"] == "1d"
assert len(result["candles"]) == 1
assert result["candles"][0]["close"] == 175.0
@pytest.mark.asyncio
async def test_get_bars_unsupported_timeframe(client: AlpacaClient):
with pytest.raises(ValueError, match="unsupported timeframe"):
await client.get_bars(
symbol="AAPL",
asset_class="stocks",
interval="3min",
)
@pytest.mark.asyncio
async def test_get_bars_invalid_asset_class(client: AlpacaClient):
with pytest.raises(ValueError, match="invalid asset_class"):
await client.get_bars(symbol="AAPL", asset_class="forex")
@pytest.mark.asyncio
async def test_get_assets(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/assets\?.*"),
json=[
{"symbol": "AAPL", "tradable": True, "class": "us_equity"},
{"symbol": "GOOG", "tradable": True, "class": "us_equity"},
],
)
result = await client.get_assets(asset_class="stocks", status="active")
assert len(result) == 2
assert result[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_get_assets_invalid_class(client: AlpacaClient):
with pytest.raises(ValueError, match="invalid asset_class"):
await client.get_assets(asset_class="forex")
@pytest.mark.asyncio
async def test_get_open_orders(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/orders\?.*"),
json=[{"id": "o1", "status": "open", "symbol": "AAPL"}],
)
result = await client.get_open_orders(limit=10)
assert len(result) == 1
assert result[0]["id"] == "o1"
@pytest.mark.asyncio
async def test_amend_order(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
method="PATCH",
url=f"{PAPER}/v2/orders/o1",
json={"id": "o1", "qty": "5", "limit_price": "180.0"},
)
result = await client.amend_order(
"o1", qty=5, limit_price=180.0, tif="gtc"
)
assert result["id"] == "o1"
req = httpx_mock.get_requests()[0]
import json as _j
body = _j.loads(req.content)
assert body["qty"] == "5"
assert body["limit_price"] == "180.0"
assert body["time_in_force"] == "gtc"
@pytest.mark.asyncio
async def test_get_calendar(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/calendar.*"),
json=[{"date": "2026-04-20", "open": "09:30", "close": "16:00"}],
)
result = await client.get_calendar(start="2026-04-20", end="2026-04-20")
assert len(result) == 1
assert result[0]["date"] == "2026-04-20"
@pytest.mark.asyncio
async def test_get_calendar_no_filters(
httpx_mock: HTTPXMock, client: AlpacaClient
):
httpx_mock.add_response(
url=f"{PAPER}/v2/calendar",
json=[{"date": "2026-04-20"}],
)
result = await client.get_calendar()
assert len(result) == 1
@pytest.mark.asyncio
async def test_get_snapshot(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{DATA}/v2/stocks/snapshots\?.*"),
json={
"AAPL": {
"latestTrade": {"p": 175.0},
"latestQuote": {"bp": 174.9, "ap": 175.1},
}
},
)
result = await client.get_snapshot("AAPL")
assert result["latestTrade"]["p"] == 175.0
@pytest.mark.asyncio
async def test_get_option_chain(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{DATA}/v1beta1/options/snapshots/AAPL.*"),
json={
"snapshots": {
"AAPL250620C00200000": {
"latestQuote": {"bp": 1.20, "ap": 1.30}
}
}
},
)
result = await client.get_option_chain("AAPL", expiry="2026-06-20")
assert result["underlying"] == "AAPL"
assert result["expiry"] == "2026-06-20"
assert "AAPL250620C00200000" in result["contracts"]
@pytest.mark.asyncio
async def test_get_activities(httpx_mock: HTTPXMock, client: AlpacaClient):
httpx_mock.add_response(
url=re.compile(rf"^{PAPER}/v2/account/activities.*"),
json=[
{"id": "1", "activity_type": "FILL"},
{"id": "2", "activity_type": "TRANS"},
],
)
result = await client.get_activities(limit=10)
assert len(result) == 2
@pytest.mark.asyncio
async def test_aclose_idempotent(client: AlpacaClient):
await client.aclose()
await client.aclose() # nessun raise
+5 -8
View File
@@ -1,21 +1,18 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import MagicMock
import pytest import pytest
from cerbero_mcp.exchanges.bybit.client import BybitClient from cerbero_mcp.exchanges.bybit.client import BybitClient
@pytest.fixture @pytest.fixture
def mock_http(): def client():
return MagicMock(name="pybit_HTTP") """BybitClient con base_url testnet e AsyncClient interno.
pytest-httpx intercetta le chiamate dell'AsyncClient httpx creato dal
@pytest.fixture costruttore (auto-mock), quindi non serve injection esplicita.
def client(mock_http): """
return BybitClient( return BybitClient(
api_key="test_key", api_key="test_key",
api_secret="test_secret", api_secret="test_secret",
testnet=True, testnet=True,
http=mock_http,
) )
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from typing import Any
import pytest
from cerbero_mcp.exchanges.cross.client import CrossClient
from fastapi import HTTPException
class _Fake:
def __init__(self, candles: list[dict[str, Any]] | None = None,
*, raises: Exception | None = None):
self._candles = candles or []
self._raises = raises
self.calls: list[dict[str, Any]] = []
async def get_historical(self, **kwargs: Any) -> dict[str, Any]:
if self._raises:
raise self._raises
self.calls.append(kwargs)
return {"candles": list(self._candles)}
async def get_bars(self, **kwargs: Any) -> dict[str, Any]:
if self._raises:
raise self._raises
self.calls.append(kwargs)
return {"candles": list(self._candles)}
class _FakeRegistry:
def __init__(self, clients: dict[str, _Fake]):
self._clients = clients
async def get(self, exchange: str, env: str) -> _Fake:
if exchange not in self._clients:
raise KeyError(exchange)
return self._clients[exchange]
def _c(ts: int, close: float = 100.0) -> dict[str, Any]:
return {"timestamp": ts, "open": close, "high": close, "low": close,
"close": close, "volume": 1.0}
@pytest.mark.asyncio
async def test_crypto_three_sources_aggregates():
fakes = {
"bybit": _Fake([_c(1, 100), _c(2, 200)]),
"hyperliquid": _Fake([_c(1, 100), _c(2, 200)]),
"deribit": _Fake([_c(1, 100), _c(2, 200)]),
}
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
out = await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert out["symbol"] == "BTC"
assert out["asset_class"] == "crypto"
assert len(out["candles"]) == 2
assert out["candles"][0]["sources"] == 3
assert out["candles"][0]["div_pct"] == 0.0
assert set(out["sources_used"]) == {"bybit", "hyperliquid", "deribit"}
assert out["failed_sources"] == []
@pytest.mark.asyncio
async def test_crypto_partial_failure_returns_partial_with_warning():
fakes = {
"bybit": _Fake([_c(1, 100)]),
"hyperliquid": _Fake([_c(1, 100)]),
"deribit": _Fake(raises=RuntimeError("upstream down")),
}
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
out = await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert out["candles"][0]["sources"] == 2
assert set(out["sources_used"]) == {"bybit", "hyperliquid"}
assert len(out["failed_sources"]) == 1
assert out["failed_sources"][0]["exchange"] == "deribit"
assert "upstream down" in out["failed_sources"][0]["error"]
@pytest.mark.asyncio
async def test_all_sources_fail_raises_502():
fakes = {
"bybit": _Fake(raises=RuntimeError("a")),
"hyperliquid": _Fake(raises=RuntimeError("b")),
"deribit": _Fake(raises=RuntimeError("c")),
}
cc = CrossClient(_FakeRegistry(fakes), env="mainnet")
with pytest.raises(HTTPException) as exc_info:
await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert exc_info.value.status_code == 502
@pytest.mark.asyncio
async def test_unsupported_symbol_raises_400():
cc = CrossClient(_FakeRegistry({}), env="mainnet")
with pytest.raises(HTTPException) as exc_info:
await cc.get_historical(
symbol="NONEXISTENT", asset_class="crypto", interval="1h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_stocks_routes_to_alpaca_only():
fake = _Fake([_c(1, 175.0)])
cc = CrossClient(_FakeRegistry({"alpaca": fake}), env="mainnet")
out = await cc.get_historical(
symbol="AAPL", asset_class="stocks", interval="1d",
start_date="2026-04-09T00:00:00", end_date="2026-04-10T00:00:00",
)
assert out["sources_used"] == ["alpaca"]
assert out["candles"][0]["close"] == 175.0
# Alpaca was called with native symbol
assert fake.calls[0]["symbol"] == "AAPL"
@pytest.mark.asyncio
async def test_unsupported_interval_raises_400():
cc = CrossClient(_FakeRegistry({}), env="mainnet")
with pytest.raises(HTTPException) as exc_info:
await cc.get_historical(
symbol="BTC", asset_class="crypto", interval="3h",
start_date="2026-05-09T00:00:00", end_date="2026-05-10T00:00:00",
)
assert exc_info.value.status_code == 400
@@ -0,0 +1,90 @@
from __future__ import annotations
from cerbero_mcp.exchanges.cross.consensus import merge_candles
def _c(ts, o, h, l, c, v):
return {"timestamp": ts, "open": o, "high": h, "low": l, "close": c, "volume": v}
def test_empty_input():
assert merge_candles({}) == []
def test_single_source_passthrough():
out = merge_candles({"bybit": [_c(1, 100, 110, 90, 105, 5)]})
assert len(out) == 1
assert out[0]["timestamp"] == 1
assert out[0]["close"] == 105
assert out[0]["sources"] == 1
assert out[0]["div_pct"] == 0.0
def test_three_sources_identical_no_divergence():
src = {
"bybit": [_c(1, 100, 110, 90, 105, 5)],
"hyperliquid": [_c(1, 100, 110, 90, 105, 3)],
"deribit": [_c(1, 100, 110, 90, 105, 7)],
}
out = merge_candles(src)
assert len(out) == 1
assert out[0]["close"] == 105.0
assert out[0]["sources"] == 3
assert out[0]["div_pct"] == 0.0
# volume is mean across sources
assert abs(out[0]["volume"] - 5.0) < 1e-9
def test_three_sources_divergent_close():
src = {
"bybit": [_c(1, 100, 110, 90, 100, 1)],
"hyperliquid": [_c(1, 100, 110, 90, 110, 1)],
"deribit": [_c(1, 100, 110, 90, 105, 1)],
}
out = merge_candles(src)
# median of [100, 110, 105] = 105
assert out[0]["close"] == 105.0
# div_pct = (110 - 100) / 105 ≈ 0.0952
assert abs(out[0]["div_pct"] - 10 / 105) < 1e-6
assert out[0]["sources"] == 3
def test_misaligned_timestamps():
src = {
"bybit": [_c(1, 100, 110, 90, 105, 1), _c(2, 100, 110, 90, 105, 1)],
"hyperliquid": [_c(2, 100, 110, 90, 105, 1), _c(3, 100, 110, 90, 105, 1)],
}
out = merge_candles(src)
timestamps = [c["timestamp"] for c in out]
sources_by_ts = {c["timestamp"]: c["sources"] for c in out}
assert timestamps == [1, 2, 3]
assert sources_by_ts == {1: 1, 2: 2, 3: 1}
def test_two_sources_even_median():
src = {
"bybit": [_c(1, 100, 110, 90, 100, 1)],
"hyperliquid": [_c(1, 100, 110, 90, 110, 1)],
}
out = merge_candles(src)
# even median = mean of two = 105
assert out[0]["close"] == 105.0
def test_empty_source_ignored():
src = {
"bybit": [_c(1, 100, 110, 90, 105, 1)],
"hyperliquid": [],
}
out = merge_candles(src)
assert len(out) == 1
assert out[0]["sources"] == 1
def test_output_sorted_by_timestamp():
src = {
"bybit": [_c(3, 100, 110, 90, 105, 1), _c(1, 100, 110, 90, 105, 1),
_c(2, 100, 110, 90, 105, 1)],
}
out = merge_candles(src)
assert [c["timestamp"] for c in out] == [1, 2, 3]
@@ -0,0 +1,47 @@
from __future__ import annotations
import pytest
from cerbero_mcp.exchanges.cross.symbol_map import (
get_sources,
to_native_interval,
to_native_symbol,
)
def test_btc_crypto_sources():
assert set(get_sources("crypto", "BTC")) == {"bybit", "hyperliquid", "deribit"}
def test_eth_crypto_sources():
assert set(get_sources("crypto", "ETH")) == {"bybit", "hyperliquid", "deribit"}
def test_unknown_crypto_symbol_returns_empty():
assert get_sources("crypto", "DOGEFAKE") == []
def test_stocks_aapl_sources():
assert set(get_sources("stocks", "AAPL")) == {"alpaca"}
def test_native_symbol_btc():
assert to_native_symbol("crypto", "BTC", "bybit") == "BTCUSDT"
assert to_native_symbol("crypto", "BTC", "hyperliquid") == "BTC"
assert to_native_symbol("crypto", "BTC", "deribit") == "BTC-PERPETUAL"
def test_native_symbol_unsupported_pair_raises():
with pytest.raises(KeyError):
to_native_symbol("crypto", "BTC", "alpaca")
def test_native_interval_1h():
assert to_native_interval("1h", "bybit") == "60"
assert to_native_interval("1h", "hyperliquid") == "1h"
assert to_native_interval("1h", "deribit") == "1h"
assert to_native_interval("1h", "alpaca") == "1h"
def test_native_interval_unknown_canonical_raises():
with pytest.raises(KeyError):
to_native_interval("3h", "bybit")
@@ -154,6 +154,47 @@ async def test_get_account_summary(httpx_mock: HTTPXMock, client: DeribitClient)
assert result["balance"] == 900.0 assert result["balance"] == 900.0
@pytest.mark.asyncio
async def test_upstream_5xx_raises_clean_http_error(
httpx_mock: HTTPXMock, client: DeribitClient
):
"""Upstream Deribit 5xx (non-JSON body) → HTTPException 502, non JSONDecodeError."""
from fastapi import HTTPException
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/get_tradingview_chart_data"),
status_code=502,
text="<html>Bad Gateway</html>",
)
with pytest.raises(HTTPException) as exc_info:
await client.get_historical(
instrument="BTC-PERPETUAL",
start_date="2026-05-09T00:00:00",
end_date="2026-05-10T00:00:00",
resolution="1h",
)
assert exc_info.value.status_code == 502
assert "Deribit upstream" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_private_call_with_bad_auth_returns_error_envelope(
httpx_mock: HTTPXMock, client: DeribitClient
):
"""Auth fallita (creds errate / scope mancante) → error envelope, non KeyError."""
httpx_mock.add_response(
url=re.compile(r"https://test\.deribit\.com/api/v2/public/auth"),
json={"error": {"code": 13004, "message": "invalid_credentials"}},
is_reusable=True,
)
summary = await client.get_account_summary("USDC")
assert summary["equity"] is None
assert summary["balance"] is None
assert "invalid_credentials" in summary["error"]
positions = await client.get_positions("USDC")
assert positions == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_place_order(httpx_mock: HTTPXMock, client: DeribitClient): async def test_place_order(httpx_mock: HTTPXMock, client: DeribitClient):
httpx_mock.add_response( httpx_mock.add_response(
+275 -17
View File
@@ -6,12 +6,16 @@ import pytest
from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient
from pytest_httpx import HTTPXMock from pytest_httpx import HTTPXMock
# Chiave privata fissa: rende deterministica la firma EIP-712 per i test write.
DUMMY_PRIVATE_KEY = "0x" + "01" * 32
DUMMY_WALLET = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1" # derived from key above
@pytest.fixture @pytest.fixture
def client(): def client():
return HyperliquidClient( return HyperliquidClient(
wallet_address="0xDeadBeef", wallet_address=DUMMY_WALLET,
private_key="0x" + "a" * 64, private_key=DUMMY_PRIVATE_KEY,
testnet=True, testnet=True,
) )
@@ -41,6 +45,13 @@ META_AND_CTX = [
], ],
] ]
META = {
"universe": [
{"name": "BTC", "maxLeverage": 50},
{"name": "ETH", "maxLeverage": 25},
]
}
CLEARINGHOUSE_STATE = { CLEARINGHOUSE_STATE = {
"marginSummary": { "marginSummary": {
"accountValue": "1500.0", "accountValue": "1500.0",
@@ -65,6 +76,9 @@ CLEARINGHOUSE_STATE = {
SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]} SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]}
# ── Read endpoints ─────────────────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient): async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response( httpx_mock.add_response(
@@ -209,19 +223,263 @@ async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
assert result["testnet"] is True assert result["testnet"] is True
@pytest.mark.asyncio # ── Write endpoints (signed via EIP-712) ───────────────────────
async def test_place_order_sdk_unavailable(client: HyperliquidClient):
"""place_order raises RuntimeError when SDK is not available (mocked)."""
import cerbero_mcp.exchanges.hyperliquid.client as mod
original = mod._SDK_AVAILABLE
mod._SDK_AVAILABLE = False @pytest.mark.asyncio
client._exchange = None async def test_place_order_limit(httpx_mock: HTTPXMock, client: HyperliquidClient):
try: """Limit order: signs and POSTs to /exchange with correct payload shape."""
result = await client.place_order("BTC", "buy", 0.1, price=50000.0) # 1. /info type=meta per asset id
# Should return error dict or raise RuntimeError httpx_mock.add_response(
assert "error" in result or result.get("status") == "error" url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
except RuntimeError as exc: json=META,
assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower() )
finally: # 2. /exchange firmato
mod._SDK_AVAILABLE = original httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
json={
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": [{"resting": {"oid": 9999}}],
},
},
},
)
result = await client.place_order(
instrument="BTC", side="buy", amount=0.01, type="limit", price=50000.0
)
# Verifica shape risposta normalizzata
assert result["status"] == "ok"
assert result["order_id"] == 9999
# Verifica request body al POST /exchange
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
assert len(requests) == 1
import json as _json
body = _json.loads(requests[0].content)
assert body["nonce"] > 0
assert body["vaultAddress"] is None
assert body["expiresAfter"] is None
assert body["action"]["type"] == "order"
assert body["action"]["grouping"] == "na"
assert len(body["action"]["orders"]) == 1
order = body["action"]["orders"][0]
assert order["a"] == 0 # BTC è index 0 in META
assert order["b"] is True # buy
assert order["p"] == "50000"
assert order["s"] == "0.01"
assert order["r"] is False
assert order["t"] == {"limit": {"tif": "Gtc"}}
sig = body["signature"]
assert set(sig.keys()) == {"r", "s", "v"}
assert sig["r"].startswith("0x") and len(sig["r"]) == 66
assert sig["s"].startswith("0x") and len(sig["s"]) == 66
assert sig["v"] in (27, 28)
@pytest.mark.asyncio
async def test_place_order_market(httpx_mock: HTTPXMock, client: HyperliquidClient):
"""Market order: usa mark_price + buffer e tif=Ioc."""
# market path: get_ticker → meta+ctxs, poi meta per asset id, poi /exchange
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META_AND_CTX,
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META,
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
json={
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": [{"filled": {"oid": 1, "totalSz": "0.01", "avgPx": "51500"}}],
},
},
},
)
result = await client.place_order(
instrument="BTC", side="buy", amount=0.01, type="market"
)
assert result["status"] == "ok"
assert result["filled_size"] == 0.01
import json as _json
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
assert len(requests) == 1
body = _json.loads(requests[0].content)
order = body["action"]["orders"][0]
assert order["t"] == {"limit": {"tif": "Ioc"}}
@pytest.mark.asyncio
async def test_place_order_stop_loss(httpx_mock: HTTPXMock, client: HyperliquidClient):
"""Stop-loss: usa trigger order type."""
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META,
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
json={
"status": "ok",
"response": {"type": "order", "data": {"statuses": [{"resting": {"oid": 7}}]}},
},
)
result = await client.place_order(
instrument="BTC",
side="sell",
amount=0.01,
type="stop_loss",
price=45000.0,
reduce_only=True,
)
assert result["status"] == "ok"
assert result["order_id"] == 7
import json as _json
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
body = _json.loads(requests[0].content)
order = body["action"]["orders"][0]
assert order["r"] is True
assert order["t"] == {
"trigger": {"isMarket": True, "triggerPx": "45000", "tpsl": "sl"}
}
@pytest.mark.asyncio
async def test_place_order_unknown_asset(httpx_mock: HTTPXMock, client: HyperliquidClient):
"""Asset sconosciuto → error dict, niente POST /exchange."""
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META,
)
result = await client.place_order(
instrument="DOGE", side="buy", amount=1.0, type="limit", price=0.1
)
assert "error" in result
assert "DOGE" in result["error"]
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client: HyperliquidClient):
"""Cancel: action.type=cancel con asset id + oid."""
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META,
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
json={"status": "ok", "response": {"type": "cancel", "data": {"statuses": ["success"]}}},
)
result = await client.cancel_order("12345", "BTC")
assert result["status"] == "ok"
assert result["order_id"] == "12345"
import json as _json
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
body = _json.loads(requests[0].content)
assert body["action"]["type"] == "cancel"
assert body["action"]["cancels"] == [{"a": 0, "o": 12345}]
assert "r" in body["signature"] and "s" in body["signature"] and "v" in body["signature"]
@pytest.mark.asyncio
async def test_close_position(httpx_mock: HTTPXMock, client: HyperliquidClient):
"""close_position: legge stato, calcola slippage, place IOC reduce-only."""
# 1. clearinghouseState per direzione
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=CLEARINGHOUSE_STATE,
)
# 2. get_ticker → metaAndAssetCtxs
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META_AND_CTX,
)
# 3. meta per asset id
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META,
)
# 4. /exchange
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/exchange"),
json={
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": [{"filled": {"oid": 5, "totalSz": "0.1", "avgPx": "47500"}}],
},
},
},
)
result = await client.close_position("BTC")
assert result["status"] == "ok"
assert result["asset"] == "BTC"
import json as _json
requests = [r for r in httpx_mock.get_requests() if r.url.path == "/exchange"]
body = _json.loads(requests[0].content)
order = body["action"]["orders"][0]
# Posizione long → side=sell per chiudere
assert order["b"] is False
assert order["r"] is True
@pytest.mark.asyncio
async def test_close_position_no_position(
httpx_mock: HTTPXMock, client: HyperliquidClient
):
"""close_position senza posizione aperta → error dict."""
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json={"assetPositions": [], "marginSummary": {}},
)
result = await client.close_position("BTC")
assert "error" in result
assert result["asset"] == "BTC"
@pytest.mark.asyncio
async def test_signing_parity_with_canonical_sdk(client: HyperliquidClient):
"""Sanity: la firma EIP-712 prodotta è bit-for-bit identica a quella che
genererebbe il SDK ufficiale ``hyperliquid-python-sdk`` per lo stesso input.
Test isolato (no httpx) per garantire che la rimozione del SDK runtime
non introduca regressioni di signing.
"""
from cerbero_mcp.exchanges.hyperliquid.client import _sign_l1_action
action = {"type": "cancel", "cancels": [{"a": 0, "o": 12345}]}
nonce = 1700000000000
sig = _sign_l1_action(DUMMY_PRIVATE_KEY, action, None, nonce, None, False)
assert sig == {
"r": "0xab1150f8d695e015a07e3f79983a0a2a4e58dedec071dfa4177a0761f37e0485",
"s": "0x208cb6370e5e56a3cefa451538c1e0096b70777d2bde172c7afb1e77c4d28d20",
"v": 28,
}
@pytest.mark.asyncio
async def test_aclose_idempotent(client: HyperliquidClient):
"""``aclose`` può essere chiamato anche senza http client attivo."""
await client.aclose()
await client.aclose()
+232
View File
@@ -0,0 +1,232 @@
from __future__ import annotations
import re
from unittest.mock import AsyncMock, MagicMock
import pytest
from cerbero_mcp.exchanges.ibkr.client import IBKRClient, IBKRError
from pytest_httpx import HTTPXMock
@pytest.fixture
def fake_signer():
s = MagicMock()
s.consumer_key = "CK"
s.access_token = "AT"
s.get_live_session_token = AsyncMock(return_value="LSTBASE64==")
s.sign_with_lst = MagicMock(return_value="SIG==")
s.make_oauth_params = MagicMock(return_value={
"oauth_consumer_key": "CK", "oauth_token": "AT",
"oauth_nonce": "n", "oauth_timestamp": "1",
"oauth_signature_method": "HMAC-SHA256", "oauth_version": "1.0",
})
return s
@pytest.fixture
def client(fake_signer):
return IBKRClient(
signer=fake_signer,
account_id="DU1234",
paper=True,
base_url="https://api.ibkr.com/v1/api",
)
@pytest.mark.asyncio
async def test_health_no_network(client):
info = await client.health()
assert info["status"] == "ok"
assert info["paper"] is True
@pytest.mark.asyncio
async def test_get_account_summary(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
json={"netliquidation": {"amount": 10000, "currency": "USD"}, "totalcashvalue": {"amount": 8000}},
)
httpx_mock.add_response(
url=re.compile(r".*/tickle"),
json={"session": "abc"},
)
data = await client.get_account()
assert "netliquidation" in data
@pytest.mark.asyncio
async def test_request_retries_once_on_401(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
# First call returns 401
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
status_code=401, text="session expired",
)
# Second call (after LST refresh) succeeds
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
json={"netliquidation": {"amount": 5000}},
)
data = await client.get_account()
assert data["netliquidation"]["amount"] == 5000
@pytest.mark.asyncio
async def test_request_raises_on_persistent_401(httpx_mock: HTTPXMock, client):
from cerbero_mcp.exchanges.ibkr.oauth import IBKRAuthError
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
# Both attempts return 401
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
status_code=401, text="bad creds",
)
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/summary"),
status_code=401, text="bad creds",
)
with pytest.raises(IBKRAuthError, match="after retry"):
await client.get_account()
@pytest.mark.asyncio
async def test_resolve_conid_caches(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(
url=re.compile(r".*/tickle"), json={"session": "x"},
)
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search.*symbol=AAPL"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
cid = await client.resolve_conid("AAPL", "STK")
assert cid == 265598
cid2 = await client.resolve_conid("AAPL", "STK")
assert cid2 == 265598
@pytest.mark.asyncio
async def test_get_positions(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/portfolio/DU1234/positions/0"),
json=[{"conid": 265598, "position": 10, "mktPrice": 150}],
)
res = await client.get_positions()
assert isinstance(res, list)
assert res[0]["position"] == 10
@pytest.mark.asyncio
async def test_get_ticker_resolves_and_fetches(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search.*symbol=AAPL"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/marketdata/snapshot"),
json=[{"31": "150.5", "84": "150.4", "86": "150.6", "conid": 265598}],
)
snap = await client.get_ticker("AAPL", "stocks")
assert snap["last_price"] == 150.5
assert snap["bid"] == 150.4
assert snap["ask"] == 150.6
@pytest.mark.asyncio
async def test_resolve_conid_empty_response_raises(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search.*symbol=NOPE"),
json=[],
)
with pytest.raises(IBKRError, match="IBKR_CONID_NOT_FOUND"):
await client.resolve_conid("NOPE", "STK")
@pytest.mark.asyncio
async def test_resolve_conid_malformed_response_raises(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search.*symbol=BAD"),
json=[{"symbol": "BAD"}], # missing conid key
)
with pytest.raises(IBKRError, match="malformed"):
await client.resolve_conid("BAD", "STK")
@pytest.mark.asyncio
async def test_place_order_auto_confirms_warning(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msgid1", "message": ["outside RTH"]}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/reply/msgid1"),
method="POST",
json=[{"order_id": "OID42", "order_status": "Submitted"}],
)
res = await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="market",
)
assert res["order_id"] == "OID42"
@pytest.mark.asyncio
async def test_place_order_rejects_critical_warning(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msgid2", "message": ["Margin requirement exceeded"]}],
)
with pytest.raises(IBKRError, match="IBKR_ORDER_REJECTED_WARNING"):
await client.place_order(
symbol="AAPL", side="buy", qty=1000000, order_type="market",
)
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/order/OID42"),
method="DELETE",
json={"msg": "Request was submitted", "order_id": "OID42"},
)
res = await client.cancel_order("OID42")
assert res["canceled"] is True
@pytest.mark.asyncio
async def test_place_order_too_many_confirmations(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
# Initial place + 3 reply cycles all return new warnings — should fail at MAX_CYCLES
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msg1", "message": ["outside RTH"]}],
)
for n in range(2, 5):
httpx_mock.add_response(
url=re.compile(rf".*/iserver/reply/msg{n-1}$"),
method="POST",
json=[{"id": f"msg{n}", "message": ["outside RTH"]}],
)
with pytest.raises(IBKRError, match="IBKR_ORDER_TOO_MANY_CONFIRMATIONS"):
await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="market",
)
@@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from cerbero_mcp.exchanges.ibkr.key_rotation import KeyRotationManager
@pytest.mark.asyncio
async def test_start_generates_new_keypair_files(tmp_path):
sig_path = tmp_path / "sig.pem"
enc_path = tmp_path / "enc.pem"
sig_path.write_bytes(b"old-sig")
enc_path.write_bytes(b"old-enc")
mgr = KeyRotationManager(
signature_key_path=str(sig_path),
encryption_key_path=str(enc_path),
)
out = await mgr.start()
assert "sig" in out["fingerprints"]
assert "enc" in out["fingerprints"]
assert (tmp_path / "sig.pem.new").exists()
assert (tmp_path / "enc.pem.new").exists()
@pytest.mark.asyncio
async def test_confirm_swap_and_validate_ok(tmp_path):
sig_path = tmp_path / "sig.pem"
enc_path = tmp_path / "enc.pem"
sig_path.write_bytes(b"old-sig")
enc_path.write_bytes(b"old-enc")
mgr = KeyRotationManager(
signature_key_path=str(sig_path),
encryption_key_path=str(enc_path),
)
await mgr.start()
async def fake_validate() -> bool:
return True
out = await mgr.confirm(validate=fake_validate)
assert "rotated_at" in out
assert (tmp_path / ".archive").exists()
@pytest.mark.asyncio
async def test_confirm_validate_fail_rollbacks(tmp_path):
sig_path = tmp_path / "sig.pem"
enc_path = tmp_path / "enc.pem"
sig_path.write_bytes(b"old-sig")
enc_path.write_bytes(b"old-enc")
mgr = KeyRotationManager(
signature_key_path=str(sig_path),
encryption_key_path=str(enc_path),
)
await mgr.start()
async def fake_validate() -> bool:
return False
with pytest.raises(RuntimeError, match="IBKR_ROTATION_VALIDATION_FAILED"):
await mgr.confirm(validate=fake_validate)
assert sig_path.read_bytes() == b"old-sig"
assert enc_path.read_bytes() == b"old-enc"
@pytest.mark.asyncio
async def test_abort_cleans_new_files(tmp_path):
sig_path = tmp_path / "sig.pem"
enc_path = tmp_path / "enc.pem"
sig_path.write_bytes(b"old-sig")
enc_path.write_bytes(b"old-enc")
mgr = KeyRotationManager(
signature_key_path=str(sig_path),
encryption_key_path=str(enc_path),
)
await mgr.start()
await mgr.abort()
assert not (tmp_path / "sig.pem.new").exists()
assert not (tmp_path / "enc.pem.new").exists()
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import base64 as _b64
import re
import time
import pytest
from cerbero_mcp.exchanges.ibkr.oauth import (
OAuth1aSigner,
build_signature_base_string,
)
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from pytest_httpx import HTTPXMock
def test_signature_base_string_canonical_order():
base = build_signature_base_string(
method="POST",
url="https://api.ibkr.com/v1/api/oauth/live_session_token",
params={
"oauth_consumer_key": "TEST_CONSUMER",
"oauth_token": "TEST_TOKEN",
"oauth_nonce": "abc123",
"oauth_timestamp": "1700000000",
"oauth_signature_method": "RSA-SHA256",
"oauth_version": "1.0",
"diffie_hellman_challenge": "ff00",
},
)
assert base.startswith("POST&")
assert "oauth_consumer_key%3DTEST_CONSUMER" in base
idx_consumer = base.index("oauth_consumer_key")
idx_token = base.index("oauth_token")
assert idx_consumer < idx_token
def test_oauth_signer_signs_with_rsa(tmp_path):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
sig_path = tmp_path / "sig.pem"
sig_path.write_bytes(pem)
enc_path = tmp_path / "enc.pem"
enc_path.write_bytes(pem)
signer = OAuth1aSigner(
consumer_key="TEST_CONSUMER",
access_token="TEST_TOKEN",
access_token_secret="TEST_SECRET",
signature_key_path=str(sig_path),
encryption_key_path=str(enc_path),
dh_prime="FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF",
)
sig = signer.sign(
method="GET",
url="https://api.ibkr.com/v1/api/iserver/auth/status",
params={
"oauth_consumer_key": "TEST_CONSUMER",
"oauth_token": "TEST_TOKEN",
"oauth_nonce": "abc",
"oauth_timestamp": "1700000000",
"oauth_signature_method": "RSA-SHA256",
"oauth_version": "1.0",
},
)
# Verify signature against the public key — proves correctness, not just shape
base = build_signature_base_string(
method="GET",
url="https://api.ibkr.com/v1/api/iserver/auth/status",
params={
"oauth_consumer_key": "TEST_CONSUMER",
"oauth_token": "TEST_TOKEN",
"oauth_nonce": "abc",
"oauth_timestamp": "1700000000",
"oauth_signature_method": "RSA-SHA256",
"oauth_version": "1.0",
},
)
key.public_key().verify(
_b64.b64decode(sig),
base.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
@pytest.mark.asyncio
async def test_live_session_token_mint(httpx_mock: HTTPXMock, tmp_path):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
(tmp_path / "sig.pem").write_bytes(pem)
(tmp_path / "enc.pem").write_bytes(pem)
# Encrypt fake "real" secret with our public key
raw_secret = b"my_real_token_secret"
encrypted = key.public_key().encrypt(raw_secret, padding.PKCS1v15())
encrypted_hex = encrypted.hex()
httpx_mock.add_response(
url=re.compile(r".*/oauth/live_session_token"),
json={
"diffie_hellman_response": "ff",
"live_session_token_signature": "00" * 20,
"live_session_token_expiration": int(time.time() * 1000) + 86400000,
},
)
signer = OAuth1aSigner(
consumer_key="TEST_CK",
access_token="TEST_AT",
access_token_secret=encrypted_hex,
signature_key_path=str(tmp_path / "sig.pem"),
encryption_key_path=str(tmp_path / "enc.pem"),
dh_prime="17", # 23 — smallest prime > 16 that fits a 1-byte modulus
)
lst = await signer.get_live_session_token(
base_url="https://api.ibkr.com/v1/api"
)
assert isinstance(lst, str) and len(lst) > 0
lst2 = await signer.get_live_session_token(
base_url="https://api.ibkr.com/v1/api"
)
assert lst == lst2 # cached
@@ -0,0 +1,44 @@
from __future__ import annotations
from cerbero_mcp.exchanges.ibkr.orders_complex import (
OrderSpec,
build_bracket_payload,
build_oco_payload,
)
def test_bracket_three_legs_with_oca():
payload = build_bracket_payload(
conid=42, sec_type="STK", side="BUY", qty=10,
entry_price=150.0, stop_loss=145.0, take_profit=160.0,
tif="GTC", exchange="SMART",
)
assert "orders" in payload
legs = payload["orders"]
assert len(legs) == 3
oca = legs[0].get("ocaGroup")
assert oca and all(l.get("ocaGroup") == oca for l in legs[1:])
assert legs[0]["orderType"] == "LMT"
assert legs[0]["price"] == 150.0
assert legs[0]["side"] == "BUY"
assert legs[1]["side"] == "SELL"
assert legs[2]["side"] == "SELL"
assert legs[1]["orderType"] == "STP"
assert legs[1]["auxPrice"] == 145.0
assert legs[2]["orderType"] == "LMT"
assert legs[2]["price"] == 160.0
def test_oco_oca_group_and_type():
legs = [
OrderSpec(conid=1, sec_type="STK", side="BUY", qty=1,
order_type="LMT", price=100),
OrderSpec(conid=1, sec_type="STK", side="BUY", qty=1,
order_type="LMT", price=110),
]
payload = build_oco_payload(legs)
assert len(payload["orders"]) == 2
oca = payload["orders"][0]["ocaGroup"]
for o in payload["orders"]:
assert o["ocaGroup"] == oca
assert o["ocaType"] == 1
+137
View File
@@ -0,0 +1,137 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from cerbero_mcp.exchanges.ibkr import tools as t
def test_place_order_req_schema():
req = t.PlaceOrderReq(symbol="AAPL", side="buy", qty=1)
assert req.order_type == "market"
assert req.tif == "day"
assert req.exchange == "SMART"
def test_place_order_req_options_validates_occ():
req = t.PlaceOrderReq(
symbol="AAPL 240119C00190000", side="buy", qty=1, asset_class="options",
)
assert req.asset_class == "options"
@pytest.mark.asyncio
async def test_get_account_tool_calls_client():
client = MagicMock()
client.get_account = AsyncMock(return_value={"netliquidation": {"amount": 10000}})
res = await t.get_account(client, t.GetAccountReq())
assert res["netliquidation"]["amount"] == 10000
@pytest.mark.asyncio
async def test_get_tick_uses_cache_or_subscribes():
client = MagicMock()
client.resolve_conid = AsyncMock(return_value=42)
ws = MagicMock()
ws.get_tick_snapshot = MagicMock(side_effect=[
None,
{"conid": 42, "last_price": 99.5, "bid": 99.4, "ask": 99.6,
"bid_size": 1, "ask_size": 1, "timestamp_ms": 1700000000000},
])
ws.subscribe_tick = AsyncMock()
res = await t.get_tick(
client, t.GetTickReq(symbol="AAPL"), ws=ws, timeout_s=0.05,
)
assert res["last_price"] == 99.5
ws.subscribe_tick.assert_awaited_once_with(42)
@pytest.mark.asyncio
async def test_place_order_enforces_leverage():
client = MagicMock()
client.get_account = AsyncMock(return_value={
"netliquidation": {"amount": 10000},
})
client.place_order = AsyncMock(return_value={"order_id": "O1"})
creds = {"max_leverage": 2}
res = await t.place_order(
client, t.PlaceOrderReq(symbol="AAPL", side="buy", qty=10),
creds=creds, last_price=100.0,
)
assert res["order_id"] == "O1"
@pytest.mark.asyncio
async def test_cancel_order_calls_client():
client = MagicMock()
client.cancel_order = AsyncMock(return_value={"order_id": "O1", "canceled": True})
res = await t.cancel_order(client, t.CancelOrderReq(order_id="O1"))
assert res["canceled"] is True
@pytest.mark.asyncio
async def test_place_order_rejects_excessive_leverage():
from fastapi import HTTPException
client = MagicMock()
client.get_account = AsyncMock(return_value={
"netliquidation": {"amount": 1000},
})
creds = {"max_leverage": 2}
# Order notional = 100*100 = 10000 vs equity 1000 → ratio 10x >> 2x cap → 403
with pytest.raises(HTTPException) as exc_info:
await t.place_order(
client, t.PlaceOrderReq(symbol="AAPL", side="buy", qty=100),
creds=creds, last_price=100.0,
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED"
@pytest.mark.asyncio
async def test_place_bracket_order_calls_client_with_three_legs():
client = MagicMock()
client.resolve_conid = AsyncMock(return_value=42)
client.account_id = "DU1"
client.get_account = AsyncMock(return_value={"netliquidation": {"amount": 100000}})
client._submit_order_with_confirmation = AsyncMock(
return_value={"order_id": "OID-parent"}
)
res = await t.place_bracket_order(
client,
t.PlaceBracketOrderReq(
symbol="AAPL", side="buy", qty=1,
entry_price=150, stop_loss=145, take_profit=160,
),
creds={"max_leverage": 4},
)
assert res["order_id"] == "OID-parent"
payload = client._submit_order_with_confirmation.call_args[0][0]
assert len(payload["orders"]) == 3
@pytest.mark.asyncio
async def test_place_oto_partial_failure_cancels_trigger():
from cerbero_mcp.exchanges.ibkr.client import IBKRError
client = MagicMock()
client.resolve_conid = AsyncMock(return_value=42)
client.account_id = "DU1"
client._submit_order_with_confirmation = AsyncMock(
side_effect=[
{"order_id": "TRIG1"},
IBKRError("network"),
]
)
client.cancel_order = AsyncMock(return_value={"canceled": True})
with pytest.raises(IBKRError, match="IBKR_OTO_PARTIAL_FAILURE"):
await t.place_oto_order(
client,
t.PlaceOtoOrderReq(
trigger=t.OrderLeg(symbol="AAPL", side="buy", qty=1,
order_type="limit", limit_price=150),
child=t.OrderLeg(symbol="AAPL", side="sell", qty=1,
order_type="limit", limit_price=160),
),
creds={"max_leverage": 4},
)
client.cancel_order.assert_awaited_once_with("TRIG1")
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket, WSError
class FakeWS:
"""Bidirectional async fake for WSS messages."""
def __init__(self) -> None:
self.sent: list[str] = []
self._inbox: asyncio.Queue[str] = asyncio.Queue()
self.closed = False
async def send(self, msg: str) -> None:
self.sent.append(msg)
async def recv(self) -> str:
return await self._inbox.get()
async def close(self) -> None:
self.closed = True
async def push(self, payload: dict) -> None:
await self._inbox.put(json.dumps(payload))
@pytest.fixture
def fake_signer():
s = MagicMock()
s.get_live_session_token = AsyncMock(return_value="LST==")
return s
@pytest.mark.asyncio
async def test_subscribe_tick_caches_snapshot(fake_signer, monkeypatch):
fake_ws = FakeWS()
async def fake_connect(url, **kw):
return fake_ws
monkeypatch.setattr("cerbero_mcp.exchanges.ibkr.ws.websockets_connect", fake_connect)
ws = IBKRWebSocket(
signer=fake_signer,
ws_url="wss://api.ibkr.com/v1/api/ws",
base_url="https://api.ibkr.com/v1/api",
max_subs=80, idle_timeout_s=300,
)
await ws.start()
await ws.subscribe_tick(265598)
await fake_ws.push({
"topic": "smd+265598",
"31": "150.5", "84": "150.4", "86": "150.6",
"7295": "100", "7296": "200",
})
await asyncio.sleep(0.05)
snap = ws.get_tick_snapshot(265598)
assert snap is not None
assert snap["last_price"] == 150.5
assert snap["bid"] == 150.4
await ws.stop()
@pytest.mark.asyncio
async def test_subscribe_limit(fake_signer, monkeypatch):
fake_ws = FakeWS()
async def fake_connect(url, **kw):
return fake_ws
monkeypatch.setattr("cerbero_mcp.exchanges.ibkr.ws.websockets_connect", fake_connect)
ws = IBKRWebSocket(
signer=fake_signer,
ws_url="wss://x", base_url="https://x",
max_subs=2, idle_timeout_s=300,
)
await ws.start()
await ws.subscribe_tick(1)
await ws.subscribe_tick(2)
with pytest.raises(WSError, match="IBKR_WS_SUB_LIMIT"):
await ws.subscribe_tick(3)
await ws.stop()
@pytest.mark.asyncio
async def test_subscribe_before_start_raises(fake_signer):
ws = IBKRWebSocket(
signer=fake_signer,
ws_url="wss://x", base_url="https://x",
max_subs=10, idle_timeout_s=300,
)
with pytest.raises(WSError, match="IBKR_WS_NOT_STARTED"):
await ws.subscribe_tick(1)
@pytest.mark.asyncio
async def test_start_after_stop_resumes_reader(fake_signer, monkeypatch):
fake_ws_a = FakeWS()
fake_ws_b = FakeWS()
fakes = iter([fake_ws_a, fake_ws_b])
async def fake_connect(url, **kw):
return next(fakes)
monkeypatch.setattr("cerbero_mcp.exchanges.ibkr.ws.websockets_connect", fake_connect)
ws = IBKRWebSocket(
signer=fake_signer,
ws_url="wss://x", base_url="https://x",
max_subs=10, idle_timeout_s=300,
)
await ws.start()
await ws.stop()
# Restart with fresh fake_ws_b
await ws.start()
await ws.subscribe_tick(42)
await fake_ws_b.push({"topic": "smd+42", "31": "100"})
await asyncio.sleep(0.05)
assert ws.get_tick_snapshot(42) is not None
await ws.stop()
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def tmp_audit_file(tmp_path, monkeypatch):
file_path = tmp_path / "audit.jsonl"
monkeypatch.setenv("AUDIT_LOG_FILE", str(file_path))
return file_path
@pytest.fixture
def app(monkeypatch, tmp_audit_file):
from tests.unit.test_settings import _minimal_env
for k, v in _minimal_env().items():
monkeypatch.setenv(k, v)
from cerbero_mcp.__main__ import _make_app
from cerbero_mcp.settings import Settings
return _make_app(Settings())
def _write_records(file_path: Path, records: list[dict]) -> None:
with file_path.open("w") as f:
for r in records:
f.write(json.dumps(r) + "\n")
def _bearer_test():
return {"Authorization": "Bearer t_test_123"}
def test_admin_audit_no_file(app):
"""Senza AUDIT_LOG_FILE settato, ritorna empty + warning."""
import os
os.environ.pop("AUDIT_LOG_FILE", None)
c = TestClient(app)
r = c.get("/admin/audit", headers=_bearer_test())
assert r.status_code == 200
body = r.json()
assert body["count"] == 0
assert "warning" in body
def test_admin_audit_no_bearer_returns_401(app):
c = TestClient(app)
r = c.get("/admin/audit")
assert r.status_code == 401
def test_admin_audit_no_bot_tag_required(app, tmp_audit_file):
"""Endpoint admin NON richiede X-Bot-Tag (solo bearer)."""
_write_records(tmp_audit_file, [])
c = TestClient(app)
r = c.get("/admin/audit", headers=_bearer_test())
assert r.status_code == 200
def test_admin_audit_returns_records(app, tmp_audit_file):
records = [
{
"audit_event": "write_op",
"asctime": "2026-05-01 10:00:00,000",
"actor": "testnet", "bot_tag": "alpha",
"exchange": "deribit", "action": "place_order",
"target": "BTC-PERPETUAL",
"payload": {"qty": 0.1},
"result": {"order_id": "abc"},
},
{
"audit_event": "write_op",
"asctime": "2026-05-01 11:00:00,000",
"actor": "mainnet", "bot_tag": "beta",
"exchange": "bybit", "action": "cancel_order",
"target": "ord-1",
"payload": {},
},
]
_write_records(tmp_audit_file, records)
c = TestClient(app)
r = c.get("/admin/audit", headers=_bearer_test())
assert r.status_code == 200
body = r.json()
assert body["count"] == 2
def test_admin_audit_filter_by_actor(app, tmp_audit_file):
records = [
{"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000",
"actor": "testnet", "bot_tag": "a", "exchange": "deribit", "action": "place_order"},
{"audit_event": "write_op", "asctime": "2026-05-01 11:00:00,000",
"actor": "mainnet", "bot_tag": "b", "exchange": "bybit", "action": "place_order"},
]
_write_records(tmp_audit_file, records)
c = TestClient(app)
r = c.get("/admin/audit?actor=mainnet", headers=_bearer_test())
assert r.status_code == 200
body = r.json()
assert body["count"] == 1
assert body["records"][0]["actor"] == "mainnet"
def test_admin_audit_filter_by_date_range(app, tmp_audit_file):
records = [
{"audit_event": "write_op", "asctime": "2026-04-30 10:00:00,000",
"actor": "testnet", "exchange": "deribit", "action": "place_order"},
{"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000",
"actor": "testnet", "exchange": "deribit", "action": "place_order"},
{"audit_event": "write_op", "asctime": "2026-05-02 10:00:00,000",
"actor": "testnet", "exchange": "deribit", "action": "place_order"},
]
_write_records(tmp_audit_file, records)
c = TestClient(app)
r = c.get("/admin/audit?from=2026-05-01&to=2026-05-01T23:59:59", headers=_bearer_test())
assert r.status_code == 200
assert r.json()["count"] == 1
def test_admin_audit_filter_by_bot_tag(app, tmp_audit_file):
records = [
{"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000",
"actor": "testnet", "bot_tag": "alpha", "exchange": "deribit", "action": "place_order"},
{"audit_event": "write_op", "asctime": "2026-05-01 11:00:00,000",
"actor": "testnet", "bot_tag": "beta", "exchange": "deribit", "action": "place_order"},
]
_write_records(tmp_audit_file, records)
c = TestClient(app)
r = c.get("/admin/audit?bot_tag=alpha", headers=_bearer_test())
assert r.status_code == 200
assert r.json()["count"] == 1
assert r.json()["records"][0]["bot_tag"] == "alpha"
def test_admin_audit_invalid_date(app, tmp_audit_file):
_write_records(tmp_audit_file, [])
c = TestClient(app)
r = c.get("/admin/audit?from=not-a-date", headers=_bearer_test())
assert r.status_code == 400
def test_admin_audit_limit(app, tmp_audit_file):
records = [
{"audit_event": "write_op", "asctime": f"2026-05-01 10:{i:02d}:00,000",
"actor": "testnet", "exchange": "deribit", "action": "place_order"}
for i in range(50)
]
_write_records(tmp_audit_file, records)
c = TestClient(app)
r = c.get("/admin/audit?limit=10", headers=_bearer_test())
assert r.status_code == 200
assert r.json()["count"] == 10
+78 -2
View File
@@ -67,7 +67,10 @@ def test_testnet_token_sets_env_testnet():
return {"env": request.state.environment} return {"env": request.state.environment}
c = TestClient(fa) c = TestClient(fa)
r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_test"}) r = c.get(
"/mcp-deribit/peek",
headers={"Authorization": "Bearer tk_test", "X-Bot-Tag": "test-bot"},
)
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"env": "testnet"} assert r.json() == {"env": "testnet"}
@@ -83,7 +86,10 @@ def test_mainnet_token_sets_env_mainnet():
return {"env": request.state.environment} return {"env": request.state.environment}
c = TestClient(fa) c = TestClient(fa)
r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_live"}) r = c.get(
"/mcp-deribit/peek",
headers={"Authorization": "Bearer tk_live", "X-Bot-Tag": "test-bot"},
)
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"env": "mainnet"} assert r.json() == {"env": "mainnet"}
@@ -96,3 +102,73 @@ def test_uses_compare_digest():
src = inspect.getsource(auth) src = inspect.getsource(auth)
assert "compare_digest" in src, "auth.py deve usare secrets.compare_digest" assert "compare_digest" in src, "auth.py deve usare secrets.compare_digest"
# ── X-Bot-Tag header ─────────────────────────────────────────────────────────
def test_missing_bot_tag_returns_400():
from cerbero_mcp.auth import install_auth_middleware
fa = FastAPI()
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
@fa.get("/mcp-deribit/health")
def h():
return {"ok": True}
c = TestClient(fa)
r = c.get("/mcp-deribit/health", headers={"Authorization": "Bearer t"})
assert r.status_code == 400
assert "X-Bot-Tag" in r.json()["error"]["message"]
def test_bot_tag_accepted_and_set_on_state():
from cerbero_mcp.auth import install_auth_middleware
fa = FastAPI()
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
@fa.get("/mcp-deribit/peek")
def peek(request: Request):
return {
"env": request.state.environment,
"bot_tag": request.state.bot_tag,
}
c = TestClient(fa)
r = c.get(
"/mcp-deribit/peek",
headers={"Authorization": "Bearer t", "X-Bot-Tag": "scanner-alpha"},
)
assert r.status_code == 200
assert r.json() == {"env": "testnet", "bot_tag": "scanner-alpha"}
def test_bot_tag_too_long_returns_400():
from cerbero_mcp.auth import install_auth_middleware
fa = FastAPI()
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
@fa.get("/mcp-deribit/health")
def h():
return {"ok": True}
c = TestClient(fa)
r = c.get(
"/mcp-deribit/health",
headers={"Authorization": "Bearer t", "X-Bot-Tag": "x" * 65},
)
assert r.status_code == 400
def test_bot_tag_not_required_on_health():
"""Health endpoint deve restare senza auth e senza bot tag."""
from cerbero_mcp.auth import install_auth_middleware
fa = FastAPI()
install_auth_middleware(fa, testnet_token="t", mainnet_token="m")
@fa.get("/health")
def h():
return {"ok": True}
c = TestClient(fa)
r = c.get("/health")
assert r.status_code == 200
+20 -32
View File
@@ -29,15 +29,8 @@ async def test_build_client_bybit_returns_correct_env(monkeypatch):
for k, v in _minimal_env().items(): for k, v in _minimal_env().items():
monkeypatch.setenv(k, v) monkeypatch.setenv(k, v)
# Stub pybit HTTP per evitare connessione reale durante __init__ # BybitClient costruisce internamente httpx.AsyncClient: nessuna
from cerbero_mcp.exchanges.bybit import client as bybit_client # connessione reale finché non si invoca un metodo di rete.
class _FakeHTTP:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr(bybit_client, "HTTP", _FakeHTTP)
from cerbero_mcp.exchanges import build_client from cerbero_mcp.exchanges import build_client
from cerbero_mcp.settings import Settings from cerbero_mcp.settings import Settings
@@ -78,28 +71,22 @@ async def test_build_client_alpaca_returns_correct_env(monkeypatch):
for k, v in _minimal_env().items(): for k, v in _minimal_env().items():
monkeypatch.setenv(k, v) monkeypatch.setenv(k, v)
# Stub alpaca SDK clients per evitare connessioni reali in __init__ # AlpacaClient (V2) usa httpx puro: il costruttore non apre connessioni
from cerbero_mcp.exchanges.alpaca import client as alpaca_client # reali (httpx.AsyncClient è lazy fino alla prima request), quindi nessuno
# stub SDK è necessario.
class _FakeSdk:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr(alpaca_client, "TradingClient", _FakeSdk)
monkeypatch.setattr(alpaca_client, "StockHistoricalDataClient", _FakeSdk)
monkeypatch.setattr(alpaca_client, "CryptoHistoricalDataClient", _FakeSdk)
monkeypatch.setattr(alpaca_client, "OptionHistoricalDataClient", _FakeSdk)
from cerbero_mcp.exchanges import build_client from cerbero_mcp.exchanges import build_client
from cerbero_mcp.settings import Settings from cerbero_mcp.settings import Settings
s = Settings() s = Settings()
c_test = await build_client(s, "alpaca", "testnet") c_test = await build_client(s, "alpaca", "testnet")
c_live = await build_client(s, "alpaca", "mainnet") c_live = await build_client(s, "alpaca", "mainnet")
try:
assert c_test is not c_live assert c_test is not c_live
assert c_test.paper is True assert c_test.paper is True
assert c_live.paper is False assert c_live.paper is False
finally:
await c_test.aclose()
await c_live.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -202,8 +189,8 @@ async def test_hyperliquid_url_from_env_overrides_default(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bybit_url_from_env_overrides_default(monkeypatch): async def test_bybit_url_from_env_overrides_default(monkeypatch):
"""Bybit: pybit non accetta `endpoint` come kwarg, ma setting di """Bybit (httpx): override BYBIT_URL_TESTNET applica direttamente a
`_http.endpoint` post-init rispecchia l'override.""" `self.base_url`, usato come base di ogni richiesta REST V5."""
from tests.unit.test_settings import _minimal_env from tests.unit.test_settings import _minimal_env
env = _minimal_env(BYBIT_URL_TESTNET="https://bybit-custom.example.com") env = _minimal_env(BYBIT_URL_TESTNET="https://bybit-custom.example.com")
@@ -216,14 +203,12 @@ async def test_bybit_url_from_env_overrides_default(monkeypatch):
s = Settings() s = Settings()
c = await build_client(s, "bybit", "testnet") c = await build_client(s, "bybit", "testnet")
assert c.base_url == "https://bybit-custom.example.com" assert c.base_url == "https://bybit-custom.example.com"
# override applicato all'istanza pybit HTTP via attributo `endpoint`
assert getattr(c._http, "endpoint", None) == "https://bybit-custom.example.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alpaca_url_from_env_overrides_default(monkeypatch): async def test_alpaca_url_from_env_overrides_default(monkeypatch):
"""Alpaca: TradingClient supporta url_override per trading API. """Alpaca V2 (httpx): `base_url` override applica al solo trading
Data clients (Stock/Crypto/Option) non supportano override sul costruttore.""" endpoint; data endpoints (data.alpaca.markets) restano hardcoded."""
from tests.unit.test_settings import _minimal_env from tests.unit.test_settings import _minimal_env
env = _minimal_env(ALPACA_URL_TESTNET="https://alpaca-custom.example.com") env = _minimal_env(ALPACA_URL_TESTNET="https://alpaca-custom.example.com")
@@ -235,7 +220,10 @@ async def test_alpaca_url_from_env_overrides_default(monkeypatch):
s = Settings() s = Settings()
c = await build_client(s, "alpaca", "testnet") c = await build_client(s, "alpaca", "testnet")
assert c.base_url == "https://alpaca-custom.example.com" try:
assert c.base_url == "https://alpaca-custom.example.com"
finally:
await c.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import logging
import pytest
from fastapi.testclient import TestClient
from starlette.requests import Request as StarletteRequest
@pytest.fixture
def app(monkeypatch):
from tests.unit.test_settings import _minimal_env
for k, v in _minimal_env().items():
monkeypatch.setenv(k, v)
from cerbero_mcp.__main__ import _make_app
from cerbero_mcp.settings import Settings
return _make_app(Settings())
@pytest.fixture
def request_log_caplog(caplog):
"""Caplog per logger mcp.request: aggiunge l'handler caplog direttamente
al logger, bypassando ``propagate=False`` settato da common/logging.py.
"""
lg = logging.getLogger("mcp.request")
lg.addHandler(caplog.handler)
lg.setLevel(logging.INFO)
caplog.set_level(logging.INFO, logger="mcp.request")
try:
yield caplog
finally:
lg.removeHandler(caplog.handler)
def _request_records(caplog):
return [rec for rec in caplog.records if rec.name == "mcp.request"]
def test_request_log_emits_for_health(app, request_log_caplog):
c = TestClient(app)
r = c.get("/health")
assert r.status_code == 200
records = _request_records(request_log_caplog)
assert any(getattr(rec, "path", None) == "/health" for rec in records)
def test_request_log_includes_request_id(app, request_log_caplog):
c = TestClient(app)
c.get("/health")
records = _request_records(request_log_caplog)
assert records, "expected at least one mcp.request record"
for rec in records:
rid = getattr(rec, "request_id", None)
assert rid and isinstance(rid, str) and len(rid) >= 16
def test_request_log_includes_method_status_duration(app, request_log_caplog):
c = TestClient(app)
c.get("/health")
records = _request_records(request_log_caplog)
rec = next(rec for rec in records if getattr(rec, "path", None) == "/health")
assert getattr(rec, "method", None) == "GET"
assert getattr(rec, "status_code", None) == 200
assert isinstance(getattr(rec, "duration_ms", None), int | float)
def test_request_log_includes_actor_and_bot_tag_on_protected(
app, request_log_caplog
):
"""Su path autenticato actor/bot_tag/exchange/tool sono propagati."""
c = TestClient(app)
c.post(
"/mcp-deribit/tools/is_testnet",
headers={
"Authorization": "Bearer t_test_123",
"X-Bot-Tag": "scanner-x",
},
json={},
)
records = _request_records(request_log_caplog)
rec = next(
rec
for rec in records
if getattr(rec, "path", None) == "/mcp-deribit/tools/is_testnet"
)
assert getattr(rec, "actor", None) == "testnet"
assert getattr(rec, "bot_tag", None) == "scanner-x"
assert getattr(rec, "exchange", None) == "deribit"
assert getattr(rec, "tool", None) == "is_testnet"
def test_request_log_unauthorized_does_not_have_actor(
app, request_log_caplog
):
"""Senza bearer, request log emette comunque ma senza actor/bot_tag."""
c = TestClient(app)
c.post("/mcp-deribit/tools/is_testnet", json={})
records = _request_records(request_log_caplog)
rec = next(
rec
for rec in records
if getattr(rec, "path", None) == "/mcp-deribit/tools/is_testnet"
)
assert getattr(rec, "status_code", None) == 401
assert getattr(rec, "actor", None) is None
assert getattr(rec, "exchange", None) == "deribit"
def test_request_id_in_state_for_handlers(app):
"""Verifica che request.state.request_id sia disponibile a handler."""
@app.get("/__test_state")
def _state_handler(request: StarletteRequest) -> dict:
return {"rid": request.state.request_id}
c = TestClient(app)
r = c.get(
"/__test_state",
headers={"Authorization": "Bearer t_test_123", "X-Bot-Tag": "x"},
)
assert r.status_code == 200, f"got {r.status_code}: {r.text[:500]}"
assert r.json()["rid"]
+120
View File
@@ -60,3 +60,123 @@ def test_x_duration_ms_header(app):
c = TestClient(app) c = TestClient(app)
r = c.get("/health") r = c.get("/health")
assert "X-Duration-Ms" in r.headers assert "X-Duration-Ms" in r.headers
def test_health_ready_empty_registry(app):
"""Senza registry il readiness ritorna not_ready ma HTTP 200."""
c = TestClient(app)
r = c.get("/health/ready")
assert r.status_code == 200
j = r.json()
assert j["status"] == "not_ready"
assert j["clients"] == []
assert j["version"] == "2.0.0"
def test_health_ready_all_healthy(app):
"""Registry con stub client healthy → status=ready."""
from cerbero_mcp.client_registry import ClientRegistry
class _StubOk:
async def health(self):
return {"status": "ok"}
async def _builder(exchange, env): # pragma: no cover - non chiamato
return _StubOk()
reg = ClientRegistry(builder=_builder)
reg._clients[("deribit", "testnet")] = _StubOk()
reg._clients[("bybit", "mainnet")] = _StubOk()
app.state.registry = reg
c = TestClient(app)
r = c.get("/health/ready")
assert r.status_code == 200
j = r.json()
assert j["status"] == "ready"
assert len(j["clients"]) == 2
for entry in j["clients"]:
assert entry["healthy"] is True
assert "duration_ms" in entry
def test_health_ready_degraded_on_error(app):
"""Registry con almeno un client che fa raise → status=degraded."""
from cerbero_mcp.client_registry import ClientRegistry
class _StubOk:
async def health(self):
return {"status": "ok"}
class _StubFail:
async def health(self):
raise RuntimeError("boom")
async def _builder(exchange, env): # pragma: no cover - non chiamato
return _StubOk()
reg = ClientRegistry(builder=_builder)
reg._clients[("deribit", "testnet")] = _StubOk()
reg._clients[("bybit", "mainnet")] = _StubFail()
app.state.registry = reg
c = TestClient(app)
r = c.get("/health/ready")
assert r.status_code == 200
j = r.json()
assert j["status"] == "degraded"
fail = next(c for c in j["clients"] if c["exchange"] == "bybit")
assert fail["healthy"] is False
assert "RuntimeError" in fail["error"]
def test_health_ready_503_when_fail_on_degraded(app, monkeypatch):
"""READY_FAILS_ON_DEGRADED=true → HTTP 503 quando degraded."""
from cerbero_mcp.client_registry import ClientRegistry
class _StubFail:
async def health(self):
raise RuntimeError("boom")
async def _builder(exchange, env): # pragma: no cover - non chiamato
return _StubFail()
reg = ClientRegistry(builder=_builder)
reg._clients[("deribit", "testnet")] = _StubFail()
app.state.registry = reg
monkeypatch.setenv("READY_FAILS_ON_DEGRADED", "true")
c = TestClient(app)
r = c.get("/health/ready")
assert r.status_code == 503
assert r.json()["status"] == "degraded"
def test_health_ready_no_probe_method(app):
"""Client senza health/is_testnet → marcato healthy con note."""
from cerbero_mcp.client_registry import ClientRegistry
class _StubBare:
pass
async def _builder(exchange, env): # pragma: no cover - non chiamato
return _StubBare()
reg = ClientRegistry(builder=_builder)
reg._clients[("foo", "testnet")] = _StubBare()
app.state.registry = reg
c = TestClient(app)
r = c.get("/health/ready")
assert r.status_code == 200
j = r.json()
assert j["status"] == "ready"
assert j["clients"][0]["note"] == "no probe method"
def test_health_ready_in_whitelist_no_auth(app):
"""/health/ready non richiede bearer."""
c = TestClient(app)
# Nessun Authorization header → 200 (whitelist)
r = c.get("/health/ready")
assert r.status_code == 200
+129 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
@@ -51,7 +53,8 @@ def test_settings_load_minimal(monkeypatch):
assert s.alpaca.max_leverage == 1 assert s.alpaca.max_leverage == 1
def test_settings_missing_token_fails(monkeypatch): def test_settings_missing_token_fails(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path) # isola dal .env reale del working dir
env = _minimal_env() env = _minimal_env()
env.pop("TESTNET_TOKEN") env.pop("TESTNET_TOKEN")
for k, v in env.items(): for k, v in env.items():
@@ -84,3 +87,128 @@ def test_settings_secret_str_no_leak(monkeypatch):
s = Settings() s = Settings()
assert "t_test_123" not in repr(s) assert "t_test_123" not in repr(s)
assert "t_live_456" not in repr(s) assert "t_live_456" not in repr(s)
def _isolated(monkeypatch, tmp_path, env: dict) -> None:
"""Isola Settings dal .env reale in working dir e setta solo env passato."""
monkeypatch.chdir(tmp_path)
for k in (
"DERIBIT_CLIENT_ID", "DERIBIT_CLIENT_SECRET",
"DERIBIT_CLIENT_ID_TESTNET", "DERIBIT_CLIENT_SECRET_TESTNET",
"DERIBIT_CLIENT_ID_LIVE", "DERIBIT_CLIENT_SECRET_LIVE",
):
monkeypatch.delenv(k, raising=False)
for k, v in env.items():
monkeypatch.setenv(k, v)
def test_deribit_credentials_legacy_single_pair(monkeypatch, tmp_path):
"""Solo DERIBIT_CLIENT_ID/SECRET → entrambi gli env usano la stessa coppia."""
_isolated(monkeypatch, tmp_path, _minimal_env())
from cerbero_mcp.settings import Settings
s = Settings()
assert s.deribit.credentials("testnet") == ("id", "secret")
assert s.deribit.credentials("mainnet") == ("id", "secret")
def test_deribit_credentials_per_env_pairs(monkeypatch, tmp_path):
"""Coppie _TESTNET e _LIVE → ognuna serve l'env corrispondente."""
env = _minimal_env()
env.pop("DERIBIT_CLIENT_ID")
env.pop("DERIBIT_CLIENT_SECRET")
env["DERIBIT_CLIENT_ID_TESTNET"] = "tid"
env["DERIBIT_CLIENT_SECRET_TESTNET"] = "tsec"
env["DERIBIT_CLIENT_ID_LIVE"] = "lid"
env["DERIBIT_CLIENT_SECRET_LIVE"] = "lsec"
_isolated(monkeypatch, tmp_path, env)
from cerbero_mcp.settings import Settings
s = Settings()
assert s.deribit.credentials("testnet") == ("tid", "tsec")
assert s.deribit.credentials("mainnet") == ("lid", "lsec")
def test_deribit_credentials_env_specific_overrides_fallback(monkeypatch, tmp_path):
"""_LIVE presente prevale sulla coppia base anche se entrambe configurate."""
env = _minimal_env()
env["DERIBIT_CLIENT_ID_LIVE"] = "lid"
env["DERIBIT_CLIENT_SECRET_LIVE"] = "lsec"
_isolated(monkeypatch, tmp_path, env)
from cerbero_mcp.settings import Settings
s = Settings()
assert s.deribit.credentials("mainnet") == ("lid", "lsec")
assert s.deribit.credentials("testnet") == ("id", "secret") # fallback
def test_deribit_credentials_missing_raises(monkeypatch, tmp_path):
"""Nessuna coppia configurata → ValueError esplicito."""
env = _minimal_env()
env.pop("DERIBIT_CLIENT_ID")
env.pop("DERIBIT_CLIENT_SECRET")
_isolated(monkeypatch, tmp_path, env)
from cerbero_mcp.settings import Settings
s = Settings()
with pytest.raises(ValueError, match="not configured for env=mainnet"):
s.deribit.credentials("mainnet")
def test_ibkr_settings_prefer_testnet_specific(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
for k in list(os.environ):
if k.startswith("IBKR_"):
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("IBKR_CONSUMER_KEY", "base_consumer")
monkeypatch.setenv("IBKR_CONSUMER_KEY_TESTNET", "paper_consumer")
monkeypatch.setenv("IBKR_ACCESS_TOKEN_TESTNET", "paper_token")
monkeypatch.setenv("IBKR_ACCESS_TOKEN_SECRET_TESTNET", "paper_secret")
monkeypatch.setenv("IBKR_SIGNATURE_KEY_PATH_TESTNET", "/secrets/sig_paper.pem")
monkeypatch.setenv("IBKR_ENCRYPTION_KEY_PATH_TESTNET", "/secrets/enc_paper.pem")
monkeypatch.setenv("IBKR_ACCOUNT_ID_TESTNET", "DU1234567")
monkeypatch.setenv("IBKR_DH_PRIME", "ffff")
from cerbero_mcp.settings import IBKRSettings
s = IBKRSettings()
creds = s.credentials("testnet")
assert creds["consumer_key"] == "paper_consumer"
assert creds["access_token"] == "paper_token"
assert creds["account_id"] == "DU1234567"
def test_ibkr_settings_fallback_to_base(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
for k in list(os.environ):
if k.startswith("IBKR_"):
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("IBKR_CONSUMER_KEY", "base_consumer")
monkeypatch.setenv("IBKR_ACCESS_TOKEN", "base_token")
monkeypatch.setenv("IBKR_ACCESS_TOKEN_SECRET", "base_secret")
monkeypatch.setenv("IBKR_SIGNATURE_KEY_PATH", "/secrets/sig.pem")
monkeypatch.setenv("IBKR_ENCRYPTION_KEY_PATH", "/secrets/enc.pem")
monkeypatch.setenv("IBKR_ACCOUNT_ID_TESTNET", "DU1234567")
monkeypatch.setenv("IBKR_DH_PRIME", "ffff")
from cerbero_mcp.settings import IBKRSettings
s = IBKRSettings()
creds = s.credentials("testnet")
assert creds["consumer_key"] == "base_consumer"
def test_ibkr_settings_missing_raises(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
for k in list(os.environ):
if k.startswith("IBKR_"):
monkeypatch.delenv(k, raising=False)
from cerbero_mcp.settings import IBKRSettings
s = IBKRSettings()
with pytest.raises(ValueError, match="IBKR credentials not configured"):
s.credentials("testnet")
Generated
+136 -183
View File
@@ -13,24 +13,6 @@ resolution-markers = [
"python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
] ]
[[package]]
name = "alpaca-py"
version = "0.43.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "msgpack" },
{ name = "pandas" },
{ name = "pydantic" },
{ name = "pytz" },
{ name = "requests" },
{ name = "sseclient-py" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/9d/3003f661c15b8003655c447c187aec10f0843647e5c98b391701b04ac3d8/alpaca_py-0.43.4.tar.gz", hash = "sha256:7d529b3654d4e817d9fd7ab461131c4f06a315c736b6a9e4a87d5406bb71114a", size = 97990, upload-time = "2026-04-29T08:41:48.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/d5/1f57cc03e7b5925a927cb7f8e7ee5f873e22632633778d28d5d23681c871/alpaca_py-0.43.4-py3-none-any.whl", hash = "sha256:dd49ac30e0f2a8f38550ef1f27a58e7fd8f3f3875deaa4e757443cdbd033a1b4", size = 122534, upload-time = "2026-04-29T08:41:50.149Z" },
]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.4" version = "0.0.4"
@@ -140,13 +122,14 @@ name = "cerbero-mcp"
version = "2.0.0" version = "2.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alpaca-py" }, { name = "cryptography" },
{ name = "eth-account" },
{ name = "eth-utils" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "hyperliquid-python-sdk" }, { name = "msgpack" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pandas" }, { name = "pandas" },
{ name = "pybit" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-json-logger" }, { name = "python-json-logger" },
@@ -167,13 +150,14 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alpaca-py", specifier = ">=0.30" }, { name = "cryptography", specifier = ">=43" },
{ name = "eth-account", specifier = ">=0.13.7" },
{ name = "eth-utils", specifier = ">=5.3.1" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx", specifier = ">=0.27" },
{ name = "hyperliquid-python-sdk", specifier = ">=0.6" }, { name = "msgpack", specifier = ">=1.1.2" },
{ name = "numpy", specifier = ">=1.26" }, { name = "numpy", specifier = ">=1.26" },
{ name = "pandas", specifier = ">=2.2" }, { name = "pandas", specifier = ">=2.2" },
{ name = "pybit", specifier = ">=5.7" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.6" },
{ name = "python-json-logger", specifier = ">=2.0" }, { name = "python-json-logger", specifier = ">=2.0" },
@@ -202,92 +186,73 @@ wheels = [
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "cffi"
version = "3.4.7" version = "2.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
] ]
[[package]] [[package]]
@@ -359,6 +324,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "cryptography"
version = "47.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" },
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" },
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" },
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" },
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" },
{ url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" },
{ url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" },
{ url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" },
{ url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" },
]
[[package]] [[package]]
name = "cytoolz" name = "cytoolz"
version = "1.1.0" version = "1.1.0"
@@ -713,22 +737,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
] ]
[[package]]
name = "hyperliquid-python-sdk"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "eth-account" },
{ name = "eth-utils" },
{ name = "msgpack" },
{ name = "requests" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/ad/12b4559a953e26fc56677de5bf689023e11213196b802b991a6e6db94814/hyperliquid_python_sdk-0.23.0.tar.gz", hash = "sha256:14df0b62511a0cf08ca5a73f73f03656868ee67845ed3362539a79674511bb51", size = 25255, upload-time = "2026-04-14T21:51:24.646Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/e9/b7b23aefc319727f670992904b1defd7aee5fc3f59a51c141a87db05f7da/hyperliquid_python_sdk-0.23.0-py3-none-any.whl", hash = "sha256:5b4f9f7ab8c0b1ad9848f2222901dc047c8f97a6e6fe3fd7286b7b34337f80cb", size = 24638, upload-time = "2026-04-14T21:51:23.27Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.13"
@@ -1123,17 +1131,12 @@ wheels = [
] ]
[[package]] [[package]]
name = "pybit" name = "pycparser"
version = "5.16.0" version = "3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
{ name = "pycryptodome" },
{ name = "requests" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/92/c7c14df206de81d7acb7d7d92180482d8d54c18dc73bf8d1ab73498bdbfc/pybit-5.16.0.tar.gz", hash = "sha256:554a812982e0271ec3a9f9483767ad5ff440d3834f3d363b689c81a0a474ebc0", size = 66764, upload-time = "2026-04-18T13:55:33.058Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/3b/9c37237af574f143cee806801ff37a83b85491cd654046c49caf1d2940a2/pybit-5.16.0-py2.py3-none-any.whl", hash = "sha256:d214c4987aabb25c10e8e031244973a3be4728e044575ab6c6f6f7873f8c1cab", size = 59230, upload-time = "2026-04-18T13:55:31.551Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
] ]
[[package]] [[package]]
@@ -1378,15 +1381,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" },
] ]
[[package]]
name = "pytz"
version = "2026.1.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -1546,21 +1540,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" },
] ]
[[package]]
name = "requests"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]] [[package]]
name = "rlp" name = "rlp"
version = "4.1.0" version = "4.1.0"
@@ -1678,14 +1657,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
] ]
[[package]]
name = "sseclient-py"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/2e/59920f7d66b7f9932a3d83dd0ec53fab001be1e058bf582606fe414a5198/sseclient_py-1.9.0-py3-none-any.whl", hash = "sha256:340062b1587fc2880892811e2ab5b176d98ef3eee98b3672ff3a3ba1e8ed0f6f", size = 8351, upload-time = "2026-01-02T23:39:30.995Z" },
]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "1.0.0" version = "1.0.0"
@@ -1777,15 +1748,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
] ]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.46.0" version = "0.46.0"
@@ -1935,15 +1897,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
] ]
[[package]]
name = "websocket-client"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
]
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "16.0" version = "16.0"