1 서론

최근 몇 년간 코인 시장은 전통 금융시장과는 다른 독특한 특성과 높은 변동성을 보이며 투자자들의 관심을 끌고 있다. 이러한 환경에서, 과거의 가격 추세와 수익률 패턴에 기반한 모멘텀 전략은 미래 가격 움직임을 예측하는 유용한 도구로 자리 잡았다. 본 리포트에서는 전통 금융시장에서 입증된 모멘텀 전략을 코인 시장에 적용하여, 급변하는 시장 상황에서도 단기 및 중장기 투자 기회를 포착할 수 있는지 검증하고자 한다.

또한, 단순히 개별 코인의 모멘텀에만 의존하는 것이 아니라 자산배분 전략을 함께 도입하면, 다양한 코인 간 상관관계 및 시장의 구조적 변화를 반영한 다각적 투자 전략을 수립할 수 있다. 이는 개별 코인의 높은 변동성과 예측 불가능한 리스크를 효과적으로 분산시켜, 포트폴리오의 안정성과 위험 관리 측면에서 우수한 성과를 달성할 수 있도록 도와줄 것이다.

본 리포트는 전략 설정일 기준 시가총액 상위 10개 종목을 대상으로 하며, 약 5년치 데이터를 활용하여 모멘텀 팩터(누적 수익률, 위험 조정 수익률, 추세 지속성 및 전환, 미래 누적 수익률)를 산출한다. 이를 바탕으로 우수한 모멘텀을 보이는 5개의 코인을 선별하고 Risk Budgeting 기법을 적용한 자산배분 전략을 수립한다. 이러한 전략을 통해 코인 시장에서의 투자 효율성을 극대화하고, 체계적인 리스크 관리 기반의 투자 인사이트를 제공하고자 한다.



2 투자 전략 구성 및 분석

2.1 대상 코인 선택

2.1.1 Why a fixed top 10 by market cap? Why not by asset class?

주식시장과 비슷하게 코인시장도 수많은 코인들이 거래되고 있고 카테고리별로 분류도 가능하다. 하지만 현 시점에서 상관관계를 고려하여 카테고리별 모멘텀 투자를 통해 얻을 자산배분 benefit이 적을거라고 생각했기에 단순히 시가총액 상위 10개 자산을 투자 유니버스로 구성했다.

이유는 다음과 같다.

1. Bitcoin Dominance

  • 대부분의 코인은 비트코인의 가격 움직임을 따라가며 2025-02-25 기준 Bitcoin Dominance는 61%, 이더리움까지 합하면 71% 수준이다.
  • 실제로 시가총액 상위 100개 코인의 5년치 시계열 데이터를 분석해본 결과, 스테이블 코인과 일부 종목을 제외하면 대부분의 코인 간 상관관계가 약 0.7 수준으로 높게 나왔다.

2. Market Sentiment

  • 최근들어 기관투자자들의 진입이 증가하고 있음에도 불구하고, 코인 시장은 여전히 전통적 금융시장보다 투기적 성향이 강해 시장 전체가 같은 방향으로 움직이는 경향이 있다.

3. Ambiguous Categories

  • 거래소 등에서 제공하는 코인 카테고리는 기준이 모호하고 신뢰성이 낮아, 카테고리별 분류에 따른 전략이 실제 투자 효과를 발휘하기 어렵다.

2.1.2 선택 개요

  • 전략 설정일 기준 시가총액을 기준으로 상위 10개 코인
  • yahoo.finance에서 데이터 수집이 가능한 코인
  • 단, 아래 조건인 경우 리스트 제외
    • 시계열 데이터가 360개 미만일 경우
    • Wrapped coin인 경우
    • 동일한 카테고리로 명확히 분류되어 중복되는 경우
  • MARKET CAP TOP 10 [2025-01-01]
    1. BTC-USD
    2. ETH-USD
    3. USDT-USD
    4. XRP-USD
    5. BNB-USD
    6. SOL-USD
    7. DOGE-USD
    8. ADA-USD
    9. TRX-USD
    10. LINK-USD
# 패키지 로딩
library(purrr)
package <- c("tidyverse", "quantmod", "PerformanceAnalytics", "astsa", 
             "fpp2", "RandomWalker", "rugarch", "scales", "gt", "pander",
             "PortfolioAnalytics", "DEoptim", "MASS", "RColorBrewer")
package_map <- map(package, ~library(.x, character.only = TRUE))

options(scipen = 100)
# 2020년 ~ 2025년 초 시가총액 상위 10개 코인 리스트
coin_snapshot <- readxl::read_xlsx("./coin_list.xlsx")

