Quant BuffetRelax, Not Over Thinking

Growth Gap Factor in Fixed Income

Log in to collect

Academic paper

Beyond Carry and Momentum in Government Bonds

AuthorsJérôme Gava; William Lefebvre; Julien Turc

Institute
  • École Polytechnique
  • BNP Paribas (France)
  • ?BNP Paribas
  • ?Ecole Polytechnique
  • ?Laboratoire de Probabilités, Statistique et Modélisation
  • ?Laboratoire de Probabilités, Statistique et Modélisation (LPSM)

Strategy in a nutshell

The strategy trades 10-year government bond futures from Australia, Canada, Germany, the UK, and the US using a GDP growth gap factor (3-year gap, 1-year gap, or 1-year change). Allocations follow bottom, median, or top approaches, or a single-factor focus. Portfolios are built by proportionally buying/selling futures based on cross-sectional scores relative to the average, adjusted for dispersion. Equal-weighted allocations across countries neutralize directional bias. Due to data availability, portfolios are rebalanced quarterly, leveraging GDP growth trends for systematic bond decisions.

Economic rationale

Economic growth and bonds are connected, as real rates tend to fluctuate around growth and short-term rates are influenced by central banks to steer output. While long-term rates are primarily driven by debt/GDP, growth alone shows inconsistent influence. Statistical and machine learning analyses reveal the GDP growth gap as a reliable predictor for bond futures, making it an effective tool for portfolio construction and improving investment outcomes.

Backtest performance

Annualised return1%
Volatility9.99%
Beta-0.004
Sharpe ratio0.1
Sortino ratio-1.53
Maximum drawdown-39%
Win rate41%

Full Python code

import numpy as np
from AlgorithmImports import *
import data_tools
from typing import Dict, List
class GrowthGapFactorinFixedIncome(QCAlgorithm):
def Initialize(self):
self.SetStartDate(1990, 1, 1)
self.SetCash(100000)

# Bond symbol and GDP symbol. (GDP at current prices)
self.symbols = {
    "ASX_XT1" : "AUS_GDP",        # 10 Year Commonwealth Treasury Bond Futures, Continuous Contract #1 (Australia)
    "MX_CGB1" : "CAN_GDP",        # Ten-Year Government of Canada Bond Futures, Continuous Contract #1 (Canada)
    "EUREX_FGBL1" : "DEU_GDP",    # Euro-Bund (10Y) Futures, Continuous Contract #1 (Germany)
    "LIFFE_R1" : "GBR_GDP",       # Long Gilt Futures, Continuous Contract #1 (U.K.)
    "CME_TY1" : "USA_GDP"         # 10 Yr Note Futures, Continuous Contract #1 (USA)
}

# Yearly GDP data used for SMA.
self.data:Dict[str, float] = {}
self.sma_period:int = 5
self.leverage:int = 3

for bond_future, gdp_symbol in self.symbols.items():
    # Futures data.
    data = self.AddData(data_tools.QuantpediaFutures, bond_future, Resolution.Daily)
    data.SetFeeModel(data_tools.CustomFeeModel())
    data.SetLeverage(self.leverage)
    self.data[gdp_symbol] = RollingWindow[float](self.sma_period)
    
    # Bond yield data.
    self.AddData(data_tools.GDPData, gdp_symbol, Resolution.Daily)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.

def OnData(self, data: Slice) -> None:
trade_flag:bool = False
gdp_last_update_date:Dict[str, datetime.date] = data_tools.GDPData.get_last_update_date()
future_last_update_date:Dict[str, datetime.date] = data_tools.QuantpediaFutures.get_last_update_date()
symbols_to_delete:List[str] = []
# store yearly gdp data
for bond_future, gdp_symbol in self.symbols.items():
    # data is still coming
    if self.Securities[bond_future].GetLastData() and self.Time.date() > future_last_update_date[bond_future] \
        or self.Securities[gdp_symbol].GetLastData() and self.Time.date() > gdp_last_update_date[gdp_symbol]:
        symbols_to_delete.append(bond_future)
        continue
    if gdp_symbol in data and data[gdp_symbol]:
        gdp:float = data[gdp_symbol].Value
        self.data[gdp_symbol].Add(gdp)
        trade_flag = True
    
if len(symbols_to_delete) != 0:
    for symbol in symbols_to_delete:
        self.symbols.pop(symbol)
# rebalance once the new data arrived
if not trade_flag: 
    return

# SMA gap
sma_gap:Dict[str, float] = { x[0] : ((self.Securities[x[1]].Price - np.average([gdp for gdp in self.data[x[1]]])) / np.average([gdp for gdp in self.data[x[1]]])) for x in self.symbols.items()
                                    if self.Securities.ContainsKey(x[1]) and x[1] in self.data and self.data[x[1]].IsReady and self.Securities[x[0]].GetLastData() and (self.Time.date() - self.Securities[x[0]].GetLastData().Time.date()).days < 5}
weight:Dict[str, float] = {}
if len(sma_gap) != 0:
    avg_gap:float = np.average([x[1] for x in sma_gap.items()])
    
    avg_gap_diff:Dict[str, float] = {x[0] : x[1] - avg_gap for x in sma_gap.items()}
    total_avg_gap_diff:float = sum([abs(x[1]) for x in avg_gap_diff.items()])
    weight = {x[0] : x[1] / total_avg_gap_diff for x in avg_gap_diff.items()}

# trade execution
invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
    if symbol not in weight:
        self.Liquidate(symbol)
for symbol, w in weight.items():
    if symbol in data and data[symbol]:
        self.SetHoldings(symbol, w)