How to write an advanced trend following strategy to algotrade Bitcoin

Trend following is often the easiest in a matter of both getting started with algo-trading and being profitable. In the previous article, I wrote a trend following strategy and even boosted its performance by using a bigger timeframe for determining the trend. But it was a simple enter-at-once and exit-at-once strategy.

The point of this tutorial is to get you started with a few of Jesse's features that help you write most of the trend following strategies you'll find. Few of such features are:

  • Exiting the trade in two points, one being at a fixed price, another at a dynamic price.
  • Updating the stop-loss to break-even after exiting half the position.
  • Filtering trades that have a bad win-to-loss ratio.
  • Using events to hook into life cycles of a trade.

First I create a new strategy with the make-strategy command:

jesse make-strategy TrendFollowingStrategy

And I edit my routes.py file to use this strategy, and I set the timeframe to 4h:

# trading routes
routes = [
    ('Bitfinex', 'BTCUSD', '4h', 'TrendFollowingStrategy'),
]

# in case your strategy required extra candles, timeframes, ...
extra_candles = []

Then I open the newly created strategy file and it looks like this:

from jesse.strategies import Strategy
import jesse.indicators as ta
from jesse import utils


class TrendFollowingStrategy(Strategy):
    def should_long(self) -> bool:
        return False

    def should_short(self) -> bool:
        return False

    def should_cancel(self) -> bool:
        return False

    def go_long(self):
        pass

    def go_short(self):
        pass

Entry rules

I want to go long when:

  • We are in an uptrend (and vice versa for short trades)
  • The closing (current) candle touches the 50 EMA line
def should_long(self) -> bool:
    return self.current_candle_touches_long_ema and self.trend == 1

def should_short(self) -> bool:
    return self.current_candle_touches_long_ema and self.trend == -1

They say an image is worth a 1000 words, so here's an image showing what I count as an uptrend in this case:

uptrend

And here is what I mean by the current candle touching the 50 EMA (the orange line is the 50 EMA):

touch-ema

Now let's write the code for current_candle_touches_long_ema and trend:

@property
def trend(self):
    short_ema = ta.ema(self.candles, 50)
    long_ema = ta.ema(self.candles, 100)
    longer_ema = ta.ema(self.candles, 200)

    if short_ema > long_ema > longer_ema:
        return 1
    elif short_ema < long_ema < longer_ema:
        return -1
    else:
        return 0

@property
def current_candle_touches_long_ema(self):
    long_ema = ta.ema(self.candles, 50)
    return self.high >= long_ema >= self.low

I used three EMA lines to determine the trend direction. I return 1 for an uptrend, and -1 for a downtrend. current_candle_touches_long_ema is pretty simple, I just have to make sure the current candle's high price is bigger than the long_ema line (which is an EMA with the period of 50) and that the current candle's low price is lower than the long_ema line.

Setting entry price and position sizing

The entry price is going to be the high of the current candle for long trades. The stop-loss price will be 3 times the current ATR away from my entry price.

In this strategy, I want to enter trades all at once, but exit at two points. I will exit first half of my position at the previous high of the trend. Here is what I mean by the high of the uptrend (the blue line is my target):

pivot

To code this, I first select the high prices of the last 20 candle bars. And then simply return the maximum of them.

For position sizing, I want to risk 5% of my total capital per each trade. To calculate the qty I use the risk_to_qty utility.

And of course, the opposite goes for short trades. Here's the code:

def go_long(self):
    entry = self.high
    stop = entry - ta.atr(self.candles)*3
    qty = utils.risk_to_qty(self.capital, 5, entry, stop, self.fee_rate)

    # highest price of the last 20 bars
    last_20_highs = self.candles[-20:, 3]
    previous_high = np.max(last_20_highs)

    self.buy = qty, entry
    self.stop_loss = qty, stop
    self.take_profit = qty/2, previous_high

def go_short(self):
    entry = self.low
    stop = entry + ta.atr(self.candles) * 3
    qty = utils.risk_to_qty(self.capital, 5, entry, stop, self.fee_rate)

    # lowest price of the last 20 bars
    last_20_lows = self.candles[-20:, 4]
    previous_low = np.min(last_20_lows)

    self.sell = qty, entry
    self.stop_loss = qty, stop
    self.take_profit = qty / 2, previous_low

Moving stop-loss to break-even