# 코인 별 OHLC, Adjusted close, 수익률, 로그수익률 테이블 생성
coin_snapshot_df <-
  tibble(ticker = coin_snapshot$ticker,
         year = coin_snapshot$YEAR) %>% 
           # 1. OHLC
    mutate(raw_data = map2(.x = ticker, .y = year, 
                           .f = ~getSymbols(Symbols = .x, 
                                            auto.assign = FALSE, 
                                            to = as.character(ymd(paste0(.y - 1, "-12-31")))) %>% 
                             na.omit()),
           # 2. Adjusted close
           data = map(raw_data, ~Ad(.x)),
           # 3. 이산 수익률
           ret = map(data, ~Return.calculate(.x) %>% na.omit()),
           # 4. 로그 수익률
           logret = map(data, ~Return.calculate(.x, method = "log") %>% na.omit()))

2.2 모멘텀 팩터 기반 평가 및 스코어 계산

  • 대상 종목 별로 다음 4가지 모멘텀 팩터를 산출한다.
    1. 과거 기간 모멘텀: 1~12개월 모멘텀 팩터 계산
    2. 샤프비율 기준 과거 기간 모멘텀: 위험을 고려한 1~12개월 모멘텀 팩터 계산
    3. 추세 지속성 및 전환 팩터: 기술적 지표인 ADX, TEMA, MACD, RSI를 활용해 추세 강도 및 반전 신호 파악
    4. 미래 기간 모멘텀: GARCH와 GBM을 통해 12개월 가격을 전망하여 1~12개월 예상 모멘텀 팩터 계산
  • 산출한 모멘텀 팩터 스코어들을 동일가중(25%)을 적용하여 최종 스코어를 산출한다.
  • 최종 스코어 상위 5개 코인을 투자 대상으로 선택한다.
  • 모든 스코어는 min-max scaling을 적용한 표준화된 점수를 적용한다.

2.2.1 [Factor 1] 과거 기간 모멘텀

  • 과거 1년 수익률 시계열 데이터를 12개의 lag로 나눠 12개월치 모멘텀 score를 계산한다. 수익률이 (+) 면 1점을 얻는다.
# 과거 12개월 기간 모멘텀 계산 함수
calculate_log_momentum_12 <- function(xts_data, lookback = 12) {
  
  one_month_index <- endpoints(xts_data, on = "months")
  xts_data_monthly <- xts_data[one_month_index]
  
  returns_df <- c()
  returns_df <- sapply(1:lookback, function(x) 
    log(coredata(xts_data_monthly[length(xts_data_monthly)])) - 
      log(coredata(xts_data_monthly[length(xts_data_monthly) - x])))
  
  count_positives = sum(ifelse(returns_df > 0, 1, 0))
  
  return(count_positives)
}

# 테이블 합산
coin_snapshot_df <- coin_snapshot_df %>% 
  group_by(year) %>% 
  mutate(momentum_score = map_dbl(data, ~calculate_log_momentum_12(.x, lookback = 12)),
         momentum_score = round(rescale(momentum_score), 2)) %>% 
  ungroup(year) 

2.2.2 [Factor 2] 샤프비율 기준 과거 기간 모멘텀

  • ’과거 기간 모멘텀’과 동일한 방식이지만 위험 조정 수익률인 샤프비율을 사용해 score를 계산한다.
# 과거 12개월 기간 샤프비율의 모멘텀 계산 함수
calculate_log_sharp_momentum_12 <- function(xts_logret, lookback = 12, rf_rate = 0) {

  one_month_index <- endpoints(xts_logret, on = "months")
  
  returns_df <- sapply(1:lookback, function(x) {
    xts_logret_window <- 
      xts_logret[(one_month_index[length(one_month_index) - x] + 1) :
                   one_month_index[length(one_month_index)]]
    
    n <- length(xts_logret_window)
    annualized_mean <- mean(xts_logret_window, na.rm = TRUE) * 365 / n
    annualized_sd <- sd(xts_logret_window, na.rm = TRUE) * sqrt(365 / n)
    sharp_ratio <- (annualized_mean - rf_rate) / annualized_sd
    return(sharp_ratio)
  })
  
  return(returns_df)
}

# 과거 12개월 기간 샤프비율의 모멘텀 랭킹 스코어 함수
sharp_rank <- function(df) {
  
  min_year <- min(df$year)
  max_year <- max(df$year)
  
  sharp_matrix_rank <- list()
  k <- 1
  
  for (i in min_year:max_year) {
      
    sharp_matrix <- df %>% 
      filter(year == i)
    
    sharp_matrix <- purrr::reduce(sharp_matrix$sharp_momentum_1_12, cbind)
    sharp_matrix_rank[[k]] <- colSums(t(apply(sharp_matrix, 1, rank)))
    sharp_matrix_rank[[k]] <- round(rescale(sharp_matrix_rank[[k]]), 2)
    
    k <- k + 1
    
  }
  return(sharp_matrix_rank)
}

