Risk-Reward Ratio: Defined & Backtested

The risk-reward ratio measures the potential profit for every dollar risked. It is the ratio between the value at risk and the profit target. For example, if you buy a stock for 10 with a profit target of 12 and set a stop-loss at 9, the risk-reward ratio is 1:2 because you’re risking 1 to make $2.

In this post, I will elaborate on the risk-reward ratio definition above, why it’s only half the equation, and backtest how various risk-reward ratios perform for an equity momentum strategy. Quite a few trading books state that trading is easy: If you are disciplined and always take profits larger than your losses, you can lose more often than you win and still make money. Let’s put this to the test.

Also, credit to Aswath Damodaran for introducing me to a great representation of risk: the Chinese symbol for crisis and opportunity.

What Is The Risk-Reward Ratio?

The risk-reward ratio, often abbreviated as RRR, is the number of dollars at risk compared to the potential profit. Most traders target a RRR, such as 1:2, ahead of placing a trade. Placing a targetted “RRR” trade will take three orders or one bracket order. Let’s review these orders in detail.

The number of dollars at risk, or the value at risk, is the total amount of money that a trader can lose on a trade determined by the stop-loss, not including slippage:

Risk = (Purchase Price – Stop-Loss) * Shares

The potential profit, or the profit target, is the difference between the sell order, usually placed as a limit order, and the purchase price:

Potential profit = (Limit Order – Purchase Price) * Shares

See the example image below for a screenshot of AMD from TradingView demonstrating a risk(red):reward(green) ratio of 2.0.

Risk-Reward Ratio Orders Example

We would need to place three orders with our broker to target a RRR when “going long”:

  1. An entry order generally set as a limit or stop-limit
  2. A risk-limiting low-side order, typically placed as a stop-loss
  3. A profit-taking high-side order, usually set as a limit order to take profit

We would place the opposite when “going short”:

  1. An entry order generally set as a limit or stop-limit
  2. A risk-limiting high-side order, typically placed as a stop-loss
  3. A profit-taking low-side order, usually set as a limit order to take profit

Some brokers support bracket orders, which simplifies the management of risk-reward targetting. Instead of placing three orders, you can create one bracket order.

So what should your risk-reward ratio be? Without knowing your winning percentage, you can’t establish an optimal RRR:

Risk-Reward Ratio: Only Half The Picture

A risk-reward ratio of 1:2 may sound great in theory, but if you’re only successful with 1:4 of your trades, you will be losing money consistently. That’s not what we want!

We have to bring in the average win percentage for the trade. We have a 1:2 RRR and a 50% win percentage. Again a 1:2 RRR means we’re risking 1.00 to make 2.00. If our trades are successful 50% of the time, we can figure out what we expect to make on each trade over the long run:

Expectancy = AverageWin _ WinPercent – AverageLoss _ LossPercent
Expectancy = 2.0 _ 50% – 1.0 _ 50%
Expectancy = 100% – 50%
Expectancy = 50%

This expectancy would indicate that we should expect 0.50 for every 1.00 risk. The math makes logical sense, too. On average, we’ll have one winning trade and one losing trade. If we earn 2.00 on one trade and lose 1.00 on the next trade, we’ll make 1.00 over two trades or 0.50 per trade. Remember, we’re only making 2.00 and not 3.00 as 3.00 is our target, but 3.00 – $1.00 is our profit.

Time is a crucial factor here, too. If two trade setups have the same expectancy, but one trade occurs twice as frequently, the higher frequency trade will make double the profit.

The Optimal Risk-Reward Ratio

Let’s see how our equity momentum strategy performs at various risk-reward ratios.

Our strategy appears to beat the SPY. See the images below for a 10% stop and 2 ATR stop using a 1:2 risk-reward ratio. I’ve ordered the results first by P&L and then by win percentage.

