Published on

Evaluating a Trading Strategy Performance - Part 1

Authors

Hi there!

Welcome back to Quanti Frutti.

Here's the song for this post, enjoy!

If you're wondering why there is a song to listen, you probably missed my introductory post, but don't worry! The only thing you need to know is that I am a sucker for rock and roll music and I treat my readers with a cool tune in each post.

Now let's not waste precious time and let's go straight to our quantitative stuff. For length reasons, we'll split this post in two parts, so you have an excuse to take a break or sip a cup of coffee before reading the second one, which is also the most rewarding and interesting one for the results and conclusions we arrive to.

Another thing I would like to point out is that these blocks of code are from my project called "Trading Tesla with Machine Learning and Sentiment Analysis". If you're interested in knowing more about it, you can find the link in the Projects section of the blog, or go directly to the GitHub repository. For the scope of this post, one thing worth to know is that the buy and sell signals were produced as predictions by a Random Forest classifier fed with several technical indicators and sentiment scores on Twitter posts relevant to Tesla.

The performance metrics

to-the-moon

In these unprecedented times of stonks, euphoric retail traders, cryptocurrencies with 400% daily gains and your friends telling you "Buy this, it's going to skyrocket soon", even the most optimistic choruses like "To the moon!", found everywhere from Twitter to Reddit to your favourite Discord channel, can be misleading and disastrous for your trading account balance.

We really must pay attention before entering any trade positions, and always try to make informed decisions. We may still fail, but at least we did our due diligence and we will have a basis, something methodical we can investigate further to improve our strategy for the next trades. Luckily, the quantitative approach to trading comes to our aid to carry out analysis and make decisions for our trading activity.

Say you have an idea for a strategy and you wish to carry out the backtesting to evaluate how well (or bad) it performs and whether it's worth to be optimised and implemented for live trading. What metrics can we use other than the strategy return on the whole backtesting period?

Keep reading and I'll show you some useful statistics that will help you to extrapolate a more scrupulous judgement about the potential profit and risk associated with the trading strategy.

The metrics we are going to compute are:

  • Buy and hold return and annualised return
  • Trading strategy return and annualised return
  • Sharpe ratio
  • Total Number of Trades
  • Hit ratio
  • Average profit per trade
  • Average loss per trade
  • Maximum drawdown
  • Day needed to recover from the maximum drawdown

Let's assume that we somehow computed the signals of our trading strategy in a Pandas DataFrame which includes, among the others, the 'Close' and the 'Signals' columns, respectively the daily close price of the stock we are analysing and the signals for buying and selling, "1" for buy (or long) and "-1" for short selling (or shorting) positions.

We will now add some columns to the dataset and compute the metrics listed above.

Let's also assume that we imported the relevant modules needed for our scripts:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

In order to compute our calculations, let's first create a column called 'Trades' that will number the trades with a progressive number and a sign to distinguish the position. We also want to label the daily data of each trade. This will be used later to compute the deduction of transaction fees. We'll cal this column 'Open_Close_Trade'.

# Numbering the trades
df['Trades'] = df['Signals'] * (df['Signals'] != df['Signals'].shift(1)).cumsum()
# Labelling with "open" and "close" position to work out the transaction costs later
df['Open_Close_Trade'] = np.where(df['Trades'] != df['Trades'].shift(1), 'O', np.nan)
df['Open_Close_Trade'] = np.where(df['Trades'] != df['Trades'].shift(-1), 'C', df['Open_Close_Trade'])
df['Open_Close_Trade'] = np.where((df['Trades'] != df['Trades'].shift(1)) & (df['Trades'] != df['Trades'].shift(-1)), 'OC', df['Open_Close_Trade'])

For example. if a long position is followed by a short position, the code above will label the trades respectively "1" and "-2", and so on. If one trade is spread over three days, the 'Open_Close_Trade' column will be respectively "O", "NaN" and "C". If it's a one row trade, it will be labelled "OC".

Then we are going to create the daily returns calculated on the close price:

# Compute daily returns
df['Returns_Daily'] = df['Close'].pct_change()

With the above, we can already work out the buy and hold returns of the stock on the whole period. We assume in our example an 'initial_capital' of 10,000:

