Overall Statistics
Total Orders
4000
Average Win
0.60%
Average Loss
-0.54%
Compounding Annual Return
-7.977%
Drawdown
65.600%
Expectancy
-0.041
Start Equity
100000.00
End Equity
54265.14
Net Profit
-45.735%
Sharpe Ratio
-0.559
Sortino Ratio
-0.635
Probabilistic Sharpe Ratio
0.000%
Loss Rate
55%
Win Rate
45%
Profit-Loss Ratio
1.11
Alpha
-0.075
Beta
-0
Annual Standard Deviation
0.134
Annual Variance
0.018
Information Ratio
-0.683
Tracking Error
0.214
Treynor Ratio
806.005
Total Fees
$1141.86
Estimated Strategy Capacity
$0
Lowest Capacity Asset
ANTUSD E3
Portfolio Turnover
7.28%
# region imports
from AlgorithmImports import *
# endregion

class SymbolData():
    def __init__(self, dataset_symbol: Symbol, data_period: int) -> None:
        self._period: int = data_period
        self._dataset_symbol: Symbol = dataset_symbol
        self._cap_mrkt_cur_usd: Optional[float] = None
        self._closes: RollingWindow = RollingWindow[float](data_period)

    def update(self, close: float, market_cap: float) -> None:
        self._closes.add(close)
        self._cap_mrkt_cur_usd = market_cap
        
    def is_ready(self) -> bool:
        return self._closes.is_ready and self._cap_mrkt_cur_usd is not None

    def get_momentum(self) -> float:
        return self._closes[0] / self._closes[self._closes.count - 1] - 1 

    def get_returns(self) -> np.ndarray:       
        return pd.Series(list(self._closes)[::-1]).pct_change().dropna().values
# https://quantpedia.com/strategies/volatility-scaled-momentum-in-cryptocurrencies
# 
# The investment universe for this strategy consists of all cryptocurrencies with available market capitalization data. The selection criteria exclude cryptocurrencies 
# with market capitalizations below $1 million or zero trading volumes.
# (Historical daily data for all cryptocurrencies available in CoinMarketCap.)
# Fundamental Recapitulation: The strategy employs various metrics (see Table 1: Characteristic Definitions), notably momentum indicators calculated over one-week (selected 
# for reporting this variant), two-week, and three-week periods. Volatility scaling is applied by adjusting each portfolio’s exposure inversely to its realized standard 
# deviation of returns from the prior week (see 2.3 Volatility-Scaling). This scaling uses an Eq. (1) formula that maintains the same unconditional volatility as the 
# original portfolio. (The strategy does not rely on traditional buy and sell signals but instead uses the momentum and volatility scaling methodology to determine 
# portfolio positions.)
# Selected Variation Computational Process: Cryptocurrencies are sorted into quintiles based on their momentum scores. So, cryptocurrencies are sorted into quintiles based 
# on the value of the corresponding characteristic (one-week momentum) and form a value-weighted portfolio for each quintile.
# Trading Strategy Execution: A long-short portfolio is created by taking a long position in the highest (fifth) quintile and a short position in the lowest (first) quintile.
# Rebalancing & Weighting: The strategy involves weekly rebalancing to adjust for changes in momentum and volatility. Positions are value-weighted within each quintile. 
# Volatility scaling adjusts the capital allocated to each portfolio, stabilizing the risk exposure over time. A leverage cap 1× is applied to manage risk, especially 
# during high-volatility periods. (Transaction costs are considered by accounting for a 25 basis point cost, ensuring the strategy remains viable after expenses.)
# 
# QC Implementation changes:
#   - Observation period is set to 3 week.

# region imports
from AlgorithmImports import *
import data_tools
# endregion

