Quant BuffetRelax, Not Over Thinking

Resilient Asset Allocation

Log in to collect

Academic paper

Lazy Momentum with Growth-Trend timing: Resilient Asset Allocation (RAA)

AuthorsWouter J. Keller

Institute
  • 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