As you can see, I'm only exiting half of my position size at the take-profit price. In other words, after my position is reduced, I want to move my stop price to break-even. To write the code for it in Jesse, I'll use the pre builtin on_reduced_position method.

Updating the self.stop_loss is all I needed to do to tell jesse update my stop-loss order. Jesse automatically picks up on it, cancels the previous stop order, and submits a new one. It couldn't get any easier!

def on_reduced_position(self):
    self.stop_loss = self.position.qty, self.position.entry_price

To learn more about events and see a list of all available event methods in Jesse, make sure to read its documentation.

Exiting the second half of my trade dynamically

For this strategy, I intend to exit the second half of my position in a dynamic situation. The idea behind this situation is to exit when the price is highly overbought and about to take a serious correction. To say it in quant's language, I want to exit when the RSI indicator reaches above 80.

First I'll use the builtin update_position() method to write my logic in. This method gets executed after every new candle only if we have an open position. Hence, it is used to update a position. Which means we don't need to check if the position is open or not.

The next thing to consider here is that I want to exit only the second half of my position. In other words, I want to liquidate the open position if it has been reduced. The easiest way to check if my position has been reduced is by using the builtin is_reduced property and the liquidate() method.

def update_position(self):
    # the RSI logic is intended for the second half of the trade
    if self.is_reduced:
        rsi = ta.rsi(self.candles)

        if self.is_long and rsi > 80:
            # self.liquidate() closes the position with a market order
            self.liquidate()
        elif self.is_short and rsi < 20:
            self.liquidate()

Using a filter

My strategy looks good so far, let's run a backtest and see how it goes:

jesse backtest 2019-01-01 2020-05-01

After around 4% percent of the backtest I get an error:

Uncaught Exception: InvalidStrategy: take-profit(3601.6) must be below entry-price(3601.6) in a short position

The error is trying to tell us at some point our strategy's take-profit and entry prices are equal ($3601.6) which isn't acceptable. This is a tricky issue to debug, but you'll get good at debugging with Jesse after writing your first few strategies.

To explain why this issue is happening, we need to take another look at the take-profit and entry:

def go_long(self):
    # entry: the high of the current candle
    entry = self.high
    stop = entry - ta.atr(self.candles)*3
    qty = utils.risk_to_qty(self.capital, 5, entry, stop, self.fee_rate)

    last_20_highs = self.candles[-20:, 3]
    previous_high = np.max(last_20_highs)

    self.buy = qty, entry
    self.stop_loss = qty, stop
    # (first) take-profit: the high of the last 20 candles
    self.take_profit = qty/2, previous_high

The error told us that the entry and the take-profit are the same at some points. This means, at that point, the current candle's high is the highest of the last 20 bars. Which isn't the type of trade we had in mind for this strategy.

We could prevent this from happening with a few dirty if-else statements in our should_long method, or by using a filter which is designed specificity for these kinds of cases.

A filter is just a function that returns a boolean value. A filter is passed by returning a True value, and vice versa. I define a filter and name it reward_to_risk_filter. The name could be anything, but it is a generally good practice to either start or end the name of a filter method with the word filter. This filter's job is to make sure that the trade we're trying to enter is worth it.

def reward_to_risk_filter(self):
    profit = abs(self.average_entry_price - self.average_take_profit)
    loss = abs(self.average_entry_price - self.average_stop_loss)
    win_loss_ratio = profit / loss
    return win_loss_ratio > 1

At this point, Jesse still doesn't know that reward_to_risk_filter() is a filter. To make it recognize my filter, I need to add it to the filters() method which is a pre builtin method that returns a Python list:

def filters(self):
    return [
        
    ]

Now I'm going to add reward_to_risk_filter inside the returning list as a variable. What that means is that no parentheses must be present at the end of it:

def filters(self):
    return [
        self.reward_to_risk_filter,
    ]

Now let's execute the backtest one more time:

jesse backtest 2019-01-01 2020-05-01

jesse-backtest

This time everything goes smoothly.

Conclusion

The simpler your can keep your strategies, the easier it will be to debug them, and even improve over the time. Writing strategies with Jesse is as simple as trading your strategies manually. So next time you find a strategy introduced in a trading book, or by a trading guru, just write the code for it, and backtest it.

© 2020 jesse-ai.com. All rights reserved.