
Trade cryptocurrencies based on volatility (RETVOL), going long on the highest volatility quintile and short on the lowest, with market-cap weighted portfolios rebalanced weekly, rescaled to 1/10 of portfolio value.
ASSET CLASS: cryptos | REGION: Global | FREQUENCY:
Weekly | MARKET: cryptos | KEYWORD: Volatility Effect, Crypto
I. STRATEGY IN A NUTSHELL
Trade 1,204 cryptocurrencies using the RETVOL factor: standard deviation of daily returns during the formation week. Sort into quintiles; long the highest-volatility quintile, short the lowest. Portfolio is market-cap weighted, weekly rebalanced, and suggested to be scaled to 10% of total capital to manage volatility.
II. ECONOMIC RATIONALE
Unlike equities’ low-volatility anomaly, cryptocurrencies reward high-volatility exposure. Traders capture a volatility risk premium, as higher-risk cryptos deliver higher expected returns, compensating for the extreme price fluctuations inherent to this asset class.
III. SOURCE PAPER
Cryptocurrency Factor Portfolios: Performance, Decomposition and Pricing Models [Click to Open PDF]
Han, Weihao, University of Aberdeen – Business School; David Newton, University of Bath – School of Management; Emmanouil Platanakis, University of Bath – School of Management; Charles M. Sutcliffe, University of Reading – ICMA Centre; Xiaoxia Ye, University of Exeter Business School – Department of Finance
<Abstract>
Cryptocurrency returns are highly non-normal, casting doubt on the standard performance metrics. We apply almost stochastic dominance (ASD), which does not require any assumption about the return distribution or degree of risk aversion. From 29 long-short cryptocurrency factor portfolios, we find eight that dominate our four benchmarks. Their returns cannot be fully explained by the three-factor coin model of Liu et al. (2022). So we develop a new three-factor model where momentum is replaced by a mispricing factor based on size and risk-adjusted momentum, which significantly improves pricing performance.


