Overall Statistics
Total Orders
225
Average Win
1.12%
Average Loss
-0.78%
Compounding Annual Return
2.000%
Drawdown
49.700%
Expectancy
0.134
Start Equity
100000.00
End Equity
115676.45
Net Profit
15.676%
Sharpe Ratio
0.027
Sortino Ratio
0.03
Probabilistic Sharpe Ratio
0.231%
Loss Rate
54%
Win Rate
46%
Profit-Loss Ratio
1.45
Alpha
0.006
Beta
-0.013
Annual Standard Deviation
0.201
Annual Variance
0.04
Information Ratio
-0.249
Tracking Error
0.262
Treynor Ratio
-0.43
Total Fees
$28.25
Estimated Strategy Capacity
$0
Lowest Capacity Asset
BATUSD E3
Portfolio Turnover
0.18%
# region imports
from AlgorithmImports import *
from collections import deque
from dateutil.relativedelta import relativedelta
# 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: float|None = 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
    
    def vix_is_ready(self) -> bool:
        return self._closes.is_ready
        
    def get_diff(self) -> np.ndarray:
        closes: np.ndarray = np.array(list(self._closes))
        diff: np.ndarray = np.diff(closes)

        return diff

    def get_returns(self) -> np.ndarray:
        closes: np.ndarray = np.array(list(self._closes))
        returns: np.ndarray = closes[:-1] / closes[1:] - 1
       
        return returns

class RegressionData():
    def __init__(self, period: int) -> None:
        self._daily_data: Deque = deque(maxlen=period)

    def update_data(self, vix: float, market: float, ff_data) -> None:
        self._daily_data.append((vix, market, ff_data.value, ff_data.size, ff_data.market, ff_data.profitability, ff_data.investment))

    def is_ready(self) -> bool:
        return len(self._daily_data) == self._daily_data.maxlen

    def get_regression_data(self) -> np.ndarray:
        data: np.ndarray = np.array(self._daily_data)
        # VIX data should be as difference.
        vix_diff = np.diff(data[:, 0], prepend=0) 
        data[:, 0] = vix_diff
        # Market returns.
        market_ret = pd.Series(data[:, 1]).pct_change().values
        data[:, 1] = market_ret

        return data[1:]

# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFamaFrench(PythonData):
    _last_update_date:Dict[Symbol, datetime.date] = {}

    @staticmethod
    def get_last_update_date() -> Dict[Symbol, datetime.date]:
       return QuantpediaFamaFrench._last_update_date

    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/fama_french/fama_french_5_factor.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaFamaFrench()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit():
            return None
        
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%d.%m.%Y") + relativedelta(months=1)
        data['market'] = float(split[1])
        data['size'] = float(split[2])
        data['value'] = float(split[3])
        data['profitability'] = float(split[4])
        data['investment'] = float(split[5])

        if config.Symbol not in QuantpediaFamaFrench._last_update_date:
            QuantpediaFamaFrench._last_update_date[config.Symbol] = datetime(1,1,1).date()
        if data.Time.date() > QuantpediaFamaFrench._last_update_date[config.Symbol]:
            QuantpediaFamaFrench._last_update_date[config.Symbol] = data.Time.date()

        return data

class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
		
# https://quantpedia.com/strategies/risk-premiums-in-the-cryptocurrency-market/
# 
# The investment universe for this strategy consists of the top (the highest market capitalization) 200 cryptocurrencies.
# (Obtain cryptocurrency prices from coinmarketcap.com and equity factors from Kenneth French’s website.)
# Broad Introductory Overview of both Methods: The strategy employs a systematic approach using the primary tool of time-varying exposures (betas) to equity 
# market factors. These exposures are calculated through time-series regressions, capturing the sensitivity of each cryptocurrency to factors such as market 
# returns (MKTRF) and volatility (VIX). (The strategy also incorporates a rolling window approach for calculating betas, allowing dynamic adjustments to 
# changing market conditions.)
# Strategy Process: The portfolios are built using the Fama-MacBeth technique described in Section 4. Betas are obtained from 180-day rolling windows. The 
# methodology involves sorting cryptocurrencies into portfolios based on their beta exposures.
# Strategy Execution: reversed (!) high minus low (HML) portfolio exposures to volatility beta (VIX):
# - The low portfolio contains cryptocurrencies with a lower than 30th percentile exposure to the factor.
# - The high portfolio contains cryptocurrencies greater than the 70th percentile.
# - The sell rule involves short-selling cryptocurrencies with high (H) exposure/beta to VIX
# - Conversely, the buy rule involves going long cryptocurrencies with low (L) exposure/beta to VIX
# (This long-short approach exploits the identified risk premia in the cryptocurrency market.)
# Sorting & Weighting: The strategy involves rebalancing the portfolios annually. The sample is rebalanced at the beginning of each year. The portfolios are 
# value-weighted, with a 180-day holding period.

