Live mode and its limits

Live mode and its limits

The simulator is one implementation of the engine’s venue interface, not part of the strategy. The IBKR implementation sits behind the same interface and maps canonical orders to Client Portal API requests, then maps broker order, fill, position and account reports back into the engine’s vocabulary. This is why OnBar, OnFill, ctx.Position, and ctx.Account have the same shape in a backtest and at a broker.

The command-line switch is --mode live; --mode backtest is the default. A live invocation adds the venue endpoint and its durable reconciliation ledger:

bin/algo run ... \
  --mode live \
  --venue-url http://127.0.0.1:5000 \
  --venue-emulated \
  --ledger var/orders.json \
  --on-orphan exit

--venue-account can pin an IBKR account; when omitted the adapter asks the venue for the selected account. The ledger gives orders durable canonical references under a persisted deployment identity (The order ledger; --deployment names the label, --ledger-migration admits a ledger written before identity existed) and lets startup compare expected orders and positions with the venue. The only current orphan policy is exit: an unexplained broker order refuses startup rather than being adopted or ignored.

This build’s live mode is an integration and reconciliation harness, not yet a general real-time runner. It requires --venue-emulated, a single series, bar resolution, no replication, no intra-bar path, and a --limit-fill other than through. The supplied bin/ibkr-emulator makes that path deterministic for testing. A non-emulated real-time feed and several live corporate-action cases remain deferred; the engine rejects them explicitly. Live market data is planned to come from Databento only; IBKR is the order venue, not a live data source.

A live venue applies its own GTC rule, so --gtc-expiry configures the fill simulator alone (GTC quarter-end cancellation): a live run given a non-default value prints one operator note, algo: --gtc-expiry ibkr-quarter configures the fill simulator; the live venue applies its own GTC rule and the ledger records each GTC order's deadline regardless, and its report’s simulator: line is unchanged (no simulator ran; the summary records never). What the live loop does instead (I13a, GLE-374): the ledger line of every GTC order it emits records the venue’s deadline, gtc_deadline, the last calendar day of the quarter after the submission bar’s trade date (absent on a non-GTC order and on lines written before the field existed), so a restart keeps it; and a cancel the adapter observes at the venue, which it reports as Explicit when the run asked for it and as Venue otherwise (Tickets and cancels at the IBKR venue), is delivered to the strategy as GTC_EXPIRED when the order’s ledger line carries a deadline, the loop is on a bar whose trade date is on or after it, and the loop did not itself ask the venue to cancel that order. “On or after” because IBKR cancels at the close of the deadline’s final trading day, so a daily-bar run observes the cancel on the deadline’s own trade date, while the emulator produces it before the next bar; a strategy’s own cancel on the deadline date stays Explicit, and an unrequested one before the deadline stays Venue. The end-of-run sweep is the loop’s own: its cancels carry Shutdown and are marked as requested, so a run whose last bar is the deadline date ends with a Shutdown cancel, not a GTC_EXPIRED one, in both loops. Without a ledger nothing is recorded and an observed cancel keeps the adapter’s reason. The emulator takes the same setting in its JSON config, "gtcExpiry": "never" or "ibkr-quarter" (empty is never; another value refuses startup naming it), mapped onto its embedded simulator; it is part of the backtest/emulator cross-check contract like capital, commission and slippage, and must equal the backtest’s --gtc-expiry for the two to agree. An order seeded through /admin/seed anchors at the seed clock’s trade date; a prior lifecycle’s true deadline lives in the engine’s ledger, not in the double.

A market-on-close order goes to IBKR as a native MOC ticket. The emulator settles it at the close of the bar it was submitted on, the price a backtest uses, so an emulated live run and a backtest of the same strategy agree. The ledger records the order as MOC, so a restart that finds one still resting at the broker adopts it as a market-on-close order. On a real account an MOC rests until the closing auction and fills at the auction price; settling at the observed bar close does not model that. MOO, LOC and StopTouch have no live mapping: the venue rejects each such order at submission and the strategy receives a rejection cancel.

A continuous futures symbol is refused in live mode before any adapter or strategy spawn, whichever way it is spelled: a bare root such as fut:XCME:ES or a marketfeed composite such as fut:XCME:ES:cont (an @ES alias, the retired registry’s symbol, is no continuous form since ER5 and takes the plain path). The message names the symbol and says that live continuous futures runs are refused (owner decision 2026-10-04, GLE-228 Decision 7): marketfeed does not stream a composite, so a live run trades a dated contract such as fut:XCME:ES:Z26. Dated contracts and equities are unaffected, as is every backtest.

Tickets and cancels at the IBKR venue

Since increment I4 unit 4b of the order-lifetime programme (GLE-378, 2026-10-07) the IBKR adapter (engine/venueibkr over internal/ibkrclient) follows IBKR’s Client Portal Web API reference for the order ticket and the cancel, and since I6 unit 3b (GLE-436, 2026-10-08) for the order reply messages IBKR asks it to confirm (Order reply messages, below). The rules hold on both lifetime profiles; a v2 run also passes each cancel answer through to the strategy (Lifecycle turns).