# 12lag 별 대상종목들의 랭킹 스코어 계산
coin_snapshot_df <- coin_snapshot_df %>% 
  mutate(sharp_momentum_1_12 = map(logret, ~calculate_log_sharp_momentum_12(.x, lookback = 12)))
coin_snapshot_df$sharp_momentum_score <- unlist(sharp_rank(coin_snapshot_df))

2.2.3 [Factor 3] 추세 지속성 및 전환 팩터

  • 추세 지속성 및 전환 팩터를 산출하기 위해서 다음의 기술적 지표를 결합해 시장의 추세 강도 및 반전 가능성을 알아본다.
    1. ADX(Average Directional Index)
    2. 이동평균선 및 기울기: 10일 및 50일 이동평균 기울기
    3. MACD
    4. RSI

Trend Factor Score = \(w_1 \times\) ADX score + \(w_2 \times\) MA Slope score + \(w_3 \times\) MACD Signal + \(w_4 \times\) RSI Signal

  • 개별 score들의 합은 10점
  • 4개 팩터의 개별 비중은 25%씩 부여
    • 최적의 가중치를 결정하기 위해 시뮬레이션이 필요하지만 현재는 동일 가중 적용

1) ADX(Average Directional Index)

ADX가 높다는 것(보통 25 기준)은 추세 강도가 강함을 의미할 뿐 방향(상승/하락)을 의미하지는 않는다. 즉, 하락 추세가 강해지는 경우에도 ADX가 커질 수 있기에 DIp(positive DX)와 DIn(negative DX)를 동시에 비교해야 제대로 된 판단이 가능하다.

ADX score는 다음과 같이 정한다.

  1. 최근 12개월 시계열 데이터의 DIp, DIn, ADX 계산(DX의 이동평균 = 14일)
  2. 다음 조건을 모두 만족하는 경우의 비율 계산
  • ADX > 25
  • DIp > DIn
calculate_ADX_score <- function(xts_data, n = 14) {
  adx_data <- ADX(HLC(xts_data), n = n)
  adx_data_1y <- xts::last(adx_data, "12 months")
  adx_above_25 <- adx_data_1y[adx_data_1y$ADX > 25 & (adx_data_1y$DIp > adx_data_1y$DIn)]
  adx_score <- (nrow(adx_above_25) / nrow(adx_data_1y))
  return(adx_score)
}

coin_snapshot_df <- coin_snapshot_df %>% 
  group_by(year) %>% 
  mutate(ADX_score = map_dbl(raw_data, ~calculate_ADX_score(.x, n = 14)),
         ADX_score = round(rescale(ADX_score), 2))

2) TEMA(Triple EMA)

TEMA는 삼중 지수 이동평균으로 3차례의 평활화를 통해 SMA, EMA보다 더 안정감있게 추세를 파악할 수 있는 지표이다. TTR::TRIX()함수는 TEMA의 변화율을 측정하는 oscillator로 0보다 크면 상승추세, 0보다 작으면 하락추세를 의미한다.

TEMA score는 다음과 같이 정한다.

  1. 최근 12개월 시계열 데이터의 TRIX 계산(TRIX의 이동평균 = 50일)
  • TTR::TRIX()함수 계산 시 9일 EMA도 동시에 계산됨(signal로 사용)
  • TEMA가 signal를 상향 돌파하는 골든 크로스 횟수를 기록
  1. 다음 조건을 모두 만족하는 경우 횟수 계산
  • TRIX > signal
  • TRIX-1 < signal-1
calculate_TEMA_score <- function(xts_data, n = 50, nsig = 12) {
  tema_data <- TTR::TRIX(Ad(xts_data), n = n, nSig = nsig)
  tema_data_1y <- xts::last(tema_data, "12 months")
  tema_cross <- tema_data_1y[lag(tema_data_1y$TRIX) < 
                               lag(tema_data_1y$signal) & tema_data_1y$TRIX > tema_data_1y$signal]
  tema_score <- nrow(tema_cross)
  return(tema_score)
}

coin_snapshot_df <- coin_snapshot_df %>% 
  mutate(TEMA_score = map_dbl(raw_data, ~calculate_TEMA_score(.x)),
         TEMA_score = round(rescale(TEMA_score), 2))

3) MACD(Moving Average Convergence Divergence)

MACD는 단기 이평선에서 장기 이평선을 차감한 값으로 두 이평선이 서로 가까워지거나(수렴) 멀어지는(발산) 원리를 이용한다. 이를 통해 추세를 파악하고 강도와 지속성을 파악하고자 한다. ’TEMA’와 매커니즘이 상당히 비슷하기에 동일한 평가 방법을 사용한다.

MACD score는 다음과 같이 정한다.

  1. 최근 12개월 시계열 데이터의 MACD 계산(fast MA = 50, slow MA = 200, sigma MA = 12)
  • TTR::MACD()함수 계산 시 9일 EMA도 동시에 계산됨(signal로 사용)
  • MACD가 signal를 상향 돌파하는 골든 크로스 횟수를 기록
  1. 다음 조건을 모두 만족하는 경우 횟수 계산
  • MACD > signal
  • MACD-1 < signal-1
