1. 고정 기간 기법

대부분의 금융 관련 머신러닝 논문은 고정 기간 기법으로 관측값을 레이블한다. 그렇지만 이러한 기법은 그 인지도에도 불구하고 피해야 한다고 프라도 교수는 말한다. \(\tau\)는 사전에 정의된 상수 임계값을 의미한다.

\[ y_t = \left\{ \begin{array}{11} 1 & \text{if } \,r_{t_i, t_i+h} > \tau \\ 0 & \text{if } \,\left|r_{t_i, t_i+h}\right| \le \tau \\ -1 & \text{if } \,r_{t_i, t_i+h} < \tau \end{array} \right. \\ \\ r_{t_i, t_i+h} = \frac{P_{t+h}}{P_t}-1 \]

2. 동적 임계값 계산

변동성이 높으면 더 높은 수익률을 요구하고, 낮으면 더 낮은 수익률에 반응하도록 수익률 임계값 \(\tau\)를 조정하는 것이 목적이다.

# EWMA 기반 실현 변동성 추정
get_daily_volatility <- function(price, span = 100) {
  log_ret <- diff(log(price))
  ewma_vol <- TTR::runSD(log_ret, n = span)
  return(ewma_vol)
}

3. 삼중 배리어 기법

삼중 배리어(Tripple-Barrier) 기법의 기본적인 아이디어는 세 가지 배리어 중 최초로 도달한 배리어에 따라 관측값을 레이블하는 것이다. 세 가지 배리어는 2개의 수평 배리어(horizontal barrier)와 1개의 수직 배리어(vertical barrier)으로 구성된다.

이처럼 삼중 배리어 기법은 어떤 배리어에 먼저 도달하느냐에 따라 lable이 결정된다. 즉, 경로 의존적(path-dependent)임을 알 수 있다!!

4. 방향과 크기 파악

알고리즘이 배팅의 방향(long vs. short)과 크기를 학습할 수 있도록 레이블하는 방법을 다룬다. 보통 포지션의 부호를 설정하는 기저 모델이 없을 경우 이익/손실 배리어를 구분할 필요가 없이 양방향 대칭을 가정하여 학습해야 한다. 교재에서 다루는 병렬 처리 기법에 대한 부분은 생략한다.

getEvents <- function(price, 
                      tEvents, 
                      ptSl = c(1,1), 
                      trgt,
                      minRet = 0.005,
                      t1 = FALSE) {
  
  # 1. 목표 구하기
  trgt <- trgt[tEvents]
  
  # 2. t1구하기(최대 보유 기간)
  if (isFALSE(t1)) t1 <- rep(NA, length(trgt))
  
  # 3. t1에 손절을 적용해 이벤트 객체를 생성
  side <- rep(1, length(trgt))
  events <- tibble(
    t0 = index(trgt),
    t1 = t1,
    trgt = trgt,
    side = side
  )
  
  events <- events |> 
    filter(trgt > minRet & !is.na(trgt))
  
  df0 <- apply_pt_sl_on_t1(
    price = price,
    events = events,
    ptSl = ptSl
  ) |> 
    unnest(cols = c("pt", "sl"))
  
  df0 <- df0 |> 
    rowwise() |> 
    mutate(df0_min = min(c_across(everything()), na.rm = TRUE)) |> 
    ungroup()
  
  events$t1 <- df0$df0_min
  
  return(events)
  
}
apply_pt_sl_on_t1 <- function(price,
                              events,
                              ptSl) {
  
  # t1(이벤트 끝)이전에 발생하면 이익/손절 실현을 적용한다.
  events <- events |> 
    mutate(
      t1 = if_else(is.na(t1), last(index(price)), t1),
      pt = if (ptSl[1] > 0) ptSl[1] * coredata(trgt) else NA_real_,
      sl = if (ptSl[2] > 0) -ptSl[2] * coredata(trgt) else NA_real_
    )
  
  res <- events %>%
    rowwise() %>%
    mutate(
      px_path = list(window(price, start = t0, end = t1)),
      ret_path = list({
        base_px <- as.numeric(price[t0])
        r <- (coredata(px_path) / base_px - 1) * side
      }),
      date_path = list({
        index(px_path)
      }),
      sl_date = list({
        which_hit <- which(ret_path < sl)
        if (length(which_hit) > 0) date_path[which_hit[1]] else NA
      }),
      pt_date = list({
        which_hit <- which(ret_path > pt)
        if (length(which_hit) > 0) date_path[which_hit[1]] else NA
      })
    ) |> 
    ungroup() |> 
    select(t1, pt = pt_date, sl = sl_date)
  
  return(res)

}
add_vertical_barrier <- function(price, tEvents, num_days = 5) {
 
  tEvents <- index(price[tEvents])
  price_idx <- index(price)
   
  t1_vec <- sapply(tEvents, function(t0) {
    target_day <- t0 + days(num_days)
    t1_idx <- which(price_idx >= target_day)[1]
    price_idx[t1_idx]
  })
  return(as.POSIXct(t1_vec, tz = "UTC"))
}
get_bins <- function(price, events) {
  
  out <- events |> 
    rowwise() |> 
    mutate(
      ret = coredata(price[t1]) / coredata(price[t0]) - 1,
      bin = sign(ret)) |> 
    ungroup() |> 
    select(t0, ret, bin)
  
  return(out)
  
}
drop_labels <- function(labels, minPtc = 0.05) {
  repeat {
    props <- prop.table(table(labels$bin))
    
    if (min(props) > 0.05 || length(props) < 3 ) break
    rare <- names(props)[which.min(props)]
    
    labels <- labels |> 
      filter(bin != rare)
  }
  return(labels)
}

4.1 Exercise 3.1 ~ 2

  • 데이터: E-mini S&P500
  • 기간: 2011-07-31 ~ 2012-07-30

1) 데이터 불러오기

