| Overall Statistics |
|
Total Orders 753 Average Win 2.05% Average Loss -1.97% Compounding Annual Return 5.960% Drawdown 43.900% Expectancy 0.179 Start Equity 100000 End Equity 298766.71 Net Profit 198.767% Sharpe Ratio 0.206 Sortino Ratio 0.125 Probabilistic Sharpe Ratio 0.018% Loss Rate 42% Win Rate 58% Profit-Loss Ratio 1.04 Alpha 0.031 Beta 0.035 Annual Standard Deviation 0.162 Annual Variance 0.026 Information Ratio -0.143 Tracking Error 0.223 Treynor Ratio 0.944 Total Fees $1844.24 Estimated Strategy Capacity $31000.00 Lowest Capacity Asset ANEW XIQBGZ33QP2D Portfolio Turnover 2.77% |
#region imports
from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
#endregion
class ETFCreationRedemptionActivityAndReturnPredictability(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2006, 1, 1) # etf data starts since 2006
self.SetCash(100_000)
self.spread_period: int = 21 # need n bid ask spread values for each ETF
self.leverage: int = 5
self.quantile: int = 5
self.data: Dict[Symbol, SymbolData] = {}
self.shares_outstanding: Dict[str, float] = {}
self.long: List[Symbol] = []
self.short: List[Symbol] = []
self.last_custom_data_date: datetime.date = datetime(1, 1, 1).date()
market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
csv_string_file: str = self.Download('data.quantpedia.com/backtesting_data/equity/etf_shares_outstanding.csv')
lines: List[str] = csv_string_file.split('\r\n')
# retrieve tickers from csv header
self.etf_tickers: List[str] = lines[0].split(';')[1:]
for etf_ticker in self.etf_tickers:
self.shares_outstanding[etf_ticker] = {}
for line in lines[1:]: # iterate through each line except header
if line == '':
continue
split: List[str] = line.split(';')
date: datetime.date = datetime.strptime(split[0], "%Y-%m-%d").date()
shares_outstanding_values: float = split[1:]
total_shares_outstanding_values: int = len(shares_outstanding_values)
if date > self.last_custom_data_date:
self.last_custom_data_date = date
# load share outstanding data and index them by year, month then day
for index in range(1, total_shares_outstanding_values, 1):
# get share outstanding value for specific ticker
shares_outstanding_value: float = float(shares_outstanding_values[index])
# make sure stored value won't be zero
if shares_outstanding_value == 0:
continue
# get etf ticker, which belongs to current share outstanding value
etf_ticker: str = self.etf_tickers[index]
if date not in self.shares_outstanding[etf_ticker]:
self.shares_outstanding[etf_ticker][date] = shares_outstanding_value
self.selection_flag: bool = False
self.UniverseSettings.Leverage = self.leverage
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.BeforeMarketClose(market, 0), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# rebalance monthly
if not self.selection_flag:
return Universe.Unchanged
# filter ETFs universe
selected: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in self.etf_tickers]
share_spread: Dict[Symbol, float] = {}
bid_ask_avg_spread: Dict[Symbol, float] = {}
for etf in selected:
etf_symbol: Symbol = etf.Symbol
# initialize SymbolData object for new etf symbol in data
if etf_symbol not in self.data:
self.data[etf_symbol] = SymbolData(self.spread_period)
# make sure, data are ready
if not self.data[etf_symbol].are_data_ready():
continue
share_spread_value: float = self.GetShareSpread(etf_symbol.Value)
# make sure there is valid share spread value
if share_spread_value == None:
continue
bid_ask_avg_spread_value: float = self.data[etf_symbol].bid_ask_avg_spread()
# store spreads
share_spread[etf_symbol] = share_spread_value
bid_ask_avg_spread[etf_symbol] = bid_ask_avg_spread_value
# there has to be enough ETFs for quintile selections
if len(share_spread) < self.quantile or len(bid_ask_avg_spread) < self.quantile:
return list(self.data.keys())
# quintile selections
quintile: int = int(len(share_spread) / self.quantile)
sorted_by_share_spread: List[Symbol] = [x[0] for x in sorted(share_spread.items(), key=lambda item: item[1])]
sorted_by_bid_ask_avg_spread: List[Symbol] = [x[0] for x in sorted(bid_ask_avg_spread.items(), key=lambda item: item[1])]
highest_share_spread: List[Symbol] = sorted_by_share_spread[-quintile:]
lowest_share_spread: List[Symbol] = sorted_by_share_spread[:quintile]
# highest_bid_ask_avg_spread = sorted_by_bid_ask_avg_spread[-quintile:]
lowest_bid_ask_avg_spread: List[Symbol] = sorted_by_bid_ask_avg_spread[:quintile]
# Investor goes long portfolio with the lowest ShareChange (quintile with the highest redemption activity) and simultaneously with the lowest bid-ask spread.
self.long = [x for x in lowest_share_spread if x in lowest_bid_ask_avg_spread]
# Investor goes short portfolio with the highest ShareChange (quintile with the highest creation activity) and simultaneously with the lowest bid-ask spread.
self.short = [x for x in highest_share_spread if x in lowest_bid_ask_avg_spread]
return list(self.data.keys())
def OnData(self, slice: Slice) -> None:
if self.time.date() > self.last_custom_data_date:
self.liquidate()
return
for etf_symbol in self.data:
if slice.contains_key(etf_symbol) and slice[etf_symbol]:
bid: float = self.Securities[etf_symbol].BidPrice
ask: float = self.Securities[etf_symbol].AskPrice
self.data[etf_symbol].update_bid_ask_spread(bid, ask)
# rebalance monthly
if not self.selection_flag:
return
self.selection_flag = False
# trade execution
targets: List[PortfolioTarget] = []
for i, portfolio in enumerate([self.long, self.short]):
for symbol in portfolio:
if slice.contains_key(symbol) and slice[symbol]:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
self.SetHoldings(targets, True)
self.long.clear()
self.short.clear()
def GetShareSpread(self, etf_ticker: str) -> float:
current_date: datetime.date = self.Time.date()
prev_month_date: datetime.date = current_date - relativedelta(months=1)
# get indexed ETF's share oustanding from dictionary
etf_shares_outstanding: float = self.shares_outstanding[etf_ticker]
# make sure share outstanding values are ready for each day
if current_date not in etf_shares_outstanding or prev_month_date not in etf_shares_outstanding:
return None
# get current share oustanding value
curr_shares_outstanding: float = etf_shares_outstanding[current_date]
# get prev month share outstanding value
prev_shares_outstanding: float = etf_shares_outstanding[prev_month_date]
# return share spread
return (curr_shares_outstanding / prev_shares_outstanding) - 1
def Selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, spread_period: int) -> None:
self.bid_ask_spread: RollingWindow = RollingWindow[float](spread_period)
def update_bid_ask_spread(self, bid: float, ask: float) -> None:
midpoint: float = (bid + ask) / 2
spread: float = (ask - bid) / midpoint
self.bid_ask_spread.Add(spread)
def are_data_ready(self) -> bool:
return self.bid_ask_spread.IsReady
def bid_ask_avg_spread(self) -> float:
bid_ask_spreads: List[float] = [x for x in self.bid_ask_spread]
bid_ask_avg_spread: float = sum(bid_ask_spreads) / len(bid_ask_spreads)
return bid_ask_avg_spread
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))