calculate_MACD_score <- function(xts_data, nf = 50, ns = 200, nsig = 12) {
  macd_data <- TTR::MACD(Ad(xts_data), nFast = nf, nSlow = ns, nSig = nsig)
  macd_data_1y <- xts::last(macd_data, "12 months")
  macd_cross <- macd_data_1y[lag(macd_data_1y$macd) < 
                               lag(macd_data_1y$signal) & macd_data_1y$macd > macd_data_1y$signal]
  macd_score <- nrow(macd_cross)
  return(macd_score)
}

coin_snapshot_df <- coin_snapshot_df %>% 
  mutate(MACD_score = map_dbl(raw_data, ~calculate_MACD_score(.x)),
         MACD_score = round(rescale(MACD_score), 2))

4) RSI(Relative Strength Index)

RSI는 과매수/과매도를 측정하는 지표로 70 이상이면 과매수, 30이하면 과매도로 판단된다.

RSI score는 다음과 같이 정한다.

  • 최근 12개월 시계열 데이터의 RSI 계산(MA = 50)
    • TTR::RSI()함수 사용
    • 최근 12개월 데이터 중 RSI가 50을 초과하는 비율 계산
calculate_RSI_score <- function(xts_data, n = 50) {
  rsi_data <- TTR::RSI(Ad(xts_data), n = n)
  rsi_data_1y <- xts::last(rsi_data, "12 months")
  rsi_cross <- rsi_data_1y[rsi_data_1y$rsi > 50]
  rsi_score <- nrow(rsi_cross) / nrow(rsi_data_1y)
  return(rsi_score)
}

coin_snapshot_df <- coin_snapshot_df %>% 
  mutate(RSI_score = map_dbl(raw_data, ~calculate_RSI_score(.x)),
         RSI_score = round(rescale(RSI_score), 2))

5) Trend Factor Score

지금까지 계산한 ADX, TEMA, MACD, RSI score들을 종합하여 Trend Factor Score를 산출한다. 동일 가중 방식을 적용한 공식은 다음과 같다.

Trend Factor Score =
\(0.25~\times\) ADX score + \(0.25~\times\) MA Slope score + \(0.25~\times\) MACD Signal + \(0.25~\times\) RSI Signal

coin_snapshot_df <- coin_snapshot_df %>% 
  mutate(trend_score = 
           0.25 * ADX_score + 
           0.25 * TEMA_score + 
           0.25 * MACD_score + 
           0.25 * RSI_score,
         trend_score = round(rescale(trend_score), 2))

2.2.4 [Factor 4] 미래 기간 모멘텀

개별 종목들의 12개월 가격 데이터를 forecast하고 1~12개월 미래 기간 모멘텀을 계산한다.

팩터 계산 순서는 다음과 같다.

  1. GARCH(Generalized AutoRegressive Conditional Heteroskedasticity) 모델을 통해 개별 종목 일일 로그 수익률의 변동성을 예측한다.
  2. GBM(Geometric Brownian Motion) 모형을 가정하고 시뮬레이션(10000회)을 진행한다.
  3. 12개의 관찰시점에서 현재 시점보다 가격이 높을 확률을 계산하고, 높을 경우 score 1점(만점 12점)을 얻는다.


[GBM 모델을 선택한 이유]

딥러닝 기법 중 시계열 예측에 뛰어나다고 알려진 LSTM(Long Short-Term Memory)을 이용해 미래 시계열 예측을 시도해보았으나, GBM 방식보다 월등한 성과를 찾지 못했다. 데이터셋 분할 방식에 상관없이, 테스트 세트에서 LSTM 모델은 학습 데이터에 비해 성능이 크게 떨어지며 MAE(Mean Absolute Error)가 증가하고 과적합 현상을 보였다. 이는 코인의 높은 변동성 때문일 수도 있다.

테스트를 진행하며 깨달은 점은 과거 시계열 데이터만으로 주가를 예측하는 모델은 한계가 있다는 것이다. 실제 주가는 과거 시계열 데이터뿐만 아니라 다양한 외부 요인의 영향을 절대적으로 많이 반영하기 때문이다.

결국, LSTM은 뛰어난 시계열 예측 능력을 갖추고 있음에도 불구하고, 계산 비용이 압도적으로 크고 예측 성과도 GBM 방식보다 우수하지 않아, 단순함과 효율성을 고려한 결과 GBM 모델을 선택하게 되었다.

BTC Histogram

BTC <- 
  getSymbols("BTC-USD", auto.assign = FALSE) %>%
  Ad() %>% 
  Return.calculate(method = "log") %>% 
  na.omit()

