O que é?

Este documento Markdown apresenta uma análise estatística sobre predição de valor do ETF SPY, que representa o índice S&P500, da bolsa americana. A ideia principal é baseada na estratégia de debit spreads utilizando opções.

Após isso, vou apresentar modelos preditivos que possam aumentar a nossa chance de entrar em trades com maior chance de sucesso, ou seja, maiores chances de que a valorização de SPY seja acima de 1 USD para aqueles 5 dias.

Biblioteca quantmod e dados de abertura e fechamento de pregões

Para os dados, vamos utilizar a biblioteca quantmod para baixar os dados do YahooFinance com a cotação histórica de SPY.

library(quantmod)
library(tidyverse)

spy = getSymbols("SPY", from = "2015-01-01", to = "2021-10-09", env = NULL) %>%
  data.frame()

knitr::kable(head(spy))
SPY.Open SPY.High SPY.Low SPY.Close SPY.Volume SPY.Adjusted
2015-01-02 206.38 206.88 204.18 205.43 121465900 180.9762
2015-01-05 204.17 204.37 201.35 201.72 169632600 177.7079
2015-01-06 202.09 202.72 198.86 199.82 209151400 176.0341
2015-01-07 201.42 202.72 200.88 202.31 125346700 178.2277
2015-01-08 204.01 206.16 203.99 205.90 147217800 181.3903
2015-01-09 206.40 206.42 203.51 204.25 158567300 179.9367

Apenas o valor de abertura e fechamentos vão nos interessar nesse momento. Pois a ideia é comprar na abertura do 1o dia e verificar o valor de SPY no fechamento ao 5o dia após a compra.

spy = spy %>%
  mutate(
    ds = rownames(spy),
    open = `SPY.Open`,
    close = `SPY.Close`,
    .keep = "none"
  )

knitr::kable(head(spy))
ds open close
2015-01-02 206.38 205.43
2015-01-05 204.17 201.72
2015-01-06 202.09 199.82
2015-01-07 201.42 202.31
2015-01-08 204.01 205.90
2015-01-09 206.40 204.25

Calculo dos diferences alvos: retorno em 5 e 10 dias

Em seguida vamos analisar os retornos em 5 dias, criando a nossa variável alvo. Por questões de teste, vamos também calcular os retornos para 10 dias e ver se há potencial também:

spy = spy %>%
  arrange(ds) %>%
  mutate(
    target_price_5d = lead(close, 5),
    target_return_5d = round(lead(close, 5) / open - 1,4),
    price_diff_5d = lead(close, 5) - open,
    target_price_10d = lead(close, 10),
    target_return_10d = round(lead(close, 10) / open - 1, 4),
    price_diff_10d = lead(close, 10) - open
  )

knitr::kable(head(spy, 10))
ds open close target_price_5d target_return_5d price_diff_5d target_price_10d target_return_10d price_diff_10d
2015-01-02 206.38 205.43 204.25 -0.0103 -2.130005 201.63 -0.0230 -4.750000
2015-01-05 204.17 201.72 202.65 -0.0074 -1.520004 202.06 -0.0103 -2.110000
2015-01-06 202.09 199.82 202.08 0.0000 -0.009994 203.08 0.0049 0.990006
2015-01-07 201.42 202.31 200.86 -0.0028 -0.559997 206.10 0.0232 4.680008
2015-01-08 204.01 205.90 199.02 -0.0245 -4.989991 204.97 0.0047 0.960006
2015-01-09 206.40 204.25 201.63 -0.0231 -4.769989 205.45 -0.0046 -0.949997
2015-01-12 204.41 202.65 202.06 -0.0115 -2.350006 202.74 -0.0082 -1.669999
2015-01-13 204.12 202.08 203.08 -0.0051 -1.039993 200.14 -0.0195 -3.979996
2015-01-14 199.65 200.86 206.10 0.0323 6.450012 201.99 0.0117 2.340011
2015-01-15 201.63 199.02 204.97 0.0166 3.339996 199.45 -0.0108 -2.180008
library(ggplot2)
library(gridExtra)
library(skimr)

spy %>%
  ggplot(aes(x = price_diff_5d)) +
  geom_histogram(alpha = 0.5, fill = "darkblue") +
  theme_light() +
  labs(
    title = "SPY Diferença de Preços entre abertura e fechamento após 5 pregões.",
    x = "Diferença de Preços",
    y = "Frequência",
    caption = paste0("Período entre ", min(spy$ds), " e ", max(spy$ds), ".")
  )

spy %>%
  ggplot(aes(x = price_diff_10d)) +
  geom_histogram(alpha = 0.5, fill = "darkblue") +
  theme_light() +
  labs(
    title = "SPY Diferença de Preços entre abertura e fechamento após 10 pregões.",
    x = "Diferença de Preços",
    y = "Frequência",
    caption = paste0("Período entre ", min(spy$ds), " e ", max(spy$ds), ".")
  )

skim(spy)
Data summary
Name spy
Number of rows 1705
Number of columns 9
_______________________
Column type frequency:
character 1
numeric 8
________________________
Group variables None

Variable type: character