library(tidyverse)
library(quantmod)
library(PerformanceAnalytics)

url <- "https://raw.githubusercontent.com/firmai/research/master/Advances%20in%20Financial%20Machine%20Learning/Labelling/sample_dollar_bars.csv"

data <- read.csv(url, stringsAsFactors = FALSE)
data <- data |> 
  mutate(date_time = as_datetime(date_time, tz = "UTC"))

data_zoo <- zoo(data$close, order.by = data$date_time)

plot(data_zoo, main = "E-mini S&P500", xlab = "Date", ylab = "Close")

2) 일일 변동성 및 CUSUM필터로 이벤트 생성

  • threshold는 일일 변동성의 평균을 사용한다.
# 1. 일일 변동성
vol <- get_daily_volatility(data_zoo, span = 50)

# 2. 이벤트 생성
get_t_events <- function(gRaw, h) {
  # gRaw: 벡터 또는 시계열 (숫자형)
  # h: threshold (양수)
  
  # diff 계산
  delta <- diff(gRaw)
  tEvents <- c()
  sPos <- 0
  sNeg <- 0

  # loop 시작 (R의 diff는 길이가 n-1이므로 index를 2부터)
  for (i in 1:length(delta)) {
    sPos <- max(0, sPos + delta[i])
    sNeg <- min(0, sNeg + delta[i])
    
    if (sNeg < -h) {
      sNeg <- 0
      tEvents <- c(tEvents, i + 1)  # index 보정 (diff는 1개 짧음)
    } else if (sPos > h) {
      sPos <- 0
      tEvents <- c(tEvents, i + 1)
    }
  }

  return(tEvents)  # 시계열 인덱스가 있다면 index 벡터로 바꿔도 됨
}

log_ret <- diff(log(data_zoo))

tEvents <- get_t_events(log_ret, h = mean(vol, na.rm = TRUE)) + 1

length(tEvents)
## [1] 4809
  • 10,000개 데이터 중 4,809개의 이벤트가 생성되었다.

