| Overall Statistics |
|
Total Trades 208 Average Win 0.09% Average Loss -0.22% Compounding Annual Return 4.294% Drawdown 7.900% Expectancy -0.227 Net Profit 4.294% Sharpe Ratio 0.345 Probabilistic Sharpe Ratio 21.707% Loss Rate 46% Win Rate 54% Profit-Loss Ratio 0.42 Alpha -0.016 Beta 0.577 Annual Standard Deviation 0.101 Annual Variance 0.01 Information Ratio -0.584 Tracking Error 0.091 Treynor Ratio 0.06 Total Fees $210.18 Estimated Strategy Capacity $5900000.00 Lowest Capacity Asset TPL R735QTJ8XC9X Portfolio Turnover 1.47% |
#region imports
from AlgorithmImports import *
#endregion
# https://quantpedia.com/Screener/Details/14
import numpy as np
import pandas as pd
from scipy.stats import linregress
from collections import deque
from datetime import timedelta
from datetime import datetime
import math
class MomentumEffectAlgorithm(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2016, 1, 1) # Set Start Date
self.SetEndDate(2017, 1, 1) # Set End Date
self.SetCash(100000) # Set Strategy Cash
# create the equities universe
self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
self.resolution = Resolution.Daily
self.UniverseSettings.Resolution = self.resolution
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Adjusted
self.UniverseSettings.MinimumTimeInUniverse = timedelta(days=7)
self.symbol_dictionary = {} # dictionary for holding SymbolData key by symbol
self.invested = [] # list of securities currently invested in portfolio
self.ranked_selection = [] # list of securities selected by momentum ranking and strategy rules on rerank
self.excluded_securities = ['SPY']
self.N_FACTOR = 20
self.risk_free_rate = 0.03
# adjust these in line with Clenow - want approx 20 stocks in the portfolio
self.num_coarse = 500 # Number of symbols selected at Coarse Selection
self.num_positions = 20 # Number of symbols with open positions
self.num_current_holdings = 0
self.risk_factor = 0.001 # targeting 10 basis point move per day
self.momentum_period = 63 # 3 month
self.momentum_threshold = 0.0 # TODO: check the value to see if this is reasonable
self.filter_period = 100
self.volatility_period = 20
self.index_filter_period = 200
# dictionaries for holding moving average, momentum, and volatility values for each symbol
self.ma = {}
self.sharpe = {}
self.returns = {}
self.momentum = {}
self.exp_momentum = {}
self.volatility = {}
# variables to control the portfolio rebalance and the stock selection reranking
self.UpdateFineFilter = 1
self.month = -1
self.dayofweek = 3
self.weekly_rebalance = False
self.monthly_rebalance = False
self.rerank = True
# set up market index TODO change this to AddIndex
market = self.AddEquity("SPY", self.resolution)
market.SetDataNormalizationMode(DataNormalizationMode.Raw)
self.market = market.Symbol
self.SetBenchmark(self.market)
self.index_sma = self.SMA(self.market, self.index_filter_period, self.resolution)
# self.RegisterIndicator(self.market, self.index_sma, self.resolution)
# self.WarmUpIndicator(self.market, self.index_sma)
# set Brokerage model and Fee Structure
self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash)
# set Free Cash to 0.5%
self.Settings.FreePortfolioValuePercentage = 0.005
# risk parity position sizing - input desired percentage, adjusts requested percentage holding by volality to give constant risk
def CalculateRiskParityPositionSizePercentage(self, symbol, vol, percentage)-> float:
quantity = self.CalculateOrderQuantity(symbol, percentage) # get the quantity of shares for holding of 100/num_positions %
rounded_holding = 0.0
if (vol > 0 and quantity != 0):
holding_percent = (quantity / vol)
rounded_holding = round(holding_percent, 2) # round down to 0.01
return rounded_holding
# risk parity position sizing
def CalculateRiskParityPositionSize(self, symbol, percentage, vol_risk_weighting)-> float:
quantity = self.CalculateOrderQuantity(symbol, percentage) # get the quantity of shares for a holding target percentage
desired_quantity = (quantity * vol_risk_weighting)
rounded_holding = round(desired_quantity, 0) # round to int
return rounded_holding
# QC methods and overrrides
def OnWarmUpFinished(self) -> None:
self.Log("Equities Momentum Algorithm Ready")
# may eventually switch this out for small or mid cap stocks
def CoarseSelectionFunction(self, coarse):
'''Drop securities which have no fundamental data or have too low prices. Select those with highest by dollar volume'''
selected = sorted([x for x in coarse if x.HasFundamentalData and x.Price > 5], key=lambda x: x.DollarVolume, reverse=True)
return [x.Symbol for x in selected[:self.num_coarse]]
# the approach we take to universe selection is (filtered, then ranked), then those symbols are managed (via symbol data or risk management) in ondata
def FineSelectionFunction(self, fundamental):
if not self.rerank:
return Universe.Unchanged
universe_valid = [x for x in fundamental
if float(x.EarningReports.BasicAverageShares.ThreeMonths) * x.Price > 1e9
and x.SecurityReference.IsPrimaryShare
and x.SecurityReference.SecurityType == "ST00000001"
and x.SecurityReference.IsDepositaryReceipt == 0
and x.CompanyReference.IsLimitedPartnership == 0
and x.OperationRatios.ROIC
and x.OperationRatios.CapExGrowth
and x.OperationRatios.FCFGrowth
and x.ValuationRatios.BookValueYield
and x.ValuationRatios.EVToEBITDA
and x.ValuationRatios.PricetoEBITDA
and x.ValuationRatios.PERatio
and x.MarketCap
]
universe_symbols = [i.Symbol for i in universe_valid]
self.returns, self.momentum, self.exp_momentum, self.volatility, self.sharpe, self.ma = self.get_indicator_data(universe_symbols)
sortedByfactor0 = sorted(universe_valid, key=lambda x: self.returns[x.Symbol], reverse=False) # high return or sharpe or low volatility
sortedByfactor1 = sorted(universe_valid, key=lambda x: x.OperationRatios.ROIC.OneYear, reverse=False) # high ROIC
sortedByfactor2 = sorted(universe_valid, key=lambda x: x.OperationRatios.CapExGrowth.ThreeYears, reverse=False) # high growth
sortedByfactor3 = sorted(universe_valid, key=lambda x: x.OperationRatios.FCFGrowth.ThreeYears, reverse=False) # high growth
sortedByfactor4 = sorted(universe_valid, key=lambda x: x.ValuationRatios.BookValueYield, reverse=False) # high Book Value Yield
sortedByfactor5 = sorted(universe_valid, key=lambda x: x.ValuationRatios.EVToEBITDA, reverse=True) # low enterprise value to EBITDA
sortedByfactor6 = sorted(universe_valid, key=lambda x: x.ValuationRatios.PricetoEBITDA, reverse=True) # low share price to EBITDA
sortedByfactor7 = sorted(universe_valid, key=lambda x: x.ValuationRatios.PERatio, reverse=True) # low share price to its per-share earnings
sortedByfactor8 = sorted(universe_valid, key=lambda x: x.MarketCap, reverse=True) # market cap
stock_dict = {}
for i, elem in enumerate(sortedByfactor0):
rank0 = i
rank1 = sortedByfactor1.index(elem)
rank2 = sortedByfactor2.index(elem)
rank3 = sortedByfactor3.index(elem)
rank4 = sortedByfactor4.index(elem)
rank5 = sortedByfactor5.index(elem)
rank6 = sortedByfactor6.index(elem)
rank7 = sortedByfactor7.index(elem)
rank8 = sortedByfactor8.index(elem)
score = sum([rank0*1.0, rank1*1.0, rank2*0.0, rank3*0.3, rank4*0.0, rank5*0.0, rank6*0.0, rank7*0.0, rank8*0.0])
stock_dict[elem] = score
self.sorted_stock_dict = sorted(stock_dict.items(), key=lambda x:x[1], reverse=True)
sorted_symbol = [x[0] for x in self.sorted_stock_dict]
top = [x for x in sorted_symbol[:self.N_FACTOR]]
self.ranked_selection = [i.Symbol for i in top]
self.rerank = False
self.weekly_rebalance = True
return self.ranked_selection
# functions to calculate momentum, exponential momentum, moving averages, returns, volatility and sharpe
def momentum_func(self, closes):
returns = np.log(closes)
x = np.arange(len(returns))
slope, _, rvalue, _, _ = linregress(x, returns)
return ((1 + slope) ** 252) * (rvalue ** 2) # annualize slope and multiply by R^2
def exponential_momentum_func(self, closes):
returns = np.log(closes)
x = np.arange(len(returns))
slope, _, rvalue, _, _ = linregress(x, returns)
annualised_slope = (np.power(np.exp(slope), 252)-1)*100
return annualised_slope * (rvalue ** 2) # annualize slope and multiply by R^2
def get_indicator_data(self, symbols):
hist_df = self.History(symbols, self.momentum_period, Resolution.Daily)
returns = {}
ma = {}
mom = {}
exp_mom = {}
volatility = {}
sharpe = {}
for s in symbols:
closes = hist_df.loc[str(s)]['close']
mom[s] = self.momentum_func(closes)
exp_mom[s] = self.exponential_momentum_func(closes)
ret = np.log( hist_df.loc[str(s)]['close'] / hist_df.loc[str(s)]['close'].shift(1) )
returns[s] = ret.mean() * 252 # annualised average return
volatility[s] = ret.std() * np.sqrt(252)
sharpe[s] = (returns[s] - self.risk_free_rate) / volatility[s]
# ma[s] = hist_df.loc[str(s)]['close'].rolling(self.filter_period).mean()
return returns, mom, exp_mom, volatility, sharpe, ma
def rma(s: pd.Series, period: int) -> pd.Series:
return s.ewm(alpha=1 / period).mean()
def atr(df: pd.DataFrame, length: int = 14) -> pd.Series:
high, low, prev_close = df['high'], df['low'], df['close'].shift()
tr_all = [high - low, high - prev_close, low - prev_close]
tr_all = [tr.abs() for tr in tr_all]
tr = pd.concat(tr_all, axis=1).max(axis=1)
atr_ = rma(tr, length)
return atr_
def OnData(self, data):
if self.IsWarmingUp: return
if (self.Time.weekday() == self.dayofweek):
self.rerank = True
if (self.month != self.Time.month):
self.monthly_rebalance = True
self.month = self.Time.month
# rerank weekly, rebalance monthly
if self.weekly_rebalance:
self.Debug(f'{self.Time} OnData Weekly Portfolio Rerank')
liquidated = []
purchased = []
# Sell
selected_symbols = [x.Value for x in self.ranked_selection]
self.Debug(f'selected: {selected_symbols}')
deselected_symbols = [symbol for symbol in self.invested if symbol not in self.ranked_selection]
self.Debug(f'deselected: {deselected_symbols}')
invested_symbols = [x.Value for x in self.invested]
self.Debug(f'invested: {invested_symbols}')
exit_df = self.History(deselected_symbols, self.filter_period, self.resolution)
# Liquidate securities that we currently hold, but no longer in the top momentum rankings and have closed below the sma 100 filter
for symbol in deselected_symbols:
if data.Bars.ContainsKey(symbol):
close = data[symbol].Close
else:
close = self.Securities[symbol].Price # frankly you can just always use this to get last price for security
filterMa = exit_df.loc[str(symbol)]['close'].mean() # sum()/self.filter_period
if close < filterMa:
self.Liquidate(symbol, f'{str(symbol)} as closed {close} is below filter {filterMa}')
liquidated.append(symbol.ID.ToString().split(' ')[0])
self.num_current_holdings -=1
# log which ones were removed and margin now
self.Debug(f'OnData: SOLD {liquidated}. Current Cash: {self.Portfolio.Cash:.2f} Margin: {self.Portfolio.MarginRemaining:.2f}')
# Buy
# check if the market is in an uptrend: index close > index 200 sma
index_df = self.History(self.market, self.index_filter_period, self.resolution)
self.sma200 = index_df.loc[str(self.market)]['close'].mean() # sum()/self.index_filter_period
if data.Bars.ContainsKey(self.market):
market_price = data[self.market].Close
else:
market_price = self.Securities[self.market].Close
buying = (market_price > self.sma200) # get last value
self.Debug(f'OnData: Buying = {buying} [{market_price:.2f}, {self.sma200:.2f}]')
# if are buying, buy selected securities (check not already in holdings first though)
if buying:
self.invested = [x.Symbol for x in self.Portfolio.Values if x.Invested]
self.num_current_holdings = len(self.invested)
invested_symbols = [x.Value for x in self.invested]
self.Debug(f'OnData: {self.num_current_holdings} invested: {invested_symbols}')
portfolio_additions = [symbol for symbol in self.ranked_selection if symbol not in self.invested]
new_symbols = [x.Value for x in portfolio_additions]
target_new_holdings = len(new_symbols)
self.Debug(f"OnData: {target_new_holdings} potential new holdings: " + str(new_symbols))
# get risk weighting based on volatility
weights = {}
total = 0
for symbol in portfolio_additions:
total += self.volatility[symbol]
total = total * 0.99 # force total weights to less than 1, reserve ~ 1% of portfolio to cash
for symbol in portfolio_additions:
weights[symbol]=round((self.volatility[symbol] / total) * (target_new_holdings/(self.num_positions)),3)
purchased = []
total_weight = 0
# set the holdings based on the weights and available cash
for symbol in portfolio_additions:
quantity = self.CalculateOrderQuantity(symbol, weights[symbol])
if data.Bars.ContainsKey(symbol):
price = data[symbol].Price
else:
price = self.Securities[symbol].Price
if (self.Portfolio.GetBuyingPower(symbol) > (price * quantity)):
self.SetHoldings(symbol, weights[symbol])
purchased.append(f"{symbol.ID.ToString().split(' ')[0]} {weights[symbol]:.3f}")
total_weight = total_weight + weights[symbol]
self.Debug(f'OnData: BOUGHT {purchased} based on rankings and available cash. {total_weight}')
self.weekly_rebalance = False
portfolio_update = f'Value: {self.Portfolio.TotalPortfolioValue:.2f}, Holdings: {self.Portfolio.TotalHoldingsValue:.2f} Cash: {self.Portfolio.Cash:.2f}, [Margin Used: {self.Portfolio.TotalMarginUsed:.2f}, Margin Remaining {self.Portfolio.MarginRemaining:.2f}]'
self.Debug(f'Portfolio: {portfolio_update}')
if self.monthly_rebalance:
self.Debug(f'{self.Time} OnData Monthly Portfolio Rebalance')
rebalanced = []
current_portfolio = [x.Symbol for x in self.Portfolio.Values if x.Invested]
# recalculate indicator data for our current portfolio TODO: break apart the calculations so we can limit this to just volatility recalc
self.returns, self.momentum, self.exp_momentum, self.volatility, self.sharpe, self.ma = self.get_indicator_data(current_portfolio)
self.num_current_holdings = len(current_portfolio)
current_portfolio_symbols = [x.Value for x in current_portfolio]
self.Debug(f'current portfolio: {current_portfolio_symbols}')
# get risk weighting bsed on volatility
weights = {}
total = 0
for symbol in current_portfolio:
total += self.volatility[symbol]
total = total * 0.99 # force total weights to less than 1
for symbol in current_portfolio:
weights[symbol]=round(self.volatility[symbol]/total, 3)
# use PortfolioTarget and SetHoldings to rebalance
# TODO: Might have to liquidate all positions before rebalance, unless setHoldings can handle the deltas
target_portfolio = []
total_weight = 0
if len(current_portfolio) > 1:
for symbol in current_portfolio:
target_portfolio.append(PortfolioTarget(symbol, weights[symbol]))
rebalanced.append(f"{symbol.ID.ToString().split(' ')[0]} {weights[symbol]:.3f}")
total_weight = total_weight + weights[symbol]
# set holdings to rebalance to target portfolio
self.SetHoldings(target_portfolio)
self.monthly_rebalance = False
self.Debug(f'OnData: Portfolio Rebalanced {self.num_current_holdings} Holdings {total_weight}, Portfolio Weights: {rebalanced}')
self.monthly_rebalance = False
def OnSecuritiesChanged(self, changes):
addedSymbols = []
removedSymbols = []
# Clean up securities list and indicator data for removed securities
for security in changes.RemovedSecurities:
symbol = security.Symbol
# self.Liquidate(symbol, f'{self.Time} {str(symbol)} no longer in Universe')
# Create indicators and warm them up for securities newly added to the universe
for security in changes.AddedSecurities:
symbol = security.Symbol
if symbol not in self.excluded_securities:
addedSymbols.append(symbol.ID.ToString().split(' ')[0])
self.Debug(f'OnSecuritiesChanged: Added: {addedSymbols}')
self.Debug(f'OnSecuritiesChanged: Removed: {removedSymbols}')