The ticket. Every order ticket carries "manualIndicator": false. IBKR’s ticket schema: “Required for US Futures; indicates if order was originated manually (True) or automatically (False)”. The engine is an automated system and the adapter has no product classification, so it sends the flag on every ticket whatever the product. The ticket’s tif takes IBKR’s documented list, “Time in force of the order ticket (DAY, IOC, GTC, OPG, PAX)”, which has no FOK (nor GTD, which the SDK does not define): the adapter denies an FOK order locally, with a reason naming those five values, before any ticket is sent. The live loop admits only Day and GTC and stops the run on any other time in force before the adapter is called, so the adapter’s denial guards its direct use.

The cancel request. A cancel is DELETE /iserver/account/{accountId}/order/{orderId}?manualIndicator=false, IBKR’s documented path and parameter: “For all orders for US Futures products, clients must submit this flag to indicate whether the order was originated manually (by a natural person) or automatically (by an automated trading system transmitting orders without human intervention). […] Orders for USFUT products that do not include this field will be rejected.” The path is the singular order. The client sent the plural /orders/{orderId} before unit 4b, a path the emulator served and IBKR does not document, so a cancel against a real gateway would have failed; the client now uses the singular path and the emulator serves both.

The cancel classes. IBKR’s success reply acknowledges the request, not the cancellation: orderCancelSuccess “Acknowledges IB’s acceptance of the request to cancel the order. Does not report whether the cancellation can or will ultimately be enacted.” So the adapter does not answer Confirmed; the confirmation is the order’s status on a later poll. CancelOrder (engine/venueibkr/cancel.go) classifies each reply, in this order:

ReplyOutcomeReason
HTTP 4xxRefusedthe error’s message, else its body, else the error text (ibkr: http 403:)
HTTP 5xx, a transport or decoding failureUnknownthe error text
HTTP 200 with an error body, such as {"error":"OrderID 123456 doesn't exist"}Refusedthe error text; an error outranks a msg
HTTP 200 with {"msg":"Request was submitted", ...}Pending, status PendingCancelRequest was submitted
any other HTTP 200 body, an empty one includedUnknownunrecognised cancel reply for order <id> (msg "<msg>")

A 4xx is the venue answering this request and saying no (an unknown id, a pacing refusal, a session that is not authenticated); a 5xx or a lost connection leaves the request’s fate open. Every Refused and Unknown answer has a reason: an error with empty text reads ibkr: cancel failed with an empty error, and a nil API error ibkr: nil API error. Every answer carries the IBKR order id and no attempt or remaining quantity, which the adapter does not know (the live loop fills in the attempt). CancelOrder also returns the error of a failure that got no HTTP answer, beside its Unknown result, so a caller that checks the error alone, as the teardown does, still sees it.

HTTP 429. Since I6 unit 3a (GLE-435) an HTTP 429 Too Many Requests on the place or the cancel endpoint is a pacing refusal, marked Paced: a submission is Denied with the error text (ibkr: http 429: Too many requests), where any other failed place call is still Unknown, and a cancel request is Refused, as every 4xx is. The adapter reads a 429 as IBKR’s rate limiter refusing the request before it was processed, an assumption IBKR’s reference does not settle, and a v2 live run’s transport scheduler sends the request again (The transport scheduler). Since I6 unit 3b a 429 answering the confirmation of an order reply message is a pacing refusal too, a Denied submission marked Paced (Order reply messages).

PendingCancel is live. IBKR’s PendingCancel status maps to a venue status of its own, sim.OrderPendingCancel, no longer to Cancelled: the venue has accepted the cancellation and not enacted it, and the order can still execute. The poll emits no cancel for it; the cancel is emitted when the live-orders report says Cancelled, with the quantity still working there (remainingQuantity). Reconciliation (engine/recon) counts it as live too: startup matches it as it matches a working order, and the in-flight check and the open-orders poll read it as at the broker, not terminated. A v2 strategy sees the wire’s PendingCancel.

Whose cancel. An observed cancel carries Explicit when this adapter asked for it and Venue (reason 8) otherwise: an expiry, a cancel made at the broker, anything the venue did of its own accord. The adapter claims a cancel before the DELETE leaves. The claims are counted per IBKR order id under a lock of their own, not the poll’s, so a cancel does not wait behind a poll’s HTTP calls; a refused request withdraws its own claim and no other, so an earlier unrefused request’s claim stands; and an order’s claims are dropped once its cancel is emitted. A poll that observes the cancel before the reply is back, as a background poller can, therefore still attributes it to the strategy. The cancel de-duplication keys on the IBKR order id rather than the order reference: two attempts of one label share a cOID without a ledger, and each emits its own cancel, once.