kurtosis <- round(kurtosis(BTC), 2)
skewness <- round(skewness(BTC), 2)

chart.Histogram(BTC, main = paste0("BTC Histogram: skewness = ", skewness, ", kurtosis = ", kurtosis) ,methods = c("add.density", "add.normal", "add.risk"), xlim = c(-0.2, 0.2), lwd = 2)

  • Negative-skewed & Positive kurtosis로 BTC 로그 수익률은 fat tail이 존재하므로 GARCH 모델에서 Normal Dist 대신 Student-t Dist를 사용한다.

GARCH + GBM Score

calculate_GARCH_GBM_score <- function(xts_logret) {
  
  # GARCH 모델로 미래 변동성 예측(student-t dist 사용)
  garchspec <- ugarchspec(mean.model = list(armaOrder = c(0,0)),
                          variance.model = list(model = "gjrGARCH"),
                          distribution.model = "std")
  garchfit <- ugarchfit(spec = garchspec, data = xts_logret)
  
  garchforecast <- ugarchforecast(fitORspec = garchfit, n.ahead = 360)
  forecast_sd <- round(mean(garchforecast@forecast$sigmaFor), 4)
  
  time_steps <- seq(30,360, by = 30)
  
  # GBM 시뮬레이션(10000 번)
  sim_results <- geometric_brownian_motion(.num_walks = 10000,
                                           .n = 360,
                                           .mu = max(mean(xts_logret), 0),
                                           .sigma = forecast_sd,
                                           .initial_value = 100,
                                           .delta_time = 1)
  # 1~12 lead에 대한 모멘텀 스코어 계산
  ## 시작 종가(100) 보다 상승할 확률이 60% 일 경우 1점
  score <- sim_results %>% 
    filter(x %in% time_steps) %>% 
    dplyr::select(x, y) %>% 
    group_by(x) %>% 
    summarise(p_value = sum(y > 100) / 10000) %>% 
    ungroup() %>% 
    summarise(p_ratio = sum(p_value > 0.5)) %>% 
    pull()
  
  return(score)
  
}

coin_snapshot_df <- coin_snapshot_df %>% 
  mutate(GARCH_GBM_score = map_dbl(logret, ~calculate_GARCH_GBM_score(.x)),
         GARCH_GBM_score = round(rescale(GARCH_GBM_score),2))

2.3 Best score 코인 선택

  • 종합 모멘텀 스코어를 기준으로 우수한 코인을 선택한다. 최종적으로 5개의 종목으로 전체 포트폴리오가 구성된다.
Ranking <- coin_snapshot_df %>% 
  dplyr::select(ticker, 
                momentum_score, 
                sharp_momentum_score, 
                trend_score, 
                GARCH_GBM_score) %>% 
  mutate(score = 
           0.25 * momentum_score + 
           0.25 * sharp_momentum_score + 
           0.25 * trend_score + 
           0.25 * GARCH_GBM_score)

Ranking_top5 <- Ranking %>% 
  mutate(rank = rank(score)) %>% 
  dplyr::select(ticker, rank) %>% 
  filter(rank > 5) %>% 
  arrange(year, desc(rank)) %>% 
  dplyr::select(ticker)

Ranking_top5_2025 <- Ranking_top5 %>% 
  filter(year == 2025) %>% 
  dplyr::select(ticker)
  • 2025년 기준 Best score 종목은 다음과 같다.
    1. TRX-USD
    2. BTC-USD
    3. SOL-USD
    4. XRP-USD
    5. BNB-USD

2.4 자산배분

  • 포트폴리오 비중은 95% CVaR(conditional VaR) Budget 방식을 통해 결정한다.
    • Risk budgeting은 포트폴리오 내 각 자산의 전체 위험에서 차지하는 기여도(contribution)를 정해진 budget으로 맞추는 전략으로 포트폴리오 내 각 자산이 전체 위험에 기여하는 비율을 관리하는데 목적이 있다.
    • max \(w\): 40%
    • min \(w\): 5%
    • max risk contribution: 40%
    • 최적화 함수는 DEOptim(Differential Evolution optim)을 사용한다.
      • DEoptim은 무작위로 생성된 후보 해 집합을 진화시키면서 최적해를 찾는 최적화 알고리즘이다.
      • R에서의 구현이 단순하고, 연속적이고 비선형인 문제에 적합한 함수이기에 선택했다.
# 상위 5개 종목 로그 수익률 테이블 생성
Ranking_top5_2025_df <- Ranking_top5_2025 %>% 
  left_join(coin_snapshot_df, by = c("year", "ticker")) %>% 
  dplyr::select(ticker, logret)

Ranking_top5_2025_xts <- purrr::reduce(Ranking_top5_2025_df$logret, cbind) %>% 
  na.omit()
colnames(Ranking_top5_2025_xts) <- Ranking_top5_2025_df$ticker

