Lesson 4.4.2 · 70 min
Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
The Interactive Brokers API does not talk to its servers but to a local program that has to be kept alive. This lesson covers what you need to trade with it from Python: choosing between TWS and IB Gateway, living with their restarts, qualifying contracts, requesting data knowing what type it is, sending bracket orders without leaving a position without a stop and rebuilding state after a disconnection. The real ib_async code is not executed; a simulated IB with a logical clock reproduces, message by message, a real order from the course data.
- Module
- 4.4 · Brokers and APIs
- Duration
- 70 min
- Prerequisites
- 1.1.4 · Order types, 1.3.6 · Brokers as market data sources, 3.3.3 · Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5, 4.4.1 · Choosing a broker from Spain or the EU: regulation, assets, costs and API
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
What has been covered and what is missing
Lesson 1.3.6 presented the architecture of the Interactive Brokers (IBKR) API: the program does not talk to the
broker's servers but to TWS or IB Gateway, which run on the machine itself and open a local port (7496 and 7497
in TWS, 4001 and 4002 in Gateway, live and paper), and it downloaded history with ib_async respecting the pacing
limits. Lesson 3.3.3 sent a currency order with Forex, LimitOrder, whatIfOrder and orderRef. This lesson
completes what you need to run a system: the life cycle of TWS and Gateway, the ib_async programming model,
contracts, market data types, bracket orders, events and reconnection.
Lesson 4.4.1 left a task to do first: check which entity you sign with. Interactive Brokers Ireland Limited appears in the CNMV list of EEA investment firms under freedom to provide services (registered on December 29, 2020) and in the ESMA register as an active investment firm, authorized by the Central Bank of Ireland (queries of October 22, 2026); the group company you sign with is stated in the contract, and that is the one to look up. The method of that lesson works for checking this broker and any other.
TWS or IB Gateway
According to the TWS API documentation, from a program's point of view IB Gateway and TWS are identical: both are the server the socket is opened to after the user has authenticated, and in both you have to type a username and password in a login window (a session without a graphical interface is not supported). Gateway is lighter (it uses around 40% fewer resources); TWS adds the platform's graphical tools1Note 1 · Interactive Brokers, TWS API, The IB Gateway, accessed on October 22, 2026..
| TWS | IB Gateway | |
|---|---|---|
| Default ports (live / paper) | 7496 / 7497 | 4001 / 4002 |
| Interface | Full platform | Minimal window |
| Resources | More | Around 40% fewer |
| Typical use | Development, manual monitoring | Server for an automated system |
What constrains a system most is not the choice but the life cycle they share:
- Daily restart. Both are designed to restart every day (among other things, to download the contract definitions again). Since version 974 they have an automatic restart with no intervention; with it they can run from Sunday to Sunday without authenticating again, and after the Saturday night server reset the credentials have to be entered again1. The reauthentication page recommends that API users enable the “Never lock Trader Workstation” and “Auto restart” options, and explains the message that appears when the weekly reauthentication is due2Note 2 · Interactive Brokers, TWS API, Daily & Weekly Reauthentication, accessed on October 22, 2026..
- Server maintenance. The system codes documentation warns that it is very likely you will lose the connection to the servers at least once a day because of daily maintenance, that TWS or Gateway reconnect by themselves afterwards and that trading during those windows is not recommended3Note 3 · Interactive Brokers, TWS API, System Message Codes (1100, 1101, 1102 and 1300), accessed on October 22, 2026..
- Automatic login. Since the session requires a window, unattended systems have used third-party tools that fill
in the username and password and manage the restarts. The best known, IBC, on which the
ib_asyncWatchdogclass relies, announced its retirement on September 1, 2026: its repository is archived, with no new features or active support4Note 4 · IBC, repository README (“This project has been retired, as of 1 September 2026”);ib_async2.1.0,Watchdogclass (source code), accessed on October 22, 2026.. Whoever uses it inherits that maintenance risk.
Mermaid source
stateDiagram-v2 [*] --> Connected Connected --> NoServer: error 1100 NoServer --> Connected: 1102 data intact NoServer --> Resubscribe: 1101 data lost Resubscribe --> Connected Connected --> NoSocket: daily restart NoSocket --> Connected: reconnect and reconcile
Messages 1100 (the connection between TWS and the servers was lost), 1101 (restored, with the data subscriptions lost:
they have to be requested again) and 1102 (restored, with the data maintained) arrive as errors, but they are not the
consequence of anything the program did3. When it is TWS or Gateway itself that goes down, the socket is lost:
ib_async emits disconnectedEvent and, on reconnecting, resynchronizes its state. The program has to rebuild its own:
the last section does that.
ib_async 2.1.0: the programming model
ib_async is the continuation of ib_insync (lesson 1.3.6). Version 2.1.0 was published on PyPI on December 8, 2025
and was the latest on October 22, 2026; it requires Python 3.10 or later and depends on aeventkit, nest_asyncio and
tzdata (with the constraint tzdata<2026.0)5Note 5 · ib_async on PyPI (version 2.1.0, published on December 8, 2025;
dependencies aeventkit>=2.1.0,<3.0.0, nest_asyncio and tzdata>=2025.2,<2026.0) and
API documentation, accessed on October 22, 2026.. It is not installed in the course environment: with the
environment's tzdata version (2026.5), adding it would force a tzdata downgrade, and running it requires a TWS or
Gateway session. That is why the real code goes in blocks that are not executed and, to run something, the lesson
builds a simulated IB with the same shape.
import importlib.util
import sys
print("Python", sys.version.split()[0])
print("ib_async installed:", importlib.util.find_spec("ib_async")
is not None)Python 3.14.9
ib_async installed: FalseThe real code for a connection, as documented by version 2.1.0:
# uv add ib_async (2.1.0)
from ib_async import IB, util
ib = IB()
ib.connect("127.0.0.1", 4002, clientId=7, # IB Gateway, paper
timeout=10, readonly=False)
print(ib.isConnected(), ib.managedAccounts())
print(ib.positions()) # already synchronized
ib.sleep(1) # never time.sleep
ib.disconnect()
# In Jupyter, before anything else: util.startLoop()Three ideas hold the library up:
- A synchronous API on top of asyncio.
connect,qualifyContractsorreqExecutionsblock until they have the response, but underneath there is an asyncio loop that keeps processing messages. While the program waits, that loop has to run: that is why you wait withib.sleep()orib.run()and never withtime.sleep(), which freezes it (theutil.sleepdocumentation itself says so). Each method has its…Asyncversion for use withawait. - Live objects.
placeOrderreturns aTradeandreqMktDataaTicker, which update themselves as messages arrive:trade.orderStatus.status,trade.fills,ticker.bid. - Events. The
IBobject and eachTradeexpose events (orderStatusEvent,execDetailsEvent,commissionReportEvent,errorEvent,disconnectedEvent;trade.filledEvent,trade.fillEvent…) to which a function is connected with+=. The documentation advises against making new requests inside an event handler.
The simulator reproduces those three ideas with the same classes and fields as version 2.1.0 (checked in its source
code): Contract, Order, OrderStatus, Execution, CommissionReport, Fill, Trade and a minimal Event.
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import NamedTuple
class Event:
"""The essentials of eventkit.Event: += connects, emit calls."""
def __init__(self, nombre):
self.nombre, self.handlers = nombre, []
def __iadd__(self, f):
self.handlers.append(f)
return self
def emit(self, *args):
for f in list(self.handlers):
f(*args)
@dataclass
class Contract:
secType: str = ""
conId: int = 0
symbol: str = ""
lastTradeDateOrContractMonth: str = ""
multiplier: str = ""
exchange: str = ""
primaryExchange: str = ""
currency: str = ""
localSymbol: str = ""
def Stock(symbol="", exchange="", currency="", **kw):
return Contract("STK", symbol=symbol, exchange=exchange,
currency=currency, **kw)
@dataclass
class Order:
orderId: int = 0
clientId: int = 0
action: str = ""
totalQuantity: float = 0.0
orderType: str = ""
lmtPrice: float = 0.0
auxPrice: float = 0.0
tif: str = ""
orderRef: str = ""
transmit: bool = True
parentId: int = 0
def MarketOrder(action, q, **kw):
return Order(action=action, totalQuantity=q, orderType="MKT", **kw)
def LimitOrder(action, q, lmt, **kw):
return Order(action=action, totalQuantity=q, orderType="LMT",
lmtPrice=lmt, **kw)
def StopOrder(action, q, stop, **kw):
return Order(action=action, totalQuantity=q, orderType="STP",
auxPrice=stop, **kw)
@dataclass
class OrderStatus:
orderId: int = 0
status: str = ""
filled: float = 0.0
remaining: float = 0.0
avgFillPrice: float = 0.0
permId: int = 0
lastFillPrice: float = 0.0
DoneStates = frozenset(["Filled", "Cancelled", "ApiCancelled",
"Inactive"])
@dataclass
class Execution:
execId: str = ""
time: datetime = None
acctNumber: str = ""
exchange: str = ""
side: str = ""
shares: float = 0.0
price: float = 0.0
permId: int = 0
orderId: int = 0
cumQty: float = 0.0
avgPrice: float = 0.0
orderRef: str = ""
@dataclass
class CommissionReport:
execId: str = ""
commission: float = 0.0
currency: str = ""
realizedPNL: float = 0.0
class Fill(NamedTuple):
contract: Contract
execution: Execution
commissionReport: CommissionReport
time: datetime
@dataclass
class Trade:
contract: Contract
order: Order
orderStatus: OrderStatus
fills: list = field(default_factory=list)
log: list = field(default_factory=list)
def __post_init__(self):
for n in ["statusEvent", "fillEvent", "filledEvent"]:
setattr(self, n, Event(n))
def isDone(self):
return self.orderStatus.status in OrderStatus.DoneStates
print([f for f in Execution.__dataclass_fields__][:7])['execId', 'time', 'acctNumber', 'exchange', 'side', 'shares', 'price']They are subsets of the real classes (which have many more fields), with the same names. The simulated IB keeps a
logical clock: messages are scheduled with a delay in milliseconds and sleep delivers them in order, without
looking at the system clock or really sleeping, so the output is always the same. Its public methods have the same
signature as those of ib_async (connect, qualifyContracts, reqMarketDataType, reqMktData, placeOrder,
sleep, reqExecutions); the ones that start with _ play the part of TWS and the broker.
import heapq
@dataclass
class Ticker:
contract: Contract
marketDataType: int = 1
bid: float = float("nan")
ask: float = float("nan")
def hasBidAsk(self):
return self.bid == self.bid and self.ask == self.ask
def midpoint(self):
return ((self.bid + self.ask) / 2 if self.hasBidAsk()
else float("nan"))
class IBSimulado:
"""The ib_async.IB interface without a network: a logical clock and
scheduled messages like the ones TWS or IB Gateway would send."""
EVENTOS = ["connectedEvent", "disconnectedEvent", "errorEvent",
"orderStatusEvent", "execDetailsEvent",
"commissionReportEvent"]
def __init__(self, catalogo, t0, suscritos=(), cotizaciones=None):
self.catalogo, self.t, self.t0 = catalogo, t0, t0
self.suscritos = set(suscritos)
self.cotizaciones = cotizaciones or {}
self.cola, self.n, self.retenidas = [], 0, []
self.trades, self._fills, self.broker_fills = {}, {}, []
self.next_id, self.tipo_datos, self.conectado = 1, 1, False
for e in self.EVENTOS:
setattr(self, e, Event(e))
# logical clock
def programa(self, dt_ms, f, *args):
self.n += 1
cuando = self.t + timedelta(milliseconds=dt_ms)
heapq.heappush(self.cola, (cuando, self.n, f, args))
def sleep(self, segundos):
fin = self.t + timedelta(seconds=segundos)
while self.cola and self.cola[0][0] <= fin:
self.t, _, f, args = heapq.heappop(self.cola)
f(*args)
self.t = fin
def ms(self):
return (self.t - self.t0) / timedelta(milliseconds=1)
def connect(self, host="127.0.0.1", port=4002, clientId=1):
self.clientId, self.conectado = clientId, True
self.connectedEvent.emit()
# contracts, like qualifyContracts in version 2.1.0
def qualifyContracts(self, *contratos):
salida = []
for c in contratos:
pedidos = {k: v for k, v in vars(c).items()
if v and k != "exchange"}
cand = [d for d in self.catalogo
if all(self._igual(d, k, v)
for k, v in pedidos.items())]
if len(cand) != 1:
if not cand:
self.errorEvent.emit(-1, 200, "No security "
"definition has been found for the request", c)
salida.append(None)
continue
destino = c.exchange
for k, v in vars(cand[0]).items():
setattr(c, k, v)
c.exchange = destino or cand[0].exchange
salida.append(c)
return salida
@staticmethod
def _igual(d, k, v):
if k == "lastTradeDateOrContractMonth": # YYYYMM or YYYYMMDD
return getattr(d, k).startswith(v)
return getattr(d, k) == v
# market data
def reqMarketDataType(self, tipo):
self.tipo_datos = tipo
def reqMktData(self, contrato):
tk = Ticker(contrato, marketDataType=self.tipo_datos)
if (self.tipo_datos in (1, 2)
and contrato.conId not in self.suscritos):
self.programa(5, self.errorEvent.emit, 1, 354,
"Requested market data is not subscribed",
contrato)
return tk
for dt, bid, ask in self.cotizaciones.get(contrato.conId, []):
self.programa(dt, self._tick, tk, bid, ask)
return tk
def _tick(self, tk, bid, ask):
tk.bid, tk.ask = bid, ask
# orders
def placeOrder(self, contrato, orden):
if not orden.orderId:
orden.orderId, self.next_id = self.next_id, self.next_id + 1
orden.clientId = self.clientId
tr = Trade(contrato, orden, OrderStatus(
orderId=orden.orderId, status="PendingSubmit",
remaining=orden.totalQuantity))
self.trades[orden.orderId] = tr
tr.log.append((self.ms(), "PendingSubmit"))
if not orden.transmit: # created in TWS, not sent
self.retenidas.append(tr)
return tr
grupo = [t for t in self.retenidas
if orden.parentId in (t.order.orderId,
t.order.parentId)]
for t in grupo + [tr]:
hija = t.order.parentId != 0
self.programa(30, self._status, t, {
"status": "PreSubmitted" if hija else "Submitted"})
self.retenidas = [t for t in self.retenidas if t not in grupo]
return tr
# what TWS and the broker do
def _status(self, tr, cambios):
if tr.isDone(): # a final status does not change
return
for k, v in cambios.items():
setattr(tr.orderStatus, k, v)
tr.log.append((self.ms(), tr.orderStatus.status))
self.orderStatusEvent.emit(tr)
tr.statusEvent.emit(tr)
if tr.orderStatus.status == "Filled":
tr.filledEvent.emit(tr)
def _exec(self, tr, ex, cr):
self.broker_fills.append(Fill(tr.contract, ex, cr, ex.time))
if not self.conectado or ex.execId in self._fills:
return # nobody listening, or repeated
fill = Fill(tr.contract, ex, CommissionReport(), self.t)
self._fills[ex.execId] = fill
tr.fills.append(fill)
self.execDetailsEvent.emit(tr, fill)
tr.fillEvent.emit(tr, fill)
def _commission(self, tr, cr):
fill = self._fills.get(cr.execId)
if fill is not None and self.conectado:
for k, v in vars(cr).items():
setattr(fill.commissionReport, k, v)
self.commissionReportEvent.emit(tr, fill,
fill.commissionReport)
def _cae(self):
self.conectado = False
self.disconnectedEvent.emit()
def reqExecutions(self):
return list(self.broker_fills)Contracts: qualify before use
A Contract describes an instrument. The IBKR documentation gives two minimal ways of identifying it unambiguously:
the conId (IBKR's unique identifier) with the destination (exchange), or the symbol, type, destination, primary
exchange (primaryExchange) and currency; derivatives also need the expiration, the multiplier or the trading
class6Note 6 · Interactive Brokers, TWS API, The Contract Object,
accessed on October 22, 2026.. qualifyContracts asks the server for the details and fills in the contract in place, conId included.
The real code:
from ib_async import IB, Contract, Future, Stock
ib = IB()
ib.connect("127.0.0.1", 4002, clientId=7)
pedidos = [Stock("ORSC", "SMART"), # which currency?
Stock("ORSC", "SMART", "USD"),
Future("QES", "202603", "CME")] # month YYYYMM
calificados = ib.qualifyContracts(*pedidos)
for pedido, c in zip(pedidos, calificados):
if c is None: # 2.1.0: None
print("not qualified:", pedido)
else:
print(c.conId, c.localSymbol, c.primaryExchange)In version 2.1.0 the result keeps one position per requested contract and puts None in those that cannot be
qualified, whether because they do not exist or because they are ambiguous (qualifyContractsAsync(..., returnAll=True) returns the list of candidates in that slot); before, the ambiguity was only noted in the log. The
simulator has a fictitious catalog with the ORSC ETF from the module 4.5 data, a namesake in Canadian dollars and two
expirations of a made-up future:
catalogo = [
Contract("STK", conId=700101, symbol="ORSC", exchange="SMART",
primaryExchange="ARCA", currency="USD",
localSymbol="ORSC"),
Contract("STK", conId=700102, symbol="ORSC", exchange="SMART",
primaryExchange="TSE", currency="CAD",
localSymbol="ORSC"),
Contract("FUT", conId=700201, symbol="QES",
lastTradeDateOrContractMonth="20260320", multiplier="50",
exchange="CME", currency="USD", localSymbol="QESH6"),
Contract("FUT", conId=700202, symbol="QES",
lastTradeDateOrContractMonth="20260618", multiplier="50",
exchange="CME", currency="USD", localSymbol="QESM6"),
]
T0 = datetime(2025, 10, 14, 13, 35, 0, 805771, tzinfo=timezone.utc)
ib = IBSimulado(catalogo, T0)
errores = []
ib.errorEvent += lambda rid, cod, txt, c: errores.append((cod, txt))
ib.connect("127.0.0.1", 4002, clientId=7)
pedidos = [Stock("ORSC", "SMART"), Stock("ORSC", "SMART", "USD"),
Contract("FUT", symbol="QES",
lastTradeDateOrContractMonth="202603"),
Stock("ORSQ", "SMART", "USD")]
for c in ib.qualifyContracts(*pedidos):
print(None if c is None else
(c.conId, c.localSymbol, c.primaryExchange or c.exchange,
c.currency, c.multiplier))
print(errores)None
(700101, 'ORSC', 'ARCA', 'USD', '')
(700201, 'QESH6', 'CME', 'USD', '50')
None
[(200, 'No security definition has been found for the request')]ORSC without a currency is ambiguous (two stocks with the same symbol) and comes back as None with no error; with
the currency it is qualified with its conId and primary exchange, keeping SMART as the destination (the IBKR router).
The future requested by month, 202603, resolves to the March contract, with its local symbol and multiplier, which
you have to use to compute the notional (lesson 3.1.1). The symbol that does not exist comes back as None and, in
addition, with an error 200 (“No security definition has been found”)7Note 7 · Interactive Brokers, TWS API, Error Codes
(200, 201, 202, 326, 354), accessed on October 22, 2026.. Two habits that avoid trouble: store the
conId of each instrument in the system's configuration (it is stable and unambiguous) and check for None before
sending anything.
Market data: knowing what you are looking at
reqMktData returns an empty Ticker that fills up as ticks arrive. Without a subscription to the data of the
corresponding market (lesson 1.3.6), IBKR does not send real-time data over the API; you can request another data
type with reqMarketDataType8Note 8 · Interactive Brokers, TWS API, Delayed Market Data
(data types 1 to 4), accessed on October 22, 2026.:
| Type | Name | What it is, according to IBKR |
|---|---|---|
| 1 | Live | Real-time data; requires a subscription |
| 2 | Frozen | The last quote recorded at the close; same subscription as type 1 |
| 3 | Delayed | Free data with a 15-20 minute delay |
| 4 | Delayed frozen | The delayed data from the close, for those without a subscription |
The Ticker stores the type in marketDataType. That field is the defense against an expensive mistake: a system that
loses its subscription (or that starts on a new machine with the default configuration) can keep receiving delayed
prices and trade on them without noticing.
ib.reqMarketDataType(3) # delayed if no type 1
ticker = ib.reqMktData(contrato, "", False, False)
ib.sleep(2) # it fills up gradually
print(ticker.marketDataType, ticker.bid, ticker.ask,
ticker.midpoint())
ib.cancelMktData(contrato)cot = {700201: [(200, 5401.25, 5401.50), (900, 5401.50, 5401.75)]}
ib = IBSimulado(catalogo, T0, suscritos={700101}, cotizaciones=cot)
errores = []
ib.errorEvent += lambda rid, cod, txt, c: errores.append((cod, txt))
ib.connect(clientId=7)
qes = ib.qualifyContracts(Contract(
"FUT", symbol="QES", lastTradeDateOrContractMonth="202603"))[0]
t1 = ib.reqMktData(qes) # type 1 without a subscription
ib.sleep(1)
print("type 1:", errores, t1.midpoint())
ib.reqMarketDataType(3)
t3 = ib.reqMktData(qes) # delayed
ib.sleep(1)
print("type 3:", t3.marketDataType, t3.bid, t3.ask, t3.midpoint())
def puedo_operar(tk):
"""Only with real-time data and both sides of the quote."""
return tk.marketDataType == 1 and tk.hasBidAsk()
print("trade on these prices?", puedo_operar(t3))type 1: [(354, 'Requested market data is not subscribed')] nan
type 3: 3 5401.5 5401.75 5401.625
trade on these prices? FalseWith type 1 and no subscription, error 354 (“Requested market data is not subscribed”) arrives and the ticker stays
empty (nan); with type 3, bid and ask arrive, but delayed. The puedo_operar rule rejects them. In a real system the
rule goes in the risk manager (lesson 4.5.1) and comes with another: a maximum age for the last tick.
Orders, statuses and events
placeOrder(contrato, orden) returns a Trade in PendingSubmit status and the rest arrives through messages. The
statuses IBKR documents9Note 9 · Interactive Brokers, TWS API, Understanding Order Status Message,
accessed on October 22, 2026.:
Mermaid source
stateDiagram-v2 [*] --> PendingSubmit PendingSubmit --> PreSubmitted PendingSubmit --> Submitted PreSubmitted --> Submitted Submitted --> Filled Submitted --> PendingCancel PendingCancel --> Cancelled Submitted --> Cancelled PendingSubmit --> Inactive
PreSubmitted is an order that has been accepted but not yet activated (an order simulated by IBKR, or the child of a
bracket until the parent executes); Submitted, an order active at its destination; PendingCancel, a cancellation
requested and not confirmed, during which an execution can still arrive. A cancellation can also arrive without the
program asking for it, when the destination rejects the order: codes 201 (“Order rejected”) and 202 (“Order
cancelled”) explain the reason7. ib_async considers the Filled, Cancelled, ApiCancelled and Inactive
statuses final (trade.isDone()).
Each execution arrives as execDetails with an Execution object (execId, time, side BOT or SLD, shares,
price, cumQty, avgPrice, orderRef…), and its commission afterwards, in a commissionReport with the same
execId10Note 10 · Interactive Brokers, TWS API, The Execution Object,
ExecID Behavior
and Commission And Fees Report,
accessed on October 22, 2026.. The current TWS API calls that object CommissionAndFeesReport; ib_async 2.1.0 keeps the name
CommissionReport and the commissionReportEvent event. The real code of a system that subscribes to the events:
from ib_async import IB, MarketOrder, Stock
ib = IB()
ib.connect("127.0.0.1", 4002, clientId=7)
orsc = ib.qualifyContracts(Stock("ORSC", "SMART", "USD"))[0]
def al_estado(trade):
s = trade.orderStatus
print(trade.order.orderRef, s.status, s.filled, s.remaining)
def al_ejecutar(trade, fill):
e = fill.execution
print(e.execId, e.side, e.shares, e.price)
def a_la_comision(trade, fill, informe):
print(informe.execId, informe.commission, informe.currency)
ib.orderStatusEvent += al_estado
ib.execDetailsEvent += al_ejecutar
ib.commissionReportEvent += a_la_comision
trade = ib.placeOrder(orsc, MarketOrder("BUY", 434, orderRef="R00005"))
trade.filledEvent += lambda t: print("filled",
t.orderStatus.avgFillPrice)
ib.run() # let the loop runThe simulator reproduces order R00005 from the live account of module 4.5: a market buy of 434 ORSC with three
executions, with the messages from brokers4_ejemplos.json. Before using that file you have to check its fields
against the documentation: the names in orderStatus, execution and commissionReport match the ib_async 2.1.0
classes, but its execIds have three segments (0000e0d5.710000035.01) where IBKR documents four, with the last one
reserved for corrections (….01 and its correction ….02)10. Read literally, the second execId in the file would
be a correction of the first. The script adds the fourth segment. The execution times are those in the file; the
submission (30 ms) is the difference between created_at and submitted_at of the same order in the Alpaca message,
and the 2 ms between the execution and its commission are an assumption of the simulator.
import json
ej = json.load(open("brokers4_ejemplos.json"))["interactive_brokers"]
def guion_r00005(ib, tr, ej, t_envio, retraso_cr_ms=2):
"""Schedules the messages of order R00005 in the simulator."""
def ms(ts):
return (datetime.fromisoformat(ts) - t_envio) / \
timedelta(milliseconds=1)
st = ej["orderStatus"] # st[0], Submitted: placeOrder
for f, s in zip(ej["fills"], st[1:]):
e = f["execution"]
ex = Execution(**{k: e[k] for k in
Execution.__dataclass_fields__ if k in e})
ex.time = datetime.fromisoformat(e["time"])
ex.execId = e["execId"] + ".01" # 4 segments (IBKR)
ex.orderId = tr.order.orderId
cr = CommissionReport(ex.execId,
f["commissionReport"]["commission"],
"USD")
dt = ms(e["time"])
ib.programa(dt, ib._exec, tr, ex, cr)
ib.programa(dt, ib._status, tr, {k: s[k] for k in (
"status", "filled", "remaining", "avgFillPrice",
"lastFillPrice")})
ib.programa(dt + retraso_cr_ms, ib._commission, tr, cr)
ib = IBSimulado(catalogo, T0)
ib.connect("127.0.0.1", 4002, clientId=7)
orsc = ib.qualifyContracts(Stock("ORSC", "SMART", "USD"))[0]
registro = []
ib.orderStatusEvent += lambda tr: registro.append(
(ib.ms(), "orderStatus", tr.orderStatus.status,
tr.orderStatus.filled))
ib.execDetailsEvent += lambda tr, f: registro.append(
(ib.ms(), "execDetails", f.execution.execId[-9:],
f.execution.shares))
ib.commissionReportEvent += lambda tr, f, r: registro.append(
(ib.ms(), "commissionReport", r.execId[-9:], r.commission))
tr = ib.placeOrder(orsc, MarketOrder("BUY", 434, orderRef="R00005"))
guion_r00005(ib, tr, ej, T0)
ib.sleep(5)
for ms, evento, a, b in registro:
print(f"{ms:8.1f} ms {evento:<16s} {a:<10} {b}")
print("done:", tr.isDone(), "average price:",
tr.orderStatus.avgFillPrice, "commission:",
round(sum(f.commissionReport.commission for f in tr.fills), 2)) 30.0 ms orderStatus Submitted 0.0
219.2 ms execDetails 035.01.01 27.0
219.2 ms orderStatus Submitted 27.0
221.2 ms commissionReport 035.01.01 0.35
1716.3 ms execDetails 035.02.01 389.0
1716.3 ms orderStatus Submitted 416.0
1718.3 ms commissionReport 035.02.01 1.36
3635.6 ms execDetails 035.03.01 18.0
3635.6 ms orderStatus Filled 434.0
3637.6 ms commissionReport 035.03.01 0.35
done: True average price: 234.8353 commission: 2.06The figure at the top draws that sequence. The order is accepted at 30 ms; the first execution (27 shares) arrives at
219 ms; the second, the large one (389), at 1.7 s; and the last one (18) at 3.6 s, with the Filled status. The
average price, 234.8353, and the total commission, 2.06 dollars, are those in the file. Three details a system has to
respect:
- The order of the messages. The execution arrives before the status that reflects it, and the commission after
it. A handler that computes the cost of the order in
orderStatusEventdoes not have the commission yet. - The status is not enough.
Submittedwithfilled = 416is a partially executed order: the position has already changed. Positions are tracked with executions (execDetails), not with statuses. - Your own identifier.
orderReftravels with the order and comes back in eachExecution: it is what lets you recognize your own orders after a reconnection. The API reference describes it as “intended for institutional customers only”, but the reference for theExecutionobject presents it as a string configurable from the API that accompanies the order throughout its life10; it is worth checking on the paper account that it is preserved.
Bracket: an order with its stop or none at all
An entry order with its take-profit and its stop (lesson 1.1.4) is sent at IBKR as a bracket: the parent and two
children with parentId. IB.bracketOrder in ib_async creates them with transmit=False on the parent and the
take-profit and transmit=True on the stop. The API reference defines the field: if it is false, the order is created
in TWS but not sent11Note 11 · Interactive Brokers, TWS API, reference for the Order
object (transmit, parentId, ocaGroup, orderRef), accessed on October 22, 2026.. That way the group goes out whole when the last one arrives, and if the program crashes
halfway, nothing goes out.
bracket = ib.bracketOrder("BUY", 100, limitPrice=234.80,
takeProfitPrice=240.00,
stopLossPrice=230.00)
for o in bracket: # parent, take-profit, stop
o.orderRef = "R00999"
ib.placeOrder(orsc, o)The simulator holds back orders with transmit=False and sends the group with the one that arrives with
transmit=True. The cell compares the correct bracket with the mistake of sending the parent with transmit=True and
crashing before the children:
def bracket(ib, accion, q, lmt, beneficio, stop):
"""Like IB.bracketOrder in ib_async (consecutive ids)."""
contraria = "SELL" if accion == "BUY" else "BUY"
i = ib.next_id
ib.next_id += 3
return (LimitOrder(accion, q, lmt, orderId=i, transmit=False),
LimitOrder(contraria, q, beneficio, orderId=i + 1,
transmit=False, parentId=i),
StopOrder(contraria, q, stop, orderId=i + 2,
transmit=True, parentId=i))
def estados(ib):
return {t.order.orderId: t.orderStatus.status
for t in ib.trades.values()}
ib = IBSimulado(catalogo, T0)
ib.connect(clientId=7)
padre, benef, stop = bracket(ib, "BUY", 100, 234.80, 240.0, 230.0)
ib.placeOrder(orsc, padre)
ib.placeOrder(orsc, benef)
ib.sleep(1)
print("before the stop:", estados(ib))
ib.placeOrder(orsc, stop)
ib.sleep(1)
print("with the stop: ", estados(ib))
ib2 = IBSimulado(catalogo, T0)
ib2.connect(clientId=8)
padre, benef, stop = bracket(ib2, "BUY", 100, 234.80, 240.0, 230.0)
padre.transmit = True # the mistake
ib2.placeOrder(orsc, padre) # ...and the program crashes
ib2.sleep(1)
print("parent alone: ", estados(ib2))before the stop: {1: 'PendingSubmit', 2: 'PendingSubmit'}
with the stop: {1: 'Submitted', 2: 'PreSubmitted', 3: 'PreSubmitted'}
parent alone: {1: 'Submitted'}With the correct bracket, the parent and the take-profit wait in PendingSubmit (created, not sent) until the stop
arrives; then the group goes out: the parent active (Submitted) and the children in PreSubmitted, waiting for the
parent to execute. In the second case the parent goes out alone and stays active: if it executes, the position has no
stop. The same reasoning applies to any group of orders that must go out together (OCA groups, one cancels all, which
ib_async builds with IB.oneCancelsAll).
Reconnection and reconciliation
A disconnection in the middle of an order is the norm, not the exception: daily restart, maintenance, network. While
the program is not listening, the broker keeps executing. On reconnecting you have to request the executions
(reqExecutions, which also accepts an ExecutionFilter) and the open orders (reqAllOpenOrders) and merge them
with what the program already had, with two rules from the IBKR documentation: each partial execution has its own
execId, and a correction arrives as another execution with the same data except the execId, which differs only in
the digits after the last period10. ib_async, in addition, clears all its internal state on disconnecting and
requests it again on connecting (positions, open and completed orders, executions), so it is the system's own book that
has to be reconciled.
from ib_async import ExecutionFilter
def al_error(req_id, codigo, texto, contrato):
if codigo == 1100:
print("no connection to the servers")
elif codigo == 1101: # data lost
resuscribir_datos()
elif codigo == 1102: # data maintained
reconciliar()
ib.errorEvent += al_error
ib.disconnectedEvent += lambda: programar_reconexion()
def reconciliar():
fills = ib.reqExecutions(ExecutionFilter(clientId=7))
abiertas = ib.reqAllOpenOrders()
fusionar(fills, abiertas) # by execId and orderRefIn the simulator, the API disconnects one second after sending R00005 (a Gateway restart), the second execution arrives while nobody is listening and, once reconnected, the broker publishes a correction of the price of the third:
ib = IBSimulado(catalogo, T0)
ib.connect(clientId=7)
libro = {} # execId -> Execution
ib.execDetailsEvent += lambda tr, f: libro.setdefault(
f.execution.execId, f.execution)
tr = ib.placeOrder(orsc, MarketOrder("BUY", 434, orderRef="R00005"))
guion_r00005(ib, tr, ej, T0)
ib.programa(1000, ib._cae) # Gateway goes down
ib.programa(2500, ib.connect, "127.0.0.1", 4002, 7)
ib.sleep(4)
# 50 s later, the broker corrects the price of the third one
tercera = ib.broker_fills[-1].execution
corr = Execution(**{**vars(tercera), "price": 234.83,
"execId": tercera.execId[:-2] + "02"})
ib.programa(50_000, ib._exec, tr, corr, CommissionReport(
corr.execId, 0.0, "USD"))
ib.sleep(60)
def posicion(ejecuciones):
"""One execution per execId base: the latest correction wins."""
vigentes = {}
for e in ejecuciones:
base, corr = e.execId.rsplit(".", 1)
if base not in vigentes or corr > vigentes[base].execId[-2:]:
vigentes[base] = e
q = sum(e.shares for e in vigentes.values())
px = sum(e.shares * e.price for e in vigentes.values()) / q
return q, round(px, 4), len(vigentes)
print("only what was heard:", posicion(libro.values()))
recibidas = [f.execution for f in ib.reqExecutions()]
print("reqExecutions: ", len(recibidas), "messages")
sumadas = sum(e.shares for e in recibidas)
print("adding everything: ", sumadas, "shares")
print("merged and with the correction:", posicion(recibidas))only what was heard: (45.0, 234.716, 2)
reqExecutions: 4 messages
adding everything: 452.0 shares
merged and with the correction: (434.0, 234.8361, 3)What the program heard gives it 45 shares (the first and third executions): the large one was lost during the
disconnection. reqExecutions returns four messages, the three executions and the correction; simply adding them up
gives 452 shares, 18 too many. Merged by execId, with the correction replacing the third, the position is the true
one: 434 shares at an average price of 234.8361 (the correction raised the price of 18 shares by two cents). This is
the same problem that lessons 4.5.2 and 4.5.5 solve in general with the logs of the live system (duplicated, lost and
corrected executions); here with IBKR's specific rules.
Pacing limits
Lesson 1.3.6 explained IBKR's limits for historical data. For all requests, the documentation sets a maximum number of
requests per second equal to the market data lines divided by two: with the default 100 lines, 50 per second; above
that, some orders may be queued and delayed12Note 12 · Interactive Brokers, TWS API, Pacing Limitations;
ib_async 2.1.0, Client class (source code), accessed on October 22, 2026.. ib_async throttles on its own: its client limits itself to 45
requests per second (MaxRequests = 45, RequestsInterval = 1), below the limit. What that means for a burst of
orders:
def salidas(n, maximo=45, ventana=1.0):
"""Send time of n requests launched at once at t=0."""
t = []
for i in range(n):
ahora = t[-1] if t else 0.0
if len(t) >= maximo:
ahora = max(ahora, t[-maximo] + ventana)
t.append(ahora)
return t
s = salidas(120)
por_segundo = {seg: sum(int(x) == seg for x in s) for seg in range(3)}
print("sent per second:", por_segundo)
print(f"the last one goes out at {s[-1]:.1f} s")sent per second: {0: 45, 1: 45, 2: 30}
the last one goes out at 2.0 sThe 120 requests take two seconds to go out: 45, 45 and 30. For the module 4.5 system, which rebalances ten ETFs once a day, it is irrelevant. For one that reacts to many instruments at once (or that cancels and resends orders in a loop), two seconds of queue change the prices at which it executes, and the sending order matters: risk-reducing orders first.
Typical mistakes
- Using
time.sleep. It freezes theib_asyncloop: no statuses or executions arrive while it sleeps. - Not checking for
NoneafterqualifyContracts. In version 2.1.0 an ambiguous contract does not raise an exception. - Trading on delayed data. You have to check
ticker.marketDataTypeand the age of the last tick. - Tracking the position with statuses. The position comes from the executions, merged by
execId. - Sending the parent of a bracket with
transmit=True. If the program crashes, a position is left without a stop. - Using the same
clientIdin two programs. IBKR rejects the second connection (error 326); in addition, each connection only sees its own orders, except the one with the master client ID configured in TWS13Note 13 · Interactive Brokers, TWS API, ClientId 0 and the Master Client ID, accessed on October 22, 2026.. - Forgetting the daily restart. A system that does not reconnect by itself stops trading once a day.
Exercises
- Modify the R00005 script so that the broker rejects the order (
Cancelledstatus with error 201 at 20 ms, no executions) and check whattr.isDone()returns and what a handler connected toerrorEventreceives.
Solution
ib = IBSimulado(catalogo, T0)
ib.connect(clientId=7)
avisos = []
ib.errorEvent += lambda rid, cod, txt, c: avisos.append((rid, cod))
tr = ib.placeOrder(orsc, MarketOrder("BUY", 434, orderRef="R00005"))
ib.programa(20, ib.errorEvent.emit, tr.order.orderId, 201,
"Order rejected - Reason: (simulated)", orsc)
ib.programa(20, ib._status, tr, {"status": "Cancelled"})
ib.sleep(1)
print(tr.isDone(), tr.orderStatus.status, tr.log, avisos)True Cancelled [(0.0, 'PendingSubmit'), (20.0, 'Cancelled')] [(1, 201)]The order finishes (isDone() is true) in Cancelled, with no executions, and the error arrives with the orderId as
the request identifier: that is how the reason is linked to the order. A system must not blindly retry a rejection; the
error text (size, price out of range, margin) says whether it makes sense.
- With the
posicionfunction, what average price and how many executions would you accept if the correction of the third execution arrived before the original execution (out of order)?
Solution
desordenadas = [recibidas[0], recibidas[1], recibidas[3],
recibidas[2]]
print(posicion(desordenadas))(434.0, 234.8361, 3)The result does not change: for each execId base, the function keeps the highest correction suffix, not the last one
to arrive. A version that kept the last message received would give the uncorrected price. The identifiers, not the
order of arrival, decide.
- A system connected to IB Gateway on a paper account receives message 1101 at 6:00 in the morning and, at 9:30, no new quotes. What has happened and what should it have done?
Solution
1101 means that the connection between Gateway and the servers was restored but the data subscriptions were lost and
have to be requested again. If the program did not request them, its Tickers kept the last values from before the
outage and are not updating. It should have called reqMktData again for each contract on receiving 1101 (with 1102
it is not necessary) and monitored the age of the last tick: a price that does not change for hours while the market is
open is an alarm, not a quiet market.
Footnotes
-
Interactive Brokers, TWS API, The IB Gateway, accessed on October 22, 2026. ↩ ↩2
-
Interactive Brokers, TWS API, Daily & Weekly Reauthentication, accessed on October 22, 2026. ↩
-
Interactive Brokers, TWS API, System Message Codes (1100, 1101, 1102 and 1300), accessed on October 22, 2026. ↩ ↩2
-
IBC, repository README (“This project has been retired, as of 1 September 2026”);
ib_async2.1.0,Watchdogclass (source code), accessed on October 22, 2026. ↩ -
ib_async on PyPI (version 2.1.0, published on December 8, 2025; dependencies
aeventkit>=2.1.0,<3.0.0,nest_asyncioandtzdata>=2025.2,<2026.0) and API documentation, accessed on October 22, 2026. ↩ -
Interactive Brokers, TWS API, The Contract Object, accessed on October 22, 2026. ↩
-
Interactive Brokers, TWS API, Error Codes (200, 201, 202, 326, 354), accessed on October 22, 2026. ↩ ↩2
-
Interactive Brokers, TWS API, Delayed Market Data (data types 1 to 4), accessed on October 22, 2026. ↩
-
Interactive Brokers, TWS API, Understanding Order Status Message, accessed on October 22, 2026. ↩
-
Interactive Brokers, TWS API, The Execution Object, ExecID Behavior and Commission And Fees Report, accessed on October 22, 2026. ↩ ↩2 ↩3 ↩4
-
Interactive Brokers, TWS API, reference for the Order object (
transmit,parentId,ocaGroup,orderRef), accessed on October 22, 2026. ↩ -
Interactive Brokers, TWS API, Pacing Limitations;
ib_async2.1.0,Clientclass (source code), accessed on October 22, 2026. ↩ -
Interactive Brokers, TWS API, ClientId 0 and the Master Client ID, accessed on October 22, 2026. ↩
Each answer is folded under its question
Self-assessment
According to the TWS API documentation, what is the difference between TWS and IB Gateway for a program that uses the API?
- AIB Gateway has a more complete API
- BFor the API they are identical: both are the server the socket is opened to after authenticating; Gateway lacks the graphical tools and uses about 40% fewer resources
- CTWS does not accept API connections
- DIB Gateway needs no username or password
Show answer
Answer B. For the API they are identical: both are the server the socket is opened to after authenticating; Gateway lacks the graphical tools and uses about 40% fewer resources
The documentation says that, from the API's point of view, IB Gateway and TWS are identical, that in both you have to enter a username and password in a login window and that Gateway is lighter (around 40% fewer resources).
After
ib.qualifyContracts(Stock('ORSC', 'SMART')), the returned list containsNone. What has happened in ib_async 2.1.0?- AThe connection failed
- BThe contract is qualified and None is its conId
- CThe contract is unknown or ambiguous (for example, the same symbol in two currencies); you need to add fields (currency, primaryExchange) or use the conId
- DYou need to wait longer
Show answer
Answer C. The contract is unknown or ambiguous (for example, the same symbol in two currencies); you need to add fields (currency, primaryExchange) or use the conId
In version 2.1.0, the result keeps the position of each requested contract and puts None in those that cannot be qualified. Before, it was only logged as a warning. A system that does not check for that None sends orders with an incomplete contract.
A system with no data subscription for a future calls
ib.reqMarketDataType(3)and receives aTickerwith bid and ask. What must it check before trading on those prices?- A
ticker.marketDataType: 3 means delayed data (15-20 minutes according to the documentation), which is no good for deciding an order - BNothing: if there are bid and ask, they are good
- CThat the bid is lower than the ask
- DThe number of market data lines
Show answer
Answer A.
ticker.marketDataType: 3 means delayed data (15-20 minutes according to the documentation), which is no good for deciding an orderWith type 3, IBKR sends delayed data with its own tick types. The ib_async Ticker stores the type in marketDataType. A simple safety rule is not to generate orders if the type is not 1 (real-time data).
- A
In an ib_async bracket order, the parent order and the take-profit go with
transmit=Falseand the stop withtransmit=True. Why?- AFor compatibility with old versions
- BSo that the stop order executes first
- CBecause the stop does not accept transmit=False
- DBecause an order with transmit=False is created in TWS without being sent; when the last one arrives with transmit=True the whole group is sent, so the parent is never left active without its children
Show answer
Answer D. Because an order with transmit=False is created in TWS without being sent; when the last one arrives with transmit=True the whole group is sent, so the parent is never left active without its children
The API reference defines transmit: if it is False, the order is created in TWS but not sent. The ib_async bracket uses that so the group goes out together. If the program crashes between the parent and the children, nothing goes out; if the parent went with transmit=True, it could execute without a stop.
After a reconnection,
reqExecutions()returns one execution with execId…035.03.02and another with…035.03.01, identical except for the price. What does the IBKR documentation say?- AThey are two different executions: add both
- BThe .02 is a correction of the .01: the execId only differs in the digits after the last period, and it replaces the earlier one
- CIt is a broker error to be ignored
- DThe .01 is the right one because it arrived first
Show answer
Answer B. The .02 is a correction of the .01: the execId only differs in the digits after the last period, and it replaces the earlier one
The ExecID documentation says that a correction arrives as another execDetails with the same parameters except the execId, which differs only in the digits after the last period. Counting them as two doubles the position; keeping the first leaves a price the broker has already corrected.
Key points
- TWS and IB Gateway are, for the API, the same local server; Gateway is lighter. Both restart every day (with automatic restart since version 974) and ask for credentials again every week; a system has to plan for those disconnections.
- ib_async 2.1.0 (latest version on PyPI, December 8, 2025) wraps the TWS API in asyncio: synchronous calls, Trade and Ticker objects that update themselves and events. You wait with ib.sleep or ib.run, never with time.sleep.
- Qualifying contracts fills in the conId; in 2.1.0 an ambiguous or unknown contract comes back as None in its position, and you have to check for it before using it.
- The market data type (1 live, 2 frozen, 3 delayed, 4 delayed frozen) travels in the Ticker: a system must not trade on data that is not type 1.
- Order statuses (PendingSubmit, PreSubmitted, Submitted, Filled, Cancelled, Inactive) arrive through orderStatus; executions through execDetails and their commission through commissionReport, afterwards. The execId identifies each execution and its corrections.
- After a disconnection, state is rebuilt by requesting executions and orders from the broker and merging them by execId with what you already had: without duplicates and applying corrections.