class VolatilityScaledMomentumInCryptocurrencies(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2018, 1, 1)
        self.set_cash(100_000)

        cryptos: Dict[str, str] = {
            "XLMUSD": "XLM",    # Stellar
            "XMRUSD": "XMR",    # Monero
            "XRPUSD": "XRP",    # XRP
            "ADAUSD": "ADA",    # Cardano
            "DOTUSD": "DOT",    # Polkadot
            "UNIUSD": "UNI",    # Uniswap
            "LINKUSD": "LINK",  # Chainlink
            "ANTUSD": "ANT",    # Aragon
            "BATUSD": "BAT",    # Basic Attention Token
            "BTCUSD": "BTC",    # Bitcoin
            "BTGUSD": "BTG",    # Bitcoin Gold
            "DASHUSD": "DASH",  # Dash
            "DGBUSD": "DGB",    # Dogecoin
            "ETCUSD": "ETC",    # Ethereum Classic
            "ETHUSD": "ETH",    # Ethereum
            "LTCUSD": "LTC",    # Litecoin
            "MKRUSD": "MKR",    # Maker
            "NEOUSD": "NEO",    # Neo
            "PAXUSD": "PAX",    # Paxful
            "SNTUSD": "SNT",    # Status
            "TRXUSD": "TRX",    # Tron
            "XTZUSD": "XTZ",    # Tezos
            "XVGUSD": "XVG",    # Verge
            "ZECUSD": "ZEC",    # Zcash
            "ZRXUSD": "ZRX"     # Ox
        }

        leverage: int = 10
        period: int = 3 * 7
        self._target: float = .1
        self._quantile: int = 5
        self._traded_percentage: float = .1

        self._data: Dict[Symbol, data_tools.SymbolData] = {}

        # data subscription
        for pair, ticker in cryptos.items():
            data: Security = self.add_crypto(pair, Resolution.DAILY, Market.BITFINEX, leverage=leverage)
            data.set_fee_model(CustomFeeModel())
            dataset_symbol = self.add_data(CoinGecko, ticker, Resolution.DAILY).symbol

            self._data[data.Symbol] = data_tools.SymbolData(dataset_symbol, period)

        self._market: Symbol = self.add_equity('SPY', Resolution.DAILY).symbol

        self._rebalance_flag: bool = False
        self.settings.daily_precise_end_time = False
        self.settings.minimum_order_margin_portfolio_percentage = 0.

        self.schedule.on(
            self.date_rules.week_start(self._market),
            self.time_rules.after_market_open(self._market),
            self._rebalance
        )

    def on_data(self, slice: Slice) -> None:
        # Update crypto data.
        for symbol, symbol_data in self._data.items():
            dataset_symbol: Symbol = symbol_data._dataset_symbol
            if slice.contains_key(symbol) and slice[symbol] and slice.contains_key(dataset_symbol) and slice[dataset_symbol]:
                symbol_data.update(slice[symbol].price, slice[dataset_symbol].market_cap)

        # Monthly rebalance.
        if not self._rebalance_flag:
            return
        self._rebalance_flag = False

        # Calculate momentum over period.
        momentum: Dict[Symbol, float] = {
            symbol: symbol_data.get_momentum()
            for symbol, symbol_data in self._data.items()
            if symbol_data.is_ready()
        }

        if len(momentum) < self._quantile:
            self.log('Not enough data for further calculation.')
            return

        # Sort and divide.
        sorted_momentum: List[tuple[Any, Any]] = sorted(momentum.items(), key=lambda x:x[1], reverse=True)
        quantile: int = len(sorted_momentum) // self._quantile
        long: List[Symbol] = list(map(lambda x:x[0], sorted_momentum))[:quantile]
        short: List[Symbol] = list(map(lambda x:x[0], sorted_momentum))[-quantile:]

        # Value weighting.
        weight: Dict[Symbol, float] = {}
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum([self._data[symbol]._cap_mrkt_cur_usd for symbol in portfolio])
            for symbol in portfolio:
                weight[symbol] = ((-1)**i) * self._data[symbol]._cap_mrkt_cur_usd / mc_sum

        # Calculate portfolio volatility.
        if len(weight) != 0:
            returns: List[np.ndarray] = [self._data[symbol].get_returns() for symbol in list(weight.keys())]
            weights: np.ndarray = np.array(list(weight.values()))

            port_vol: float = self._portfolio_volatility(weights, returns)
            target_leverage: float = min(self._target / port_vol, 1)

        # Trade execution.
        targets: List[PortfolioTarget] = [PortfolioTarget(symbol, w * target_leverage) for symbol, w in weight.items() if slice.contains_key(symbol) and slice[symbol]]
        self.set_holdings(targets, True)

    def _rebalance(self) -> None:
        self._rebalance_flag = True

    def _portfolio_volatility(self, weights: np.ndarray, daily_returns: np.ndarray) -> float:
        # Calculate the volatility of portfolio.
        returns_array: np.ndarray = np.column_stack(daily_returns)
        covariance_matrix: np.ndarray = np.cov(returns_array, rowvar=False) * 252
        result: float = np.sqrt(np.dot(weights.T, np.dot(covariance_matrix, weights)))
        return result

# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.security.price * parameters.order.absolute_quantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))