# Compute buy and hold returns

# The cumulative return
df['Returns_B&H_Cum'] = (df['Returns_Daily'] + 1).cumprod() * 100
# The equity curve for the investment or buy and hold strategy
df['Capital_B&H'] = initial_capital * df['Returns_B&H_Cum'] / 100
# Set the first data point to its initial value
df['Capital_B&H'][0] = initial_capital

We need now to work out the trading stretegy returns. This will require some more code as we want to include the transaction fee at each trade. Let's first write a function that will return the transaction cost.

# Helper function to compute transaction costs
def tx_cost(tx_amount, tx_fee, fee_pc=True):
    """
    Return transaction costs based on transaction fee
    and amount traded.

    Arguments:
        tx_amount: float, portion of capital traded
        tx_fee: float, transaction fee in percentage or absolute value
        fee_pc: bool, whether the transaction fee is provided as percentage or absolute value
    """

    if fee_pc:
        return tx_amount * tx_fee / 100
    return tx_fee

The next step is to compute the returns of the strategy based on the signal and the stock price movement. With that we will have all the data to work out the actual return at each trade taking in account the fraction of capital traded and the transaction costs at each trade:

# Signal returns (no transaction fee and amount traded information)
df['Returns_Signals_Daily'] = df['Signals'] * df['Returns_Daily']
# Initialise the equity curve for the trading strategy
df['Capital_Strategy'] = initial_capital

Let's initialise some variables we need into our next algorithm. In my code, I am assuming trading 20% of the account at each trade ('traded_amount_pc') and 0.05% 'transaction_fee':

# Percentage of amount traded at each trade
tx_amount_pc = traded_amount_pc / 100
# Transaction fee at each trade
tx_fee = transaction_fee
capital = initial_capital
tx_amount = tx_amount_pc * capital
reserve = capital - tx_amount
tx_amount -= tx_cost(tx_amount, tx_fee, fee_pc)
tx_amount_unreal = tx_amount
position = 'closed' if df.iloc[0]['Open_Close_Trade'] == 'OC' else 'open'

The next code will work out the strategy capital based on the amount traded and the transaction cost, using the labels we created previously for each trade:

for index, row in df[1:].iterrows():

    tx_amount_unreal += tx_amount * row['Returns_Signals_Daily']
    tx_amount = tx_amount * (1 + row['Returns_Daily'])

    if position == 'closed':
        tx_amount_unreal -= tx_cost(tx_amount_unreal, tx_fee, fee_pc)
        capital = reserve + tx_amount_unreal
        position = 'open'

    df.loc[index, 'Capital_Strategy'] = reserve + tx_amount_unreal

    if row['Open_Close_Trade'] in ('O', 'OC'):
        # Update transaction amount and reserve
        tx_amount = tx_amount_pc * capital
        reserve = capital - tx_amount
        tx_amount -= tx_cost(tx_amount, tx_fee, fee_pc)
        tx_amount_unreal = tx_amount

    if row['Open_Close_Trade'] in ('C', 'OC') or index == df.index[-2]:
        position = 'closed'

Since we have the equity curve for the trading strategy, we can now compute the drawdown, which represents the downward movements of the return from a peak to the lowest point. It is essentially a measure of the decline of funds that can occur during a given period before returning into profit again. The drawdown is usually expressed as a percentage, and we calculate it on a time series by computing the negative return of the equity curve from its cumulative maximum at each data point:

# Compute trading strategy drawdown
df['Capital_Strategy_Cum_Max'] = df['Capital_Strategy'].cummax()
df['Drawdown'] = ((df['Capital_Strategy'] - df['Capital_Strategy_Cum_Max']) / df['Capital_Strategy_Cum_Max']) * 100

In order to compute some of the statistics such as "Hit Ratio" and average profit and loss per trade, we also need to aggregate all the trades keeping the strategy equity curve:

