The Simulated Venue: How Kestrel Fills Orders Without Lookahead

When exactly does a simulated order fill, and at what price? A backtest that fills at the wrong bar quietly lies to you.

Intro

The Quiet Lie of Backtests

Here is a backtest bug that never throws an error. Your strategy looks at a bar, decides to buy, and the simulator fills the order at that same bar's close. The close was not known when the decision was made. The result looks wonderful, and it is entirely fiction.

  • Decision bar vs fill bar
  • Close was not yet known
  • No error, just fiction

Today's Question

Today we answer one precise question: when, and at what price, does a simulated order fill in Kestrel? The answer is three small rules, a handful of price formulas, and a strict ordering of reports. Last episode we tracked positions and average cost. Now we build the venue that creates the fills those positions consume.

  • When does an order fill?
  • At what price?
  • In what order are reports sent?

Educational Note

A quick note before we begin. This series teaches code and design decisions, not trading advice. Every number you see is illustrative, and nothing here suggests that any strategy makes money. We are only checking that the simulator tells the truth about what could have happened.

  • Educational only
  • Numbers are illustrative

The Problem

Bars Hide Time

A bar summarises a whole interval as four prices. Open, high, low and close. Inside that interval the order of events is hidden. The high might have come before the low, or after. Because of that, the only honest moments in a bar are its open, which is known at the start, and its close, known at the end.

  • Open, high, low, close
  • Path inside is unknown
  • Only open and close are anchored

Decision Time vs Fill Time

When an alpha sees bar number five, bar five has already finished. Any order it sends can only reach the market afterwards. So the earliest honest fill is during bar six. The simulator must make it impossible for an order to touch the bar that produced the decision, even by accident.

  • Decision happens at bar end
  • Order reaches market after
  • Earliest fill is next bar

Venue Structure

Inside SimulatedVenue

The SimulatedVenue keeps three pieces of state. First, working orders in a std map keyed by order id. Second, the start time of the latest bar seen for each instrument. Third, a pending queue of execution reports waiting to be collected. Nothing else drives behaviour, which keeps it easy to reason about.

  • working_ keyed by id
  • Last bar start per instrument
  • pending_ report queue

Why a Sorted Map

Why a std map rather than a hash map? Iteration order. A map visits orders in ascending id, always. If two orders could both fill on the same bar, they fill in the same sequence on every run, on every machine. A hash map would give you an arbitrary order, and arbitrary order is the enemy of reproducible backtests.

  • Ascending id iteration
  • Same sequence every run
  • Determinism by construction

Submit and Cancel

On submit, the venue queues a New report, or a Rejected report if the instrument is unknown or expired. It also remembers the start time of the last bar it had seen, called lastBarStartAtSubmit. That single timestamp is the anchor for the first anti-lookahead rule. Cancel is simpler: it queues a Cancelled report immediately.

  • Submit: New or Rejected
  • Remember lastBarStartAtSubmit
  • Cancel: reported immediately

Eligibility Rules

Three Gates

When a bar arrives through onBar, the venue asks which working orders are even allowed to trade on it. There are three rules, and an order must pass all of them. Think of three gates in a row. Fail one, and the order simply waits for a later bar.

  • Three rules, all must pass
  • Fail one and the order waits

Rule One: Not the Bar in Progress

Rule one: the bar's start must be strictly greater than lastBarStartAtSubmit. The order was sent while some bar was the latest known. That bar, and anything older, is off limits. Only a bar that started afterwards can fill it. This is the rule that crosses out the decision bar.

  • bar.start > lastBarStartAtSubmit
  • Never the bar in progress

Rule Two: Born Before the Bar

Rule two: the bar's start must be at least the order's createdAt. This matters when instruments have different bar widths. Imagine a fifteen minute bar that started at nine fifteen, and an order created at nine twenty from a one minute alpha. The fifteen minute bar began before the order existed, so it cannot fill it.

  • bar.start >= createdAt
  • Matters with mixed bar widths
  • No bar older than the order

Rule Three: Optional Latency

Rule three is optional. If the config sets decisionToExchangeMs, each order gets an eligibleFrom time, and the bar's start must be at or after it. That models the delay between deciding and reaching the exchange. With the default of zero, this rule never blocks anything.

  • bar.start >= eligibleFrom
  • decisionToExchangeMs, default off

The Eligibility Code

Here is the loop, as it appears in the engine. For every working order, in ascending id, it checks the instrument matches, then the three timing rules. There is one more clause for quote driven mode, where limit orders are skipped on bars because they are handled against quotes instead. Passing orders collect into a list of ids.

  • Loop in ascending id
  • Three timing rules
  • Quote-driven limits skipped

