The Effect of Market Returns on Factor Returns
Log in to collectAcademic paper
The Market State, Mispricing and Asset Pricing Anomalies
Michael Di Carlo; Ilias Tsiakas
- CAUniversity of Guelph
Strategy in a nutshell
Universe: all NYSE, AMEX, NASDAQ stocks above $5. Sort firms by Stambaugh, Yu, and Yuan (2015) mispricing measure into quintiles. Go long the most underpriced (Low) and short the most overpriced (High) firms when the previous month’s market return is negative. Portfolios are value-weighted and rebalanced monthly.
Economic rationale
Mispricing drives return anomalies. Market state matters: overpriced/high-volatility portfolios underperform, enabling significant risk-adjusted gains by shorting them. This highlights the conditional predictability of mispricing-based anomalies.
Backtest performance
Annualised return14.57%
Volatility10.65%
Beta-0.189
Sharpe ratio1.37
Sortino ratio-0.179
Win rate53%
Full Python code
from AlgorithmImports import *
import data_tools
from typing import List, Dict
import numpy as np
import pandas as pd
# endregion
class TheEffectofMarketReturnsonFactorReturns(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.fundamental_count:int = 3000
self.leverage:int = 5
self.quantile:int = 5
self.daily_period:int = 252
self.monthly_period:int = 12
self.tickers_to_ignore:List[str] = ['SGA']
self.data:Dict[Symbol, SymbolData] = {}
self.weight:Dict[Symbol, float] = {}
self.selection_flag:bool = False
self.rebalance_flag:bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)
self.current_year:int = -1
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(data_tools.CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# store daily stock prices
for stock in fundamental:
symbol:Symbol = stock.Symbol
if symbol in self.data:
self.data[symbol].update_daily_return(stock.AdjustedPrice)
# monthly selection
if not self.selection_flag:
return Universe.Unchanged
self.selection_flag = False
# store monthly market prices
if self.market in self.data:
self.data[self.market].update_monthly_return(self.Securities[self.market].Price, 0)
# store monthly stock prices
for stock in fundamental:
symbol:Symbol = stock.Symbol
if stock.Volume != 0:
volume:float = stock.Volume
else:
continue
if symbol in self.data:
self.data[symbol].update_monthly_return(stock.AdjustedPrice, volume)
fundamental:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.MarketCap != 0 and x.Market == 'usa' \
and not np.isnan(x.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths) and x.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths != 0 \
and not np.isnan(x.FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths) and x.FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths != 0 \
and not np.isnan(x.FinancialStatements.IncomeStatement.OperatingExpense.TwelveMonths) and x.FinancialStatements.IncomeStatement.OperatingExpense.TwelveMonths != 0 \
and not np.isnan(x.FinancialStatements.IncomeStatement.DepreciationAndAmortization.TwelveMonths) and x.FinancialStatements.IncomeStatement.DepreciationAndAmortization.TwelveMonths != 0 \
and ((x.SecurityReference.ExchangeId == "NYS") or (x.SecurityReference.ExchangeId == "NAS") or (x.SecurityReference.ExchangeId == "ASE")) \
and x.Symbol.Value not in self.tickers_to_ignore]
if len(fundamental) > self.fundamental_count:
selection = {x.Symbol:x for x in sorted(fundamental, key=lambda x:x.MarketCap, reverse=True)[:self.fundamental_count]}
else:
selection = {x.Symbol:x for x in fundamental}
# price warmup
for symbol in list(selection.keys()):
if symbol in self.data:
continue
self.data[symbol] = data_tools.SymbolData(self.daily_period, self.monthly_period)
history:DataFrame = self.History(symbol, self.daily_period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
data:pd.DataFrame = history.loc[symbol]
monthly_data = data.groupby(pd.Grouper(freq='MS')).last()
for time, row in monthly_data.iterrows():
self.data[symbol].update_monthly_return(row.close, row.volume)
for time, row in data.iterrows():
self.data[symbol].update_daily_return(row.close)
if self.market not in self.data:
self.data[self.market] = data_tools.SymbolData(self.daily_period, self.monthly_period)
history:DataFrame = self.History(self.market, self.daily_period, Resolution.Daily)
if not history.empty:
data:pd.DataFrame = history.loc[self.market].groupby(pd.Grouper(freq='MS')).last()
for time, row in data.iterrows():
self.data[self.market].update_monthly_return(row.close, 0)
else:
self.Log(f"Not enough data for {symbol} yet.")
if self.Time.year != self.current_year:
self.current_year = self.Time.year
[self.data[sym].update_total_assets(selection[sym].FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths) for sym in selection]
# stock returns
returns_by_stock:Dict[Symbol, List[Tuple[datetime, float]]] = {sym : sym_data.get_monthly_returns() for sym, sym_data in self.data.items() if sym_data.is_ready() and sym_data.total_asset_is_ready() and sym in selection.keys() and sym != self.market}
stock_returns:List = list(zip(*[[i for i in x] for x in returns_by_stock.values()]))
market_returns:np.ndarray = np.array(self.data[self.market].get_monthly_returns())
if len(stock_returns) == 0:
return Universe.Unchanged
# run stock regression
x:np.ndarray = market_returns
y:np.ndarray = np.array(stock_returns)
model = data_tools.multiple_linear_regression(x, y)
beta_values:np.ndarray = model.params[1]
resid = model.resid
data:Dict[str, float] = {}
# get all factors
data['symbols'] = [sym for sym in list(returns_by_stock.keys())]
data['betas'] = [beta_values[n] for n, sym in enumerate(list(returns_by_stock.keys()))]
data['VOL'] = [self.data[sym].get_volatility() for sym in list(returns_by_stock.keys())]
data['CORR'] = [np.corrcoef(market_returns, self.data[sym].get_monthly_returns())[0, 1] for sym in list(returns_by_stock.keys())]
data['IVOL'] = [np.std(resid.T[n]) for n, sym in enumerate(list(returns_by_stock.keys()))]
data['ME'] = [selection[sym].MarketCap for sym in list(returns_by_stock.keys())]
data['BM'] = [selection[sym].ValuationRatios.PBRatio for sym in list(returns_by_stock.keys())]
data['OP'] = [(selection[sym].FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths - selection[sym].FinancialStatements.IncomeStatement.OperatingExpense.TwelveMonths - selection[sym].FinancialStatements.IncomeStatement.DepreciationAndAmortization.TwelveMonths) for sym in list(returns_by_stock.keys())]
data['AG'] = [self.data[sym].get_asset_growth() for sym in list(returns_by_stock.keys())]
data['MOM'] = [self.data[sym].get_momentum() for sym in list(returns_by_stock.keys())]
data['STR'] = [self.data[sym].get_str() for sym in list(returns_by_stock.keys())]
data['MAX'] = [self.data[sym].get_max() for sym in list(returns_by_stock.keys())]
data['M1'] = [self.data[sym].get_m1() for sym in list(returns_by_stock.keys())]
data['ILLIQ'] = [self.data[sym].get_illiquidity() for sym in list(returns_by_stock.keys())]
# create dataframe from factors
df = pd.DataFrame(data)
# rank stocks by percentile
df = df.dropna(axis=0)
df[df.columns[1:]] = df[df.columns[1:]].rank(axis=0, pct=True)
df['average_percentile'] = df[df.columns[1:]].mean(axis=1)
# sort by average percentile and divide to quantiles
if len(df) >= self.quantile:
sorted_symbols:List[Symbol] = df.sort_values(by='average_percentile', ascending=True).symbols.values
quantile:int = int(len(sorted_symbols) / self.quantile)
long:List[FineFundamental] = list(sorted_symbols)[:quantile]
short:List[FineFundamental] = list(sorted_symbols)[-quantile:]
# calculate weights based on values
sum_long:float = sum([selection[x].MarketCap for x in long])
for stock in long:
self.weight[stock] = selection[stock].MarketCap / sum_long
sum_short:float = sum([selection[x].MarketCap for x in short])
for stock in short:
self.weight[stock] = -selection[stock].MarketCap / sum_short
self.rebalance_flag = True
return list(selection.keys())
def OnData(self, data: Slice) -> None:
if not self.rebalance_flag:
return
self.rebalance_flag = False
# trade execution
if self.data[self.market].get_last_month_return() < 0:
invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for price_symbol in invested:
if price_symbol not in self.weight:
self.Liquidate(price_symbol)
for price_symbol, weight in self.weight.items():
if price_symbol in data and data[price_symbol]:
self.SetHoldings(price_symbol, weight)
else:
if self.Portfolio.Invested:
self.Liquidate()
self.weight.clear()
def Selection(self):
self.selection_flag = True