
The strategy involves trading 10Y bonds based on z-scores of 12-month equity returns. Positions are taken long or short depending on the z-score’s sign and magnitude, with monthly rebalancing.
ASSET CLASS: bonds, futures | REGION: Global | FREQUENCY:
Monthly | MARKET: bonds | KEYWORD: Equity Return
I. STRATEGY IN A NUTSHELL
The strategy trades 10-year government bonds from the U.S., U.K., Germany, Japan, Canada, and Australia. Positions are determined by the z-score of past 12-month equity returns (12-month return minus 10-year average, divided by standard deviation), capped at ±1. Positive z-scores → short bonds; negative z-scores → long bonds. Position size equals the absolute z-score. The portfolio is equally weighted and rebalanced monthly. Variants include using only the sign or uncapped z-scores.
II. ECONOMIC RATIONALE
Equities and bonds act as substitutes: strong stock performance lowers bond demand (prices drop), and vice versa in downturns. The strategy leverages this predictable inverse relationship, producing robust returns across economic cycles. Returns are larger during significant market moves, reflecting true market behavior rather than structural bond risk.
III. SOURCE PAPER
Predicting Bond Returns: 70 years of International Evidence [Click to Open PDF]
Guido Baltussen, Martin Martens, Olaf Penninga, Erasmus University Rotterdam (EUR); Northern Trust Corporation – Northern Trust Asset Management, Erasmus University Rotterdam, Robeco Asset Management
<Abstract>
We examine the predictability of government bond returns using a deep sample spanning 70 years of international data across the major bond markets. Using an economic, trading-based testing framework we find strong economic and statistical evidence of bond return predictability with a Sharpe ratio of 0.87 since 1950. This finding is robust over markets and time periods, including 30 years of out-of-sample data on international bond markets and a set of nine additional countries. Furthermore, the results are consistent over economic environments, including prolonged periods of rising or falling rates, and is exploitable after transaction costs. The predictability relates to predictability in inflation and economic growth. Overall, government bond premia display predictable dynamics and the timing of international bond market returns offers exploitable opportunities to investors.


IV. BACKTEST PERFORMANCE
| Annualised Return | 3.8% |
| Volatility | 10% |
| Beta | -0.068 |
| Sharpe Ratio | 0.38 |
| Sortino Ratio | -0.101 |
| Maximum Drawdown | N/A |
| Win Rate | 69% |
V. FULL PYTHON CODE
import numpy as np
from collections import deque
from AlgorithmImports import *
class PredictingBondReturnswithEquityReturn(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
# Bond future and equity etf.
self.symbols = [
("ASX_XT1", 'EWA'), # 10 Year Commonwealth Treasury Bond Futures, Continuous Contract #1 (Australia)
("MX_CGB1", 'EWC'), # Ten-Year Government of Canada Bond Futures, Continuous Contract #1 (Canada)
("EUREX_FGBL1", 'EWG'), # Euro-Bund (10Y) Futures, Continuous Contract #1 (Germany)
("LIFFE_R1", 'EWU'), # Long Gilt Futures, Continuous Contract #1 (U.K.)
("SGX_JB1", 'EWJ'), # SGX 10-Year Mini Japanese Government Bond Futures, Continuous Contract #1 (Japan)
("CME_TY1", 'SPY') # 10 Yr Note Futures, Continuous Contract #1 (USA)
]
# Monthly price data.
self.data = {}
self.month_period = 5
self.period = self.month_period * 12 + 1
self.SetWarmUp(self.period * 21)
for bond_future, equity_etf in self.symbols:
data = self.AddData(QuantpediaFutures, bond_future, Resolution.Daily)
data.SetFeeModel(CustomFeeModel())
data.SetLeverage(10)
# Equity data.
self.AddEquity(equity_etf, Resolution.Daily)
self.data[equity_etf] = deque(maxlen = self.period)
self.last_month = -1
self.Schedule.On(self.DateRules.MonthStart(self.symbols[0][1]), self.TimeRules.At(0, 0), self.Rebalance)
def OnData(self, data):
# Update only on new month start.
if self.Time.month == self.last_month:
return
self.last_month = self.Time.month
# Store monthly data.
for bond_future, equity_etf in self.symbols:
if equity_etf in data and data[equity_etf]:
price = data[equity_etf].Value
self.data[equity_etf].append(price)
def Rebalance(self):
# Z score calc.
weight = {}
for bond_future, equity_etf in self.symbols:
if self.Securities[bond_future].GetLastData() and self.time.date() < QuantpediaFutures.get_last_update_date()[bond_future]:
# At least 3 years of data is ready.
minimum_data_count = ((self.period-1) / self.month_period) * 3
if len(self.data[equity_etf]) >= minimum_data_count:
closes = [x for x in self.data[equity_etf]]
separete_yearly_returns = [Return(closes[x:x+13]) for x in range(0, len(closes),1)]
return_mean = np.mean(separete_yearly_returns)
return_std = np.std(separete_yearly_returns)
z_score = (separete_yearly_returns[-1] - return_mean) / return_std
if z_score > 1: z_score = 1
elif z_score < -1: z_score = -1
weight[bond_future] = -1 * z_score
# Trade execution
invested = [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, weight in weight.items():
self.SetHoldings(symbol, weight)
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFutures(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return QuantpediaFutures._last_update_date
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
data = QuantpediaFutures()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
data['back_adjusted'] = float(split[1])
data['spliced'] = float(split[2])
data.Value = float(split[1])
if config.Symbol.Value not in QuantpediaFutures._last_update_date:
QuantpediaFutures._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
if data.Time.date() > QuantpediaFutures._last_update_date[config.Symbol.Value]:
QuantpediaFutures._last_update_date[config.Symbol.Value] = data.Time.date()
return data
def Return(values):
return (values[-1] - values[0]) / values[0]