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>
27 KiB
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_idper 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
POST /mcp-ibkr/tools/get_accountcon bearer testnet ritorna saldo paper account realePOST /mcp-ibkr/tools/place_order(1 share AAPL market) → ordine fillato in paper, audit log presentePOST /mcp-ibkr/tools/place_bracket_order→ 3 ordini collegati via OCA group, primo fill cancella gli altriPOST /mcp-ibkr/tools/get_depth→ 5 livelli depth con dati < 1s di latenzaPOST /admin/ibkr/rotate-keys/{start,confirm}→ swap atomico, rollback automatico su validation fail- Container restart → primo
get_account< 5s (OAuth flow + first call), zero input umano - Test suite verde: 90% coverage su
oauth.py/client.py/ws.py/key_rotation.py, 85% sutools.py/orders_complex.py /health/readysegnala 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
IBKRWebSocketper env mantiene sub attive in background; i tool RESTget_tick/get_depthritornano l'ultimo snapshot in cache. Bot polling-based, niente streaming verso il bot. - Paper vs live = account separati, stesso host: IBKR usa
api.ibkr.comper 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
conidnumerico; 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— aggiungoIBKRSettingssrc/cerbero_mcp/exchanges/__init__.py— branchif exchange == "ibkr"inbuild_clientsrc/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— aggiungocryptography>=43(RSA + DH; potrebbe essere già transitiva)docker-compose.yml— bind mount./secrets:/secrets:roREADME.md— sezione "IBKR Setup"
Module boundaries:
oauth.pyesponeOAuth1aSigner.get_live_session_token() -> str(cached). Non sa nulla di endpoint applicativi; conosce solo l'endpoint/oauth/live_session_token.client.pyriceve unOAuth1aSignercome dipendenza, non costruisce keys. Non sa nulla di WebSocket.ws.pyriceve unOAuth1aSigner, gestisce WSS in modo indipendente. Espone metodi asyncsubscribe_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.pyopera su filesystem +OAuth1aSignerfactory; non tocca routing FastAPI direttamente.
4. Settings & OAuth flow
Pydantic settings
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
# ─── 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)
- Login portale
https://www.interactivebrokers.com→ "User Settings" → "Self-Service OAuth" python scripts/ibkr_oauth_setup.py --env testnet→ genera 2 RSA keypair + stampa SHA-256 fingerprint- Sul portale: registra le 2 public key, ottieni
consumer_key python scripts/ibkr_oauth_setup.py --consumer-key <K> --request-token→ ottiene request token + URL autorizzazione- Aprire URL nel browser, autorizzare, copiare
verifier_code python scripts/ibkr_oauth_setup.py --verifier <V>→ scambia peraccess_token(long-lived ~5 anni) +access_token_secret- Copiare 3 valori in
.env. Ripetere per env live.
Runtime flow (fully unattended)
- Container avvia →
IBKRClientlazy-instantiated alla prima request OAuth1aSignercarica RSA private keys da disk (path da settings)- Prima request privata →
_get_live_session_token():- Genera nonce + timestamp
- Firma
POST /oauth/live_session_tokencon RSA-SHA256 - Diffie-Hellman key exchange con
dh_prime - Riceve
lst(live session token, valido 24h) - Cache in memory con scadenza
now + 86000s
- Request successive: HMAC-SHA256 dei params con
lstcome key lstscaduto → 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)
conidresolution:place_order(symbol="AAPL")→ lookupGET /trsrv/secdef/search?symbol=AAPL&secType=STK→ primo match → cache LRU. Per options: parse OCC (AAPL 240119C00190000) →/iserver/secdef/info.accountIdvalidation: al boot,GET /iserver/accounts→ verificaaccount_id_<env>presente. AltrimentiIBKR_ACCOUNT_NOT_FOUND.- 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. ticklekeep-alive: automatico se ultima request > 4min fa. Indipendente dal LST.- Empty market data → error envelope: snapshot vuoto = subscription mancante; ritorniamo
IBKR_NO_MARKET_DATA_SUBSCRIPTIONinvece di dict vuoto silenzioso. - Leverage cap: IBKR non accetta
leverageper-order. Calcoliamonotional / equity≤max_leveragepre-submit chiamandoget_accountper equity. Pattern asincrono ma cached 30s.
PlaceOrderReq schema
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
- Boot: non connette finché un tool streaming non viene chiamato.
- First sub call: apre WSS, autentica con LST corrente (header
Cookie: api=<lst>), invia subscribe message (smd+{conid}+{fields}osbd+{conid}+{exchange}+{rows}). - Message dispatch: ogni messaggio
smd-...aggiornadict[conid, TickSnapshot]; ognisbd-...aggiornadict[conid, DepthSnapshot]. - Heartbeat: ping ogni 30s; se nessun pong in 60s → forza reconnect.
- Reconnect: backoff esponenziale 1s, 2s, 4s, max 30s. Su reconnect: re-subscribe automatico a tutti i conid attivi.
- Idle unsub: track
last_polled_at[conid]; se >ws_idle_timeout_s(default 300s) → invia unsub, libera slot. Sub forzata viasubscribe_ticknon scade fino aunsubscribeesplicito. - Sub limit: se sub attive ≥
ws_max_subscriptions(default 80) → error envelopeIBKR_WS_SUB_LIMITsu 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, poiIBKR_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:
- Lock filesystem-level via
fcntl.flocksusecrets/.lock - Rename
signature.pem→secrets/.archive/<ts>/signature.pem.old - Rename
signature.pem.new→signature.pem - Stesso per
encryption.pem - Aggiorna
IBKRSettingsin-memory tramiteapp.state.settings.ibkr.consumer_key_<env> = new_consumer_key(settings live, no restart) - Probe
GET /iserver/auth/status - Su KO: rollback (swap inverso), ripristina settings precedenti, alza eccezione
Scheduled health check
Task asyncio creato in lifespan startup:
- Ogni 6h:
GET /iserver/auth/statussu entrambi gli env - Se
competing=trueoauthenticated=falseper >2 cicli consecutivi: log warning +/admin/ibkr/healthespone state degraded - Auto-trigger
/ticklesu 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.pyIBKR section: 75%
Integration smoke (manuale post-deploy)
Non in CI (richiede credenziali reali). Documentato in README:
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 -vverdeuv run ruff check src/cerbero_mcp/exchanges/ibkr/ src/cerbero_mcp/routers/ibkr.pyno warninguv run python -c "from cerbero_mcp.settings import Settings; Settings()"no validation error con.envesempiodocker compose build && docker compose up -dhealthy < 60scurl /health/ready -H "Authorization: Bearer <TESTNET>"ritornaibkrprobato- 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, mode0600, ownership UID container; bind mount./secrets:/secrets:roaggiunto indocker-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