Fills before cancels. PollOnce reads the live orders before the trades and emits the fills before the cancels. Every execution that preceded an order’s cancel is then among the trades read, so a cancel does not reach the strategy ahead of a fill of its order, and its remaining quantity agrees with the fills delivered. Partial executions arrive as one fill per IBKR trade record, each with its own execution_id as ExecID: 4 then 6 of 10 are two fills, and with a ledger each carries the attempt and the quantity still working after it (RemainingQuantity 6, then 0), stamped from the ledger’s record. Without a ledger the adapter leaves those two fields zero; live mode requires a ledger, so only tests run without one.

The emulator’s answers. The emulator answers the cancel with IBKR’s documented bodies, all HTTP 200. A working order is cancelled at once and acknowledged with {"msg":"Request was submitted","order_id":<id>,"conid":<conid>,"account":"<account>"}, which the adapter classifies Pending and confirms from the Cancelled status on its next poll, the path a real gateway takes. An unknown id gets IBKR’s documented example, {"error":"OrderID <id> doesn't exist"}. An order already terminal gets {"error":"OrderID <id> is already <status> and cannot be cancelled"}, the emulator’s own text: IBKR documents no body for that case and may acknowledge the request instead, which the adapter handles as Pending followed by the order’s own terminal status. The manualIndicator parameter is accepted and ignored.

On the v1 profile. Two things change on purpose: a Pending answer is an accepted request, so the strategy gets no CancelReject and the loop marks the order requested, as it does a confirmed one; and an observed cancel the run did not ask for is Venue rather than Explicit (the ledger’s GTC classification accepts both). The v1 loop records no cancel intent in the ledger.

Teardown. After the last bar the live loop, on either profile, reads the order reports (a failure is fatal, engine: live teardown: order status: <error>) and sweeps them in report order. A swept order gets one Shutdown cancel stamped at the last bar’s close, as the simulator’s end-of-data sweep stamps it:

  • a Working order gets one cancel request; an error from it is fatal (engine: live teardown: cancel <id>: <error>), and a Confirmed or Pending answer gets the Shutdown cancel;
  • an order already PendingCancel is treated as requested: no second request, and the Shutdown cancel;
  • a cancel answered Refused or Unknown is not a shutdown. After the sweep the loop reads the order reports again: an order now PendingCancel gets the Shutdown cancel; one terminal, or no longer reported, gets none, since the venue ended it some other way; one still Working is a leak, and fatal, engine: live teardown: order <id> still working after its cancel was <outcome>: <reason> with <outcome> refused or unknown, and the run does not record the order terminated in the ledger;
  • every other status is skipped.

The loop marks each swept order requested and records its Shutdown cancel through the ledger, so a restart does not resurrect it. The sweep covers every working order the account reports (see the known limits under Lifecycle turns).

Order reply messages

Since I6 unit 3b of the order-lifetime programme (GLE-436, 2026-10-08) the adapter confirms an IBKR order reply message only when the deployment allows every message id it carries, and declines any other (engine/venueibkr/reply.go). Until then it confirmed every reply (confirmed: true), waiving each of IBKR’s order precautions on the engine’s behalf. The policy is the adapter’s, so it holds on both lifetime profiles.

IBKR’s reply flow. The place call (POST /iserver/account/{accountId}/orders) can answer HTTP 200 with order reply messages instead of the order: “An array containing objects that each deliver the order reply messages emitted against one order ticket in the submission request’s array.” Each carries an id, “The replyId UUID of the order ticket’s emitted order reply messages, used to confirm them and proceed”, a message array of texts, messageIds, “identifiers that categorize the types of order reply messages that have been emitted”, and isSuppressed, “Internal use”. IBKR’s example:

[{"id": "07a13a5a-4a48-44a5-bb25-5ab37b79186c",
  "message": ["The following order \"BUY 5 AAPL NASDAQ.NMS @ 150.0\" price exceeds \nthe Percentage constraint of 3%.\nAre you sure you want to submit this order?"],
  "isSuppressed": false,
  "messageIds": ["o163"]}]

The client answers POST /iserver/reply/{replyId} with {"confirmed": <bool>}: “true will agree to the message transmit the order. false will decline the message and discard the order.” A confirmation can draw another reply: “These confirmation messages must also be responded to before the order will submit.” And the answer must come at once: “Submitting other orders or other requests will cancel the order and attempts to acknowledge the reply will result in a 503 error.”

