fix: keep IBKR sessions usable across API workers and threads - #200
fix: keep IBKR sessions usable across API workers and threads#200johnsontamiwt wants to merge 3 commits into
Conversation
`ib_insync` binds a connection to both the process that opened it (TWS and IB Gateway allow one session per client id) and the asyncio event loop of the thread that created it. The API served IBKR from gunicorn workers that shared neither: each process kept its own in-memory session map and defaulted to client id 1, and each gthread request thread created a fresh event loop. A second worker's connect was therefore answered with "Error 326: client id is already in use" followed by a 20s TimeoutError, and requests alternated between a connected and a disconnected view of the same account as they were balanced across workers. Calls that did reach a live client from another thread ran against an idle loop. Alpaca is unaffected because it is a stateless REST broker with no session and no client id. - `ibkr_trading/session.py`: pin each connection to a dedicated thread whose event loop stays alive, and marshal every call onto it, which also serialises access to the socket. A process-wide registry keyed by (host, port, client_id) reuses the live session so the process cannot answer its own connect with Error 326. - `ibkr_trading/config.py`: resolve connection settings from the stored credential instead of querying credential tables from the route, per MODULE_BOUNDARIES. The UI's 127.0.0.1 placeholder is unreachable from a container, so it falls back to the saved host; an explicit host or port is always honoured, and a blank client id no longer raises. The credential's `ibkr_client_id` belongs to the order session (default 7), so the UI session never borrows it. - `routes/ibkr.py`: reconnect on demand from the stored credential, so every endpoint works regardless of which worker serves it. A failed connect from inside a container now explains the loopback trap instead of only suggesting that TWS may be down. - `ibkr_trading/account.py`: `IB.accountSummary()` returns tag rows whose values are strings paired with a currency, but the broker-account UI reads flat numeric fields -- the shape the Alpaca route already returns -- so the account panel rendered every metric as "--" while showing the account id. Expose those flat fields alongside the existing `summary` map, which stays for agent/MCP callers. Missing tags are omitted rather than zeroed, since a margin requirement of 0 reads very differently from "unknown", and a failed summary read no longer reports success with an empty payload. - `live_trading/account_snapshot.py`: read IBKR over one short-lived session instead of the crypto path's swap+spot pair (which collides on the same client id), and serialise snapshots so concurrent refreshes cannot answer each other with Error 326. How to test: - `pytest tests/test_ibkr_session_and_account.py` covers session reuse, thread affinity, failed-connect cleanup, credential merge precedence, and summary flattening. - Verified end to end against a live IB Gateway paper account: connect, repeated connect, account, positions, orders, status and disconnect all succeed, two concurrent account snapshots return without warnings, and no Error 326 remains in the logs. The account panel renders net liquidation, cash, buying power and both margin requirements. Backward compatibility: response fields are additive -- `summary` and `account` are unchanged. `GET /api/ibkr/account` now returns 400 instead of 200 when the summary read fails; the OpenAPI contract already documents 400 for this operation, and regenerating the spec produces no IBKR diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against a live micro gold future (MGCV6, multiplier 10) held with a protective stop. `Position.avgCost` is reported per contract, i.e. already multiplied by the contract multiplier, so using it as the entry price showed a 4,433.30 fill as 44,332.96. Report `avgPrice` per unit alongside the unchanged `avgCost`, and use it for the account snapshot's entry price. Stocks have a multiplier of 1 and are unaffected. A stop order carries its trigger in `auxPrice` and leaves `lmtPrice` at 0, so a protective stop rendered with no price at all. Report `stopPrice` and a `price` field holding whichever of the two governs the order. Futures also share a symbol across expiries, so carry `localSymbol` and `lastTradeDate` and prefer the former when naming a position or order. Expose the order id under the `id` key order tables key rows on, so an open order can be identified and cancelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up commit: verified the account panel against a live futures position rather than an empty paper account, which exposed three more payload problems on the same surface. Held: 1x MGCV6 (Micro Gold OCT'26, multiplier 10) filled at 4433.30, with a protective stop
Also carrying Verified end to end again on the same account — entry price All fields are additive: |
`pending_order_position_sync` reads `IBKRClient.get_positions()` and fed `avgCost` into the L1 account mirror, so a futures entry was recorded inflated by the contract multiplier. Extract `position_entry_price()` into `ibkr_trading/account.py` and use it from both the account snapshot and the position sync, so the two paths cannot drift again. The reconciliation key deliberately stays on `symbol`/`ib_symbol` -- it has to match the symbol the strategy stored -- so only the price changes. Alpaca's `avgCost` is `avg_entry_price` and is already per unit, so that branch is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Third commit: Scoped deliberately:
One correction to my earlier comment: the follow-up commit added 4 tests, not 7. Current totals: 21 in Re-verified on the live account: |
|
Gentle ping on the CI state: the checks here are sitting at To make that approval cheap, here is what was verified locally against this repo's own tooling:
Scope is 3 commits across 8 backend files, and the response changes are additive — Happy to rebase, split the commits, or narrow the scope if that would make review easier. |
What changed and why
ib_insyncbinds a connection to two things: the process that opened it (TWS and IB Gateway allow one session per client id) and the asyncio event loop of the thread that created it. The API served IBKR from gunicorn workers that shared neither — each process kept its own in-memory session map and defaulted to client id 1, and eachgthreadrequest thread created a fresh event loop in_ensure_event_loop().The result, with the stock
GUNICORN_WORKERS=2:Worker A holds client id 1; worker B's connect is refused. Because requests are balanced across workers,
GET /api/ibkr/statusalternates between a connected and a disconnected view of the same account, and the data endpoints fail at random. Calls that did reach a live client from another thread ran against an idle loop instead.Alpaca is unaffected throughout: it is a stateless REST broker with no session and no client id, which is why the same UI works there.
A second, independent bug surfaced once the connection held: the account panel showed the account id but rendered every metric as
--.IB.accountSummary()returns tag rows whose values are strings paired with a currency, whileBrokerAccountCardreads flat numeric fields (net_liquidation,buying_power, …) — the shape the Alpaca route already returns.Changes
ibkr_trading/session.py(new)(host, port, client_id)reuses the live session, so the process cannot answer its own connect with Error 326.ibkr_trading/config.py(new)MODULE_BOUNDARIES.md). The UI's127.0.0.1placeholder is unreachable from a container, so it falls back to the saved host; an explicit host or port is always honoured; a blank client id no longer raises. The credential'sibkr_client_idbelongs to the order session (default 7), so the UI session never borrows it.ibkr_trading/account.py(new)summarymap which stays for agent/MCP callers. Missing tags are omitted rather than zeroed — a margin requirement of0reads very differently from "unknown".routes/ibkr.pylive_trading/account_snapshot.pyHow to test
cd backend_api_python pytest tests/test_ibkr_session_and_account.py14 tests covering session reuse, thread affinity, failed-connect cleanup, credential merge precedence, and summary flattening.
Full suite is unchanged: 47 failed / 1335 passed before, 47 failed / 1349 passed after — the same pre-existing failures, plus the new tests.
Verified end to end against a live IB Gateway paper account (backend in Docker, Gateway on the host):
connectwith an empty body, with the UI's127.0.0.1defaults, and twice in a row — all 200; the second connect previously produced Error 326account,positions,orders,status,disconnect— all 200/connect— 200, via on-demand reconnectfetch_account_snapshotcalls — no warnings, no Error 326grep -c "Error 326"over the backend logs —0ruff checkpasses on all touched files.Backward compatibility
summaryandaccountare unchanged, so agent/MCP callers are unaffected.GET /api/ibkr/accountnow returns 400 instead of 200 when the summary read fails. The OpenAPI contract already documents 400 for this operation, andscripts/export_openapi.pyproduces no IBKR diff.Note for maintainers
Operators running more than one gunicorn worker were hitting this constantly. The fix makes a multi-worker deployment behave correctly, but each worker still holds its own IB session under the same client id policy. Moving the session into the trading worker (which already owns runtime leases) and reaching it over the command queue would give the deployment a single IB connection — happy to open a discussion if that direction is wanted.