skim_variable n_missing complete_rate min max empty n_unique whitespace
ds 0 1 10 10 0 1705 0

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
open 0 1.00 275.78 66.18 182.34 216.08 267.96 303.47 453.32 ▇▇▅▂▂
close 0 1.00 275.82 66.19 182.86 216.16 267.87 303.33 453.19 ▇▇▃▂▂
target_price_5d 5 1.00 276.03 66.17 182.86 216.29 268.58 303.67 453.19 ▇▇▃▂▂
target_return_5d 5 1.00 0.00 0.02 -0.19 -0.01 0.00 0.01 0.15 ▁▁▇▃▁
price_diff_5d 5 1.00 0.72 6.53 -56.87 -1.63 1.02 3.91 33.46 ▁▁▂▇▁
target_price_10d 10 0.99 276.25 66.14 182.86 216.41 269.02 304.12 453.19 ▇▇▃▂▂
target_return_10d 10 0.99 0.01 0.03 -0.22 -0.01 0.01 0.02 0.17 ▁▁▇▅▁
price_diff_10d 10 0.99 1.41 9.09 -66.12 -1.67 2.13 5.93 41.45 ▁▁▂▇▁

Note que temos algumas informações interessantes aqui durante esse período:

Por curiosidade, vamos observar melhor os percentis para a diferença de preços de 10 dias:

quantile(spy$price_diff_10d, seq(0,1,0.05), na.rm = T)
##          0%          5%         10%         15%         20%         25% 
## -66.1199950 -12.9079924  -7.4200012  -4.1930113  -2.7500000  -1.6699985 
##         30%         35%         40%         45%         50%         55% 
##  -0.7540064  -0.0010009   0.8059994   1.4989949   2.1299900   2.7100015 
##         60%         65%         70%         75%         80%         85% 
##   3.3840086   4.2099950   4.9660002   5.9300085   6.8299924   7.8880016 
##         90%         95%        100% 
##   9.7559880  13.0779971  41.4500130

O dado mostra um viés positivo dessa distribuição, indicando que em 55% das vezes, há um retorno superior a 1.50 USD. Como precisamos apenas de 1.00 USD, essa estratégia me parece bem promissora.

É possível predizer melhor quando o retorno será acima de 1 USD?

Nesta etapa vou desenvolver alguns modelos para verificar se é possível realizar a predição e melhorar nossas chances de realizar uma compra de um debit spread e ter lucro após 5 ou 10 dias. Podemos utilizar alguns métodos diferentes para isso que incluem análise de regressão, árvores de decisão e algoritmos de rede neural. No geral, o processo de modelagem será composto por:

  1. Criação de variáveis
  2. Divisão do conjunto de observações em treinamento e teste.
  3. Ajuste de modelo nos dados de treinamento, avaliação no conjunto de teste.
  4. Escolha do melhor modelo.

Criação de variáveis

A criação de variáveis será feita com base em dados passados, inclusive com o fechamento do dia anterior. Mas no geral, as variáveis são construções baseadas nos preços históricos, incluindo alguns indicadores conhecimentos de média móveis, e algumas outras variáveis que tirei da minha cabeça.

O preço do fechamento anterior será utilizado para evitar informação futura da abertura do dia atual. Em seguida, todas as variáveis serão criadas baseadas nesta nova coluna.

model.data = spy %>%
  mutate(
    # Saídas
    target_return_5d = target_return_5d,
    target_return_10d = target_return_10d,
    
    # O preço do fechamento anterior será utilizado para evitar informação 
    # futura da abertura do dia atual:
    fecha = lag(close, 1),
    
    # Variáveis de retorno temporal
    return_1d = fecha / lag(fecha, 1) - 1,
    return_2d = fecha / lag(fecha, 2) - 1,
    return_3d = fecha / lag(fecha, 3) - 1,
    return_5d = fecha / lag(fecha, 5) - 1,
    return_10d = fecha / lag(fecha, 10) - 1,
    return_20d = fecha / lag(fecha, 20) - 1,
    
    # Variação em relação à médias moveis
    return_mean_3d = fecha / rollapply(fecha, 3, mean, align = "right", fill = NA) - 1,
    return_mean_5d = fecha / rollapply(fecha, 5, mean, align = "right", fill = NA) - 1,
    return_mean_10d = fecha / rollapply(fecha, 10, mean, align = "right", fill = NA) - 1,
    return_mean_20d = fecha / rollapply(fecha, 20, mean, align = "right", fill = NA) - 1,

    # Desvio padrão temporal
    sdratio_5d = rollapply(fecha, 5, sd, align = "right", fill = NA) / 
      rollapply(fecha, 5, mean, align = "right", fill = NA),
    sdratio_10d = rollapply(fecha, 10, sd, align = "right", fill = NA) / 
      rollapply(fecha, 10, mean, align = "right", fill = NA),
    sdratio_20d = rollapply(fecha, 20, sd, align = "right", fill = NA) / 
      rollapply(fecha, 20, mean, align = "right", fill = NA),
    
    # Razão de desvios
    sdratio_10d_5d = sdratio_10d / sdratio_5d - 1,
    sdratio_20d_10d = sdratio_20d / sdratio_10d - 1,
    
    # Razão das máximas
    return_max_3d = fecha / rollapply(fecha, 3, max, align = "right", fill = NA) - 1,
    return_max_5d = fecha / rollapply(fecha, 5, max, align = "right", fill = NA) - 1,
    return_max_10d = fecha / rollapply(fecha, 10, max, align = "right", fill = NA) - 1,
    return_max_20d = fecha / rollapply(fecha, 20, max, align = "right", fill = NA) - 1,
    
    # Razão das mínimas
    return_min_3d = fecha / rollapply(fecha, 3, min, align = "right", fill = NA) - 1,
    return_min_5d = fecha / rollapply(fecha, 5, min, align = "right", fill = NA) - 1,
    return_min_10d = fecha / rollapply(fecha, 10, min, align = "right", fill = NA) - 1,
    return_min_20d = fecha / rollapply(fecha, 20, min, align = "right", fill = NA) - 1,
    
    .keep = "none" # Incluir apenas as variáveis mencionadas
  
  ) %>%
  filter(complete.cases(.)) # Filtrar as linhas vazias

