/ TRADING

Risk-Reward Ratio: Defined & Determined

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’m going to 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. There are quite a few trading books that state 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.

The Risk-Reward Ratio Further Explained

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.

AMD Risk-Reward Ratio from TradingView

Risk-Reward Ratio Orders in Detail

We would need to place three orders with our broker in order 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’re going to be losing money consistently. That’s not what we want!

We have to bring in the average win percentage for the trade. Let’s say 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 we 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 on average. 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.

Obviously, time is a crucial factor here, too. If there are two trade setups with the same expectency, 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 both using a 1:2 risk-reward ratio. I’ve ordered the results first by P&L and then by win percentage.

Risk-Reward Ratio vs. SPY

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%

ATR Risk-Reward Ratio vs. SPY

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 we rebalanced weekly instead of monthly, I’ve added a profit-taking order, and added 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)))
leo

Leo Smigel

Based in Pittsburgh, Analyzing Alpha is a blog by Leo Smigel exploring what works in the markets.