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 |
Derek Melchin
See the attached backtest for an updated version of the algorithm in PEP8 style.
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.
Derek Melchin
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!