library(tidyverse)
library(lubridate)
library(scales)
library(ggrepel)
library(tidyquant)
library(jsonlite)
theme_set(theme_minimal())
invisible(Sys.setlocale("LC_TIME", "en_US.UTF-8"))
spx <- tq_get("^GSPC", from = as.Date("1900-01-01"))
spx_tr <- tq_get("^SP500TR", from = as.Date("1900-01-01"))
spx |> saveRDS("stockdata/spx.RDS")
spx_tr |> saveRDS("stockdata/spx_tr.RDS")
spx <- readRDS("stockdata/spx.RDS")
spx_tr <- readRDS("stockdata/spx_tr.RDS")
The first time that I learned about index investing based on simple moving averages (SMA) was probably in a book by Meb Faber (“Global Asset Allocation” I believe). The idea is to invest in an index (e.g. S&P 500) when its price is above its long-term moving average (e.g. 10 months) and to move to cash when the price is below the moving average. The rationale is that this simple rule helps to avoid large drawdowns during bear markets. Meb Faber wasn’t the first to propose this idea, but he popularized it.
I backtested SMA on various indices, computed risk shifting strategies (e.g. shift to low-risk bonds when the index is below its SMA), and found that SMA worked well for very long. Even in commodities (e.g. gold) it worked well. I saw a clear rationale for the SMA being such a good indicator and explained it for myself with crowd behavior and trend following.
I was so excited about SMA that I wanted to invest all of my ETF holding according to SMA. And I didn’t because of transaction costs with my bank and being possibly classified as a trader which would have unwanted tax implications in my tax jurisdiction (Switzerland).
My excitement peaked around the year 2000 in the midst of the pandemic crash and subsequent recovery. Time to see how it would have done since.
strat_spx <- spx |>
mutate(r = adjusted/lag(adjusted)-1) |>
mutate(sma200 = RcppRoll::roll_mean(adjusted, 200, fill = NA, align = "right"),
sma100 = RcppRoll::roll_mean(adjusted, 100, fill = NA, align = "right"),
sma50 = RcppRoll::roll_mean(adjusted, 50, fill = NA, align = "right"),
above200 = adjusted > sma200,
above100 = adjusted > sma100,
above50 = adjusted > sma50) |>
mutate(strat200 = ifelse(lag(above200), r, 0),
strat100 = ifelse(lag(above100), r, 0),
strat50 = ifelse(lag(above50), r, 0))
strat_spx |>
drop_na() |>
mutate(p = cumprod(1+r)*100,
p_strat200 = cumprod(1+strat200)*100,
p_strat100 = cumprod(1+strat100)*100,
p_strat50 = cumprod(1+strat50)*100) |>
ggplot(aes(x = date)) +
geom_line(aes(y = p, color = "Buy and hold"), size = 0.5) +
geom_line(aes(y = p_strat200, color = "SMA 200"), size = 0.5) +
geom_line(aes(y = p_strat100, color = "SMA 100"), size = 0.5) +
geom_line(aes(y = p_strat50, color = "SMA 50"), size = 0.5) +
# scale_y_log10() +
scale_color_manual(values = c("Buy and hold" = "blue", "SMA 200" = "red", "SMA 100" = "green", "SMA 50" = "purple")) +
labs(title = "S&P 500: Buy and hold vs. Simple Moving Average strategies",
subtitle = "SPX price return, Data source: Yahoo Finance",
y = "Cumulative return ($100 invested in 1928)",
x = "",
color = "") +
theme(legend.position = "top")
This is a simplified strategy not taking into account a risk-free rate when being out of the market. But it is still surprising how well SMA worked for almost a century.
This was the price return not taking into account dividends. Total return data on the SPX at least from Yahoo Finance only goes back to 1988.
strat_spx_tr <- spx_tr |>
mutate(r = adjusted/lag(adjusted)-1) |>
mutate(sma200 = RcppRoll::roll_mean(adjusted, 200, fill = NA, align = "right"),
sma100 = RcppRoll::roll_mean(adjusted, 100, fill = NA, align = "right"),
sma50 = RcppRoll::roll_mean(adjusted, 50, fill = NA, align = "right"),
above200 = adjusted > sma200,
above100 = adjusted > sma100,
above50 = adjusted > sma50) |>
mutate(strat200 = ifelse(lag(above200), r, 0),
strat100 = ifelse(lag(above100), r, 0),
strat50 = ifelse(lag(above50), r, 0))
strat_spx_tr |>
drop_na() |>
mutate(p = cumprod(1+r)*100,
p_strat200 = cumprod(1+strat200)*100,
p_strat100 = cumprod(1+strat100)*100,
p_strat50 = cumprod(1+strat50)*100) |>
ggplot(aes(x = date)) +
geom_line(aes(y = p, color = "Buy and hold"), size = 0.5) +
geom_line(aes(y = p_strat200, color = "SMA 200"), size = 0.5) +
geom_line(aes(y = p_strat100, color = "SMA 100"), size = 0.5) +
geom_line(aes(y = p_strat50, color = "SMA 50"), size = 0.5) +
# scale_y_log10() +
scale_color_manual(values = c("Buy and hold" = "blue", "SMA 200" = "red", "SMA 100" = "green", "SMA 50" = "purple")) +
labs(title = "S&P 500 Total Return: Buy and hold vs. Simple Moving Average strategies",
subtitle = "SPX total return, Data source: Yahoo Finance",
y = "Cumulative return ($100 invested in 1970)",
x = "",
color = "") +
theme(legend.position = "top")
Clearly buy-and-hold outperformed the SMA strategies when considering total return. This is due to the fact that dividends were quite consistent over time and that being out of the market meant missing out on dividends. The picture would look different if a risk-free rate (or government bonds) would be considered when being out of the market.
Anyway, what’s noticable is the reduced risk:
strat_spx_tr |>
drop_na() |>
summarise(sd_buy_hold = sd(r)*sqrt(252),
sd_strat200 = sd(strat200)*sqrt(252),
sd_strat100 = sd(strat100)*sqrt(252),
sd_strat50 = sd(strat50)*sqrt(252)) |>
pivot_longer(everything(), names_to = "strategy", values_to = "annualized_volatility") |>
# mutate(annualized_volatility = scales::percent(annualized_volatility, accuracy = 0.1)) |>
ggplot(aes(x = strategy, y = annualized_volatility, fill = strategy)) +
geom_col() +
scale_fill_manual(values = c("sd_buy_hold" = "blue", "sd_strat200" = "red", "sd_strat100" = "green", "sd_strat50" = "purple")) +
scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
labs(title = "S&P 500 Total Return: Annualized volatility of different strategies",
subtitle = "Data source: Yahoo Finance",
y = "Annualized volatility",
x = "",
fill = "") +
theme(legend.position = "none")
The buy-and-hold strategy had an annualized volatility of almost 18%, while the volatility of all SMA strategies stayed below 12%. The Sharpe ratio (assuming a risk-free rate of 2%) was the highest for the SMA200 strategy (aghain excluding a risk-off asset).
strat_spx_tr |>
drop_na() |>
summarise(sharpe_buy_hold = (mean(r)*252-0.02)/sd(r)/sqrt(252),
sharpe_strat200 = (mean(strat200)*252-0.02)/sd(strat200)/sqrt(252),
sharpe_strat100 = (mean(strat100)*252-0.02)/sd(strat100)/sqrt(252),
sharpe_strat50 = (mean(strat50)*252-0.02)/sd(strat50)/sqrt(252)) |>
pivot_longer(everything(), names_to = "strategy", values_to = "sharpe_ratio") |>
# mutate(sharpe_ratio = round(sharpe_ratio, 2)) |>
ggplot(aes(x = strategy, y = sharpe_ratio, fill = strategy)) +
geom_col() +
scale_fill_manual(values = c("sharpe_buy_hold" = "blue", "sharpe_strat200" = "red", "sharpe_strat100" = "green", "sharpe_strat50" = "purple")) +
labs(title = "S&P 500 Total Return: Sharpe ratio of different strategies",
subtitle = "Assuming a risk-free rate of 2%, Data source: Yahoo Finance",
y = "Sharpe ratio",
x = "",
fill = "") +
theme(legend.position = "none")
Drawdowns are also reduced:
strat_spx_tr |>
drop_na() |>
mutate(p = cumprod(1+r)*100,
p_strat200 = cumprod(1+strat200)*100,
p_strat100 = cumprod(1+strat100)*100,
p_strat50 = cumprod(1+strat50)*100) |>
mutate(dd_buy_hold = (p - cummax(p))/cummax(p),
dd_strat200 = (p_strat200 - cummax(p_strat200))/cummax(p_strat200),
dd_strat100 = (p_strat100 - cummax(p_strat100))/cummax(p_strat100),
dd_strat50 = (p_strat50 - cummax(p_strat50))/cummax(p_strat50)) |>
summarise(max_dd_buy_hold = min(dd_buy_hold),
max_dd_strat200 = min(dd_strat200),
max_dd_strat100 = min(dd_strat100),
max_dd_strat50 = min(dd_strat50)) |>
pivot_longer(everything(), names_to = "strategy", values_to = "max_drawdown") |>
# mutate(max_drawdown = scales::percent(max_drawdown, accuracy = 0.1)) |>
ggplot(aes(x = strategy, y = max_drawdown, fill = strategy)) +
geom_col() +
scale_fill_manual(values = c("max_dd_buy_hold" = "blue", "max_dd_strat200" = "red", "max_dd_strat100" = "green", "max_dd_strat50" = "purple")) +
scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
labs(title = "S&P 500 Total Return: Maximum drawdown of different strategies",
subtitle = "Data source: Yahoo Finance",
y = "Maximum drawdown",
x = "",
fill = "") +
theme(legend.position = "none")
So while holding to the SPX one lost over 50% during the financial crisis, the SMA200 strategy only lost about 23%.
strat_spx_tr |>
drop_na() |>
mutate(dd = familyoffice::calc_drawdown(r),
dd_strat200 = familyoffice::calc_drawdown(strat200),
dd_strat100 = familyoffice::calc_drawdown(strat100),
dd_strat50 = familyoffice::calc_drawdown(strat50)) |>
ggplot(aes(x = date)) +
geom_line(aes(y = dd, color = "Buy and hold"), alpha = 0.5) +
geom_line(aes(y = dd_strat200, color = "SMA 200"), alpha = 0.5) +
geom_line(aes(y = dd_strat100, color = "SMA 100"), alpha = 0.5) +
geom_line(aes(y = dd_strat50, color = "SMA 50"), alpha = 0.5) +
scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
scale_color_manual(values = c("Buy and hold" = "blue", "SMA 200" = "red", "SMA 100" = "green", "SMA 50" = "purple")) +
labs(title = "S&P 500 Total Return: Drawdowns of different strategies",
subtitle = "Data source: Yahoo Finance",
y = "Drawdown",
x = "",
color = "") +
theme(legend.position = "top")
The 200-day SMA kind of sucked after the dot-com bubble burst and did not even recover until after the financial crisis. But the 200-day SMA did a great job. It’s also the 200-day SMA that I personally liked the most (and considered implementing).
Now let’s look at the period since 2020.
strat_spx_tr |>
filter(date >= as.Date("2020-01-01")) |>
drop_na() |>
mutate(p = cumprod(1+r)*100,
p_strat200 = cumprod(1+strat200)*100,
p_strat100 = cumprod(1+strat100)*100,
p_strat50 = cumprod(1+strat50)*100) |>
ggplot(aes(x = date)) +
geom_line(aes(y = p, color = "Buy and hold"), size = 0.5) +
geom_line(aes(y = p_strat200, color = "SMA 200"), size = 0.5) +
geom_line(aes(y = p_strat100, color = "SMA 100"), size = 0.5) +
geom_line(aes(y = p_strat50, color = "SMA 50"), size = 0.5) +
# scale_y_log10() +
scale_color_manual(values = c("Buy and hold" = "blue", "SMA 200" = "red", "SMA 100" = "green", "SMA 50" = "purple")) +
labs(title = "S&P 500 Total Return: Buy and hold vs. Simple Moving Average strategies since 2020",
subtitle = "SPX total return, Data source: Yahoo Finance",
y = "Cumulative return ($100 invested in 2020)",
x = "",
color = "") +
theme(legend.position = "top")
Clearly, buy-and-hold outperformed all SMA strategies since 2020 at least in terms of total return. Again the volatility could greatly be reduced:
strat_spx_tr |>
filter(date >= as.Date("2020-01-01")) |>
drop_na() |>
summarise(sd_buy_hold = sd(r)*sqrt(252),
sd_strat200 = sd(strat200)*sqrt(252),
sd_strat100 = sd(strat100)*sqrt(252),
sd_strat50 = sd(strat50)*sqrt(252)) |>
pivot_longer(everything(), names_to = "strategy", values_to = "annualized_volatility") |>
# mutate(annualized_volatility = scales::percent(annualized_volatility, accuracy = 0.1)) |>
ggplot(aes(x = strategy, y = annualized_volatility, fill = strategy)) +
geom_col() +
scale_fill_manual(values = c("sd_buy_hold" = "blue", "sd_strat200" = "red", "sd_strat100" = "green", "sd_strat50" = "purple")) +
scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) +
labs(title = "S&P 500 Total Return: Annualized volatility of different strategies since 2020",
subtitle = "Data source: Yahoo Finance",
y = "Annualized volatility",
x = "",
fill = "") +
theme(legend.position = "none")
But the question is if the reduced volatility is worth the lower return. The Sharpe ratio (assuming a risk-free rate of 2%) was the highest for the buy-and-hold strategy.
strat_spx_tr |>
filter(date >= as.Date("2020-01-01")) |>
drop_na() |>
summarise(sharpe_buy_hold = (mean(r)*252-0.02)/sd(r)/sqrt(252),
sharpe_strat200 = (mean(strat200)*252-0.02)/sd(strat200)/sqrt(252),
sharpe_strat100 = (mean(strat100)*252-0.02)/sd(strat100)/sqrt(252),
sharpe_strat50 = (mean(strat50)*252-0.02)/sd(strat50)/sqrt(252)) |>
pivot_longer(everything(), names_to = "strategy", values_to = "sharpe_ratio") |>
# mutate(sharpe_ratio = round(sharpe_ratio, 2)) |>
ggplot(aes(x = strategy, y = sharpe_ratio, fill = strategy)) +
geom_col() +
scale_fill_manual(values = c("sharpe_buy_hold" = "blue", "sharpe_strat200" = "red", "sharpe_strat100" = "green", "sharpe_strat50" = "purple")) +
labs(title = "S&P 500 Total Return: Sharpe ratio of different strategies since 2020",
subtitle = "Assuming a risk-free rate of 2%, Data source: Yahoo Finance",
y = "Sharpe ratio",
x = "",
fill = "") +
theme(legend.position = "none")
The Sharpe ratio of SMA strategies was still a little higher than that of buy-and-hold.
strat_spx_tr |>
filter(date >= as.Date("2020-01-01")) |>
drop_na() |>
ggplot(aes(x = date)) +
geom_line(aes(y = adjusted, color = "SPX Total Return"), size = 0.5) +
geom_line(aes(y = sma200, color = "SMA 200"), size = 0.5) +
scale_color_manual(values = c("SPX Total Return" = "blue", "SMA 200" = "red")) +
labs(title = "S&P 500 Total Return and its 200-day Simple Moving Average since 2020",
subtitle = "Data source: Yahoo Finance",
y = "Price (log scale)",
x = "",
color = "") +
theme(legend.position = "top")