Construct and preview an order
An order preview is a server-side credit and margin check, not an order acknowledgement and not a promise that a later submission will be accepted or filled. This page constructs one paper-account limit order and calls only whatIfOrderAsync; it never calls placeOrder directly.
Complete connection and readiness, account scope, contract qualification, and the market-data/history checks appropriate to the strategy first. A what-if uses the order protocol, so the API connection cannot be read-only. Use a paper session and keep live trading disabled.
Minimal paper-account preview
The transmit=True field below is counterintuitive but required by the what-if protocol. Pinned ib_async copies the order, sets whatIf=True only on that copy, and sends it for preview. IBKR evaluates the copy instead of routing it to an order destination. Setting transmit=False causes Message 413.
import asyncio
from math import isfinite
from ib_async import Contract, IB, LimitOrder, OrderState, Stock
async def main() -> None:
ib = IB()
try:
await ib.connectAsync(
host="127.0.0.1",
port=4002,
clientId=12,
timeout=10,
readonly=False,
raiseSyncErrors=True,
)
ib.RaiseRequestErrors = True
expected_accounts = frozenset({"PAPER_ACCOUNT_A"})
accessible_accounts = frozenset(ib.managedAccounts())
if accessible_accounts != expected_accounts:
raise RuntimeError("authorized paper account scope mismatch")
search = Stock(
"AAPL", "SMART", "USD", primaryExchange="NASDAQ"
)
qualified = await ib.qualifyContractsAsync(search)
if len(qualified) != 1 or not isinstance(qualified[0], Contract):
raise LookupError("contract did not resolve uniquely")
contract = qualified[0]
order = LimitOrder(
action="BUY",
totalQuantity=10,
lmtPrice=180.00,
account="PAPER_ACCOUNT_A",
tif="DAY",
outsideRth=False,
transmit=True, # Required for what-if; the copied order is not routed.
orderRef="preview:rebalance:example-001",
)
future = ib.whatIfOrderAsync(contract, order)
state = await asyncio.wait_for(asyncio.shield(future), timeout=15)
if not isinstance(state, OrderState):
raise RuntimeError("preview did not return OrderState")
numeric = state.numeric()
required = (
numeric.initMarginChange,
numeric.maintMarginChange,
numeric.commission,
)
if state.warningText or any(
value is None or not isfinite(value) for value in required
):
raise RuntimeError("preview warning or incomplete numeric result")
if state.commissionCurrency != contract.currency:
raise RuntimeError("unexpected preview commission currency")
print(numeric.initMarginChange, numeric.commission)
finally:
# Disconnect also clears an unresolved what-if request after a timeout.
ib.disconnect()
asyncio.run(main())
IB.whatIfOrderAsync returns an OrderState when the protocol's openOrder callback carries a usable initial-margin change. IB.whatIfOrder is the blocking wrapper over the same request.
The high-level call does not modify the original order: its whatIf, orderId, clientId, and permId remain unchanged. That isolation does not make the object safe for later placement by itself; re-run all placement gates against current account, contract, market, and policy state.
Construct one explicit intent
LimitOrder sets orderType="LMT", the action, quantity, and limit price on an Order. The remaining fields are still the caller's responsibility.
For this bounded workflow:
- use only
BUYorSELL, a positive finite quantity, and a positive finite limit within application-configured caps (the example caps both at1_000_000, far below protocol unset sentinels); - set the paper account explicitly and verify it is in the current connection's exact authorized scope;
- use
tif="DAY",outsideRth=False, and keepoverridePercentageConstraints=False; - require the caller to supply a unique, stable, non-secret
orderRefto correlate the immutable intent and preview; - leave
orderId,clientId, andpermIdat zero before preview; - do not combine a per-account order with
faGroup,faMethod,faPercentage,faProfile, ormodelCodein this example; - leave
whatIf=Falseon the original andtransmit=True; the high-level method owns the preview copy and flag.
A limit order provides a price boundary but may never fill. The preview does not validate the economic intent, future marketability, later buying power, short availability, venue support, exchange hours, or fill price. Do not replace it with a market order merely to make the preview easier.
Read OrderState without inventing zeroes
OrderState contains three before/change/after triplets—initial margin, maintenance margin, and equity with loan—plus commission estimate/range, currency, status, warning text, and completed-order fields. Fields can be empty or use protocol unset sentinels.
OrderState.numeric converts the nine margin/equity strings and three commission values to rounded floats. Empty, invalid, or unset values become None; they do not become zero. Treat a non-OrderState result, missing required margin/commission fields, non-finite values, any warning, or an unexpected commission currency as a failed preview. Estimates can change before placement and do not reserve funds.
Persist the preview decision separately from any future order acknowledgement:
- immutable intent ID and
orderRef; - qualified contract identity and routing fields;
- exact account/model/allocation scope;
- action, quantity, type, limit, TIF, and relevant flags;
- preview request and response times;
- raw
OrderState, normalized values, warnings, and correlated errors; - policy version and the application decision (
approved,rejected, orunknown).
Never treat a successful preview as a durable order ID, permId, submission, acceptance, or execution.
Production-pattern preview owner
There is no public what-if cancellation method in pinned ib_async. A timeout or caller cancellation can leave the request unresolved inside the current connection epoch. The owner below shields the protocol future, allows only one request, and marks the epoch for recycling after timeout/cancellation. Once marked, it refuses all further previews; the connection owner must disconnect before any later order operation.
from __future__ import annotations
import asyncio
from math import isfinite
from ib_async import Contract, IB, LimitOrder, OrderState, Stock
MAX_PREVIEW_QUANTITY = 1_000_000
MAX_PREVIEW_LIMIT = 1_000_000
def validate_preview_order(
order: LimitOrder, accessible_accounts: frozenset[str]
) -> None:
if order.account not in accessible_accounts:
raise ValueError("preview account is not accessible")
if order.action not in {"BUY", "SELL"}:
raise ValueError("preview action must be BUY or SELL")
if (
not isfinite(float(order.totalQuantity))
or order.totalQuantity <= 0
or order.totalQuantity > MAX_PREVIEW_QUANTITY
):
raise ValueError("preview requires a positive finite quantity")
if order.lmtPrice is None or not isfinite(float(order.lmtPrice)):
raise ValueError("preview requires a positive finite limit")
if float(order.lmtPrice) <= 0 or float(order.lmtPrice) > MAX_PREVIEW_LIMIT:
raise ValueError("preview requires a positive finite limit")
if order.orderType != "LMT" or order.tif != "DAY":
raise ValueError("preview requires a DAY limit order")
if order.outsideRth or order.overridePercentageConstraints:
raise ValueError("preview bypasses are not allowed")
if not order.transmit or order.whatIf:
raise ValueError("high-level what-if requires transmit=True and whatIf=False")
if order.orderId or order.clientId or order.permId:
raise ValueError("preview order must not reuse order identifiers")
if not order.orderRef or len(order.orderRef) > 64:
raise ValueError("preview requires an intent-specific orderRef")
if any(
(
order.faGroup,
order.faProfile,
order.faMethod,
order.faPercentage,
order.modelCode,
)
):
raise ValueError("per-account preview cannot include allocation fields")
def build_preview_order(
account: str,
accessible_accounts: frozenset[str],
quantity: float,
limit_price: float,
intent_ref: str,
) -> LimitOrder:
order = LimitOrder(
action="BUY",
totalQuantity=quantity,
lmtPrice=limit_price,
account=account,
tif="DAY",
outsideRth=False,
transmit=True,
orderRef=intent_ref,
)
validate_preview_order(order, accessible_accounts)
return order
def validate_preview_state(
state: object, expected_currency: str
) -> OrderState:
if not isinstance(state, OrderState):
raise RuntimeError("preview did not return OrderState")
numeric = state.numeric()
required = (
numeric.initMarginChange,
numeric.maintMarginChange,
numeric.commission,
)
if state.warningText:
raise RuntimeError("preview returned warning text")
if any(value is None or not isfinite(value) for value in required):
raise RuntimeError("preview returned incomplete numeric fields")
if state.commissionCurrency != expected_currency:
raise RuntimeError("preview returned unexpected commission currency")
return state
class WhatIfPreviewOwner:
def __init__(
self,
ib: IB,
accessible_accounts: frozenset[str],
expected_currency: str,
timeout: float = 15,
) -> None:
if not isfinite(timeout) or timeout <= 0:
raise ValueError("preview timeout must be positive and finite")
self.ib = ib
self.accessible_accounts = accessible_accounts
self.expected_currency = expected_currency
self.timeout = timeout
self.pending: asyncio.Future[OrderState] | None = None
self.connection_recycle_required = False
async def preview(
self, contract: Contract, order: LimitOrder
) -> OrderState:
if self.connection_recycle_required:
raise RuntimeError("connection recycle required before order work")
if self.pending is not None:
raise RuntimeError("what-if preview already pending")
validate_preview_order(order, self.accessible_accounts)
self.pending = self.ib.whatIfOrderAsync(contract, order)
try:
state = await asyncio.wait_for(
asyncio.shield(self.pending), timeout=self.timeout
)
return validate_preview_state(state, self.expected_currency)
except (asyncio.TimeoutError, asyncio.CancelledError):
self.connection_recycle_required = True
raise
finally:
if self.pending is not None and self.pending.done():
self.pending = None
async def main() -> None:
ib = IB()
try:
await ib.connectAsync(
host="127.0.0.1",
port=4002,
clientId=12,
timeout=10,
readonly=False,
raiseSyncErrors=True,
)
ib.RaiseRequestErrors = True
expected_accounts = frozenset({"PAPER_ACCOUNT_A"})
accessible_accounts = frozenset(ib.managedAccounts())
if accessible_accounts != expected_accounts:
raise RuntimeError("paper account scope mismatch")
search = Stock(
"AAPL", "SMART", "USD", primaryExchange="NASDAQ"
)
qualified = await ib.qualifyContractsAsync(search)
if len(qualified) != 1 or not isinstance(qualified[0], Contract):
raise LookupError("contract did not resolve uniquely")
order = build_preview_order(
account="PAPER_ACCOUNT_A",
accessible_accounts=accessible_accounts,
quantity=10,
limit_price=180.00,
intent_ref="preview:rebalance:example-001",
)
owner = WhatIfPreviewOwner(
ib, accessible_accounts, expected_currency="USD", timeout=15
)
state = await owner.preview(qualified[0], order)
numeric = state.numeric()
print(numeric.initMarginChange, numeric.commission)
finally:
# Required after every example run, and mandatory if the owner timed out.
ib.disconnect()
if __name__ == "__main__":
asyncio.run(main())
The expected callback sequence is request ID allocation, copied order with whatIf=True, protocol placeOrder, then openOrder with OrderState. The first usable openOrder removes and resolves the request future; a repeated callback for that request ID no longer has a request future to change. A terminal request-correlated error can settle the future before a later openOrder. Pinned IB.RaiseRequestErrors=False turns such an error into an empty result, so both examples set it to True; the production result gate still rejects any non-OrderState value. Nonterminal warnings can precede completion and must remain in correlated logs; OrderState.warningText fails the policy gate. Ordinary order-status/fill events are not the completion contract for this preview.
Repeated application retries are not harmless: IBKR sends each preview to its credit system and publishes no clear what-if rate limit, so serialize requests and avoid bulk polling.
Failure matrix
| Signal | Preview meaning | Owner action |
|---|---|---|
| Timeout or task cancellation | Outcome is unknown and there is no public per-request cancel. | Mark the connection epoch unusable for further order work, disconnect, reconnect, and rebuild readiness before retrying. |
Message 107 | Order fields are incomplete. | Reject the intent; do not fill missing fields from guesses. |
Message 110 | Limit price violates the contract's minimum increment. | Refresh contract market-rule details, rebuild the price, and preview a new immutable intent. |
Message 111 | TIF and order type are incompatible. | Reject and rebuild the combination. |
Message 360 | Smart-combo what-if is unsupported. | Do not infer margin from a partial or different structure. Use an authorized alternative review process. |
Message 413 | What-if copy has transmit=False. | Keep the original non-what-if flag false but pass transmit=True to the high-level preview path. |
Message 424 | An advisor order is missing allocation. | Fail closed; resolve account/group/profile scope before another preview. |
Non-OrderState, warning text, missing/non-finite margin or commission, or wrong currency | The preview is incomplete or requires human/policy review. | Persist the raw state, classify the result as rejected or unknown, and do not place from it. |
Evidence and offline boundary
Behavioral claims above are backed by the current IBKR Campus what-if section, allocation overview, and message codes, plus pinned ib_async 2.1.0 what-if request construction, asynchronous copy and send behavior, callback completion, and order types/state conversion.
CI compiles and signature-checks both examples. Offline tests prove the high-level copy is isolated from the original, the copied flags and request ID reach the pinned protocol seam, openOrder resolves the preview, construction gates fail closed, and timeout poisons the owner until connection recycling. They do not connect to TWS, perform a credit check, validate a real account/contract/order combination, or establish that a later order would be accepted. No test or example calls IB.placeOrder.