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.

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 Return3.8%
Volatility10%
Beta-0.068
Sharpe Ratio0.38
Sortino Ratio-0.101
Maximum DrawdownN/A
Win Rate69%

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]

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading