QuantJourney Backtester

QuantJourney Backtester

Share product feedback

✓

Thank you.

Your note is now in the QuantJourney inbox.

docs/engine/stop-loss.mdx

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.

Sourcebacktester/execution/order_types.py + backtester/execution/fill_engine.py + strategies/stop_orders.py
LayerOrder execution / protective exit
ModeOrder mode
InputOrderType.STOP, side, quantity, stop price, OHLC bar
Outputfill when high/low crosses the stop level
Primary APIOrder(..., order_type=OrderType.STOP, stop_price=...)
Main caveatA stop price is a trigger level, not a guaranteed fill 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

text
bar.open  = 100
bar.high  = 112
bar.low   = 94
bar.close = 105

take_profit = 110
stop_loss   = 95

This 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.

Entry, stop placement and signal exit
python
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.

Sell stop-limit
python
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.close when 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_LIMIT used where a plain stop was intended?
  • Did a daily bar touch both TP and SL?