Resilient Asset Allocation
Log in to collectAcademic paper
Lazy Momentum with Growth-Trend timing: Resilient Asset Allocation (RAA)
Wouter J. Keller
- NLVrije Universiteit Amsterdam
- ?VU University Amsterdam
Strategy in a nutshell
The portfolio consists of a 5-asset risky basket (QQQ, IWN, IEF, TLT, GLD) equally weighted, switched to a 2-asset cash basket (IEF, TLT) when both the unemployment trend (UE12) and canary assets (VWO, BND) exhibit bearish signals. Monthly rebalancing is applied using RET12 and 13612W momentum filters.
Economic rationale
RAA combines principles from permanent portfolios and Defensive Asset Allocation to perform robustly across all economic regimes. Equal-weighting and conditional cash allocation reduce downside risk, lowering drawdowns by up to two-thirds compared to a 60/40 benchmark while maintaining strong returns.
Backtest performance
Annualised return12.3%
Volatility8.9%
Beta0.328
Sharpe ratio1.38
Sortino ratio0.515
Maximum drawdown-11.8%
Win rate89%
Full Python code
from AlgorithmImports import *
from typing import List
from dateutil.relativedelta import relativedelta, FR
# endregion
class ResilientAssetAllocation(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
# set assets variables
self.static_universe:List[str] = ['QQQ', 'IWN', 'IEF', 'TLT', 'GLD']
self.cash_universe:List[str] = ['IEF', 'TLT']
self.canary_universe:List[str] = ['VWO', 'BND']
self.ue_period:int = 12
momentum_periods:List[int] = [21, 63, 126, 252]
self.score_weights:np.ndarray = np.array([12, 4, 2, 1])
self.symbol_data:Dict[List] = {}
# warm up of indicators
self.SetWarmup(self.ue_period * 31, Resolution.Daily)
for ticker in self.static_universe + self.cash_universe:
self.AddEquity(ticker, Resolution.Daily)
for ticker in self.canary_universe:
self.AddEquity(ticker, Resolution.Daily)
self.symbol_data[ticker] = [self.MOMP(ticker, period, Resolution.Daily) for period in momentum_periods]
# SMA assets
self.ue:Symbol = self.AddData(UnemploymentRate, 'UE', Resolution.Daily).Symbol
self.ue_window = RollingWindow[float](12)
def OnData(self, data: Slice) -> None:
if self.IsWarmingUp:
return
# calculate the momentum scores of the canary symbols
canary_score:List[float] = [np.dot(np.array([momentum.Current.Value for momentum in item[1]]), self.score_weights) \
for item in self.symbol_data.items() if item[0] in self.canary_universe and \
all(indicator.IsReady for indicator in item[1])]
# wait until all the assets' indicators are warmed-up
if len(canary_score) != len(self.canary_universe):
return
# rebalance when 'UE' data are in - monthly
if data.ContainsKey(self.ue) and data[self.ue]:
self.ue_window.Add(data[self.ue].Value)
if not self.ue_window.IsReady:
return
if self.ue_window.IsReady:
invested_tickers:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
traded_universe:List[str] = self.static_universe if not self.Portfolio.Invested else invested_tickers
# change allocation of assets
if not self.Portfolio.Invested:
# both trends are positive
if data[self.ue].Value > self.ue_window[self.ue_window.Count-1] and canary_score[0] < 0 and canary_score[1] < 0:
traded_universe = self.cash_universe
else:
traded_universe = self.static_universe
# firstly, liquidate symbols that should not be held
for ticker in invested_tickers:
if ticker not in traded_universe:
self.Liquidate(ticker)
# rebalance new portfolio
for ticker in traded_universe:
if ticker in data and data[ticker]:
self.SetHoldings(ticker, 1 / len(traded_universe))
else:
if self.Portfolio.Invested:
last_update_date:datetime.date = UnemploymentRate.get_last_update_date()
# custom data stopped comming in
if self.Securities[self.ue].GetLastData() and self.Time.date() > last_update_date:
self.Liquidate()
class UnemploymentRate(PythonData):
def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
return SubscriptionDataSource('data.quantpedia.com/backtesting_data/economic/UNEMPLOYMENT_RATE.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
_last_update_date:datetime.date = datetime(1,1,1).date()
@staticmethod
def get_last_update_date() -> datetime.date:
return UnemploymentRate._last_update_date
def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
data = UnemploymentRate()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
# Parse the CSV file's columns into the custom data class - first Friday of the month
data.Time = (datetime.strptime(split[0], '%Y-%m-%d').date() + relativedelta(weekday=FR(1))) + timedelta(days=1)
if data.Time.date() > UnemploymentRate._last_update_date:
UnemploymentRate._last_update_date = data.Time.date()
data.Value = float(split[1])
return data