From 109b8e4686bf18b0b38b07aabacab3d86faa5eb0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 3 May 2026 19:23:08 +0000 Subject: [PATCH] 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) --- .../2026-05-03-ibkr-integration-design.md | 530 ++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-ibkr-integration-design.md diff --git a/docs/superpowers/specs/2026-05-03-ibkr-integration-design.md b/docs/superpowers/specs/2026-05-03-ibkr-integration-design.md new file mode 100644 index 0000000..fa97848 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-ibkr-integration-design.md @@ -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 `_`; 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