# 포트폴리오 기본 스펙 설정
port_spec <- portfolio.spec(colnames(Ranking_top5_2025_xts))

## 포트폴리오 제약조건 설정
port_spec <- add.constraint(portfolio = port_spec, 
                            type = "weight_sum", 
                            min_sum = 0.99, 
                            max_sum = 1.01)
port_spec <- add.constraint(portfolio = port_spec,
                            type = "box", 
                            min = 0.05, 
                            max = 0.4)

## 포트폴리오 목적함수 생성
port_spec <- add.objective(portfolio = port_spec, 
                           type = "risk", 
                           name = "CVaR", 
                           arguments = list(p = 0.95, 
                                            clean = "boudt"), 
                           enabled = TRUE, 
                           garch = TRUE)

port_spec <- add.objective(portfolio = port_spec, 
                           type = "risk_budget_objective", 
                           name = "CVaR", 
                           arguments = list(p = 0.95, 
                                            clean = "boudt"), 
                           enabled = TRUE, 
                           garch = TRUE, 
                           max_prisk = 0.4)

# 포트폴리오 최적화 - DEoptim 최적화 함수 사용
set.seed(1)
controlDE <- DEoptim.control(NP = 100, 
                             itermax = 500, 
                             F = 0.8, 
                             CR = 0.9, 
                             trace = FALSE)

opt <- optimize.portfolio(R = Ranking_top5_2025_xts,
                          portfolio = port_spec,
                          optimize_method = "DEoptim")

2025 포트폴리오 개별 투자 비중

BTC-USD TRX-USD SOL-USD XRP-USD BNB-USD
5.0% 38.0% 8.4% 37.2% 11.4%

지금까지 ’코인 모멘텀 전략’에 대해 소개했다. 그럼 이제 퀀트 투자에 있어서 가장 중요한 단계인 백테스팅을 진행하겠다.

2.5 백테스팅 및 성과 평가

2.5.1 전략 포트폴리오 Summary

  • ‘2020-01-01’ 기준으로 백테스팅을 실시하여 전략의 성과 및 위험 평가
  • 종목 리밸런싱: 매년 초 투자 종목 리스트를 업데이트한다. (연 1회)
  • 비중 리밸런싱: 매월 말 Risk Budgeting에 맞춰 비중을 조절한다. (연 12회)
# 1. 전략 백테스트 포트폴리오 테이블 생성
Portfolio_list <- list()

Portfolio_list <- Ranking_top5 %>% 
  summarise(ticker = list(ticker))

# 2. 리밸런싱 비중 계산 함수로 매년 대상 코인 비중 결정
create_optim_wt <- function(ticker_vec, year) {
  
  logret_xts <- tibble(ticker = ticker_vec) %>% 
    mutate(logret = map(ticker, ~getSymbols(Symbols = .x, 
                                            auto.assign = FALSE, 
                                            to = as.character(ymd(paste0(year, "-12-31"))),
                                            from = as.character(ymd(paste0(year - 1, "-01-01")))
                                            ) %>% 
                          na.omit() %>% 
                          Ad()),
           logret = map(logret, ~Return.calculate(.x, method = "log") %>% na.omit()))
  logret_matrix <- purrr::reduce(logret_xts$logret, cbind) %>% na.omit()
  colnames(logret_matrix) <- logret_xts$ticker

  # 포트폴리오 기본 스펙 설정
  port_spec <- portfolio.spec(colnames(logret_matrix))
  
  ## 포트폴리오 제약조건 설정
  port_spec <- add.constraint(portfolio = port_spec, 
                              type = "weight_sum", 
                              min_sum = 0.99, 
                              max_sum = 1.01)
  port_spec <- add.constraint(portfolio = port_spec, 
                              type = "box", 
                              min = 0.05,
                              max = 0.4)
  
  ## 포트폴리오 목적함수 생성
  port_spec <- add.objective(portfolio = port_spec, 
                             type = "risk", 
                             name = "CVaR", 
                             arguments = list(p = 0.95, 
                                              clean = "boudt"), 
                             enabled = TRUE, 
                             garch = TRUE)
  
  port_spec <- add.objective(portfolio = port_spec, 
                             type = "risk_budget_objective", 
                             name = "CVaR", 
                             arguments = list(p = 0.95, 
                                              clean = "boudt"), 
                             enabled = TRUE, 
                             garch = TRUE, 
                             max_prisk = 0.4)
  
  # 포트폴리오 최적화 - DEoptim 최적화 함수 사용
  set.seed(1)
  controlDE <- DEoptim.control(NP = 100, 
                               itermax = 500, 
                               F = 0.8, 
                               CR = 0.9, 
                               trace = FALSE)
  
  opt <- optimize.portfolio.rebalancing(R = logret_matrix,
                                        portfolio = port_spec,
                                        optimize_method = "DEoptim",
                                        rebalance_on = "quarters",
                                        rolling_window = 360,
                                        traceDE = 0)
  
  opt_weights <- extractWeights(opt)
  return(opt_weights[1:nrow(opt_weights) -1])
}

