In this post, we are going to look at a very well established strategy called "momentum", or colloquially, the strategy of "buying winners and selling losers". In fact, this approach has been established as a common risk factor in the Fama-French style by Jegadeesh and Titman (1993). Moreover, this post will serve as a simple benchmark to outline what constitutes a sensible trading strategy, what to look for and what to avoid. Let's get into it.
Intuition and setup
Every strategy needs to be intuitive in an economic context and the mechanism as to why the strategy should work has to be clear. For example, many strategies are devised on some sort of co-movement or correlation of two or more variables. However, in the real world, there are many "random" correlations (remember, correlation ≠ causation), which are exemplified in the Spurious Correlations website. For instance, just because there is a correlation between cheese consumption and people dying entangled in bed sheet, one would not take this as a signal to buy cheese producing stocks when a news report emerges of people dying in this bizarre way.
For momentum, the mechanism has several explanations well described in the paper, therefore we will not dwell too much on it. Put simply, we consider the definition that market participants transpose past returns of assets into the future. People follow the rationale of thinking that if a stock has been going up, there is probably a good reason for it and they will want to jump on that wave.
Now that we have an idea what momentum is and why it should work, we need to talk about how to trade it. There are many ways one can do that, but we will stay true to the paper and do it in a more precise, academic way.
We said momentum is buying winners and selling losers, which gives a good idea how we construct the portfolio. The pseudo-algorithm for construction is as follows:
calculate performance (gross return) of our assets in the past XY periods (look-back period)
rank these assets by the performance from highest to lowest
select X number of the highest ranked assets, i.e. "winners" and go long (buy them)
select Y number of the lowest ranked assets, i.e. "losers" and go short (sell them)
hold these for a defined number of periods
repeat every period, adding to portfolio
After understanding the intuition and technical construction, let's define the parameters to make the strategy more concrete, namely:
traded assets: we will use the SPDR® sector ETFs - think financials, utilities, consumer staples etc.
risk-free rate: trading US assets in the US Dollar, we need to use US risk-free rate, ideally consistent with frequency
frequency: to avoid trading costs, we will use monthly frequency
time-frame: for a proper backtest, we need to use at least 200 periods as a rule of thumb, thus, for monthly frequency, we need around 20 years
look-back period: initially, we will use 6 months
holding period: also 6 months
how many assets in the long/short leg: having 9 assets, we will trade 3 assets in each leg
how much of our own capital we are using: traditionally, we have two options, either 0 (pure long-short, zero-cost strategy) or 1, being invested 100% with our own capital. The proceeds of shorting will be used for additional buying, i.e. creating leverage
long and short leg caps: defining how much we can go long and short if shorting is allowed. Traditionally, short-cap is set to -1, thus setting long-cap to 2 if we are using 100% of our capital (long and short cap have to be equal to our own capital)
benchmark: it is not sufficient that a strategy makes money; it has to outperform the benchmark (either the market or equal-weights portfolio of selected assets). Thus, benchmark selection is crucial
trading costs: many people forget this crucial part. It is not difficult the create a highly profitable crazy-high-frequency strategy. However, once transaction costs are taken into account, these usually tend to lose money. In our case, we will use 20bps as one-way transaction cost, given we are trading very liquid, broad ETFs
Now we have everything important we need to get started, so let's put it into code! (If you want the whole code to just copy and run it, jump to the end or visit our GitHub and download the repository!)
Getting the relevant data and wrangling it is probably one of the most challenging aspects of trading. We might even have great trading ideas which in hindsight work, but we just don't have the right data in a timely manner to test it. For this reason, some people prefer working only with price data, as these are usually easily accessible.
Data is the new oil. - Clive Humby, British mathematician
For our purposes, we will utilise the yfinance Python package and download data from Yahoo Finance. Side note: if you visit the full code at the end or visit our GitHub, you will have the option to load prepared data from Excel or to "pickle" file just by changing parameters. For now, le'ts focus on Yahoo.
After having downloaded the data, we will need to process it; resample the daily data to monthly according to our setup, manipulate the risk-free rate and most importantly, calculate returns:
Determining the weights
This is the most crucial and error-prone step of any backtest. In order to determine the returns of a back-tested strategy, we need two things: observed returns of asset in the past periods, and the weights we would have had in that period, according to our strategy. Since returns are fixed in time, we can only change the weights, which we are going to do now.
The biggest pitfall of this step, especially in parametric approaches, is the look-ahead bias, i.e., using information in some period which was not available at that time. In our case, we have to eliminate this, such that the weights which we determine have to be set into +1 period ahead.
Let us also revisit the pseudo-algorithm from above to get a better understanding of the code that follows:
in each period i, starting with a delay of our look-back (6), calculate the gross return (cumulative product) of returns of our assets
rank these assets from highest to lowest gross return
determine the winners/losers
in the i period, assign weights for the following i+1+holding periods according to our rules
repeat for next period
This sounds like a job for a for-loop! Before we initiate the loop, let us set up auxiliary functions, namely for:
determining the losers/winners, using a sliced dataframe of returns in desired period
determining the index position of these asset in our dataframe
Let us also briefly talk about the rules for setting up the weights; we will introduce three flavors of momentum:
Pure time-series momentum. We determine the winners/losers and equal-weight them, meaning if we have 3 assets in long-leg, each will receive 0.33 and 3 in short-leg, each will receive -0.33. These will be then scaled by long/short caps
Cumulative time-series momentum. We determine the weights in the same way as before, but we cumulate the weights. Imagine you allocate 0.33 to asset "ABC" in period one and then in the next period you assign 0.33 to the asset again, meaning you are increasing the allocation to this asset and cumulating the weight. Of course, this will be then scaled not to break budget-constraints. This way is the most "consistent" way to do it according to the paper
Cumulative time-series and cross-sectional momentum. We again determine the winners and losers, but not only that - now we don't simply assign equal weights to the asset, but we scale them by cross-sectional performance. That means if we have three winners A,B and C with returns of 5%,10% and 5% respectively, we would determine the weight of asset i as weight_i=return_i / sum[returns]. In this case, the weights would be 0.25, 0.5 and 0.25 respectively. This is already built into our function.
Scaling and returns calculation
We have figured out the weights and saved them into a dictionary holding all the dataframes. We are now almost ready to multiply the dataframes of weights with the returns to determine how the different strategies would have performed. Before we do that though, we need to scale the weights according to our caps and budget constraints.
The reason behind this is that in the cumulative strategies, the weight could be cumulating infinitely after we sum the newly determined weight in current period with the previous weights. Also, because we have positive and negative weights, we have to scale them separately to take into account the different caps.
This sounds again like a good job for a for-loop function; we will loop through the dictionary of weights, calling a different dataframe each time and doing the scaling. When we scale the weights, we will obtain our final weights. Therefore in the same step, we can complete 3 additional tasks:
calculate the returns of a portfolio as cross-sectional (i.e. "horizontal") sum of the returns dataframe
calculate the turnover, and from that the overall transaction costs every period to subtract from returns
calculate earning on the risk-free rate if we are using less than 100% of our capital or the costs of borrowing if we are using more than 100% of our capital
As a finishing touch, we create a dataframe for benchmark - in our case an equal-weights long-only portfolio (note that this is an EW not allowing for drift and also we neglect transaction costs of the benchmark).
The code for this, where the dictionary holding portfolio returns is of particular interest to us, looks as follows:
Now comes the most exciting part of creating an investment strategy; seeing whether it works! We can judge that in many ways, but the key metrics to look at are:
geometric average over the whole period (annualized)
volatility of return (annualized)
Alpha & Beta
All metrics are on an absolute basis as well as relative to the benchmark (except for IR, alpha and beta of course). We built a simple class with two functions which takes 4 arguments:
dataframe of portfolio returns, where each column is a time-series of returns of each strategy
annualisation factor: if we have monthly data, it is equal to 12, daily 252, weekly 52
[optional]: dataframe of time-series of benchmark returns
[optional]: risk-free rate to calculate Sharpe ratio. If we have pure long-short strategy, we omit this.
You can either import this class together with the other functions, or treat it as a separate file and import it similarly to packages (this will be shown in the overall code). This approach is generally preferred for auxiliary functions, as you can keep a centralised repository of your functions which you can re-use and modify for future use.
Now, having imported this class, let us use it on our dataframe of portfolio returns, export a dataframe with summary statistics, and print what the best strategy is.
Even if not absolutely essential for our analysis (remember, numbers tell more than a graph), it is useful to plot some graphs to get an idea how the strategy evolves, in what periods it does better or worse, and how it moves with respect to the benchmark.
We will plot the following plots (and save them in the process):
NAVs of our different strategies together with the benchmark
stacked area plot to assess the evolution of the weights
a scatter plot of different strategies and the benchmark together with the OLS regression line
The whole code in one chunk
NO INVESTMENT ADVICE
Nothing in the Site constitutes professional and/or financial advice, nor does any information on the Site constitute a comprehensive or complete statement of the matters discussed or the law relating thereto. Finance Club of the University of Zurich is not a fiduciary by virtue of any person's use of or access to the Site or Content.