Crypto Portfolio Optimization

Kris Kilgroe

2022-07-01

Disclaimer

This is not financial advice. This is a technical exercise in applied portfolio optimization for cryptocurrency, in Rstudio.


Introduction

Portfolio Optimization is often applied to stocks. Today, we will apply this tool to cryptocurrencies, to determine the best percentage allocation of each selected token. This analysis stems from Modern Portfolio Theory (MPT), which denotes that the ideal portfolio exists to provide one max returns coupled with optimal risk.

A key component of this analysis involves calculating the Sharpe Ratio.1 

The higher the Sharpe Ratio, the higher the portfolio’s risk-adjusted performance.


Process

We will calculate an optimized portfolio methodically by:

  • Picking 10 crypto coins/tokens, downloading, and cleaning the relevant dataset
  • Testing a single portfolio by calculating the requirements for the portfolio returns, risk, and Sharpe Ratio
  • Optimizing a spread of 5000 randomly generated portfolios with the same requirements
  • Visualizing the minimum variance and tangency portfolios, along with the efficient frontier
  • Drawing relevant conclusions

Phase 1: Setting Up the Data

Download & load the relevant packages

library(tidyverse) #to download the data
library(tidyquant) #to download the data
library(plotly) #to create interactive charts
library(timetk) #to manipulate the data series
library(forcats) #to manipulate categorical variables

Choose crypto coins & tokens

I will choose 10 personal preferences:

  1. Bitcoin
  2. Ethereum
  3. USD Coin
  4. Solana
  5. Polkadot
  6. Avalanche
  7. Polygon
  8. Chainlink
  9. Algorand
  10. Monero


Download the price data

We will capture the time range from 03 JANUARY 2009 to 01 JULY 2022, for Bitcoin, Ethereum, USD Coin, Solana, Polkadot, Avalanche, Polygon, Chainlink, Algorand, and Monero. The tq_get() function draws the crypto coins & token USD valuations from Yahoo Finance.

tick <- c('BTC-USD', 'ETH-USD', 'USDC-USD', 'SOL-USD', 'DOT-USD', 
          'AVAX-USD', 'MATIC-USD', 'LINK-USD', 'ALGO-USD', 'XMR-USD')

price_data <- tq_get(tick,
                     from = '2009-01-03',
                     to = '2022-07-01',
                     get = 'stock.prices')

knitr::kable(head(price_data))
symbol date open high low close volume adjusted
BTC-USD 2014-09-17 465.864 468.174 452.422 457.334 21056800 457.334
BTC-USD 2014-09-18 456.860 456.860 413.104 424.440 34483200 424.440
BTC-USD 2014-09-19 424.103 427.835 384.532 394.796 37919700 394.796
BTC-USD 2014-09-20 394.673 423.296 389.883 408.904 36863600 408.904
BTC-USD 2014-09-21 408.085 412.426 393.181 398.821 26580100 398.821
BTC-USD 2014-09-22 399.100 406.916 397.130 402.152 24127600 402.152


Calculate the daily returns for these stocks, using the logarithmic (continuous) returns

log_ret_tidy <- price_data %>%
  group_by(symbol) %>%
  tq_transmute(select = adjusted,
               mutate_fun = periodReturn,
               period = 'daily',
               col_rename = 'ret',
               type = 'log')


Survey the first few rows

knitr::kable(head(log_ret_tidy))
symbol date ret
BTC-USD 2014-09-17 0.0000000
BTC-USD 2014-09-18 -0.0746434
BTC-USD 2014-09-19 -0.0724015
BTC-USD 2014-09-20 0.0351112
BTC-USD 2014-09-21 -0.0249677
BTC-USD 2014-09-22 0.0083174


Use the spread() function to convert the tidy format to a wide format, and convert it into a time series object using the xts() function

log_ret_xts <- log_ret_tidy %>%
  spread(symbol, value = ret) %>%
  tk_xts()