skim(model.data)
Data summary
Name model.data
Number of rows 1674
Number of columns 26
_______________________
Column type frequency:
numeric 26
________________________
Group variables None

Variable type: numeric

skim_variable n_missing complete_rate mean sd p0 p25 p50 p75 p100 hist
target_return_5d 0 1 0.00 0.02 -0.19 -0.01 0.00 0.01 0.15 ▁▁▇▃▁
target_return_10d 0 1 0.01 0.03 -0.22 -0.01 0.01 0.02 0.17 ▁▁▇▅▁
fecha 0 1 275.65 65.04 182.86 216.64 268.96 302.84 453.19 ▇▇▃▂▂
return_1d 0 1 0.00 0.01 -0.11 0.00 0.00 0.01 0.09 ▁▁▇▁▁
return_2d 0 1 0.00 0.01 -0.14 0.00 0.00 0.01 0.11 ▁▁▇▂▁
return_3d 0 1 0.00 0.02 -0.13 -0.01 0.00 0.01 0.17 ▁▂▇▁▁
return_5d 0 1 0.00 0.02 -0.18 -0.01 0.00 0.01 0.17 ▁▁▇▁▁
return_10d 0 1 0.01 0.03 -0.23 -0.01 0.01 0.02 0.19 ▁▁▇▃▁
return_20d 0 1 0.01 0.04 -0.31 -0.01 0.02 0.03 0.23 ▁▁▇▇▁
return_mean_3d 0 1 0.00 0.01 -0.08 0.00 0.00 0.00 0.05 ▁▁▂▇▁
return_mean_5d 0 1 0.00 0.01 -0.10 0.00 0.00 0.01 0.09 ▁▁▇▁▁
return_mean_10d 0 1 0.00 0.02 -0.15 0.00 0.00 0.01 0.07 ▁▁▁▇▁
return_mean_20d 0 1 0.00 0.02 -0.20 0.00 0.01 0.02 0.11 ▁▁▁▇▁
sdratio_5d 0 1 0.01 0.01 0.00 0.00 0.01 0.01 0.07 ▇▁▁▁▁
sdratio_10d 0 1 0.01 0.01 0.00 0.01 0.01 0.01 0.09 ▇▁▁▁▁
sdratio_20d 0 1 0.01 0.01 0.00 0.01 0.01 0.02 0.11 ▇▁▁▁▁
sdratio_10d_5d 0 1 0.65 1.06 -0.32 -0.02 0.31 0.92 12.78 ▇▁▁▁▁
sdratio_20d_10d 0 1 0.61 0.91 -0.30 0.01 0.34 0.88 7.98 ▇▁▁▁▁
return_max_3d 0 1 -0.01 0.01 -0.14 -0.01 0.00 0.00 0.00 ▁▁▁▁▇
return_max_5d 0 1 -0.01 0.01 -0.17 -0.01 0.00 0.00 0.00 ▁▁▁▁▇
return_max_10d 0 1 -0.01 0.02 -0.23 -0.01 0.00 0.00 0.00 ▁▁▁▁▇
return_max_20d 0 1 -0.02 0.03 -0.29 -0.02 -0.01 0.00 0.00 ▁▁▁▁▇
return_min_3d 0 1 0.01 0.01 0.00 0.00 0.00 0.01 0.11 ▇▁▁▁▁
return_min_5d 0 1 0.01 0.01 0.00 0.00 0.01 0.02 0.17 ▇▁▁▁▁
return_min_10d 0 1 0.02 0.02 0.00 0.01 0.02 0.03 0.17 ▇▁▁▁▁
return_min_20d 0 1 0.03 0.03 0.00 0.01 0.03 0.05 0.29 ▇▁▁▁▁

Este conjunto de variáveis já nos permite ter um início para trabalhar. Em seguida, precisamos dividir o conjunto de dados em treinamento e teste, para que possamos desenvolver o modelo.

library(caret)
library(Hmisc)

set.seed(100) # Pra reprodução dos resultados

# Criação da variável indicadora para treinamento e test
index = createDataPartition(
  y = model.data$target_return_5d,
  p = 0.7,
  list = F
)

# Separação dos conjuntos de dados
data.train = model.data[index,]
data.test = model.data[-index,]

# Função para plotar o gráfico de resíduos - Será utilizado em todos os mdoelos.
residuals.plot = function(observed, predicted) {
  res = data.frame(
    observed,
    predicted,
    residual = observed - predicted
  )
  
  plot1 = res %>%
    ggplot(aes(x = predicted, y = residual)) +
    geom_point() +
    geom_smooth() +
    theme_light(base_size = 20) +
    labs(
      title = "Gráfico de Resíduos",
      x = "Valor Predito",
      y = "Resíduos (Observado - Estimado)"
    )
  
  plot2 = res %>%
    ggplot(aes(x = residual)) +
    geom_histogram(alpha = 0.5,  fill = "darkblue") +
    theme_light(base_size = 20) +
    labs(
      title = "Distribuição de Resíduos",
      x = "Resíduos",
      y = "Frequenia"
    )
  
  grid.arrange(plot1, plot2, nrow = 1)
}

# Função para taxa de acerto
# Esta função vai avaliar quantos % de acerto para predição acima de 1 USD.