The rule. A reply is confirmed only when it carries at least one message id and every one of its ids is on the allow-list, matched exactly, case included, on the id as IBKR sent it. Any other reply is declined (confirmed: false), and its order is not transmitted.

  • One id off the list declines the whole reply: IBKR answers one reply id for all the messages of a ticket, and confirming it would waive that precaution too.
  • A reply with no message id, or only empty or blank ones, is declined whatever the list holds.
  • The isSuppressed flag is ignored: IBKR documents it as “Internal use” and “Always returns false”, and a reply that reaches the adapter was not auto-accepted, so it is judged like any other.
  • The adapter neither suppresses messages nor resets their suppression. /iserver/questions/suppress makes IBKR auto-accept a message id for the whole brokerage session, outside the run’s control and shared with every client of the session, and a suppressed message does not reach the adapter, so suppression would bypass the policy. Do not suppress a precaution id at the gateway either.

Only the first element of a reply array is answered: IBKR ties the elements to the request’s tickets by index, and the adapter sends one ticket. A first element without a reply id cannot be answered: the submission is Unknown and nothing is sent.

The default list. venueibkr.DefaultReplyAllow is IBKR’s five general risk disclosures: o10151, o10152, o10153, o10331 and p12. A disclosure is raised whatever the order’s size, value or price, so confirming it waives nothing about the order. A precaution is IBKR checking the order against one of its limits (price percentage, size, total value, tick size) or warning of a condition of the order itself (no market data, an immediate fill); confirming it waives that check on the engine’s behalf, the risk the plan records as R23, so every precaution is declined unless the deployment lists it. IBKR publishes its message ids only in its list of suppressible ones (the Client Portal Web API page, “Suppressible MessageIds”). The table gives each listed id, IBKR’s meaning as the reference summarises it, and what the default does:

Message idIBKR’s meaningDefault
o163the order exceeds the price percentage limitdeclined
o354an order without market datadeclined
o382a value exceeds the tick size limitdeclined
o383the order size exceeds the size limitdeclined
o403the order will most likely fill immediatelydeclined
o451the order’s value estimate exceeds the total value limitdeclined
o10151the risks of market ordersconfirmed
o10152the risks of active stop ordersconfirmed
o10153IB may set a cap price on the order, which may keep it from tradingconfirmed
o10331the stop order types and their risksconfirmed
p12an order not immediately executable may be rejected if its limit price is too far from the reference priceconfirmed
o10138a size modification exceeds the size modification limitdeclined
o2136a mixed allocation orderdeclined
o2137a cross-side orderdeclined
o2165no fractional trading outside regular hoursdeclined
o10082a called bonddeclined
o10164, o10223cash-quantity ordersdeclined
o10288crypto market ordersdeclined
o10332OSL crypto ordersdeclined
o10333an option exercise at the moneydeclined
o10334an order placed in the omnibus accountdeclined
o10335an internal Rapid Entry windowdeclined
o10336illiquid securitiesdeclined
p6an order distributed over several accountsdeclined
any other idnot in IBKR’s list: o0 carries the stop-order text in one of IBKR’s examples, and o102 appears only as an example of the suppress requestdeclined

Chains. Each reply of a chain is judged on its own, in turn, and a decline at any hop ends the order with that reply’s ids and texts in its reason. The adapter confirms at most 10 replies of one submission: a reply that would be the eleventh confirmation is declined instead, with the cause more than 10 replies (the allow-list is judged first). Only a confirmation transmits, so a malfunctioning venue’s chain ends with positive evidence rather than an Unknown.

The answer to a confirmation. Every hop’s answer is read by one table, the first row that applies:

AnswerSubmission
HTTP 4xxDenied with the error text, such as ibkr: http 400: reply id not found: 'r1'; Paced too for HTTP 429 (ibkr: http 429: Too many requests)
any other failure: an HTTP 5xx, 503 included, or a lost connectionUnknown
IBKR’s error envelope, {"error": ...}, in an HTTP 200Denied with the envelope’s text, such as Order not confirmed
a body the adapter cannot readUnknown
another replythe next hop judges its first element
an order elementread as a direct place answer: Rejected is Denied (venue rejected order), no order id Unknown, otherwise Accepted
no order element: an empty array, null or an empty bodyUnknown

A refused confirmation leaves the ticket untransmitted, so a 4xx and the error envelope, IBKR’s answer to an unknown reply id, are denials. A 429 is read, as on the place endpoint, as IBKR’s rate limiter refusing the request before it was processed: a v2 live run’s transport scheduler sends the order again, with a new place call and a new conversation (The transport scheduler), and a v1 run hears it as a denial. A 503 is Unknown, although IBKR documents it as its answer to acknowledging a reply whose ticket was cancelled: any gateway or proxy may send one, so it is not positive evidence, and a wrong denial would let the engine forget a live order where a wrong Unknown only waits for reconciliation. Until unit 3b every failure on a reply hop was Unknown.

The decline. A decline is sent once, and nothing is sent after it. Only a confirmation transmits a parked ticket, so the answer to a decline does not matter: IBKR documents none (the emulator answers an empty array), and whatever comes back, an error, any HTTP status or a lost connection included, the submission is Denied with the declined reason, and not Paced, since sending the order again would draw the same message. The one exception is an answer whose first element reports a live order, an order id with a status other than Rejected: it contradicts the decline, so the submission is Unknown with that order id, for reconciliation, rather than a denial that would let the engine forget a working order.

