
Quarterly rebalanced portfolios use Fama-French regressions to calculate idiosyncratic variance, predict excess market returns, and determine equity weights based on expected returns, market variance, and investor risk aversion.
ASSET CLASS: CFDs, ETFs, funds, futures | REGION: Global| FREQUENCY:
Quarterly | MARKET: equities | KEYWORD: Market Timing , Aggregate , Idiosyncratic Stock Volatilities
I. STRATEGY IN A NUTSHELL
Estimate market variance (squared daily excess returns) and idiosyncratic variance (Fama–French residuals) for S&P 500 stocks. Predict next quarter’s expected excess return, then allocate portfolio weights based on expected return, market variance, and risk aversion. Rebalance quarterly.
II. ECONOMIC RATIONALE
High idiosyncratic volatility reflects divergent investor opinions, potentially leading to a negative relationship with future stock returns.
III. SOURCE PAPER
Market Timing with Aggregate and Idiosyncratic Stock Volatilities [Click to Open PDF]
Hui Guo, University of Cincinnati – Department of Finance – Real Estate; Jason Higbee, Federal Reserve Bank of St. Louis – Research Division
<Abstract>
Guo and Savickas [2005] show that aggregate stock market volatility and average idiosyncratic stock volatility jointly forecast stock returns. In this paper, we quantify the economic significance of their results from the perspective of a portfolio manager. That is, we evaluate the performance, e.g., the Sharpe ratio and Jensen’s alpha, of a mean-variance manager who tries to time the market based on those variables. We find that, over the period 1968-2004, the associated market-timing strategy outperforms the buy-and-hold strategy, and the difference is statistically and economically significant.