hit.rate = function(price, observed, predicted) {
  df = data.frame(
    price,
    observed,
    predicted,
    price_observed = price * observed,
    price_predicted = price * predicted
  )
  
  df %>%
    mutate(
      price_observed_1usd = ifelse(price_observed > 1, "yes", "no"),
      price_predicted_1usd = ifelse(price_predicted > 1, "yes", "no")
    ) %>%
    filter(price_predicted_1usd == "yes") %>%
    count(price_predicted_1usd, price_observed_1usd) %>%
    mutate(
      pct = n/sum(n)
    )
    
}

# Simplificando cálculos para a matrix de confusão:
my.confusion.matrix = function(price, observed, predicted) {
  
  df = data.frame(
    price,
    observed,
    predicted,
    price_observed = price * observed,
    price_predicted = price * predicted
  )
  
  df.out = df %>%
    mutate(
      price_observed_1usd = ifelse(price_observed > 1, "yes", "no"),
      price_predicted_1usd = ifelse(price_predicted > 1, "yes", "no")
    )
  
  caret::confusionMatrix(as.factor(df.out$price_predicted_1usd), as.factor(df.out$price_observed_1usd), positive = "yes")
}

# Função de perfil, para testar diferentes limites de predição:

profiling = function(price, observed, predicted) {
  df = data.frame(
    price,
    observed,
    predicted,
    price_observed = price * observed,
    price_predicted = price * predicted
  )
  
  df %>%
    mutate(
      price_observed_1usd = ifelse(price_observed > 1, "yes", "no"),
      pred_intervals = cut2(price_predicted, g = 10),
    ) %>%
    count(pred_intervals, price_observed_1usd) %>%
    pivot_wider(
      names_from = price_observed_1usd, 
      values_from = n,
      names_prefix = "observed_"  
    ) %>%
    mutate(
      hit_rate = observed_yes / (observed_yes + observed_no)
    )
   
   
  
}

Modelos para Returno de 5 dias

Vamos iniciar agora com os modelos para predição de retorno de 5 dias. Vamos fazer o de 10 dias posteriormente.

Regressão Linear Simples

Aqui temos o modelo de regressão linear simples como ponto de partida, utilizando procedimento stepwise para modelo parcimonioso.

mod.reg.null = lm(target_return_5d ~ 1, data = data.train[,-c(2,3)])
mod.reg.full = lm(target_return_5d ~ ., data = data.train[,-c(2,3)])
mod.reg.step = step(mod.reg.null, direction = "forward", scope = formula(mod.reg.full), trace = 0)
summary(mod.reg.step)
## 
## Call:
## lm(formula = target_return_5d ~ return_max_20d + return_max_10d + 
##     return_3d + return_2d + return_mean_20d + sdratio_20d + return_min_20d + 
##     return_min_3d + return_5d + return_min_5d + sdratio_10d + 
##     sdratio_10d_5d + return_10d, data = data.train[, -c(2, 3)])
## 
## Residuals:
##       Min        1Q    Median        3Q       Max 
## -0.151812 -0.008591  0.002218  0.012085  0.143570 
## 
## Coefficients:
##                   Estimate Std. Error t value Pr(>|t|)    
## (Intercept)      0.0024818  0.0013831   1.794 0.073017 .  
## return_max_20d  -0.8344263  0.1423507  -5.862 5.96e-09 ***
## return_max_10d   0.7519354  0.1550565   4.849 1.41e-06 ***
## return_3d       -0.2572269  0.0858848  -2.995 0.002802 ** 
## return_2d        0.2872759  0.0997717   2.879 0.004058 ** 
## return_mean_20d  0.3744827  0.1820480   2.057 0.039904 *  
## sdratio_20d     -1.1779313  0.3276572  -3.595 0.000338 ***
## return_min_20d   0.2245139  0.1098132   2.045 0.041129 *  
## return_min_3d   -0.4415899  0.1704695  -2.590 0.009706 ** 
## return_5d       -0.2168892  0.0715576  -3.031 0.002492 ** 
## return_min_5d    0.0401672  0.1544889   0.260 0.794909    
## sdratio_10d      0.7021622  0.2724562   2.577 0.010084 *  
## sdratio_10d_5d  -0.0016528  0.0008899  -1.857 0.063520 .  
## return_10d      -0.0967114  0.0649589  -1.489 0.136810    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.02278 on 1160 degrees of freedom
## Multiple R-squared:  0.07224,    Adjusted R-squared:  0.06185 
## F-statistic: 6.948 on 13 and 1160 DF,  p-value: 4.394e-13
# Final Results (Training):
residuals.plot(data.train$target_return_5d, predict(mod.reg.step))

my.confusion.matrix(438.66, data.train$target_return_5d, predict(mod.reg.step))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  306 349
##        yes 217 302
##                                           
##                Accuracy : 0.5179          
##                  95% CI : (0.4889, 0.5468)
##     No Information Rate : 0.5545          
##     P-Value [Acc > NIR] : 0.9946          
##                                           
##                   Kappa : 0.0478          
##                                           
##  Mcnemar's Test P-Value : 3.664e-08       
##                                           
##             Sensitivity : 0.4639          
##             Specificity : 0.5851          
##          Pos Pred Value : 0.5819          
##          Neg Pred Value : 0.4672          
##              Prevalence : 0.5545          
##          Detection Rate : 0.2572          
##    Detection Prevalence : 0.4421          
##       Balanced Accuracy : 0.5245          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.train$target_return_5d, predict(mod.reg.step)))
price_predicted_1usd price_observed_1usd n pct
yes no 217 0.4181118
yes yes 302 0.5818882
knitr::kable(profiling(438.66, data.train$target_return_5d, predict(mod.reg.step)))
pred_intervals observed_no observed_yes hit_rate
[-19.332,-0.670) 48 70 0.5932203
[ -0.670,-0.135) 55 62 0.5299145
[ -0.135, 0.272) 63 55 0.4661017
[ 0.272, 0.570) 49 68 0.5811966
[ 0.570, 0.839) 59 58 0.4957265
[ 0.839, 1.115) 50 68 0.5762712
[ 1.115, 1.456) 51 66 0.5641026
[ 1.456, 1.932) 48 70 0.5932203
[ 1.932, 3.019) 53 64 0.5470085
[ 3.019,35.544] 47 70 0.5982906

