The one design decision
The LLM never touches the order API.
market data ──> Context Builder (deterministic)
│
v
Agent (LLM) ──> Proposal
│
v
Risk Gate (pure Python) ──reject──> journal + reason
│ accept
v
Broker (ib_async) ──> IBKR paper
A proposal is journalled before the gate runs, so agent judgment is scored separately from execution luck. A rejected proposal that would have been a winner is a finding about the limits, not about the agent.
First connection: run preflight
python preflight.py --port 4002
Checks connection, paper-account prefix, NAV, contract lookup, whether quotes come back live or delayed (i.e. whether subscription sharing actually took effect), historical-data permission, and the volume-units question below. Places no orders. Each check catches its own failure so one break doesn't mask the rest.
Market data on a paper account
Sharing is opt-in: Client Portal → Settings → Account Settings → Paper Trading Account → Yes, then choose which live username's subscriptions to share. You cannot consume the shared data on live and paper simultaneously.
reqHistoricalData requires a Level 1 streaming subscription — delayed mode
does not bypass it, and without one you get error 162. IBKR's free
US Real-Time Non-Consolidated Streaming Quotes (Cboe One + IEX) satisfies
this, but it is not the NBBO, and the SEC Vendor Display Rule means TWS won't
show it in the order ticket without a paid consolidated subscription.
The volume-units question — unresolved
ExecutionModel.max_participation caps orders at a share of bar volume, which
assumes that number is consolidated share volume. Two things can make it wrong,
neither of which announces itself:
- IBKR reports historical stock volume in round lots (×100), not shares.
- A non-consolidated feed may carry venue-limited volume (~10× low).
Either would make the model far more restrictive than intended — 100× or 10×.
preflight.py pulls a liquid name and reports which case it's looking at.
Settle this before running any strategy whose results you intend to believe.
Paper-only enforcement
Two real checks and one weak one, because this is the only bug here that costs money:
IBKRPaper.connectdrops the connection unless every managed account starts withDU/DF. This is the load-bearing check.- The gate rejects
NOT_PAPER_ACCOUNTon a separate code path, so a caller that bypasses the broker wrapper is still stopped. IBKRPaper.__init__refuses any port outside{7497, 4002}. Weaker than it looks, and originally overstated here: the port proves nothing about the session. Gateway's API port is a config value, and a Gateway logged into a live account will happily serve it on 4002. Observed in practice on 2026-09-08, where port 4002 returned live accounts. The port check only catches typing the live port; the account prefix is what actually protects you.