Lethargic Asset Allocation
Log in to collectAcademic paper
Growth-Trend Timing and 60-40 Variations: Lethargic Asset Allocation (LAA)
Wouter J. Keller
- 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