Stop Orders and Stop Loss
Stop-loss execution semantics, state contract, gap-through behavior and daily-bar caveats.
A stop-loss is not a separate engine. It is a stop order used as a protective exit. The strategy submits the stop after the entry has actually filled, and the fill engine keeps it pending until price crosses the stop level or the strategy cancels it.
backtester/execution/order_types.py + backtester/execution/fill_engine.py + strategies/stop_orders.pyOrderType.STOP, side, quantity, stop price, OHLC barOrder(..., order_type=OrderType.STOP, stop_price=...)State Contract
- A signal does not create a position.
- A submitted entry order does not create a position.
- A filled entry creates a position.
- A protective stop should be attached only after the position is visible in portfolio state.
- A signal-based exit must cancel resting stop, limit, bracket or OCO child orders.
- A pending stop does not change NAV, cash or position until it fills.
Fill Rules
For a long position:
- A sell stop triggers when
bar.low <= stop_price. - If the next bar gaps below the stop, theoretical fill is
min(stop_price, bar.open). - The stop price is a trigger level, not a guaranteed fill price.
For a short position:
- A buy stop triggers when
bar.high >= stop_price. - If the next bar gaps above the stop, theoretical fill is
max(stop_price, bar.open). - The stop price is a trigger level, not a guaranteed fill price.
Daily-Bar Execution Boundary
bar.open = 100
bar.high = 112
bar.low = 94
bar.close = 105
take_profit = 110
stop_loss = 95This bar proves that both levels were touched. It does not prove which level was touched first. A daily-bar backtest therefore applies the documented fill priority. For tick-sensitive stop behavior, use intraday bars.
Protective Stop Pattern
The real example in strategies/stop_orders.py uses a two-phase pattern: entry first, protective stop after the fill is visible.
from backtester import Backtester
from backtester.execution import Order, OrderSide, OrderType
class StopOrderStrategy(Backtester):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._has_stop = {}
self._prev_signal = {}
def _compute_orders(self, date, bars, current_positions, nav):
fast = self.instruments_data.get_feature("SMA_20_close")
slow = self.instruments_data.get_feature("SMA_50_close")
for inst in self.instruments:
if date not in fast.index or inst not in fast.columns:
continue
pos = current_positions.get(inst, 0.0)
signal = 1 if fast.loc[date, inst] > slow.loc[date, inst] else 0
prev = self._prev_signal.get(inst, 0)
if pos == 0 and self._has_stop.get(inst, False):
self._has_stop[inst] = False
if signal == 1 and prev == 0 and pos == 0:
self.order_percent(inst, 0.15)
elif pos > 0 and not self._has_stop.get(inst, False):
entry = self.get_average_entry_price(inst)
if entry is not None:
self.fill_engine.submit(Order(
instrument=inst,
side=OrderSide.SELL,
quantity=pos,
order_type=OrderType.STOP,
stop_price=entry * 0.95,
))
self._has_stop[inst] = True
elif signal == 0 and prev == 1 and pos > 0:
self.fill_engine.cancel_all(instrument=inst)
self.close_position(inst)
self._has_stop[inst] = False
self._prev_signal[inst] = signal
strategy = StopOrderStrategy(
instruments=["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
backtest_period={"start": "2020-01-01", "end": "2025-01-01"},
execution_mode="orders",
)Stop-Limit Variant
Use STOP_LIMIT when you want a stop trigger but refuse fills worse than a limit. If the market gaps through the limit, the order can remain pending.
self.fill_engine.submit(Order(
instrument=inst,
side=OrderSide.SELL,
quantity=pos,
order_type=OrderType.STOP_LIMIT,
stop_price=95.00,
limit_price=94.50,
))Failure Modes
- Placing a protective stop before the entry has filled.
- Computing the stop from
bar.closewhen the actual fill happened at next bar open. - Forgetting to cancel a resting stop after a signal-based market exit.
- Leaving stale OCO or bracket children active after the trade thesis expires.
- Treating a touched stop as guaranteed execution in an illiquid asset.
- Ignoring gap-through behavior.
- Testing intraday stop strategies with daily bars.
- Comparing results across engines without checking same-bar TP/SL priority.
Audit Checklist
- Is stop placement based on actual average entry price?
- Does a signal exit cancel active protective orders?
- Did the stop gap through the trigger level?
- Was
STOP_LIMITused where a plain stop was intended? - Did a daily bar touch both TP and SL?