Fill Prices

Market Orders

Now the price. A market order fills at the adverse version of the bar's open. Adverse means slippage is applied against you. The open is the first price the order can honestly see on its first eligible bar, so it is the natural anchor.

  • Market fills at adverse(open)
  • Open is the first visible price

The Adverse Function

The adverse function is where slippage lives. For a buy, take the price times one plus s, then round up to the tick. For a sell, take the price times one minus s, then round down to the tick. Here s is slippage in basis points divided by ten thousand. Rounding always goes against you, so slippage can never improve a price.

  • Buy: round up after raising
  • Sell: round down after lowering
  • s = slippageBps / 10,000

Slippage Defaults

Slippage is set per asset class. The defaults are two basis points for equity, one for futures, and five for options, where spreads are typically wider relative to price. These are simply the code's defaults, and they are configurable. They are a modelling assumption, not a measurement of any real market.

  • Equity 2 bps
  • Future 1 bps
  • Option 5 bps

Worked Example: Market Buy

Let's compute one. A market buy, next bar opens at twenty five hundred. Slippage is two basis points, so s is point zero zero zero two. Twenty five hundred times one point zero zero zero two is twenty five hundred point fifty. The tick is five paise, and twenty five hundred point fifty is already a multiple of it, so the fill is twenty five hundred point fifty rupees.

  • 2500 x 1.0002 = 2500.50
  • Tick 0.05, already aligned
  • Fill at 2500.50

Limit Buy: Three Scenarios

A limit buy has three possible outcomes on its first eligible bar. If the open is already at or below the limit, the market gapped through it, and you fill at the open, which is a better price. If the open is above but the low reaches the limit, you fill at the limit, passively. Otherwise the bar missed, and the order keeps working.

  • Open <= limit: fill at open
  • Low <= limit: fill at limit
  • Otherwise: keep working

The Limit Branch in Code

The code is compact. If open is at or below the limit, the price is the open. Else if low is at or below the limit, the price is the limit, the fill is flagged passive, and a touchOnly flag records whether the low went through the limit by less than a tick. That flag feeds the optional queue model.

  • Gap-through gives better price
  • Passive fill at the limit
  • touchOnly: within one tick

Worked Example: Limit Buy

Numbers. A limit buy at twenty four ninety five. The next bar opens at twenty four ninety eight, with a low of twenty four ninety four. The open is above the limit, so no gap through. The low is below the limit, so the order fills at the limit, twenty four ninety five, flagged passive. Notice there is no slippage here: a resting limit order does not pay it.

  • Limit 2495, open 2498, low 2494
  • Open above limit: no gap
  • Fill at 2495, passive

Stop Orders

Stops trigger rather than rest. A buy stop at S fills at the adverse open if the open is already at or above S, because the market gapped over the trigger. Otherwise, if the high reaches S, it fills at adverse S. Sells are symmetric, using the low. In both cases slippage applies, because a triggered stop becomes a market order.

  • Open >= S: adverse(open)
  • High >= S: adverse(S)
  • Sells use low, symmetric

Participation and IOC

Volume Is Finite

A bar has finite volume, and a simulator that fills ten thousand shares on a bar that traded two hundred is cheating. So there is a participation rate. When it is above zero and the bar has volume, the venue caps the fill at the larger of one lot, or the rate times volume rounded down to a lot.

  • participationRate > 0, volume > 0
  • cap = max(lot, round down rate x volume)

Worked Example: Cap of 123

Say the rate is point one, the bar volume is twelve hundred thirty four, and the lot size is one. Point one times twelve thirty four is one twenty three point four. Rounding down to a lot gives one twenty three. That is larger than one lot, so the cap is one hundred twenty three. An order for five hundred fills one twenty three now.

  • 0.1 x 1234 = 123.4
  • Round down to lot: 123
  • Order of 500 fills 123

Partial Fills Keep Working

The unfilled remainder stays in the working map, with the same id and the same original eligibility. On the next bar it can fill again, up to that bar's cap. Notice the lastBarStartAtSubmit rule still holds, so the remainder can only trade on later bars, never revisit the same one.

  • Remainder stays in working_
  • Fills again on later bars
  • Never re-trades the same bar

Immediate-or-Cancel

An IOC order is different. It gets exactly one chance: the first eligible bar. Whatever fills fills, and the venue cancels the remainder after that bar. This lets a strategy say, take what the market offers right now and forget the rest, without leaving stale orders behind.

  • IOC: first eligible bar only
  • Remainder is cancelled

Report Ordering

Applying a Fill