Aplicação na base de teste a avaliação:

# Final results (testing)
residuals.plot(data.test$target_return_5d, predict(mod.reg.step, newdata = data.test))

my.confusion.matrix(438.66, data.test$target_return_5d, predict(mod.reg.step, newdata = data.test))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  129 143
##        yes  98 130
##                                           
##                Accuracy : 0.518           
##                  95% CI : (0.4732, 0.5626)
##     No Information Rate : 0.546           
##     P-Value [Acc > NIR] : 0.903480        
##                                           
##                   Kappa : 0.0437          
##                                           
##  Mcnemar's Test P-Value : 0.004593        
##                                           
##             Sensitivity : 0.4762          
##             Specificity : 0.5683          
##          Pos Pred Value : 0.5702          
##          Neg Pred Value : 0.4743          
##              Prevalence : 0.5460          
##          Detection Rate : 0.2600          
##    Detection Prevalence : 0.4560          
##       Balanced Accuracy : 0.5222          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.test$target_return_5d, predict(mod.reg.step, newdata = data.test)))
price_predicted_1usd price_observed_1usd n pct
yes no 98 0.4298246
yes yes 130 0.5701754
knitr::kable(profiling(438.66, data.test$target_return_5d, predict(mod.reg.step, newdata = data.test)))
pred_intervals observed_no observed_yes hit_rate
[-9.878,-0.967) 25 25 0.50
[-0.967,-0.201) 23 27 0.54
[-0.201, 0.210) 22 28 0.56
[ 0.210, 0.501) 23 27 0.54
[ 0.501, 0.861) 25 25 0.50
[ 0.861, 1.200) 26 24 0.48
[ 1.200, 1.598) 23 27 0.54
[ 1.598, 2.229) 26 24 0.48
[ 2.229, 3.939) 16 34 0.68
[ 3.939,19.968] 18 32 0.64

A conclusão que podemos tirar é que o modelo, meso com um baixo R2, tem algum poder preditivo, uma vez que o hit-rate foi acima de 50%. Houve estabilidade também, uma vez que os hit-rates são semelhantes entre treinamento e test:

  • Hit-rate treinamento: 58.2%
  • Hit-rate test: 57%

Esse modelo não considera interações entre variáveis, portanto, vamos verificar se é possível ter um modelo melhor com árvores de decisão:

Árvore de Decisão

library(rpart)
library(rpart.plot)

mod.tree = rpart(target_return_5d ~ ., data = data.train[,-c(2,3)])
rpart.plot(mod.tree)

# Final Results (Training):
residuals.plot(data.train$target_return_5d, predict(mod.tree))

my.confusion.matrix(438.66, data.train$target_return_5d, predict(mod.tree))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  497 547
##        yes  26 104
##                                           
##                Accuracy : 0.5119          
##                  95% CI : (0.4829, 0.5409)
##     No Information Rate : 0.5545          
##     P-Value [Acc > NIR] : 0.9985          
##                                           
##                   Kappa : 0.1002          
##                                           
##  Mcnemar's Test P-Value : <2e-16          
##                                           
##             Sensitivity : 0.15975         
##             Specificity : 0.95029         
##          Pos Pred Value : 0.80000         
##          Neg Pred Value : 0.47605         
##              Prevalence : 0.55451         
##          Detection Rate : 0.08859         
##    Detection Prevalence : 0.11073         
##       Balanced Accuracy : 0.55502         
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.train$target_return_5d, predict(mod.tree)))
price_predicted_1usd price_observed_1usd n pct
yes no 26 0.2
yes yes 104 0.8
knitr::kable(profiling(438.66, data.train$target_return_5d, predict(mod.tree)))
pred_intervals observed_no observed_yes hit_rate
[-23.75, 5.60) 497 547 0.5239464
5.60 9 28 0.7567568
[ 7.22,27.31] 17 76 0.8172043
residuals.plot(data.test$target_return_5d, predict(mod.tree, newdata = data.test))

my.confusion.matrix(438.66, data.test$target_return_5d, predict(mod.tree, newdata = data.test))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  203 229
##        yes  24  44
##                                           
##                Accuracy : 0.494           
##                  95% CI : (0.4493, 0.5387)
##     No Information Rate : 0.546           
##     P-Value [Acc > NIR] : 0.9912          
##                                           
##                   Kappa : 0.0515          
##                                           
##  Mcnemar's Test P-Value : <2e-16          
##                                           
##             Sensitivity : 0.1612          
##             Specificity : 0.8943          
##          Pos Pred Value : 0.6471          
##          Neg Pred Value : 0.4699          
##              Prevalence : 0.5460          
##          Detection Rate : 0.0880          
##    Detection Prevalence : 0.1360          
##       Balanced Accuracy : 0.5277          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.test$target_return_5d, predict(mod.tree, newdata = data.test)))
price_predicted_1usd price_observed_1usd n pct
yes no 24 0.3529412
yes yes 44 0.6470588
knitr::kable(profiling(438.66, data.test$target_return_5d, predict(mod.tree, newdata = data.test)))
pred_intervals observed_no observed_yes hit_rate
[-23.75, 5.60) 203 229 0.5300926
5.60 6 13 0.6842105
[ 7.22,27.31] 18 31 0.6326531