IV. BACKTEST PERFORMANCE
| Annualised Return | 17.06% |
| Volatility | 25.56% |
| Beta | 0.05 |
| Sharpe Ratio | 0.68 |
| Sortino Ratio | 0.401 |
| Maximum Drawdown | N/A |
| Win Rate | 45% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from typing import List, Dict
class VolatilityEffectInCryptos(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2015, 1, 1)
self.SetCash(1_000_000)
self.period: int = 5 # need n of daily prices
self.percentage_traded: float = .1
self.quantile: int = 5
self.leverage: int = 5
self.cryptos: Dict[str, str] = {
"ANTUSD": "ANT", # Aragon
"BTCUSD": "BTC", # Bitcoin
"BTGUSD": "BTG", # Bitcoin Gold
"DAIUSD": "DAI", # Dai
"DGBUSD": "DGB", # Dogecoin
"EOSUSD": "EOS", # EOS
"ETCUSD": "ETC", # Ethereum Classic
"ETHUSD": "ETH", # Ethereum
"FUNUSD": "FUN", # FUN Token
"LTCUSD": "LTC", # Litecoin
"MKRUSD": "MKR", # Maker
"NEOUSD": "NEO", # Neo
"OMGUSD": "OMG", # OMG Network
"TRXUSD": "TRX", # Tron
"XLMUSD": "XLM", # Stellar
"XMRUSD": "XMR", # Monero
"XRPUSD": "XRP", # XRP
"XTZUSD": "XTZ", # Tezos
"XVGUSD": "XVG", # Verge
"ZECUSD": "ZEC", # Zcash
"ZRXUSD": "ZRX" # Ox
}
self.data: Dict[str, SymbolData] = {}
self.SetBrokerageModel(BrokerageName.Bitfinex)
for crypto, ticker in self.cryptos.items():
# GDAX is coinmarket, but it doesn't support this many cryptos, so we choose Bitfinex
data: Securities = self.AddCrypto(crypto, Resolution.Daily, Market.Bitfinex)
data.SetLeverage(self.leverage)
network_symbol: Symbol = self.AddData(CryptoNetworkData, ticker, Resolution.Daily).Symbol
self.data[crypto] = SymbolData(network_symbol, self.period)
self.selection_flag: bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.WeekStart("BTCUSD"), self.TimeRules.At(0, 0), self.Selection)
def OnData(self, data: Slice) -> None:
crypto_data_last_update_date: Dict[Symbol, datetime.date] = CryptoNetworkData.get_last_update_date()
# daily updating of crypto prices and market capitalization(CapMrktCurUSD)
for crypto, symbol_obj in self.data.items():
network_symbol: str = symbol_obj.network_symbol
if crypto in data.Bars and data[crypto]:
# get crypto price
price: float = data.Bars[crypto].Value
self.data[crypto].update(price)
if network_symbol in data and data[network_symbol]:
# get market capitalization
cap_mrkt_cur_usd: float = data[network_symbol].Value
self.data[crypto].update_cap(cap_mrkt_cur_usd)
# rebalance weekly
if not self.selection_flag:
return
ret_vol: Dict[str, float] = {} # storing "RETVOL" for each crypto currency
for crypto, symbol_obj in self.data.items():
network_symbol: str = self.cryptos[crypto]
if network_symbol not in crypto_data_last_update_date:
continue
if self.Securities[network_symbol].GetLastData() and network_symbol in crypto_data_last_update_date:
if self.Time.date() > crypto_data_last_update_date[network_symbol]:
continue
# crypto doesn't have enough data
if not symbol_obj.is_ready():
continue
# calculate "RETVOL" for current crypto
ret_vol[crypto] = symbol_obj.ret_vol()
# not enough cryptos for quintile selection
if len(ret_vol) < self.quantile:
self.Liquidate()
return
# perform quintile selection
quantile: int = int(len(ret_vol) / self.quantile)
sorted_by_vol_scale: List[str] = [x[0] for x in sorted(ret_vol.items(), key=lambda item: item[1])][-quantile:]
# go long on the fifth quintile (highest volatility)
long: List[str] = sorted_by_vol_scale[-quantile:]
# trade execution
invested: List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for ticker in invested:
if ticker not in long:
self.Liquidate(ticker)
total_long_cap: float = sum([self.data[x].cap_mrkt_cur_usd for x in long])
portfolio:List[PortfolioTarget] = [PortfolioTarget(ticker, self.percentage_traded * (self.data[ticker].cap_mrkt_cur_usd / total_long_cap)) for ticker in long if ticker in data and data[ticker]]
if len(portfolio) != 0:
self.SetHoldings(portfolio)
self.selection_flag = False
def Selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, network_symbol: str, period: int) -> None:
self.network_symbol: str = network_symbol
self.cap_mrkt_cur_usd: Union[None, float] = None
self.closes: RollingWindow = RollingWindow[float](period)
def update(self, close: float) -> None:
self.closes.Add(close)
def update_cap(self, cap_mrkt_cur_usd: float) -> None:
self.cap_mrkt_cur_usd: float = cap_mrkt_cur_usd
def is_ready(self) -> bool:
return self.closes.IsReady and self.cap_mrkt_cur_usd
def ret_vol(self) -> float:
daily_prices: np.ndarray = np.array([x for x in self.closes])
daily_returns: np.ndarray = (daily_prices[:-1] - daily_prices[1:]) / daily_prices[1:]
# return "RETVOL"
return np.std(daily_returns)
# Crypto network data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
# Data source: https://coinmetrics.io/community-network-data/
class CryptoNetworkData(PythonData):
_last_update_date: Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return CryptoNetworkData._last_update_date
def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
self.cap_mrkt_cur_usd_index = None
return SubscriptionDataSource(f"data.quantpedia.com/backtesting_data/crypto/{config.Symbol.Value}_network_data.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
# File exmaple:
# date,AdrActCnt,AdrBal1in100KCnt,AdrBal1in100MCnt,AdrBal1in10BCnt,AdrBal1in10KCnt,AdrBal1in10MCnt,AdrBal1in1BCnt,AdrBal1in1KCnt,AdrBal1in1MCnt,AdrBalCnt,AdrBalNtv0.001Cnt,AdrBalNtv0.01Cnt,AdrBalNtv0.1Cnt,AdrBalNtv100Cnt,AdrBalNtv100KCnt,AdrBalNtv10Cnt,AdrBalNtv10KCnt,AdrBalNtv1Cnt,AdrBalNtv1KCnt,AdrBalNtv1MCnt,AdrBalUSD100Cnt,AdrBalUSD100KCnt,AdrBalUSD10Cnt,AdrBalUSD10KCnt,AdrBalUSD10MCnt,AdrBalUSD1Cnt,AdrBalUSD1KCnt,AdrBalUSD1MCnt,AssetEODCompletionTime,BlkCnt,BlkSizeMeanByte,BlkWghtMean,BlkWghtTot,CapAct1yrUSD,CapMVRVCur,CapMVRVFF,CapMrktCurUSD,CapMrktFFUSD,CapRealUSD,DiffLast,DiffMean,FeeByteMeanNtv,FeeMeanNtv,FeeMeanUSD,FeeMedNtv,FeeMedUSD,FeeTotNtv,FeeTotUSD,FlowInExNtv,FlowInExUSD,FlowOutExNtv,FlowOutExUSD,FlowTfrFromExCnt,HashRate,HashRate30d,IssContNtv,IssContPctAnn,IssContPctDay,IssContUSD,IssTotNtv,IssTotUSD,NDF,NVTAdj,NVTAdj90,NVTAdjFF,NVTAdjFF90,PriceBTC,PriceUSD,ROI1yr,ROI30d,RevAllTimeUSD,RevHashNtv,RevHashRateNtv,RevHashRateUSD,RevHashUSD,RevNtv,RevUSD,SER,SplyAct10yr,SplyAct180d,SplyAct1d,SplyAct1yr,SplyAct2yr,SplyAct30d,SplyAct3yr,SplyAct4yr,SplyAct5yr,SplyAct7d,SplyAct90d,SplyActEver,SplyActPct1yr,SplyAdrBal1in100K,SplyAdrBal1in100M,SplyAdrBal1in10B,SplyAdrBal1in10K,SplyAdrBal1in10M,SplyAdrBal1in1B,SplyAdrBal1in1K,SplyAdrBal1in1M,SplyAdrBalNtv0.001,SplyAdrBalNtv0.01,SplyAdrBalNtv0.1,SplyAdrBalNtv1,SplyAdrBalNtv10,SplyAdrBalNtv100,SplyAdrBalNtv100K,SplyAdrBalNtv10K,SplyAdrBalNtv1K,SplyAdrBalNtv1M,SplyAdrBalUSD1,SplyAdrBalUSD10,SplyAdrBalUSD100,SplyAdrBalUSD100K,SplyAdrBalUSD10K,SplyAdrBalUSD10M,SplyAdrBalUSD1K,SplyAdrBalUSD1M,SplyAdrTop100,SplyAdrTop10Pct,SplyAdrTop1Pct,SplyCur,SplyExpFut10yr,SplyFF,SplyMiner0HopAllNtv,SplyMiner0HopAllUSD,SplyMiner1HopAllNtv,SplyMiner1HopAllUSD,TxCnt,TxCntSec,TxTfrCnt,TxTfrValAdjNtv,TxTfrValAdjUSD,TxTfrValMeanNtv,TxTfrValMeanUSD,TxTfrValMedNtv,TxTfrValMedUSD,VelCur1yr,VtyDayRet180d,VtyDayRet30d
# 2009-01-09,19,19,19,19,19,19,19,19,19,19,19,19,19,0,0,19,0,19,0,0,0,0,0,0,0,0,0,0,1614334886,19,215,860,16340,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,9.44495122962963E-7,0,950,36500,100,0,950,0,1,0,0,0,0,1,0,0,0,0,11641.53218269,1005828380.584716757433,0,0,950,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,950,950,950,950,950,950,950,950,950,950,950,950,950,0,0,0,0,0,0,0,0,0,0,0,0,0,950,50,50,950,17070250,950,1000,0,1000,0,0,0,0,0,0,0,0,0,0,0,0,0
def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
data: CryptoNetworkData = CryptoNetworkData()
data.Symbol = config.Symbol
try:
cols: List[str] = ['CapMrktCurUSD']
if not line[0].isdigit():
header_split = line.split(',')
self.col_index = [header_split.index(x) for x in cols]
return None
split: str = line.split(',')
data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
for i, col in enumerate(cols):
data[col] = float(split[self.col_index[i]])
data.Value = float(split[self.col_index[0]])
if config.Symbol.Value not in CryptoNetworkData._last_update_date:
CryptoNetworkData._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
if data.Time.date() > CryptoNetworkData._last_update_date[config.Symbol.Value]:
CryptoNetworkData._last_update_date[config.Symbol.Value] = data.Time.date()
except:
return None
return data
VI. Backtest Performance