Once a price and quantity are decided, applyFill computes the cost of the trade. How that cost is calculated is the subject of the next episode, so we treat it as a black box here. Then comes a subtle but crucial detail about the order of what gets reported.

  • applyFill computes cost
  • Cost model: episode 11

Fill First, Update Second

The venue queues the Fill first, and then the PartiallyFilled or Filled order update second. That ordering is part of the determinism contract. Downstream, positions consume the Fill and the order manager consumes the update. If the order flipped between runs, state would transition differently, and replays would diverge.

  • Queue Fill first
  • Then PartiallyFilled or Filled
  • Report order is a contract

Draining the Queue

Reports sit in pending until the engine collects them, as ExecutionReports, which are either an OrderUpdate or a Fill. Because the working map, the eligibility loop, and the queue are all ordered, the same inputs produce a byte for byte identical stream of reports. That is what lets golden tests, which we meet in episode sixteen, compare whole runs.

  • ExecutionReport: OrderUpdate or Fill
  • Same inputs, same reports
  • Golden tests: episode 16

Realism Knobs

Knobs, All Off by Default

The basic venue is deliberately optimistic in some ways, so Kestrel offers realism knobs. All of them are off by default, and with them off, the golden test outputs are bit identical to before they existed. You opt in explicitly, and you can see exactly what each one changes.

  • All default to off
  • Goldens unchanged when off
  • Opt in explicitly

Latency Knob

The first knob is decisionToExchangeMs. Set it to, say, five hundred, and an order created at a given moment cannot trade on any bar starting within that half second. On one minute bars this rarely changes much, but with narrower bars it can move a fill to a later bar, and sometimes a worse price.

  • Delays eligibility
  • Can shift the fill to a later bar

Queue Model: Touch

The second knob is queueModel. In touch mode, a passive limit that only touches within a tick of the limit fills at most one minus queueAheadFraction times the volume. The default fraction is one point zero, which means a mere touch never fills. The reasoning: if price only kissed your level, other orders were ahead of you in the queue.

  • Touch within one tick only
  • Cap (1 - queueAheadFraction) x volume
  • Default 1.0: never fills on a touch

Queue Model: Quote

In quote mode, limit orders fill only against two sided quotes, not bars. If the limit is marketable on arrival, it fills at the ask. Once resting, it fills at the limit price. Either way, the quantity is capped by the ask size. That is why the eligibility loop skips limit orders on bars when quote driven.

  • Fills against two-sided quotes
  • Marketable: at the ask
  • Resting: at the limit
  • Capped by askSize

Adverse Selection Report

The third knob is a diagnostic. For passive fills, adverse selection sums sign times next mid minus price, times quantity, times the multiplier. If your resting buys keep being followed by falling prices, this number shows it. Crucially, it only reports. It never changes profit and loss.

  • Passive fills only
  • sum sign x (nextMid - price) x qty x mult
  • Diagnostic, never changes P&L

Paper Uses the Same Venue

One last design point. Paper trading uses PaperVenue, which is a thin wrapper around this very SimulatedVenue. So the fill rules you validate offline are the fill rules your paper session uses. There is no second simulator to drift out of sync, which is the same one engine philosophy from episode one.

  • PaperVenue wraps SimulatedVenue
  • One simulator, two modes

Pitfalls

Three Classic Mistakes

Let's collect the pitfalls. First, filling at the close of the decision bar, which leaks the future. Second, slippage that improves a price, which happens when you round to the nearest tick or apply the wrong sign. Third, forgetting that tick rounding has a direction. Each one flatters results, and none produces an error.

  • Filling at the decision close
  • Slippage that helps you
  • Rounding the wrong way

Rounding Direction Demo

See why direction matters. Suppose a buy lands at twenty five hundred point twenty six. Nearest tick rounding gives twenty five hundred point twenty five, cheaper than the computed price. Kestrel rounds buys up to twenty five hundred point thirty. For a sell, it rounds down. Every rounding step costs you, never helps you.

  • 2500.26 nearest gives 2500.25
  • Buys round up to 2500.30
  • Rounding never helps you

Recap

Recap

To recap. Orders queue as New, remember the last bar start, and wait in an ordered map. Three rules decide eligibility: after the submit bar, not older than the order, and past any latency. Market orders fill at adverse open, limits at the open or the limit, stops at adverse open or trigger. Participation caps quantity, and the Fill precedes its update.

  • Three anti-lookahead gates
  • Adverse, tick-aware prices
  • Fill reported before update

Next: What a Fill Costs

A fill price is only half the story. Every one of these trades also pays brokerage, taxes and charges in India. Next episode, we open the Indian cost model: brokerage, securities transaction tax, GST and friends.

  • Next: episode 11
  • Indian cost model
  • Subscribe for the series