Lesson 2.3.5 · 75 min

Factors (value, quality, low volatility, size) and event strategies

A factor premium is a relationship between a characteristic known today and next month's return. This lesson measures it in three ways (quintiles, rank IC and Fama-MacBeth regressions) on a synthetic universe with three planted premiums and three controls with none, builds the long-short portfolios with and without beta neutrality and studies three types of event: earnings, index changes and dividends.

Fig. — t-statistic of the rank IC of six universe characteristics: three planted premia and three controls without any
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

One premium per characteristic

A factor is an observable characteristic of each stock (how cheap it is, how much it has risen, how volatile it is) that is related to its future return. The simplest factor strategy sorts the universe by the characteristic, buys one end and sells the other, and rebuilds the portfolio every month. The same structure works for value, momentum, low volatility, quality or size:

PIT dataMonth-end signalRank scoreSort and quintilesLong-short portfolioNext-month returnIC and Fama-MacBeth
Mermaid source
flowchart TB
  D[PIT data] --> S[Month-end signal]
  S --> P[Rank score]
  P --> O[Sort and quintiles]
  O --> C[Long-short portfolio]
  C --> R[Next-month return]
  R --> M[IC and Fama-MacBeth]

The factor literature is huge and full of false positives: Harvey, Liu and Zhu (2016) count hundreds of published factors and propose requiring a new one to have a t-statistic well above 2 (lesson 2.1.8). That is why this lesson works with data where the answer is known.

Signals without look-ahead and with bankruptcies included

Two errors invalidate a factor study before it starts: using a data point that was not known on the signal date and losing the stocks that stop trading (lessons 1.4.4 and 1.5.1). The first is solved by universo2_caracteristicas.parquet, which has one row per stock and month-end with what was known that day (equity and earnings from the last published report). The second has to be solved by hand: in the month a company goes bankrupt its return is −100%, and that row is in no monthly table because the stock no longer trades at the end of the month.

IN [01]python
import numpy as np
import pandas as pd

aj = pd.read_parquet("universo2_ajustados.parquet")
P = aj.pivot(index="fecha", columns="ticker", values="cierre_ajustado")
rd = P.pct_change(fill_method=None)

ev = pd.read_csv("universo2_eventos.csv", parse_dates=["fecha"])
for _, e in ev.dropna(subset=["rentabilidad_deslistado"]).iterrows():
    ultima = P[e["ticker"]].last_valid_index()
    rd.loc[ultima, e["ticker"]] = ((1 + rd.loc[ultima, e["ticker"]])
                                   * (1 + e["rentabilidad_deslistado"])
                                   - 1)

def a_fin_de_mes(df):
    df.index = df.index.to_timestamp("M")
    return df

mes = rd.index.to_period("M")
r_mes = a_fin_de_mes((1 + rd).groupby(mes).prod(min_count=1) - 1)
print(r_mes.shape)
r_mes.loc["2008-09":"2008-11", ["QXQT", "QXAC", "QXAI"]].round(3)
OUT [01]Daily and monthly returns with delisting returns
(240, 120)
ticker       QXQT  QXAC   QXAI
fecha
2008-09-30 -0.357   NaN -0.154
2008-10-31 -1.000   NaN -0.087
2008-11-30    NaN   NaN  0.210

QXQT goes bankrupt in October 2008: its last session is on the 17th and that month's return is −100%, because the delisting return (the shares are cancelled) is compounded with that of its last session. QXAC, acquired for cash in 2007, no longer appears. Without this step, a portfolio holding QXQT in October 2008 would count its −35.7% in September and then nothing, as if it had been sold in time.

Signals are computed at the close of the last day of each month and compared with the return of the next month. 12-1 momentum is the return from the end of month t−12t-12 to the end of t−1t-1: it skips the last month, which shows a small reversal. Volatility uses the last 252 sessions (minimum 126) and beta, the covariance with the universe's equal-weighted index over the same window. Value, quality, size and ESG come from the point-in-time table.

IN [02]python
P_mes = a_fin_de_mes(P.groupby(mes).last())
momentum = P_mes.shift(1) / P_mes.shift(12) - 1

mercado = rd.mean(axis=1)
vol_d = rd.rolling(252, min_periods=126).std()
beta_d = (rd.rolling(252, min_periods=126).cov(mercado)
          .div(mercado.rolling(252, min_periods=126).var(), axis=0))
vol = a_fin_de_mes(vol_d.groupby(mes).last())
beta = a_fin_de_mes(beta_d.groupby(mes).last())

ch = pd.read_parquet("universo2_caracteristicas.parquet")
ch["mes"] = ch["fecha"].dt.to_period("M").dt.to_timestamp("M")
ch = ch.set_index(["mes", "ticker"])

S = pd.DataFrame({"momentum": momentum.stack(),
                  "baja_vol": -vol.stack(), "beta": beta.stack()})
S.index.names = ["mes", "ticker"]
S = S.join(ch[["capitalizacion", "patrimonio_neto", "roe",
               "puntuacion_esg", "horquilla_pb"]], how="inner")
