| Overall Statistics |
|
Total Trades 482 Average Win 0.34% Average Loss -0.30% Compounding Annual Return 12.994% Drawdown 17.000% Expectancy 0.266 Net Profit 51.694% Sharpe Ratio 0.907 Probabilistic Sharpe Ratio 38.810% Loss Rate 41% Win Rate 59% Profit-Loss Ratio 1.13 Alpha 0.116 Beta -0.019 Annual Standard Deviation 0.126 Annual Variance 0.016 Information Ratio 0.018 Tracking Error 0.228 Treynor Ratio -6.153 Total Fees $716.35 |
import pandas as pd
from dateutil.relativedelta import relativedelta
class ROCAndNearness:
"""
This class manages the historical data for a symbol and calculates the ROC and Nearness factors.
"""
def __init__(self, symbol, algorithm, roc_lookback_months, nearness_lookback_months):
"""
Input:
- symbol
Symbol to apply this indicator to
- algorithm
Algorithm instance running the backtest
- roc_lookback_months
Number of trailing months to calculate the rate of change over (> 0)
- nearness_lookback_months
Number of trailing months to calculate the nearness factor over (> 0)
"""
self.symbol = symbol
self.algorithm = algorithm
self.roc_lookback_months = roc_lookback_months
self.nearness_lookback_months = nearness_lookback_months
self.lookback_months = max(roc_lookback_months, nearness_lookback_months)
# Warm up history
self.warm_up_history(symbol, algorithm)
# Setup indicator consolidator
self.consolidator = TradeBarConsolidator(timedelta(1))
self.consolidator.DataConsolidated += self.CustomDailyHandler
algorithm.SubscriptionManager.AddConsolidator(self.symbol, self.consolidator)
def CustomDailyHandler(self, sender, consolidated):
"""
Updates the rolling lookback window with the latest data.
Inputs
- sender
Function calling the consolidator
- consolidated
Tradebar representing the latest completed trading day
"""
# Add new data point to history
if consolidated.Time not in self.history.index:
row = pd.DataFrame({'open': consolidated.Open, 'high': consolidated.High, 'close': consolidated.Close},
index=[consolidated.Time])
self.history = self.history.append(row)
# Remove expired history
start_lookback = Expiry.EndOfMonth(self.algorithm.Time) - relativedelta(months=self.lookback_months + 1)
self.history = self.history[self.history.index >= start_lookback]
def dispose(self):
"""
Removes the monthly conoslidator.
"""
self.algorithm.SubscriptionManager.RemoveConsolidator(self.symbol, self.consolidator)
def warm_up_history(self, symbol, algorithm):
"""
Warms up the `historical_prices_by_symbol` dictionary with historical data as far back as the
earliest lookback window.
Input:
- symbol
Symbol that needs history warm up
- algorithm
Algorithm instance running the backtest
"""
# Get the historical data
start_lookback = Expiry.EndOfMonth(algorithm.Time) - relativedelta(months=self.lookback_months + 1)
history = algorithm.History([symbol], start_lookback, algorithm.Time, Resolution.Daily)
# Rollback history timestamp by 1 day to ensure accurate monthly ROC values
if history.shape[0] > 0:
history = history.unstack(level=0)
history = history.set_index(history.index.map(lambda x: x - timedelta(days=1))).stack().swaplevel()
self.history = history.loc[symbol, ['open', 'high', 'close']] if symbol in history.index else pd.DataFrame()
@property
def IsReady(self):
"""
Boolean to signal if the symbol has sufficient history to fill the ROC lookback window.
"""
if self.history.shape[0] == 0:
return False
return self.history.index[0] < Expiry.EndOfMonth(self.algorithm.Time) - relativedelta(months=self.roc_lookback_months)
@property
def roc(self):
"""
Calculates the rate of change over the ROC lookback window.
"""
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):
"""
Calculates how close the closing price of the nearness lookback window was to its maximum price.
"""
lookback = self.get_lookback(self.nearness_lookback_months)
return lookback.iloc[-1].close / lookback.high.max()
def get_lookback(self, num_months):
"""
Slices the historical data into the trailing `num_months` months.
Input:
- num_months
Number of trailing months in the lookback window
Returns DataFrame containing data for the lookback window.
"""
start_lookback = Expiry.EndOfMonth(self.algorithm.Time) - relativedelta(months=num_months + 1)
end_lookback = Expiry.EndOfMonth(self.algorithm.Time) - relativedelta(months=1)
return self.history[(self.history.index >= start_lookback) & (self.history.index < end_lookback)]import pandas as pd
from dateutil.relativedelta import relativedelta
from ROCAndNearness import ROCAndNearness
class ROCAndNearnessAlphaModel(AlphaModel):
"""
This class ranks securities in the universe by their historical rate of change and nearness to trailing highs.
"""
symbol_data_by_symbol = {}
month = -1
def __init__(self, roc_lookback_months=6, nearness_lookback_months=12, holding_months=6, pct_long_short=10):
"""
Input:
- roc_lookback_months
Number of trailing months to calculate the rate of change over (> 0)
- nearness_lookback_months
Number of trailing months to calculate the nearness factor over (> 0)
- holding_months
Number of months to hold positions (> 0)
- pct_long_short
The percentage of the universe we go long and short (0 < pct_long_short <= 50)
"""
if roc_lookback_months <= 0 or nearness_lookback_months <= 0 or holding_months <= 0:
algorithm.Error(f"Requirement violated: roc_lookback_months > 0 and nearness_lookback_months > 0 and holding_months > 0")
algorithm.Quit()
return
if pct_long_short <= 0 or pct_long_short > 50:
algorithm.Error(f"Requirement violated: 0 < pct_long_short <= 50")
algorithm.Quit()
return
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
def Update(self, algorithm, data):
"""
Called each time our alpha model receives a new data slice.
Input:
- algorithm
Algorithm instance running the backtest
- data
A data structure for all of an algorithm's data at a single time step
Returns a list of Insights to the portfolio construction model
"""
# Emit insights on a monthly basis
time = algorithm.Time
if self.month == time.month:
return []
self.month = time.month
return self.generate_insights(self.ranked_symbols, time)
def OnSecuritiesChanged(self, algorithm, changes):
"""
Called each time our universe has changed.
Input:
- algorithm
Algorithm instance running the backtest
- changes
The additions and subtractions to the algorithm's security subscriptions
"""
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()
@property
def ranked_symbols(self):
"""
Returns a list of Symbols ordered in increasing rank. A higher rank is associated with
relatively greater rate of change over the roc lookback window and a closer closing price
to the max price in the nearness lookback window.
"""
ranking_df = pd.DataFrame()
for symbol, symbol_data in self.symbol_data_by_symbol.items():
if symbol_data.IsReady:
row = pd.DataFrame({'ROC': symbol_data.roc, 'Nearness': symbol_data.nearness}, index=[symbol])
ranking_df = ranking_df.append(row)
return ranking_df.rank().sum(axis=1).sort_values().index
def generate_insights(self, ranked_symbols, time):
"""
Generates the insights to construct a balanced long-short portfolio.
Input:
- ranked_symbols
List of Symbols ordered in increasing rank
- time
Current time in the backtest
Returns list of Insights.
"""
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 insightsfrom Selection.FundamentalUniverseSelectionModel import FundamentalUniverseSelectionModel
class AssetManagementUniverseSelection(FundamentalUniverseSelectionModel):
"""
This universe selection model refreshes monthly to contain US securities in the asset management industry.
"""
def __init__(self, coarse_size = 400, fine_size = 100):
"""
Input:
- coarse_size
Number of securities to return from coarse selection
- fine_size
Number of securities to return from fine selection
"""
self.month = -1
self.coarse_size = coarse_size
self.fine_size = fine_size
super().__init__(True)
def SelectCoarse(self, algorithm, coarse):
"""
Coarse universe selection is called each day at midnight.
Input:
- algorithm
Algorithm instance running the backtest
- coarse
List of CoarseFundamental objects
Returns the first `coarse_size` symbols that have fundamental data and traded in the US market.
"""
if self.month == algorithm.Time.month:
return Universe.Unchanged
filtered = [x for x in coarse if (x.HasFundamentalData) and (x.Market == "usa")]
return [ x.Symbol for x in filtered[:self.coarse_size] ]
def SelectFine(self, algorithm, fine):
"""
Fine universe selection is performed each day at midnight after `SelectCoarse`.
Input:
- algorithm
Algorithm instance running the backtest
- fine
List of FineFundamental objects that result from `SelectCoarse` processing
Returns a list of symbols that passed coarse universe selection and are in the asset management industry.
"""
self.month = algorithm.Time.month
filtered = [f for f in fine if f.AssetClassification.MorningstarIndustryCode == MorningstarIndustryCode.AssetManagement]
return [ x.Symbol for x in filtered[:self.fine_size] ]class NetDirectionWeightedPortfolioConstructionModel(PortfolioConstructionModel):
"""
This PCM allocates its portfolio based on the net direction of insights for all the symbols. A symbol
that has two active insights with an up direction will have twice the allocation than a symbol with
only one. Additionally, a symbol that has an up active insight and a down active insight will have no
position.
"""
insights = []
month = -1
def CreateTargets(self, algorithm, insights):
"""
Called each time the alpha model emits a list of insights.
Input:
- algorithm
Algorithm instance running the backtest
- insights
List of insights
Returns a list of portfolio targets.
"""
for i in insights:
self.insights.append(i)
if self.month == algorithm.Time.month:
return []
self.month = algorithm.Time.month
# Classify insights
active_insights = []
expired_insights = []
while (len(self.insights) > 0):
insight = self.insights.pop()
(active_insights if insight.IsActive(algorithm.UtcTime) else expired_insights).append(insight)
self.insights = active_insights
# Liquidate symbols that have expired insights with no active insights
active_symbols = set([i.Symbol for i in active_insights])
expired_symbols = set([i.Symbol for i in expired_insights])
liquidate_symbols = expired_symbols.difference(active_symbols)
portfolio_targets = [PortfolioTarget.Percent(algorithm, symbol, 0) for symbol in liquidate_symbols]
# Get net direction by symbol and total number of directional insights
net_direction_by_symbol, num_directional_insights = self.get_net_direction(active_insights)
# Create portfolio targets for active symbols
for symbol, net_direction in net_direction_by_symbol.items():
percent = 0 if num_directional_insights == 0 else net_direction / num_directional_insights
portfolio_targets.append( PortfolioTarget.Percent(algorithm, symbol, percent) )
return portfolio_targets
def get_net_direction(self, insights):
"""
Determines the net direction of each symbol and the number of active directional insights.
Input:
- insights
A list of active insights
Returns a dictionary showing the net direction of each symbol, and the number of directional 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_insightsfrom AssetManagementUniverseSelection import AssetManagementUniverseSelection
from ROCAndNearnessAlphaModel import ROCAndNearnessAlphaModel
from NetDirectionWeightedPortfolioConstructionModel import NetDirectionWeightedPortfolioConstructionModel
class AssetManagementFirmMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2017, 1, 4)
self.SetEndDate(2020, 6, 1)
self.SetCash(100000)
self.UniverseSettings.Resolution = Resolution.Daily
self.SetUniverseSelection(AssetManagementUniverseSelection())
self.AddAlpha(ROCAndNearnessAlphaModel())
self.Settings.RebalancePortfolioOnInsightChanges = False
self.SetPortfolioConstruction(NetDirectionWeightedPortfolioConstructionModel())
self.SetExecution(ImmediateExecutionModel())