Files
Cerbero-mcp/docs/superpowers/specs/2026-05-03-ibkr-integration-design.md
root 109b8e4686 docs(V2): IBKR integration design spec
Approach A2 (Client Portal Web API + OAuth 1.0a Self-Service): fully
unattended REST con setup iniziale one-time, niente sidecar Java.
Scope V1 include: simple+complex orders (bracket/OCO/OTO), WebSocket
streaming snapshot-on-demand (tick+depth), key rotation semi-automatica
con auto-rollback. 8-commit plan atomico, ~6-8 giorni dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:23:08 +00:00

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_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__.pyapp.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

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)

  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 / equitymax_leverage pre-submit chiamando get_account per 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

  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.pemsecrets/.archive/<ts>/signature.pem.old
  3. Rename signature.pem.newsignature.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:

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