Este modelo de árvore de decisão apresenta um resultado já mais interessante: * Hit rate treinamento: 80% * Hit rate teste: 64%

Apesar de um pouco instável devido a diferença nessas estatísticas, me parece que este modelo ainda sim tente a acertar um pouco mais do que aleatoriamente (64% vs 50.3%), com potencial para identificar entrada de trades.

Em seguida vamos testar um modelo de redes neurais, que é mais complexo e considera ainda mais interações.

Rede Neural

library(neuralnet)

mod.neural = neuralnet(target_return_5d ~ ., data = data.train[,-c(2,3)], hidden = 10)
plot(mod.neural)
# Final Results (Training):
residuals.plot(data.train$target_return_5d, predict(mod.neural, data.train))

my.confusion.matrix(438.66, data.train$target_return_5d, predict(mod.neural, data.train))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  304 285
##        yes 219 366
##                                           
##                Accuracy : 0.5707          
##                  95% CI : (0.5418, 0.5992)
##     No Information Rate : 0.5545          
##     P-Value [Acc > NIR] : 0.138619        
##                                           
##                   Kappa : 0.1417          
##                                           
##  Mcnemar's Test P-Value : 0.003788        
##                                           
##             Sensitivity : 0.5622          
##             Specificity : 0.5813          
##          Pos Pred Value : 0.6256          
##          Neg Pred Value : 0.5161          
##              Prevalence : 0.5545          
##          Detection Rate : 0.3118          
##    Detection Prevalence : 0.4983          
##       Balanced Accuracy : 0.5717          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.train$target_return_5d, predict(mod.neural, data.train)))
price_predicted_1usd price_observed_1usd n pct
yes no 219 0.374359
yes yes 366 0.625641
knitr::kable(profiling(438.66, data.train$target_return_5d, predict(mod.neural, data.train)))
pred_intervals observed_no observed_yes hit_rate
[-47.476,-2.988) 75 43 0.3644068
[ -2.988,-1.436) 57 60 0.5128205
[ -1.436,-0.474) 58 60 0.5084746
[ -0.474, 0.339) 55 62 0.5299145
[ 0.339, 0.989) 59 58 0.4957265
[ 0.989, 1.720) 45 73 0.6186441
[ 1.720, 2.516) 53 64 0.5470085
[ 2.516, 3.664) 50 68 0.5762712
[ 3.664, 5.571) 44 73 0.6239316
[ 5.571,40.025] 27 90 0.7692308
residuals.plot(data.test$target_return_5d, predict(mod.neural, newdata = data.test))

my.confusion.matrix(438.66, data.test$target_return_5d, predict(mod.neural, newdata = data.test))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  134 119
##        yes  93 154
##                                           
##                Accuracy : 0.576           
##                  95% CI : (0.5313, 0.6198)
##     No Information Rate : 0.546           
##     P-Value [Acc > NIR] : 0.09619         
##                                           
##                   Kappa : 0.1529          
##                                           
##  Mcnemar's Test P-Value : 0.08598         
##                                           
##             Sensitivity : 0.5641          
##             Specificity : 0.5903          
##          Pos Pred Value : 0.6235          
##          Neg Pred Value : 0.5296          
##              Prevalence : 0.5460          
##          Detection Rate : 0.3080          
##    Detection Prevalence : 0.4940          
##       Balanced Accuracy : 0.5772          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.test$target_return_5d, predict(mod.neural, newdata = data.test)))
price_predicted_1usd price_observed_1usd n pct
yes no 93 0.3765182
yes yes 154 0.6234818
knitr::kable(profiling(438.66, data.test$target_return_5d, predict(mod.neural, newdata = data.test)))
pred_intervals observed_no observed_yes hit_rate
[-29.520,-2.934) 28 22 0.44
[ -2.934,-1.595) 25 25 0.50
[ -1.595,-0.497) 25 25 0.50
[ -0.497, 0.182) 30 20 0.40
[ 0.182, 0.979) 24 26 0.52
[ 0.979, 1.855) 18 32 0.64
[ 1.855, 2.756) 19 31 0.62
[ 2.756, 4.185) 26 24 0.48
[ 4.185, 6.269) 17 33 0.66
[ 6.269,69.948] 15 35 0.70

De forma interessante, a rede neural estimou um modelo estável, com um bom hit rate também:

  • Hit-rate treinamento: 62.5%
  • Hit-rate test: 62.3%

Desta forma, também é um modelo candidato a ser utilizado.

Juntar os modelos pra avaliação conjunta

Vamos observar as tabelas de predições e ver qual é o poder preditivo dos modelos em conjunto.

data.train %>%
  mutate(
    pred.lm = ifelse(438.66 * predict(mod.reg.step) > 1, "yes", "no"),
    pred.rpart = ifelse(438.66 * predict(mod.tree) > 1, "yes", "no"),
    pred.neural = ifelse(438.66 * predict(mod.neural, newdata = .) > 1, "yes","no"),
    obs = ifelse(438.66 * target_return_5d > 1, "yes","no")
  ) %>%
  count(pred.lm, pred.rpart, pred.neural, obs) %>%
  pivot_wider(names_from = obs, values_from = n) %>%
  mutate(
    hit_rate = yes / (yes + no)
  ) %>%
  knitr::kable()