Portfolio_list <- Portfolio_list %>% 
  mutate(opt_weights = map2(ticker, year, ~create_optim_wt(ticker_vec = .x, year = .y)))
# 3. 데이터 정제
list_long <- map(Portfolio_list$opt_weights, function(mat) {
  as_tibble(mat, rownames = "date") %>%
    pivot_longer(-date, names_to = "ticker", values_to = "weight") %>%
    mutate(date = as.Date(date))
})

combined_long <- bind_rows(list_long)

Portfolio_optim_wt <- pivot_wider(combined_long, 
                                  names_from = "ticker", 
                                  values_from = "weight", 
                                  values_fill = 0)

Portfolio_optim_wt <- xts(x = round(Portfolio_optim_wt[, -1], 4), 
                          order.by = Portfolio_optim_wt$date)

Portfolio_optim_ret <- tibble(ticker = colnames(Portfolio_optim_wt)) %>% 
  mutate(ret = map(ticker, ~getSymbols(Symbols = .x, 
                                          auto.assign = FALSE) %>% 
    na.omit() %>% 
    Ad()),
  ret = map(ret, ~Return.calculate(.x) %>% na.omit()))

Portfolio_optim_ret_matrix <- purrr::reduce(Portfolio_optim_ret$ret, cbind) %>% na.omit()
colnames(Portfolio_optim_ret_matrix) <- Portfolio_optim_ret$ticker

# 4. 포트폴리오 수익률 산출 및 시각화
Portfolio_optim <- Return.portfolio(R = Portfolio_optim_ret_matrix, 
                                    weights = Portfolio_optim_wt)

charts.PerformanceSummary(Portfolio_optim, main = "4 Factor Momentum Portfolio")

PF_table <- rbind(table.AnnualizedReturns(Portfolio_optim), 
                  table.DownsideRisk(Portfolio_optim))

colnames(PF_table) <- "Momentum.PF"

panderOptions("table.split.table", 85)
panderOptions("table.alignment.rownames", "left")
pander(PF_table)
  Momentum.PF
Annualized Return 0.1948
Annualized Std Dev 0.5611
Annualized Sharpe (Rf=0%) 0.3472
Semi Deviation 0.0255
Gain Deviation 0.0257
Loss Deviation 0.0284
Downside Deviation (MAR=210%) 0.029
Downside Deviation (Rf=0%) 0.025
Downside Deviation (0%) 0.025
Maximum Drawdown 0.8035
Historical VaR (95%) -0.0555
Historical ES (95%) -0.0855
Modified VaR (95%) -0.0517
Modified ES (95%) -0.0847

2.5.2 벤치마크 Summary

1) BTC:Cash = 6:4 (rebalancing period = Quarterly)

  • 비트코인과 현금 투자 비중을 6:4로 맞추고 분기별 리밸런싱 실시
bench_ret <- 
  getSymbols("BTC-USD", auto.assign = FALSE, from = "2020-07-13") %>% 
  Ad() %>% 
  Return.calculate() %>% 
  na.omit()

Benchmark1_pf <- Return.portfolio(R = bench_ret, 
                                  weights = 0.6, 
                                  rebalance_on = "quarters")

Bench1_table <- rbind(table.AnnualizedReturns(Benchmark1_pf), 
                      table.DownsideRisk(Benchmark1_pf))

charts.PerformanceSummary(Benchmark1_pf, main = "BTC:Cash = 6:4")

2) ETH:Cash = 6:4 (rebalancing period = Quarterly)

  • 이더리움과 현금 투자 비중을 6:4로 맞추고 분기별 리밸런싱 실시
bench_ret <- 
  getSymbols("ETH-USD", auto.assign = FALSE, from = "2020-07-13") %>% 
  Ad() %>% 
  Return.calculate() %>% 
  na.omit()

Benchmark2_pf <- Return.portfolio(R = bench_ret, 
                                  weights = 0.6, 
                                  rebalance_on = "quarters")

Bench2_table <- rbind(table.AnnualizedReturns(Benchmark2_pf), 
                      table.DownsideRisk(Benchmark2_pf))

charts.PerformanceSummary(Benchmark2_pf, main = "ETH:Cash = 6:4")

3) BTC:ETH:Cash = 5:2:3 (rebalancing period = Quarterly)

  • 비트코인, 이더리움 그리고 현금 투자 비중을 5:2:3로 맞추고 분기별 리밸런싱 실시
BTC <- getSymbols("BTC-USD", auto.assign = FALSE, from = "2020-07-13") %>% Ad()
ETH <- getSymbols("ETH-USD", auto.assign = FALSE, from = "2020-07-13") %>% Ad()

