Strategy Library

Momentum in Mutual Fund Returns

Introduction

Researchers have shown that the historical returns of a mutual fund and the nearness of its net asset value (NAV) to a previous high can provide significant predictive power about the fund's future returns. In respect to the historical returns, some have attributed the persistence to investor herding and macroeconomic variables. When it comes to the NAV, some suggest the outperformance of funds with a NAV near its trailing high is a result of anchoring bias in investors' psychology. As we do not have access to invest in individual mutual funds on the QC platform or access to NAV metrics, in this tutorial, we trade asset management firms and use their respective share price as a proxy for fund performance and NAV.

Method

Universe Selection

In coarse universe selection, we return symbols that have fundamental data.

def SelectCoarse(self, algorithm, coarse):
    if self.month == algorithm.Time.month:
            return Universe.Unchanged
            
    return [x.Symbol for x in coarse if x.HasFundamentalData]

In fine universe selection, we return symbols that Morningstar has classified as being in the asset management industry.

def SelectFine(self, algorithm, fine):
    self.month = algorithm.Time.month
        
    return [f.Symbol for f in fine if f.AssetClassification.MorningstarIndustryCode == MorningstarIndustryCode.AssetManagement]

Alpha Construction

When constructing the alpha model, we can provide parameters for the lookback windows and the percentage of the universe to long/short. Both of these arguments are validated in the constructor. By default, this alpha model uses the trailing 6 months to calculate the rate of change factor and the trailing 12 months to calculate the nearness to historical highs.

def __init__(self, roc_lookback_months=6, nearness_lookback_months=12, holding_months=6, pct_long_short=10):
    if roc_lookback_months <= 0 or nearness_lookback_months <= 0 or holding_months <= 0:
        algorithm.Quit(f"Requirement violated:  roc_lookback_months > 0 and nearness_lookback_months > 0 and holding_months > 0")
        
    if pct_long_short <= 0 or pct_long_short > 50:
        algorithm.Quit(f"Requirement violated: 0 < pct_long_short <= 50")

    self.roc_lookback_months = roc_lookback_months
    self.nearness_lookback_months = nearness_lookback_months
    self.holding_months = holding_months
    self.pct_long_short = pct_long_short

For each security added to the universe, we construct a ROCAndNearness indicator which warm up the lookback windows and registers a data consolidator. When a security is removed from the universe, we unsubscribe the associated consolidator.

def OnSecuritiesChanged(self, algorithm, changes):
    for added in changes.AddedSecurities:
        roc_and_nearness = ROCAndNearness(added.Symbol, algorithm, self.roc_lookback_months, self.nearness_lookback_months)
        self.symbol_data_by_symbol[added.Symbol] = roc_and_nearness

    for removed in changes.RemovedSecurities:
        symbol_data = self.symbol_data_by_symbol.pop(removed.Symbol, None)
        if symbol_data:
            symbol_data.dispose()

Alpha Update

On the first trading day of each month, we rank the symbols in the universe and emit insights for the portfolio construction model. We instruct the alpha model to emit insights on a monthly basis by adding the following guard to the Update method.

def Update(self, algorithm, data):
    # Emit insights on a monthly basis
    time = algorithm.Time
    if self.month == time.month:
        return []
    self.month = time.month

    ...

Alpha Ranking

We only rank symbols that have enough history to fill the rate of change lookback window. Therefore, we define the IsReady method of the ROCAndNearness as

@property
def IsReady(self):
    return self.get_lookback(self.roc_lookback_months).shape[0] > 1

To rank the symbols, we start by filling a DataFrame with the rate of change and nearness to trailing high values for each symbol. When the DataFrame is full, we rank the symbols by both metrics and sum the ranks. The symbols with a larger final sum have a greater index in the `ranked_symbols` list.

def Update(self, algorithm, data):
    ...
    ranking_df = pd.DataFrame()
    for symbol, symbol_data in self.symbol_data_by_symbol.items():
        if data.ContainsKey(symbol) and symbol_data.IsReady:
            row = pd.DataFrame({'ROC': symbol_data.roc, 'Nearness': symbol_data.nearness}, index=[symbol])
            ranking_df = ranking_df.append(row)
    ranked_symbols =  ranking_df.rank().sum(axis=1).sort_values().index
   ...

Calculating the rate of change and nearness factors is done by slicing the historical data into the approriate lookback window size, and then computing the respective values.

