Quant BuffetRelax, Not Over Thinking

Keller’s & Keunig’s Defensive Asset Allocation

Log in to collect

Academic paper

Breadth Momentum and the Canary Universe: Defensive Asset Allocation (DAA)

AuthorsWouter J. Keller; Jan Willem Keuning

Institute
  • NLVrije Universiteit Amsterdam
  • ?VU University Amsterdam
  • ?TrendXplorer

Strategy in a nutshell

DAA invests across three universes: risky (12 global ETFs), protective/canary (VWO and BND), and cash (BIL, IEF, LQD). Using 13612W momentum, the number of “bad” canary assets (b) determines cash allocation: CF = b/B (max 100%). Depending on canary signals, the portfolio splits between top T risky assets and cash, with equal-weighting. Rebalancing is monthly.

Economic rationale

Canary assets serve as early warning indicators. Bad momentum in VWO/BND signals potential market downturns, likely reflecting emerging market sensitivity, currency effects, or broader market stress. The approach reduces downside risk while maintaining exposure to top-performing risky assets

Backtest performance

Annualised return16%
Beta0.216
Sortino ratio0.53
Maximum drawdown-10.6%
Win rate73%

Full Python code

from AlgorithmImports import *
from typing import List, Dict
import numpy as np
# endregion

class DefensiveAssetAllocation(QCAlgorithm):

def Initialize(self):
self.SetCash(100000)
self.SetStartDate(2008, 1, 1)

self.risky_topT:int = 6
self.half_risky_topT:int = 3

self.risky_universe:List[str] = [
    "SPY", "IWM",
    "QQQ", "VGK",
    "EWJ", "VWO",
    "VNQ", "GSG",
    "GLD", "TLT",
    "HYG", "LQD",
]

self.cash_universe:List[str] = ["BIL", "IEF", "LQD"]
self.canary_universe:List[str] = ["VWO", "BND"]
self.all_tickers:List[str] = self.risky_universe + self.cash_universe + self.canary_universe

self.period_list:List[int] = [21, 63, 126, 252]
self.objective_score_weights:np.ndarray = [12., 4., 2., 1.]

self.SetWarmUp(max(self.period_list), Resolution.Daily)

self.momp_data_by_ticker:Dict[str, List[MomentumPercent]] = {}

# subscribe data
for ticker in self.all_tickers:
    self.AddEquity(ticker, Resolution.Daily)
    self.momp_data_by_ticker[ticker] = [self.MOMP(ticker, period, Resolution.Daily) for period in self.period_list]

self.recent_month:int = -1

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

if self.recent_month == self.Time.month:
    return
self.recent_month = self.Time.month

# rank all the risky and cash symbol groups by momentum score
sorted_risky:List = sorted([item for item in self.momp_data_by_ticker.items() if item[0] in self.risky_universe and
                            all(indicator.IsReady for indicator in item[1])],   # all indicators for ticker are ready
                            key=lambda x: np.dot(np.array([momentum.Current.Value for momentum in x[1]]), self.objective_score_weights),
                            reverse=True)

best_growth:List[str] = [x[0] for x in sorted_risky[:self.risky_topT]]
second_best_growth:List[str] = best_growth[:self.half_risky_topT]

sorted_cash:List = sorted([item for item in self.momp_data_by_ticker.items() if item[0] in self.cash_universe and
                            all(indicator.IsReady for indicator in item[1])],   # all indicators for ticker are ready
                            key=lambda x: np.dot(np.array([momentum.Current.Value for momentum in x[1]]), self.objective_score_weights),
                            reverse=True)
if len(sorted_cash) < 1:
    self.Liquidate()
    return

best_cash:str = sorted_cash[0][0]

# calculate the momentum score of the canary symbols
canary_scores:List[float] = [np.dot(np.array([momentum.Current.Value for momentum in item[1]]), self.objective_score_weights) \
                for item in self.momp_data_by_ticker.items() if item[0] in self.canary_universe and \
                all(indicator.IsReady for indicator in item[1])]

if len(canary_scores) != 2:
    self.Liquidate()
    return

weight:Dict[str, float] = {}
if all(x < 0 for x in canary_scores):
    weight[best_cash] = 1.

elif all(x > 0 for x in canary_scores):
    weight = { x : 1. / float(len(best_growth)) for x in best_growth}

elif any(x < 0 for x in canary_scores):
    weight[best_cash] = .5
    for x in second_best_growth:
        weight[x] = .5 / float(len(second_best_growth))

# liquidate
invested:List[str] = [x.Symbol.Value for x in self.Portfolio.Values if x.Invested]
for ticker in invested:
    if ticker not in weight:
        self.Liquidate(ticker)

# new trade execution / rebalance
for ticker, w in weight.items():
    if ticker in data and data[ticker]:
        self.SetHoldings(ticker, w)