# 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 `_`; fallback a `` (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 --request-token` → ottiene request token + URL autorizzazione 5. Aprire URL nel browser, autorizzare, copiare `verifier_code` 6. `python scripts/ibkr_oauth_setup.py --verifier ` → 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=`. **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_` 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=`), 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: } → 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// → 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//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_ = 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./mcp-ibkr/tools/get_account \ -H "Authorization: Bearer " -X POST -d '{}' # → saldo paper account curl .../mcp-ibkr/tools/place_order \ -H "Authorization: Bearer " -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 "` 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 ` 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