3) 수직 배리어 생성

  • num_days = 1
t1 <- add_vertical_barrier(price = data_zoo,
                           tEvents = tEvents,
                           num_days = 1)

head(t1)
## [1] "2011-08-02 09:07:37 UTC" "2011-08-02 10:52:48 UTC"
## [3] "2011-08-02 12:27:07 UTC" "2011-08-02 13:13:13 UTC"
## [5] "2011-08-02 13:32:02 UTC" "2011-08-02 13:37:28 UTC"

4) 삼중배리어 기법 적용

tripple_barrier_events <- getEvents(price = data_zoo,
                                    tEvents = tEvents,
                                    ptSl = c(1,1),
                                    trgt = vol,
                                    minRet = mean(vol, na.rm = T),
                                    t1 = t1)
head(tripple_barrier_events)
## # A tibble: 6 × 4
##   t0                  t1                  trgt         side
##   <dttm>              <dttm>              <zoo>       <dbl>
## 1 2011-08-01 19:47:28 2011-08-02 06:46:46 0.002159646     1
## 2 2011-08-01 19:53:37 2011-08-02 06:46:46 0.002157316     1
## 3 2011-08-02 12:47:28 2011-08-02 13:44:01 0.002057912     1
## 4 2011-08-02 13:13:13 2011-08-02 13:44:01 0.002062822     1
## 5 2011-08-03 18:00:05 2011-08-03 18:36:01 0.002075666     1
## 6 2011-08-03 18:15:08 2011-08-03 18:26:42 0.002079140     1

5) 라벨링

labels <- get_bins(price = data_zoo,
                   events = tripple_barrier_events)

head(labels)
## # A tibble: 6 × 3
##   t0                       ret   bin
##   <dttm>                 <dbl> <dbl>
## 1 2011-08-01 19:47:28 -0.00604    -1
## 2 2011-08-01 19:53:37 -0.00507    -1
## 3 2011-08-02 12:47:28  0.00354     1
## 4 2011-08-02 13:13:13  0.00275     1
## 5 2011-08-03 18:00:05 -0.00320    -1
## 6 2011-08-03 18:15:08 -0.00399    -1
table(labels$bin)
## 
##   -1    0    1 
## 1300    6 1224

6) 희귀 라벨링 제거

clean_labels <- drop_labels(labels)

table(clean_labels$bin)
## 
##   -1    1 
## 1300 1224

5. Meta-Labeling

베팅의 크기를 알고 있다고 가정해보자. 이 경우에는 베팅의 크기가 중요한데 이는 얼마를 집행할 것인지를 결정하는 문제이다. 물론 베팅이 없을 가능성도 존재한다. 지금까지는 베팅의 방향과 크기 모두를 머신러닝 알고리즘에 의존해 한번에 라벨링을 시도했다면 앞으로는 베팅의 방향을 이미 알고 있다는 전제 하에 베팅의 크기에 대한 학습을 시도하려고 한다. 프라도 교수는 이러한 방식을 메타 레이블링(meta-labeling)이라고 부르는데 1차 외생 모델을 어떻게 사용할지 학습하는 부수적인 머신러닝 모델을 구축하고자 하기 때문이다.


메타 레이블링을 기존 함수들에 적용하기 위해 약간의 수정이 필요하다.