@property
def roc(self):
    lookback = self.get_lookback(self.roc_lookback_months)
    start_price = lookback.iloc[0].open
    end_price = lookback.iloc[-1].close
    return (end_price - start_price) / start_price 

@property
def nearness(self):
    lookback = self.get_lookback(self.nearness_lookback_months)
    return lookback.iloc[-1].close / lookback.high.max()

Alpha Insights

We return insights that instruct the portfolio construction model to form a balance long-short portfolio. The percentage of the universe we long and short is customizable in the alpha model constructor. Here, we long the 25% of symbols with the highest rank, short the 25% of symbols with the lowest ranks, and instruct the portfolio construction model to hold positions for 6 months.

def Update(self, algorithm, data):
    ...
    insights = []
    num_long_short = int(len(ranked_symbols) * (self.pct_long_short / 100))
    if num_long_short > 0:
        hold_duration = Expiry.EndOfMonth(time) + relativedelta(months=self.holding_months-1, seconds=-1)
        for symbol in ranked_symbols[-num_long_short:]:
            insights.append(Insight.Price(symbol, hold_duration, InsightDirection.Up))
        for symbol in ranked_symbols[:num_long_short]:
            insights.append(Insight.Price(symbol, hold_duration, InsightDirection.Down))
    return insights

Portfolio Construction

We utilize a custom portfolio construction model that rebalances monthly and performs allocations based on the net direction of insights for each symbol. A symbol that has two active insights with an up direction will have twice the allocation than a symbol with only one. Furthermore, a symbol that has an up active insight and a down active insight will have no position. We calculate the net direction of the symbols with the following helper method.

def get_net_direction(self, insights):
    net_direction_by_symbol = {}
    num_directional_insights = 0

    for insight in insights:
        symbol = insight.Symbol
        direction = insight.Direction
        if symbol in net_direction_by_symbol:
            net_direction_by_symbol[symbol] += direction
        else:
            net_direction_by_symbol[symbol] = direction

        num_directional_insights += abs(direction)

    return net_direction_by_symbol, num_directional_insights

Algorithm

Relative Performance

To analyze the value of this trading strategy, we compare its performance to buying-and-holding the S&P 500 index ETF, SPY. We can see the results from the table below. The strategy has a lower Sharpe ratio than the SPY for all of the time frames we tested, except for the downfall of the 2020 stock market crash. During this time it greatly outperformed the SPY, achieving a 10.4 Sharpe ratio. We also notice that the strategy generates more consistent returns than the benchmark, documented by the lower annual standard deviation of returns throughout all the testing periods.

Period NameStart DateEnd DateStrategySharpeASD
Backtest 1/1/2015 8/16/2020Strategy0.192 0.046
Benchmark 0.7090.186
Fall 2015 8/10/2015 10/10/2015Strategy-1.448 0.052
Benchmark -0.7240.251
2020 Crash 2/19/2020 3/23/2020Strategy 10.386 0.104
Benchmark-1.2430.793
2020 Recovery 3/23/2020 6/8/2020Strategy-2.942 0.177
Benchmark 13.7610.386

We find the lack of performance for this strategy is not largely attributed to the trading fees. After ignoring the transaction fees, spread costs, and slippage, the strategy still has a lower Sharpe ratio than the benchmark. See the backtest results here.

Market & Competition Qualification

Although this strategy passes several of the metrics required for Alpha Streams and the Quant League competition, it requires further work to meet the following requirements:

  • Profitable
  • PSR >= 80%
  • Max drawdown duration <= 6 months
  • Insights contain the following properties: Symbol, Duration, Direction, and Weight
  • Minute or second data resolution
  • Insight Weighting or Equal Weighting Portfolio Construction model

Conclusion

The momentum pattern examined throughout this tutorial has a greater Sharpe ratio than the SPY during the downfall of the 2020 stock market crash and has more a lower annual standard deviation of returns than the SPY over all the periods we tested. However, we conclude the strategy, which is loosely based on the research of Sapp (2010), does not consistently outperform our benchmark. To continue the development of this strategy, future areas of research include:

  • Incorporating historical returns and NAV of mutual funds to better-reflect the strategy documented by Saap (2010).
  • Adjusting the parameters in the ROCAndNearnessAlphaModel.
  • Performing more filtering and sorting in the AssetManagementUniverseSelection model.
  • Testing other portfolio construction techniques.

You can also see our Documentation and Videos. You can also get in touch with us via Chat.

Did you find this page helpful?

Contribute to the tutorials: