
The strategy uses GDP growth gap factors to allocate 10-year bond futures across five countries, neutralizing directional bias with equal-weighted adjustments and rebalancing quarterly for systematic investment.
ASSET CLASS: CFDs, futures | REGION: Global | FREQUENCY:
Quarterly | MARKET: bonds | KEYWORD: Growth
I. 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.
II. 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.
III. SOURCE PAPER
Beyond Carry and Momentum in Government Bonds [Click to Open PDF]
Gava, Jerome and Lefebvre, William and Turc, Julien
<Abstract>
This article revisits recent literature on factor investing in government bonds, in particular regarding the definition of value and defensive investing. Using techniques derived from machine learning, the authors identify the key drivers of government bond futures and the groups of factors that are most genuinely relevant. Beyond carry and momentum, they propose an approach to defensive investing that considers the safe-haven nature of government bonds. These two main styles may be complemented by value and a reversal factor in order to achieve returns independently from broad movements in interest rates.
IV. BACKTEST PERFORMANCE
| Annualised Return | 1% |
| Volatility | 9.99% |
| Beta | -0.004 |
| Sharpe Ratio | 0.1 |
| Sortino Ratio | -1.53 |
| Maximum Drawdown | -39% |
| Win Rate | 41% |
V. 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)