I already burned a month of my life building mt5-httpapi — a full Windows VM booted under QEMU/KVM inside Docker, just so I could talk to a broker that only ships a Windows-only Python wheel. It works. It’s also completely deranged: a hardware-virtualized Windows 11 install, debloated to hell, running MetaTrader 5 in portable mode, so I can hit a REST endpoint instead of writing MQL5. I’d do it again. But when I went to hook up Interactive Brokers next, I sat there for a solid ten minutes braced for the same nightmare — some IBKR-flavored Windows binary, another VM, another 4GB ISO download, another round of “why is the mouse cursor stuck in the noVNC frame.”
It didn’t happen. IB Gateway — the headless trading terminal IBKR ships for API access — runs native on Linux. No .exe. No wine. No wheel that only builds on `win_amd64`. Just a JVM process you can drop straight into a container. So ibkr-httpapi is the sibling project mt5-httpapi always deserved: same shape — curl in, JSON out, one bearer token, one asset-class URL scheme — except the entire stack is Linux containers. No KVM. No Windows ISO. No noVNC-into-a-full-desktop just to click through a Windows setup wizard once a decade.
Talking to a Broker Is Miserable
Interactive Brokers’ API story is the kind of thing that makes you understand why every retail trading bot on GitHub is either abandoned or three years out of date:
- The TWS API only exists if something with a GUI is running — TWS itself, or its headless sibling IB Gateway. There’s no “just call a REST endpoint,” there’s a socket protocol that only speaks to a live desktop session.
- Logging that session in requires clicking through a login screen, accepting terms, occasionally slapping a 2FA push on your phone — every single day, because IBKR force-logs-out the gateway once every 24 hours whether you like it or not.
ib_async(the maintained fork of the abandonedib_insync) gives you a solid asyncio wrapper over that socket, but it hands you rawContractobjects — you’re still building aStockvsOptionvsFuturevsBagby hand for every asset class, with different required fields for each.- IBKR revokes API access for repeat pacing violations. There’s no gentle warning system on their end — you hammer the historical-data endpoint too hard, you eat a suspension. Most homegrown scripts have zero rate-limit awareness until they get bitten once.
- Every market-data call you make either gets thrown away after you read it, or you’re rolling your own CSV/SQLite persistence layer from scratch, again, for the fourth project this year.
- Want RSI or MACD on top of the bars you just pulled? That’s another dependency, another indicator library, another set of edge cases around NaN warmup periods.
None of that is IBKR’s fault exactly — it’s a professional-grade brokerage API, not a toy — but it means every project that wants “give me OHLC for AAPL over HTTP” ends up reinventing the same six things badly. I got tired of reinventing them, so I built the thing once, properly, and put a spec in front of it.
The Stack
The stack is a FastAPI service (ibkrapi/) sitting in front of an IB Gateway container, talking to it over the TWS API socket via ib_async. Nothing exotic — a single shared IB instance, guarded by an asyncio.Lock so concurrent requests don’t stampede the connect call, with exponential backoff (starts at the configured reconnect_backoff, doubles up to reconnect_max_backoff) if the socket ever drops. Every router pulls the connection through one function, get_ib() — first caller waits for the handshake, everyone after just reuses it. When IBKR force-logs-out the gateway for its daily restart, the next request that comes in reconnects on demand instead of the whole API falling over.
The gateway container itself is built from gnzsnz/ib-gateway-docker, with IBC (IB Controller) baked in to handle the login flow headlessly — username/password go in via a gitignored .env.ibkr file, IBC drives the actual login UI on IB Gateway’s behalf so nobody has to click a mouse. One legal wrinkle worth knowing about: IBKR’s installer license forbids redistributing pre-built images containing their binary, so you build the gateway image locally rather than pull some rando’s Docker Hub tag with IBKR’s installer baked in. The docker-compose.yml.example spells this out explicitly and defaults to a mutable :stable tag you’re expected to pin to a digest once you’ve built your own.
Six Asset Classes, One URL Scheme
Every market type gets its own prefix instead of one overloaded “symbol” endpoint that silently guesses what you meant:
/stocks/<symbol> Equities (STK)
/options/<symbol> Options (OPT) — ?expiry=YYYYMMDD&strike=N&right=C|P
/options/<symbol>/chain Full option chain — all strikes × expirations
/futures/<symbol> Futures (FUT) — ?expiry=YYYYMM&exchange=CME
/futures/<symbol>/continuous Continuous future — no expiry needed
/cfd/<symbol> CFDs
/forex/<pair> Currencies (CASH) — IDEALPRO default
/crypto/<symbol> Crypto (CRYPTO) — PAXOS defaultStocks, options, futures, CFDs, forex, crypto — six asset classes, one Contract factory each in ibkrapi/contracts.py, each one taking whatever disambiguating params that class actually needs (options need expiry/strike/right, futures need expiry/exchange, stocks need basically nothing) and filling in the rest from config.yaml:contract_defaults.<class> so you’re not repeating “SMART/USD” on every single call. Where it makes sense for that asset class you get the same four verbs: contract details, a live tick snapshot (with Greeks bolted on for options), historical bars, and historical raw ticks. On top of the six market-data classes there’s cross-asset order entry, a positions list, account summary, and an executions/completed-orders history — all served under the same /v1 prefix.
curl -H "Authorization: Bearer $API_TOKEN"
"http://localhost:8889/v1/stocks/AAPL/rates?duration=30+D&barSize=1d"
curl -H "Authorization: Bearer $API_TOKEN"
"http://localhost:8889/v1/options/AAPL/tick?expiry=20260619&strike=200&right=C"
curl -H "Authorization: Bearer $API_TOKEN"
"http://localhost:8889/v1/futures/ES/continuous?exchange=CME"Spec-First, Not Vibes-First
None of the routers above are hand-written FastAPI decorators scattered across files hoping they stay in sync. api/v1.yaml is the actual source of truth — an OpenAPI 3.1.0 document with 42 operations and 34 schemas — and make generate is the umbrella target that drives three separate generators off it: fastapi-codegen (with custom Jinja templates) spits out ibkrapi/api/_generated/{models.py, routers/*.py} as async stub handlers that delegate straight into the hand-written ibkrapi/api/impl.py; oapi-codegen emits a fully typed Go client at pkg/clients/go/client.gen.go; and openapi-python-client generates a standalone installable Python client package. Nine routers get mounted onto the app in server.py — system, stocks, options, futures, cfd, forex, crypto, orders, history — all under a fixed /v1 prefix. Nobody hand-edits anything under _generated/ or pkg/clients/*; you change the spec, you rerun the generator, the drift-check in CI fails the build if you forget.
Auth, Because This Isn’t a Toy
Bearer token, checked with hmac.compare_digest instead of a plain ==, because a naive string comparison leaks timing information — enough requests and you can bisect a token character by character. Set api_token in config.yaml (or API_TOKEN env) and every request needs Authorization: Bearer <token> or it gets a 401 with the standard error envelope: {code, message, details}. Leave the token empty and the API is wide open to anything that can reach the socket — fine on a loopback-only deployment behind nginx, a very bad idea anywhere else, and the project’s own docs are blunt about that.
Pacing + The Goldmine
Every IBKR-bound call goes through a preemptive rate limiter before it ever touches the socket, because IBKR revokes API access for repeat pacing violations and “sorry, my bot didn’t know” isn’t a defense that works. Three tiers, each with its own sliding-window counter plus a per-contract asyncio.Lock plus a global concurrency semaphore: historical data calls are capped soft-50/hard-55 per 10-minute window (IBKR’s hard limit sits at 60), market_data calls stay under the ~50-msg/sec TWS socket ceiling, and orders get throttled hardest of all — 5/sec, 3 concurrent, because a flood of order calls is almost never intentional. Cross the soft cap and you get a warning in the logs; cross the hard cap and the caller eats a 429 RATE_LIMIT_NEAR envelope with the exact rule, usage, limit, and retry-after baked into details.
Behind that same gate, anything cacheable gets written to disk under data/history/ on every call — bars and ticks go into per-(asset class, symbol, timeframe) CSV files shaped for wickworks ingestion, contract details and option-chain metadata get a long-TTL JSON cache, and every tick/chain snapshot piggybacks onto a historian ledger. None of it deletes anything; it’s explicitly designed as an append-only “goldmine” — mount ./data, back it up, and every call you ever make quietly accumulates into a long-term dataset instead of getting thrown away after you read the response once. Need a guaranteed-fresh read instead of the cache? Every cacheable endpoint takes ?refresh=true, which bypasses the cache read but still writes the fresh result back so the next caller benefits too.
Technical Analysis Without Writing an Indicator Library
Every asset class’s /rates endpoint has a sibling — POST /<class>/<symbol>/rates/ta — that fetches the same bars and hands them off to wickworks, the same TA sidecar mt5-httpapi already uses. RSI, MACD, Bollinger Bands, ADX, ATR, VWAP, Ichimoku, order blocks, fair value gaps, BOS/CHoCH structure breaks, swing structure, support/resistance levels, liquidity zones, session anchors — computed server-side, in one call, on bars you already have. As of the last update this got smarter: the TA path now composes with the same bars cache /rates uses instead of doing its own separate fetch, so a repeated TA request against cached bars costs zero IBKR pacing budget and still comes back with fresh indicator math. wickworks stays strictly primitive-only by design — raw series and structural facts, never “buy” or “sell” — so if you want opinions you build them in your own consumer, not in the sidecar.
Point it at your own instance via wickworks.url in config; leave it empty and /rates/ta just returns a clean 503 instead of pretending to work. The outbound call itself is scheme-allowlisted — only http:///https:// — specifically so a misconfigured URL can’t get coerced into an SSRF against something like file://.
Why the Hell Does a Headless Gateway Need a VNC Surface
Fair question, because IB Gateway isn’t actually headless in the traditional sense — it’s a Java Swing GUI application running under a virtual framebuffer (Xvfb) inside the container. IBC drives that GUI programmatically to log in and click past the daily restart dialogs, and it handles the overwhelming majority of cases without a human anywhere near it. But IBKR occasionally throws a wrench in: a weekly 2FA mobile push that times out, an unexpected dialog IBC’s automation doesn’t recognize, a “new device” confirmation the first time you spin up a fresh gateway instance. When that happens you need to actually *see* the desktop sitting behind Xvfb, and that’s what Dockerfile.novnc is for — a small websockify proxy that fronts the gateway’s VNC port (:5900) over HTTP/WebSocket so you can look at (and click on) the IB Gateway desktop from a plain browser tab, no native VNC client required. It’s not a full virtual machine like mt5-httpapi’s dockurr/windows setup needs — there’s no OS to boot, it’s a 57-line digest-pinned python:3.12-slim image running websockify, whose entrypoint just templates your VNC password into index.html so it autoconnects straight into the session. Point a browser at it when something’s stuck, fix the one dialog, close the tab, forget it exists again until the next weekly 2FA hiccup.
Container Hardening, Because This Touches Money
The API and wickworks containers run cap_drop: [ALL], read_only: true root filesystems with noexec,nosuid tmpfs mounts for the bits that need to write, no-new-privileges:true, and per-service memory/CPU/pid limits. Networking is split into three isolated Docker networks: front (nginx talking to the API), backend (API talking to the gateway, which needs outbound IBKR cloud egress), and an internal: true network for API-to-wickworks traffic that has no egress path at all — wickworks physically cannot phone home even if you wanted it to. The gateway container is the one exception that can’t run fully locked down (Xvfb + a JVM + IBC writing across the filesystem doesn’t tolerate a read-only root), so it gets no-new-privileges as the floor instead. All public base images are SHA-digest pinned, Python deps are hash-locked via uv pip compile --generate-hashes and installed with --require-hashes, there’s a rolling 7-day age-gate on new dependency versions so a just-published supply-chain-poisoned package can’t land same-day, and make audit / make audit-go / make audit-compose run pip-audit, govulncheck, and a grep-based compose scanner (banned settings like privileged, pid:host, Docker socket mounts, unpinned tags, publicly-bound ports) respectively.
The Part Where This Drives a Real Fucking Brokerage Account
I’m not going to bury this in a footnote. This isn’t a market-data toy — POST /orders places a real order against a real IBKR account, and it moves real money the moment it’s accepted. There’s no order-modify endpoint on purpose: to change a resting order you cancel it (DELETE /orders/{orderId}) and place a fresh one, deliberately, rather than mutating a live order in place. DELETE /orders with no ID cancels *every* open order on the account in one shot. POST /options/exercise exercises or lapses real contracts. None of these have an undo button. If you’re pointing an agent or a script at this thing, make it confirm the resolved symbol, side, quantity, and price back to a human before it fires anything mutating, and never let it auto-retry a rejected order — a rejection is a stop sign, not a bug to route around.
Two things soften the blast radius if you want them: TRADING_MODE=paper in .env.ibkr connects to IBKR’s paper-trading gateway (port 4002) instead of live (4001) — paper account numbers start DU, live ones start U, and /accounts will tell you which one you’re actually talking to. And IBC supports READ_ONLY_API=yes, which blocks the trading API at the gateway level entirely if all you want out of this thing is market data and account visibility. Leave api_token empty and this whole surface — market data, positions, and order placement alike — is unauthenticated to anything that can reach the port. Set the token. Bind to loopback. Don’t be the reason someone else’s script places an order on your account.
The Bottom Line
If mt5-httpapi was “make MetaTrader 5 speak HTTP even though Windows is in the way,” ibkr-httpapi is the version where Windows was never in the way to begin with — IB Gateway just runs on Linux like a normal piece of server software, so the whole stack is containers, a spec-first API with generated clients in two languages, pacing that keeps IBKR from banning you, a disk cache that turns every call into permanent data instead of throwaway JSON, and the same wickworks TA sidecar doing the indicator math server-side. Six asset classes, one bearer token, zero Windows VMs. Grab it at github.com/psyb0t/ibkr-httpapi, read the licensing notes before you build the gateway image, and set an API token before you expose this to anything that isn’t localhost. It’s WTFPL-licensed — do whatever you want with it, just don’t blame me when your bot buys 500 puts on the wrong ticker.