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:

  1. 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.
  2. 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.
  3. 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.AddFuture(ticker,
                resolution = Resolution.Daily,
                extendedMarketHours = True,
                dataNormalizationMode = DataNormalizationMode.BackwardsRatio,
                dataMappingMode = DataMappingMode.OpenInterest,
                contractDepthOffset = 0
            )
            future.SetLeverage(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.RebalancingTime = 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 GetTradingSignal(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 GetYZVolatility(self, history, available_symbols):
    '''Yang and Zhang 'Drift-Independent Volatility Estimation'

    Formula: sigma_YZ^2 = sigma_OJ^2 + self.k * sigma_SD^2 + (1-self.k)*sigma_RS^2 (Equation 20 in [1])
        where,  sigma_OJ - (Overnight Jump Volitility estimator)
                sigma_SD - (Standard Volitility estimator)
                sigma_RS - (Rogers and Satchell Range Volatility estimator)
    '''
    YZ_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.OneMonth):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:
            YZ_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_OJ (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_OJ = np.std(open_to_close_log_returns) 

        # sigma_SD (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_SD = np.std(close_to_close_log_returns) 

        # sigma_RS (Rogers and Satchell (1991))
        h = np.log(high/open)
        l = np.log(low/open)
        c = np.log(close/open)
        sigma_RS_daily = (h * (h - c) + l * (l - c))**0.5
        sigma_RS_daily = sigma_RS_daily[np.isfinite(sigma_RS_daily)] 
        sigma_RS = np.mean(sigma_RS_daily) 

        # daily Yang and Zhang volatility
        sigma_YZ = np.sqrt(sigma_OJ**2 + k * sigma_SD**2 + (1 - k) * sigma_RS**2) 

        # append annualized volatility to the list
        YZ_volatility.append(sigma_YZ*np.sqrt(252)) 

    return YZ_volatility

3. Correlation Factor (CF)

def GetCorrelationFactor(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.ThreeMonths):]

    # 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.SymbolChangedEvents), 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].SymbolProperties.ContractMultiplier.

def OnData(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.SymbolChangedEvents.ContainsKey(symbol_data.Symbol):
            changed_event = data.SymbolChangedEvents[symbol_data.Symbol]
            old_symbol = changed_event.OldSymbol
            new_symbol = changed_event.NewSymbol
            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.MarketOrder(new_symbol, quantity // self.Securities[new_symbol].SymbolProperties.ContractMultiplier, tag = tag)

    # skip if less than 30 days passed since the last trading date
    if self.Time < self.RebalancingTime:
        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.OneYear, 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.GetTradingSignal(history) 
    volatility = self.GetYZVolatility(history, available_symbols) 

    # Get the correlation factor
    CF_rho_bar = self.GetCorrelationFactor(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*CF_rho_bar)/(N_assets*vol)
        if str(weight) == 'nan': continue

        mapped = self.symbol_data[symbol].Mapped
        qty = self.CalculateOrderQuantity(mapped, np.clip(weight, -1, 1))
        multiplier = self.Securities[mapped].SymbolProperties.ContractMultiplier
        order_qty = (qty - self.Portfolio[mapped].Quantity) // multiplier
        self.MarketOrder(mapped, order_qty)

    # Set next rebalance time
    self.RebalancingTime = Expiry.EndOfMonth(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

  1. 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
  2. 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

Author