bench_ret <- cbind(BTC, ETH) %>% 
  Return.calculate() %>% 
  na.omit()

Benchmark3_pf <- Return.portfolio(R = bench_ret, 
                                  weights = c(0.5, 0.2), 
                                  rebalance_on = "quarters")

Bench3_table <- rbind(table.AnnualizedReturns(Benchmark3_pf), 
                      table.DownsideRisk(Benchmark3_pf))

charts.PerformanceSummary(Benchmark3_pf, main = "BTC:ETH:Cash = 5:2:3")

2.5.3 모멘텀 포트폴리오 vs. 벤치마크

Total_ret <- cbind(Portfolio_optim, 
                   Benchmark1_pf,
                   Benchmark2_pf,
                   Benchmark3_pf)

Total_summary <- cbind(PF_table, 
                       Bench1_table,
                       Bench2_table,
                       Bench3_table)

colnames(Total_ret) <- c("Momentum.PF", "BTC.60%", "ETH.60%", "BTC.ETF_50%.20%")
colnames(Total_summary) <- c("Momentum.PF", "BTC.60%", "ETH.60%", "BTC.ETF_50%.20%")

chart.CumReturns(Total_ret, 
                 legend.loc = "topleft", 
                 lwd = 2, 
                 main = "Momentum Portfolio vs. Benchmark", 
                 colorset = brewer.pal(name = "Set2", n = 4))

PF_ret <- percent(Total_summary$Momentum.PF[1], accuracy = 0.01)
PF_sd <- percent(Total_summary$Momentum.PF[2], accuracy = 0.01)
PF_MDD <- percent(Total_summary$Momentum.PF[10], accuracy = 0.01)

pander(Total_summary)
  Momentum.PF BTC.60% ETH.60% BTC.ETF_50%.20%
Annualized Return 0.1948 0.2966 0.3055 0.343
Annualized Std Dev 0.5611 0.31 0.4215 0.3776
Annualized Sharpe (Rf=0%) 0.3472 0.9567 0.7249 0.9083
Semi Deviation 0.0255 0.0134 0.0186 0.0169
Gain Deviation 0.0257 0.0148 0.0195 0.0169
Loss Deviation 0.0284 0.0133 0.019 0.0172
Downside Deviation (MAR=210%) 0.029 0.0175 0.0224 0.0207
Downside Deviation (Rf=0%) 0.025 0.0128 0.0179 0.0162
Downside Deviation (0%) 0.025 0.0128 0.0179 0.0162
Maximum Drawdown 0.8035 0.5519 0.5465 0.6043
Historical VaR (95%) -0.0555 -0.0295 -0.0389 -0.0373
Historical ES (95%) -0.0855 -0.0433 -0.0598 -0.0547
Modified VaR (95%) -0.0517 -0.0283 -0.0396 -0.037
Modified ES (95%) -0.0847 -0.0393 -0.0626 -0.0588
  • 모멘텀 전략의 연 평균 수익률은 19.48%로 벤치마크 대비 Poor Performance를 보이고 있다.
  • 연 평균 변동성은 56.11%, 최대 낙폭은 80.35% 이다.



3 결론: 단순함의 원칙

“모든 것을 가능한 한 단순하게 만들어야 한다. 그러나 너무 단순하게 만들어서는 안된다.” - 아인슈타인-

단순하지만 결코 단순하지 않은 “BTC 60%” 전략이 복잡한 4 Factor 모멘텀 전략을 압도했다. 또한 모멘텀 전략은 모든 벤치마크에 뒤쳐지는 결과를 보이고 있다. 아직 코인 투자에서 모멘텀 전략이 효과적이지 않은 걸까? 이는 단순히 모멘텀 전략 자체의 한계뿐 아니라, 비트코인과 이더리움의 압도적인 Dominance 때문이라는 점을 시사한다. 앞서 언급한 바와 같이, 이더리움까지 합친 이들의 Dominance는 약 70% 수준에 육박한다. 비트코인과 이더리움에 투자하면 어떤 알트코인 보다도 장기적이고 안정적인 높은 위험 조정 수익률을 실현할 수 있으므로, 굳이 더 큰 알파(\(\alpha\))를 찾겠다고 30%의 불모지에서 복잡한 전략을 사용할 필요가 없다는 결론에 이르게 된다.

물론, 기관투자자들이 진입과 내재 가치 평가가 본격화되면 투자 패러다임은 달라질 수 있다. 그러나 현 시점에서는 비트코인과 이더리움의 Dominance가 모멘텀 전략의 추가 알파 창출을 어렵게 만들고 있음을 감안할 때, 단순하고 안정적인 “BTC 60%” 전략이 오히려 최선의 선택일 수 있다. 단순함의 원칙이야말로 현재 코인 투자 시 필요한 최고의 전략이 아닐까.