Abstract
In this tutorial we implement a correlation-adjusted time-series momentum strategy (TSMOM-CF) that addresses three weaknesses typically found in traditional time-series momentum strategies (TSMOM). Our implementation is based on the paper "Demystifying Time-Series Momentum Strategies: Volatility Estimators, Trading Rules and Pairwise Correlations" by Nick Baltas and Robert Kosowski. We will also compare TSMOM-CF to the basic momentum strategy implemented in our strategy library - Momentum Effect in Commodities Futures.
Introduction
Baltas and Kosowski modify the basic momentum strategy by incorporating trend strength into the trading signal, using an efficient volatility estimator, and adding a dynamic leverage mechanism. The modifications overcome these three weaknesses:
- An Oversimplified Trading Signal: The traditional time-series momentum strategy (TSMOM) results in high portfolio turnover which, after accounting for transaction costs, leads to diminished performance. Baltas and Kosowski attribute the traditional strategy's extreme long/short positions to an oversimplified trading signal whose values are a discrete +1 or -1. The traditional trading signal is based on the sign of the past 12-month average simple return. Baltas and Kosowski propose a trading signal with a continuous value between +1 and -1. Their signal is a statistical measure that reflects the strength of the price trend.
- An Inefficient Volatility Estimator: The TSMOM generally scales asset positions using the estimated volatility of portfolio constituents. The traditional strategy's volatility estimator is the standard deviation of past daily close-to-close returns, which is subject to large estimation errors. Baltas and Kosowski demonstrate that a more efficient volatility estimator can significantly reduce portfolio turnover which, after taking into account transaction costs, boosts the portfolio performance. They present the Yang and Zhang volatility estimator, a range-based estimator that considers the open, high, low, and close prices of assets. The next section will discuss this estimator in greater detail.
- A Fixed Portfolio Allocation Mechanism: The TSMOM does not consider the correlation between assets during portfolio construction. It simply allocates funds to each asset based on the properties of the individual assets. Strategies based on TSMOM significantly underperform in the post-2008 global financial crisis (GFC) period due to the increased level of asset co-movement at the time. As a remedy, Baltas and Kosowski introduce a dynamic leverage adjustment for the overall portfolio by adding a correlation factor to the weighting scheme.
TSMOM-CF Theory
Baltas and Kosowski's modifications to the basic time-series momentum strategy can be summarized in the formula below:
\[r_{t,t+1}^{TSMOM-CF} = \frac{1}{N_t} \sum_{i=1}^{N_t} X_t^i \frac{\sigma_{P,tgt}}{\sigma_t^i} CF(\bar{\rho}_t)r_{t,t+1}^i\]
where:
- \(r_{t,t+1}^{TSMOM-CF}\) = TSMOM-CF portfolio return from time \(t\) to time \(t+1\)
- \(N_t\) = Number of portfolio constituents at time \(t\)
- \(X_t^i\) = Trading signal value of asset \(i\) at time \(t\)
- \(\sigma_{P,tgt}\) = Target level of volatility for the overall portfolio
- \(\sigma_t^i\) = Estimated volatility of asset \(i\) at time \(t\)
-
\(CF(\bar{\rho}_t)\) = Correlation factor that adjusts the level of leverage applied to each portfolio constituents at time \(t\)
-
\(r_{t,t+1}^i\) = Return of asset \(i\) from time \(t\) to time \(t+1\)
The formula shows that the weights for each portfolio constituent are dependent on three parts:
Part I: Trading Rule Adjustment (\(X_t^i\))
The TREND trading rule determines the trading signal based on the statistical strength of the realized return:
\[ \text{TREND}_i^{12M} \quad \begin{cases} +1, \text{ if } t(r_{t-12,t})>+1 \\ t(r_{t-12,t}), \text{ otherwise} \\ -1, \text{ if } t(r_{t-12,t})<-1 \\ \end{cases} \]
where \(t()\) is the t-statistic of the daily futures log-returns over the past 12 months to scale the gross exposure to each portfolio constituents.
When the absolute value of our t-statistic is greater than 1, the trend is highly statistically significant, so the strategy puts 100% exposure to the asset. When the t-statistic is between -1 and 1, the strength of the trend is not as significant, so the strategy scales its exposure to less than 100%.
Part II: Yang and Zhang Volatility Estimato(\(\sigma_{YZ}\))
Instead of estimating each asset's volatility as the standard deviation of past close-to-close daily logarithmic returns, Baltas and Kosowski adopt a more efficient volatility estimator proposed by Yang and Zhang (2000). The formula for the Yang and Zhang volatility estimator (\(\sigma_{YZ}\)) is shown below:
\[\begin{equation} \begin{aligned} \sigma_{YZ}^2(t) = \ & \sigma_{OJ}^2(t) + k \sigma_{SD}^2(t) \\ & + (1-k) \sigma_{RS}^2(t) \end{aligned} \end{equation}\]
where:
- \(\sigma_{OJ}\) = Overnight jump estimator (standard deviation of close-to-open daily logarithmic returns)
- \(\sigma_{SD}\) = Standard volatility estimator (standard deviation of close-to-close daily logarithmic returns)
- \(\sigma_{RS}\) = Rogers and Satchell (1991) range estimator
- \(k\) = parameter that minimizes YZ estimator variance, which is a function of the numbers of days in the estimation
The formula for parameter \(k\) is below:
\[k = \frac{0.34}{1.34+\frac{N_D+1}{N_D-1}}\]
The Rogers and Satchell range estimator calculation is based on the following formula:
\[\begin{equation} \begin{aligned} \sigma_{RS}^2(\tau) = \ & h(\tau)[h(\tau)-c(\tau)] \\ & +l(\tau)[l(\tau)-c(\tau)] \end{aligned} \end{equation}\]
where \(h(\tau)\), \(l(\tau)\), and \(c(\tau)\) denote the logarithmic difference between the high, low and closing prices respectively with the opening price. The \(RS\) volatility of an asset at the end of month \(t\), assuming a certain estimation period, is equal to the average daily \(RS\) volatility over this period.
The estimation period is chosen to be 1 month, or 21 trading days, based on Baltas and Kosowski's suggestions.
Part III: Correlation Factor (CF)
Baltas and Kosowski's correlation factor (CF) is a function of \(\bar{\rho}\), which is the average pairwise signed correlation of all portfolio constituents. The calculations are shown below:
\[CF(\bar{\rho}) = \sqrt{\frac{N}{1+(N-1)\bar{\rho}}}\] \[\bar{\rho} = 2 \frac{\sum_{i=1}^N \sum_{j=i+1}^N X_i X_j \rho_{i,j}}{N(N-1)}\]
where:
- \(N\) = number of assets in the portfolio
- \(\rho_{i,j}\) = correlation between asset \(i\), \(j\)
- \(X_i\) = trade signal of asset \(i\)
- \(\bar{\rho}\) = average pairwise signed correlation for the entire portfolio
Method
We manually create a universe of tradable commodity Futures from all available commodity Futures traded on CME and ICE. The subscribed data resolution is daily.
Step 1: Import the data
class ImprovedCommodityMomentumTrading(QCAlgorithm):
def initialize(self):
for ticker in tickers:
future = self.add_future(ticker,
resolution = Resolution.DAILY,
extended_market_hours = True,
data_normalization_mode = DataNormalizationMode.BACKWARDS_RATIO,
data_mapping_mode = DataMappingMode.OPEN_INTEREST,
contract_depth_offset = 0
)
future.set_leverage(3) # Leverage was set to 3 for each of the futures contract
Step 2: Set the portfolio target volatility and decide rebalance schedule
def initialize(self):
# Last trading date tracker to achieve rebalancing the portfolio every month
self.rebalancing_time = self.time
# Set portfolio target level of volatility, set to 12%
self.portfolio_target_sigma = 0.12
Step 3: Implement functions to calculate the three components of Baltas and Kosowski weights
1. TREND Trade Signal
def get_trading_signal(self, history):
'''TREND Trading Signal
- Uses the t-statistics of historical daily log-returns to reflect the strength of price movement trend
- TREND Signal Conditions:
t-stat > 1 => TREND Signal = 1
t-stat < 1 => TREND Signal = -1
-1 < t-stat < 1 => TREND Signal = t-stat
'''
settle = history.unstack(level = 0)['close']
settle = settle.groupby([x.date() for x in settle.index]).last()
# daily futures log-returns based on close-to-close
log_returns = np.log(settle/settle.shift(1)).dropna()
# Calculate the t-statistics as
# (mean-0)/(stdev/sqrt(n)), where n is sample size
mean = np.mean(log_returns)
std = np.std(log_returns)
n = len(log_returns)
t_stat = mean/(std/np.sqrt(n))
# cap holding at 1 and -1
return np.clip(t_stat, a_max=1, a_min=-1)
2. Yang and Zhang Volatility Estimator
def get_y_z_volatility(self, history, available_symbols):
'''Yang and Zhang 'Drift-Independent Volatility Estimation'
Formula: sigma__y_z^2 = sigma__o_j^2 + self.k * sigma__s_d^2 + (1-self.k)*sigma__r_s^2 (Equation 20 in [1])
where, sigma__o_j - (Overnight Jump Volitility estimator)
sigma__s_d - (Standard Volitility estimator)
sigma__r_s - (Rogers and Satchell Range Volatility estimator)
'''
y_z_volatility = []
time_index = history.loc[available_symbols[0]].index
#Calculate YZ volatility for each security and append to list
for ticker in available_symbols:
past_month_ohlc = history.loc[ticker].loc[time_index[-1]-timedelta(self.one_month):time_index[-1]].dropna()
open, high, low, close = past_month_ohlc.open, past_month_ohlc.high, past_month_ohlc.low, past_month_ohlc.close
estimation_period = past_month_ohlc.shape[0]
if estimation_period <= 1:
y_z_volatility.append(np.nan)
continue
# Calculate constant parameter k for Yang and Zhang volatility estimator
# using the formula found in Yang and Zhang (2000)
k = 0.34 / (1.34 + (estimation_period + 1) / (estimation_period - 1))
# sigma__o_j (overnight jump => stdev of close-to-open log returns)
open_to_close_log_returns = np.log(open/close.shift(1))
open_to_close_log_returns = open_to_close_log_returns[np.isfinite(open_to_close_log_returns)]
sigma__o_j = np.std(open_to_close_log_returns)
# sigma__s_d (standard deviation of close-to-close log returns)
close_to_close_log_returns = np.log(close/close.shift(1))
close_to_close_log_returns = close_to_close_log_returns[np.isfinite(close_to_close_log_returns)]
sigma__s_d = np.std(close_to_close_log_returns)
# sigma__r_s (Rogers and Satchell (1991))
h = np.log(high/open)
l = np.log(low/open)
c = np.log(close/open)
sigma__r_s_daily = (h * (h - c) + l * (l - c))**0.5
sigma__r_s_daily = sigma__r_s_daily[np.isfinite(sigma__r_s_daily)]
sigma__r_s = np.mean(sigma__r_s_daily)
# daily Yang and Zhang volatility
sigma__y_z = np.sqrt(sigma__o_j**2 + k * sigma__s_d**2 + (1 - k) * sigma__r_s**2)
# append annualized volatility to the list
y_z_volatility.append(sigma__y_z*np.sqrt(252))
return y_z_volatility
3. Correlation Factor (CF)
def get_correlation_factor(self, history, trade_signals, available_symbols):
'''Calculate the Correlation Factor, which is a function of the average pairwise correlation of all portfolio contituents
- the calculation is based on past three month pairwise correlation
- Notations:
rho_bar - average pairwise correlation of all portfolio constituents
CF_rho_bar - the correlation factor as a function of rho_bar
'''
# Get the past three month simple daily returns for all securities
settle = history.unstack(level = 0)['close']
settle = settle.groupby([x.date() for x in settle.index]).last()
past_three_month_returns = settle.pct_change().loc[settle.index[-1]-timedelta(self.three_months):]
# Get number of assets
n_assets = len(available_symbols)
# Get the pairwise signed correlation matrix for all assets
correlation_matrix = past_three_month_returns.corr()
# Calculate rho_bar
summation = 0
for i in range(n_assets-1):
for temp in range(n_assets - 1 - i):
j = i + temp + 1
x_i = trade_signals[i]
x_j = trade_signals[j]
rho_i_j = correlation_matrix.iloc[i,j]
summation += x_i * x_j * rho_i_j
# Equation 14 in [1]
rho_bar = (2 * summation) / (n_assets * (n_assets - 1))
# Calculate the correlation factor (CF_rho_bar)
# Equation 18 in [1]
return np.sqrt(n_assets / (1 + (n_assets - 1) * rho_bar))
Step 4: Construct/Rebalance the Portfolio
For efficiency purposes, a history request is called once on each rebalance date to get all the data from the past year for all securities. We retrieve our trade signal, Yang and Zhang volatility, and correlation factor by passing the history data frame to each respective function.
In practice, we need to handle rollover events of continuous futures' mapping (data.symbol_changed_events
), as well as adjust the order size by the contract multiplier of each future contract, which can be fetched by self.securities[symbol_data.mapped].symbol_properties.contract_multiplier
.
def on_data(self, data):
'''
Monthly rebalance at the beginning of each month.
Portfolio weights for each constituents are calculated based on Baltas and Kosowski weights.
'''
# Rollover for future contract mapping change
for symbol_data in self.symbol_data.values():
if data.symbol_changed_events.contains_key(symbol_data.symbol):
changed_event = data.symbol_changed_events[symbol_data.symbol]
old_symbol = changed_event.old_symbol
new_symbol = changed_event.new_symbol
tag = f"Rollover - Symbol changed at {self.time}: {old_symbol} -> {new_symbol}"
quantity = self.portfolio[old_symbol].quantity
# Rolling over: to liquidate any position of the old mapped contract and switch to the newly mapped contract
self.liquidate(old_symbol, tag = tag)
self.market_order(new_symbol, quantity // self.securities[new_symbol].symbol_properties.contract_multiplier, tag = tag)
# skip if less than 30 days passed since the last trading date
if self.time < self.rebalancing_time:
return
'''Monthly Rebalance Execution'''
# dataframe that contains the historical data for all securities
history = self.history([x.symbol for x in self.symbol_data.values()], self.one_year, Resolution.DAILY)
history = history.droplevel([0]).replace(0, np.nan)
# Get the security symbols are are in the history dataframe
available_symbols = list(set(history.index.get_level_values(level = 0)))
if len(available_symbols) == 0:
return
# Get the trade signals and YZ volatility for all securities
trade_signals = self.get_trading_signal(history)
volatility = self.get_y_z_volatility(history, available_symbols)
# Get the correlation factor
c_f_rho_bar = self.get_correlation_factor(history, trade_signals, available_symbols)
# Rebalance the portfolio according to Baltas and Kosowski suggested weights
n_assets = len(available_symbols)
for symbol, signal, vol in zip(available_symbols, trade_signals, volatility):
# Baltas and Kosowski weights (Equation 19 in [1])
weight = (signal*self.portfolio_target_sigma*c_f_rho_bar)/(n_assets*vol)
if str(weight) == 'nan': continue
mapped = self.symbol_data[symbol].mapped
qty = self.calculate_order_quantity(mapped, np.clip(weight, -1, 1))
multiplier = self.securities[mapped].symbol_properties.contract_multiplier
order_qty = (qty - self.portfolio[mapped].quantity) // multiplier
self.market_order(mapped, order_qty)
# Set next rebalance time
self.rebalancing_time = Expiry.end_of_month(self.time)
Summary
The implementation of TSMOM-CF in the post-GFC period, January 2018 to September 2019, shows significant performance improvement over the basic TSMOM. The backtest of TSMOM-CF produces Sharpe ratio of 0.198, compared to TSMOM's Sharpe ratio of -0.746 and SPY Sharpe ratio of 0.46. The exact TSMOM algorithm can be found in the Momentum Effect in Commodities Futures tutorial.
Reference
- Baltas, Nick & Kosowski, Robert. (2017). Demystifying Time-Series Momentum Strategies: Volatility Estimators, Trading Rules and Pairwise Correlations. SSRN Electronic Journal. 10.2139/ssrn.2140091. Online Copy
- Yang, Dennis & Zhang, Qiang. (2000). Drift-Independent Volatility Estimation Based on High, Low, Open, and Close Prices. The Journal of Business, 73(3), 477-492. doi:10.1086/209650. Online Copy
Alethea Lin
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
To unlock posting to the community forums please complete at least 30% of Boot Camp.
You can continue your Boot Camp training progress from the terminal. We hope to see you in the community soon!