Standalone Rust crate for integrating with Interactive Brokers TWS and IB Gateway.
Wraps ibapi v3.0 with a clean async API, typed errors, market data
snapshots, option chain resolution, and diagnostic event broadcasting.
The IB API has quirks. ibcore smooths them over:
| Raw ibapi | ibcore |
|---|---|
| Numeric error codes with inconsistent docs | Typed IbError::FarmDisconnect |
| Error codes in notice stream | tokio::sync::broadcast of DiagnosticEvent |
| Manual contract construction | build_option_contract, get_primary_exchange |
| No structured disconnect detection | is_connection_dead() for reconnect logic |
Raw TickType decoding |
StockSnapshot / OptionSnapshot with Greeks |
ibcore is built from production experience running automated options trading against IBKR. Every reconnect edge case, every data farm quirk, every undocumented behavior discovered in live trading is handled here.
Prerequisites
- Rust 1.85+ (edition 2024)
- IB Gateway or TWS running with API enabled
cargo add ibcoreuse ibcore::{IbClient, AccountType};
#[tokio::main]
async fn main() -> Result<(), ibcore::IbError> {
// Connect to paper trading Gateway on port 4002
let ib = IbClient::connect(
"127.0.0.1",
4002,
1, // client_id
"delayed", // market_data_type: "delayed" or "realtime"
AccountType::Paper,
)
.await?;
// Get a stock snapshot
let snap = ib.stock_snapshot("SPY").await?;
println!("SPY last: ${:.2}, bid: ${:.2}, ask: ${:.2}",
snap.last, snap.bid, snap.ask);
ib.disconnect().await;
Ok(())
}use ibcore::IbClient;
let chain = ib.fetch_option_chain("SPY").await?;
println!("SPY expirations: {:?}", chain.expirations);
println!("SPY strikes: {:?}", chain.strikes);use ibcore::IbClient;
let snap = ib.option_snapshot(
"SPY",
(2025, 7, 17), // expiry_ymd: (year, month, day)
570.0, // strike
true, // is_call (true = Call, false = Put)
0.0, // _implied_vol (reserved)
0.0, // _underlying_price (reserved)
"CBOE", // exchange
).await?;
println!(
"SPY 570C: bid={:.2}, ask={:.2}, delta={:.4}, theta={:.4}, iv={:.4}",
snap.bid, snap.ask, snap.option_delta, snap.option_theta, snap.option_iv
);use futures::StreamExt;
let contract = ibcore::Contract::stock("SPY").build();
let mut stream = ib.tick_stream(&contract).await?;
while let Some(event) = stream.next().await {
match event? {
ibcore::TickEvent::Price { tick_type, price } => {
println!("{tick_type:?}: ${price:.2}");
}
ibcore::TickEvent::Greeks { delta, theta, implied_volatility, .. } => {
println!("delta={delta:.4}, theta={theta:.4}, iv={implied_volatility:.4}");
}
_ => {} // ignore Size, String, Generic, etc.
}
}let contract = ibcore::Contract::stock("SPY").build();
let data = ib.historical_data(
&contract,
ibcore::BarSize::Hour,
ibcore::Duration::days(5),
ibcore::WhatToShow::Trades,
ibcore::TradingHours::Regular,
).await?;
println!("Period: {} to {} — {} bars", data.start, data.end, data.bars.len());
for bar in &data.bars {
println!("O={:.2}, H={:.2}, L={:.2}, C={:.2}, V={:.0}",
bar.open, bar.high, bar.low, bar.close, bar.volume);
}use tokio::task;
let mut rx = ib.diagnostic_events();
let watcher = task::spawn(async move {
while let Ok(event) = rx.recv().await {
match event.farm_status {
ibcore::FarmState::Inactive => {
eprintln!("⚠️ Data farm inactive: code {}", event.error_code);
}
ibcore::FarmState::Warning => {
eprintln!("⚠️ Data farm warning: {}", event.error_message);
}
_ => {}
}
}
});ibcore is also available as a Python package via PyO3 bindings.
Prerequisites
- Python 3.9+
- Rust toolchain (for maturin build)
Installation
pip install ibcoreConnect and get a stock snapshot
from ibcore import IbClient
ib = IbClient.connect("127.0.0.1", 4002, 1, "delayed", "paper")
snap = ib.stock_snapshot("SPY")
print(f"SPY last: ${snap.last:.2f}, bid: ${snap.bid:.2f}, ask: ${snap.ask:.2f}")
ib.disconnect()Get an option snapshot with Greeks
snap = ib.option_snapshot("SPY", 2025, 7, 17, 570.0, True, "CBOE")
print(f"delta={snap.option_delta:.4f}, theta={snap.option_theta:.4f}, iv={snap.option_iv:.4f}")Place an order
order_id = ib.place_order("SPY", "BUY", 100.0, "MKT", None, "SMART")
print(f"Order placed: {order_id}")Build from source
cd python && pip install maturin && maturin develop --releaseibcore ships a diagnostic CLI tool for Gateway health checks:
# Build from source
cargo build -p ibkr-diag --release
# Check versions
./target/release/ibkr-diag version
# Run a 10-second diagnostic against local Gateway
./target/release/ibkr-diag diagnose --duration 10
# JSON output for scripting
./target/release/ibkr-diag diagnose --json | jq '.farm_state_counts'| Method | Returns | Description |
|---|---|---|
connect(host, port, client_id, market_data_type, account_type) |
Result<IbClient, IbError> |
Connect to IB Gateway with typed config |
disconnect() |
() |
Graceful disconnect |
stock_snapshot(symbol) |
Result<StockSnapshot, IbError> |
One-shot bid/ask/last/volume |
option_snapshot(symbol, expiry, strike, right) |
Result<OptionSnapshot, IbError> |
Bid/ask/last + Greeks (delta, gamma, theta, vega, iv) |
fetch_option_chain(symbol) |
Result<OptionChainData, IbError> |
All expirations + all strikes |
positions() |
Result<Vec<Position>, IbError> |
Current positions |
account_summary(tag, currency) |
Result<Vec<AccountSummaryResult>, IbError> |
NetLiq, GrossPosValue, etc. |
pnl(account_id, model_code) |
Result<PnL, IbError> |
Realized + unrealized P&L |
net_liquidation(account_id, currency) |
Result<f64, IbError> |
Single net liquidation value |
diagnostic_events() |
broadcast::Receiver<DiagnosticEvent> |
Subscribe to structured diagnostic events |
server_version() |
i32 |
Gateway server version |
tick_stream(contract) |
Result<TickStream, IbError> |
Subscribe to live market data ticks |
place_order(symbol, action, quantity, order_type, limit_price, exchange) |
Result<i32, IbError> |
Place an order; returns order_id |
cancel_order(order_id) |
Result<(), IbError> |
Cancel an open order |
open_orders() |
list[dict] |
Snapshot of all open orders |
order_updates() |
OrderUpdateReceiver |
Subscribe to live order status changes (blocking iterator) |
historical_data(contract, bar_size, duration, what_to_show, trading_hours) |
Result<HistoricalData, IbError> |
One-shot historical OHLCV bars |
Emitted on every IB error/warning notice:
pub struct DiagnosticEvent {
pub gateway_version: i32,
pub error_code: i32,
pub error_message: String,
pub error_time: Option<time::OffsetDateTime>,
pub farm_status: FarmState, // Ok | Warning | Inactive | Unknown(i32)
pub connection_state: ConnectionState, // Connected | Disconnected | Reconnecting
pub account_type: AccountType, // Live | Paper
pub os: &'static str,
pub timestamp: chrono::DateTime<chrono::Utc>,
}Subscribe with ib.diagnostic_events() and process asynchronously.
Buffer size is 1024 events; slow subscribers will miss old events.
Typed error variants — no raw error codes in your business logic:
| Variant | Trigger | Typical IB codes |
|---|---|---|
ConnectionFailed |
Can't connect | 502, 504, 506 |
ConnectionReset |
TCP reset | 507, 100 |
Timeout |
IO timeout | — |
MarketData |
Data subscription error | 10000–10999 |
FarmDisconnect |
Data farm offline | 2107 |
CompetingSession |
Live session blocks paper | 10197 |
OrderRejected |
Order invalid | 200–299 |
ContractResolution |
Contract not found | — |
Other |
Unclassified | everything else |
The IbError exception is available in Python as a hierarchy of subclasses. Each Rust variant maps to a specific Python exception type, all inheriting from IbError:
| Python Exception | Rust Variant | Extra Fields |
|---|---|---|
IbConnectionFailedError |
ConnectionFailed |
— |
IbConnectionResetError |
ConnectionReset |
— |
IbMarketDataError |
MarketData |
— |
IbFarmDisconnectError |
FarmDisconnect |
— |
IbOrderRejectedError |
OrderRejected |
rejection_json: Optional[str] |
IbContractResolutionError |
ContractResolution |
— |
IbCompetingSessionError |
CompetingSession |
— |
IbTimeoutError |
Timeout |
— |
IbError (base) |
Other |
— |
All subclasses expose .code (Optional[int]) and .message (str) inherited from the base IbError class.
Backward compat: except IbError: still catches every ibcore exception. You can add more specific handlers for fine-grained control:
try:
ib.stock_snapshot("SPY")
except IbMarketDataError as e:
print(f"Market data error {e.code}: {e.message}")
except IbConnectionFailedError:
reconnect()
except IbError as e:
print(f"Unknown: {e}")| Function | Description |
|---|---|
build_option_contract(symbol, expiry, strike, right) |
Construct IB Contract for options |
get_primary_exchange(symbol) |
Map symbol to exchange (SPY→CBOE, TLT→SMART) |
parse_expiry(date_str) |
Parse "20260717" to NaiveDate |
is_connection_dead(&IbError) |
Check if error means the connection is gone |
classify_farm(error_code) |
Map IB error code to FarmState |
ibcore does NOT auto-reconnect. It gives you the tools to decide:
loop {
match IbClient::connect(host, port, id, md_type, acct_type).await {
Ok(ib) => {
// ... run your strategy ...
// When the connection dies:
break;
}
Err(e) if is_connection_dead(&e) => {
tracing::warn!("Gateway unreachable, retrying in 5s: {e}");
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
Err(e) => return Err(e), // non-connection error
}
}This is deliberate. Your reconnect strategy depends on your trading logic. ibcore tells you WHAT went wrong; you decide HOW to recover.
docker run -d \
--name ib-gateway \
-p 4002:4002 \
-e TWS_USERID=your_username \
-e TWS_PASSWORD=your_password \
-e TRADING_MODE=paper \
ghcr.io/unusualalpha/ib-gateway-docker:latest- Download IB Gateway from IBKR
- Configure: API → Enable ActiveX and Socket Clients → Port 4002
- Check "Download open orders on connection"
- Start Gateway and log in
# Check Gateway is listening
nc -zv 127.0.0.1 4002
# Run ibcore tests (requires Gateway)
cargo test --lib- Wrap, don't re-implement. ibcore wraps
ibapiv3.0, adding ergonomics and safety on top. All wire-protocol details stay inibapi. - Type errors, don't propagate codes.
IbErroris exhaustive. If you match on it, you handle every failure mode the Gateway can produce. - Observe, don't guess. The
DiagnosticEventbroadcast channel lets you monitor Gateway health without polling. Multiple consumers can subscribe independently. - No business logic. ibcore knows about IBKR, not about trading strategies. Position tracking, portfolio allocation, risk management — those belong in your application layer.
ibcore ships PyO3 Python bindings in the python/ workspace crate (ibcore-python).
Build the shared library with:
# Requires Python 3.7+ and Rust 1.85+
PYO3_PYTHON=$(which python3) cargo build -p ibcore-python --releaseThe resulting .so file is at target/release/libibcore_python.so.
import asyncio
from ibcore._ibcore import (
IbClient, StockSnapshot, OptionSnapshot,
DiagnosticEvent, FarmState, ConnectionState,
AccountType, DiagnosticEventReceiver,
)
async def main():
# Connect to paper trading Gateway
client = await IbClient.connect(
"127.0.0.1", 4002, 1, "delayed", "paper",
)
# Stock snapshot
snap = await client.stock_snapshot("SPY")
print(f"SPY: last={snap.last}, bid={snap.bid}, ask={snap.ask}")
# Option snapshot
opt = await client.option_snapshot(
"SPY", 2026, 7, 17, 570.0, True, "CBOE",
)
print(f"SPY 570C: delta={opt.option_delta}, iv={opt.option_iv}")
# Account data
nl = await client.net_liquidation("")
print(f"Net Liq: ${nl:.2f}")
# Positions
positions = await client.positions()
for p in positions:
print(f" {p['symbol']}: {p['quantity']} @ {p['avg_cost']}")
# Diagnostics
receiver = client.diagnostic_events()
for event in receiver:
if event.farm_status == FarmState.WARNING:
print(f"⚠️ {event.error_message}")
await client.disconnect()
asyncio.run(main())async with await IbClient.connect("127.0.0.1", 4002, 1, "delayed", "paper") as ib:
snap = await ib.stock_snapshot("SPY")
print(snap)Open orders and order status updates are accessible via IbClient.open_orders() and IbClient.order_updates().
from ibcore import IbClient, OrderStatusEvent
ib = IbClient.connect("127.0.0.1", 4002, 1, "delayed", "paper")
# (A) Fetch current open orders
open_orders = ib.open_orders()
for o in open_orders:
print(f"Order #{o['order_id']}: {o['action']} {o['quantity']} {o['symbol']} @ {o['limit_price']} [{o['status']}]")
# (B) Subscribe to live order updates
receiver = ib.order_updates()
for event in receiver:
if event.order_id == 0 and event.commission is not None:
print(f"Commission report: ${event.commission:.2f}")
elif event.kind == "Filled":
print(f"Order #{event.order_id} filled: {event.filled_qty} @ ${event.avg_price:.2f}")
elif event.kind == "Rejected":
print(f"Order #{event.order_id} rejected: {event.reason}")
elif event.kind == "Cancelled":
print(f"Order #{event.order_id} cancelled: {event.reason}")
# loop exits when receiver is dropped or stream ends (disconnect)
ib.disconnect()Notes:
open_orders()returnslist[dict]— one dict per open order, with keysorder_id,symbol,action,quantity,order_type,limit_price,status,filled_qty,remaining_qty.order_updates()returns anOrderUpdateReceiver(iterable). The iterator blocks onnext()— use a thread wrapper for timeouts.- Commission-only events appear as
kind="Filled",order_id=0,commission=Some(x.xx). Checkevent.order_id == 0 and event.commission is not Noneto identify them. Theexecution_idfield (Nonefor fill-status events,Some(id)for commission-report events) allows matching commissions to their fills by temporal pairing. KeyboardInterruptduringnext()is delayed until the next event arrives.- Only one
order_updates()subscription is allowed per IB Gateway. A second call returnsIbError.
- ibquirk — AI bot that diagnoses IBKR API problems using ibcore's DiagnosticEvents. Join the waitlist.
- Discord — Community for IBKR API developers. Get help, report bugs, share quirks.
MIT — see LICENSE.
ibcore is not affiliated with Interactive Brokers Group, Inc.