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 aStop
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 aStopTrail
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 asCompleted
we issue aStop
order with pricestop_loss
percent below theexecuted.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 thebuy
order. This is becausecheat-on-close
ensures it will be executed without waiting for the next barNotice 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 tocheat-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
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 aLimit
orderThe parameter
buy_limit
(when notFalse
) will be a percentage to take off the current price to set the expected buy point. -
Sets
transmit=False
for thebuy
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 thebuy
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 pendingbuy
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
from __future__ import (absolute_import, division, print_function,
unicode_literals)
import argparse
import datetime
import backtrader as bt
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)
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()
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)
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)
APPROACHES = dict(
manual=ManualStopOrStopTrail,
manualcheat=ManualStopOrStopTrailCheat,
auto=AutoStopOrStopTrail,
)
def runstrat(args=None):
args = parse_args(args)
cerebro = bt.Cerebro()
# Data feed kwargs
kwargs = dict()
# Parse from/to-date
dtfmt, tmfmt = '%Y-%m-%d', 'T%H:%M:%S'
for a, d in ((getattr(args, x), x) for x in ['fromdate', 'todate']):
if a:
strpfmt = dtfmt + tmfmt * ('T' in a)
kwargs[d] = datetime.datetime.strptime(a, strpfmt)
data0 = bt.feeds.BacktraderCSVData(dataname=args.data0, **kwargs)
cerebro.adddata(data0)
# Broker
cerebro.broker = bt.brokers.BackBroker(**eval('dict(' + args.broker + ')'))
# Sizer
cerebro.addsizer(bt.sizers.FixedSize, **eval('dict(' + args.sizer + ')'))
# Strategy
StClass = APPROACHES[args.approach]
cerebro.addstrategy(StClass, **eval('dict(' + args.strat + ')'))
# Execute
cerebro.run(**eval('dict(' + args.cerebro + ')'))
if args.plot: # Plot if requested to
cerebro.plot(**eval('dict(' + args.plot + ')'))
def parse_args(pargs=None):
parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description=(
'Stop-Loss Approaches'
)
)
parser.add_argument('--data0', default='../../datas/2005-2006-day-001.txt',
required=False, help='Data to read in')
# Strategy to choose
parser.add_argument('approach', choices=APPROACHES.keys(),
help='Stop approach to use')
# Defaults for dates
parser.add_argument('--fromdate', required=False, default='',
help='Date[time] in YYYY-MM-DD[THH:MM:SS] format')
parser.add_argument('--todate', required=False, default='',
help='Date[time] in YYYY-MM-DD[THH:MM:SS] format')
parser.add_argument('--cerebro', required=False, default='',
metavar='kwargs', help='kwargs in key=value format')
parser.add_argument('--broker', required=False, default='',
metavar='kwargs', help='kwargs in key=value format')
parser.add_argument('--sizer', required=False, default='',
metavar='kwargs', help='kwargs in key=value format')
parser.add_argument('--strat', required=False, default='',
metavar='kwargs', help='kwargs in key=value format')
parser.add_argument('--plot', required=False, default='',
nargs='?', const='{}',
metavar='kwargs', help='kwargs in key=value format')
return parser.parse_args(pargs)
if __name__ == '__main__':
runstrat()