date close
Min. :2012-07-09 Min. :1202
1st Qu.:2015-02-09 1st Qu.:1677
Median :2017-09-12 Median :1940
Mean :2017-09-11 Mean :2035
3rd Qu.:2020-04-14 3rd Qu.:2283
Max. :2022-11-15 Max. :3248
Purpose Of This Post
There is plethora of day trading coaches claiming indicators like moving averages are useful for timing the market in stock trading. With this short post, I want to investigate whether supervised machine learning models like random forests are capable of extracting information from moving averages (technical indicator) about the future direction of a stock on a daily basis, which will in turn show whether the previously mentioned claims hold water.
Description of the Data
The data I’ll be working with in this post is daily price information on the MSCI World from 2012 to the end of 2022:
Using the zoo
package, I can apply rolling window means of any length. For the visualisation below, I use 2 weeks, 1 month, 6 months and 1 year windows. For the actual machine learning models, I will go all the way down to 3 day windows, to get as many of these curves into the model, as I can. As random forests are capable of feature selection, there is no issue with putting a large amount of potentially useless predictors into the model.
Code
%>%
dt arrange(date) %>%
transmute(date, close = price) %>%
mutate(mov_avg30 = rollapply(lag(close), width = 30, FUN = mean,
align = "right", fill = "NA"),
mov_avg180 = rollapply(lag(close), width = 180, FUN = mean,
align = "right", fill = "NA"),
mov_avg365 = rollapply(lag(close), width = 365, FUN = mean,
align = "right", fill = "NA"),
year = year(date)) %>%
# filter(year == 2022) %>%
pivot_longer(-c(date, year)) %>%
ggplot(aes(date, value, colour = name)) +
geom_line() +
labs(title = "MSCI Closing Price With Moving Averages",
y = "Closing Price",
x = NULL,
colour = NULL) +
scale_colour_manual(values = c("#173F5F", "firebrick",
"#3CAEA3", "grey25")) +
scale_y_continuous(labels = dollar_format())
Before I proceed with the modelling, I calculate the moving averages and normalise them to a percentage expressing the distance to the price. Hence, if the current closing price is above the trend line, the trend line position is expressed as a percentage in \([0,1]\), which is calculated as \(\frac{\text{Trend Line Value}}{\text{Index Value}}\). If the trend line is above the price, it takes a value greater than one. At this stage, it is important to exclude the latest closing price from the rolling windows, as this would lead to data leakage.
Code
<- dt %>%
data arrange(date) %>%
transmute(date, open, close = price) %>%
mutate(direction = ifelse(close > open, "up", "down"),
mov_avg3 = rollapply(lag(close), width = 3, FUN = mean,
align = "right", fill = "NA"),
mov_avg7 = rollapply(lag(close), width = 7, FUN = mean,
align = "right", fill = "NA"),
mov_avg14 = rollapply(lag(close), width = 14, FUN = mean,
align = "right", fill = "NA"),
mov_avg30 = rollapply(lag(close), width = 30, FUN = mean,
align = "right", fill = "NA"),
mov_avg180 = rollapply(lag(close), width = 180, FUN = mean,
align = "right", fill = "NA"),
mov_avg365 = rollapply(lag(close), width = 365, FUN = mean,
align = "right", fill = "NA"),
across(c(mov_avg3:mov_avg365), ~ .x/lag(close)-1),
yday = yday(date),
yweek = week(date),
ymonth = month(date),
wday = wday(date),
year = year(date),
across(where(is.character), as.factor)) %>%
drop_na(direction)
glimpse(data %>% drop_na())
Rows: 2,336
Columns: 15
$ date <date> 2013-12-02, 2013-12-03, 2013-12-04, 2013-12-05, 2013-12-06…
$ open <dbl> 1627.21, 1620.69, 1613.05, 1606.40, 1599.90, 1612.65, 1618.…
$ close <dbl> 1621.34, 1613.31, 1604.74, 1598.88, 1612.61, 1617.60, 1614.…
$ direction <fct> down, down, down, down, up, up, down, down, down, down, up,…
$ mov_avg3 <dbl> -6.427498e-04, 3.005744e-03, 4.781061e-03, 5.228261e-03, 4.…
$ mov_avg7 <dbl> -0.002782715, 0.002012445, 0.006128483, 0.009813784, 0.0114…
$ mov_avg14 <dbl> -6.386120e-03, -1.043229e-03, 4.449149e-03, 9.451909e-03, 1…
$ mov_avg30 <dbl> -0.0110247970, -0.0062618986, -0.0012636546, 0.0041344185, …
$ mov_avg180 <dbl> -0.07192371, -0.06720412, -0.06193912, -0.05632089, -0.0522…
$ mov_avg365 <dbl> -0.12856759, -0.12409107, -0.11906142, -0.11369795, -0.1097…
$ yday <dbl> 336, 337, 338, 339, 340, 343, 344, 345, 346, 347, 350, 351,…
$ yweek <dbl> 48, 49, 49, 49, 49, 49, 50, 50, 50, 50, 50, 51, 51, 51, 51,…
$ ymonth <dbl> 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12,…
$ wday <dbl> 2, 3, 4, 5, 6, 2, 3, 4, 5, 6, 2, 3, 4, 5, 6, 2, 3, 4, 5, 6,…
$ year <dbl> 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013,…
As can be seen above, the target variable I am trying to predict is whether the price of the index will go up
or down
within the next day. As a regression will be much more difficult than just predicting movements, the classification approach is giving the model more of a chance to get predictions right. As for the trading, we are not concerned with the absolute price swings in this model. More on that in the last section on letting the model trade.
Fitting A Classification Model
For the classification, I use all data pre-2019 to train the model and let it trade on hold-out data from 2019 to 2022. Therefore, I split the data into training and testing:
Code
<- data %>%
dt_train filter(year <= 2018)
<- data %>%
dt_test filter(year > 2018)
In a next step, I fit the model without hyperparameter tuning, as I want to see how the model fares generally, without trying to optimise already. Note that I include lags up to 7 days for all predictors in the model, which means that the model can also account for changes in the levels of the trend lines in the past week:
Code
set.seed(1)
<- workflow() %>%
rf_fit add_model(rand_forest() %>%
set_mode("classification") %>%
set_engine("ranger", importance = "permutation")) %>%
add_recipe(recipe(direction ~ ., data = dt_train) %>%
step_rm(close) %>%
step_lag(all_numeric_predictors(), lag = seq(1, 7)) %>%
step_impute_median(all_numeric_predictors()) %>%
step_normalize(all_numeric_predictors())) %>%
fit(dt_train)
Looking at the evaluation metrics:
Code
<- metric_set(accuracy, sensitivity, specificity, precision,
eval_metrics
recall)
%>%
rf_fit augment(dt_test) %>%
eval_metrics(truth = direction, estimate = .pred_class)
# A tibble: 5 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 accuracy binary 0.480
2 sensitivity binary 0.777
3 specificity binary 0.235
4 precision binary 0.456
5 recall binary 0.777
The evaluation metrics don’t look good: accuracy is a coin toss and the model is a little too trigger happy forecasting upward movements, as the sensitivity is high, but precision low.
Code
%>%
rf_fit augment(dt_test) %>%
roc_auc(direction, .pred_up)
# A tibble: 1 × 3
.metric .estimator .estimate
<chr> <chr> <dbl>
1 roc_auc binary 0.489
Code
%>%
rf_fit augment(dt_test) %>%
select(direction, .pred_class, .pred_up, .pred_down) %>%
roc_curve(direction, .pred_up) %>%
autoplot() %>%
labs(title = "ROC AUC Curve")
[[1]]
$title
[1] "ROC AUC Curve"
attr(,"class")
[1] "labels"
ROC AUC looks truly terrible. The model is no better than a random coin toss. What I really want to see though is the performance of the model trading on the holdout data set, as we are not only concerned with the directions of movements, but also with the impact of different movement magnitudes on the portfolio value.
Letting The Model Trade
First, I use the trained model to make predictions on the holdout data set:
Code
<- rf_fit %>%
preds augment(dt_test) %>%
select(date, open, .pred_class)
head(preds)
# A tibble: 6 × 3
date open .pred_class
<date> <dbl> <fct>
1 2019-01-01 1885. down
2 2019-01-02 1885. down
3 2019-01-03 1883. down
4 2019-01-04 1854. up
5 2019-01-07 1904. up
6 2019-01-08 1920. up
Now comes the interesting part: I will let the model trade based on the following rules:
- Start at Portfolio Value of 100%
- If the model signals “up”, then enter the market and hold for each “up” signal, until the first “down” prediction arrives
- In that case, sell and bank the difference
This will be repeated over the whole trading period:
Code
for (i in 1:nrow(preds)){
# on first day, no trading, portfolio value is at 100%
if (i == 1){
"PV"] = 1
preds[i, next
}
# hold if current is "up" and last was "up"
else if (preds$.pred_class[i] == "up" & preds$.pred_class[i-1] == "up"){
"PV"] = preds[i-1,"PV"]
preds[i,
}
# stay out of market if current is down and previous was down
else if (preds$.pred_class[i] == "down" & preds$.pred_class[i-1] == "down"){
"PV"] = preds[i-1, "PV"]
preds[i,
}
# go into market if previous was "down" and current is "up"
else if (preds$.pred_class[i] == "up" & preds$.pred_class[i-1] == "down"){
= preds$open[i]
buy_price "PV"] = preds[i-1, "PV"]
preds[i,
}
# go out of market if previous was "up" and current is "down"
# this is the only transaction that actually affects PV
else if (preds$.pred_class[i] == "down" & preds$.pred_class[i-1] == "up"){
= preds$open[i]
sale_price "PV"] = preds[i-1, "PV"] * (sale_price/buy_price)
preds[i,
} }
After the loop has finished, we can inspect the performance on the holdout data:
Code
%>%
dt_test transmute(date, msci = close) %>%
mutate(msci = msci/first(msci)) %>%
left_join(
%>%
preds transmute(date, model = PV)
%>%
) fill(model) %>%
mutate(model = ifelse(is.na(model), 1, model)) %>%
pivot_longer(-date) %>%
ggplot(aes(date, value, colour = name)) +
geom_step() +
labs(title = "Comparing One Model To Just Holding The MSCI",
y = "Cumulative Performance",
x = NULL,
colour = NULL) +
scale_y_continuous(labels = scales::percent_format()) +
::scale_colour_d3() ggsci
From the chart, it becomes apparent that the random forest model does not perform particularly well, coming in significantly lower than the MSCI. The model missed out on the 12 months upwards rally throughout the year of 2021, but subsequently rode down the contraction in 2022 - suboptimal performance to put it lightly.
Lastly, let’s fit 50 random forest models with the exact same parameters to the data in order to inspect the robustness of the predictions and how strong performance varies between models. Writing to functions to save some redundant typing:
Code
<- function(){
fit_rf workflow() %>%
add_model(rand_forest() %>%
set_mode("classification") %>%
set_engine("ranger", importance = "permutation")) %>%
add_recipe(recipe(direction ~ ., data = dt_train) %>%
step_rm(close) %>%
step_lag(all_numeric_predictors(), lag = seq(1, 7)) %>%
step_impute_median(all_numeric_predictors()) %>%
step_normalize(all_numeric_predictors())) %>%
fit(dt_train)
}
<- function(tbl){
trade for (i in 1:nrow(tbl)){
# on first day, no trading, portfolio value is at 100%
if (i == 1){
"PV"] = 1
tbl[i, next
}
# hold if current is "up" and last was "up"
else if (tbl$.pred_class[i] == "up" & tbl$.pred_class[i-1] == "up"){
"PV"] = tbl[i-1,"PV"]
tbl[i,
}
# stay out of market if current is down and previous was down
else if (tbl$.pred_class[i] == "down" & tbl$.pred_class[i-1] == "down"){
"PV"] = tbl[i-1, "PV"]
tbl[i,
}
# go into market if previous was "down" and current is "up"
else if (tbl$.pred_class[i] == "up" & tbl$.pred_class[i-1] == "down"){
= tbl$open[i]
buy_price "PV"] = tbl[i-1, "PV"]
tbl[i,
}
# go out of market if previous was "up" and current is "down"
# this is the only transaction that actually affects PV
else if (tbl$.pred_class[i] == "down" & tbl$.pred_class[i-1] == "up"){
= tbl$open[i]
sale_price "PV"] = tbl[i-1, "PV"] * (sale_price/buy_price)
tbl[i,
}
}
return(tbl)
}
<- tibble(models = replicate(fit_rf(), n = 50, simplify = F)) %>%
multiple_rf mutate(model_nr = paste("Model", 1:nrow(.)),
preds = map(models, ~ augment(.x, dt_test)),
preds = map(preds, ~ trade(.x)))
I can plot the results, showing the range of the predictions with confidence bands versus the actual MSCI price:
Code
%>%
multiple_rf unnest(preds) %>%
transmute(model_nr, date, value = PV) %>%
group_by(date) %>%
summarise(min_model = min(value),
mean_model = mean(value),
max_model = max(value),
q25 = quantile(value, 0.25),
q75 = quantile(value, 0.75)) %>%
left_join(dt_test %>%
arrange(date) %>%
transmute(date, msci = close/first(close)),
by = "date") %>%
ggplot(aes(x = date)) +
geom_line(aes(y = msci)) +
geom_line(aes(y = mean_model), colour = "dodgerblue") +
geom_ribbon(aes(ymin = q75, ymax = max_model),
alpha = 0.25, colour = NA, fill = "dodgerblue") +
geom_ribbon(aes(ymin = q25, ymax = q75),
alpha = 0.4, fill = "dodgerblue") +
geom_ribbon(aes(ymin = min_model, ymax = q25),
alpha = 0.25, fill = "dodgerblue") +
labs(title = "Comparing 50 Random Forests To The MSCI",
subtitle = "Blue line indicates the average performance of 50 models.\nThe darker blue ribbon indicates the 25th and 75th percentile range.\nThe lighter blue ribbon indicates the total range of model performances.",
y = "Cumulative Performance",
x = NULL) +
scale_y_continuous(labels = percent_format())
As seen in the chart above, the random forest models have huge variance. Combined with the previously seen ROC AUC score, this is a clear indication that there is not much useful information in the predictors. Therefore, I can conclude that moving averages convey close to no information about the price development in the MSCI, and instead of spending countless hours analysing and working on daily trading decisions, one might be much better off just holding an ETF on the MSCI over a longer period of time. Of course, the disclaimer here is: We never know, whether things might change completely going forward.
I hope this post has been interesting to you. In case of constructive feedback or if you want to exchange about this or a related topic, feel free to reach out.
Thank you for reading.
A work by Mathias Steilen