pn = S["patrimonio_neto"].where(S["patrimonio_neto"] > 0)
S["value"] = np.log(pn / S["capitalizacion"])
S["calidad"] = S["roe"]
S["tamano"] = np.log(S["capitalizacion"])
S["esg"] = S["puntuacion_esg"].astype(float)
siguiente = r_mes.shift(-1).stack()
siguiente.index.names = ["mes", "ticker"]
S["r_sig"] = siguiente
S = S.dropna(subset=["r_sig"])

SENALES = ["momentum", "value", "baja_vol",
           "calidad", "tamano", "esg"]
print(len(S), "stock-months,",
      S.index.get_level_values("mes").nunique(), "months")
S[SENALES].count()
OUT [02]Month-end signals
25890 stock-months, 239 months
momentum    24450
value       25890
baja_vol    25170
calidad     25890
tamano      25890
esg         25890
dtype: int64

That leaves 25,890 stock-months over 239 months, about 108 stocks per month. Momentum needs twelve months of history and volatility six, so they have fewer observations. No signal uses data later than the month-end: prices are from that day, and equity and ROE come from the last report published before it.

Three ways to measure a premium

Rank IC

The rank IC (information coefficient) is the Spearman correlation, each month, between the signal and the next month's return. Its mean says how well the signal ranks; its t-statistic, with one data point per month, whether that mean is distinguishable from zero. Because it uses ranks, a few extreme returns (a bankruptcy, a takeover bid) do not dominate it.

IN [03]python
def ic_mensual(s):
    return S.groupby(level="mes").apply(
        lambda g: g[s].corr(g["r_sig"], method="spearman")).dropna()

ic = pd.DataFrame({s: ic_mensual(s) for s in SENALES})
tabla_ic = pd.DataFrame({
    "ic_medio": ic.mean(),
    "t": ic.mean() / ic.std() * np.sqrt(ic.count()),
    "meses_pos_pct": 100 * (ic > 0).mean(),
    "verdad": ["premium", "premium", "premium", "0", "0", "0"],
})
tabla_ic.round(3)
OUT [03]Monthly rank IC by characteristic
          ic_medio      t  meses_pos_pct   verdad
momentum     0.028  2.928         55.649  premium
value        0.025  3.077         56.485  premium
baja_vol     0.013  1.049         53.138  premium
calidad     -0.033 -4.610         38.912        0
tamano      -0.006 -0.834         46.862        0
esg          0.010  1.558         56.485        0

The three planted factors have a positive IC: momentum (0.028, t = 2.9) and value (0.025, t = 3.1), close to the +0.03 and +0.02-0.03 the generator announces; low volatility, 0.013 with t = 1.0, almost invisible. An IC of 0.03 is small: it is positive in 56% of months and negative in the rest. Among the controls, size and ESG are indistinguishable from zero, but quality has the most significant IC in the table: −0.033 with t = −4.6, and negative in 61% of months. An analysis that stopped here would conclude that buying low-ROE companies is the best strategy in the universe. The generator's truth is that quality has no premium at all.

Quintiles

The quintile sort answers a different question: is the relationship monotonic, or is the whole premium at one end? Each month the stocks are split into five groups by the signal and the equal-weighted average return of each group is measured over the following month.

IN [04]python
def quintil(g, s):
    return pd.qcut(g[s].rank(method="first"), 5, labels=False)

filas = {}
for s in SENALES:
    d = S.dropna(subset=[s]).copy()
    d["q"] = d.groupby(level="mes", group_keys=False).apply(
        lambda g: quintil(g, s))
    q = d.groupby(["mes", "q"])["r_sig"].mean().unstack()
    fila = 100 * 12 * q.mean()
    fila["Q5-Q1"] = fila[4] - fila[0]
    dif_q = q[4] - q[0]
    fila["t"] = dif_q.mean() / dif_q.std() * np.sqrt(len(dif_q))
    filas[s] = fila
pd.DataFrame(filas).T.round(2)
OUT [04]Annual return by quintile (equal-weighted)
q             0      1      2      3      4  Q5-Q1     t
momentum   0.63   9.88  10.06  10.48  10.27   9.65  3.48
value      4.09   6.88  10.07  10.21  10.79   6.71  2.92
baja_vol   3.32  10.64   9.88   9.10   7.71   4.39  1.25
calidad   11.19  12.41   7.59   6.03   4.71  -6.48 -3.26
tamano     8.46   9.76   7.41   8.22   8.07  -0.38 -0.18
esg        7.43   9.25   6.42   8.31  10.47   3.04  1.44

Columns 0 to 4 are the quintiles, from the lowest value of the signal to the highest, in annual return. Value is almost monotonic (from 4.1% to 10.8%). Momentum is not: the loser quintile returns 0.6% and the other four, all around 10%. That difference of 9.6 points is much larger than the planted premium delivers; exercise 1 explains where it comes from. Low volatility is hump-shaped: the most volatile quintile returns 3.3% and the calmest, 7.7%, below the middle ones. Quality repeats the IC's signal with −6.5 points between the extremes.