IV. BACKTEST PERFORMANCE
| Annualised Return | 20.07% |
| Volatility | 37.8% |
| Beta | -0.125 |
| Sharpe Ratio | 0.43 |
| Sortino Ratio | -0.471 |
| Maximum Drawdown | N/A |
| Win Rate | 55% |
V. FULL PYTHON CODE
import numpy as np
from AlgorithmImports import *
import statsmodels.api as sm
import numpy as np
from typing import Dict, List, Tuple
from pandas.core.frame import dataframe
from pandas.core.series import Series
class MarketTimingwithAggregateandIdiosyncraticStockVolatilities(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.data:Dict[Symbol, SymbolData] = {}
self.weight:Dict[Symbol, float] = {}
self.period:int = 3*21
self.fundamental_count:int = 500
self.leverage:int = 5
self.quantile:int = 10
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
self.last_size_long:List[Symbol] = []
self.last_size_short:List[Symbol] = []
self.last_book_long:List[Symbol] = []
self.last_book_short:List[Symbol] = []
self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.data[self.market] = SymbolData(self.period)
self.bonds:Symbol = self.AddEquity('SHY', Resolution.Daily).Symbol
self.regression_data:List[Tuple[float, float, float]] = []
self.regression_data_min_period:int = 12
self.selection_flag:bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthEnd(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)
self.settings.daily_precise_end_time = False
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Update the rolling window every day.
for stock in fundamental:
symbol:Symbol = stock.Symbol
# Store monthly price.
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected: List[Fundamental] = sorted([x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.MarketCap != 0 \
and not np.isnan(x.ValuationRatios.PBRatio) and x.ValuationRatios.PBRatio != 0
and x.SecurityReference.ExchangeId in self.exchange_codes],
key=lambda x: x.DollarVolume, reverse=True)[:self.fundamental_count]
selected_dict: Dict[Symbol, Fundamental] = {x.Symbol : x for x in selected}
market_cap:Dict[Symbol, float] = {}
book_to_market:Dict[Symbol, float] = {}
# Warmup price rolling windows.
for symbol in list(selected_dict.keys()) + [self.market]:
if symbol in self.data:
continue
self.data[symbol] = SymbolData(self.period)
history:dataframe = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
closes:Series = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].update(close)
if self.data[symbol].is_ready():
market_cap[symbol] = selected_dict[symbol].MarketCap
book_to_market[symbol] = 1 / selected_dict[symbol].ValuationRatios.PBRatio # book-to-market
# Return, if after fundamental filtration we don't have enough stocks to sort them into deciles.
# In next month we will use size and book factors from t-3 month, where t is current month.
if len(market_cap) < self.quantile:
return Universe.Unchanged
quantile:int = int(len(market_cap) / self.quantile)
sorted_by_market_cap:List[Symbol] = [x[0] for x in sorted(market_cap.items(), key=lambda item: item[1])]
sorted_book_to_market:List[Symbol] = [x[0] for x in sorted(book_to_market.items(), key=lambda item: item[1])]
# Size Factor
current_size_long:List[Symbol] = sorted_by_market_cap[:quantile]
current_size_short:List[Symbol] = sorted_by_market_cap[-quantile:]
# Book to market Factor
current_book_long:List[Symbol] = sorted_book_to_market[:quantile]
current_book_short:List[Symbol] = sorted_book_to_market[-quantile:]
# Check if factors are ready
if (len(self.last_size_long) > 0 and len(self.last_size_short) > 0 and
len(self.last_book_long) > 0 and len(self.last_book_short) > 0):
# Check if market prices are ready
if self.data[self.market].is_ready():
# Calculate factors daily returns
book_factor_daily_returns:List[float] = self.CalculateFactorDailyReturns(self.last_book_long, self.last_book_short)
size_factor_daily_returns:List[float] = self.CalculateFactorDailyReturns(self.last_size_long, self.last_size_short)
market_returns:np.ndarray = self.data[self.market].daily_performances()
market_variance:np.ndarray = (np.std(market_returns) * np.sqrt(252)) ** 2
market_return:float = self.data[self.market].performance()
# stock residuals
idiosyncratic_variance:List[Tuple[float, float]] = []
for stock in selected:
symbol: Symbol = stock.Symbol
if symbol not in self.data:
continue
if not self.data[symbol].is_ready():
continue
Y:np.ndarray = self.data[symbol].daily_performances()
X:np.ndarray = [
market_returns,
size_factor_daily_returns,
book_factor_daily_returns
]
regression_model = self.MultipleLinearRegression(X, Y)
idiosyncratic_variance.append((regression_model.resid[-1], selected_dict[symbol].MarketCap))
# summary idiosyncratic variance calculation
summary_market_cap:float = sum([x[1] for x in idiosyncratic_variance])
summary_idiosyncratic_variance:float = sum([x[0] * (x[1] / summary_market_cap) for x in idiosyncratic_variance])
self.regression_data.append((market_return, market_variance, summary_idiosyncratic_variance))
self.last_size_long = current_size_long
self.last_size_short = current_size_short
self.last_book_long = current_book_long
self.last_book_short = current_book_short
return Universe.Unchanged
def OnData(self, data:Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
if self.bonds not in data or not data[self.bonds]:
return
# regression data is ready
if len(self.regression_data) >= self.regression_data_min_period:
Y = [x[0] for x in self.regression_data][:-1]
X = [
[x[1] for x in self.regression_data][1:],
[x[2] for x in self.regression_data][1:]
]
# expected market return calculation
regression_model = self.MultipleLinearRegression(X, Y)
alpha:float = regression_model.params[0]
beta1:float = regression_model.params[1]
beta2:float = regression_model.params[2]
expected_market_return:float = alpha + self.regression_data[0][1]*beta1 + self.regression_data[0][2]*beta2
expected_market_variance:float = np.mean([x[1] for x in self.regression_data])
risk_aversion:int = 5
# equity and tbill weight
equity_weight:float = expected_market_return / (risk_aversion * expected_market_variance)
tbill_weight:float = 1 - equity_weight
# trade execution
if self.market in data and data[self.market] and self.bonds in data and data[self.bonds]:
self.SetHoldings(self.market, equity_weight)
self.SetHoldings(self.bonds, tbill_weight)
def CalculateFactorDailyReturns(self, factor_long:List[Symbol], factor_short:List[Symbol]) -> List[float]:
long_daily_returns:List[Symbol] = self.ListOfDailyReturns(factor_long, True)
short_daily_returns:List[Symbol] = self.ListOfDailyReturns(factor_short, False)
stocks_daily_returns:np.ndarray = np.array(long_daily_returns + short_daily_returns)
factor_daily_returns:List[float] = []
for i in range(len(stocks_daily_returns[0])):
factor_daily_returns.append(np.mean(stocks_daily_returns[:, i])) # Takes column of 2d array
return factor_daily_returns
def ListOfDailyReturns(self, symbols:List[Symbol], long_flag:bool) -> List[Symbol]:
symbol_list = []
for symbol in symbols:
# Those aren't symbols which were return from coarse
if symbol in self.data:
if self.data[symbol].is_ready():
if long_flag:
symbol_list.append(self.data[symbol].daily_performances())
else:
symbol_list.append(self.data[symbol].short_daily_performances())
return symbol_list
def MultipleLinearRegression(self, x, y):
x:np.ndarray = np.array(x).T
x = sm.add_constant(x)
result = sm.OLS(endog=y, exog=x).fit()
return result
def Selection(self):
if self.Time.month % 3 == 0:
self.selection_flag = True
class SymbolData():
def __init__(self, period:int) -> None:
self.closes:RollingWindow[float] = RollingWindow[float](period)
self.period:int = period
def update(self, close:float) -> None:
self.closes.Add(close)
def is_ready(self) -> bool:
return self.closes.IsReady
def performance(self) -> float:
return self.closes[0] / self.closes[self.period - 1] - 1
def daily_performances(self) -> np.ndarray:
closes = np.array([x for x in self.closes])
return (closes[:-1] - closes[1:]) / closes[1:]
def short_daily_performances(self) -> np.ndarray:
closes = np.array([x for x in self.closes])
return ( -(closes[:-1] - closes[1:]) ) / closes[1:] # We change mark of daily performance for short stocks.
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
VI. Backtest Performance