Multi-strategy execution engine
A local, asyncio-native engine that runs several strategies against Interactive Brokers, with risk checks, reconciliation and recovery designed in.
- Python
- asyncio
- SQLite
- Interactive Brokers API
- Docker
- safety rules, each enforced in code and named in a test
- 16
- safety rules, each enforced in code and named in a test
- tests pass with no broker connection, 37 of them fault injection
- 1,033
- tests pass with no broker connection, 37 of them fault injection
- order cycles on a paper account; the first five found an order-ID bug
- 8
- order cycles on a paper account; the first five found an order-ID bug
Personal research. Not investment advice. Backtested and paper-traded results are hypothetical.
Problem
Most of the ways an automated trader loses money have little to do with the strategy. The process dies while holding a position. An order goes out twice after a crash. A stop never reaches the broker. A circuit breaker blocks the exit it was meant to protect. A look-ahead bug makes a backtest look better than live trading can be.
I wanted an engine that runs several strategies on one Interactive Brokers account and treats those failures as the main design problem. Done means 20 paper-trading sessions with no unprotected position, then a signed checklist before any real money.
Constraints
- Real money is the end state. A missing feature costs nothing. A missing stop can cost the account. Every trade-off leans that way.
- One account, one process. Three strategies share one asyncio event loop and one IBKR connection. There's no message broker and no web UI.
- The broker API has sharp edges. Historical data has pacing limits, order IDs must rise, callbacks can arrive twice or out of order, and the account reports in a different currency from the config.
- A small footprint. Eight runtime dependencies. Adding one needs a written decision.
Design
Bracket Submitter
Writes every leg as LOCAL_PENDING in one transaction, previews margin with whatIfOrder on a clone of the entry, then numbers the orders and transmits them. Only the last child transmits, so the entry can't go live without its stop. A margin preview that times out rejects the order.
assert_paper_environment(self._connection)
with order_context(bracket.entry.order_uuid):
await self._commit_intent(bracket)
# Build, probe, *then* number. The probe is a `placeOrder` at TWS
# and consumes an ID, and IBKR requires each new order ID to exceed
# the last one it has seen (D-035).
unnumbered = build_bracket_orders(bracket, account=self._connection.account)
preview = await self._preview(bracket, unnumbered)
orders = self._number(bracket, unnumbered)
await self._transmit(bracket, orders)The top row decides. Strategies are written in YAML from 14 indicators and 23 operators, and a causality harness checks every one. Each strategy runs as a supervised task on 15-minute bars, and only bars that have closed reach it. It asks the broker which side it holds before it evaluates, so entry rules run only when flat and exit rules only when holding.
The middle row acts. An entry signal passes the circuit breakers, is sized from the distance to its stop, and goes to the bracket submitter. Exit signals take a separate path that no breaker, halt or slippage check can block.
The bottom row keeps state. Every order is written to an append-only SQLite log before it's sent, so after a crash the engine can match what it meant to send against what the broker has.
| Strategy | Instruments | Entry | Stop |
|---|---|---|---|
| Trend following | AAPL, MSFT, NVDA | EMA crossover, with MACD and ADX filters | 2.5 ATR |
| Mean reversion | KO, PEP, JNJ | Below the lower Bollinger band, with RSI and z-score filters | The gap from the middle band to the lower band |
| Breakout | SPY, QQQ, IWM | Above the prior Donchian high after a squeeze, on high volume | 2 ATR |
The strategies are simple on purpose. The engine is the project.
Key decisions
- Disjoint universes instead of netting. Each instrument belongs to exactly one strategy, and a config with overlapping universes refuses to start. Two strategies can never fight over one position.
- Commit before sending. Each order gets a UUID, written to SQLite and carried in its
orderRef, before anything reaches the broker. A crash can then leave only one ambiguous case, and boot recovery settles it by matchingorderRef. Recovery never resubmits an order. - The broker holds the stop. Every entry is a bracket with a server-side stop, and only the last order in the group transmits, so the entry can't go live without its stop. If the engine dies, the position is still protected.
- Exits are never gated. Exits and stops are market orders, built by functions that take no price. Breakers, halts and degraded modes only block entries, and tests read the code's imports to prove the exit path can't consult them.
- Safety by type, not by comment. The kill switch can cancel stops only with a
FlatConfirmedvalue. Only the flat check can make one, after the broker reports no positions. If flat is never confirmed, the stops stay. - No look-ahead by construction. The rule language has no forward shift:
shiftonly accepts 1 or more bars back. A harness also recomputes every indicator and rule on truncated data and checks that past values never change. - Built with an AI coding agent under a written contract. The agent worked one phase at a time, and each phase ended with a gate command that had to exit 0. 39 decision records note where the spec and reality disagreed. Where the spec itself was wrong, the agent stopped and waited for my decision.
Evidence
The code, checked on a clean copy in October 2026:
- 1,033 tests pass: 996 unit tests and 37 fault-injection tests. They run against a scripted fake gateway that can drop the connection, duplicate callbacks and fill an order while it's being cancelled.
- Static checks are clean.
mypy --strictand ruff pass on all 61 modules, and the causality harness passes all three strategies.
On a paper account, in August 2026:
- The read path works. The engine matched all nine symbols to IBKR contracts and ran observe-only cycles on live bars. Replaying those bars one at a time produced 5 signals across the 9 symbols. That showed an empty cycle was a quiet market, not a broken path.
- The order path took five rejections to get right. The first five order cycles failed with "Duplicate order id". It took four diagnoses, three of them wrong, to find the cause in IBKR's documentation: new order IDs must rise, and the margin check uses one too. The engine now numbers orders after the margin check, and refuses a falling ID before it reaches the broker.
- A check that passed for the wrong reason. One cycle ran before the open, never filled, and still printed "CYCLE OK". It also left its bracket working, which filled at the open and was stopped out with nobody watching. The check now needs a real fill, a working stop, a flat finish and nothing left open.
- A full round trip. The seventh cycle filled, held a live stop, exited and finished flat. The eighth matched the broker's commission report, and the realised P&L agreed with the fills and commissions exactly.
Not done yet: the full trading loop hasn't placed an order. On live data it has only run in observe mode, which records orders instead of sending them. The 20-session paper soak hasn't started, and nothing has run on a live account. Live orders are refused at the point of submission, not only at connect.
What I'd change
- Wire in what's built. The run command starts the strategy tasks, an alert sender and a monitor for read-only API mode. Reconciliation, boot recovery, trailing exits, the watchdog, gateway health checks, breaker tripping and the pacing throttle are built and tested, but the loop doesn't start them yet.
- Cancel the bracket after a rule exit. A rule exit is a separate market order, so the bracket's stop and target stay working after it fills. They need cancelling once the position is flat, as the paper-cycle harness already does.
- Make the soak measurable. Store alerts and heartbeats durably, so the 20 sessions can be checked from data rather than from logs.
- Close two gaps with the safety rules. Write rule exits to the event log before sending them, as entries are, and run the causality check at startup as well as in the test suite.