Fig. — Next-month annual return by quintile: value and low volatility (equal-weighted, 2006-2025)

The figure compares the two shapes. In value, return rises with the signal in an orderly way. In low volatility, the most volatile quintile, highlighted, lags far behind, but the calmest does not beat the rest: in raw returns, the low-volatility premium is masked by beta. Calm stocks have less beta and in a market rising 8% a year they earn less through that channel.

Fama-MacBeth

The two previous measures look at one signal at a time. But the characteristics are correlated with one another, and one can look profitable only because it resembles another that is:

IN [05]python
S[["value", "calidad", "momentum", "baja_vol", "beta",
   "tamano"]].corr(method="spearman").round(2)
OUT [05]Rank correlation between characteristics
          value  calidad  momentum  baja_vol  beta  tamano
value      1.00    -0.79     -0.23     -0.23  0.12   -0.37
calidad   -0.79     1.00     -0.06      0.15 -0.13    0.34
momentum  -0.23    -0.06      1.00      0.31  0.02    0.14
baja_vol  -0.23     0.15      0.31      1.00 -0.56    0.26
beta       0.12    -0.13      0.02     -0.56  1.00   -0.28
tamano    -0.37     0.34      0.14      0.26 -0.28    1.00

The characteristics are not independent. Quality and value have a rank correlation of −0.79: ROE is earnings divided by equity, and a company that is cheap relative to equity (high value) tends to have a low ROE. Low volatility and beta, −0.56: calm stocks have less beta. Value and momentum, −0.23: stocks that have fallen are cheaper. Each of these pairs can make a signal look like what it is not.

The Fama-MacBeth regression (1973) separates what each one contributes. Each month a cross-sectional regression of next month's return on all the signals at once is estimated,

ri,t+1=γ0,t+∑kγk,t zk,i,t+εi,t+1,r_{i,t+1} = \gamma_{0,t} + \sum_k \gamma_{k,t}\, z_{k,i,t} + \varepsilon_{i,t+1},

and then the γk,t\gamma_{k,t} of the 239 months are averaged. The standard error comes from how the coefficients vary from one month to the next, which already includes the correlation between stocks in the same month; Newey-West adds their autocorrelation (lesson 2.1.1). The signals enter as normal rank scores, the same transformation the generator uses, so the coefficients are directly comparable with the known answer: 0.25% a month for momentum, 0.40% for value, 0.25% for low volatility and 0 for the rest.

IN [06]python
import statsmodels.api as sm
from scipy.stats import norm

def puntuacion(v):
    return norm.ppf((v.rank() - 0.5) / v.count())

def fama_macbeth(cols, lags=3):
    coefs = []
    for m, g in S.dropna(subset=cols).groupby(level="mes"):
        if len(g) < 30:
            continue
        X = np.column_stack([np.ones(len(g))]
                            + [puntuacion(g[c]) for c in cols])
        b = np.linalg.lstsq(X, g["r_sig"].to_numpy(), rcond=None)[0]
        coefs.append(b[1:])
    coefs = pd.DataFrame(coefs, columns=cols)
    filas = {}
    for c in cols:
        nw = sm.OLS(coefs[c], np.ones(len(coefs))).fit(
            cov_type="HAC", cov_kwds={"maxlags": lags})
        filas[c] = {"coef_pct_mes": 100 * nw.params.iloc[0],
                    "t_nw": nw.tvalues.iloc[0]}
    return pd.DataFrame(filas).T

fm = fama_macbeth(SENALES + ["beta"])
fm["verdad"] = [0.25, 0.40, 0.25, 0, 0, 0, 0]
fm.round(2)
OUT [06]Fama-MacBeth with all signals and beta
          coef_pct_mes  t_nw  verdad
momentum          0.46  5.09    0.25
value             0.29  2.14    0.40
baja_vol          0.30  2.47    0.25
calidad          -0.02 -0.13    0.00
tamano            0.06  0.98    0.00
esg               0.09  1.66    0.00
beta              0.29  1.94    0.00

With all the signals at once, quality drops to −0.02% a month with t = −0.13: nothing, the known answer. Value comes out at 0.29 (truth 0.40) and low volatility at 0.30 (truth 0.25), both with |t| > 2. Size, ESG and beta are indistinguishable from zero with a threshold of 2, although ESG gets close (t = 1.66). Momentum comes out at 0.46, almost double its 0.25: the coefficient is linear in returns, and the four scripted bankruptcies (stocks with very low momentum that end at −100%) inflate it. The rank IC, which does not weight extreme returns, measures it better.

IN [07]python
pares = {"value alone": ["value"],
         "value + momentum": ["value", "momentum"],
         "calidad alone": ["calidad"],
         "calidad + value": ["calidad", "value"],
         "calidad + value + momentum": ["calidad", "value", "momentum"],
         "baja_vol alone": ["baja_vol"],
         "baja_vol + beta": ["baja_vol", "beta"]}
pd.DataFrame({k: fama_macbeth(v).iloc[0]
              for k, v in pares.items()}).T.round(2)
