Quant BuffetRelax, Not Over Thinking

Lethargic Asset Allocation

Log in to collect

Academic paper

Growth-Trend Timing and 60-40 Variations: Lethargic Asset Allocation (LAA)

AuthorsWouter J. Keller

Institute
  • NLVrije Universiteit Amsterdam
  • ?VU University Amsterdam

Strategy in a nutshell

The portfolio combines a risky basket (QQQ, IWD, GLD, IEF) and a cash basket (SHY, IWD, GLD, IEF), equally weighted and rebalanced monthly. Using SMA10 on SPY and SMA12 on the unemployment rate as filters, QQQ is temporarily replaced by SHY when both indicate negative trends. When SPY recovers, QQQ is reintroduced.

Economic rationale

GT Timing adapts equal-weighted all-season portfolio principles, maintaining balanced exposure across growth, recession, inflation, and deflation scenarios. Replacing SPY with QQQ in the risky portion captures tech-driven growth while managing downside risk during downturns.

Backtest performance

Annualised return10.5%
Volatility8.5%
Beta0.48
Sharpe ratio1.24
Sortino ratio0.696
Maximum drawdown-15%
Win rate94%

Full Python code

from AlgorithmImports import *
from typing import List
from dateutil.relativedelta import relativedelta, FR
# endregion

class LethargicAssetsAllocation(QCAlgorithm):

def Initialize(self) -> None:
 self.SetStartDate(2010, 1, 1)
 self.SetCash(100000)
 
 # set assets variables
 self.static_universe:List[str] = ['IWD', 'GLD', 'IEF']
 self.risky_asset:str = 'QQQ'
 self.safe_asset:str = 'SHY'
 
 self.spy_period:int = 10 * 21
 self.ue_period:int = 12

 # warm up of indicators
 self.SetWarmup(self.ue_period * 31, Resolution.Daily)

 for ticker in self.static_universe + [self.risky_asset, self.safe_asset]:
     self.AddEquity(ticker, Resolution.Daily)

 # SMA assets
 self.spy:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
 self.ue:Symbol = self.AddData(UnemploymentRate, 'UE', Resolution.Daily).Symbol

 self.spy_sma = self.SMA(self.spy, self.spy_period, Resolution.Daily)
 self.ue_sma = self.SMA(self.ue, self.ue_period, Resolution.Daily)

def OnData(self, data: Slice) -> None:
 if self.IsWarmingUp:
     return

 # rebalance when 'UE' data are in - monthly
 if data.ContainsKey(self.ue) and data.ContainsKey(self.spy) and data[self.spy] and data[self.ue]:
     # both SMA indicators are warmed up and ready
     if all(sma.IsReady for sma in [self.spy_sma, self.ue_sma]):
         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 negative - UE rate has risen
             if data[self.spy].Value < self.spy_sma.Current.Value and data[self.ue].Value > self.ue_sma.Current.Value:
                 traded_universe += [self.safe_asset]
             else:
                 traded_universe += [self.risky_asset]
         else:
             # SPY trend is positive
             if data[self.spy].Value > self.spy_sma.Current.Value:
                 if self.risky_asset not in traded_universe:
                     traded_universe = list(map(lambda x: x.replace(self.safe_asset, self.risky_asset), traded_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