
Trade cryptocurrencies based on the VOLSCALE factor, going long on the lowest volume quintile and short on the highest, with market-cap weighted portfolios rebalanced weekly, rescaled to 1/10 of portfolio value.
ASSET CLASS: cryptos | REGION: Global | FREQUENCY:
Weekly | MARKET: cryptos | KEYWORD: Volume, Crypto
I. STRATEGY IN A NUTSHELL
Trade 1,204 cryptocurrencies using the VOLSCALE factor: log average daily volume × price ÷ market cap. Sort into quintiles; long the lowest-volume quintile, short the highest. Portfolio is market-cap weighted, weekly rebalanced, and suggested to be scaled to 10% of total capital to manage volatility.
II. ECONOMIC RATIONALE
Low-volume cryptos earn a liquidity risk premium, analogous to illiquid equities. First-quintile (lowest-volume) portfolios consistently outperform, indicating higher returns compensate investors for holding less liquid assets.
III. SOURCE PAPER
Cryptocurrency Factor Portfolios: Performance, Decomposition and Pricing Models [Click to Open PDF]
Weihao Han, 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 | 16.8% |
| Volatility | 11.97% |
| Beta | 0.066 |
| Sharpe Ratio | 1.4 |
| Sortino Ratio | 0.543 |
| Maximum Drawdown | N/A |
| Win Rate | 62% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from typing import List, Dict
class ScaledVolumeInCryptos(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] = {
"BATUSD": "BAT", # Basic Attention Token
"BTCUSD": "BTC", # Bitcoin
"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
"SNTUSD": "SNT", # Status
"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.Settings.MinimumOrderMarginPortfolioPercentage = 0.
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 their volumes and market capitalization(CapMrktCurUSD)
for crypto, symbol_obj in self.data.items():
network_symbol = symbol_obj.network_symbol
if crypto in data.Bars and data[crypto]:
# get crypto price and crypto volume
price: float = data.Bars[crypto].Value
volume: float = data.Bars[crypto].Volume
self.data[crypto].update(price, volume)
if network_symbol in data and data[network_symbol]:
# get market capitalization
cap_mrkt_cur_usd: float = data[network_symbol].Value
if cap_mrkt_cur_usd is not None:
self.data[crypto].update_cap(cap_mrkt_cur_usd)
# rebalance weekly
if self.Time.date().weekday() != 0:
return
vol_scale: Dict[str, float] = {} # storing "VOLSCALE" for each crypto currency
for crypto, symbol_obj in self.data.items():
network_symbol: str = self.cryptos[crypto]
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 "VOLSCALE" for current crypto
vol_scale[crypto] = symbol_obj.vol_scale()
# not enough cryptos for quantile selection
if len(vol_scale) < self.quantile:
self.Liquidate()
return
# perform quantile selection
quantile: int = int(len(vol_scale) / self.quantile)
sorted_by_vol_scale: List[str] = [x[0] for x in sorted(vol_scale.items(), key=lambda item: item[1])]
# go long on the first quantile (lowest volume), go short on the fifth quantile
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]]
self.SetHoldings(portfolio)
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)
self.volumes: RollingWindow = RollingWindow[float](period)
def update(self, close: float, volume: float) -> None:
self.closes.Add(close)
self.volumes.Add(volume)
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.volumes.IsReady and self.cap_mrkt_cur_usd
def vol_scale(self) -> float:
daily_prices: np.ndarray = np.array([x for x in self.closes])
daily_volumes: np.ndarray = np.array([x for x in self.volumes])
dollar_volumes: np.ndarray = daily_prices * daily_volumes
volscale = np.log(np.mean(dollar_volumes)) / self.cap_mrkt_cur_usd
# return "VOLSCALE"
return volscale
# 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:
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']
split = line.split(',')
if not line[0].isdigit():
self.col_index = [split.index(x) for x in cols]
return None
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]])
# store last update date
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