knitr::kable(head(log_ret_xts))
ALGO-USD AVAX-USD BTC-USD DOT-USD ETH-USD LINK-USD MATIC-USD SOL-USD USDC-USD XMR-USD
NA NA 0.000000000 NA NA NA NA NA NA NA
NA NA -0.074643352 NA NA NA NA NA NA NA
NA NA -0.072401507 NA NA NA NA NA NA NA
NA NA 0.035111240 NA NA NA NA NA NA NA
NA NA -0.024967660 NA NA NA NA NA NA NA
NA NA 0.008317417 NA NA NA NA NA NA NA


Replace “NA” values with “0”

There are NA (or alternatively null) values present due to the lack of value captured at those time-date groups (i.e. the coin or token was not in existence at the time)

log_ret_xts[is.na(log_ret_xts)] <- 0
knitr::kable(head(log_ret_xts))
ALGO-USD AVAX-USD BTC-USD DOT-USD ETH-USD LINK-USD MATIC-USD SOL-USD USDC-USD XMR-USD
0 0 0.000000000 0 0 0 0 0 0 0
0 0 -0.074643352 0 0 0 0 0 0 0
0 0 -0.072401507 0 0 0 0 0 0 0
0 0 0.035111240 0 0 0 0 0 0 0
0 0 -0.024967660 0 0 0 0 0 0 0
0 0 0.008317417 0 0 0 0 0 0 0

Phase 2: Testing a Single Portfolio

To calculate portfolio returns, risk (standard deviation), and the Sharpe Ratio, the required factors are:

  • Mean asset (daily) returns
  • Covariance matrix of all assets
  • Random weights

Calculate the mean daily returns for each asset

mean_ret <- colMeans(log_ret_xts)
knitr::kable(round(mean_ret, 5))
x
ALGO-USD -0.00083
AVAX-USD 0.00042
BTC-USD 0.00131
DOT-USD 0.00030
ETH-USD 0.00042
LINK-USD 0.00116
MATIC-USD 0.00163
SOL-USD 0.00124
USDC-USD 0.00000
XMR-USD -0.00002


Calculate the covariance matrix for all crypto coins & tokens, and annualize by multiplying by 252

cov_mat <- cov(log_ret_xts) * 252
knitr::kable(round(cov_mat,4))
ALGO-USD AVAX-USD BTC-USD DOT-USD ETH-USD LINK-USD MATIC-USD SOL-USD USDC-USD XMR-USD
ALGO-USD 0.5081 0.1857 0.1657 0.1651 0.2351 0.3091 0.3169 0.1910 -0.0021 0.2170
AVAX-USD 0.1857 0.3839 0.1002 0.1604 0.1433 0.1905 0.2290 0.1998 0.0002 0.1319
BTC-USD 0.1657 0.1002 0.3868 0.0929 0.2531 0.2560 0.1917 0.0939 -0.0018 0.2545
DOT-USD 0.1651 0.1604 0.0929 0.2988 0.1377 0.1852 0.1758 0.1515 -0.0002 0.1243
ETH-USD 0.2351 0.1433 0.2531 0.1377 0.4065 0.3743 0.2716 0.1618 -0.0026 0.3238
LINK-USD 0.3091 0.1905 0.2560 0.1852 0.3743 0.8008 0.3356 0.2075 -0.0023 0.3418
MATIC-USD 0.3169 0.2290 0.1917 0.1758 0.2716 0.3356 0.7912 0.2202 -0.0028 0.2493
SOL-USD 0.1910 0.1998 0.0939 0.1515 0.1618 0.2075 0.2202 0.4826 -0.0001 0.1399
USDC-USD -0.0021 0.0002 -0.0018 -0.0002 -0.0026 -0.0023 -0.0028 -0.0001 0.0019 -0.0020
XMR-USD 0.2170 0.1319 0.2545 0.1243 0.3238 0.3418 0.2493 0.1399 -0.0020 0.4851


Calculate and check the random weights2

wts <- runif(n = length(tick))
wts <- wts/sum(wts)
knitr::kable(wts)
x
0.0738931
0.0176051
0.1158264
0.1405402
0.0245540
0.0110357
0.0966279
0.1312985
0.1534317
0.2351875


Calculate and check the portfolio returns

port_returns <- (sum(wts * mean_ret) + 1)^252 - 1
knitr::kable(port_returns)
x
0.1286352