RRRWin%StopPnLDrawdown
40.58%25%$44,411.8027.41%
30.58%25%$43,979.3927.40%
20.58%20%$43,111.4925.52%
30.58%20%$43,098.2926.11%
40.58%20%$42,886.2826.16%
20.58%25%$42,799.5527.54%
10.59%25%$42,697.6227.72%
20.58%15%$41,317.2028.68%
10.58%20%$40,882.0626.02%
10.59%15%$39,924.1327.72%
40.58%15%$38,878.5128.17%
30.58%15%$38,766.1328.49%
40.56%10%$37,107.7626.50%
30.56%10%$36,983.2926.95%
20.56%10%$36,174.6225.71%
10.58%10%$29,568.0025.95%
40.47%5%$28,370.3021.49%
30.47%5%$26,661.1822.11%
20.49%5%$23,676.8719.53%
10.56%5%$17,756.5620.65%
RRRWin%StopPnLDrawdown
159%15%$39,924.1327.72%
159%25%$42,697.6227.72%
158%20%$40,882.0626.02%
258%25%$42,799.5527.54%
358%25%$43,979.3927.40%
458%25%$44,411.827.41%
358%20%$43,098.2926.11%
158%10%$29,568.025.95%
258%20%$43,111.4925.52%
458%20%$42,886.2826.16%
258%15%$41,317.2028.68%
458%15%$38,878.5128.17%
358%15%$38,766.1328.49%
156%5%$17,756.5620.65%
256%10%$36,174.6225.71%
356%10%$36,983.2926.95%
456%10%$37,107.7626.50%
249%5%$23,676.8719.53%
347%5%$26,661.1822.11%
447%5%$28,370.3021.49%
RRRWin%ATRP&LMax Drawdown
10.59%5$41,386.9627.10%
20.57%5$41,157.9927.75%
30.57%5$40,034.0728.22%
10.58%4$38,716.6826.62%
30.56%4$38,639.2327.63%
40.56%4$38,422.2227.85%
20.57%4$37,881.3426.51%
40.57%5$37,637.3127.92%
40.53%3$35,955.1326.12%
30.53%3$35,838.2225.18%
20.54%3$35,420.9524.89%
40.48%2$30,487.5622.30%
10.57%3$29,660.2722.45%
30.48%2$29,253.4622.47%
20.49%2$28,330.3921.87%
10.56%2$27,552.520.18%
40.36%1$21,250.917.18%
30.38%1$19,331.2117.27%
20.42%1$16,188.8917.34%
10.55%1$10,905.3416.06%
RRRWin%ATRP&LMax Drawdown
159%5$41,386.9627.10%
158%4$38,716.6826.62%
257%5$41,157.9927.75%
357%5$40,034.0728.22%
457%5$37,637.3127.92%
157%3$29,660.2722.45%
257%4$37,881.3426.51%
156%2$27,552.520.18%
356%4$38,639.2327.63%
456%4$38,422.2227.85%
155%1$10,905.3416.06%
254%3$35,420.9524.89%
353%3$35,838.2225.18%
453%3$35,955.1326.12%
249%2$28,330.3921.87%
348%2$29,253.4622.47%
448%2$30,487.5622.30%
242%1$16,188.8917.34%
338%1$19,331.2117.27%
436%1$21,250.9017.18%

Risk-Reward Ratio Summarized

While our strategy makes money and beats the benchmark, it’s not due to any stop-loss or risk-reward ratio magic: It’s due to momentum. This is easily seen below by setting our stop-loss to 99%, which is a proxy for no stop-loss or RRR profit-taking:

RRRWin%StopP&LMax Drawdown
20.58%99%$45,448.7224.60%

So the next time someone tells you trading is easy and all you need is excellent money management, dig in. Take what they’re saying and test it over multiple timeframes, instruments, strategies, and markets. I’m not saying RRR targetting and bracket orders don’t work, but I am saying test everything! Also, investing and trading may be the most competitive game in town — in other words, it’s not easy.

The Code

As always, the code will be on the Analyzing Alpha Github

The code is almost identical to the optimal stop loss for stocks posted previously. The only difference is that we rebalanced weekly instead of monthly; I’ve added a profit-taking order and a trade win percentage. I chose to show each of the individual orders, but we could have used a single bracket order instead.

from datetime import datetime, timedelta
import math
import backtrader as bt
from positions.securities import get_security_data, get_securities_data,\
                                                    get_sp500_tickers
from indicators.momentum import momentum

START_DATE = '2010-01-01'
END_DATE = '2019-12-31'
START = datetime.strptime(START_DATE, '%Y-%m-%d')
END = datetime.strptime(END_DATE, '%Y-%m-%d')
BENCHMARK_TICKER = 'SPY'

EXCLUDE_WINDOW = 10
MOMENTUM_WINDOW = 90
MINIMUM_PERIOD = MOMENTUM_WINDOW + EXCLUDE_WINDOW
POSITIONS = 20
USE_ATR = False

