Skip to content
GetProfitable
Search

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_type to Fill, then run BracketCrossover with atr_mult of 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.

Slippage on a market orderA buy order clears four price levels, so the average price paid is worse than the price first quoted.Buy 1,000 shares at marketpricesell orders resting (bar length = size)20.04300 shares20.03200 shares20.01200 shares20.00300 sharesnothing resting at 20.02order sweeps up the bookaverage fill 20.02SLIPPAGE0.02 a share$20.00 in totalintended 20.00Each level fills at its own price; the average is what you really paid.
Slippage on a market order. You click at 20.00, but only 300 shares are resting there, so the rest of the order fills at 20.01, 20.03 and 20.04. The average price paid is 20.02, and that two-cent gap is slippage.
Market, limit and stop ordersA price track crossing a resting limit order below the market and a stop order above it.10410210098PriceTime (the market moves left to right)priceSTOP BUY at 103.00waits above the market; becomes a market order when touchedtriggers hereMARKET ORDERfills at once at 100.60filled hereLIMIT BUY at 98.50rests below; fills only at 98.50 or better
Market, limit and stop orders. A market order buys straight away at whatever price is there. A limit order waits below until the price comes to it, and a stop order sits above and turns into a market order the moment price touches it.
Risk and reward on one tradeA price scale showing an entry with a stop two points below and a target six points above, so the reward band is three times the risk band.PRICETARGET 106.00ENTRY 100.00STOP 98.00REWARDRISK6.00 pointsthree times the risk2.00 pointsthe most you loserisk : reward = 1 : 3
Risk and reward on one trade. One trade on a price scale: the entry sits 2.00 points above the stop and 6.00 points below the target, so the shaded reward band is three times the risk band. The ratio compares what is lost if the stop is hit with what is gained if the target is reached.

Finished this module? Take the module quiz.