Calculate and check the portfolio risk

port_risk <- sqrt(t(wts) %*% (cov_mat %*% wts))
knitr::kable(port_risk)
0.4071548


Calculate and check the Sharpe Ratio3

sharpe_ratio <- port_returns/port_risk
knitr::kable(sharpe_ratio)
0.3159367

Phase 3: Optimizing with 5000 Portfolios

Optimize by utilizing 5000 random portfolios and requiring a for loop

num_port <- 5000


Create a matrix to store the weights

all_wts <- matrix(nrow = num_port,
                  ncol = length(tick))


Create an empty vector to store the Portfolio returns

port_returns <- vector('numeric', length = num_port)


Create an empty vector to store the Portfolio Standard deviation

port_risk <- vector('numeric', length = num_port)


Create an empty vector to store the Portfolio Sharpe Ratio

sharpe_ratio <- vector('numeric', length = num_port)



Run the for loop 5000 times and be sure to use the set.seed() function for reproducibility

set.seed(1)
for (i in seq_along(port_returns)) {
  
  wts <- runif(length(tick))
  wts <- wts/sum(wts)
  
  #store the weights in the matrix
  all_wts[i,] <- wts
  
  #portfolio returns
  port_ret <- sum(wts * mean_ret)
  port_ret <- ((port_ret + 1)^252) - 1
  
  #store the Portfolio Returns values
  port_returns[i] <- port_ret
  
  
  #create and store portfolio risk
  port_sd <- sqrt(t(wts) %*% (cov_mat  %*% wts))
  port_risk[i] <- port_sd
  
  #create and store Portfolio Sharpe Ratios
  #assume a 0% Risk free rate
  sr <- port_ret/port_sd
  sharpe_ratio[i] <- sr
  
}

Phase 4: Visualizing the Data

Create a data table to store all of the values together

portfolio_values <- tibble(Return = port_returns,
                           Risk = port_risk,
                           SharpeRatio = sharpe_ratio)


Coerce the time series objects to tibble objects, then change the column names

all_wts <- tk_tbl(all_wts)
colnames(all_wts) <- colnames(log_ret_xts)


Combine all of the values together

portfolio_values <- tk_tbl(cbind(all_wts, portfolio_values))

#inspect the first few values
knitr::kable(head(portfolio_values))
ALGO-USD AVAX-USD BTC-USD DOT-USD ETH-USD LINK-USD MATIC-USD SOL-USD USDC-USD XMR-USD Return Risk SharpeRatio
0.0481418 0.0674732 0.1038693 0.1646754 0.0365688 0.1628952 0.1712877 0.1198153 0.1140704 0.0112030 0.2252936 0.4514293 0.4990673
0.0368588 0.0315945 0.1229416 0.0687347 0.1377619 0.0890625 0.1284167 0.1775000 0.0680067 0.1391226 0.2092005 0.4691999 0.4458665
0.2234411 0.0507126 0.1557825 0.0300139 0.0638790 0.0923005 0.0032010 0.0914097 0.2078994 0.0813603 0.0788695 0.3990047 0.1976657
0.0910545 0.1132450 0.0932193 0.0351725 0.1562729 0.1262589 0.1500147 0.0203882 0.1366933 0.0776808 0.1590890 0.4569036 0.3481893
0.1357237 0.1069758 0.1294391 0.0914312 0.0875764 0.1305011 0.0038573 0.0788985 0.1210705 0.1145265 0.1116775 0.4304285 0.2594565
0.1166175 0.2102762 0.1069675 0.0597707 0.0172573 0.0242860 0.0772221 0.1266318 0.1616377 0.0993332 0.1248760 0.3951751 0.3160018


Look at the portfolios that matter the most

  • Minimum variance portfolio: maximized returns with minimized risk
  • Tangency portfolio: the allocation with highest Sharpe Ratio
min_var <- portfolio_values[which.min(portfolio_values$Risk),]
max_sr <- portfolio_values[which.max(portfolio_values$SharpeRatio),]


Plot the weights of each portfolio

Minimum variance portfolio

