Quant BuffetRelax, Not Over Thinking

Timing S&P500 Using a Large Set of Forecasting Variables

Log in to collect

Academic paper

Return Predictability and Market-Timing: A One-Month Model

AuthorsBlair Hull; Xiao Qiao; Petra Bakosova

Institute
  • ?HTAA, LLC
  • HKCity University of Hong Kong
  • ?Paraconic Technologies US Inc
  • ?School of Data Science, City University of Hong Kong
  • ?Hull Tactical

Strategy in a nutshell

.Universe: S&P 500. Use 15 macroeconomic and financial predictors to forecast next-month equity risk premium via weighted least squares with stepwise variable selection. Allocate proportionally to S&P 500 if forecast positive, otherwise invest in Treasury bills. Model updated monthly; daily forecasts used for allocation.

Economic rationale

Macro and financial variables capture predictable variations in equity returns. Stepwise WLS modeling prioritizes recent data and significant predictors, producing parsimonious, reliable forecasts that enable dynamic, risk-adjusted allocation outperforming buy-and-hold strategies.

Backtest performance

Annualised return16.6%
Volatility16.6%
Beta0
Sharpe ratio0.76
Sortino ratio0
Maximum drawdown-20.3%
Win rate0%

Full Python code

from AlgorithmImports import *
import data_tools
from pandas.core.frame import DataFrame
from dateutil.relativedelta import relativedelta
import statsmodels.api as sm
from statsmodels.tools.eval_measures import rmse
# endregion
class TimingSP500UsingaLargeSetofForecastingVariables(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.period:int = 24
self.quarter_period:int = 3 # due to Deliquencies quarterly data
self.month_period:int = 21
self.max_alocation:float = 1.5
self.decay_factor:float = 0.99
self.leverage:int = 3
self.max_missing_days:int = 31
self.independent_variables_num:int = 12
self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.bil:Symbol = self.AddEquity('BIL', Resolution.Daily).Symbol
self.commodity_index:Symbol = self.AddEquity('GSG', Resolution.Daily).Symbol
self.dollar_index:Symbol = self.AddEquity('UUP', Resolution.Daily).Symbol
self.baltic_index:Symbol = self.AddData(data_tools.BalticDryIndex, 'BADI', Resolution.Daily).Symbol
for symbol in [self.market, self.bil]:
    self.Securities[symbol].SetLeverage(self.leverage)
quandl_tickers:List[str] = ['RATEINF/CPI_USA']
self.consumer_index_usa:Symbol = self.AddData(data_tools.QuandlValue, 'RATEINF/CPI_USA', Resolution.Daily).Symbol
quarterly_custom_data:List[str] = ['DRALACBN_YOY']
monthly_custom_data:List[str] = ['INDPRO_YOY', 'BAA_AAA', 'NEWORDER', 'HOUST', 'UNEMPLOYMENT_RATE']
daily_custom_data:List[str] = ['T10Y3M']
self.data:Dict[Symbol, float] = {}
self.quarterly_custom_data:List[Symbol] = [self.AddData(data_tools.QuarterlyCustomData, x, Resolution.Daily).Symbol for x in quarterly_custom_data]
self.monthly_custom_data:List[Symbol] = [self.AddData(data_tools.MonthlyCustomData, x, Resolution.Daily).Symbol for x in monthly_custom_data]
self.daily_custom_data:List[Symbol] = [self.AddData(data_tools.DailyCustomData, x, Resolution.Daily).Symbol for x in daily_custom_data] + [self.baltic_index]
self.qc_data:List[Symbol] = [self.consumer_index_usa]
self.recent_month:int = -1
def OnData(self, data: Slice):
# monthly rebalance
if self.recent_month == self.Time.month:
    return
self.recent_month = self.Time.month
quarterly_custom_data_last_update_date:Dict[Symbol, datetime.date] = data_tools.QuarterlyCustomData._last_update_date
monthly_custom_data_last_update_date:Dict[Symbol, datetime.date] = data_tools.MonthlyCustomData._last_update_date
daily_custom_data_last_update_date:Dict[Symbol, datetime.date] = data_tools.DailyCustomData._last_update_date
# check if data is still arriving
if all([self.Securities[x].GetLastData() for x in self.quarterly_custom_data + self.monthly_custom_data + self.daily_custom_data + self.qc_data]) \
    and any([self.Time.date() >= monthly_custom_data_last_update_date[x] for x in monthly_custom_data_last_update_date]) \
    and any([self.Time.date() >= quarterly_custom_data_last_update_date[x] for x in quarterly_custom_data_last_update_date]) \
    and any([self.Time.date() >= daily_custom_data_last_update_date[x] for x in daily_custom_data_last_update_date]) \
    and any((self.Time.date() - self.Securities[x].GetLastData().Time.date()).days >= self.max_missing_days for x in self.qc_data):
    self.Liquidate()
    return Universe.Unchanged
# call history on all variables
history:DataFrame = self.History(self.monthly_custom_data + self.daily_custom_data + self.qc_data + self.quarterly_custom_data, start=self.Time.date() - relativedelta(months=self.period + self.quarter_period), end=self.Time.date())['value'].unstack(level=0)
history_equities:DataFrame = self.History([self.market, self.commodity_index, self.dollar_index], start=self.Time.date() - relativedelta(months=self.period), end=self.Time.date())['close'].unstack(level=0)
history = history.groupby(pd.Grouper(freq='MS')).last()
history_equities = history_equities.groupby(pd.Grouper(freq='MS')).last()
history['DRALACBN_YOY'] = history['DRALACBN_YOY'].fillna(method='ffill') # forward fill due quarterly data
history = history[self.quarter_period:]
history = pd.concat([history, history_equities], axis=1)
history = history[1:]
if history.iloc[0].isna().any():
    return

if len(history.columns) < self.independent_variables_num:
    return
history['normalized_market'] = history[self.market]
history = history.fillna(method='ffill')
history[['INDPRO_YOY', self.commodity_index, self.dollar_index, self.baltic_index, self.consumer_index_usa, 'normalized_market']] = history[['INDPRO_YOY', self.commodity_index, self.dollar_index, self.baltic_index, self.consumer_index_usa, 'normalized_market']].pct_change()
history[['HOUST', 'UNEMPLOYMENT_RATE', 'DRALACBN_YOY']] = history[['HOUST', 'UNEMPLOYMENT_RATE', 'DRALACBN_YOY']].diff()
# normalize variables with their 2 year standard deviation
history_normalized = history.loc[:, history.columns != self.market].iloc[-1] / history.loc[:, history.columns != self.market][:-1].std()

# save values to expanding list
for symbol, value in history_normalized.items():
    if symbol not in self.data:
        self.data[symbol] = []
    self.data[symbol].append(value)

if self.market not in self.data:
    self.data[self.market] = []
self.data[self.market].append(history[self.market].pct_change().iloc[-1])
    
# run factor analysis
if len(self.data[next(iter(self.data))]) >= self.period:
    df:DataFrame = pd.DataFrame(self.data)
    result_factors:Dict[Symbol, float] = self.factor_analysis(self.market, df, len(history.loc[:, history.columns != self.market].columns))
    # run regression on best model and predict return of market 
    if result_factors:
        y:np.ndarray = df[self.market][1:].values
        x:np.ndarray = df[list(result_factors.keys())][:-1].values
        model = self.multiple_linear_regression(x, y)
        predicted_y:np.ndarray = model.predict(sm.add_constant(df[list(result_factors.keys())][-1:].values, has_constant='add'))
        rmse_value:float = rmse(y, predicted_y)
        alocation:float = predicted_y * (1 / rmse_value) * 5
        # trade execution
        if predicted_y < 0:
            self.Liquidate()
            self.SetHoldings(self.bil, 1)
            return
        traded_alocation:Tuple[float, float] = (alocation if alocation < self.max_alocation else self.max_alocation, self.max_alocation - alocation if alocation < self.max_alocation else 0)
        if self.market in data and data[self.market] and self.bil in data and data[self.bil]:
            self.SetHoldings(self.market, traded_alocation[0])
            self.SetHoldings(self.bil, traded_alocation[1])
def factor_analysis(self, asset_id: str, factor_df: pd.DataFrame, n:int=0, ignore_negative_loading:bool = False) -> Dict:
""" 
Performs factor analysis.
:param asset_id: Id of asset that is going to be fit.
:param factor_df: Performance dataframe with all the factors and asset with asset_id.
:param n: Max number of assets in the model.
:return: Fitted model dictionary.
"""
order:List[str] = []
y:np.ndarray = factor_df[asset_id].values 
reduced_df:DataFrame = factor_df.drop([asset_id], axis=1)
# reduced_df.drop(['Date'], axis=1, inplace=True)

for i, _ in enumerate(reduced_df.items()):
    # not negative beta models properties
    aic_reduced:List[float] = []
    col_names_reduced:List[str] = []
        
    for j, (j_asset_name, _) in enumerate(reduced_df.items()):
        if j_asset_name in order:
            continue
        
        if i > len(order) - 1:
            order.append(j_asset_name)
        else:
            order[i] = j_asset_name
        
        order_reduced:List[str] = [x for x in order if x != '']
        x:np.ndarray = reduced_df[order_reduced].values
        model = self.multiple_linear_regression(x, y)
        aic:float = model.aic
        
        if ignore_negative_loading:
            # store only not negative beta models
            if not any(x < 0. for x in betas_):
                aic_reduced.append(aic)
                col_names_reduced.append(j_asset_name)
        else:
            aic_reduced.append(aic)
            col_names_reduced.append(j_asset_name)
    
    if len(aic_reduced) == 0:
        order[i] = ''
    else:
        min_aic_index:int = min(range(len(aic_reduced)), key=aic_reduced.__getitem__)   # index of lowest aic
        order[i] = col_names_reduced[min_aic_index]
        
    if i > 0:
        order_reduced:List[str] = [x for x in order if x != '']
        previous_order:List[str] = [x for x in order_reduced[:-1]]
        
        if len(order_reduced) != 0 and len(previous_order) != 0:
            # build previous and actual regression models
            x:np.ndarray = reduced_df[order_reduced].values
            model = self.multiple_linear_regression(x, y)
            aic:float = model.aic
            betas:List[float] = model.params[1:]
            # betas, _, lm_aic = self.multiple_linear_regression(x, y)
            x = reduced_df[previous_order].values
            model = self.multiple_linear_regression(x, y)
            prev_aic:float = model.aic
            prev_betas:List[float] = model.params[1:]
            # prev_betas, _, previous_aic = self.multiple_linear_regression(y, x)
            
            if (aic >= prev_aic) or (n != 0 and len(order_reduced) > n):
                result:Dict[str, float] = { order_id : beta for order_id, beta in zip(previous_order, betas.tolist()) }
                return result

order_reduced:List[str] = [x for x in order if x != '']
x:np.ndarray = reduced_df[order_reduced].values
model = self.multiple_linear_regression(x, y)
betas:List[float] = model.params[1:]
# betas, _, lm_aic = self.multiple_linear_regression(y, x)
result:Dict[str, float] = { order_id : betas  for order_id, betas in zip(order_reduced, betas.tolist()) }
return result
def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray):
# x:np.ndarray = np.array(x).T
x = sm.add_constant(x, prepend=True)
w:List[float] = self.decay_factor ** np.arange(len(y), 0, -1)
result = sm.WLS(endog=y, exog=x, weights=w).fit()
return result