getEvents_meta <- function(price, 
                        tEvents, 
                        ptSl = c(1, 1), 
                        trgt,
                        minRet = 0.005,
                        t1 = FALSE,
                        side = NULL) {
  
  # 1. 목표 구하기
  trgt <- trgt[tEvents]
  
  # 2. t1구하기(최대 보유 기간)
  if (isFALSE(t1)) t1 <- rep(NA, length(trgt))
  
  # 3. t1에 손절을 적용해 이벤트 객체를 생성
  if (is.null(side)) {
    side <- rep(1, length(trgt))
    ptSl <- c(ptSl[1], ptSl[1]) # 대칭
  } else {
    side <- side[tEvents]
  }
  
  events <- tibble(
    t0 = index(trgt),
    t1 = t1,
    trgt = trgt,
    side = side
  )
  
  events <- events |> 
    filter(trgt > minRet & !is.na(trgt))
  
  df0 <- apply_pt_sl_on_t1(
    price = price,
    events = events,
    ptSl = ptSl
  ) |> 
    unnest(cols = c("pt", "sl"))
  
  df0 <- df0 |> 
    rowwise() |> 
    mutate(df0_min = min(c_across(everything()), na.rm = TRUE)) |> 
    ungroup()
  
  events$t1 <- df0$df0_min
  
  return(events)
  
}
get_bins_meta <- function(price, events) {
  
  out <- events |> 
    rowwise() |> 
    mutate(
      ret = coredata(price[t1]) / coredata(price[t0]) - 1,
      bin = if_else(side * ret <= 0, 0, 1)) |> 
    ungroup() |> 
    select(t0, ret, bin)
  
  return(out)
}

6. How to use Meta-Labeling

메타 레이블링 전략의 출력값은 이진 변수이기에 1종 오류와 2종 오류 간의 트레이트 오프로 생각할 수 있다. 정밀도는 ’모델의 예측값이 얼마나 정확하게 예측됐는가를 나타내는 지표’이고 재현도는 ’실제값 중에서 모델이 얼마나 정확하게 예측했는가를 나타내는 지표’이다. 특히 재현율은 모델의 검증력(power)과 유사하다.


분류기 전체의 효율성을 극대화시키는 정밀도와 재현율의 조합이 존재하는데 F1-score는 분류기의 효율성을 측정하는데 정밀도와 재현율 사이의 조화 평균을 사용해 측정한다. 그리고 메타 레이블링은 더 높은 F1-score를 원할 경우 더욱 유용하다.

\[ \text{Precision(정밀도)} = \frac{TP}{TP + FP} \\ \text{Recall(재현율)} = \frac{TP}{TP + FN} \\ \]

  1. 1차 모델에서 정밀도는 낮더라도 높은 재현율을 갖는 모델을 구축한다.
  2. 1차 모델에서 예측된 양성에 대해 메타 레이블링을 적용해 낮은 정밀도를 교정한다.

2차 머신러닝 알고리즘의 역할은 1차(외생) 모델에 의해 결정된 양성이 참인지 거짓인지를 판단하는 것이다. 즉 베팅 기회를 측정하는 것이 목적이 아니라 제안된 베팅을 실행할 것인지, 실행하지 않을 것인지를 결정하는 것이 목적이다. 메타 레이블링이 강력한 이유는 다음과 같다.

  1. 화이트 박스 + 블랙 박스
    • 1차 모델(화이트 박스, 계량 경제 모형, 기술적 트레이딩 모형 등) 위에 2차 모델(머신러닝)을 구축하는 방식으로 인해 해석력과 수용력이 높아진다.
  2. 과적합 효과 억제
  3. 방향과 크기를 분리함으로써 정교한 전략 구조 생성

7. Excercise 3.4

Question. 잘 알려진 기술적 분석 통계량(예: 교차 이동 평균)에 기초해 추세 추종 전략을 개발하라. 모델은 각 관측값에 대해 방향을 제안하지만, 크기는 제안하지 않는다.

  1. ptS=[1,2]t1에 대해 메타 레이블링을 만들어라. 여기서 numDays=1 이다. 코드 3.1에서 계산된 일별 표준 편차를 trgt로 사용하라.
  2. 랜덤 포레스트를 학습해 거래할 것인지 말 것인지를 결정하라. 이때 유의할 점은 기저 모델(교차 이동 평균)이 방향 [-1,1]을 결정했으므로 정해야 할 사항은 거래할 것인지 말 것인지를 판단하는 것이다.

