Fill simulation
Orders returned from OnBar go to the engine’s built-in simulator, which holds a cash account per run (no margin model – cash in, cash out, commission always a cost).
When and how orders fill
The golden rule: you decide at a bar’s close, and your order lives through the next bar’s path. A market order therefore fills at the next bar’s open – the first price that existed after your decision – never at the close you just saw.
At bar resolution the simulator knows only each bar’s OHLC, and resolves each order type conservatively:
| Type | Fills when (buy side; sells mirror) | At price |
|---|---|---|
| Market | always, on the next bar | open + slippage |
| Limit | bar low <= limit (strictly below under --limit-fill through; on a tick grid that is at least one tick past) | min(open, limit) – the open when the bar gapped through your limit |
| Stop | bar high >= stop | max(open, stop) + slippage |
| Stop-limit | stop leg as Stop, then rests as Limit | trigger price if it already satisfies the limit; else a resting limit from the next bar |
| Trailing stop | the engine-held stop (trail distance from the best price since activation) is touched | min/max(open, stop) + slippage; the stop ratchets from each bar’s close and never moves against you |
Slippage (--slippage-ticks, in ticks) is adverse and applies to market-style executions only – a limit price is a hard bound that can never be slipped through. The offset is the tick count multiplied by the traded symbol’s own tick size, so one setting is correct across symbols of different price scales (0.25 for ES, 0.01 for an equity). The tick size is resolved per instrument from the data adapter’s reported tick_size; a continuous run reads its root’s instrument metadata once, at startup (GLE-325). If slippage is on (> 0) and a traded symbol’s tick size cannot be resolved, orders on that symbol are rejected rather than mis-priced.
Tick resolution
The bundled Databento adapter serves raw trades as well as bars, so the simulator can do better than OHLC: at tick resolution it replays each bar’s actual trade prints and fills your orders against the real intra-bar path. --simulation-resolution is auto by default, which uses tick when all of these hold (and quietly falls back to bar, with a note, otherwise):
- single series in the run (one trade stream to replay),
- time bars (activity bars’ windows aren’t addressable by time),
- no
--replicate(cloned bars have no faithful trade stream), - the adapter advertises the TRADES schema,
- the data’s coverage is tick-granular over the window.
Forcing --simulation-resolution tick when a precondition fails errors out instead of degrading; bar always works. The resolution actually used is recorded in the report’s simulator: line.
If a tick-resolution bar has no prints, market orders fall back to its open while other orders cannot match in that window. An attached trailing stop currently loses its known parent fill price in this fallback and may miss a protective exit at the next print; see the simulator finding.
--limit-fill sets how much evidence a limit fill needs. conservative (default) requires a print strictly through your limit price at tick resolution – a touch is not a fill, because in a real book a touch may never reach your order. At bar and path resolution it still fills on a touch (a bar low exactly at a buy limit), as the table above shows. through requires the trade-through at every resolution: a buy limit fills only on a bar or path constituent whose low is strictly below it (a sell limit, a high strictly above it), which proves a print beyond the limit. A stop-limit whose trigger price equals its limit is a touch too, so its leg triggers and rests instead of filling on the trigger. Fill prices do not change. Live runs refuse through, because the venue decides fills. optimistic fills on the touch at tick resolution; use it only to measure the optimistic/pessimistic spread, never for results you intend to trust.
Path resolution (--include-path)
Between OHLC and ticks sits a third option. When your adapter coarsened the bars it serves – a daily bar built from 1-minute bars, say – it often still holds those finer bars. --include-path asks for them: each coarse bar arrives with its intra-bar path, the finer constituent bars it was aggregated from. At bar resolution the simulator then walks that path in order instead of guessing from OHLC, so it knows whether the high came before the low.
That changes fills. The classic case is a bracket whose bar crosses both the take-profit and the stop-loss: at plain bar resolution the --bracket-ambiguity convention has to guess which hit first; with the path, the simulator simply sees which the price reached first and fills that one. Trailing stops likewise ratchet across the path – tightening on an early constituent and firing on a later one within the same coarse bar – instead of ratcheting once from the close. So a path run’s fills can differ from the same run without it; that is the point.
--include-path is opt-in and needs an adapter that advertises it (serves_path); against an adapter that does not, the run refuses to start rather than silently ignoring the flag:
algo: engine: adapter databento-adapter does not advertise BARS/BARS_TIME
serves_path (intra-bar path) ...; rerun without the path opt-in or use a
path-capable adapter
It applies to time bars only, and the path stays private to the engine – your strategy still receives plain fixed-size bars, exactly as without the flag; only the fill simulation sees the finer detail. Tick resolution, when it applies, is finer still and takes precedence (the path is then unused). The data wire auto-prefers stdio-pb-v1 for path runs, because stdio-dbn-v1 cannot represent fractional volume or odd intervals exactly.
The money knobs
bin/algo run ... --strategy bin/smacross --symbol MSFT --interval 1m \
--capital 50000 --commission-per-order 1.00 --slippage-ticks 1 \
--simulation-resolution bar simulator: resolution=bar bracket_ambiguity=conservative limit_fill=conservative slippage_ticks=1
3 fills, commission 3.00, realized PnL -0.34
open MSFT -1 @ 261.0800 (last 261.0300, unrealized 0.05)
equity 49996.71 (capital 50000.00, return -0.0066%)
| Flag | Default | Meaning |
|---|---|---|
--capital | 100000 | Starting cash of the run’s account. |
--commission-per-order | 0 | Flat charge per fill. |
--commission-per-share | 0 | Per-share/contract charge per fill (both can apply). |
--slippage-ticks | 0 | Adverse slippage in ticks (x each symbol’s tick size) on market-style fills. |
The simulator: block reads: the conventions in force (recorded verbatim so a result is never separable from its assumptions), fill count, total commission, realized PnL (average-cost basis, before commission), any open positions marked at the last close with their unrealized PnL, and final equity = cash + marked positions, with the return on capital.
Brackets and OCO
Bracket attaches a take-profit and a stop-loss to an entry. The exits activate only when the entry fills, and form an OCO pair – when one fills, the other is cancelled with reason OCOTriggered. This compiles and is the canonical shape:
func (b *Breakout) OnBar(ctx *algolang.Context, bar algolang.Bar) ([]algolang.Order, error) {
if !ctx.Flat(bar.Symbol) {
return nil, nil // an entry or its exits are still live
}
entry := algolang.MarketBuy(bar.Symbol, int64(b.Size))
entry.ClientID = "breakout_1"
// Take profit 1.00 above the close, stop loss 0.50 below.
return []algolang.Order{
algolang.Bracket(entry, bar.Close+1.00, bar.Close-0.50),
}, nil
}The child orders inherit the entry’s symbol and quantity, get GTC time in force, and get ClientIDs derived from the entry’s (..._tp, ..._sl) so your OnFill/OnCancel can tell them apart. If the entry is cancelled before filling, the children are cancelled too (ParentCancelled – they were never live). Attached orders nest one level only.
Same-bar ambiguity. What if one bar’s range crosses both the target and the stop? A bar cannot say which the path hit first. The --bracket-ambiguity convention decides, and is recorded in the report:
| Value | Assumption |
|---|---|
conservative (default) | The stop filled first. Matches Interactive Brokers’ paper-trading simulator. |
optimistic | The target filled first. Sanity check only. |
path_direction | Bar closed above its open: target first; otherwise stop first. A heuristic. |
At tick resolution the ambiguity all but vanishes – the trade prints say which level traded first – and the convention only breaks per-print ties.
Time in force. A resting Day order gets one evaluation bar of its own symbol and expires at that bar’s close if unfilled. A Day bracket child activated during a bar survives that activation bar and gets its own next bar window. GTC orders remain working until filled or cancelled, and under --gtc-expiry ibkr-quarter until the venue’s quarter-end deadline (next subsection). IOC and FOK cancel after their immediate evaluation window; because the simulator fills whole orders, they are operationally identical here. This one-bar Day convention does not depend on exchange-session callbacks and is tracked as GLE-200 for sub-daily bars.
GTC quarter-end cancellation
Interactive Brokers offers no order that is immune to expiry: a good-till-cancelled order is cancelled at the close of the last trading day of the calendar quarter following the quarter in which it was placed (placed in Q3 2026, cancelled at the end of Q4 2026; placed on 2026-10-01, cancelled at the end of Q1 2027), and a modified order gets a new deadline. A backtest that ignores this shows protection a live account does not have. --gtc-expiry selects whether the simulator models it (the order-lifetime programme’s increment I13a, GLE-374):
| Value | Meaning |
|---|---|
never (default) | A GTC order rests until it fills, the strategy cancels it, or the run ends. Byte-identical to every earlier release: no anchor is recorded, nothing is counted, and the event stream of any run is what it was. |
ibkr-quarter | A working GTC order is cancelled, reason GTC_EXPIRED, before the first bar whose trade date falls after the end of the calendar quarter following the quarter of its submission’s trade date. |
It is a command-line option only, like --lifecycle-json: no run-config key sets it, so a --config run keeps never. Any other value (--gtc-expiry quarter, say) is refused naming both values.
The rule. Every physical submission is anchored at the exchange trade date of the bar the strategy just decided on: BarSession.TradeDate when the bar carries a session block (a session-keyed daily or weekly series), else the bar’s economic date, the UTC calendar date of the instant one nanosecond before its close (a bar closing exactly at midnight belongs to the day that midnight ends). The deadline is the last calendar day of the quarter after the anchor’s: anchored 2026-09-30, due after 2026-12-31; anchored 2026-10-01, due after 2027-03-31. No exchange calendar is needed, because on trade dates no date lies between a quarter’s final trading day and its last calendar day, so “before any bar whose trade date is past the deadline” is the same test either way. The anchor and the submission instant are stored side by side, so a bar keyed 2026-09-30 whose close is 2026-10-01T01:00:00Z anchors in Q3.
Before each bar is walked, after the previous close’s queued events are delivered and before anything is matched against the bar, the simulator cancels every working GTC order, of any symbol, whose deadline is strictly before the bar’s trade date: the venue’s deadline is a clock event, not a property of the bar’s instrument, so another symbol’s bar advances it too and an illiquid symbol does not keep a dead order alive. A bar keyed on the deadline’s own date is still walked and can fill the order. The cancel is stamped at the simulation clock, the last observed close, and reaches the strategy as an OnCancel with reason GTC_EXPIRED (CancelReasonGTCExpired) ahead of that bar’s fills. A due order’s OCO group settles as a fill would: its still-inactive children are cancelled ParentCancelled, a sibling that is itself due is GTC_EXPIRED in its own right, and any other live sibling is cancelled OCOTriggered by it. An order already expired is not swept again at the end of data; one whose deadline is still ahead is swept with Shutdown, as before.
Paths and coarse bars. With --include-path (and a path on the bar) the deadline is resolved inside the bar: each constituent is tested before it is walked, so an order fills on the constituents up to and including the deadline’s trade date and is cancelled before the first one after it, the cancel stamped at the parent bar’s close. Without a path a bar is resolved no finer than itself: a weekly bar straddling a mid-week quarter end cancels the order before the bar, although the bar’s range may cross the order’s level, and a post-deadline fill is not admitted. That approximation is counted: Summary.GTCExpiredCoarse is the number of expiries produced before a bar whose close is more than 24 hours after its open (a CME session, an equity day and an epoch day are not coarse; a week is) and whose span contains the deadline. When the model is on, the report’s simulator: line gains gtc_expiry=ibkr-quarter gtc_expired=N gtc_expired_coarse=M; a default run’s line is unchanged.
What resets the anchor. Each physical submission is a fresh anchor: a cancel-and-replace under the same client id restarts the deadline from the replacement’s trade date, and the replaced order’s cancel keeps the reason the strategy’s cancel gave it (Explicit). A bracket child anchors at its activation, the bar or constituent on which its parent filled, the simulation’s stand-in for the instant the venue would release it. A continuous roll re-submits a resting order onto the incoming contract as a new physical submission, so it resets the anchor too: a GTC order rolled every quarter does not expire in a :cont backtest (I15 may carry the original anchor across a roll). A GTC order submitted before any bar was observed (direct simulator use) anchors at the first bar’s trade date, the parent’s on a path bar. Only GTC carries a deadline; Day, IOC, FOK and an unspecified time in force (a CSV order, an engine roll leg) are untouched.
The lifecycle trace labels the reason gtc_expired and counts it among the unsolicited_cancels; the CSV strategy wire renders it gtc_expired as well. A run that keeps the default emits the reason nowhere, so its trace and its CSV lines are unchanged.
Known limits. The cancel instant is the last observed close, the latest instant known to precede the deadline, not IBKR’s closing time on the quarter’s final trading day: after a data gap the stamp is earlier than the venue’s (visible in the trace, and not a fill), and a sub-daily bar without a session block takes its UTC economic date, which puts the evening hours of a CME trading day on the previous UTC date. The coarse condition is mechanical: it reads the open’s plain UTC calendar date (an instant at midnight starts that day, so a weekly epoch bar opening exactly at 2026-10-01T00:00:00Z is exact for a 2026-09-30 deadline, while a close at midnight ends the previous day) and flags the bar when the deadline is on or after it. It over-reports when a bar’s ts_open is the previous session’s close: a daily feed stamped that way spans a weekend (Friday 21:00Z to Monday 21:00Z is 72 hours) and is flagged coarse when the deadline is that Friday, although the bar’s trading is all after it. Harmless, because it is a count and not a fill. I13b refines the instant from the versioned exchange calendar (the quarter’s final trading close in exchange time, early closes and holidays included), replacing both approximations; an incomplete calendar will leave this rule in force and report it.