
The strategy trades 10+ year bonds using seasonal return patterns, going long on high-return months and short on low, rebalancing monthly, with futures recommended for better liquidity and cost efficiency.
ASSET CLASS: futures | REGION: Global | FREQUENCY:
Monthly | MARKET: bonds | KEYWORD: Seasonality
I. STRATEGY IN A NUTSHELL
The strategy trades 10+ year government bonds across 22 developed and emerging markets using a seasonal momentum signal based on average returns for the same calendar month over the past 20 years. A zero-cost portfolio goes long the top 20% and short the bottom 20% of bonds, rebalanced monthly. Futures are used to improve liquidity and minimize transaction costs.
II. ECONOMIC RATIONALE
Seasonal patterns in bond returns arise from cyclical investor behavior rather than fundamentals. These sentiment-driven anomalies persist due to limited arbitrage and high turnover. Trading through bond futures enhances liquidity and implementation efficiency, making the strategy both practical and profitable.
III. SOURCE PAPER
Cross-Sectional Seasonalities in International Government Bond Returns [Click to Open PDF]
Adam Zaremba.Montpellier Business School; Poznan University of Economics and Business; University of Cape Town (UCT)
<Abstract>
We are the first to document the cross-sectional return seasonality effect in international government bonds. Using a variety of tests, we examine fixed-income securities from 22 countries for the years 1980–2018. The bonds with high (low) returns in the same-calendar month in the past continue to overperform (underperform) in the future. The effect is robust to many considerations, including controlling for established predictors of bond returns. Our results support the behavioural story of the anomaly, demonstrating its highest profitability in the periods of elevated investor sentiment and in the market segments of strong limits to arbitrage. Nonetheless, investment application of bond seasonality might be challenging due to high trading costs and the required short holding periods.


IV. BACKTEST PERFORMANCE
| Annualised Return | 5.41% |
| Volatility | 11.07% |
| Beta | 0.015 |
| Sharpe Ratio | 0.49 |
| Sortino Ratio | -0.573 |
| Maximum Drawdown | N/A |
| Win Rate | 55% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import data_tools
from collections import deque
class SeasonalitiesBondReturns(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.symbols = {
"ASX_XT1", # 10 Year Commonwealth Treasury Bond Futures, Continuous Contract #1 (Australia)
"MX_CGB1", # Ten-Year Government of Canada Bond Futures, Continuous Contract #1 (Canada)
"EUREX_FOAT1", # Euro-OAT Futures, Continuous Contract #1 (France)
"EUREX_FGBL1", # Euro-Bund (10Y) Futures, Continuous Contract #1 (Germany)
"LIFFE_R1", # Long Gilt Futures, Continuous Contract #1 (U.K.)
"EUREX_FBTP1", # Long-Term Euro-BTP Futures, Continuous Contract #1 (Italy)
"CME_TY1", # 10 Yr Note Futures, Continuous Contract #1 (USA)
"SGX_JB1" # SGX 10-Year Mini Japanese Government Bond Futures, Continuous Contract #1 (Japan)
}
# daily price data
self.data = {}
# monthly returns
self.monthly_return = {}
self.daily_period = 21
self.monthly_period = 20 * 12
self.traded_count = 1
for symbol in self.symbols:
# Bond future data.
data = self.AddData(data_tools.QuantpediaFutures, symbol, Resolution.Daily)
data.SetFeeModel(data_tools.CustomFeeModel())
data.SetLeverage(5)
self.data[symbol] = RollingWindow[float](self.daily_period)
self.monthly_return[symbol] = deque(maxlen=self.monthly_period)
self.settings.minimum_order_margin_portfolio_percentage = 0.
self.settings.daily_precise_end_time = False
self.rebalance_flag: bool = False
self.Schedule.On(self.DateRules.MonthEnd('ASX_XT1'), self.TimeRules.At(0, 0), self.Rebalance)
def OnData(self, data):
# store monthly future returns
for symbol in self.symbols:
if symbol in data and data[symbol]:
price = data[symbol].Value
self.data[symbol].Add(price)
if not self.rebalance_flag:
return
self.rebalance_flag = False
curr_month = self.Time.month
SAME = {}
# store monthly returns
for symbol in self.symbols:
if self.Securities[symbol].GetLastData() and self.Time.date() < data_tools.QuantpediaFutures.get_last_update_date()[symbol]:
if self.data[symbol].IsReady:
monthly_ret = self.data[symbol][0] / self.data[symbol][self.daily_period - 1] - 1
self.monthly_return[symbol].append((monthly_ret, curr_month))
# monthly returns are ready
if len(self.monthly_return[symbol]) >= self.monthly_period / 2:
next_month = curr_month+1 if curr_month < 12 else 1
same_month_returns = [x[0] for x in self.monthly_return[symbol] if x[1] == next_month]
SAME[symbol] = np.mean(same_month_returns)
else:
self.liquidate(symbol)
continue
long = []
short = []
if len(SAME) >= self.traded_count * 2:
# decile = int(len(SAME) / self.quantile)
# count = decile
# sorting by SAME
sorted_by_SAME = sorted(SAME.items(), key = lambda x: x[1], reverse = True)
long = [x[0] for x in sorted_by_SAME[:self.traded_count]]
short = [x[0] for x in sorted_by_SAME[-self.traded_count:]]
# order execution
targets: List[PortfolioTarget] = []
for i, portfolio in enumerate([long, short]):
for symbol in portfolio:
if symbol in data and data[symbol]:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / self.traded_count))
self.SetHoldings(targets, True)
def Rebalance(self):
self.rebalance_flag = True