Stop Trading

Trading can be dangerous and trading using stop orders can help into either avoiding big losses or securing profits. backtrader provides you with several mechanisms to implement Stop - based strategies

Basic Strategy

A classic Fast EMA crosses over a Slow EMA approach will be used. But:

Only the up-cross will be taken into account to issue a buy order

Exiting the market, i.e.: sell will be done via a Stop

The strategy will therefore start with this simple skeleton

class BaseStrategy(bt.Strategy): params = dict( fast_ma=10, slow_ma=20, ) def __init__(self): # omitting a data implies self.datas[0] (aka self.data and self.data0) fast_ma = bt.ind.EMA(period=self.p.fast_ma) slow_ma = bt.ind.EMA(period=self.p.slow_ma) # our entry point self.crossup = bt.ind.CrossUp(fast_ma, slow_ma)

And using inheritance we’ll work out different approaches as to how to implement the Stops

Manual Approach

To avoid having too many approaches, this subclass of our basic strategy will allow:

Either having a Stop fixed at a percentage below the acquisition price

Or setting a dynamic StopTrail which chases the price as it moves (using points in this case)

class ManualStopOrStopTrail(BaseStrategy): params = dict( stop_loss=0.02, # price is 2% less than the entry point trail=False, ) def notify_order(self, order): if not order.status == order.Completed: return # discard any other notification if not self.position: # we left the market print('SELL@price: {:.2f}'.format(order.executed.price)) return # We have entered the market print('BUY @price: {:.2f}'.format(order.executed.price)) if not self.p.trail: stop_price = order.executed.price * (1.0 - self.p.stop_loss) self.sell(exectype=bt.Order.Stop, price=stop_price) else: self.sell(exectype=bt.Order.StopTrail, trailamount=self.p.trail) def next(self): if not self.position and self.crossup > 0: # not in the market and signal triggered self.buy()

As you may see, we have added parameters for

The percentage: stop_loss=0.02 (2%)

Or trail=False , which when set to a numeric value will tell the strategy to use a StopTrail

For the documentation on orders see:

Let’s execute our script with a fixed Stop :

$ ./stop-loss-approaches.py manual --plot BUY @price: 3073.40 SELL@price: 3009.93 BUY @price: 3034.88

And the chart

As we see:

When there is an up-cross a buy is issued

When this buy is notified as Completed we issue a Stop order with price stop_loss percent below the executed.price

Result:

The first instance is quickly stopped-out

But because the sample data is one from a trending market … there is no further instance of the price going below the stop_loss percentage

Let’s use the same approach but applying a StopTrail order:

$ ./stop-loss-approaches.py manual --plot --strat trail=20 BUY @price: 3073.40 SELL@price: 3070.72 BUY @price: 3034.88 SELL@price: 3076.54 BUY @price: 3349.72 SELL@price: 3339.65 BUY @price: 3364.26 SELL@price: 3393.96 BUY @price: 3684.38 SELL@price: 3708.25 BUY @price: 3884.57 SELL@price: 3867.00 BUY @price: 3664.59 SELL@price: 3650.75 BUY @price: 3635.17 SELL@price: 3661.55 BUY @price: 4100.49 SELL@price: 4120.66

And the chart

Now we see how this, compared to the previous approach, is not so productive.

Although the market is trending, the price drops several times more than 20 points (our trail value)

And this takes us out of the market

And because the market is trending, it takes time for the moving averages to cross again in the desired direction

Why using notify_order ?

Because this ensures that the order that has to be controlled by the Stop has actually been executed. This may not be a big deal during backtesting but it is when trading live.

Let’s simplify the approach for backtesting, by using the cheat-on-close mode available with backtrader.

class ManualStopOrStopTrailCheat(BaseStrategy): params = dict( stop_loss=0.02, # price is 2% less than the entry point trail=False, ) def __init__(self): super().__init__() self.broker.set_coc(True) def notify_order(self, order): if not order.status == order.Completed: return # discard any other notification if not self.position: # we left the market print('SELL@price: {:.2f}'.format(order.executed.price)) return # We have entered the market print('BUY @price: {:.2f}'.format(order.executed.price)) def next(self): if not self.position and self.crossup > 0: # not in the market and signal triggered self.buy() if not self.p.trail: stop_price = self.data.close[0] * (1.0 - self.p.stop_loss) self.sell(exectype=bt.Order.Stop, price=stop_price) else: self.sell(exectype=bt.Order.StopTrail, trailamount=self.p.trail)

In this case:

The cheat-on-close mode is activated in the broker during the __init__ phase of the strategy

The StopOrder is issued immediately after the buy order. This is because cheat-on-close ensures it will be executed without waiting for the next bar Notice that the closing price ( self.data.close[0] ) is used for the stop, because there is no execution price yet. And we know that it will be the closing price thanks to cheat-on-close

The notify_order method is now purely a logging method which tells us when things have been bought or sold.

A sample run with StopTrail :

$ ./stop-loss-approaches.py manualcheat --plot --strat trail=20 BUY @price: 3076.23 SELL@price: 3070.72 BUY @price: 3036.30 SELL@price: 3076.54 BUY @price: 3349.46 SELL@price: 3339.65 BUY @price: 3362.83 SELL@price: 3393.96 SELL@price: 3685.48 SELL@price: 3665.48 SELL@price: 3888.46 SELL@price: 3868.46 BUY @price: 3662.92 SELL@price: 3650.75 BUY @price: 3631.50 SELL@price: 3661.55 BUY @price: 4094.33 SELL@price: 4120.66

And the chart

Notice that:

The results are very similar but not the same as before This is due to cheat-on-close giving the strategy the closing price (which is non-realistic, but can be a good approximation) instead of the next available price (which is the next opening price)

Automating the approach

It would be perfect if the logic for the orders could be kept together in next and one didn’t have to use cheat-on-close . And it can be done!!!

Let’s use

Parent-Child orders

Note This is part of the Bracket Order functionality. See: Bracket Orders

class AutoStopOrStopTrail(BaseStrategy): params = dict( stop_loss=0.02, # price is 2% less than the entry point trail=False, buy_limit=False, ) buy_order = None # default value for a potential buy_order def notify_order(self, order): if order.status == order.Cancelled: print('CANCEL@price: {:.2f} {}'.format( order.executed.price, 'buy' if order.isbuy() else 'sell')) return if not order.status == order.Completed: return # discard any other notification if not self.position: # we left the market print('SELL@price: {:.2f}'.format(order.executed.price)) return # We have entered the market print('BUY @price: {:.2f}'.format(order.executed.price)) def next(self): if not self.position and self.crossup > 0: if self.buy_order: # something was pending self.cancel(self.buy_order) # not in the market and signal triggered if not self.p.buy_limit: self.buy_order = self.buy(transmit=False) else: price = self.data.close[0] * (1.0 - self.p.buy_limit) # transmit = False ... await child order before transmission self.buy_order = self.buy(price=price, exectype=bt.Order.Limit, transmit=False) # Setting parent=buy_order ... sends both together if not self.p.trail: stop_price = self.data.close[0] * (1.0 - self.p.stop_loss) self.sell(exectype=bt.Order.Stop, price=stop_price, parent=self.buy_order) else: self.sell(exectype=bt.Order.StopTrail, trailamount=self.p.trail, parent=self.buy_order)

This new strategy, which still builds on BaseStrategy , does:

Add the possibility to issue the buy order as a Limit order The parameter buy_limit (when not False ) will be a percentage to take off the current price to set the expected buy point.

Sets transmit=False for the buy order. This means the order won’t be transmitted to the broker immediately. It will await the transmission signal from a child order

Immediately issues a child order by using: parent=buy_order This will trigger transmitting both orders to the broker And will tag the child order for scheduling when the parent order has been executed. No risk of the Stop order executing before the buy order is in place. If the parent order is cancelled, the child order will also be cancelled

Being this a sample and with a trending market, the Limit order may never be executed and still be active when a new signal comes in. In this case the sample will simple cancel the pending buy order and carry on with a new one at the current price levels. This, as stated above, will cancel the child Stop order.

Cancelled orders will be logged

Let’s execute trying to buy 0.5% below the current close price and with trail=30

A sample run with StopTrail :

$ ./stop-loss-approaches.py auto --plot --strat trail=30,buy_limit=0.005 BUY @price: 3060.85 SELL@price: 3050.54 CANCEL@price: 0.00 buy CANCEL@price: 0.00 sell BUY @price: 3332.71 SELL@price: 3329.65 CANCEL@price: 0.00 buy CANCEL@price: 0.00 sell BUY @price: 3667.05 SELL@price: 3698.25 BUY @price: 3869.02 SELL@price: 3858.46 BUY @price: 3644.61 SELL@price: 3624.02 CANCEL@price: 0.00 buy CANCEL@price: 0.00 sell BUY @price: 4073.86

And the chart

The log and the buy/sell signs on the chart show that no sell order was executed without having a corresponding buy order, and that cancelled buy orders where immediately followed by the cancellation of the child sell order (without any manual coding)

Conclusion

Using different approaches for how to trade with stops has been shown. This can be used to avoid losses or secure profit.

Beware: very tight stop orders could also simply have the effect of getting your positions out of the market, if the stop is set within the normal range of movement of the price.

Script Usage

$ ./stop-loss-approaches.py --help usage: stop-loss-approaches.py [-h] [--data0 DATA0] [--fromdate FROMDATE] [--todate TODATE] [--cerebro kwargs] [--broker kwargs] [--sizer kwargs] [--strat kwargs] [--plot [kwargs]] {manual,manualcheat,auto} Stop-Loss Approaches positional arguments: {manual,manualcheat,auto} Stop approach to use optional arguments: -h, --help show this help message and exit --data0 DATA0 Data to read in (default: ../../datas/2005-2006-day-001.txt) --fromdate FROMDATE Date[time] in YYYY-MM-DD[THH:MM:SS] format (default: ) --todate TODATE Date[time] in YYYY-MM-DD[THH:MM:SS] format (default: ) --cerebro kwargs kwargs in key=value format (default: ) --broker kwargs kwargs in key=value format (default: ) --sizer kwargs kwargs in key=value format (default: ) --strat kwargs kwargs in key=value format (default: ) --plot [kwargs] kwargs in key=value format (default: )

The code