Interactive Brokers SDK
Complete reference for ctx.ibkr — programmatic stocks, options, futures, forex, and bonds trading on Interactive Brokers from worker code.
Architectural notes
IBKR is the architectural odd-one-out among our trading integrations:
- No API keys, no signed requests. Auth is session-based — the IBKR Client Portal Gateway runs on the user's machine at
https://localhost:5000and the user logs in there with their IBKR username + password. - The worker reaches the user's gateway over the public internet. This means the user's machine must be on, the gateway must be running, and the gateway URL must be reachable (port-forwarding or tunnel, e.g. tailscale).
conidnot symbol. IBKR identifies every instrument by a numeric Contract ID. AAPL = 265598, IBM = 8314, etc. Usesearch_contractorget_contract_detailsto discover.- Daily reset. All
iserver/*endpoints are unavailable for a few minutes around 01:00 in the user's region as IBKR resets the brokerage session.
19 methods. Server-only.
Setup
- Install Client Portal Gateway on the machine that will run the worker (or any machine the worker can reach):
- Download from interactivebrokers.com/en/trading/ib-api.php
- Run with
bin/run.sh root/conf.yaml(Linux/Mac) orbin\run.bat root\conf.yaml(Windows) - Open
https://localhost:5000in a browser, accept the self-signed cert, log in with your IBKR credentials.
- Add an IBKR block to your workspace canvas.
- Configure the gateway URL in the inspector —
https://localhost:5000if the worker runs on the same machine, or your public/tailnet URL for remote workers. - Connect the block to your Worker block via an edge.
ctx.ibkris now available.
def tick(ctx):
accs = ctx.ibkr.get_accounts()
ctx.log.info(f"Accounts: {[a['accountId'] for a in accs]}")If no IBKR block is connected:
RuntimeError: No trading block connected. Connect an IBKR block to this worker.conid Discovery
Every IBKR endpoint identifies instruments by conid. Two ways to find it:
search_contract
Search by ticker + security type. Fast for stocks, options, futures, forex.
ctx.ibkr.search_contract(symbol: str, sec_type: str = "STK") -> dict
# sec_type: "STK" | "OPT" | "FUT" | "CASH" (forex) | "BOND" | "CFD" | "FUND" | "IND" | "WAR"res = ctx.ibkr.search_contract("AAPL", "STK")
conid = res[0]["conid"] # 265598get_contract_details
Full metadata for a known conid: trading rules, exchanges, symbology, increments.
ctx.ibkr.get_contract_details(conid: int) -> dictSession Management
These ensure the brokerage session is alive (IBKR sessions die after ~30 min of inactivity).
tickle
Keepalive ping. Call every few minutes from a long-running worker.
ctx.ibkr.tickle() -> dictauth_status
Check if the brokerage session is authenticated.
ctx.ibkr.auth_status() -> dict
# returns {"authenticated": true|false, "competing": false, "connected": true, "MAC": "..."}select_account
Set the active trading account for the session. Required after login when the user has multiple accounts (cash + margin, or sub-accounts).
ctx.ibkr.select_account(account_id: str) -> dictdef setup(ctx):
ctx.ibkr.tickle()
accs = ctx.ibkr.get_accounts()
ctx.ibkr.select_account(accs[0]["accountId"])Account & Portfolio
get_accounts
All accounts visible to the logged-in user.
ctx.ibkr.get_accounts() -> listget_subaccounts
For Financial Advisor (FA) or IBroker users: their managed sub-accounts.
ctx.ibkr.get_subaccounts() -> listget_summary
High-level account summary: equity, available funds, P&L, margin.
ctx.ibkr.get_summary(account_id: str) -> dictget_ledger
Multi-currency balance ledger — cashbalance, netliquidationvalue, unrealizedpnl, stockmarketvalue, margin per currency.
ctx.ibkr.get_ledger(account_id: str) -> dictledger = ctx.ibkr.get_ledger("U1234567")
usd = ledger.get("USD", {})
ctx.log.info(f"USD cash: ${usd.get('cashbalance', 0):,.2f}, "
f"NLV: ${usd.get('netliquidationvalue', 0):,.2f}")get_positions
All open positions for an account.
ctx.ibkr.get_positions(account_id: str) -> listMarket Data
get_market_snapshot
Top-of-book quotes for one or many conids.
ctx.ibkr.get_market_snapshot(conids, fields: list = None) -> list| Field | Tag | Description |
|---|---|---|
| Last | 31 | Last traded price |
| Bid | 84 | Best bid |
| Ask | 86 | Best ask |
| BidSize | 88 | Best-bid size |
| AskSize | 85 | Best-ask size |
| LastSize | 7059 | Last trade size |
| Volume | 87 | Today's volume |
IBKR's preflight quirk
The first snapshot request after a fresh session is a preflight — it initiates the streaming pipeline and returns no prices. The second request returns actual data. Pattern:
ctx.ibkr.get_market_snapshot(conids=[265598])
import time; time.sleep(0.5)
quote = ctx.ibkr.get_market_snapshot(conids=[265598])
last = float(quote[0].get("31", 0))get_historical_data
Historical OHLC bars + volume.
ctx.ibkr.get_historical_data(
conid: int,
period: str = "1d", # "{n}{unit}" e.g. "1d", "1w", "3m", "1y"
bar: str = "5min", # bar size: "1min", "5min", "1h", "1d", ...
outside_rth: bool = False,
) -> dicthist = ctx.ibkr.get_historical_data(conid=265598, period="1d", bar="5min")
closes = [b["c"] for b in hist["data"]]
sma_20 = sum(closes[-20:]) / 20Quota
IBKR allows max 5 concurrent historical-data requests per session. Don't fan out across 100 conids in one tick.
Orders
get_orders
All orders (any status) for the session.
ctx.ibkr.get_orders() -> dictget_filled_orders
Same shape but server-filtered to filled orders only.
ctx.ibkr.get_filled_orders(account_id: str = None) -> dictplace_order
Place a single order.
ctx.ibkr.place_order(
account_id: str,
conid: int,
side: str, # "BUY" | "SELL"
quantity: float,
order_type: str = "MKT", # "MKT" | "LMT" | "STP" | "STP_LMT" | "TRAIL"
price: float = None, # required for LMT, STP_LMT
tif: str = "DAY", # "DAY" | "GTC" | "IOC" | "OPG" | "FOK"
) -> dict# Market buy 10 shares of AAPL
r = ctx.ibkr.place_order(
account_id="U1234567",
conid=265598, side="BUY", quantity=10, order_type="MKT",
)IBKR fat-finger replies
IBKR sometimes responds with a confirmation prompt instead of placing the order ("Are you sure you want to buy at >5% above last?"). The response will be a list with id (the message id) and message. Call reply_to_order(message_id, confirmed=True) to proceed, or suppress_messages([id]) once at startup to skip these warnings for the whole session.
modify_order
Modify an unfilled order. Pass all original fields plus the change — IBKR replaces the order rather than patching.
ctx.ibkr.modify_order(
account_id: str,
order_id: str,
conid: int,
side: str,
quantity: float,
order_type: str = "LMT",
price: float = None,
tif: str = "DAY",
) -> dictcancel_order
Cancel an order. Returns acknowledgement of receipt — not confirmation that the cancel filled.
ctx.ibkr.cancel_order(account_id: str, order_id: str) -> dictreply_to_order
Reply to a fat-finger / large-order confirmation message.
ctx.ibkr.reply_to_order(message_id: str, confirmed: bool = True) -> dictdef tick(ctx):
r = ctx.ibkr.place_order(account_id, conid, "BUY", 1000, "MKT")
if isinstance(r, list) and r and "message" in r[0]:
# Fat-finger warning
ctx.log.warn(f"IBKR: {r[0]['message']}")
ctx.ibkr.reply_to_order(r[0]["id"], confirmed=True)suppress_messages
Suppress one or more fat-finger message types for the remainder of this session. Useful in pure-bot workflows where you don't want manual confirmations.
ctx.ibkr.suppress_messages(message_ids: list) -> dictdef setup(ctx):
ctx.ibkr.suppress_messages(["o163"]) # large-order warningCommon Patterns
Keepalive
IBKR sessions die silently after ~30 minutes of inactivity. Tickle every tick:
def tick(ctx):
ctx.ibkr.tickle()
# ... rest of strategyDaily reset awareness
Brokerage endpoints (everything iserver/*) are unavailable for ~5 minutes around 01:00 in the user's region. Plan strategies that don't trade during this window.
from datetime import datetime, timezone
def tick(ctx):
now = datetime.now(timezone.utc)
if now.hour == 5 and now.minute < 10: # rough US/Eastern 01:00 in UTC
ctx.log.info("IBKR daily reset window — skipping")
return
# ... tradeError handling
def tick(ctx):
try:
positions = ctx.ibkr.get_positions(account_id)
except Exception as e:
msg = str(e)
if "competing" in msg.lower():
ctx.log.error("Another IBKR session is competing — aborting")
return
ctx.log.error(f"IBKR unreachable: {e}")
returnIbkrAPIError carries code and msg attributes. Most non-200s come back as JSON with an error field that the SDK extracts.
Cloud-Run proxy
IBKR traffic does not route through the Cloud Run proxy — the gateway is on the user's machine, not a public IP. Workers connect directly. The verify=False SSL flag is set because the gateway uses a self-signed cert.
Recipes
Multi-account daily NLV summary
def setup(ctx):
ctx.ibkr.tickle()
def tick(ctx):
accs = ctx.ibkr.get_accounts()
total_usd = 0
for a in accs:
ledger = ctx.ibkr.get_ledger(a["accountId"])
usd = ledger.get("USD", {})
nlv = float(usd.get("netliquidationvalue", 0))
total_usd += nlv
ctx.log.info(f"{a['accountId']:10} NLV ${nlv:,.2f}")
ctx.monitor.metric("ibkr_total_nlv", total_usd)Mean-reversion on AAPL with bracket exits
def setup(ctx):
ctx.ibkr.tickle()
# Skip fat-finger prompts for the session
ctx.ibkr.suppress_messages(["o163", "o451"])
# Cache the conid
res = ctx.ibkr.search_contract("AAPL", "STK")
ctx.state.set("aapl_conid", int(res[0]["conid"]))
ctx.state.set("account", ctx.ibkr.get_accounts()[0]["accountId"])
def tick(ctx):
ctx.ibkr.tickle()
conid = ctx.state.get("aapl_conid")
acct = ctx.state.get("account")
hist = ctx.ibkr.get_historical_data(conid=conid, period="1d", bar="15min")
closes = [b["c"] for b in hist["data"]]
if len(closes) < 20:
return
sma = sum(closes[-20:]) / 20
last = closes[-1]
pos = ctx.ibkr.get_positions(acct)
has = any(p["conid"] == conid and p["position"] != 0 for p in pos)
if last < sma * 0.99 and not has:
r = ctx.ibkr.place_order(acct, conid, "BUY", 10, "MKT")
ctx.log.info(f"BUY 10 AAPL @ {last:.2f} (SMA20 {sma:.2f})")
# If IBKR asks for confirmation, auto-confirm
if isinstance(r, list) and r and "message" in r[0]:
ctx.ibkr.reply_to_order(r[0]["id"], confirmed=True)
elif last > sma * 1.02 and has:
ctx.ibkr.place_order(acct, conid, "SELL", 10, "MKT")
ctx.log.info(f"SELL 10 AAPL @ {last:.2f}")Working-order ladder around current price
def setup(ctx):
ctx.ibkr.tickle()
ctx.ibkr.suppress_messages(["o163"])
ctx.state.set("conid", 265598)
ctx.state.set("account", ctx.ibkr.get_accounts()[0]["accountId"])
def tick(ctx):
ctx.ibkr.tickle()
conid = ctx.state.get("conid")
acct = ctx.state.get("account")
# Cancel any leftovers
open_orders = ctx.ibkr.get_orders().get("orders", [])
for o in open_orders:
if o.get("conid") == conid and o.get("status") in ("PreSubmitted", "Submitted"):
ctx.ibkr.cancel_order(acct, o["orderId"])
# Re-quote: 5 buy rungs, $0.50 apart
snap = ctx.ibkr.get_market_snapshot(conids=[conid])
import time; time.sleep(0.4)
snap = ctx.ibkr.get_market_snapshot(conids=[conid])
if not snap or "31" not in snap[0]:
return
last = float(snap[0]["31"])
for i in range(1, 6):
ctx.ibkr.place_order(
account_id=acct, conid=conid, side="BUY",
quantity=10, order_type="LMT",
price=round(last - i * 0.5, 2), tif="DAY",
)
ctx.log.info(f"Buy ladder placed below {last:.2f}")Reference
| IBKR endpoint | SDK method |
|---|---|
POST /tickle | tickle |
POST /iserver/auth/status | auth_status |
POST /iserver/account | select_account |
GET /portfolio/accounts | get_accounts |
GET /portfolio/subaccounts | get_subaccounts |
GET /portfolio/{id}/summary | get_summary |
GET /portfolio/{id}/ledger | get_ledger |
GET /portfolio/{id}/positions/0 | get_positions |
GET /iserver/marketdata/snapshot | get_market_snapshot |
GET /iserver/marketdata/history | get_historical_data |
GET /iserver/secdef/search | search_contract |
GET /iserver/contract/{conid}/info | get_contract_details |
GET /iserver/account/orders | get_orders / get_filled_orders |
POST /iserver/account/{id}/orders | place_order |
POST /iserver/account/{id}/order/{oid} | modify_order / cancel_order |
POST /iserver/reply/{messageId} | reply_to_order |
POST /iserver/questions/suppress | suppress_messages |