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>
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user