class Momentum(bt.ind.OperationN):
    lines = ('trend',)
    params = dict(period=MINIMUM_PERIOD,
                  exclude_window=EXCLUDE_WINDOW)
    func = momentum

    def __init__(self):
        self.addminperiod(self.p.period)
        self.exclude_window = self.p.exclude_window


class Strategy(bt.Strategy):
    params = dict(
        num_positions=POSITIONS,
        use_atr=USE_ATR,
        rrr=2.0,
        stop_loss=0.05,
        atr_factor=3.0,
        when=bt.timer.SESSION_START,
        timer=True,
        weekdays=[1],
        weekcarry=True,
        momentum=Momentum,
        momentum_period=MINIMUM_PERIOD
    )

    def __init__(self):
        self.d_with_len = []
        self.orders = {}
        self.inds = {}
        self.rebalance_date = None
        self.add_timer(
            when=self.p.when,
            weekdays=self.p.weekdays,
            weekcarry=self.p.weekcarry
        )
        for d in self.datas[1:]:
            self.orders[d] = []
            self.inds[d] = {}
            self.inds[d]['momentum'] = self.p.momentum(d,
                                                       period=MINIMUM_PERIOD,
                                                       plot=False)
            self.inds[d]['atr'] = bt.indicators.ATR(d,
                                                    period=14)

    def prenext(self):
        # Add data for datas that meet preprocessing requirements
        # And call next even though data is not available for all tickers
        self.d_with_len = [d for d in self.datas[1:] if len(d)]

        if len(self.d_with_len) >= self.p.num_positions:
            self.next()

    def nextstart(self):
        # This is only called once when all data is present
        # So we are not unnecessarily calculating d_with_len
        self.d_with_len = self.datas[1:]
        self.next()
        print("All datas loaded")

    def next(self):
        if self.rebalance_date:
            today = self.data.datetime.date(ago=0)
            buy_date = self.rebalance_date + timedelta(days=1)
            if today == buy_date:
                #print("BUY DATE: ", buy_date)
                self.rebalance_buy()

    def notify_timer(self, timer, when, *args, **kwargs):
        if len(self.d_with_len) >= self.p.num_positions:
            self.rebalance_sell()

    def rebalance_sell(self):
        self.rebalance_date = self.data.datetime.date(ago=0)
        self.rankings = list(self.d_with_len)
        self.rankings.sort(key=lambda s: self.inds[s]['momentum'][0],

                           reverse=True)
        for i, d in enumerate(self.rankings):
            if self.getposition(d).size != 0:
                if i >= self.p.num_positions:
                    self.close(d, ticker=d.p.name)
                    for o in self.orders[d]:
                        if o and o.status == o.Accepted and \
                                (o.getordername() == 'Stop' or
                                 o.getordername() == 'Limit'):
                            self.cancel(o)

        # Rank according to momentum and return stock list
        # Buy stocks with remaining cash

    def rebalance_buy(self):
        positions = 0
        for d in self.datas:
            if self.getposition(d).size != 0:
                positions += 1

        if positions < self.p.num_positions:
            pos_value = self.broker.get_cash() / (self.p.num_positions - positions)
            for i, d in enumerate(self.rankings[:self.p.num_positions]):
                if self.getposition(d).size == 0 and \
                        not math.isnan(self.inds[d]['momentum'][0]) > 0 and \
                        pos_value > d.close[0]:
                    buy_size = pos_value // d.close[0]

                    buy_order = self.buy(d,
                                         size=buy_size,
                                         transmit=False,
                                         ticker=d.p.name)

                    if self.p.use_atr:
                        sell_price = d.close[0] + self.inds[d]['atr'][0] * self.p.atr_factor * self.p.rrr
                        stop_price = d.close[0] - self.inds[d]['atr'][0] * self.p.atr_factor
                        stop_loss = (self.inds[d]['atr'][0] * self.p.atr_factor) / d.close[0]
                    else:
                        sell_price = (1.0 + self.p.stop_loss * self.p.rrr) * d.close[0]
                        stop_price = (1.0 - self.p.stop_loss) * d.close[0]
                        stop_loss = self.p.stop_loss

                    sell_order = self.sell(d,
                                            price=sell_price,
                                            size=buy_order.size,
                                            exectype=bt.Order.Limit,
                                            transmit=False,
                                            parent=buy_order,
                                            ticker=d.p.name)

                    stop_order = self.sell(d,
                                            price=stop_price,
                                            size=buy_order.size,
                                            exectype=bt.Order.Stop,
                                            transmit=True,
                                            parent=buy_order,
                                            ticker=d.p.name)

                    self.orders[d].append(sell_order)
                    self.orders[d].append(stop_order)

    def stop(self):
        self.ending_value = round(self.broker.get_value(), 2)
        self.PnL = round(self.ending_value - startcash, 2)

