Docs

Execution Specification

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 (since tick_ask ≤ limit_price at trigger)
  • Sell limit fill price ≥ limit_price − slippage (since tick_bid ≥ limit_price at trigger)
  • Fill prices can exceed raw bar OHLCV range by up to sampled_spread + slippage in 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_index onward within the same bar.
  • IOC orders not filled at tick.tick_index are 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 ClosedTrade is recorded for qty units at the fill price and the original entry price.
  • position.qty is reduced by qty; the position remains open with its original entry_price.
  • Commission and slippage are computed on qty only.
  • A subsequent full-close (qty=0) or another partial close may follow.

Rejection rules:

  • qty <= 0 on a close-side order (the qty=0 convention) means "close the full position" — not a partial close.

Netting reversal:

  • qty > position.qty on any close-side order (including close_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.qty increases by the filled qty, entry_fill_price is 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.