This document is the authoritative definition of execution engine behavior. Tests verify conformance to this specification. Behavior is never defined implicitly through tests.
Any change to execution behavior must update this document, relevant tests, and golden regression baselines in the same commit.
0. Execution Config Resolution (Per-Ticker Overrides)
Every parameter described in this document — spread, slippage, price_volatility, ohlcv_type,
commission_per_unit, commission_mode, swap_per_unit — is resolved per ticker, not once for the whole
run: a ticker with an entry in the run's sparse ticker_overrides map uses that entry; a ticker with
no entry uses the run's single global DefaultExecutionModelConfig unchanged. This applies uniformly
across every rule in sections 1-11 below — read "the config" in what follows as "the resolved
per-ticker config," whether that resolves to the global default or to an override.
The one exception is rng_seed: it is always engine-global, even inside an override entry. The
synthetic tick generator is constructed once per run from the global config's rng_seed; per-ticker
trajectory decorrelation already comes from hashing the ticker symbol, not from a distinct seed per
ticker, so a per-ticker rng_seed override is ignored.
A ticker with no override falls back to the global default, which may itself be all-zero (no
execution costs modeled) if the caller left it at its default-constructed value. This is a warning
condition, not a silent one, whenever ticker_overrides is non-empty: the engine logs which tickers
are running on the global default at construction time, and flags explicitly when that fallback is
zero-cost.
1. Bid/Ask Derivation
Raw synthetic tick prices are generated per bar using a deterministic pseudo-random sequence seeded
by rng_seed, bar_idx, and a per-ticker hash. Raw ticks are clamped to [bar.low, bar.high].
The ask spread and bid spread are sampled independently with different hash salts:
noise_std = min(bar_range * price_volatility, base_spread * 4.0)
sampled_spread = max(0, base_spread + N(0,1) * noise_std)
where the Gaussian sample is deterministic (hash-based, per tick). Noise width scales with each
bar's own high-low range, not a fixed fraction of base_spread — a calm bar barely perturbs the
spread, a volatile bar can swing it substantially — capped at 4x base_spread (matching typical
real-world spread widening in volatile conditions) so the zero-floor clamp above stays a rare event
instead of dragging the realized mean upward.
Short-circuits, in order: base_spread <= 0 returns 0; price_volatility <= 0 returns
base_spread exactly; bar_range < 1e-12 (flat bar) also returns base_spread exactly.
Slippage is sampled independently, with its own hash salt and a different formula:
sampled_slippage = base_slippage + N(0,1) * bar_range * price_volatility
Unlike spread, slippage noise is uncapped and unclamped — there is no ask/bid-crossing invariant to
protect, since slippage is a fill-price adjustment rather than part of quote construction.
Occasional favorable (negative) slippage is realistic price improvement, and real-world slippage
during liquidity stress can spike far more than typical spread widening, so it is neither floored
at 0 nor capped. The same short-circuits apply: base_slippage <= 0 returns 0;
price_volatility <= 0 or bar_range < 1e-12 returns base_slippage exactly.
In addition, an intra-bar spread multiplier widens the spread at bar open and narrows it linearly toward the base value at bar close (this multiplier applies only to spread, not slippage):
spread_mult = 1 + (bar_range / (bar_range + cfg.spread)) × (1 − progress)
effective_spread = sampled_spread × spread_mult
where progress = tick_index / (tick_count − 1). This causes spreads to be wider near the open
and tighter near the close of each bar.
Bid/ask are derived from the raw tick depending on OhlcvType:
| OhlcvType | tick_bid | tick_ask |
|---|---|---|
| Bid | raw_tick | raw_tick + spread |
| Ask | raw_tick − spread | raw_tick |
| Midpoint | raw_tick − spread/2 | raw_tick + spread/2 |
2. Fill Price Rules
All fills apply slippage on top of the prevailing bid or ask.
| Order type | Trigger condition | Fill price |
|---|---|---|
| Market buy | always | tick_ask + slippage |
| Market sell | always | max(0, tick_bid − slippage) |
| Buy limit | tick_ask <= limit_price |
tick_ask + slippage |
| Sell limit | tick_bid >= limit_price |
max(0, tick_bid − slippage) |
| Buy stop | tick_ask >= stop_price |
tick_ask + slippage |
| Sell stop | tick_bid <= stop_price |
max(0, tick_bid − slippage) |
| Long TP exit | tick_bid >= take_profit |
max(0, tick_bid − slippage) |
| Long SL exit | tick_bid <= stop_loss |
max(0, tick_bid − slippage) |
| Short TP exit | tick_ask <= take_profit |
tick_ask + slippage |
| Short SL exit | tick_ask >= stop_loss |
tick_ask + slippage |
Consequences:
- Buy limit fill price
≤ limit_price + slippage(sincetick_ask ≤ limit_priceat trigger) - Sell limit fill price
≥ limit_price − slippage(sincetick_bid ≥ limit_priceat trigger) - Fill prices can exceed raw bar OHLCV range by up to
sampled_spread + slippagein each direction
3. Slippage Cost
slippage_cost = |fill_price − reference_price| × qty
where reference_price is the bid or ask at the fill tick (the price that would be obtained without
slippage). For a market buy: reference_price = tick_ask; for a sell: reference_price = tick_bid.
Note: slippage_cost captures only the slippage component (slippage × qty). The bid/ask spread
cost is embedded in fill_price relative to raw bar prices but is not separately tracked in
slippage_cost.
4. Commission
Commission is applied per fill based on CommissionMode:
| Mode | Open leg | Close leg |
|---|---|---|
| RoundTrip | qty × rate |
qty × rate |
| OpenOnly | qty × rate |
0 |
| CloseOnly | 0 | qty × rate |
5. Accounting Identities
gross_pnl = (exit_fill − entry_fill) × qty × sign (sign = +1 long, −1 short)
net_pnl = gross_pnl − entry_fees − exit_fees
At end of backtest with all positions force-closed:
result.net_pnl = sum(ct.net_pnl for all closed_trades)
result.total_fees = sum(ct.fees for all closed_trades)
result.total_slippage = sum(ct.slippage for all closed_trades)
Tolerance: 1e-6.
6. Order Time-in-Force
| TIF | Behavior |
|---|---|
| GTC | Stays open until filled, cancelled, or end of backtest |
| IOC | Must fill on the bar it arrives; cancelled at end of that bar's tick sequence if not |
| GTD | Expires when bar_epoch >= expiry_epoch; status = Expired |
Same-bar fill semantics: an order submitted via submitBarOrders() at bar N has
created_ts = bar_N.epoch. It is eligible for ticks 1 … N−1 of bar N (tick 0 = bar.open
is never used for fills — the fill loop starts at cursor = 1). A same-bar fill occurs when
the trigger condition is met on any of those ticks.
Tick count per bar equals the time delta in seconds to the next bar timestamp
(bars[i+1].ts − bars[i].ts). A 1-minute bar has 60 ticks; a daily bar has 86,400.
Market orders have no price condition and fill unconditionally at tick 1. Limit/stop orders fill at the first tick where their price condition is satisfied (tick 1 through N−1).
6a. on_tick Fill Semantics (C++ replay engine — not user-accessible from Python strategies)
on_tick is used internally by the C++ replay engine (GUI interactive path). It is not
callable from Python strategy code — Python strategies use only on_bar.
Orders submitted via the on_tick C++ path are processed at the tick identified by
tick.tick_index and fill against that tick's bid/ask prices:
- Market orders fill unconditionally at
tick.tick_index, not at tick 1. - Limit/stop orders are eligible from
tick.tick_indexonward within the same bar. - IOC orders not filled at
tick.tick_indexare cancelled at end of that tick. - All normal commission, slippage, and margin rules apply.
on_tick orders and on_bar orders coexist. on_bar orders are submitted at bar
open (eligible tick 1+); on_tick orders are submitted at their respective tick.
The tick prices used in the on_tick C++ path are guaranteed identical to the prices used
for execution in the replay pass — both use the same SyntheticTickGenerator seeded by
rng_seed.
7. Bracket Exit Collision
When both TP and SL are set and both are touched by the same bar's tick sequence, the first one
encountered in tick order wins. The tick sequence is deterministic given rng_seed and bar data.
The winning exit is the one whose trigger condition is first satisfied. Both TP and SL being
touched means whichever happens at a lower tick index is the exit.
8. Gap Execution
When a bar opens beyond a pending order's trigger level, tick 1 of that bar will trigger
and fill the order. Tick 1 is the first interpolated price after bar.open — very close to
bar.open but not exactly equal to it. There is no additional slippage adjustment for gap
magnitude beyond cfg.slippage.
9. Overnight Swap
Computed once per calendar date boundary in the bar sequence.
Long positions: equity decreases by qty × swap_per_unit[weekday].
Short positions: equity increases by the same amount.
10. Margin Model
Order rejected if: new_notional + existing_notional > equity × leverage
where new_notional = qty × fill_price and existing_notional is the sum of all open position
notionals. Rejected orders have a non-empty reject_reason.
11. Partial Closes
A market sell order with 0 < qty < position.qty triggers a partial close:
- A
ClosedTradeis recorded forqtyunits at the fill price and the original entry price. position.qtyis reduced byqty; the position remains open with its originalentry_price.- Commission and slippage are computed on
qtyonly. - A subsequent full-close (
qty=0) or another partial close may follow.
Rejection rules:
qty <= 0on a close-side order (theqty=0convention) means "close the full position" — not a partial close.
Netting reversal:
qty > position.qtyon any close-side order (includingclose_position(qty=N)) triggers a netting reversal: the existing position is fully closed and the excess qty opens a new position in the opposite direction. Example: long 10, sell 15 → close long 10, open short 5. Commission and slippage are split proportionally between the close leg and the open leg.
Same-side orders:
- Same-side orders (buy while long, sell while short) scale into the existing position:
position.qtyincreases by the filled qty,entry_fill_priceis updated to the weighted average of the previous entry and the new fill ((prev_price × prev_qty + fill_price × fill_qty) / (prev_qty + fill_qty)). Margin check applies to the incremental notional.
The PnL identity holds across partial closes: the sum of net_pnl over all partial ClosedTrades for a position equals the net_pnl that a single full-close would have produced.
12. Futures Roll-Cycle Rebasing
Disabled by default (roll_cycle_months empty) — behavior is then byte-identical to a build
without this feature. Enabling it is per-ticker, via the same ticker_overrides resolution
described in Section 0: roll_cycle_months (a list of months, 1=Jan..12=Dec) and
roll_days_before_monthend (default 5) declare a raw/naive continuous-contract series' recurring
roll dates so the engine can rebase execution-model bookkeeping across each boundary instead of
treating the raw price splice as real price movement.
Roll date rule. For each month in roll_cycle_months, in each candidate year: take that
month's last calendar day, then step backward one calendar day at a time — counting only Mon–Fri,
skipping Sat/Sun without counting them, and never counting the starting day itself — until
roll_days_before_monthend business days have been counted. The roll boundary is the epoch
(UTC midnight) reached at that count. With roll_days_before_monthend=5 (default) and a cycle
month whose last calendar day is a Friday, the boundary is the Friday of the preceding week.
Trigger condition. A ticker's next roll boundary is resolved once and cached; the rebase for
a given step fires when that step's timestamp reaches or passes the cached boundary
(RollBoundaryCrossed). If more than one boundary falls within a single step's gap (only possible
with an unusually coarse bar interval relative to roll_days_before_monthend), only one ratio is
ever applied for that step — the cache advances past every such boundary, but the ratio itself is
computed once, from that step's own bar pair.
Ratio measurement and application.
ratio = curr_bar.open / prev_bar.close
where prev_bar and curr_bar are that ticker's bars immediately before and at the step
containing the roll boundary. If prev_bar.close <= 0, the rebase is skipped entirely for that
boundary — no fields are modified and no RollEvent is logged. Otherwise, ratio is applied by
multiplication to:
- the open position, if any:
entry_fill_price,take_profit(if set),stop_loss(if set) - every resting order on that ticker:
limit_price,stop_price,take_profit,stop_loss(each only if already set / nonzero)
No other position or order field is touched. Commission, slippage, and margin calculations on any subsequent fill use the rebased levels exactly as they would any other configured price level — Sections 2–4 and 10 apply unchanged.
What is not rebased. The raw bar series a strategy reads via tv.close/open/high/low is
never modified — this is forward-only bookkeeping rebase, not a historical rewrite. A strategy
computing its own indicators directly from tv.close still sees the raw splice jump at the roll
boundary; only the engine's own position/order price levels are corrected.
Bracket re-scheduling. Take-profit/stop-loss crossing ticks are pre-scheduled at bar-seal time
using whatever take_profit/stop_loss stood at that moment. Because the rebase for a given step
runs after that step's own bar has already sealed, its bracket schedule was decided against the
stale, pre-rebase levels — the engine re-schedules immediately after rebasing so the already-current
bar is re-evaluated against the corrected levels. The single exception: the roll-boundary step's own
bracket schedule was locked in before the rebase ran and is not itself revisited — a narrow,
deliberate edge case, not an oversight.
Logging. Every applied rebase appends one RollEvent (ticker, timestamp of the boundary,
ratio) to result.roll_log, unconditionally — there is no separate mode to opt into this
visibility. A ticker with roll_cycle_months configured that never actually crosses a boundary
during the run produces no entries; roll_log is empty (and, in the .reamer JSON file, omitted
entirely) whenever no ticker rolled.
13. Exogenous Data Resolution
Attaches an arbitrary, freeform timeseries to a ticker via the run's sparse exogenous map
(dict[str, str], ticker → .exo.bin sidecar path), independent of data/ticker_overrides. A
ticker with no entry in exogenous behaves as if the feature does not exist for it: tv.exogenous
is always '' and tv.exogenous_valid is always False.
Storage. Each .exo.bin sidecar holds one ticker's entries, sorted strictly ascending by epoch
second with no duplicate timestamps (the CSV loader enforces this at write time: on duplicate
timestamps in the source CSV, the last line wins). Each entry's value is stored as raw bytes at a
recorded byte offset and length — the engine never parses, validates, or imposes a schema on the
content.
Resolution. A per-ticker cursor starts unresolved (exogenous_valid = False) and advances
forward only, once per step, using the same "advance forward, never re-search" technique used for
bar indexing elsewhere in the engine:
while next_entry.ts <= current_step_ts: advance cursor to next_entry
Once the cursor has advanced past index -1 (i.e. at least one entry's timestamp is <= the
current step's timestamp), tv.exogenous_valid becomes True for that ticker and remains True
for the rest of the run — it never reverts to False once set, even though the specific entry the
cursor points to keeps advancing as later entries become current. tv.exogenous always reflects
the latest entry whose timestamp is at or before the current step, decoded as UTF-8 text —
verbatim, with no interpretation. Parsing that text (json.loads or otherwise) is the strategy's
responsibility; a malformed value raises a normal Python exception out of on_bar, not an
engine-level failure.
Before the first entry. For every step strictly before a ticker's first exogenous timestamp,
and for the entire run on any ticker with no attached series, tv.exogenous is '' and
tv.exogenous_valid is False — indistinguishable at the API level; both cases require checking
.exogenous_valid before use, exactly like .valid.
No execution-model interaction. Exogenous data is read-only context for strategy code. It has no effect on fill price, slippage, commission, margin, or any other rule in this document, and attaching a series to a ticker does not itself submit, modify, or reject any order — Sections 1–12 apply identically whether or not a ticker has exogenous data attached. It is also not counted against the free-tier processed-bar budget: only OHLCV bars are billable.