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 the Symbol objects that have fundamental data.

def select_coarse(self, algorithm, coarse):
    if self.month == algorithm.time.month:
            return Universe.UNCHANGED
    return [x.symbol for x in coarse if x.has_fundamental_data]

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

def select_fine(self, algorithm, fine):
    self.month = algorithm.time.month
    return [f.symbol for f in fine if f.asset_classification.morningstar_industry_code == MorningstarIndustryCode.ASSET_MANAGEMENT]

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 warms up the lookback windows and registers a data consolidator. When a security is removed from the universe, we unsubscribe the associated consolidator.

def on_securities_changed(self, algorithm, changes):
    for added in changes.added_securities:
        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.removed_securities:
        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 Insight objects 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 is_ready(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.contains_key(symbol) and symbol_data.is_ready:
            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.end_of_month(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

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 9.3 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 Name Start Date End Date Strategy Sharpe ASD
Backtest 1/1/2015 8/16/2020 Strategy 0.108 0.002
Benchmark 0.582 0.023
Fall 2015 8/10/2015 10/10/2015 Strategy -1.421 0.002
Benchmark -0.642 0.004
2020 Crash 2/19/2020 3/23/2020 Strategy 9.298 0.008
Benchmark -1.467 0.416
2020 Recovery 3/23/2020 6/8/2020 Strategy -2.955 0.025
Benchmark 7.942 0.101