Overall Statistics |
Total Orders 260 Average Win 0.92% Average Loss -0.51% Compounding Annual Return 9.459% Drawdown 14.000% Expectancy 0.814 Start Equity 100000 End Equity 184110.43 Net Profit 84.110% Sharpe Ratio 0.521 Sortino Ratio 0.504 Probabilistic Sharpe Ratio 27.749% Loss Rate 35% Win Rate 65% Profit-Loss Ratio 1.79 Alpha 0.032 Beta 0.124 Annual Standard Deviation 0.081 Annual Variance 0.007 Information Ratio -0.248 Tracking Error 0.163 Treynor Ratio 0.342 Total Fees $567.30 Estimated Strategy Capacity $59000000.00 Lowest Capacity Asset VGK T6UUS5E5P1ET Portfolio Turnover 2.05% |
# The investment universe consists of the Offensive: SPY, QQQ, IWM, VGK, EWJ, VWO, VNQ, DBC, GLD, TLT, HYG, LQD; Protective: SPY, VWO, VEA, BND; and the Defensive: TIP, DBC, BIL, IEF, # TLT, LQD, BND. The trading algorithm is: on the close of the last trading day of each month t. # 1. Calculate a relative momentum score for each of assets in the offensive and defensive universe, where relative momentum at t equals pt / SMA(12) – 1. Note that the slow SMA(12) # trend is calculated based on month-end values with maximum lag 12, so as the average over pt. pt-12 representing the most recent 13 month-end prices, including today. # 2. Select the Top3 from a defensive universe with both relative and absolute SMA(12) momentum, if at least one of the assets in the protective (or canary) universe show negative # absolute momentum, where absolute momentum at t is based on fast momentum 13612W, which is the weighted average of returns over 1, 3, 6 and 12 months with weights 12, 4, 2, 1, resp. # Otherwise, select the offensive universe. # 3. Depending on step 2, select the Top 6 assets with the highest relative momentum value of the offensive or the defensive universe and allocate 1/(Top 6) of the portfolio to each. # Replace the ‘bad’ defensive selections (assets with momentum less than BIL) by BIL. Hold positions until the final trading day of the following month. Re-balance the entire portfolio # monthly, regardless of whether there is a change in position. This implies that the switching is 100% to defensive when at least one of the Protective (or ‘canary’) universe assets # shows negative (or ‘bad’) absolute momentum, no switching (so 0% defensive) with no canary assets ‘bad’. # region imports from AlgorithmImports import * import pandas as pd import numpy as np from typing import List, Dict from pandas.core.frame import DataFrame # endregion class BoldAssetAllocation(QCAlgorithm): def Initialize(self): self.SetCash(100000) # self.SetStartDate(2008, 1, 1) # self.SetStartDate(2023, 4, 1) # Set Start Date # self.SetEndDate(2023, 6, 1) # Set End Date self.SetStartDate(2018, 1, 1) # Set Start Date self.SetEndDate(2024, 9, 30) # Set End Date # all assets self.offensive:List[str] = [ "SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD", ] # Max ETF Fees if chose the 6 most expensive comes to 0.44% # SPY: .09% # QQQ: .20% 5 # IWM: .19% 6 # VGK: .11% # EWJ: .50% 2 # VWO: .08% # VNQ: .12% # DBC: .85% 1 # GLD: .40% 4 # TLT: .15% # HYG: .48% 3 # LQD: .14% self.protective:List[str] = ["SPY", "VWO", "VEA", "BND"] self.defensive:List[str] = ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"] self.safe:str = "BIL" # Max ETF Fees if chose the 3 most expensive comes to 0.20% # TIP: .19% 2 # DBC: .85% 1 # BIL: .1354% # IEF: .15% 3 # TLT: .15% # LQD: .14% # BND: .03% # strategy parameters (our implementation) self.prds:List[int] = [1, 3, 6, 12] # fast momentum settings self.prdweights:np.ndarray = np.array([12, 4, 2, 1]) # momentum weights self.LO, self.LP, self.LD, self.B, self.TO, self.TD = [ len(self.offensive), len(self.protective), len(self.defensive), 1, 6, 3, ] # number of offensive, protective, defensive assets, threshold for "bad" assets, select top n of offensive and defensive assets self.hprd:int = (max(self.prds + [self.LO, self.LD]) * 21 + 50) # momentum periods calculation # repeat safe asset so it can be selected multiple times self.all_defensive:List[str] = self.defensive + [self.safe] * max( 0, self.TD - sum([1 * (e == self.safe) for e in self.defensive]) ) self.equities:List[str] = list( dict.fromkeys(self.protective + self.offensive + self.all_defensive) ) leverage:int = 3 for equity in self.equities: data:Equity = self.AddEquity(equity, Resolution.Daily) data.SetLeverage(leverage) self.recent_month:int = -1 self.settings.daily_precise_end_time = False def OnData(self, data:Slice) -> None: if self.IsWarmingUp: return # monthly rebalance if self.recent_month == self.Time.month: return self.recent_month = self.Time.month # get price data and trading weights h:DataFrame = self.History(self.equities, self.hprd, Resolution.Daily)["close"].unstack(level=0) weights:pd.Series = self.trade_weights(h) # # Used to display the start and end date of historical price dataframe # self.Debug("Date 1: " + str(h.iloc[:1].index)) # self.Debug("Date 2: " + str(h.iloc[-1:].index)) # trade self.SetHoldings([PortfolioTarget(x, y) for x, y in zip(weights.index, weights.values) if x in data and data[x]]) def trade_weights(self, hist:DataFrame) -> pd.Series: # initialize weights series weights:pd.Series = pd.Series(0, index=hist.columns) # end of month values h_eom:DataFrame = hist.loc[hist.groupby(hist.index.to_period("M")).apply(lambda x: x.index.max())].iloc[:-1, :] # Check if protective universe is triggered. # build dataframe of momentum values mom:DataFrame = (h_eom.iloc[-1, :].div(h_eom.iloc[[-p - 1 for p in self.prds], :], axis=0) - 1) mom = mom.loc[:, self.protective].T # determine number of protective securities with negative weighted momentum n_protective:float = np.sum(np.sum(mom.values * self.prdweights, axis=1) < 0) # % equity offensive pct_in:float = 1 - min(1, n_protective / self.B) # Get weights for offensive and defensive universes. # determine weights of offensive universe if pct_in > 0: # price / SMA mom_in = h_eom.iloc[-1, :].div(h_eom.iloc[[-t for t in range(1, self.LO + 1)]].mean(axis=0), axis=0) mom_in = mom_in.loc[self.offensive].sort_values(ascending=False) # equal weightings to top relative momentum securities in_weights = pd.Series(pct_in / self.TO, index=mom_in.index[:self.TO]) weights = pd.concat([weights, in_weights]) # determine weights of defensive universe if pct_in < 1: # price / SMA mom_out = h_eom.iloc[-1, :].div(h_eom.iloc[[-t for t in range(1, self.LD + 1)]].mean(axis=0), axis=0) mom_out = mom_out.loc[self.all_defensive].sort_values(ascending=False) # equal weightings to top relative momentum securities out_weights = pd.Series((1 - pct_in) / self.TD, index=mom_out.index[:self.TD]) weights = pd.concat([weights, out_weights]) weights:pd.Series = weights.groupby(weights.index).sum() return weights