1. Fit a Primary model: Trend Following
data <- tibble(
  date = index(data_zoo),
  close = coredata(data_zoo)
)

data <- data |> 
  mutate(
    fast_mavg = TTR::SMA(close, n = 20),
    slow_mavg = TTR::SMA(close, n = 50),
    side = dplyr::lag(if_else(fast_mavg >= slow_mavg, 1, -1))
  ) |> 
  na.omit()

head(data)
## # A tibble: 6 × 5
##   date                close fast_mavg slow_mavg  side
##   <dttm>              <dbl>     <dbl>     <dbl> <dbl>
## 1 2011-08-01 19:47:28 1284.     1276.     1284.    -1
## 2 2011-08-01 19:53:37 1282.     1277.     1284.    -1
## 3 2011-08-01 19:58:00 1282.     1277.     1283.    -1
## 4 2011-08-01 20:00:00 1282.     1277.     1283.    -1
## 5 2011-08-01 20:10:06 1282.     1278.     1283.    -1
## 6 2011-08-01 23:41:59 1281      1278.     1282.    -1
table(data$side)
## 
##   -1    1 
## 4747 5203
2. Filter Events: CUSUM Filter and Labels
# 일일 변동성 계산
daily_vol <- get_daily_volatility(data_zoo, span = 50)

# CUSUM필터로 이벤트 생성
log_ret <- diff(log(data$close))
tEvents <- get_t_events(log_ret, h = mean(daily_vol, na.rm = T)) + 1

# 수직 배리어 생성


t1 <- add_vertical_barrier(price = data_zoo,
                           tEvents = tEvents, 
                           num_days = 1)

ptSl <- c(1, 2)

# 삼중 배리어 기법 적용
triple_barrier_events <- getEvents_meta(price = data_zoo, 
                                        tEvents = tEvents, 
                                        ptSl = ptSl, 
                                        trgt = daily_vol, 
                                        minRet = mean(daily_vol, na.rm = T), 
                                        t1 = t1, 
                                        side = data$side)
# 메타 라벨링
labels <- get_bins_meta(data_zoo, events = triple_barrier_events)

head(labels)
## # A tibble: 6 × 3
##   t0                       ret   bin
##   <dttm>                 <dbl> <dbl>
## 1 2011-08-01 19:53:37 -0.00507     1
## 2 2011-08-03 17:43:21 -0.00319     1
## 3 2011-08-03 17:48:20 -0.00399     1
## 4 2011-08-03 18:26:42  0.00420     0
## 5 2011-08-03 18:36:01  0.00541     0
## 6 2011-08-03 19:15:17  0.00500     0
table(labels$bin)
## 
##    0    1 
##  810 1641
3. Fit a Meta model
  • 다음과 같은 특징(feature)을 만든다.
    • Volatility
    • Serial Correlation
    • The returns at the different lags from the serial correlation
    • The sides from the SMavg Strategy
3.1 Features
roll_ret <- function(close, n) {
  TTR::ROC(close, n = n, type = "discrete")
}

roll_sd <- function(ret, width) {
  rollapply(ret, width = width, FUN = sd, na.pad = TRUE, align = "right")
}

func_acf <- function(ret, lag) {
  ret <- na.omit(ret)
  acf(ret, plot = F, lag.max = lag)$acf[lag + 1]
}

roll_acf <- function(ret, lag) {
  rollapply(ret, width = 50, FUN = func_acf, lag = lag, na.pad = T)
} 