if __name__ == '__main__':
    startcash = 10000
    cerebro = bt.Cerebro(stdstats=False, optreturn=False)

    # Add Benchmark (datas[0])
    benchmark = get_security_data(BENCHMARK_TICKER, START, END)
    benchdata = bt.feeds.PandasData(dataname=benchmark,
                                    name='SPY',
                                    plot=False)
    cerebro.adddata(benchdata)

    # Add Securities (datas[1:])
    tickers = get_sp500_tickers()
    securities = get_securities_data(tickers, START_DATE, END_DATE)

    # Add securities as datas1:
    for ticker, data in securities.groupby(level=0):
        if len(data) < MINIMUM_PERIOD:
            print(f"Skipping: ticker {ticker} with length{len(data)} \
                does not meet the minimum length of {MINIMUM_PERIOD}.")
            continue

        print(f"Adding ticker: {ticker}.")
        d = bt.feeds.PandasData(dataname=data.droplevel(level=0),
                                name=ticker,
                                plot=False)
        d.plotinfo.plotmaster = benchdata
        d.plotinfo.plotlinelabels = True

        cerebro.adddata(d)

    print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())

    # Add Strategy
    if USE_ATR:
        cerebro.optstrategy(Strategy,
                            rrr=(1, 2, 3, 4),
                            atr_factor=(1, 2, 3, 4, 5))
    else:
        cerebro.optstrategy(Strategy,
                            rrr=(1, 2, 3, 4),
                            stop_loss=(0.05, 0.10, 0.15, 0.20, 0.25))

    # Add observers & analyzers
    cerebro.addobserver(bt.observers.CashValue)
    cerebro.addobserver(bt.observers.Benchmark,
                        data=benchdata,
                        _doprenext=True,
                        timeframe=bt.TimeFrame.NoTimeFrame)
    cerebro.addanalyzer(bt.analyzers.Returns)
    cerebro.addanalyzer(bt.analyzers.DrawDown)

    cerebro.addobserver(bt.observers.Trades)
    cerebro.addobserver(bt.observers.BuySell)

    # Analyze the trades
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')

    # Run optimization
    opt_results = cerebro.run(tradehistory=False)

    # Generate results list
    final_results_list = []

    for run in opt_results:
        for strategy in run:
            value = strategy.ending_value
            PnL = strategy.PnL
            if USE_ATR:
                stop_loss = strategy.p.atr_factor
            else:
                stop_loss = strategy.p.stop_loss
            rrr = strategy.p.rrr
            trades = strategy.analyzers.trades.get_analysis()
            total_trades = trades.total.closed
            total_won = trades.won.total
            perc_win = total_won / total_trades
            drawdown = strategy.analyzers.drawdown.get_analysis()['max']['drawdown']
            final_results_list.append([rrr, perc_win, stop_loss, PnL, drawdown])

            print(f"Strategy Total Return: {strategy.analyzers.returns.get_analysis()['rtot']}")

    #Sort Results List
    by_PnL = sorted(final_results_list, key=lambda x: x[3], reverse=True)
    by_win = sorted(final_results_list, key=lambda x: x[1], reverse=True)

    #Print results
    print('Results: Ordered by Profit:')
    for result in by_PnL:
        print('| RRR | Win% | Stop | PnL | Drawdown|')
        print('| {}  | {}%  | {} | {} | {}'.format(
            result[0],
            round(result[1], 2),
            result[2],
            round(result[3], 2),
            round(result[4], 2)))

    print('Results: Ordered by Win%:')
    for result in by_win:
        print('| RRR | Win% | Stop | PnL | Drawdown|')
        print('| {}  | {}%  | {} | {} | {}'.format(
            result[0],
            round(result[1], 2),
            result[2],
            round(result[3], 2),
            round(result[4], 2)))

Leave a Comment