OUT [07]Coefficient of the first signal depending on the controls
                            coef_pct_mes  t_nw
value alone                         0.16  2.09
value + momentum                    0.27  3.38
calidad alone                      -0.20 -2.85
calidad + value                    -0.21 -1.75
calidad + value + momentum          0.02  0.19
baja_vol alone                      0.10  0.92
baja_vol + beta                     0.31  2.69

Each row is the coefficient of the first signal with the others as controls. On its own, quality has −0.20% a month with t = −2.85. With value, it stays at −0.21: value alone is in turn attenuated (0.16) by its negative correlation with momentum. With value and momentum, quality falls to 0.02 with t = 0.19. Value, when momentum is added, rises from 0.16 to 0.27. Low volatility goes from 0.10 (t = 0.92) to 0.31 (t = 2.69) when controlling for beta.

This is the typical confounding of factor studies: the effect of a characteristic depends on which others it is compared with, and omitting a single one (here, momentum) changes the conclusions about the rest. The multiple regression does not guarantee the truth, but the univariate sort does not even try.

Long-short portfolios: dollar-neutral or beta-neutral

A dollar-neutral long-short portfolio buys the top quintile and sells the bottom one for the same amount. If the two quintiles have different betas, the portfolio is left exposed to the market. The alternative, beta-neutral, scales each leg by the inverse of its average beta before the portfolio is formed, as in Frazzini and Pedersen (2014):

rLSβ=rLβL−rSβSr^{\beta}_{LS} = \frac{r_L}{\beta_L} - \frac{r_S}{\beta_S}

Costs are approximated with each stock's quoted half spread (horquilla_pb), paid on each leg's turnover. The portfolio is formed at the month-end close with that day's signals and earns the next month's return: execution is at the signal's close, an optimistic simplification that lesson 2.4.8 discusses.

IN [08]python
mercado_mes = r_mes.mean(axis=1)

def long_short(s, neutral_beta=False):
    d = S.dropna(subset=[s, "beta"]).copy()
    d["q"] = d.groupby(level="mes", group_keys=False).apply(
        lambda g: quintil(g, s))
    r, coste, prev = {}, {}, None
    for m, g in d.groupby(level="mes"):
        L, C = g[g["q"] == 4], g[g["q"] == 0]
        kL = 1 / L["beta"].mean() if neutral_beta else 1.0
        kC = 1 / C["beta"].mean() if neutral_beta else 1.0
        tL = L.index.get_level_values("ticker")
        tC = C.index.get_level_values("ticker")
        w = pd.concat([pd.Series(kL / len(L), tL),
                       pd.Series(-kC / len(C), tC)])
        giro = w.sub(prev, fill_value=0).abs() if prev is not None \
            else w.abs()
        media_h = g["horquilla_pb"].droplevel(0) / 2 / 1e4
        coste[m] = (giro * media_h.reindex(giro.index)).sum()
        r[m] = kL * L["r_sig"].mean() - kC * C["r_sig"].mean()
        prev = w
    r, coste = pd.Series(r), pd.Series(coste).fillna(0)
    neto = r - coste
    b = np.polyfit(mercado_mes.shift(-1).reindex(r.index), r, 1)[0]
    return pd.Series({"rent_anual_pct": 1200 * neto.mean(),
                      "coste_anual_pct": 1200 * coste.mean(),
                      "sharpe": neto.mean() / neto.std() * np.sqrt(12),
                      "beta": b}), neto

ls_tabla, ls_series = {}, {}
for s in ["baja_vol", "value", "momentum", "calidad", "tamano"]:
    for nb in (False, True):
        clave = f"{s} {'beta' if nb else 'dollar'}"
        ls_tabla[clave], ls_series[clave] = long_short(s, nb)
pd.DataFrame(ls_tabla).T.round(2)
OUT [08]Quintile long-short, with bid-ask spread costs
                 rent_anual_pct  coste_anual_pct  sharpe  beta
baja_vol dollar            4.27             0.13    0.28 -0.45
baja_vol beta              8.36             0.15    0.65  0.08
value dollar               6.98             0.12    0.68  0.07
value beta                 5.63             0.13    0.59 -0.01
momentum dollar            9.28             0.37    0.77  0.04
momentum beta             11.04             0.38    1.01 -0.01
calidad dollar            -6.52             0.06   -0.74 -0.08
calidad beta              -5.45             0.07   -0.65  0.03
tamano dollar             -0.20             0.05   -0.02 -0.17
tamano beta                1.81             0.06    0.21  0.01

Dollar-neutral low volatility earns 4.27% a year net with a Sharpe of 0.28 and a beta of −0.45: it is short the market. Beta-neutral, 8.36% with a Sharpe of 0.65 and a beta of 0.08. In value and momentum the extreme quintiles have similar betas and neutrality changes little. Quality loses with both constructions (Sharpe −0.74 and −0.65): the long-short of a confounded characteristic inherits the confounding, and nobody would notice without the regression. Size gives nothing. Bid-ask spread costs are small (0.05% to 0.38% a year) because the quintiles change little from one month to the next and the universe's stocks are liquid; momentum, which rotates more, is the most expensive.

