Manage Orders, Positions, and Portfolios with the IB API

4 min read

Core idea

Orders are not function calls — they are state machines observed at a distance. You submit an order with placeOrder(order_id, contract, order) and from that moment IB starts emitting status updates: pre-submission, submitted, partially filled, filled, cancelled. Each transition arrives as a separate callback (orderStatus, openOrder, execDetails), and your job is to keep the lifecycle reconciled with your local model of "what is the system trying to do." The same request-callback architecture that delivered market data in the previous topic now drives orders, positions, and PnL — but with stricter correctness requirements, because each callback represents a real economic event.

Author's framing: The IB API uses a consistent request-callback pattern across market data, order management, position tracking, and portfolio details. Mastering that one pattern unlocks the entire surface.

Why it matters

Order IDs are the contract you sign with the broker

nextValidOrderId is delivered to your app at connect time as a single integer; from there, you own the increment. Two clients sharing the same client ID will collide and the broker rejects the second submission. The discipline — request the next valid ID, place the order, increment locally — is the same one banks use for transactional IDs. It is what makes the callbacks unambiguous: when orderStatus(order_id=42, status='Filled', ...) arrives, you know exactly which intent it corresponds to.

Three callbacks describe one lifecycle

openOrder fires when IB acknowledges the order is now resting in the book — it carries the full contract and order back, useful for confirming what was actually placed. orderStatus fires on every state transition — submitted, partial fill, full fill, cancel — and carries filled, remaining, avg_fill_price. execDetails fires per fill, with execution-level detail (exec ID, liquidity flag, exchange). Storing all three in separate SQLite tables — open_orders for intent, trades for executions — gives you the audit trail every live trading operation needs.

Modify-by-cancel-and-replace is the safe pattern

IB technically allows order modification through placeOrder with the same order_id and updated fields. But the surface area is large (which fields can change, which can't), and the behavior under partial fills is subtle. The defensive pattern — cancel_order_by_id(order_id) then send_order(contract, new_order) — is idempotent, easy to reason about, and survives reconnects. reqGlobalCancel() covers the panic-button case: cancel everything across all clients, including manually-entered TWS orders. Use it in shutdown handlers.

Account values, positions, and PnL share one request

reqAccountUpdates(True, account) triggers two callbacks: updateAccountValue (157 account-level metrics like NetLiquidation, BuyingPower, AvailableFunds) and updatePortfolio (per-position market value, average cost, unrealized PnL, realized PnL). Separately, reqPnL(req_id, account, "") triggers pnl(req_id, daily_pnl, unrealized_pnl, realized_pnl). The pattern is identical to historical bars: stash into a dictionary, sleep two seconds, read. The 157 account values give you the raw material for any risk metric — leverage, margin headroom, cash sweep — without additional API calls.

Position dictionaries underpin portfolio strategies

A positions: {symbol: portfolio_data} dictionary populated by the updatePortfolio callback is the foundation of the next topic's portfolio-target order methods (order_target_quantity, order_target_value, order_target_percent). Without live position state, you cannot compute "the delta between where the portfolio is and where it should be." The position dictionary is the closing of the loop from market data → signal → order → fill → position.

Key takeaways

Mental model

Mental model

Practical application

The order-management layer extends the trading app from the previous topic with five capability bundles.

  1. Track nextValidOrderId. Add self.nextValidOrderId = None to IBWrapper.__init__. Override nextValidId(order_id) to call super().nextValidId(order_id) and store it. Every send_order reads self.wrapper.nextValidOrderId, calls placeOrder, then calls self.reqIds(-1) to request the next valid ID from the server.

  2. Override the three order callbacks. orderStatus, openOrder, execDetails all live on IBWrapper. In production, these write to SQLite (open_orders and trades tables); during development, they print. The three callbacks together describe one lifecycle.

  3. Build the order-management methods. cancel_order_by_id(order_id)self.cancelOrder(order_id, ""). cancel_all_orders()self.reqGlobalCancel(). update_order(contract, order, order_id)cancel_order_by_id(order_id); return self.send_order(contract, order). The cancel-and-replace pattern keeps modification semantics simple.

  4. Wire up portfolio retrieval. Add self.account_values = {} and self.positions = {} to IBWrapper.__init__. Override updateAccountValue(key, value, currency, account) and updatePortfolio(contract, position, market_price, ...). Add get_account_values(key=None) and get_positions() on IBClient — each calls reqAccountUpdates(True, self.account), sleeps two seconds, returns the dict.

  5. Wire up PnL retrieval. Separate from reqAccountUpdates, reqPnL(req_id, account, "") triggers pnl(req_id, daily, unrealized, realized). Store keyed by request_id in account_pnl. Returns a snapshot of daily, unrealized, and realized PnL.

Example

Consider a simple rebalance: your app currently holds 100 shares of AAPL and the strategy says the target is 150. You want to buy 50 more, but a market shock during your check pushes the price down 2% — your sizing model now says the target is 200 shares.

Without good order hygiene, you submit the original 50-share order, then immediately submit another 50-share order. Now there are two orders in flight. The first fills at the higher price; the second fills at the lower price; your position is correct (200 shares) but your execution is sloppy and the audit trail is confusing.

With cancel-and-replace, you check app.positions["AAPL"]["position"] and see 100 shares plus one pending order at order_id 47. You call app.update_order(aapl_contract, new_order_for_100_shares, order_id=47). The cancel arrives; the replacement is placed at the new size. openOrder logs the cancellation and the new order; execDetails logs the single fill of 100 shares. One row in trades, one position transition, clean audit. The state-machine discipline is what makes the difference between a system you trust and a pile of imperatively-issued orders you have to forensically reconstruct.

Continue exploring

Tags