p <- min_var %>%
  gather(`ALGO-USD`:`XMR-USD`, key = Asset,
         value = Weights) %>%
  mutate(Asset = as.factor(Asset)) %>%
  ggplot(aes(x = fct_reorder(Asset,Weights), y = Weights, fill = Asset)) +
  geom_bar(stat = 'identity') +
  theme_minimal() +
  labs(x = 'Assets', y = 'Weights', title = "Minimum Variance Portfolio Weights") +
  scale_y_continuous(labels = scales::percent) 

ggplotly(p, height = 550, width = 1000)


Tangency portfolio

p <- max_sr %>%
  gather(`ALGO-USD`:`XMR-USD`, key = Asset,
         value = Weights) %>%
  mutate(Asset = as.factor(Asset)) %>%
  ggplot(aes(x = fct_reorder(Asset,Weights), y = Weights, fill = Asset)) +
  geom_bar(stat = 'identity') +
  theme_minimal() +
  labs(x = 'Assets', y = 'Weights', title = "Tangency Portfolio Weights") +
  scale_y_continuous(labels = scales::percent) 

ggplotly(p, height = 550, width = 1000)


Plot all the random portfolios and visualize the efficient frontier

p <- portfolio_values %>%
  ggplot(aes(x = Risk, y = Return, color = SharpeRatio)) +
  geom_point() +
  theme_classic() +
  scale_y_continuous(labels = scales::percent) +
  scale_x_continuous(labels = scales::percent) +
  labs(x = 'Annualized Risk',
       y = 'Annualized Returns',
       title = "Portfolio Optimization & Efficient Frontier") +
  geom_point(aes(x = Risk,
                 y = Return), data = min_var, color = 'red') +
  geom_point(aes(x = Risk,
                 y = Return), data = max_sr, color = 'red') +
  annotate('text', x = 0.32, y = 0.26, label = "Tangency Portfolio") +
  annotate('text', x = 0.21, y = 0.13, label = "Minimum variance portfolio") +
  annotate(geom = 'segment', x = 0.00, xend = 0.00,  y = 0.00, 
           yend = 0.00, color = 'red', arrow = arrow(type = "open")) +
  annotate(geom = 'segment', x = 0.00, xend = 0.00,  y = 0.00, 
           yend = 0.00, color = 'red', arrow = arrow(type = "open"))


ggplotly(p, height = 500, width = 800)

Conclusion

From a profile of 5000 randomly generated profiles

  • The minimum variance portfolio serves the needs of a slightly more conservative investor with allocations at the listed percentage allocations, with a lower annualized risk.

  • The tangency portfolio serves a slightly more risk-on investor with allocations at the listed percentage allocations, with a higher estimate of annualized returns.

  • Within the context of the cryptocurrency market, the annualized risk is high for both portfolio allocations. One month in the cryptocurrency market can be dynamic due to its volatility. As it is a nascent market, it may be prudent to reevaluate a crypto portfolio quarterly.

  • The Sharpe Ratios of the minimum variance and tangency portfolios range between 0.37 to 0.64. According to certain sources, the typical Sharpe Ratio of an “actively managed mutual fund” is 0.25, and the Sharpe Ratio for a “diversified portfolio of stock and bond ETFs” is anywhere between 0.5-0.75.4 As of today, cryptocurrency classifies as an intangible asset.5 Thus, one may determine that the aforementioned Sharpe Ratio range, based on a full allocation to a “diversified” cryptocurrency portfolio, may compare to a classical mix of securities including stocks and bonds.6

For any questions or comments on this analysis please contact


Session Info