The reason. A declined order’s reason is reply_declined: <cause>: <text> (algolang.ReplyDeclined, sdk/strategy/reply.go). The cause is the reply’s ids that are off the list, in the reply’s order with duplicates kept, joined by commas; or no message id; or more than 10 replies. The text is every text of the reply joined by |, since IBKR does not say which text belongs to which id, or no message text when it has none. Every run of white space, IBKR’s line breaks included, becomes one space, so the reason is one line. A ticket of 150 that draws IBKR’s size precaution is denied with

reply_declined: o383: The following order "BUY 150 AAPL NASDAQ.NMS" size exceeds the Size Limit of 100. Are you sure you want to submit this order?

and a reply carrying o451, o10151 and o383 with three texts has the cause o451,o383 and carries all three. As with the engine’s other tokens, the text after the token is for people, not for parsing.

What the engine does with a decline. A decline is a venue denial, and final.

  • A lifetime-v2 strategy sees an OrderUpdate with status Denied and the reason reply_declined: ..., which grants a turn, then the OrderCancel{Rejected} that follows every venue denial; a v1 strategy sees the OrderCancel{Rejected} alone. The operator hears the denial line on stderr, algo: order "big" (AAPL) from instance AAPL denied: reply_declined: o383: The following order ....
  • The race guard ends the attempt, so the label’s successor, and any order held behind it, is admitted, and neither the exposure oracle nor the admission policy counts it (The engine race guard).
  • The ledger closes the order’s reference as a reject with the denial’s reason, IBKR’s text. Every live venue denial now records its venue’s reason: venue rejected order when it has none, and a pacing refusal keeps venue refused the submission for pacing.
  • The desired-order controller blocks the key for that frame, with the reason as State(key).Blocked, so it does not submit the declined order again until the intent changes (The desired-order controller).

Configuration. --venue-reply-allow sets the list for a single run, comma-separated (--venue-reply-allow o10151,o10152,o10153,o10331,p12,o383 adds the size precaution to the default); none is the empty list, which confirms no reply, and blank, the default, selects the five disclosures. It is refused with --config, like the other single-run flags. In a run-config file it is the data key venue-reply-allow, a list of strings set at any level, a more specific level’s list replacing an outer one; [] confirms no reply, and null is refused:

"data": {
  "adapter": "bin/file-csv-adapter",
  "venue-reply-allow": ["o10151", "o10152", "o10153", "o10331", "p12", "o383"]
}

Every run checks the list before any spawn, a backtest included, which ignores it, and a run-config file’s list is checked as the plan is built: each id is 1 to 32 ASCII letters and digits, none is listed twice, and none stands alone. The refusal names the key, as in engine: venue-reply-allow: message id p12 listed twice. The adapter matches the ids as given, so O383 allows nothing IBKR sends as o383.

The emulator’s fixture. The emulator raises order reply messages from its JSON config’s replyWarnings, a list of warnings with the fields messageId, message, minQuantity, minValue and hop. A ticket draws a warning when its quantity is at least minQuantity and its value at least minValue: the quantity times its limit price, else its stop price, else, for an order with neither, the last close the emulator observed for its contract (0 when none); with both thresholds zero every ticket draws it. The warnings a ticket draws are grouped by hop: each group, in ascending order, is one reply carrying the group’s ids and texts in configuration order, so warnings that share a hop make one reply with several ids and distinct hops make a chain, the next group answering the confirmation of the one before. The ticket is placed once every reply is confirmed; a decline drops it and is answered [] (HTTP 200), and an unknown reply id is answered HTTP 400. IBKR’s size precaution on tickets of 100 or more:

"replyWarnings": [
  {"messageId": "o383", "minQuantity": 100,
   "message": "The following order \"BUY 150 AAPL NASDAQ.NMS\" size exceeds \nthe Size Limit of 100.\nAre you sure you want to submit this order?"}
]

The emulator refuses to start when the fixture is combined with requireReply, or holds a warning with no messageId, a negative or non-finite threshold or a negative hop, and under the fixture a place request carries one ticket (HTTP 400 otherwise). The generic reply flow, requireReply with replyChainLength, is unchanged: it raises o163 and, on a chain, o451, IBKR’s price and value precautions, so the default list declines it.