# Compute returns for each trade from strategy capital curve (including transaction fees)
trades_PL = df.groupby(by='Trades')['Capital_Strategy'].agg(['first', 'last'])
trades_PL.sort_index(inplace=True, key=lambda x: abs(x))
trades_PL.loc[trades_PL['first'] == trades_PL['last'],'first'] = trades_PL['last'].shift(1)
trades_PL['Returns_Trades'] = 0
trades_PL['Returns_Trades'] = (trades_PL['last'] - trades_PL['first']) / trades_PL['first'] * 100

We are almost there to calculate the metrics listed previously. Before, though, we need to retrieve some other data which we'll use in our calculations.

The number of years our time series spans on:

years = round((df.index[-1] - df.index[0]).days/365.25, 2)

The risk free rate required in the Sharpe ratio formula:

risk_free_rate = ((1 + 0.065)**(1/365))-1

Let's assign the series of daily returns of the strategy to a variable for ease of use later:

df['Returns_Strategy_Daily'] = df['Capital_Strategy'].pct_change()
returns_strategy_trades = df[1:]['Returns_Strategy_Daily']

And finally the total number of trading days, trades, and a breakdown of the ones with positive and negative returns:

n_trading_days = df.shape[0] - 1
n_positive_trades = (trades_PL['Returns_Trades'] >= 0).sum()
n_negative_trades = (trades_PL['Returns_Trades'] < 0).sum()
n_trades = trades_PL.shape[0] - 1

We can now proceed to calculate our performance metrics. Let's start with the return for the buy and hold scenario and the return of the trading strategy. For each of them, we will compute both the return on the whole period and the annualised figure. The annualised figure scales to a 12-month period the rate of return, allowing an easier comparison of different investments over a year.

The formula to annualise the return is:

Rann=(1+R1/y)1\begin{aligned} R_{ann}=(1+R^{1/y})-1 \end{aligned}

where y is the number of years over which the total return R is calculated.

# Buy and hold returns
returns_buy_hold = round(df['Returns_B&H_Cum'][-1], 2)
returns_buy_hold_ann = round((((1 + df['Returns_B&H_Cum'][-1]/100)**(1/years))-1) * 100, 2)

# Strategy returns
returns_strategy = round(df['Capital_Strategy'][-1]/df['Capital_Strategy'][0] * 100, 2)
returns_strategy_ann = round((((1 + returns_strategy/100)**(1/years))-1) * 100, 2)

The buy and hold return will represent the profit if we were to buy the stock and hold it for the whole period; the strategy return, on the other hand, is the profit we make by trading according to the buy and sell signals. As you probably know, just comparing the two returns, gives us a first feedback on whether the trading strategy is more profitable than just holding the stock.

Let's now calculate the Sharpe ratio. The Sharpe ratio represents the average return in excess of the risk free rate per unit of volatility of the asset price. The formula is:

SharpeRatio=RRfσ\begin{aligned} Sharpe Ratio=\frac{R - R_f}{σ} \end{aligned}

where R is the return of the strategy, Rf is the risk free rate and σ is the volatility of the asset price across the period considered.

# Sharpe ratio
sharpe_ratio = round(((returns_strategy_trades - risk_free_rate).mean()\
                      /np.std((returns_strategy_trades - risk_free_rate), ddof=1)) * n_trading_days**0.5, 2)

As you can see in the formula, we annualised the Sharpe Ratio as well by multiplying by the square root of the number of trading days.

Another bit of useful information about our strategy performance will be expressed by the hit ratio, which is the percentage of profitable trades out of all trades executed, and the average profit and loss of all trades. The meaning of the last two and the computation for all is quite straight forward:

# Hit ratio, average trades profit, average trades loss
hit_ratio = round(n_positive_trades/n_trades  * 100, 2)
average_trades_profit = round(trades_PL['Returns_Trades'][trades_PL['Returns_Trades'] >= 0].mean(), 2)
average_trades_loss = round(trades_PL['Returns_Trades'][trades_PL['Returns_Trades'] < 0].mean(), 2)

We end the first part of this post here. In the second part, we'll complete this topic with the code to compute:

  • Maximum drawdown
  • Day needed to recover from the maximum drawdown
  • Graphs of drawdown, returns of buy and hold and trading strategy

If you want to jump straight to the second part, here's the link.

For now, thanks for visiting and I hope you enjoyed!

Renato