Order types and bracket orders
Lesson 21 · about 14 min
With the loop in place, adding order types is a matter of writing down, for each type, the rule that decides whether a bar fills it and at what price. Then a bracket order is a market entry that spawns a stop and a limit, linked so that one cancels the other. This lesson finishes the engine and runs the crossover with a real protective stop for the first time.
Fill rules per order type
For a bar with open O, high H and low L:
| Order | Fills if | At price |
|---|---|---|
| Market | always | O, plus slippage against you |
Limit buy at P |
L ≤ P |
min(O, P): if the bar opens below the limit you get the open |
Limit sell at P |
H ≥ P |
max(O, P) |
Stop buy at P |
H ≥ P |
max(O, P), plus slippage: a gap above the stop fills at the open |
Stop sell at P |
L ≤ P |
min(O, P), minus slippage |
Limits get no slippage: by definition you are filled at your price or better. Stops get slippage because they become market orders when triggered. Both rules are optimistic in one respect: a limit that is touched exactly is assumed to fill, when in reality you may be behind other orders at that price. If you want to be conservative, require L < P strictly.
# src/event_backtest.py (continued). Order, OrderType, Fill and Portfolio are unchanged from the
# previous lesson and stay at the top of the file; fill_price and EventBacktester below replace
# the earlier engine. If you run this block on its own, first: from src.event_backtest import *
from dataclasses import replace
import numpy as np
import pandas as pd
def fill_price(order: Order, bar: pd.Series, slip: float) -> float | None:
o, h, l = float(bar["open"]), float(bar["high"]), float(bar["low"])
if order.order_type is OrderType.MARKET:
return o * (1 + order.side * slip)
if order.order_type is OrderType.LIMIT:
if order.side > 0 and l <= order.price:
return min(o, order.price)
if order.side < 0 and h >= order.price:
return max(o, order.price)
return None
if order.order_type is OrderType.STOP:
if order.side > 0 and h >= order.price:
return max(o, order.price) * (1 + slip)
if order.side < 0 and l <= order.price:
return min(o, order.price) * (1 - slip)
return None
raise ValueError(f"unknown order type {order.order_type}")
Priority and one-cancels-other
When several pending orders could fill on the same bar, the engine needs a rule for which goes first. The conservative convention from lesson 1: stops before limits, so that when a bar touches both the stop and the target, the stop wins. Market orders go first of all, since they fill at the open before anything else in the bar can happen.
When an order in an OCO group fills, its siblings are cancelled. A bracket order is an entry with stop_loss and take_profit set; when it fills, the engine creates the two exit orders in a fresh OCO group and lets them be checked against the remainder of the same bar, because the entry filled at the open and the stop could be hit by the close.
PRIORITY = {OrderType.MARKET: 0, OrderType.STOP: 1, OrderType.LIMIT: 2}
class EventBacktester:
def __init__(self, bars: pd.DataFrame, strategy, cash: float = 100_000.0,
cost_bps: float = 5.0, slippage_bps: float = 2.0):
self.bars, self.strategy = bars, strategy
self.portfolio = Portfolio(cash=cash)
self.cost_rate, self.slip = cost_bps / 10_000.0, slippage_bps / 10_000.0
self.pending: list[Order] = []
self.fills: list[Fill] = []
self._next_id, self._next_group = 1, 1
def submit(self, order: Order) -> Order:
assigned = replace(order, order_id=self._next_id)
self._next_id += 1
self.pending.append(assigned)
return assigned
def _bracket_children(self, parent: Order) -> list[Order]:
group, self._next_group = self._next_group, self._next_group + 1
exit_side = -parent.side
children = []
if parent.stop_loss is not None:
children.append(Order(exit_side, parent.qty, OrderType.STOP, parent.stop_loss, oco_group=group))
if parent.take_profit is not None:
children.append(Order(exit_side, parent.qty, OrderType.LIMIT, parent.take_profit, oco_group=group))
return [replace(c, order_id=self._assign_id()) for c in children]
def _assign_id(self) -> int:
oid, self._next_id = self._next_id, self._next_id + 1
return oid
def _execute(self, t: pd.Timestamp, bar: pd.Series) -> None:
queue = sorted(self.pending, key=lambda o: PRIORITY[o.order_type])
self.pending = []
while queue:
order = queue.pop(0)
price = fill_price(order, bar, self.slip)
if price is None:
self.pending.append(order)
continue
fill = Fill(t, order.order_id, order.side, order.qty, price, abs(order.qty) * price * self.cost_rate)
self.fills.append(fill)
self.portfolio = self.portfolio.apply(fill)
if order.oco_group is not None:
queue = [o for o in queue if o.oco_group != order.oco_group]
self.pending = [o for o in self.pending if o.oco_group != order.oco_group]
if order.stop_loss is not None or order.take_profit is not None:
queue = sorted(queue + self._bracket_children(order), key=lambda o: PRIORITY[o.order_type])
def cancel_all(self) -> None:
self.pending = []
def run(self) -> pd.DataFrame:
equity = np.empty(len(self.bars))
for i, (t, bar) in enumerate(self.bars.iterrows()):
self._execute(t, bar)
equity[i] = self.portfolio.equity(float(bar["close"]))
for order in self.strategy.on_bar(i, self.bars, self.portfolio, self):
self.submit(order)
return pd.DataFrame({"equity": equity}, index=self.bars.index)
Two changes from the previous lesson besides the fill rules. The queue is re-sorted whenever bracket children are added, so a spawned stop is checked before a spawned limit. And on_bar now receives the engine as a fourth argument so a strategy can call cancel_all() when it exits on signal and needs to remove a resting bracket.
A crossover with a real stop
from src.data import synthetic_ohlcv
from src.indicators import sma, atr
class BracketCrossover:
def __init__(self, fast=20, slow=50, atr_mult=2.0, risk_fraction=0.01):
self.fast, self.slow, self.atr_mult, self.risk_fraction = fast, slow, atr_mult, risk_fraction
self._signal = self._atr = None
def on_bar(self, i, bars, portfolio, engine):
if self._signal is None:
self._signal = (sma(bars["close"], self.fast) > sma(bars["close"], self.slow)).to_numpy()
self._atr = atr(bars).to_numpy()
want_long, close, a = bool(self._signal[i]), float(bars["close"].iloc[i]), float(self._atr[i])
if np.isnan(a):
return []
if want_long and portfolio.qty == 0 and not engine.pending:
stop_distance = self.atr_mult * a
qty = np.floor(self.risk_fraction * portfolio.equity(close) / stop_distance)
qty = min(qty, np.floor(0.95 * portfolio.equity(close) / close)) # never exceed cash
if qty <= 0:
return []
return [Order(+1, qty, stop_loss=close - stop_distance, take_profit=close + 3 * stop_distance)]
if not want_long and portfolio.qty > 0:
engine.cancel_all() # remove the resting bracket
return [Order(-1, portfolio.qty)]
return []
bars = synthetic_ohlcv(1500, seed=42)
engine = EventBacktester(bars, BracketCrossover())
curve = engine.run()
fills = pd.DataFrame([f.__dict__ for f in engine.fills])
print(f"final equity {curve['equity'].iloc[-1] / 100_000:.3f}")
print(fills.groupby("side").size().rename({1: "buys", -1: "sells"}))
The stop is placed relative to the close at decision time, not the fill; a real bracket placed with the entry order works the same way. Sizing uses the Risk Management course formula directly: quantity = dollars at risk ÷ stop distance in price units, capped so the notional never exceeds cash. The not engine.pending guard stops the strategy from stacking a second entry while the first is waiting for its open.
Which exit fired
exits = fills[fills["side"] == -1]
print(f"{len(exits)} exits; average exit price {exits['price'].mean():.2f}")
That is as far as the fills table can take you, because the engine does not yet record why a fill happened. Add an order_type field to Fill, populate it in _execute, and you can count stop exits, target exits and signal exits separately. That breakdown is the first thing to look at when a bracketed strategy underperforms its unbracketed version: if most exits are stops, the stop is too tight for the instrument's noise.
Key idea: Each order type is one fill rule against the bar's open, high and low. Stops are checked before limits so a bar that touches both resolves conservatively. A bracket is an entry that spawns an OCO pair of exits, active from the same bar it fills.
What is still missing
Partial fills, order rejection for insufficient margin, short-sale availability, multiple instruments, and intrabar sequencing finer than "stop before limit". Each is an afternoon's work in this structure, and none is needed for daily-bar single-instrument strategies. The engine is small enough to read in full, which is the property that matters most: when a result surprises you, you can find out why.
Try it: Add
order_typetoFill, then runBracketCrossoverwithatr_multof 1, 2 and 4. For each, print the number of stop exits, target exits and signal exits, and the final equity. Write one sentence about what the tightest stop did to the trade count and what it did to the result.
Recap
- Limit fills at your price or better with no slippage; stops fill at the trigger or the gap open, with slippage.
- Priority is market, then stop, then limit, so same-bar ambiguity resolves to the stop.
- OCO groups cancel siblings on fill; a bracket entry spawns an OCO stop and target, checked against the rest of the entry bar.
- Size bracket entries by dollars at risk over stop distance, capped by available cash.
- Record why each fill happened so that stop, target and signal exits can be counted separately.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.