# region imports
from AlgorithmImports import *
import data_tools
import statsmodels.api as sm
# endregion

class RiskPremiumsInTheCryptocurrencyMarket(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 = 180
        self._quantile: int = 3
        self._traded_percentage: float = .1
        self._rebalance_months: List[int] = [1, 7]

        self._data: Dict[Symbol, SymbolData] = {}
        self._regression_data: data_tools.RegressionData = data_tools.RegressionData(period)

        # data subscription
        for pair, ticker in cryptos.items():
            data: Security = self.add_crypto(pair, Resolution.DAILY, Market.BITFINEX, leverage=leverage)
            data.set_fee_model(data_tools.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._vix: Symbol = self.add_data(CBOE, 'VIX', Resolution.DAILY).symbol
        self._fama_french: Symbol = self.add_data(data_tools.QuantpediaFamaFrench, 'fama_french_5_factor', 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.month_start(self._market),
            self.time_rules.after_market_open(self._market),
            self.rebalance
        )

    def on_data(self, slice: Slice) -> None:
        if slice.contains_key(self._market) and slice[self._market]:
            # Update market, fama french and VIX data.
            if self.securities[self._vix].get_last_data() and self.securities[self._fama_french].get_last_data():
                ff_data: PythonData = self.securities[self._fama_french].get_last_data()
                vix_data: PythonData = self.securities[self._vix].get_last_data()
                self._regression_data.update_data(vix_data.value, slice[self._market].close, ff_data)

            # Update crypto data.
            for symbol, symbol_data in self._data.items():
                dataset_symbol: Symbol = symbol_data._dataset_symbol
                if self.securities[symbol].get_last_data() and self.securities[dataset_symbol].get_last_data():
                    symbol_data.update(self.securities[symbol].get_last_data().price, self.securities[dataset_symbol].get_last_data().MarketCap)

        # Rebalance every six months.
        if not self._rebalance_flag:
            return
        self._rebalance_flag = False

        if not self._regression_data.is_ready():
            return

        crypto_returns_dict: Dict[Symbol, np.ndarray] = {
            symbol : symbol_data.get_returns() for symbol, symbol_data in self._data.items() if symbol_data.is_ready()
        }
        crypto_returns: List[float] = list(zip(*[[i for i in x] for x in crypto_returns_dict.values()]))

        if len(crypto_returns_dict) < self._quantile:
            return

        # Regression.
        y: np.ndarray = np.array(crypto_returns)
        x: np.ndarray = self._regression_data.get_regression_data()
        model = self.multiple_linear_regression(x, y)
        beta_values: np.ndarray = model.params[1]

        # Store betas.
        beta_by_symbol: Dict[Symbol, float] = {
            sym : beta_values[n] for n, sym in enumerate(list(crypto_returns_dict.keys()))
        }

        long: List[Symbol] = []
        short: List[Symbol] = []

        # Sort by beta and divide into quantiles.
        if len(beta_by_symbol) >= self._quantile:
            sorted_beta: List[Symbol] = sorted(beta_by_symbol, key=beta_by_symbol.get)
            quantile: int = len(sorted_beta) // self._quantile
            long = sorted_beta[:quantile]
            short = sorted_beta[-quantile:]

        weight: Dict[Symbol, float] = {}

        # Trade execution.
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum(list(map(lambda x: self._data[x]._cap_mrkt_cur_usd, portfolio)))
            for symbol in portfolio:
                weight[symbol] = ((-1)**i) * self._traded_percentage * (self._data[symbol]._cap_mrkt_cur_usd / mc_sum)

        portfolio: List[PortfolioTarget] = [
            PortfolioTarget(symbol, w) for symbol, w in weight.items() if slice.contains_key(symbol) and slice[symbol]
        ]
        self.set_holdings(portfolio, True)

    def rebalance(self) -> None:
        if self.time.month in self._rebalance_months:
            self._rebalance_flag = True

    def multiple_linear_regression(self, x: np.ndarray, y: np.ndarray):
        x = sm.add_constant(x, has_constant='add')
        result = sm.OLS(endog=y, exog=x).fit()
        return result