Quant BuffetRelax, Not Over Thinking

Keller’s & Keunig’s Vigilant Asset Allocation

Log in to collect

Academic paper

Breadth Momentum and Vigilant Asset Allocation (VAA): Winning More by Losing Less

AuthorsWouter J. Keller; Jan Willem Keuning

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

Strategy in a nutshell

The portfolio invests in 12 global ETFs and 3 cash-like bond assets, using a 13612W relative momentum filter. The top-performing assets (T=2) are equally weighted, while non-positive momentum assets are replaced by cash. Crash protection is implemented via breadth: if the number of “bad” assets exceeds the threshold B=4, cash allocation increases proportionally. The long-only portfolio is rebalanced monthly.

Economic rationale

VAA-G12 uses universe-level breadth momentum for crash protection, improving on individual absolute momentum approaches like PAA. Counting bad assets relative to a threshold provides granular risk control, enhancing performance during bear markets and improving overall asset allocation.

Backtest performance

Annualised return15.6%
Volatility10.2%
Beta0.078
Sharpe ratio0.85
Sortino ratio0.462
Maximum drawdown-13%
Win rate69%

Full Python code

from AlgorithmImports import *
from typing import List, Dict
#endregion

class VigilantAssetAllocation(QCAlgorithm):

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

# VAA-G12 risk investment universe assets
self.growth_symbols:List[str] = [
    "SPY", "IWM", "QQQ", "VGK", 
    "EWJ", "EEM", "EFA", "ACWX", 
    "IYR", "GSG", "GLD", "SHY", 
    "IEF", "TLT", "LQD", "HYG", "AGG"
    ]

# cash universe assets
self.safety_symbols:List[str] = ["LQD", "IEF", "SHY"]

momentum_periods:List[int] = [21, 63, 126, 252]
self.score_weights:np.ndarray = np.array([12, 4, 2, 1])

self.SetWarmUp(momentum_periods[-1], Resolution.Daily)

self.symbol_data:Dict[List] = {}
self.B:int = 4
self.T:int = 2
self.leverage:int = 3

for ticker in self.growth_symbols + self.safety_symbols:
    data:Equity = self.AddEquity(ticker, Resolution.Minute)
    data.SetLeverage(self.leverage)
    self.symbol_data[ticker] = [self.MOMP(ticker, period, Resolution.Daily) for period in momentum_periods]

self.recent_month:int = -1

def OnData(self, data:Slice) -> None:
# monthly rebalance
if self.recent_month == self.Time.month:
    return
self.recent_month = self.Time.month

if self.IsWarmingUp: return

# this approach overweights the front month momentum value and progressively underweights older momentum values
growth_score:Dict[str, float] = { ticker : sum(np.array([x.Current.Value for x in momentum_indicators]) * self.score_weights) for ticker, momentum_indicators in self.symbol_data.items() 
                                if ticker in self.growth_symbols if all(ind.IsReady for ind in momentum_indicators) }

safe_score:Dict[str, float] = { ticker : sum(np.array([x.Current.Value for x in momentum_indicators]) * self.score_weights) for ticker, momentum_indicators in self.symbol_data.items() 
                                if ticker in self.safety_symbols if all(ind.IsReady for ind in momentum_indicators) }

# wait until all the assets' indicators are warmed-up
if len(growth_score) != len(self.growth_symbols) or len(safe_score) != len(self.safety_symbols):
    return

sorted_growth:List = sorted(growth_score.items(), key=lambda x: x[1], reverse=True)
sorted_safe:List = sorted(safe_score.items(), key=lambda x: x[1], reverse=True)

# count the number of risky assets with negative momentum scores and store in b
b:int = sum(1 for x in sorted_growth if x[1] < 0)
CF:float = float(b) / float(self.B)
        
# select two offensive asset with the highest score and allocate 25% of the portfolio to that asset at the close
# pick two best from risky and one risk-free symbols
best_growth:List[str] = [x[0] for x in sorted_growth][:self.T]
best_safe:str = sorted_safe[0][0]

# if more than four risky assets exhibit negative momentum scores, select the risk-free asset (LQD, IEF or SHY) with the highest score
if b >= self.B:
    # liquidate
    invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
    for ticker in invested:
        if ticker != best_safe:
            self.Liquidate(ticker)

    self.SetHoldings(best_safe, 1)

# if none of the risky assets come back with negative momentum scores, allocation 100% to the 2 best scoring risky assets
else:
    # liquidate
    invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
    for ticker in invested:
        if ticker not in best_growth + [best_safe]:
            self.Liquidate(ticker)

    for ticker in best_growth:
        self.SetHoldings(ticker, (1-CF) / 2) 
    
    self.SetHoldings(best_safe, CF)