Recorded assumptions. The policy rests on these, stated here as assumptions until a paper-account probe checks them:

  • Real replies carry messageIds. IBKR’s Client Portal page calls the field “Internal use only”; a reply without ids is always declined, and no allow-list can admit it.
  • A stop order draws o10331, IBKR’s listed stop-order disclosure, which the default confirms. One of IBKR’s examples shows that text under o0, which the default declines.
  • An HTTP 429 on a confirmation was refused before IBKR processed it, as unit 3a reads a 429 on the place call.
  • The ticket a declined reply leaves parked does not transmit. IBKR says a decline will “discard the order”; the probe checks that it does.
  • IBKR’s reference gives no HTTP status for its error envelope on an unknown reply id and no answer body for a decline: the adapter reads the envelope as a refusal whatever the status, and a decline’s answer as nothing but a possible live order.
  • IBKR does not tie a reply’s texts to its ids, so the reason carries every text. Its own texts for o383 and o451 are not in the reference; the emulator’s follow the documented o163 wording.

Known limits and follow-ups. These must close before the adapter trades live (GLE-445):

  • A reply conversation is not serialized against the adapter’s other gateway requests. IBKR cancels a parked ticket when another request reaches the session first, and today another submission, a cancel, the poll and the reconciler’s queries can all reach the gateway while a reply is pending. The engine fails safe (the confirmation draws a 503 or an error, read as Unknown, and reconciliation resolves it), but an allow-listed order can be lost. Each place call and its whole reply chain should be serialized against every request on the brokerage session, polls included, and the emulator should drop a parked ticket when requests interleave; today’s concurrency test proves race-freedom only.
  • The paper-account probe checks the assumptions above.
  • The declined reason carries the venue’s free text uncapped; a cap on its length is a follow-up.
  • An opt-in /iserver/questions/suppress/reset in the live pre-flight would undo a suppression made at the gateway.
  • An error envelope or an advanced-order reject answering the place call itself is still Unknown, where one answering a confirmation is Denied; the asymmetry is deliberate until it is revisited.
  • The declined ids do not reach OrderUpdate.BrokerCode.
  • A strategy that sends the declined order again at every decision (a v1 strategy, or one that does not forward OnOrderUpdate to the controller) draws the same decline each time; detecting the repeated order is a follow-up.

Tests. engine/venueibkr/aa_reply_tournament_spec_test.go (24 tests) drives the conversation over a fake gateway that scripts every answer and logs every request (each default id, each precaution, mixed replies, chains, the bound, every answer to a confirmation and to a decline, the first element only) and over the emulator’s fixture (a size and a value warning, a chain, the generic flow, sixteen concurrent submissions under -race); engine/venueibkr/reply_review_test.go holds the review panel’s gap tests (the answer table at later hops, concurrent conversations’ order ids and fills, nothing after a decline, no suppression request on any path, wrapped and typed-nil errors); engine/aa_reply_tournament_spec_test.go (10 tests) runs the live loop over the adapter and the emulator (a decline reaching a v2 and a v1 strategy, a declined replacement, the ledger’s reason, a confirmation refused with a 429 and sent again by the transport scheduler) and the configuration through engine.Run; and a tournament_reply_spec_test.go in each of cmd/tools/ibkr-emulator/emu, engine/runconfig, cmd/algo and sdk/strategy pins, in turn, the fixture, the key, the flag and the token.

The order ledger

--ledger PATH names the live loop’s order ledger (engine/ledger): an append-only JSONL log at PATH, one object per line, fsync’d before each broker call, with a counter sidecar PATH.ctr and a lock sidecar PATH.lock. It is the engine’s own record of what it expects to find at the broker and the basis of startup reconciliation (engine/recon), and since increment I7 of the order-lifetime programme (GLE-373) it owns its identity.