feature_data <- data |> 
                  mutate(log_ret = log(close) - log(dplyr::lag(close)),
                         mon1 = roll_ret(close, 1),
                         mon2 = roll_ret(close, 2),
                         mon3 = roll_ret(close, 3),
                         mon4 = roll_ret(close, 4),
                         mon5 = roll_ret(close, 5),
                         volatility_50 = roll_sd(log_ret, 50),
                         volatility_31 = roll_sd(log_ret, 31),
                         volatility_15 = roll_sd(log_ret, 15),
                         autocorr_1 = roll_acf(log_ret, lag = 1),
                         autocorr_2 = roll_acf(log_ret, lag = 2),
                         autocorr_3 = roll_acf(log_ret, lag = 3),
                         autocorr_4 = roll_acf(log_ret, lag = 4),
                         autocorr_5 = roll_acf(log_ret, lag = 5),
                         log_t1 = dplyr::lag(log_ret, 1),
                         log_t2 = dplyr::lag(log_ret, 2),
                         log_t3 = dplyr::lag(log_ret, 3),
                         log_t4 = dplyr::lag(log_ret, 4),
                         log_t5 = dplyr::lag(log_ret, 5)
                         ) |> 
  select(-c(close, fast_mavg, slow_mavg))

x <- feature_data[which(feature_data$date %in% labels$t0), ]
y <- labels$bin
3.2 Balance classes
library(caret)
library(rsample)
library(MLmetrics)

# Split data into training, validation and test sets
data_split <- initial_time_split(x, prop = 0.85)
x_train <- training(data_split)
x_validation <- testing(data_split)

y_train <- y[data_split$in_id]
y_validation <- y[data_split$out_id]

train_df <- tibble(bin = y_train) |> 
  bind_cols(x_train) |> 
  mutate(bin = factor(if_else(bin == 1, "Y", "N"))) |> 
  select(-date) |> 
  na.omit()

valid_df <- tibble(bin = y_validation) |> 
  bind_cols(x_validation) |> 
  mutate(bin = factor(if_else(bin == 1, "Y", "N"))) |> 
  select(-date) |> 
  na.omit()
3.3 Fit a model
grid_rf <- expand.grid(
  mtry = c(10, 25, 50, 100)
)

ctrl <- trainControl(
  method = "timeslice", 
  initialWindow = 60, 
  horizon = 3, 
  fixedWindow = T,
  classProbs = T,
  summaryFunction = prSummary
)

set.seed(1995)

m_rf <- caret::train(bin ~ .,
                     data = train_df,
                     method = "rf",
                     trControl = ctrl,
                     tuneGrid = grid_rf,
                     metric = "F")

m_rf
## Random Forest 
## 
## 2082 samples
##   20 predictor
##    2 classes: 'N', 'Y' 
## 
## No pre-processing
## Resampling: Rolling Forecasting Origin Resampling (3 held-out with a fixed window) 
## Summary of sample sizes: 60, 60, 60, 60, 60, 60, ... 
## Resampling results across tuning parameters:
## 
##   mtry  AUC        Precision  Recall     F        
##    10   0.2430556  0.4729272  0.3727665  0.7543676
##    25   0.2388285  0.4677356  0.3898644  0.7510624
##    50   0.2410427  0.4577909  0.3801602  0.7484111
##   100   0.2401872  0.4579365  0.3867837  0.7438420
## 
## F was used to select the optimal model using the largest value.
## The final value used for the model was mtry = 10.
  • ROC 곡선
library(pROC)

p_valid <- predict(m_rf, newdata = valid_df, type = "prob")[, "Y"]

roc_obj <- pROC::roc(valid_df$bin, p_valid)
plot(roc_obj, print.auc = T, lwd = 2, legacy.axes = T, grid = T)

  • Feature Importance
vi <- caret::varImp(m_rf, scale = F)$importance
vi <- vi |> 
  arrange(desc(Overall))

ggplot(vi, aes(x = reorder(rownames(vi), Overall), y = Overall)) +
  geom_col(fill = "darkblue") +
  coord_flip() +
  theme_bw() +
  labs(title = "Feature Importance (Gini, RF)", x = NULL, y = "Importance")

  • 장기간의 틱 데이터를 구하기가 쉽지 않아….제대로된 테스트가 힘들다. 책도 전부 공부하고 온전한 1년치 이상의 데이터로 다시 도전해보려고 한다.