pred.lm pred.rpart pred.neural no yes hit_rate
no no no 204 157 0.4349030
no no yes 90 152 0.6280992
no yes no 4 15 0.7894737
no yes yes 8 25 0.7575758
yes no no 93 105 0.5303030
yes no yes 110 133 0.5473251
yes yes no 3 8 0.7272727
yes yes yes 11 56 0.8358209
data.test %>%
  mutate(
    pred.lm = ifelse(438.66 * predict(mod.reg.step, newdata = data.test) > 1, "yes", "no"),
    pred.rpart = ifelse(438.66 * predict(mod.tree, newdata = data.test) > 1, "yes", "no"),
    pred.neural = ifelse(438.66 * predict(mod.neural, newdata = data.test) > 1, "yes","no"),
    obs = ifelse(438.66 * target_return_5d > 1, "yes","no")
  ) %>%
  count(pred.lm, pred.rpart, pred.neural, obs) %>%
  pivot_wider(names_from = obs, values_from = n) %>%
  mutate(
    hit_rate = yes / (yes + no)
  ) %>%
  knitr::kable()
pred.lm pred.rpart pred.neural no yes hit_rate
no no no 79 68 0.4625850
no no yes 44 63 0.5887850
no yes no 4 7 0.6363636
no yes yes 2 5 0.7142857
yes no no 44 41 0.4823529
yes no yes 36 57 0.6129032
yes yes no 7 3 0.3000000
yes yes yes 11 29 0.7250000

E em seguida, vamos ver se um modelo de regressão consegue ponderar estas predições:

blend.train = data.train %>%
  mutate(
    pred.lm = ifelse(438.66 * predict(mod.reg.step) > 1, "yes", "no"),
    pred.rpart = ifelse(438.66 * predict(mod.tree) > 1, "yes", "no"),
    pred.neural = ifelse(438.66 * predict(mod.neural, newdata = .) > 1, "yes","no"),
    obs = ifelse(438.66 * target_return_5d > 1, "yes","no")
  )

blend.lm = lm(target_return_5d ~ pred.lm * pred.rpart * pred.neural, data = blend.train)
summary(blend.lm)
## 
## Call:
## lm(formula = target_return_5d ~ pred.lm * pred.rpart * pred.neural, 
##     data = blend.train)
## 
## Residuals:
##       Min        1Q    Median        3Q       Max 
## -0.181817 -0.008542  0.003095  0.012530  0.081582 
## 
## Coefficients:
##                                          Estimate Std. Error t value Pr(>|t|)
## (Intercept)                             -0.004683   0.001168  -4.010 6.44e-05
## pred.lmyes                               0.004275   0.001962   2.179  0.02952
## pred.rpartyes                            0.013930   0.005222   2.668  0.00774
## pred.neuralyes                           0.010650   0.001843   5.778 9.69e-09
## pred.lmyes:pred.rpartyes                 0.003796   0.008631   0.440  0.66020
## pred.lmyes:pred.neuralyes               -0.008061   0.002812  -2.866  0.00423
## pred.rpartyes:pred.neuralyes            -0.001979   0.006650  -0.298  0.76606
## pred.lmyes:pred.rpartyes:pred.neuralyes  0.009193   0.010041   0.916  0.36008
##                                            
## (Intercept)                             ***
## pred.lmyes                              *  
## pred.rpartyes                           ** 
## pred.neuralyes                          ***
## pred.lmyes:pred.rpartyes                   
## pred.lmyes:pred.neuralyes               ** 
## pred.rpartyes:pred.neuralyes               
## pred.lmyes:pred.rpartyes:pred.neuralyes    
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.02219 on 1166 degrees of freedom
## Multiple R-squared:  0.1155, Adjusted R-squared:  0.1102 
## F-statistic: 21.76 on 7 and 1166 DF,  p-value: < 2.2e-16
residuals.plot(blend.train$target_return_5d, predict(blend.lm))

my.confusion.matrix(438.66, blend.train$target_return_5d, predict(blend.lm))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  407 395
##        yes 116 256
##                                           
##                Accuracy : 0.5647          
##                  95% CI : (0.5358, 0.5933)
##     No Information Rate : 0.5545          
##     P-Value [Acc > NIR] : 0.2499          
##                                           
##                   Kappa : 0.1629          
##                                           
##  Mcnemar's Test P-Value : <2e-16          
##                                           
##             Sensitivity : 0.3932          
##             Specificity : 0.7782          
##          Pos Pred Value : 0.6882          
##          Neg Pred Value : 0.5075          
##              Prevalence : 0.5545          
##          Detection Rate : 0.2181          
##    Detection Prevalence : 0.3169          
##       Balanced Accuracy : 0.5857          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, blend.train$target_return_5d, predict(blend.lm)))
price_predicted_1usd price_observed_1usd n pct
yes no 116 0.311828
yes yes 256 0.688172
knitr::kable(profiling(438.66, blend.train$target_return_5d, predict(blend.lm)))
pred_intervals observed_no observed_yes hit_rate
-2.054 204 157 0.4349030
-0.179 93 105 0.5303030
0.957 110 133 0.5473251
2.617 90 152 0.6280992
4.056 4 15 0.7894737
[ 7.597,11.897] 22 89 0.8018018

O blend de modelos aparenta ter poder preditivo superior aos modelos individuais. Vamos checar agora se isso ocorre na base de teste também.

blend.test = data.test %>%
  mutate(
    pred.lm = ifelse(438.66 * predict(mod.reg.step, data.test) > 1, "yes", "no"),
    pred.rpart = ifelse(438.66 * predict(mod.tree, data.test) > 1, "yes", "no"),
    pred.neural = ifelse(438.66 * predict(mod.neural, newdata = data.test) > 1, "yes","no"),
    obs = ifelse(438.66 * target_return_5d > 1, "yes","no")
  )

residuals.plot(blend.test$target_return_5d, predict(blend.lm, newdata = blend.test))