Namespace and references. Every ledger belongs to one namespace, <label>-<nonce>: the configured deployment label and a ten-character nonce (lower-case base32, a–z and 2–7) drawn once from crypto/rand when the ledger is created and written as the log’s first line, {"kind":"identity","deployment":"demo","nonce":"k7x2q3mz4a"}, before any reference is allocated. Every canonical reference the ledger hands out is <label>-<nonce>-<n>, n a monotonic 64-bit ordinal from one, and goes to IBKR verbatim as the order’s cOID, which the venue echoes as order_ref. IBKR’s cOID is at most 64 characters: a reference that would be longer is refused by the ledger before it is allocated, the counter and the sidecar untouched (ledger: canonical reference "..." is 65 characters, over IBKR's 64-character cOID limit), and the live loop turns that into a fatal error of the run before any submission; a 40-character label reaches the limit at ordinal 10¹². A restart reopens the same namespace and continues the counter, and an ordinal handed out but not recorded (a crash between allocation and the order line) is not reissued.

The deployment label. --deployment (config key data.deployment) is 1 to 40 characters from [A-Za-z0-9_], no dash (the dash separates a reference’s parts); empty means algo. A ledger keeps the identity it recorded when it was created and refuses to open under another label (ledger: "var/orders.json" was created under deployment "demo", but "algo" is configured (a ledger keeps the identity it recorded when it was created)), so a ledger created under a label must keep being run under it. The label is the operator’s name for a deployment; the nonce is what keeps two deployments apart: two started from one copied configuration draw different nonces, allocate under different namespaces, and each delivers only its own executions and cancels to its strategy (below).

Legacy ledgers. A ledger written before identity existed holds order records whose references are c-<n> and no identity line. It refuses to open, writing nothing, unless --ledger-migration exclusive-owner (config key data.ledger-migration) is set: the operator’s assertion that no other deployment on the account allocated references under that prefix. The refusal counts the order records, how many of them are open and the prefixes found, and names the migration. With the migration the ledger appends an identity line carrying legacy_prefix ("c"), owns every c-<n> from then on, continues the counter from the legacy high-water mark, and the flag is unnecessary afterwards. The migration is refused when the legacy references carry more than one prefix (c-* and x-*: ambiguous) or one that does not parse as <prefix>-<n>; an identity line with an empty label or nonce is a corrupt log and refuses to open; a log holding only corporate-action lines, or only a sidecar, is fresh, not legacy. Another value of --ledger-migration is refused.

The order frame and attempts. The order line carries the complete supported frame: the label, symbol, side, quantity, type, prices, time in force, parent label and GTC deadline it carried before, plus the attempt under the label, the trailing distance, the standalone OCO tag and the allocation instant (attempt, trail_amount, oco_group, submitted_at; absent on a line written before the fields existed). The allocation instant is the close of the bar the loop was on when it assigned the reference. The attempt is the strategy’s stamp when it made one (the v2 working view stamps attempts 1-based per label) and otherwise the ledger’s, one above the highest attempt recorded under the label, so an attempt number is restart-stable and is not reused after a restart. Lookup and ExpectedOpen return copies carrying OwnerID, the namespace: every record the ledger holds is owned, migrated legacy ones included. Adoption restores the frame whole: each adopted order carries its trailing distance, OCO tag, attempt, reference (OrderRef), the executed and remaining quantities with the status they imply (Working, or PartiallyFilled once anything executed), its allocation instant and its owner, and AdoptedState.OwnerID is the namespace. Delivered fills and cancels carry the reference, the attempt and the quantity still working (OrderRef, Attempt, RemainingQuantity); a fill carries the venue’s execution id (ExecID, IBKR’s execution_id), which the adapter reads from every trade record.

Executions. IBKR’s trade feed is account-wide, so every execution report the adapter emits, on the real-time channel and on the reconciliation poll alike, goes through one gate, RecordExecution, which checks in this order and stops at the first that applies:

  1. a report without an execution id is an error (the de-duplication key is required; nothing is written);
  2. a reference outside the namespace is foreign (reference "other-zzzzzzzzzz-1" is not in namespace "demo-k7x2q3mz4a"): another deployment’s execution on the account;
  3. an owned reference with no order record is unexplained;
  4. an execution id already applied is a duplicate when it was applied to this order (nothing written, the record returned) and invalid when it was applied to another, since an execution id is the venue’s identity of one execution and names one order, globally; an id already quarantined is invalid too, with nothing written;
  5. the frame: a non-positive quantity, a symbol or side other than the order’s, a cumulative executed quantity over the order’s, or a time before the allocation instant (compared at millisecond resolution, IBKR’s trade_time_r; a zero time on either side skips the check) makes the report invalid;
  6. otherwise it is applied: a fill line is appended and folded, the cumulative executed quantity and the symbol’s expected position move, and the order terminates once its quantity is reached. Partial executions accumulate, and a fill after a recorded cancel still applies (the cancel-and-fill race moved the position at the venue).

Foreign, unexplained and invalid reports are quarantined: a quarantine line carrying the report, its outcome and the reason is written once per execution id (a re-report is classified again and writes nothing), and the report touches no record, position, de-duplication set or expected-open set. Quarantined() lists them in log order. The live loop drops a foreign execution, and a cancel of a reference it does not own, with one stderr line each and a count, and fails loud on an unexplained or invalid one, naming the execution and the outcome: an owned order the ledger cannot explain is a contradiction, not noise. A duplicate is dropped without a line (the other channel delivered it). So two deployments started from one configuration deliver neither’s executions to the other, and a restart replays the fold, the de-duplication set and the quarantine list from the log. A cancel or reject of a reference the ledger holds no record for writes nothing and is not delivered.

Durable cancel intent. RecordCancelRequest writes a cancel_request line before a cancel is sent, ClearCancelRequest a cancel_refused line when the venue refused it and the order still works, and CancelRequested reads the intent back after a restart; intent on a terminated order clears silently. The API is I7’s. The v2 live cancel (I4 unit 4b) records the intent before each request leaves and keeps it on a Pending or Unknown answer; a Refused answer clears only the intent this request recorded, and not when the venue reports the order already pending cancellation, so an earlier request’s intent stands. A failure to record or clear it is fatal to the run. The v1 loop records none. Recovering a recorded intent at restart (adopting the order as pending cancellation, re-issuing or awaiting the cancel) is I8’s.

One handle, bounded lines. Open takes an exclusive, non-blocking flock on PATH.lock and holds it until Close, so a second process, or a second open on the same path in one process, is refused (ledger: "var/orders.json" is already open (another process or handle holds "var/orders.json.lock")) rather than replaying the same counter and reissuing a reference. A log line is at most 4 MiB: the writer refuses a longer entry and replay reads up to that limit, so every accepted line survives a restart. Every line round-trips through the Entry struct, the on-disk schema, whose fields are append-only.

Backtests are unaffected: the simulator allocates no ledger reference, and the KBD masters are byte-identical.

The scripted venue

engine/venuetest (I4 unit 4b) is a deterministic, in-process venue for the live loop’s tests, with the asynchronous cancellation a real venue has and the emulator cannot produce: the emulator embeds the simulator, which cancels synchronously. venuetest.New(cfg) returns a *Scripted that implements the live loop’s venue (the venue adapter interface plus PollOnce), and its Step is the loop’s clock stepper, so runLiveLoop drives it exactly as it drives the IBKR adapter over the emulator. It starts no goroutine, guards its state with one mutex and imports engine/sim and sdk/strategy only, so any package’s tests can use it. It ships with the module, as the emulator does, with no production caller.

The script. Config.Orders maps the cOID the engine submits (the ledger’s canonical reference when a ledger is active, else the label) to an OrderScript; an order without one is accepted and cancelled on request.

  • Submission. Deny denies it with that text; Unknown answers SubmitUnknown with no id, and with Booked the venue books the order anyway (the answer was lost). Booked orders take the ids sv-1, sv-2, … in booking order; a denial, a pacing refusal or an unbooked Unknown takes none.
  • Cancel. Cancel.Outcome decides every cancel request: Confirmed, or none, cancels at once; Pending makes the order PendingCancel until the step ResolveAt (0: not during the run), so a cancel can be deferred, and a second request answers Pending again; Refused refuses with Reason (by default venuetest: cancel refused by script); Unknown answers without evidence, as a transport failure would, and with Applied the cancel took effect anyway. The Confirmed, Pending and Refused answers carry the order’s status, attempt and remaining quantity as evidence. A cancel of an unknown id is Refused without evidence, and one of an order already filled or cancelled is Refused with it.
  • Fills. Fills execute at the steps they name, capped at the quantity remaining, while the order is Working or PendingCancel, so an order can fill, or fill in part, during a pending cancel. Each execution carries its own id (sv-1-e1, sv-1-e2, …), the attempt and the quantity still working after it; the order is Filled when nothing remains, which makes a pending cancel moot. At one step the executions come before a cancel that resolves there, which then carries the post-fill remaining quantity. A fill at a step already passed when the order was booked does not fire.
  • Pacing. Pacing{SubmitsPerStep, CancelsPerStep} refuses each request past the limit within one step with venuetest.PacingRefusal (venuetest: pacing refusal): a denial for a submission, a Refused answer for a cancel. The counters reset at every Step. Since I6 unit 3a each refusal is marked Paced, a pacing refusal that a v2 live run’s transport scheduler sends again (The transport scheduler).

Events queue until PollOnce moves them onto the Events channel without blocking (venuetest: events buffer full when the channel is full, venuetest: poll after shutdown after Shutdown). The order, fill, position and account reports answer from the script’s state; the account state has no cash book, and ModifyOrder is refused.

The clock. Step 0’s clock is Config.Start, which defaults to 2026-01-02T14:31:00Z, the close of bar 0 in the engine’s oracle fixture, so step k’s clock is bar k’s close and an event on a bar is stamped at that bar’s close, not before the ledger’s allocation instant of an order submitted on it. Each Step advances the clock by Interval (a minute by default). EventBuffer is the channel’s depth (1024 when not positive).

The call log. Calls() lists the venue calls in order, one entry each: init, submit <cOID>, cancel <id>, step <n>, poll, status <id>, orders, fills, positions, account and shutdown. Name, Clock, Calls, Events and ModifyOrder log nothing, nor does a Step or PollOnce that returns early on a cancelled context. A test reads in it what reached the venue: one cancel request for an order already pending, none for a label with no order id, no second request at teardown.

Reuse. The admission race guard’s tests (The engine race guard) reuse the fixture: a Pending cancel resolving at step k with fills inside the window gives fill-before-cancel, cancel-before-fill and a partial fill; Pending with ResolveAt 0 is a cancel the venue does not confirm; and Calls() shows which orders reached the venue. The transport scheduler’s tests (The transport scheduler) use Pacing for the venue’s pacing refusals.

No reply messages. The scripted venue has no reply conversation and needed none for I6 unit 3b: to the engine a declined IBKR order reply is a venue denial with a text, which Deny already scripts. The reply policy’s engine tests drive the IBKR adapter over the emulator’s replyWarnings fixture instead (Order reply messages).