ib_async-dx
ib_async-dx
ib_async without the gateway. The program you already wrote against ib_async, with no IB Gateway, no Trader Workstation and no JVM between it and the venue.
Two lines change
A program written against ib_async talks to IB Gateway or Trader Workstation over a socket on localhost, and that process talks to the venue. ib_async-dx puts the ib-dx engine where the gateway was: it logs in, holds the trading, market-data, historical and security-definition connections open, and answers every call ib_async makes.
- from ib_async import IB, Stock
+ from ib_async_dx import IB, Stock
ib = IB()
- ib.connect("127.0.0.1", 4001, clientId=1) # a gateway on localhost
+ ib.connect(username="...", password="...") # a paper session; paper=False for live
That is the whole change. ib_async_dx is ib_async: the same names, the same
objects and the same submodules, run by ib_async’s own code, installed as a
dependency. Two classes differ: IB, a subclass of ib_async’s whose connect
takes a login instead of a gateway’s address, and IBC, which has no gateway
to start. The rest of the program — its events, its *Async calls, its
Ticker and Trade, its util — runs unmodified, with the engine in place of
the one layer that knew there was a socket.
Tip
Leave the credentials out of
connectand it readsIB_USERNAMEandIB_PASSWORDfrom the environment. Where those are set, the connect line can stay exactly as it was for a paper account: the host and port are not used, and the client id is carried into the login. The port does not choose paper or live; addpaper=Falsefor a live account.
What goes away
The gateway
Nothing to install, launch, log into on a schedule, or restart. No window either: it runs in a container, over ssh, on a machine with no display.
The JVM
No heap to size, and no JVM garbage collector pausing the process your ticks pass through.
The localhost socket
The engine runs inside your Python process. Nothing listens on a port, and ticks are delivered in-process.
IBC and the watchdog
No login window to script and no gateway to restart when it wedges. The engine logs in itself, and rebuilds a dropped connection on the session it already holds. ib_async's own Watchdog still runs, as a reconnect loop, with the login its IBC names.
What you get
ib_async, exactly
ib_async_dx exports ib_async's 103 names, and every one but IB and IBC is ib_async's own object, its __version__ among them. A test holds the two packages to that on every run.
Every call it makes, carried
ib_async's IB makes 67 calls on its transport at 2.1.0, and every one lands on the engine. A test reads that list out of ib_async's own source on every run.
Nothing in between
The engine is compiled Rust, running in your process through PyO3. There is no second process that can be wedged while your program looks healthy.
More than a gateway forwards
What the venue states that no documented call asks for — the order types the account may use, its algorithms, the features it has enabled, the whole option model — is a method on IB. Beyond ib_async lists them, with the four ib_async bugs fixed here.
Honest about its limits
A field the engine cannot carry is refused, as a gateway refuses a message it cannot read, instead of going out as something other than what was asked. The few things taken and not applied, and everything else that differs from ib_async over a gateway, are written down with the reason for each.
Where to go next
Status
Under active development. The Python package is here with its suite: 318
tests, 315 of which run offline on every push to main and every pull
request, on Python 3.11, 3.13 and free-threaded 3.14t, 2 that need a live
login, and 1 a Python version condition excludes. ib_async’s own
suite is written to run against the engine too, and has not been run against
the venue at this revision; see Evidence.
A Rust client — ib_async’s model in Rust spelling, on the engine’s public API — is coming. It is not in this repository yet.
Neither ib_async-dx nor the engine is published to PyPI or crates.io yet; both install from their repositories: see Getting started.
Note
An independent project. It is not affiliated with ib_async or its maintainers, nor with Interactive Brokers. ib_async-dx runs ib_async’s own code as a dependency and does not copy it. ib_async is BSD-2-Clause, and ib_async-dx is AGPL-3.0.
Getting started
What you need
- An Interactive Brokers account, paper or live. No IB software: no IB
Gateway, no Trader Workstation, and not the
ibapipackage either. - Python 3.11 or newer; for a notebook, 3.11 to 3.13 (Notebooks).
- A Rust toolchain, 1.89 or newer. The engine installs from its repository, and installing it compiles it.
ib_async 2.1, ib_async’s current release, is a dependency of this package, and pip installs it with it. Nothing is on PyPI yet: ib_async-dx and the engine both install from their repositories.
Install
pip install "git+https://github.com/userFRM/ib-dx@c18523506144df7b9f81fdbb82b68405f24f13ea"
pip install "ib_async-dx @ git+https://github.com/userFRM/ib_async-dx"
The engine goes first, at the commit this package is tested against: it has no release yet. ib_async-dx names it as a dependency, and with nothing on PyPI, pip has to find it already installed.
Credentials
The credentials go to the session, through connect. There is no
configuration file, and no process holding a login on your behalf.
from ib_async_dx import IB
ib = IB()
ib.connect(username="your_user", password="your_pass")
Left out, they are read from IB_USERNAME and IB_PASSWORD, and the scripts
under scripts/ read the same two:
export IB_USERNAME="your_username"
export IB_PASSWORD="your_password"
There is no host to name. connect still takes a host, a port and a client
id, because ib_async’s does. The host and port are not used: the engine finds
the server the account lives on by itself. The client id is carried into the
login, where it keys this session’s orders.
paper is True unless the program says otherwise; the port does not
choose. A live session is asked for with paper=False, and that enters the
venue’s second-factor approval, which waits on a device: connect returns once
it has been answered. A paper session presents no second factor. Use a paper
account while you are writing something; a live account is a live account.
timeout bounds each request ib_async makes as the session opens, as it
does against a gateway. It does not bound the login, which a gateway also
makes before a program connects, nor the engine’s wait of up to three seconds,
inside the login, for the venue to name the working orders: a paper login
presents no second factor, and a live one waits on it for as long as the
engine allows. The login runs off ib_async’s event loop, so the loop keeps
turning while it waits, and a connect cancelled, interrupted, or overtaken by
disconnect(), ends at once; the engine drops the session that login opens.
The login itself runs on until the engine returns, on a thread that does not
hold the program open. A login that fails raises ConnectionError.
Important
One program per login. Each program is its own session on the login, and a second program on the same login, or a gateway, takes the session from the first; the venue says which host took it. Programs that share one gateway login under their own client ids each need a login of their own here.
Caution
Credentials are the account. Never commit them, never paste them into an issue, and never put them in a file the repository tracks.
A first program
An ib_async program, as ib_async writes one, with the import and the connect line changed:
from ib_async_dx import IB, Stock
ib = IB()
ib.connect() # credentials from the environment, paper
spy = Stock("SPY", "SMART", "USD")
ib.qualifyContracts(spy)
bars = ib.reqHistoricalData(spy, "", "2 D", "1 hour", "TRADES", useRTH=True)
print(bars[-1])
ib.pendingTickersEvent += lambda tickers: print(len(tickers), "updates")
ib.reqMktData(spy)
ib.sleep(5)
ib.disconnect()
ib.sleep(), never time.sleep(). ib_async runs its event loop on the calling
thread, so a plain sleep stops it: quotes stop arriving, and every stream reads
as dead when it is only unattended.
What connect takes
ib_async’s parameters, with its defaults, and four keyword-only ones after
them, username, password, paper and sessionFile. Every argument given
at its default:
from ib_async_dx import IB, StartupFetchALL
ib = IB()
ib.connect(
host="127.0.0.1", port=7497, clientId=1, # host and port: accepted, not used
timeout=4, readonly=False, account="",
raiseSyncErrors=False, fetchFields=StartupFetchALL,
username="", password="", # left empty: see below
paper=True, # False asks for a live session
sessionFile=None, # where the session is kept between runs
)
A username and password left empty are an IBC’s login where one was
started in the same context (see Limits), and otherwise
IB_USERNAME and IB_PASSWORD.
connectAsync takes the same. readonly=True makes a read-only session,
which refuses to send anything that places, changes or withdraws an order, as a
gateway set to read-only does.
The session is kept between runs. With sessionFile=None it is kept in
~/.ib_dx/session-<user>-<paper|live>: readable by its owner only, sealed
with the password, and refused if it names another account. A start that names
a session the venue still holds is answered with a challenge rather than a full
login. The venue lets a session go soon after the process holding it ends, so
this covers a quick restart; a program started again later logs in afresh, and
on a live account that login waits on the second factor. A path moves the file,
and sessionFile=False keeps nothing.
An ib_async.IB you already hold
Code that builds ib_async.IB() itself can keep doing so, and hand the
instance to attach:
import ib_async
import ib_async_dx
ib = ib_async_dx.attach(ib_async.IB(), username="your_user", password="your_pass")
ib.connect() # names no host: there is no gateway
attach puts the engine under that one instance and hands it back; nothing of
ib_async’s classes or modules is patched. It takes the same login as connect,
spelled username, password, paper, session_file and readonly; the
client id is the one connect names. A session the instance holds is ended
first, as its disconnect() ends one. It is what ib_async_dx.IB does on every
connect.
Next steps
- What drop-in means — the promise, and how it is proven
- Running ib_async itself — what the engine changes, what it carries, and how ib_async’s own tests run here
- Beyond ib_async — the four bugs fixed, and the calls ib_async has no name for
- Notebooks — all eight of ib_async’s notebook subjects, with no gateway
- Limits — read this before you depend on a call
What drop-in means
The promise is the one an ib_async user would ask for: an existing program
changes its import and its connect line, and nothing else. The connect line
gains credentials, or, for a paper account, stays as it was where
IB_USERNAME and IB_PASSWORD are set, because nothing local is addressed:
then only the import changes, as a gateway keeps its login in its own
settings. A live account adds paper=False.
- from ib_async import IB, Stock
+ from ib_async_dx import IB, Stock
ib = IB()
- ib.connect("127.0.0.1", 4001, clientId=1) # a gateway on localhost
+ ib.connect(username="...", password="...") # a paper session; paper=False for live
What the import gives
ib_async_dx is ib_async. It exports ib_async’s __all__, all 103 names, and
every one of them is ib_async’s own object — the same class, the same function,
the same enum, the same __version__ — except two:
IB, a subclass ofib_async.IB. ItsconnectandconnectAsyncput the engine in place of ib_async’sClientand then run ib_async’s own connect. Everything else on it is ib_async’s, apart from the four bug fixes and the added calls.IBC, a subclass ofib_async.IBCwith no gateway to launch: starting it holds the login it names for the connects made in the same context, and terminating it ends every session that login opened. ib_async’s ownWatchdog, handed one, connects itsIBwith that login and connects it again when the session ends (Limits has the detail).
Client is ib_async’s own socket client, re-exported unchanged like every
other name: a program that builds one itself still addresses a gateway. IB
is what runs on the engine.
__version__ and __version_info__ are ib_async’s, so a program that checks
the API level it runs on reads what it always did. This package’s own version
is __ib_async_dx_version__.
ib_async’s submodules resolve here as themselves, so both import forms work:
import ib_async_dx.order and from ib_async_dx.util import df.
ib_async_dx.ib is ib_async’s ib module with IB replaced, and
ib_async_dx.ibcontroller its ibcontroller module with IB and IBC
replaced. Because the objects are ib_async’s own, isinstance checks, pickles
and a module that still imports Stock from ib_async all agree with the rest
of the program.
The connect line
connect is ib_async’s signature, with its parameters and defaults, and four
keyword-only parameters after them: username, password, paper and
sessionFile. Getting started
writes it out.
hostandportare accepted and not used. The port does not choose paper or live: the session is paper unlesspaper=False.clientIdis carried into the login, where it keys this session’s orders. One program per login: a second program on the same login takes the session from the first.usernameandpassword, left empty, are anIBC’s login where one was started in the same context, and otherwiseIB_USERNAMEandIB_PASSWORD.readonly=Truemakes a read-only session, which refuses to send anything that places, changes or withdraws an order, as a gateway set to read-only does.timeoutbounds each request ib_async makes as the session opens, as it does against a gateway. It does not bound the login, which a gateway also makes before a program connects, nor the engine’s wait of up to three seconds, inside the login, for the venue to name the working orders: a live login waits on its second factor for as long as the engine allows.
The rest is ib_async’s own connect: as it does against a gateway, it asks for
the account’s values, its positions, its open and completed orders and its
executions, as fetchFields says.
How it is proven
The package against ib_async, on every run.
test_the_package_is_ib_async.py checks that ib_async_dx.__all__ equals
ib_async’s and that every name in it is ib_async’s own object but IB and
IBC, __version__ among them; that all 13 of ib_async’s submodules resolve
under both import forms; that IB and IBC are subclasses of ib_async’s; and
that connect and connectAsync are ib_async’s parameters, defaults and kinds
with the four keyword-only ones after them. That is the parity check: a name ib_async adds
to __all__, or a change to its connect, fails here before it reaches a
program.
Against ib_async’s own source, on every run.
test_every_call_their_library_makes_is_carried reads every call ib_async’s
IB makes on its client out of ib_async’s installed source — 67 at 2.1.0 — and
fails if any one of them does not land on the engine. It is not a list kept by
hand, so a call ib_async adds is a failing test here rather than a program that
stops. And test_a_raw_message_is_the_request_it_names.py runs each of the 80
requests ib_async’s client writes as a message, through its own send, and
checks that each reads back into the message it was and reaches the engine
exactly as the same request made by name.
Against ib_async’s own tests. tests/ib_async_upstream/conftest.py runs
ib_async’s test suite against the engine instead of against a gateway: their
shared ib fixture is an ib_async_dx.IB, connected with the login in the
environment, and so is the ib_async.IB() a test builds for itself. The suite
is theirs, fetched from their repository rather than copied into this one. It
has not been run against the venue at this revision; what each of its three
tests asks, and how to run them, is in
Running ib_async itself.
Against the venue. Two tests take a live login, each with ib_async’s own
IB attached. One runs an unmodified ib_async program — connect, qualify,
bars, quotes through pendingTickersEvent. The other takes an order through
its whole life in ib_async’s objects: priced as a what-if, placed, changed and
withdrawn. Both are skipped where IB_USERNAME and IB_PASSWORD are not set,
and neither has been run against the venue at this revision. The scripts under
scripts/ do the same through ib_async_dx.IB against a paper account, and
the notebooks do it a cell at a time. Neither has been run
against the venue through ib_async_dx.IB yet, so Evidence
rates both paths ✅ Offline.
What crosses, offline. The other 316 tests need no session: that a
combination keeps its legs on every request path, that a fill’s cost reaches
ib_async as its own CommissionReport, that a refused order reaches its
Trade, that connect takes its login from the environment or an IBC and
is paper unless told otherwise, that a session opens and closes once, on the
program’s loop, however a connect is cancelled, overtaken or failed, and that
each bug fix answers what ib_async 2.1 does not.
Evidence lists what they cover.
In Rust
Coming: ib_async’s model and names, in Rust spelling, on the engine’s public API. It is not in this repository yet.
Running ib_async itself
ib_async is layered. IB, Wrapper, Ticker, Trade and everything above
them are transport-agnostic; only its Client and Connection know there is a
socket to a gateway on localhost. Replacing that one layer runs the rest of the
library unchanged.
That is what ib_async_dx.IB does. Its connect gives the instance a client
that answers from the engine, IbkrDxClient, in place of ib_async’s, and then
runs ib_async’s own connect. Nothing of ib_async is copied or modified: it is
installed as ib_async publishes it, and its classes and modules are left as
they are.
from ib_async_dx import IB, Stock
ib = IB()
ib.connect(username="your_user", password="your_pass")
spy = Stock("SPY", "SMART", "USD")
ib.qualifyContracts(spy)
bars = ib.reqHistoricalData(spy, "", "2 D", "1 hour", "TRADES", useRTH=True)
ib.pendingTickersEvent += lambda tickers: print(len(tickers), "updates")
ib.reqMktData(spy)
ib.sleep(5)
ib.disconnect()
Their IB, their Wrapper, their events, their types — this engine
underneath, and no gateway process.
An ib_async.IB built elsewhere gets the same client from
ib_async_dx.attach(ib, username=..., password=...), which hands the instance
back ready to connect. A session the instance holds is ended first, as its
disconnect() ends one, and the client it replaces is no longer tied to it.
ib_async_dx.IB calls it on every connect. An attached ib_async.IB is
ib_async’s own, so the bugs fixed on
ib_async_dx.IB are still there.
The session’s life
- Callbacks run on the loop, a pass at a time. A pass hands their wrapper
what the engine holds, as one batch:
tcpDataArrivedbefore its first message,tcpDataProcessedafter its last, as their transport marks a packet. The loop makes each pass itself, 10 ms after the last, so a program away from its loop — intime.sleep, say — queues nothing, and one whose loop ends without adisconnect()leaves no pass running. A refusal stated inside a request call waits for the next pass, as a gateway’s comes back on the socket once the call has returned. A handler that ends the session ends the pass: nothing more of it reaches their wrapper, as their client drops what it had read once its socket is closed. - A handler can wait on the session. What the engine says inside a pass
or a close reaches their wrapper once the engine has returned, and the next
pass is due before one is made. So under
util.startLoop()a blocking request made in a handler is answered, and a connect made on hearing the session end logs in, on an attachedib_async.IBas onib_async_dx.IB. - The login runs off the loop, which keeps turning while it waits. What the engine announces from inside it — the connect acknowledgement, the accounts and the next id — reaches nothing there, on the login’s thread; the same three are said on the loop once it returns. Their wrapper is called on the loop’s thread and no other.
timeoutbounds each request of ib_async’s startup sync, as it does against a gateway. The login is the engine’s to bound, the engine’s wait of up to three seconds for the venue to name the working orders among it: a paper login presents no second factor, and a live one waits on it for as long as the engine allows, as a gateway’s login is made before a program connects.- A connect ends at once when it is cancelled — by
wait_for, a task cancel or an interrupt — or overtaken bydisconnect()or by another connect. The engine is told, and drops the session its login opens instead of keeping it.disconnect()returns at once too: where the login holds the engine while it announces the session, the engine is told on a thread of its own. The login itself runs on inside the engine until the engine returns — on a live account, until the second factor is answered or its wait runs out — on a thread that does not hold the program open. An overtaken connect raisesConnectionError. Of two connects on oneib_async_dx.IB, the later one’s session is the one left open, whenever the later one is made. An attachedib_async.IBruns ib_async’s own connect, which does not stand down for another: there a connect overtaken during ib_async’s startup sync, once the engine has logged in, can still report success on the later session, or end it. - A login that fails raises
ConnectionError,apiErrorsays why, and the client reads disconnected, as their client fails a connect. - A session the engine ends as it opens fails the connect, with nothing
said on
disconnectedEvent: their client says nothing of a socket that closed before the API was ready either. - A session the engine ends once open is handled as their client handles a
dropped socket, once: every waiting request fails,
disconnectedEventfires, and delivery stops. A pass that raises ends the session the same way, as their transport closes a socket whose data it could not handle. - A
connecton anIBthat is connected ends that session first, asdisconnect()ends one, and opens the next, as their client closes its socket before it opens another.
What is carried
Their IB calls 67 methods on the transport layer, counted from their own
source at 2.1.0. All of them are carried here. A test reads that list out of
their source on every run rather than checking a list kept by hand, so a name
they add is a failure here rather than a program that stops.
Their types are theirs. A callback carrying an object hands over one of
this engine’s, and their wrapper reads one of theirs, so every argument is
rebuilt on the way through — by its own type name, from their dataclass. A
field they have and this engine does not keeps its default, and neither side
needs editing when the other gains one. A fill’s cost reaches them as their
CommissionReport, a histogram as their HistogramData, a historical tick as
their own record.
Their requests are carried whole. A contract or an order going the other
way is rebuilt as the engine’s, field by field, whatever the field: a
combination keeps its legs, an algo its parameters, an order its soft-dollar
tier and its conditions, joined by and or by or. A field is carried as their
client sends it, one at their own default among them; what their client sends
as an empty field (None, an empty string or list, their unset number) is left to
the engine’s own default. A value its field cannot take — text where a number
goes — is refused with 320 on errorEvent, under the request’s number, as a
gateway refuses a message it cannot read, rather than the request going out on
terms nobody stated.
An order is what their client writes. Their placeOrder writes the order
as its message, and the message is read back as a gateway reads it, so what
reaches the engine is what a gateway would be sent: a field their client does
not write is not carried, and volatility, which their client clears on any
order but a volatility order, is cleared. Their client also writes
eTradeOnly, firmQuoteOnly and nbboPriceCap, which the venue no longer
takes. They are carried to the engine, which answers an order stating one as a
gateway answers it: where the venue has retired them for the account, refused
with 10268, 10269 or 10270 under the order’s number; otherwise placed without
it, with the notice 2168, 2169 or 2170.
Orders and requests are numbered from one counter, as their client numbers them, so an order never takes the number of a request still waiting. The counter starts past every id the account has used that a request can carry, which the venue names at every connect, and past the next order id the engine saved for the account and client id, and is kept past every one the venue names after: a new order never takes an id a fill has already spent, and an order placed elsewhere under an id wider than a request can carry leaves every request numberable. On such an account no request can be numbered past the account’s order ids, so a new order, a bracket and a what-if take the engine’s next order id, which the engine accepts, and requests go on from the counter. The engine keeps that saved id in a file of its own, and its documentation says where and how to move it.
Their client’s own messages are the requests they name. send is their
client’s own, writing the fields as one message, and sendMsg reads a
message back, as a gateway reads one, into the request their client writes it
for, and answers it as a gateway does. A message naming no request their
client writes is logged on ib_async_dx.bridge, and nothing answers it. One
that does not read as the request it names is refused with 320 on
errorEvent, under that request’s number where it was read before the field
that failed, and under -1 before it. Either way the session carries on.
A few details are worth knowing:
- A bar’s date is handed over in the spelling their own parser reads. Their
parseIBDatetimedecides the shape from the string — eight digits is a day, a date and a time and a zone separated by single spaces is an aware moment — and the frame example in their own tests callstz_converton the date column, which refuses a naive datetime. - A price and the size that goes with it reach them together, as their
priceSizeTick, and a size that changed on its own as theirtickSize, as a gateway sends them. - A refusal reaches their
errorin the four-argument shape their wrapper declares, as a gateway’s does, and after the call that caused it has returned. So a new order refused before it is sent reaches theTradeplaceOrderhanded back, which their wrapper marks as it marks one a gateway refused:Cancelledfor an error, and, onib_async_dx.IB, for 321, which ib_async counts as a warning (why). - The engine checks an option list as a gateway checks one: the one key
taken is
manual, valued 0 or 1, none is taken on the two option computations, and an entry with no key or no value, another key or another value is refused with 320, 10337 or 10338, under the request’s number, with nothing sent.reqFundamentalDatareads no list. See Limits. - An order’s status names the client that placed it, as a gateway’s does, so
their wrapper finds the
Tradean order another client placed is kept under. - A record their wrapper builds whole arrives whole: a routing component, a
family code. A bar’s average price arrives as its
average, and a condition says how it joins the next. A callback that cannot be rebuilt, or that their wrapper raises on, is logged onib_async_dx.bridgeand passed over, as their decoder treats a message it cannot handle, and the session carries on. - Every account the login holds is listed by
managedAccounts(), not only the first. isConnected()answers as their client does. An outage the engine is still mending (1100 until 1102) leaves the session connected.disconnect()does not call theirconnectionClosed, as their own client’s does not. Their wrapper treats that as a session that went away underneath them: it fails every request still waiting and raises on their global error event. That is right for a socket that dropped and wrong for a caller who asked to stop.- An
IBconnects again after it disconnected. Each session and each connect is counted, and nothing kept for an earlier one — a pass, a login, a held refusal — reaches the next. - A request made while not connected raises
ConnectionError("Not connected"), as their client’s does. A request reached by name onib.clienttakes its arguments as their client’s method does, by position or by keyword;connectandrunon it are their client’s own, andresetends the session. What leaves for the engine is paced as their client paces what it writes to a gateway: at mostMaxRequestsperRequestsIntervalon the loop’s clock,throttleStartandthrottleEndsaid around a wait, and 0 disables the pacing. - Request ids stop at the widest a request can carry: the rest of the range
is the engine’s own. An account whose ids reach it has
getReqId()raiseOverflowErrorrather than number a request the engine refuses. updateEventfires, and their wrapper’slastTimemoves, only on a pass of the engine that delivered something, as they do over a socket only when data arrives. A session with nothing arriving stays quiet, sosetTimeoutfirestimeoutEvent.
Their own test suite
The strongest available statement about whether their library runs here is their own tests. They are not copied here; point a run at a checkout of theirs:
git clone https://github.com/ib-api-reloaded/ib_async /tmp/ib_async
git -C /tmp/ib_async checkout ab629f34c1 # 2.1.0
cp tests/ib_async_upstream/conftest.py /tmp/ib_async/tests/
IB_USERNAME=… IB_PASSWORD=… pytest /tmp/ib_async/tests \
-o asyncio_mode=auto \
-o asyncio_default_fixture_loop_scope=session \
-o asyncio_default_test_loop_scope=session
Both loop scopes are needed: their session-scoped connection fixture and their
tests must share one event loop, or callbacks land on a loop that is not
running while the test waits on them. pandas has to be installed too: their
test_contract.py imports it, and without it the run stops at collection.
The conftest replaces their shared ib fixture with an ib_async_dx.IB,
connected with the login in the environment, and makes ib_async.IB this
package’s, so every test runs on the engine. At 2.1.0 their suite is three
tests, taken from their commit ab629f34c1; none has been run against the venue
at this revision:
| Test | What it asks, and what holds offline |
|---|---|
test_account_summary | The account summary, through their ib fixture |
test_request_error_raised | A RequestError carrying 321 from a refused what-if. ib_async 2.1 counts 321 as a warning, which never ends a request, so against any server that answers 321 its own IB waits for good; ib_async_dx.IB ends the request with the refusal, and test_a_what_if_refused_with_321_ends_with_the_refusal holds that offline |
test_contract_format_data_pd | It builds its own ib_async.IB() and connects it to 127.0.0.1:4001 rather than taking the fixture. The conftest makes ib_async.IB this package’s before their tests are collected, in that run only, so that IB connects to the engine whatever host and port it names |
What it does not carry
Some of what their IB reads off its client answers for a transport that has
no socket. connectionStats() counts the messages each way, as their client
does: a request is one sent, and what reaches their wrapper one received, over
the session since it opened. Its byte counts are the engine’s count of the
session’s protocol bytes with the venue. And their client’s conn, the socket
connection, is not there.
Everything on their IB is routed. The rest of what differs is in
Limits.
Where it answers as a gateway does
A program meeting one of these for the first time may read it as a difference. Each is what ib_async over a gateway does too.
readonly=Truemakes a read-only session, which refuses to send anything that places, changes or withdraws an order, as a gateway set to read-only does.serverVersion()is 217 once connected, the level a current gateway states in its handshake and the engine states as its own, and 0 until then, as their client answers it.reqExecutions()answers with the day’s executions: those this session has seen and those the venue restated when it opened, fills on orders already completed among them. ib_async 2.1’s request cannot ask for more.reqCompletedOrders()asks the venue.numIdsonreqIdschanges nothing: ids are handed out one at a time.- The handshake a program makes with the gateway it connects to —
verifyRequestand the three after it — is taken, and nothing answers it, as a gateway answers nothing. A name their client does not have is a missing attribute, as on theirs. - Callbacks that reach nothing in ib_async over a gateway reach nothing here.
Their wrapper has no handler for
displayGroupList,displayGroupUpdated,rerouteMktDataReq,rerouteMktDepthReqorreplaceFAEnd, discardsdeltaNeutralValidation, and is never sentverifyMessageAPI,verifyCompleted,verifyAndAuthMessageAPI,verifyAndAuthCompletedortickEFP, so aTicker’s EFP fields keep their defaults.winErroris no message at all. A request a gateway would reroute is itself answered differently: see Limits. FlexReportfetches a report over the web with a token of its own and never touches a session, here or over a gateway.
Beyond ib_async
ib_async_dx.IB is ib_async’s IB with three kinds of addition: four of
ib_async’s bugs fixed, the engine’s calls beyond the documented API, and a
warning at connect. No field is added to an ib_async class, so ib_async’s
objects, their reprs, util.df’s columns and equality stay ib_async’s.
ib_async’s bugs, fixed
Each fix is a small override on IB, with a test. The fixes are on
ib_async_dx.IB: an ib_async.IB given to attach is ib_async’s own, and
keeps its behaviour, bugs included.
| In ib_async 2.1 | Here | Test |
|---|---|---|
reqUserInfo() returns []. Its wrapper’s userInfo ends the request without the White Branding ID it was answered with | reqUserInfo() returns the White Branding ID, as its docstring says | test_reqUserInfo_answers_the_white_branding_id, beside ib_async’s own answer |
connect asks for positions whatever fetchFields says | Left out of fetchFields, StartupFetch.POSITIONS means the connect’s own request is not made, so positions() stays empty until reqPositions(), which asks as usual. Any other request, made while the connect runs among them, is the program’s own | test_fetchFields_without_positions_asks_for_no_positions, beside ib_async’s own answer; test_only_the_connects_own_startup_request_for_positions_is_skipped |
Error 321 is a warning, which never ends what it is about. A what-if refused with it never resolves, and a new order refused with it stays ValidationError, open for good. ib_async’s own test expects a RequestError carrying 321, and its wrapper notes that a new order refused with 321 should probably be deleted | ib_async’s own rule for 110 applied to 321: a request waiting under the number ends with the refusal, raised as RequestError where RaiseRequestErrors is set, and a new order still PendingSubmit is cancelled. On an order already working, 321 stays a warning, since a refused change leaves the order live | test_a_what_if_refused_with_321_ends_with_the_refusal, test_a_refused_new_order_reaches_its_trade, test_321_on_an_order_already_working_stays_a_warning |
disconnect() does nothing while a connect is under way, and the connect then opens the session the program asked to close | The connect ends with ConnectionError, and the engine drops the session its login opens | test_a_disconnect_during_the_login_ends_it |
The tests are in tests/python/test_ib_runs_on_the_engine.py and
tests/python/test_a_session_opens_and_closes_on_the_loop.py.
The engine’s calls beyond the documented API
The venue states more than the documented API has messages for. These methods
read it from the engine, and return types of this package’s own:
TickerExtras, OptionModel, OrderPreset, CompetingSession,
CorporateAction, ScannedStrategy and PositionElsewhere, with the
engine’s own SpreadScan for what a spread scan looks for. All are importable
from ib_async_dx and none is in __all__. Called while not connected, before
connect or after disconnect, each raises ConnectionError("Not connected"), as ib_async’s client does.
| Method | Returns | What it answers |
|---|---|---|
reqMktDataEx(..., marketDataType=None) | Ticker | reqMktData’s arguments, and a market data type for this request only, numbered as reqMarketDataType numbers them: 1 live, 2 frozen, 3 delayed, 4 delayed frozen. None keeps the session’s. A contract holds one subscription: asked again while subscribed, it follows the one that is up, so cancel between two types |
reqCurrentTimeInMillis(), reqCurrentTimeInMillisAsync() | int | The time in milliseconds since the epoch: a call in the documented API that ib_async does not have. It is read off the session’s clock, the local clock set by the venue’s to within about a second, as a gateway answers it from its own clock |
tickerExtras(ticker) | TickerExtras | What the venue has stated for the ticker’s market data request beyond ib_async’s Ticker, read when called: sharesOutstanding, openAYearAgo, shortSaleRestricted (a short-sale circuit breaker is on, not whether the contract can be shorted), and statedFigures, statedRows, numberedFigures and pairedFigures, series by the venue’s own numbering. A series is asked for by its number in genericTickList. A figure not stated is nan |
optionModel(ticker) | OptionModel or None | The venue’s model of the ticker’s option: the eight figures of OptionComputation and the ten it has no field for — calDays, rate, rho, fugit, exerciseBoundary, forwardCoeff, modelYield, bridgeYield, timeValue and priceBasedVol. A figure not stated is None; priceBasedVol is always a bool, False when the venue did not state it |
closingOptionModel(ticker) | OptionModel or None | The same model as it stood at the close |
companyData(contract) | dict[int, list[tuple[str, str]]] | What the venue states about the contract’s company or terms, as its own key and value pairs, by series. Asked for by number in genericTickList, and kept after the cancel. Empty means not entitled or nothing stated; the two cannot be told apart |
enabledFeatures() | list[str] | The capability tokens the venue granted this account at logon |
orderPermissions() | dict[str, list[str]] | The order types the venue permits, by security type |
permittedOrderTypes(secType) | list[str] or None | The order types permitted for one security type, or None if it is not permitted. An order the account may not place comes back Inactive with no text |
algorithms() | dict[str, list[str]] | The algorithms the venue offers, keyed PROVIDER/SECTYPE |
algorithmsFor(secType) | list[str] | The algorithms offered for one security type, across every provider |
orderPresets() | list[OrderPreset] | The sets of order defaults the account holds, as key, version and lastChanged. Their values are not carried |
competingSession() | CompetingSession or None | Another session that held the account when this one connected: its origin, when it logged in (loggedInAt, in UTC), and readOnly, true when this session may read but not trade because the other holds the account |
reqPing(), lastRtt() | None, float or None | reqPing measures the round trip to the venue; lastRtt reads the latest, in milliseconds |
reqCorporateActions(contract, startDate, endDate), reqCorporateActionsAsync(...) | list[CorporateAction] | A contract’s corporate actions over a range of days, YYYYMMDD, asked for by its conId. Each states its kind as the venue names it — CD a cash dividend, SD a dividend in shares, SS a split, SO a spin-off, RO a rights offer, FR a future rolling into the next month — with its date, value and currency, and the announceDate, recordDate, payDate, paymentType and distributionType the kind carries; one it does not carry is empty. A request given up before its answer, on a RequestTimeout among other ways, is withdrawn; one dropped by disconnect() raises ConnectionError |
reqSpreadScan(contract, scan, timeout=10), reqSpreadScanAsync(...) | list[ScannedStrategy] | An underlying, by its conId, scanned by the venue for strategies worth putting on. scan is the engine’s SpreadScan, whose fields are the venue’s own words. As reqScannerData does, it subscribes, takes the first answer and cancels; the subscription’s quotes reach the underlying’s ticker meanwhile. Each strategy states its legs as contract ids and sizes, a leg sold negative, its kind and aggression as the venue numbers them, its thirteen figures in the venue’s order, its breakEvens and its lastFigure; a figure not stated is nan. Unanswered within timeout seconds of being asked, 0 for no limit, it found nothing: []; refused, it cancels nothing. The engine keeps a scan’s answer for as long as the underlying’s market data is subscribed, so a scan made while it still is — by the program’s own reqMktData, or by a scan just ended — can be answered with the strategies the last scan found. A scan dropped by disconnect() raises ConnectionError |
positionsElsewhere() | list[PositionElsewhere] | Holdings the venue reports that this broker does not hold itself, as conId, symbol, secType, currency, position, avgCost and held: 'Away' for a position held at another broker, 'DisplayOnly' for a row shown but not held, 'Aside' for one reported apart without saying why. Kept out of positions(), so the account is not overstated |
accountValuesElsewhere(held) | list[AccountValue] | The account figures for one of those sets, 'Away', 'DisplayOnly' or 'Aside', as ib_async’s own AccountValue with an empty modelCode. A figure stated in two currencies is two rows. Kept out of accountValues() and accountValueEvent |
A warning at connect. When another session held the account as this one
connected, connect logs it at WARNING on this package’s ib_async_dx.ib
logger: where the other session connected from, when it logged in as the venue
states it, and whether it holds the account. It is said only of a session still
open, and never fails a connect that has completed. ib_async’s own loggers
carry only what ib_async says.
Tip
These calls are one-way. A program that uses one cannot move back to a gateway, because a gateway has no message to carry it. Everything ib_async itself names moves both ways.
Notebooks
notebooks/
holds eight notebooks, one on each of ib_async’s own subjects, written against
ib_async itself — its IB, its contracts, its events, its util.df — and run
on the engine through ib_async_dx.IB. None of them starts a gateway, and every one
opens a paper session.
Each begins the same way. It is the one cell that differs from a notebook
written for a gateway: the import names ib_async_dx, and connect takes the
login.
import os
from dotenv import load_dotenv
from ib_async_dx import IB, util
util.startLoop()
load_dotenv()
ib = IB()
ib.connect(
username=os.environ["IB_USERNAME"],
password=os.environ["IB_PASSWORD"],
paper=True,
)
util.startLoop() is ib_async’s own: it applies nest_asyncio so that
ib_async’s blocking calls can run inside the notebook’s loop, as it does over a
gateway. A handler can make one too, a request on an error or a connect when
the session ends. nest_asyncio cannot run asyncio’s timeouts on Python 3.14,
so there ib_async’s own calls fail under it, over a gateway as here: run the
notebooks on Python 3.11 to 3.13.
The eight
| Notebook | What it shows | ib_async calls |
|---|---|---|
basics | What the account is worth, what it holds, and one quote | accountSummary, positions, qualifyContracts, reqMktData, cancelMktData |
contract_details | What the venue knows about a contract, how it answers a description that matches more than one, and a search by name | reqContractDetails, reqMatchingSymbols |
bar_data | How far back the venue holds a series, the bars themselves as a frame, and a series kept up to date | reqHeadTimeStamp, reqHistoricalData, util.df, keepUpToDate=True, cancelHistoricalData |
tick_data | Top of book on three contracts as it changes, and every print as it happens | reqMktData, pendingTickersEvent, reqTickByTickData, ticker.tickByTicks |
market_depth | Which venues will answer for a book, a ten-level book, and the book moving | reqMktDepthExchanges, reqMktDepth, ticker.domBids, ticker.domAsks, ticker.updateEvent |
scanners | What the venue can scan for, one scan run, and its rows as a frame | reqScannerParameters, ScannerSubscription, reqScannerData, util.df |
ordering | An order placed, watched, moved and withdrawn, and one previewed rather than sent | placeOrder, LimitOrder, trade.log, cancelOrder, openTrades, whatIfOrder |
option_chain | The S&P 500 index’s option chains, the options within 20 points of it on the next three monthly expiries, and their quotes and model greeks | reqMarketDataType, reqTickers, reqSecDefOptParams, qualifyContracts, ticker.modelGreeks |
A few things they show along the way:
- A quote wants a qualified contract. ib_async keys a
Tickerby the contract, and a contract without itsconIdcannot be a key, soqualifyContractscomes first. - One
Tickerper contract, amended in place.pendingTickersEventfires with the tickers that changed, rather than on a timer; tick-by-tick is the trade stream itself rather than a summary of it. - A kept-up-to-date series amends its last bar as it forms, rather than repeating it.
- A book asked for at a named venue is answered by that venue. Asked for at none, it is acknowledged and may produce nothing, which is what an account with no aggregate entitlement is told.
- The venue states its scanner parameters as one XML document, and the notebook reads the scan codes out of it.
- A
Tradeis amended as the venue reports on it, and its log is the whole history of the order. A modification is the same order under the same id, at a new price;whatIfOrderasks what an order would cost the account and sends nothing. - An option chain is every expiry against every strike. The notebook takes the options it wants from that matrix and qualifies them in one call.
Note
orderingplaces a real order on the paper account: a buy at half the last price, meant to rest unfilled until a later cell withdraws it. A price is not a guarantee; the trade’s status says what happened. Run the notebook through, or withdraw it yourself.
Running them
pip install "git+https://github.com/userFRM/ib-dx@c18523506144df7b9f81fdbb82b68405f24f13ea"
pip install "ib_async-dx @ git+https://github.com/userFRM/ib_async-dx" \
jupyter python-dotenv pandas
git clone https://github.com/userFRM/ib_async-dx
jupyter lab ib_async-dx/notebooks
util.df needs pandas, which ib_async does not install on its own. The
credentials come from a .env file at the repository root, read by
python-dotenv, as IB_USERNAME and IB_PASSWORD. The repository ignores
that file; keep it that way.
One thing to keep
ib.sleep(), never time.sleep(). The library runs its event loop on the
calling thread, so a plain sleep stops it: quotes stop arriving, and every
stream reads as dead when it is only unattended.
Limits
What a caller has to know before writing against ib_async-dx, sorted by what kind of thing it is: where ib_async on the engine behaves differently from ib_async over a gateway, what is taken and not applied, and what is the venue’s answer rather than this package’s.
ib_async runs as itself, so its calls, its arguments and its answers are its
own. What differs is what its IB reads off a transport with no socket
underneath, and what the engine does that a gateway does not. What a gateway
refuses is refused as a gateway refuses it, on errorEvent. What is taken and
not applied is named below, where it happens. Where the engine answers as a gateway does, and
a program might not expect it to, Running ib_async itself says
so.
Where it differs from a gateway
connectaddresses nothing.hostandportare accepted and not used, and the port does not choose paper or live: the session is paper unlessconnectis givenpaper=False.clientIdis carried into the login.- One program per login. Each program is its own session on the login, and a second program on the same login, or a gateway, takes the session from the first; the venue says which host took it. Programs that share one gateway login under their own client ids each need a login of their own here.
- Every session hears every order on the account, whatever its
clientId:trades(),openTrades(),reqOpenOrders()andopenOrderEventinclude the account’s orders placed elsewhere — in TWS, on the phone, or by a program under another client id. Over a gateway, a program is told of its own orders only, unless it connects as client 0 or as the gateway’s master client, or asks withreqAllOpenOrders(). - A request a gateway reroutes is refused. Asked for a quote or a book on
a contract the venue serves under another, a contract for difference
standing for a share among them, a gateway tells the program where to ask
instead, and ib_async, which has no handler for that, hears nothing. Here
the request is refused in the venue’s words, on
errorEvent. - A pass’s ticks reach a ticker in the engine’s order. The engine states
a quote’s prices before its sizes, and a price is handed over with the size
that goes with it, so the bid, ask and last a pass states land in
ticker.ticksafter the other ticks of that pass. The ticker’s fields end the pass as they would over a gateway. connectionStats()counts the messages each way, as ib_async’s client does: a request is one sent, and what reaches ib_async’s wrapper one received. Its byte counts are the session’s protocol bytes with the venue, as the engine counts them, where ib_async’s are the bytes on its socket to a gateway.- The engine checks each option list as a gateway checks it. On nine of
the eleven requests a gateway reads a list on —
reqMktData,placeOrder(the order’sorderMiscOptions),reqMktDepth,reqHistoricalData,reqScannerSubscription,reqRealTimeBars,reqNewsArticle,reqHistoricalNewsandreqHistoricalTicks— the one key taken ismanual, valued 0 or 1, and it changes nothing a gateway sends. The other two,calculateImpliedVolatilityandcalculateOptionPrice, take no key at all. An entry with no key or no value is refused with 320, a key the request does not take with 10337 and another value with 10338, onerrorEventunder the request’s number, and the request is not sent. Where the venue lifts the checks for the account (NOAPIMISCVLDamong itsenabledFeatures()), no key or value is refused on those grounds, and amanualthat does not read as the number 0 or 1 is still refused, with 321.reqFundamentalDatareads no list, as a gateway reads none:fundamentalDataOptionsis taken, and nothing in it is checked or applied. - An account named on an account request is checked as a gateway checks
it.
groupNameonreqAccountSummary, the account onreqAccountUpdates, and the account onreqPnLandreqPnLSingleare refused where a gateway refuses them, in its words. On a login holding one account, the account named onreqAccountUpdatesis ignored, as a gateway ignores it. On a login holding several, each request answers for the account it names, andAllonreqAccountSummaryfor every account the login holds.modelCode, an advisor group andAllNonPropare taken and not applied, and so isAllonreqPnL,reqPnLSingle,reqAccountUpdatesMultiandreqPositionsMulti, which answer for the session’s account; the engine logs a warning once per session saying so. IBClaunches nothing, and holds the login it names.ib_async_dx.IBCis ib_async’s with no gateway to launch: the engine logs in onconnect, and rebuilds a dropped connection on the session it already holds. Starting it holds itsuseridandpassword, on a live session wheretradingModeis'live'and on paper otherwise, as the gateway it would launch holds a login; a connect in the same context that names no login of its own logs in with it, and an emptyuseridorpasswordis read fromIB_USERNAMEorIB_PASSWORD. A login named onconnect, or given toattach, comes first. Terminating it ends every session that login opened, as stopping a gateway ends the sessions connected to it, and from whichever context it is terminated, no connect logs in with it afterwards. ib_async’s ownWatchdog, handed one, is a reconnect loop: it starts the IBC and connects itsIBin a task of its own, so eachWatchdoglogs in with its own IBC’s login, and when the session ends it connects again. Its probes and timeouts are ib_async’s own. A session that ends because another program or a gateway logged in on the same login is one theWatchdogconnects again, which takes it back. The paths, the Java settings and the FIX login are a gateway’s, and nothing reads them. Nor is IBC’s ownconfig.ini: a login or aTradingModekept there is not read, souseridandpasswordare given to theIBCor left to the environment, and atradingModeleft empty is paper. Where theIBC’s login is used, itstradingMode, notconnect’spaper, decides whether the session is live.
A restart is a new login
The session lives in the program’s process and ends with it. A gateway held
its login while a program restarted; here each start logs in, and on a live
account that login waits on the second factor. The session is kept in
~/.ib_dx/session-<user>-<paper|live> unless connect is given another
sessionFile, and a start naming a session the venue still holds is answered
with a challenge rather than a full login. The venue lets a session go soon
after its process ends, so that covers a quick restart only.
What ib_async itself does not have
reqCurrentTimeInMillisis in the documented API and not in ib_async.ib_async_dx.IBadds it, with its…Asynctwin; see Beyond ib_async.
The venue’s answer, not this package’s
These are the venue’s answers, as the engine’s sessions have met them, and none is something this package decides. They are written down because a program meeting one for the first time reads it as a fault here.
- Market depth depends on the entitlement. A venue the account is not entitled to refuses by name. A book asked for on no particular venue is acknowledged and then produces nothing, which is what an account with no aggregate entitlement is told. It does not error.
- News headlines and corporate events need subscriptions. Without a news subscription the providers list comes back and every query is empty; without a Wall Street Horizon subscription the calendar’s schema arrives and its events are empty.
- The protocol is not published. The venue can change it without notice.
The engine’s own limits — what it measures, and what no session has settled yet — are set out in ib-dx’s documentation.
Evidence
What was measured, and on what. Status is assigned from a named artifact — a test, a script, or a recorded server response — not from reading the code.
| Status | Definition |
|---|---|
| ✅ Supported | Implemented; exercised against IBKR production servers; the answer is delivered to the caller |
| ✅ Documented | A property of the venue rather than a call: established against IBKR production servers, with nothing for this package to implement |
| ✅ Offline | Implemented, and held by the offline suite on every run; not yet exercised against IBKR production servers |
| — Not yet | Not in this repository yet |
The suites
| Suite | Count | Needs a session |
|---|---|---|
tests/python | 318 | 2 of them. The other 315 run offline, and 1 a Python version condition excludes |
tests/ib_async_upstream | ib_async’s own suite, 3 tests at 2.1.0 (ab629f34c1), written to run on the engine; not run against the venue at this revision | Yes |
scripts/ | 3 checks against a paper account; not run against the venue at this revision | Yes |
CI builds the engine from source with its test hooks, at the engine commit this
package is tested against (c1852350), and runs tests/python on every push
to main and every pull request, on Python 3.11, 3.13 and free-threaded
3.14t, against this package installed from the wheel and from the source
distribution it builds, never from the checkout. The same runs fail when a
test count the book or the README publishes differs from what the suite
collects. It also installs the wheel beside the engine built as a release
builds it, without its test hooks, and imports it. The workflow passes no
credentials, so there the two live tests are skipped; they run against the
venue locally, with IB_USERNAME and IB_PASSWORD set.
A release is the Python package and the Rust client together. Its workflow refuses a tag until the Rust client is in the repository and nothing on this page is still “Not yet”, then runs the suite again against the tag’s wheel and source distribution before attaching them to the release.
Surfaces
| Surface | Status | Verification |
|---|---|---|
ib_async_dx is ib_async’s public API | ✅ Offline | __all__ equal to ib_async’s, and every name ib_async’s own object except IB and IBC, __version__ among them; ib_async’s 13 submodules under both import forms: tests/python/test_the_package_is_ib_async.py |
ib_async’s IB on the engine, through attach | ✅ Offline | Its events, *Async calls and types, with no gateway. All 67 transport calls their IB makes are carried, read out of their source on every run by test_every_call_their_library_makes_is_carried. Written for the venue and not run against it at this revision: test_an_unmodified_program_runs_on_this_engine and test_an_order_lives_its_whole_life_through_their_api, in tests/python/test_ib_async_transport.py |
Their client’s send and sendMsg | ✅ Offline | Each of the 80 requests their client writes as a message, written by their own code through send, reads back into the message it was and reaches the engine exactly as the same request made by name; a message that does not read is refused with 320, and one naming no request is logged and unanswered: tests/python/test_a_raw_message_is_the_request_it_names.py |
ib_async_dx.IB on the engine | ✅ Offline | The same client, installed by its connect, with the login, the positions fix and the competing-session warning: tests/python/test_ib_runs_on_the_engine.py. Written for the venue and not yet run there: their own suite through tests/ib_async_upstream/conftest.py; scripts/sdk_sweep.py (every read, printing what came back; places nothing), scripts/sdk_lifecycle.py and scripts/order_round_trip.py (an order placed, changed and withdrawn on paper); the eight notebooks |
| The session’s life: on the loop, a pass a batch; a login cancelled, overtaken or refused; a session ended as it opens or once open | ✅ Offline | tests/python/test_a_session_opens_and_closes_on_the_loop.py |
| The four ib_async bug fixes | ✅ Offline | tests/python/test_ib_runs_on_the_engine.py and tests/python/test_a_session_opens_and_closes_on_the_loop.py; see Beyond ib_async |
The engine’s calls beyond the documented API, on IB | ✅ Offline | tests/python/test_ib_runs_on_the_engine.py; see Beyond ib_async |
reqCorporateActions, reqSpreadScan, positionsElsewhere, accountValuesElsewhere | ✅ Offline | tests/python/test_ib_runs_on_the_engine.py; see Beyond ib_async |
TickerExtras.statedRows | ✅ Offline | tests/python/test_ib_runs_on_the_engine.py; see Beyond ib_async |
IBC with ib_async’s own Watchdog | ✅ Offline | A reconnect loop, connecting with its IBC’s login and again when the session ends, each Watchdog with its own: tests/python/test_ib_runs_on_the_engine.py |
| Rust client | — Not yet | Coming, on the engine’s public API |
ib_async’s own suite. Its three tests at 2.1.0, from their commit
ab629f34c1, have not been run against the venue at this revision, so none of
them is a result here. test_request_error_raised asserts a RequestError
carrying code 321 from a refused what-if, which their own IB never raises: it
counts 321 as a warning, and a warning never ends the request it belongs to.
ib_async_dx.IB ends it with the refusal, which
test_a_what_if_refused_with_321_ends_with_the_refusal holds offline.
test_contract_format_data_pd builds its own ib_async.IB(), which the
runner’s conftest makes this package’s before their tests are collected, so it
connects to the engine.
The package is ib_async
All offline, in tests/python/test_the_package_is_ib_async.py.
| What holds | Test |
|---|---|
__all__ is ib_async’s 103 names, and only IB and IBC are other objects | test_the_names_are_theirs_and_so_are_the_objects |
__version__ and __version_info__ are ib_async’s, and this package’s version is __ib_async_dx_version__, outside __all__ | test_the_version_is_ib_asyncs_and_this_packages_is_apart |
| Every submodule of ib_async’s resolves here, as an attribute and as an import | test_every_submodule_of_theirs_resolves_here, test_from_a_submodule_import_works |
ib_async_dx.ib is ib_async’s ib module with IB replaced, and a star import of it binds the names ib_async’s does | test_the_ib_module_is_theirs_with_ib_replaced |
ib_async_dx.ibcontroller is ib_async’s ibcontroller module with IB and IBC replaced, its Watchdog ib_async’s own | test_the_ibcontroller_module_is_theirs_with_IB_and_IBC_replaced |
IB is a subclass of ib_async’s, with ib_async’s own __init__, and attach is outside __all__ | test_ib_is_their_ib |
connect and connectAsync are ib_async’s parameters, defaults and kinds, then four keyword-only ones | test_connect_is_theirs_plus_the_login |
IB, connected
All offline, on the engine’s test session, in
tests/python/test_ib_runs_on_the_engine.py.
| What holds | Test |
|---|---|
The login comes from the environment when connect names none, and the session file is named for the account | test_the_login_comes_from_the_environment_when_connect_names_none |
A login named on connect is the one used, in ib_async’s positional form; the client id reaches the login; sessionFile=False keeps nothing | test_a_login_named_on_connect_is_the_one_used |
| A session is paper unless the program says live | test_a_session_is_paper_unless_the_program_says_live |
An IB connects again after it disconnected, with ib_async’s own placeOrder | test_an_ib_connects_again_after_it_disconnected |
A second connect closes the session the first opened | test_a_second_connect_closes_the_first_session |
An IBC’s login is the one a connect naming none logs in with, paper unless its trading mode is live, and terminating it ends that session | test_an_ibc_carries_its_login_to_the_connect_and_terminates_its_session |
Terminating an IBC from another context ends every session its login opened, and no later connect logs in with it | test_a_terminated_ibc_ends_every_session_of_its_login_and_lends_it_to_none |
ib_async’s own Watchdog logs in with its IBC’s login, again after the session ends, and each Watchdog with its own | test_an_unmodified_watchdog_logs_in_with_its_ibcs_login |
An order’s status names the client that placed it, so an order another client placed reaches its Trade | test_an_order_status_names_the_client_that_placed_the_order |
| A price reaches their ticker with the size that goes with it, and a size that changed alone as a size tick | test_a_price_carries_its_size_and_a_size_alone_reaches_tickSize |
A refused new order reaches its Trade, and one refused with 321 is cancelled | test_a_refused_new_order_reaches_its_trade |
An order placed once the engine has given the session up is refused after placeOrder has made its Trade, and the trade is cancelled before the close | test_an_order_placed_as_the_engine_gives_the_session_up_reaches_its_trade |
A what-if refused with 321 ends with the refusal, as RequestError where RaiseRequestErrors is set | test_a_what_if_refused_with_321_ends_with_the_refusal |
| 321 on an order already working stays a warning, and the order is kept | test_321_on_an_order_already_working_stays_a_warning |
| The engine checks an option list as a gateway checks one: 10337 for another key, none taken on the two option computations, 10338 for another value, in the gateway’s words, under the request’s number, nothing sent | test_an_option_list_is_checked_as_a_gateway_checks_one |
| An order stating an attribute the venue no longer takes is carried to the engine, which gives the notice a gateway gives | test_an_order_stating_a_retired_attribute_reaches_the_engine_with_it |
| A handler asking again on every refusal is answered a refusal a pass, and does not hold the loop | test_a_handler_that_asks_again_on_every_refusal_does_not_hold_the_loop |
| A wrapper that raises is logged, and the session carries on | test_a_wrapper_that_raises_is_logged_and_the_session_carries_on |
| An order id the venue names after the connect is not handed out | test_an_order_id_the_venue_names_after_the_connect_is_not_handed_out |
| On an account whose order ids have outgrown a request, a new order, a bracket and a what-if take the engine’s next order id and are taken, and requests go on from their own counter | test_an_order_is_numbered_past_an_order_id_no_request_can_carry |
| Prices stated as the session ends reach the ticker before the close | test_prices_stated_as_the_session_ends_reach_the_ticker |
| A refusal held as the program disconnects does not reach the next session | test_a_refusal_held_as_the_program_disconnects_does_not_reach_the_next_session |
| Orders and requests are numbered from one counter, so an order’s refusal reaches the order and not a request still waiting | test_orders_and_requests_are_numbered_from_one_counter |
updateEvent fires on what arrives, and timeoutEvent when nothing does | test_updateEvent_and_timeoutEvent_follow_what_arrives |
fetchFields without POSITIONS asks for no positions at connect; ib_async 2.1 asks anyway | test_fetchFields_without_positions_asks_for_no_positions |
reqUserInfo answers the White Branding ID; ib_async 2.1 answers [] | test_reqUserInfo_answers_the_white_branding_id |
reqMktDataEx asks with the market data type named, with its option list for the engine to check, and None keeps the session’s | test_reqMktDataEx_asks_with_the_market_data_type_named |
reqCurrentTimeInMillis answers an int of milliseconds, within 2 s of the local clock on a session with no venue | test_reqCurrentTimeInMillis_is_the_venues_clock |
The option model is read by the ticker’s request, and an unstated figure is None | test_the_option_model_is_read_by_the_tickers_request |
What the engine states arrives in TickerExtras, OrderPreset and CompetingSession | test_what_the_engine_states_is_carried_in_this_packages_types |
| The account’s grants are read from the engine | test_the_accounts_grants_are_read_from_the_engine |
| A ping reaches the engine | test_a_ping_reaches_the_engine |
An extra called while not connected, before connect or after disconnect, raises ConnectionError | test_an_extra_while_not_connected_raises |
| A record ib_async builds whole, a routing component, is answered from the engine and the session carries on | test_the_routing_components_arrive_as_their_records |
connectionStats() counts the messages each way, carries the engine’s byte counts, and raises while not connected | test_connectionStats_counts_the_messages_each_way |
The competing-session warning is logged on ib_async_dx.ib | test_the_competing_session_warning_is_this_packages_own |
| A connect that completed is not failed by the warning after it: not by a stamp that does not parse, nor by a handler that disconnected | test_a_completed_connect_is_not_failed_by_what_follows_it |
priceBasedVol is a bool, False when the venue did not state it | test_priceBasedVol_is_a_bool_when_unstated |
| The size kept for a quote goes with its subscription, cancelled or a snapshot answered | test_a_subscription_over_leaves_no_size_behind |
ib_async’s own Watchdog, with this package’s IBC, connects and connects again when the session ends | test_an_unmodified_watchdog_keeps_the_session_up |
reqCorporateActions asks by the contract’s id, takes the answer, withdraws a query given up, and refuses a contract with no id | test_reqCorporateActions_asks_by_the_contracts_id_and_takes_the_answer, test_reqCorporateActions_given_up_withdraws_the_query, test_reqCorporateActions_needs_the_contracts_id |
reqSpreadScan takes the first answer and cancels, its quotes go to the underlying’s ticker, unanswered in time it found nothing, and refused it cancels nothing | test_reqSpreadScan_takes_the_first_answer_and_cancels, test_reqSpreadScan_unanswered_in_time_found_nothing, test_reqSpreadScan_refused_cancels_nothing |
A reqCorporateActions or reqSpreadScan the program disconnects under raises ConnectionError and withdraws nothing | test_a_request_the_program_disconnects_under_ends_with_the_session (2) |
| Holdings elsewhere and their figures are read apart from the account’s own, and a set is one of three | test_holdings_elsewhere_are_kept_apart, test_accountValuesElsewhere_names_one_of_three_sets |
The session’s life
All offline, in tests/python/test_a_session_opens_and_closes_on_the_loop.py.
Where a login has to be held open, the test engine keeps the engine’s own rule:
a disconnect counted during a login drops the session it opens.
| What holds | Test |
|---|---|
| A connect cancelled during its login tells the engine, and leaves no session | test_a_connect_cancelled_during_its_login_leaves_no_session |
A disconnect() during the login ends the connect with ConnectionError, leaves no session, and returns at once while the login holds the engine | test_a_disconnect_during_the_login_ends_it |
Of two connects on one IB, the later one’s session is left open and the earlier ends at once, on ib_async_dx.IB and on an attached ib_async.IB | test_a_later_connect_retires_an_earlier_one_still_logging_in (2) |
| Only the connect’s own startup request for positions is skipped | test_only_the_connects_own_startup_request_for_positions_is_skipped |
A connect overtaken once logged in, still waiting on the venue, fails and leaves the later session open, on ib_async_dx.IB and on an attached ib_async.IB | test_a_connect_overtaken_after_its_login_leaves_the_later_session_open (2) |
A connect overtaken in ib_async’s startup sync fails, and leaves the later session open with connectedEvent said once, whatever raiseSyncErrors says | test_a_connect_overtaken_in_its_startup_sync_fails_and_leaves_the_later_open (2) |
| Of three connects made at once, the last is left open | test_of_three_connects_at_once_the_last_is_left_open |
A disconnect() from a handler the connect runs ends the connect | test_a_disconnect_from_a_handler_during_the_connect_ends_it |
| A login given up on does not hold the program open | test_a_login_given_up_on_does_not_hold_the_program_open |
Under util.startLoop() a handler can wait on the session: a blocking request made in an errorEvent handler is answered, and a connect made in a disconnectedEvent handler of an attached ib_async.IB logs in, whether the engine ended the session or its IBC was terminated. Skipped on Python 3.14, where nest_asyncio cannot run asyncio’s timeouts | test_a_handler_can_wait_on_the_session_under_startLoop |
| A login given up on reaches nothing and keeps nothing, when the engine installs its session after the disconnect, and its engine’s close does not end the later session | test_a_login_given_up_on_reaches_nothing_and_keeps_nothing (2) |
An interrupted connect() opens nothing later | test_an_interrupted_connect_opens_nothing_later |
| A handler that ends the session ends the pass: no held price and no held refusal reaches the cleared wrapper | test_a_handler_that_ends_the_session_mid_pass_ends_the_pass, test_a_handler_that_ends_the_session_leaves_the_refusals_behind_it |
Their wrapper is called on the loop’s thread only, what the engine announces from inside the login among it, and hears connectAck, managedAccounts and nextValidId there | test_their_wrapper_is_called_on_the_loops_thread_only |
| A program away from its loop queues no passes, and passes go on while the loop runs | test_a_program_that_leaves_the_loop_queues_no_passes |
A loop closed without disconnect() leaves no pass running | test_a_loop_closed_without_disconnect_leaves_nothing_running |
A session that ends as it opens fails the connect, says why on apiError, and nothing more is done for it | test_a_session_that_ends_as_it_opens_fails_the_connect_and_stops |
A refused login raises ConnectionError, says why on apiError, and leaves the client disconnected | test_a_login_refused_raises_ConnectionError_and_says_so |
timeout does not bound the login; were the engine to wait for the next id after it, timeout would bound that (a guard: the engine answers at once) | test_timeout_bounds_the_wait_after_the_login_not_the_login |
Connecting an attached IB that is connected ends that session and opens a new one | test_connecting_an_attached_ib_that_is_connected_opens_a_new_session |
serverVersion() is 0 until connected | test_serverVersion_is_nought_until_connected |
attach to a connected IB ends its session first, and unties the old client | test_attach_to_a_connected_ib_ends_its_session_first |
attach names no client id | test_attach_names_no_client_id |
connect, run, reset, MaxRequests, RequestsInterval and events on the client mean what they mean on theirs | test_the_client_level_names_mean_what_they_mean_in_ib_async |
What leaves for the engine is paced: at most MaxRequests per RequestsInterval, the overflow deferred on the loop’s clock, throttleStart and throttleEnd said around the wait, 0 letting the whole burst leave at once, and a session ended mid-throttle leaving nothing queued for the next | test_requests_past_the_rate_leave_an_interval_later |
| A request reached by name on the client takes keywords | test_a_request_reached_by_name_takes_keywords |
| A pass that raises ends the session once | test_a_pass_that_raises_ends_the_session_once |
| What one pass delivers is one batch, on the loop | test_what_one_pass_delivers_is_one_batch |
What crosses to the engine
All offline, in tests/python.
| What holds | Test |
|---|---|
attach replaces the transport and nothing else: their IB, their wrapper | test_ib_async_transport.py::test_attach_replaces_only_the_transport |
| A combination keeps its legs on every request path — details, quotes, bars | test_ib_async_bridge_carries_everything.py::test_a_combination_keeps_its_legs_on_every_request_path |
A contract named by more than its symbol keeps the rest: an ISIN, includeExpired | test_ib_async_bridge_carries_everything.py::test_a_contract_named_by_more_than_its_symbol_keeps_the_rest |
| A contract named by its id states nothing else beside it | test_ib_async_transport.py::test_a_contract_named_by_id_states_nothing_else |
| An algo’s parameters, a combination’s routing and a soft-dollar tier reach the order | test_ib_async_transport.py::test_what_tunes_an_algo_reaches_the_order |
| A field set to something the engine cannot carry is refused, by name | test_ib_async_transport.py::test_a_field_that_cannot_be_carried_is_refused |
| A field at ib_async’s default is carried as ib_async sends it; what it sends empty is left to the engine | test_ib_async_transport.py::test_a_field_at_their_default_is_carried |
| An order condition joined by or is carried | test_ib_async_transport.py::test_a_condition_joined_by_or_is_carried |
A fill’s cost reaches them as their own CommissionReport | test_ib_async_bridge_carries_everything.py::test_a_fills_cost_arrives_as_the_record_their_wrapper_reads, test_ib_async_depth.py::test_a_commission_report_reaches_them_as_their_own_type |
A histogram reaches them as their HistogramData | test_a_histogram_crosses_the_bridge_in_their_type.py (2) |
| A historical tick reaches them as their own record | test_ib_async_depth.py::test_a_historical_tick_is_handed_over_as_their_own_record |
| A record tuple is rebuilt field by field, and a record named as theirs and of another type is rebuilt as theirs | test_ib_async_bridge_carries_everything.py::test_a_record_tuple_is_rebuilt_field_by_field, ::test_a_record_named_as_theirs_and_not_theirs_is_rebuilt_as_theirs |
| A book level reaches their ticker, on both sides and below the top | test_ib_async_depth.py::test_a_book_level_reaches_their_ticker, ::test_the_other_side_and_a_deeper_level |
| A size with no price beside it is a size tick | test_ib_async_bridge_carries_everything.py::test_a_size_with_no_price_beside_it_is_a_size_tick |
| A bar carries its average price, a condition how it joins the next, and a record ib_async builds whole arrives whole | test_ib_async_bridge_carries_everything.py::test_a_bar_carries_its_average_price, ::test_a_condition_arrives_saying_how_it_joins_the_next, ::test_a_record_theirs_builds_whole_arrives_whole |
| A callback that cannot be rebuilt is logged and passed over | test_ib_async_bridge_carries_everything.py::test_a_callback_that_cannot_be_rebuilt_is_logged_and_passed_over |
A refusal reaches their error in the four-argument shape it declares | test_a_refusal_reaches_their_wrapper.py (3) |
| Every account the login holds crosses over | test_ib_async_bridge_carries_everything.py::test_every_account_the_login_holds_crosses_over |
| A wide order id placed elsewhere leaves requests numberable, and no id past the widest a request carries is handed out | test_an_order_id_wider_than_a_request_leaves_requests_numberable.py (2), test_ib_async_depth.py::test_a_seeded_order_id_is_the_next_one_their_client_issues |
readonly reaches the session | test_ib_async_transport.py::test_readonly_reaches_the_session_through_the_adapter |
An outage leaves the session connected; a session the engine ends fires disconnectedEvent once, stops delivery and refuses requests | test_ib_async_transport.py::test_an_outage_leaves_the_session_open_and_an_end_closes_it |
| Ending a session is not reported as a session that went away | test_ib_async_transport.py::test_ending_a_session_is_not_a_session_that_went_away |
The handshake their client can send, verifyRequest and the three after it, is taken and answered by nothing, as over a gateway | test_ib_async_transport.py::test_the_handshake_goes_unanswered_as_over_a_gateway |
Their suite’s own ib_async.IB() is this package’s in the runner | test_ib_async_transport.py::test_their_suite_builds_this_packages_IB_where_it_builds_its_own |
| Every request their client writes as a message reads back into the message it was, an order field for field, and reaches the engine as the request it names, an order whole whatever parts of the message it writes; an order carries what their client writes and nothing else; a message that does not read is refused with 320, and one naming no request is logged and unanswered; nothing is sent while not connected | test_a_raw_message_is_the_request_it_names.py (171) |
Each of ib_async’s eight notebook subjects has a notebook here, and each is Python that connects through ib_async_dx on a paper session | test_the_notebooks_are_ib_asyncs_subjects.py (2) |
Measured on the engine
These were measured on the engine this package runs on, with the client this package uses, and are recorded with the engine in ib-dx’s evidence, as it stood when they were taken.
| Capability | Status | What was seen |
|---|---|---|
| Market depth through ib_async | ✅ Supported | Inserts, updates and deletes delivered as the venue states them, ten levels a side, through ib_async’s own ticker. Which venues answer is the account’s entitlement |
| Real-time bars through ib_async | ✅ Supported | Five-second bars during regular hours, each with open, high, low, close and volume, alongside a book on the same session, through ib_async’s own reqRealTimeBars |
| A session survives losing its connection | ✅ Supported | A dropped connection is rebuilt on the session already open, with no second factor: five forced drops recovered in 2–8 s, and an eight-hour session rode through its losses unattended |
| A session does not survive its process | ✅ Documented | Ended without logging out, the session was gone forty seconds later, and a later start was answered with a full login rather than a challenge. A restart is therefore a new login. What that costs an account with a second factor has not been measured; a paper session presents none |
| A long session | ✅ Supported | One session held for 175 minutes across a market open: 106,053 quotes, 180,433 trades, 95,985 book rows and 4,148 bars, with no unrequested disconnect |