sessionInfo()
#> R version 4.2.1 (2022-06-23)
#> Platform: x86_64-pc-linux-gnu (64-bit)
#> Running under: Linux Mint 20.3
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.9.0
#> LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.9.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#>  [1] timetk_2.8.1               plotly_4.10.0             
#>  [3] tidyquant_1.0.4            quantmod_0.4.20           
#>  [5] TTR_0.24.3                 PerformanceAnalytics_2.0.4
#>  [7] xts_0.12.1                 zoo_1.8-10                
#>  [9] lubridate_1.8.0            forcats_0.5.1             
#> [11] stringr_1.4.0              dplyr_1.0.9               
#> [13] purrr_0.3.4                readr_2.1.2               
#> [15] tidyr_1.2.0                tibble_3.1.7              
#> [17] ggplot2_3.3.6              tidyverse_1.3.1           
#> 
#> loaded via a namespace (and not attached):
#>  [1] fs_1.5.2           httr_1.4.3         tools_4.2.1        backports_1.4.1   
#>  [5] bslib_0.3.1        utf8_1.2.2         R6_2.5.1           rpart_4.1.16      
#>  [9] DBI_1.1.3          lazyeval_0.2.2     colorspace_2.0-3   nnet_7.3-17       
#> [13] withr_2.5.0        tidyselect_1.1.2   curl_4.3.2         compiler_4.2.1    
#> [17] cli_3.3.0          rvest_1.0.2        xml2_1.3.3         labeling_0.4.2    
#> [21] bookdown_0.27      sass_0.4.1         scales_1.2.0       quadprog_1.5-8    
#> [25] digest_0.6.29      rmarkdown_2.14     pkgconfig_2.0.3    htmltools_0.5.2   
#> [29] parallelly_1.32.0  highr_0.9          dbplyr_2.2.0       fastmap_1.1.0     
#> [33] htmlwidgets_1.5.4  rlang_1.0.2        readxl_1.4.0       rstudioapi_0.13   
#> [37] farver_2.1.0       jquerylib_0.1.4    generics_0.1.2     jsonlite_1.8.0    
#> [41] crosstalk_1.2.0    magrittr_2.0.3     Matrix_1.4-1       Rcpp_1.0.8.3      
#> [45] Quandl_2.11.0      munsell_0.5.0      fansi_1.0.3        furrr_0.3.0       
#> [49] lifecycle_1.0.1    stringi_1.7.6      yaml_2.3.5         MASS_7.3-57       
#> [53] recipes_0.2.0      grid_4.2.1         parallel_4.2.1     listenv_0.8.0     
#> [57] crayon_1.5.1       lattice_0.20-45    splines_4.2.1      haven_2.5.0       
#> [61] hms_1.1.1          knitr_1.39         pillar_1.7.0       codetools_0.2-18  
#> [65] future.apply_1.9.0 reprex_2.0.1       glue_1.6.2         evaluate_0.15     
#> [69] rsample_0.1.1      data.table_1.14.2  modelr_0.1.8       vctrs_0.4.1       
#> [73] rmdformats_1.0.4   tzdb_0.3.0         cellranger_1.1.0   gtable_0.3.0      
#> [77] future_1.26.1      assertthat_0.2.1   xfun_0.31          gower_1.0.0       
#> [81] prodlim_2019.11.13 broom_0.8.0        class_7.3-20       survival_3.2-13   
#> [85] viridisLite_0.4.0  timeDate_3043.102  hardhat_1.1.0      lava_1.6.10       
#> [89] globals_0.15.0     ellipsis_0.3.2     ipred_0.9-13

Footnotes


  1. The Sharpe Ratio Defined: https://news.morningstar.com/classroom2/course.asp?docId=2932&page=4↩︎

  2. Dividing the weights by the sum of weights ensures the correct ratio of percentages (all adding to 1.00 or 100%).↩︎

  3. Assume a 0% risk free rate to calculate the Sharpe Ratio. The generic formula is: Sharpe Ratio = (Rx – Rf) / StdDev Rx, where Rx = expected portfolio return,Rf = Risk-free rate of return, & StdDev Rx = the portfolio standard deviation (or volatility/risk); Link: https://corporatefinanceinstitute.com/resources/knowledge/finance/sharpe-ratio-definition-formula/↩︎

  4. Your Sharpe Ratio Is Low For The Same Reasons You’re Bad At Golf: https://seekingalpha.com/article/4239442-your-sharpe-ratio-is-low-for-reasons-bad-golf↩︎

  5. Accounting for cryptocurrencies: https://www.accaglobal.com/in/en/student/exam-support-resources/professional-exams-study-resources/strategic-business-reporting/technical-articles/cryptocurrencies.html↩︎

  6. Again, this is not financial advice.↩︎