Fig. — Low-volatility long-short (calm quintile against volatile), dollar-neutral and beta-neutral, net of spread

The figure shows the two low-volatility curves. The dollar-neutral one gains in down years (23% in 2008, 31% in 2022) and loses in strong up years (from 2017 to 2019 and from 2023 to 2025), because it is short the market; the beta-neutral one, highlighted, ends at 430 versus 182. Beta neutrality has a cost the table does not see: it requires leveraging the calm leg (with an average beta of 0.71, about 1.4 USD bought for every dollar) and financing that leverage.

Event strategies

An event study aligns each observation by its event date, not by calendar, and measures the abnormal return: the stock's return minus the market's (here, the universe's equal-weighted index). Summed over a window it is the CAR (cumulative abnormal return). The golden rule is the same as throughout the level: the strategy's window starts after the information is public.

AnnouncementReactionEntry day +1Exit day +60
Mermaid source
flowchart LR
  A[Announcement] --> R[Reaction]
  R --> E[Entry day +1]
  E --> S[Exit day +60]

Post-earnings-announcement drift (PEAD)

eventos2_resultados.csv has 8,656 quarterly reports with estimated and actual EPS. The standardized unexpected earnings (SUE) divide the surprise by its standard deviation over the previous eight quarters (minimum four): it only uses surprises already published. The reaction session is the announcement day if it is released before the open and the next one if it is released after the close.

IN [09]python
res = pd.read_csv("eventos2_resultados.csv",
                  parse_dates=["fecha_anuncio", "periodo_fin"])
res = res.sort_values(["ticker", "periodo_fin"])
res["sd8"] = res.groupby("ticker")["sorpresa"].transform(
    lambda s: s.shift(1).rolling(8, min_periods=4).std())
res["sue"] = res["sorpresa"] / res["sd8"]

sesiones = P.index
res["d0"] = (sesiones.searchsorted(res["fecha_anuncio"])
             + (res["momento"] == "tras_cierre").astype(int))
res = res.dropna(subset=["sue"])
res = res[res["d0"] + 60 < len(sesiones)].copy()
trimestre = res["fecha_anuncio"].dt.to_period("Q")
res["decil"] = res.groupby(trimestre)["sue"].transform(
    lambda s: pd.qcut(s.rank(method="first"), 10, labels=False))
print(len(res), "announcements with SUE")
res.groupby("decil")["sue"].median().round(2).to_numpy()
OUT [09]SUE, reaction session and deciles by quarter
8066 announcements with SUE
array([-1.93, -1.13, -0.69, -0.4 ,  0.  ,  0.  ,  0.36,  0.68,  1.08,
        1.9 ])
IN [10]python
anormal = rd.sub(rd.mean(axis=1), axis=0).to_numpy()
col = {t: j for j, t in enumerate(rd.columns)}

def ventana(d0, tickers, desde, hasta):
    dias = np.arange(desde, hasta + 1)
    M = np.full((len(d0), len(dias)), np.nan)
    for k, (d, t) in enumerate(zip(d0, tickers)):
        idx = d + dias
        ok = (idx >= 0) & (idx < len(sesiones))
        M[k, ok] = anormal[idx[ok], col[t]]
    return pd.DataFrame(np.nan_to_num(M), columns=dias)

ar = ventana(res["d0"].to_numpy(), res["ticker"], -20, 60)
ar_dec = ar.groupby(res["decil"].to_numpy()).mean()
car_dec = 100 * ar_dec.loc[:, 1:].cumsum(axis=1)
resumen_pead = pd.DataFrame({
    "antes_-20_-1": 100 * ar_dec.loc[:, -20:-1].sum(axis=1),
    "reaccion_0": 100 * ar_dec[0],
    "deriva_1_60": car_dec[60],
})
resumen_pead.loc[[0, 1, 4, 5, 8, 9]].round(2)
OUT [10]Abnormal return in event time
   antes_-20_-1  reaccion_0  deriva_1_60
0          0.10       -4.32        -0.98
1         -0.02       -3.13        -0.19
4         -0.04       -0.36        -0.68
5          0.07        0.38         0.08
8          0.56        2.82         0.91
9         -0.17        4.33         1.37

Decile 10 (the largest positive surprises) jumps 4.33% on the reaction day and keeps rising 1.37% over the next 60 sessions; decile 1 falls 4.32% and drifts a further −0.98%. The difference in drift between the extremes is 2.35%, exactly what the generator plants (2% to 3%). In the middle deciles there is no noticeable jump or drift. The antes_-20_-1 column is the control: in the 20 sessions before the announcement no drift is planted, and the measured values are noise (from −0.17% to +0.56%, with no order across deciles).

Fig. — Post-earnings drift: cumulative abnormal return from the session after the reaction, by surprise decile (SUE)

The figure plots the CAR from the session after the reaction. The curves of the extreme deciles separate gradually over the 60 sessions: a slow drift that does not run out in a few days. It is the regularity Bernard and Thomas (1989) documented in real data: prices take time to fully incorporate the earnings surprise. In the course data it is a planted drift of +0.01% per session and unit of surprise.

Index additions and deletions

The universe's fictitious index is reviewed every quarter: the company that has grown comes in and the one that has shrunk goes out. The change is announced five sessions before it takes effect, and funds that track the index have to buy or sell at the close of the effective date. That forced flow moves the price (Shleifer, 1986; Harris and Gurel, 1986)1Note 1 · Andrei Shleifer (1986), “Do Demand Curves for Stocks Slope Down?”, The Journal of Finance 41(3), doi:10.1111/j.1540-6261.1986.tb04518.x; Lawrence Harris and Eitan Gurel (1986), “Price and Volume Effects Associated with Changes in the S&P 500 List”, The Journal of Finance 41(4), doi:10.1111/j.1540-6261.1986.tb04550.x.: it rises between the announcement and the effective date and reverses afterwards. The classic strategy buys the day after the announcement and sells at the close of the effective date, to the index funds.

IN [11]python
rb = pd.read_csv("eventos2_rebalanceos.csv",
                 parse_dates=["fecha_anuncio", "fecha_efectiva"])
rb["d0"] = sesiones.searchsorted(rb["fecha_anuncio"])
rb["de"] = sesiones.searchsorted(rb["fecha_efectiva"]) - rb["d0"]
print("sessions from announcement to effective date:",
      sorted(rb["de"].unique()))

filas = {}
car_rb = {}
for (tipo, motivo), g in rb.groupby(["tipo", "motivo"]):
    car = 100 * ventana(g["d0"].to_numpy(), g["ticker"],
                        -5, 25).mean().cumsum()
    car_rb[(tipo, motivo)] = car
    de = int(g["de"].max())
    filas[(tipo, motivo)] = {
        "n": len(g),
        "anuncio_a_efectiva": car[de] - car[0],
        "efectiva_a_+20": car[de + 20] - car[de]}
pd.DataFrame(filas).T.round(2)
OUT [11]CAR around index rebalances
sessions from announcement to effective date: [np.int64(4), np.int64(5)]
                             n  anuncio_a_efectiva  efectiva_a_+20
alta revision_trimestral  20.0                2.20           -1.70
     sustitucion           7.0                2.39           -4.21
baja deslistado            7.0              -13.42            0.00
     revision_trimestral  20.0               -3.97           -0.57

From the announcement to the effective date there are 4 or 5 sessions. Additions in a quarterly review rise 2.20% in that window and reverse −1.70% over the next 20 sessions: close to what was planted (+3.0% and −1.5%), with 20 events. Deletions in a review fall 3.97% and barely reverse (−0.57% instead of +1.5%): with 20 events, the standard error of a 20-session CAR is several points and a reversal of 1.5 cannot be told apart from one of 0. Additions by replacement (7 cases) behave like the other additions. Deletions due to delisting have no planted effect: their −13.4% is the fall of companies going bankrupt, which was already known before the announcement. Mixing them with the other deletions would distort the study.

Fig. — Index additions and deletions at quarterly reviews: cumulative abnormal return from the announcement

The figure shows the average CAR of additions and deletions in quarterly reviews, with the announcement and the effective date marked. The move is concentrated between the two lines: it is the window in which the index funds have not yet bought and everyone else gets ahead of them.

Dividends: the control

On a dividend's ex-date the traded price falls by roughly the amount paid, because whoever buys that day no longer receives it (lesson 1.2.1). With unadjusted prices it looks like a negative “ex-date effect”; with total return there is nothing, and in this data no effect is planted: it is the control for this section.

IN [12]python
dv = pd.read_csv("universo2_dividendos.csv", parse_dates=["fecha_ex"])
pr = pd.read_parquet("universo2_precios.parquet").pivot(
    index="fecha", columns="ticker", values="cierre")
r_precio = pr.pct_change(fill_method=None).to_numpy()
i = sesiones.searchsorted(dv["fecha_ex"])
j = dv["ticker"].map(col).to_numpy()
ok = i < len(sesiones)
rex = pd.DataFrame({"precio": r_precio[i[ok], j[ok]],
                    "anormal_total": anormal[i[ok], j[ok]]})
print(len(rex), "ex-dates")
(100 * rex.agg(["mean", "std"])).round(3)
OUT [12]Ex-date session: traded price versus total return
7029 ex-dates
      precio  anormal_total
mean  -0.732          0.005
std    1.991          1.509

Across the 7,029 ex-dates, the traded price falls on average 0.73%, what the universe's quarterly dividends pay. The abnormal total return is +0.005%: nothing. A rule that “sells before the ex-date” with unadjusted prices would seem to avoid a certain drop; with total returns it is clear that the drop is offset by the dividend (and in real life, taxes on the dividend change the math).

What changes in real life

  • Short selling costs money. A long-short needs to borrow the shares of the quintile it sells. The cheapest to short are large, liquid stocks; the ones that weigh most in a momentum or low-quality portfolio tend to be hard to borrow. costes2_prestamo.csv has rates of 0.25% to 0.50% for most and 25% to 60% for the bankruptcies in their last months (lesson 2.4.7).
  • Published premiums shrink. Many anomalies return less after they are published; part of the premium was overfitting and part gets arbitraged away.
  • Capacity. An equal-weighted quintile portfolio across 108 stocks puts the same money in the smallest as in the largest. Weighting by market capitalization or capping by volume (lesson 2.2.3) changes the result.
  • Events and execution. Post-earnings drift is spread over 60 sessions, so it tolerates next-day execution well; the rebalance effect is concentrated in five sessions and many participants compete for it, and selling at the close of the effective date requires trading in the auction (lesson 1.1.2).

Exercises

  1. The quintile momentum long-short earns 9.6% a year, much more than its planted premium predicts. Repeat the quintile calculation removing the four companies that go bankrupt and explain the difference. Which measure in the lesson does not have this problem?
Solution
IN [13]python
quiebras = ev.loc[ev["evento"] == "deslistado_quiebra", "ticker"]
sin_q = S[~S.index.get_level_values("ticker").isin(quiebras)]
sin_q = sin_q.dropna(subset=["momentum"]).copy()
sin_q["q"] = sin_q.groupby(level="mes", group_keys=False).apply(
    lambda g: quintil(g, "momentum"))
q_sin = sin_q.groupby(["mes", "q"])["r_sig"].mean().unstack()
print("Q1..Q5 without bankruptcies (% annual):",
      (1200 * q_sin.mean()).round(2).tolist())
print("Q5-Q1:", round(1200 * (q_sin[4] - q_sin[0]).mean(), 2))
print("Mean IC with and without bankruptcies:",
      round(ic["momentum"].mean(), 4),
      round(sin_q.groupby(level="mes").apply(
          lambda g: g["momentum"].corr(g["r_sig"], "spearman")
      ).mean(), 4))
OUT [13]Momentum without the bankruptcies
Q1..Q5 without bankruptcies (% annual): [3.45, 10.03, 10.54, 10.4, 10.29]
Q5-Q1: 6.84
Mean IC with and without bankruptcies: 0.0279 0.0219

Without the four bankruptcies, the loser quintile goes from returning 0.63% to 3.45% a year and the difference between the extremes drops from 9.65 to 6.84 points. The generator's scripted bankruptcies spend months falling (very low momentum) and end at −100%: an equal-weighted portfolio of twenty stocks holding one of them loses five points that month. The rank IC barely changes (from 0.028 to 0.022): because it uses ranks, a return of −100% weighs the same as one of −20%. That is why the rank IC is the robust measure, and why bankruptcies must never be removed from a backtest: they also happen in real life, and they are part of what a strategy earns (or loses).

  1. Compute a PEAD portfolio: buy SUE decile 10 and sell decile 1 on the session after the reaction, hold for 60 sessions and pay 10 basis points per side on each leg. How much of the drift is left?
Solution
IN [14]python
coste_ida_vuelta = 2 * 10 / 1e4          # entry and exit, per leg
bruto = car_dec[60].loc[9] - car_dec[60].loc[0]
neto = bruto - 100 * 2 * coste_ida_vuelta
print(f"D10 - D1 drift: {bruto:.2f} %  |  net: {neto:.2f} %"
      "  (over 60 sessions)")
OUT [14]PEAD net of costs
D10 - D1 drift: 2.35 %  |  net: 1.95 %  (over 60 sessions)

The drift between the extremes is 2.35% over 60 sessions; with 10 basis points per side on both legs (40 basis points per round trip) 1.95% is left. With four announcements a year per company and a 60-session window, a PEAD portfolio is almost always invested, so that 1.95% repeats about four times a year on the part of capital allocated to each event. The cost of shorting the bottom decile is still missing.

  1. A data vendor sells an ESG score and shows a backtest with a Sharpe of 0.4 and an IC with t = 1.6 over twenty years. Based on this lesson, what would you ask before buying it?
Solution

How many signals it tested before showing that one (with six characteristics, a t of 1.6 in one of them is what chance would produce: in this universe ESG is pure noise and gives exactly that). Whether the score is point-in-time or was revised backwards with later information. Whether the result survives a Fama-MacBeth regression with value, size, momentum and beta, because an ESG score is usually correlated with size and sector. Whether the portfolio is beta- and sector-neutral, how much it costs to short the bottom quintile and what happens outside the sample in which it was designed. With t = 1.6 and no prior hypothesis, no reasonable threshold for a new factor (lesson 2.1.8) accepts it.

Footnotes

  1. Andrei Shleifer (1986), “Do Demand Curves for Stocks Slope Down?”, The Journal of Finance 41(3), doi:10.1111/j.1540-6261.1986.tb04518.x; Lawrence Harris and Eitan Gurel (1986), “Price and Volume Effects Associated with Changes in the S&P 500 List”, The Journal of Finance 41(4), doi:10.1111/j.1540-6261.1986.tb04550.x. ↩

Each answer is folded under its question

Self-assessment

  1. Quality (ROE) has a rank IC of −0.033 with t = −4.6, but in a Fama-MacBeth regression with the other signals its coefficient is almost zero. What is going on?

    • AQuality is strongly correlated (−0.79) with value and, on its own, inherits value's premium with the sign flipped; controlling for value and momentum it explains nothing
    • BQuality has a negative premium
    • CFama-MacBeth is miscalculated
    • DRank IC is useless
    Show answer

    Answer A. Quality is strongly correlated (−0.79) with value and, on its own, inherits value's premium with the sign flipped; controlling for value and momentum it explains nothing

    ROE is (E/P)·(P/B): a company that is cheap on P/B tends to have a low ROE. The univariate sort measures the total relationship, which includes what quality shares with value. The multiple regression separates what belongs to each characteristic: with value and momentum, the quality coefficient drops to 0.02% a month with t = 0.19 (with all the signals, −0.02 and t = −0.13). Value alone is not enough, because value is in turn attenuated by its correlation with momentum. The known answer is 0.

  2. The portfolio that buys the lowest-volatility quintile and sells the highest earns 4.3% a year net with a Sharpe of 0.28 and a beta of −0.45. Beta-neutral, 8.4% with a Sharpe of 0.65. Why does it change so much?

    • ABecause of costs
    • BBecause the beta-neutral portfolio holds fewer stocks
    • CBy chance
    • DBecause volatile stocks have more beta: the dollar-neutral portfolio is short the market and loses the market premium; scaling each leg by its beta reveals the anomaly
    Show answer

    Answer D. Because volatile stocks have more beta: the dollar-neutral portfolio is short the market and loses the market premium; scaling each leg by its beta reveals the anomaly

    With average betas of 0.71 in the calm quintile and 1.24 in the volatile one, buying one and selling the other in equal amounts leaves a very negative beta. In a rising market that costs the market premium and hides the anomaly. It is the construction of Frazzini and Pedersen (2014): lever up the low-beta leg and scale down the high-beta leg.

  3. What distinguishes Fama-MacBeth from a pooled regression of all months and stocks at once?

    • ANothing
    • BIt estimates one cross-sectional regression per month and averages the coefficients: the standard error comes from the variation of the coefficients across months, which captures the cross-sectional correlation within a month
    • CIt uses only large stocks
    • DIt does not need point-in-time data
    Show answer

    Answer B. It estimates one cross-sectional regression per month and averages the coefficients: the standard error comes from the variation of the coefficients across months, which captures the cross-sectional correlation within a month

    Within a month all stocks share the market and sector shock, and the residuals are correlated. A pooled regression with ordinary errors would treat 26,000 observations as independent. Fama-MacBeth reduces the problem to a series of 239 monthly coefficients and, with Newey-West, also accounts for their autocorrelation.

  4. In post-earnings-announcement drift (PEAD), why does the measurement window start on the session after the reaction?

    • ABecause there is no data on the reaction day
    • BTo make the result larger
    • CBecause the reaction-day jump is already in the price when the surprise becomes known: a strategy can only buy afterwards, and what the drift measures is what is left
    • DBy accounting convention
    Show answer

    Answer C. Because the reaction-day jump is already in the price when the surprise becomes known: a strategy can only buy afterwards, and what the drift measures is what is left

    With an announcement before the open, the reaction session is the same day; after the close, the next one. In both cases the surprise becomes known and the price jumps at the same time. Counting that jump in the strategy would be look-ahead. In the data, the average jump is ±4.3% and the subsequent drift between the top and bottom deciles, about 2.4%.

  5. The universe's ESG score gives an IC with t = 1.6 and a Fama-MacBeth coefficient with t = 1.7. What is it?

    • AA placebo: the generator makes it independent of everything; its t is the kind of result chance often produces when several signals are tested
    • BA real ESG premium
    • CA data error
    • DA size effect
    Show answer

    Answer A. A placebo: the generator makes it independent of everything; its t is the kind of result chance often produces when several signals are tested

    The universe's ESG score is a monthly AR(1) process independent of returns. That it comes out with t = 1.6 and 1.7 is a reminder of how easy it is to find a “promising” signal in noise: with six characteristics tested, one or two t-stats above 1.5 are normal.

Key points

  • A factor premium is measured in three layers: quintiles (is it monotonic?), rank IC (does it rank?) and Fama-MacBeth (what does it add controlling for the others?).
  • Signals are computed with month-end, point-in-time data, and returns include delisting returns: without them, bankruptcies disappear and the portfolio looks better.
  • The universe's quality has an IC of −0.033 (t −4.6) only because of its correlation with value; with value and momentum in the regression, its coefficient is ≈ 0, the known answer.
  • Low volatility is barely visible in a dollar-neutral long-short (Sharpe 0.28, beta −0.45); beta-neutral, it rises to 0.65.
  • Events are measured in event time and from the session after the information: a PEAD of about 2.4% between deciles over 60 sessions, +2.2% from announcement to index inclusion and nothing on ex-dates.
  • Size and ESG are controls with no premium; ESG gives t = 1.6 by chance: the bar for a new factor has to be much higher than t = 2.

Resources