IBKR adapter
Interactive Brokers via ib_async. Requires a locally-running TWS or IB Gateway.
horizon.venues.ibkr.Ibkr wraps ib_async (the maintained fork of ib_insync). Unlike every other venue in Horizon, IBKR has no hosted HTTPS endpoint. A locally-running TWS (desktop app) or IB Gateway (headless Java app) holds the authenticated session and exposes a TCP socket the Python process connects to.
The class exposes requires_local_gateway = True so higher layers can surface a useful warning when the gateway is not running.
Deployment envelope (the hard part)
Before the adapter can do anything useful, the following must be running:
- TWS or IB Gateway with API access enabled, logged in, and the correct paper/live profile.
- IBC (community autologin daemon) to keep the gateway logged in across the mandatory daily restarts.
- A supervisor (systemd, Docker with a restart policy,
supervisord) to relaunch the gateway if it crashes. - Market-data subscriptions purchased on IBKR’s Market Data page if you need real-time quotes. Delayed data is free (
reqMarketDataType(3)).
A typical prod layout: Docker image with ibgateway + IBC + xvfb + supervisord. The Python process talks to host.docker.internal:4002 (paper) or 4001 (live).
Ports
| Client | Port | Notes |
|---|---|---|
| TWS paper | 7497 | Default. |
| TWS live | 7496 | |
| IB Gateway paper | 4002 | Headless. |
| IB Gateway live | 4001 |
Each concurrent Python process needs a unique client_id on the gateway (1..999). client_id=0 receives manually-entered TWS orders.
Quickstart
from horizon.venues.ibkr import Ibkr
ibkr = Ibkr(host="127.0.0.1", port=7497, client_id=1, paper=True)
ibkr.connect() # raises if the gateway isn't running
print(ibkr.balance())
No API keys. The gateway login (username, password, 2FA) is managed by IBC at the gateway process level, not in the Python code.
Paper / live safety valve
Ibkr(paper: bool = True) is mandatory paired with port. The
constructor cross-checks against the known IBKR port pairs and refuses
to construct on a mismatch — a typo on port= can otherwise silently
route real capital because IBKR has no in-band paper/live flag.
Ibkr(port=7497, paper=True) # OK — paper TWS
Ibkr(port=4002, paper=True) # OK — paper Gateway
Ibkr(port=7496, paper=False) # OK — live TWS, EXPLICIT
Ibkr(port=4001, paper=False) # OK — live Gateway, EXPLICIT
Ibkr(port=7496, paper=True) # ValueError: port 7496 is a known IBKR LIVE port
Ibkr(port=7497, paper=False) # ValueError: port 7497 is a known IBKR PAPER port
Unknown / custom ports (e.g. a tunnelled gateway) are allowed but
emit a WARNING log so the operator can confirm intent.
Connection health probe
Ibkr.health_check() issues a cheap reqCurrentTime round-trip
against the gateway and detects the silent TCP-half-open failure mode
where isConnected() still returns True for minutes after the
gateway has died.
if not ibkr.health_check():
# ibkr.is_connected() is now False; next call will reconnect.
raise RuntimeError("IBKR gateway unresponsive")
print(ibkr._last_health_rtt_s) # last measured RTT in seconds
print(ibkr._last_health_at) # tz-aware UTC datetime
Call from your run loop every 5–30 seconds in production. The method
is rate-limited through the same TokenBucket (default 45 req/s,
burst 10) so it cannot pace-violate the gateway by itself.
Symbology
| Market id | Contract |
|---|---|
"AAPL" | Stock(AAPL, SMART, USD) |
"OPT:AAPL:20260619:180:C" | Option (OCC-style expiry, strike, right) |
"FUT:ES:202503:CME" | Future (symbol, YYYYMM, exchange) |
"FX:EUR.USD" | Forex |
Contracts are qualified on first use (qualifyContracts resolves the conId).
Order types
| Type | OrderAction.order_type | IB mapping |
|---|---|---|
| Market | "market" | MKT |
| Limit | "limit" | LMT |
| Stop | "stop" | STP with auxPrice=stop_price |
| Stop-limit | "stop_limit" | STP LMT with auxPrice + lmtPrice |
| Trailing-stop | "trailing_stop" | TRAIL with trailingPercent or auxPrice |
TIF maps to DAY, GTC, IOC, OPG, GTD. Bracket and OCO orders are roadmap (use ib.bracketOrder() directly until then).
Status map
IBKR statuses map to OrderStatus:
| IB status | OrderStatus |
|---|---|
PendingSubmit, PreSubmitted, ApiPending | PendingNew |
Submitted | Accepted |
Submitted with filled > 0 | PartiallyFilled |
Filled | Filled |
Cancelled, ApiCancelled | Canceled |
PendingCancel | PendingCancel |
Inactive | Rejected |
Fills
A fill becomes “final” when execDetailsEvent + commissionReportEvent have both fired for the same execId, with orderStatus=Filled. The adapter:
- Captures the execution on
execDetailsEvent(price, shares, side, venue) and pushes aVenueFillinto the buffer. - Refines the buffered fill’s
feeandfee_currencywhencommissionReportEventarrives for the matchingexecId.
drain_fills() returns everything buffered since the last call. Fills dedup by execId.
Financial advisor accounts
ib.managedAccounts() lists sub-accounts. The adapter picks the first as the default when account= is not passed. For block trading across sub-accounts, set order.account per sub-account or use faGroup / faProfile on the IB Order for allocation; see Allocation for how this fits into Horizon’s block-allocation flow.
Gotchas
- Scheduled restart. TWS restarts daily around midnight exchange-local; IB Gateway restarts weekly. The socket drops. IBC handles the relaunch; the adapter must reconnect. Wire a supervisor that restarts the Python process too.
- Timezones. IBKR returns exchange-local timestamps. The adapter normalizes to UTC at the boundary.
- Real-time data subscriptions. Without them,
reqMktDatareturns delayed quotes. NBBO fat-finger checks and best-ex reporting need real-time data. - clientId collisions. Two processes with the same
clientIdwill kick each other. Reserve ranges per service.
Status by phase
| Capability | Status |
|---|---|
| Connect via ib_async, auto-disconnect on close | Shipped |
| Stock, Option, Future, Forex symbology | Shipped |
| Submit (market, limit, stop, stop_limit, trailing) | Shipped |
| Cancel, cancel_all (per-symbol), amend | Shipped |
| Positions, balance (via accountSummary), open_orders | Shipped |
| Fills via execDetailsEvent + commissionReportEvent | Shipped |
Paper / live safety valve (paper= cross-checks port=) | Shipped |
Rate limiter (TokenBucket, 45 req/s default, tunable) | Shipped |
health_check() via reqCurrentTime | Shipped |
| Structured logging on every silent-except path | Shipped |
| Bracket / OCO orders as one ticket | Roadmap |
Real-time quotes (reqMktData) | Roadmap |
| Managed account enumeration | Auto-selects first |
| Financial-advisor allocation groups | L2 |