my.confusion.matrix(438.66, blend.test$target_return_5d, predict(blend.lm, newdata = blend.test))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  159 166
##        yes  68 107
##                                           
##                Accuracy : 0.532           
##                  95% CI : (0.4872, 0.5764)
##     No Information Rate : 0.546           
##     P-Value [Acc > NIR] : 0.75            
##                                           
##                   Kappa : 0.0891          
##                                           
##  Mcnemar's Test P-Value : 2.281e-10       
##                                           
##             Sensitivity : 0.3919          
##             Specificity : 0.7004          
##          Pos Pred Value : 0.6114          
##          Neg Pred Value : 0.4892          
##              Prevalence : 0.5460          
##          Detection Rate : 0.2140          
##    Detection Prevalence : 0.3500          
##       Balanced Accuracy : 0.5462          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, blend.test$target_return_5d, predict(blend.lm, newdata = blend.test)))
price_predicted_1usd price_observed_1usd n pct
yes no 68 0.3885714
yes yes 107 0.6114286
knitr::kable(profiling(438.66, blend.test$target_return_5d, predict(blend.lm, newdata = blend.test)))
pred_intervals observed_no observed_yes hit_rate
-2.054 79 68 0.4625850
-0.179 44 41 0.4823529
0.957 36 57 0.6129032
2.617 44 63 0.5887850
[ 4.056, 7.860) 11 10 0.4761905
[ 7.860,11.897] 13 34 0.7234043

Treinando modelos usando caret

Em seguida, vamos treinar todos os modelos utilizando o pacote caret, para que a comparação entre modelos seja feita de forma eficiente e que possamos utilizar cross-validation de forma simples e rápida. Também, vamos incluir outros modelos como random forest e gradient boosting.

# Parâmetros para cross-validação
trainParams = trainControl(method = "repeatedcv", number = 5, repeats = 3)

set.seed(100)

# Modelo Linear
caret.lm = train(target_return_5d ~ ., 
                 data = data.train[,-c(2,3)],
                 method = "lm",
                 trControl = trainParams)

# Árvore de Decisão
caret.dtree = train(target_return_5d ~ ., 
                 data = data.train[,-c(2,3)],
                 method = "rpart",
                 trControl = trainParams)

# Rede Neural
caret.neuralnet = train(target_return_5d ~ ., 
                 data = data.train[,-c(2,3)],
                 method = "neuralnet",
                 trControl = trainParams)

# Random Forest
caret.rf = train(target_return_5d ~ ., 
                 data = data.train[,-c(2,3)],
                 method = "rf",
                 trControl = trainParams)

# Gradient Boosting Machine (gbm)
caret.gbm = train(target_return_5d ~ ., 
                 data = data.train[,-c(2,3)],
                 method = "gbm",
                 trControl = trainParams,
                 verbose = F)

# Comparação de Modelos
results = resamples(
  list(
    linear_model = caret.lm,
    decision_tree = caret.dtree,
    neural_net = caret.neuralnet,
    random_forest = caret.rf,
    gradient_boosting = caret.gbm
  )
)

dotplot(results, metric = "RMSE", main = "Comparação de Estimativas de Erro")

Podemos verificar que o modelo de GBM teve uma estimativa de erro um pouco menor, mas ainda sim um intervalo de confiança grande no geral, com grande overlap com modelos mais simples como o modelo de regressão linear e a árvore de decisão. Por fim, vamos utilizar nossas funções anteriores para ver como o resultado na base teste se mantém:

residuals.plot(data.test$target_return_5d, predict(caret.gbm, newdata = data.test))

my.confusion.matrix(438.66, data.test$target_return_5d, predict(caret.gbm, newdata = data.test))
## Confusion Matrix and Statistics
## 
##           Reference
## Prediction  no yes
##        no  153 157
##        yes  74 116
##                                           
##                Accuracy : 0.538           
##                  95% CI : (0.4932, 0.5824)
##     No Information Rate : 0.546           
##     P-Value [Acc > NIR] : 0.6574          
##                                           
##                   Kappa : 0.096           
##                                           
##  Mcnemar's Test P-Value : 6.845e-08       
##                                           
##             Sensitivity : 0.4249          
##             Specificity : 0.6740          
##          Pos Pred Value : 0.6105          
##          Neg Pred Value : 0.4935          
##              Prevalence : 0.5460          
##          Detection Rate : 0.2320          
##    Detection Prevalence : 0.3800          
##       Balanced Accuracy : 0.5495          
##                                           
##        'Positive' Class : yes             
## 
knitr::kable(hit.rate(438.66, data.test$target_return_5d, predict(caret.gbm, newdata = data.test)))
price_predicted_1usd price_observed_1usd n pct
yes no 74 0.3894737
yes yes 116 0.6105263
knitr::kable(profiling(438.66, data.test$target_return_5d, predict(caret.gbm, newdata = data.test)))
pred_intervals observed_no observed_yes hit_rate
[-9.361, 0.690) 54 46 0.4600000
[ 0.690, 0.983) 99 110 0.5263158
[ 0.983, 1.333) 15 33 0.6875000
[ 1.333, 1.499) 19 24 0.5581395
[ 1.499, 2.735) 20 31 0.6078431
[ 2.735,21.586] 20 29 0.5918367

Considerações Finais

Este documento apresentou o procedimento geral de desenvolvimento de um modelo de machine learning para predição futura de preço de ações. No geral, os resultados não são muito positivos, o que se é esperado dado que esta tarefa é muito complexa e os mercados são bem otimizados. No entanto, ainda algumas coisas podem ser feitas ainda para explorar mais esta área: