---
title: "2.3.5 Factor investing and event strategies"
description: "Value, momentum, low volatility, quality and size via quintiles, IC and Fama-MacBeth; long-short portfolios and events: earnings, index changes, dividends."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/factor-investing-and-event-strategies/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/factor-investing-and-event-strategies.png"
---

# 2.3.5 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.3 · Strategy families
- Duration: 75 min
- Prerequisites: [1.2.1 · Stocks: splits, dividends and corporate actions](https://pythonparatrading.com/en/python-trading-course/level-1/stocks-splits-dividends-corporate-actions/), [1.4.4 · Universes without survivorship bias: delistings, mergers and symbol changes](https://pythonparatrading.com/en/python-trading-course/level-1/survivorship-bias-and-point-in-time-data/), [1.5.1 · Financial statements, ratios, XBRL and SEC EDGAR](https://pythonparatrading.com/en/python-trading-course/level-1/financial-statements-xbrl-and-sec-edgar/), [2.1.1 · Hypothesis tests, p-values, bootstrap and regression with robust standard errors](https://pythonparatrading.com/en/python-trading-course/level-2/hypothesis-testing-bootstrap-robust-errors/)

## By the end of this lesson

- Build monthly point-in-time signals (12-1 momentum, value, low volatility, quality, size) without look-ahead and with delisting returns.
- Measure a premium with quintile sorts, rank IC and Fama-MacBeth regressions with Newey-West errors, and know what each one answers.
- Detect a confounded premium (quality inheriting value's premium) and one hidden by beta (low volatility).
- Build dollar-neutral and beta-neutral long-short portfolios, with bid-ask spread costs.
- Measure abnormal returns in event time: post-earnings drift, index additions and deletions, and dividend ex-dates.

## 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:

```mermaid
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.

<aside class="callout" data-kind="aviso">

The `universo2` universe is **synthetic**: 120 fictitious companies from 2006 to 2025 with **three planted premiums**
(each month, `α = 0.25% × z_momentum + 0.40% × z_value − 0.25% × z_vol`, with `z` the normal rank score) and **three
controls with a zero coefficient**: quality (ROE), size and an ESG score that is pure noise. The events
(post-earnings drift, index rebalances) are also planted. The figures measure whether the methods recover what was put
in; they are not evidence that these factors work in real markets.

</aside>

## 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.

```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)
```
```output
(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-12$ to the end of $t-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.

```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()
```
```output
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.

```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)
```
```output
          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.

```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)
```
```output
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).* Two panels of five bars. Value, from the most expensive to the cheapest stocks: 4.1%, 6.9%, 10.1%, 10.2%, 10.8%, almost monotonic. Volatility, from the most volatile to the calmest: 3.3%, 10.6%, 9.9%, 9.1%, 7.7%; the most volatile quintile, highlighted, earns much less, but the calmest does not beat the middle ones.

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:

```python
S[["value", "calidad", "momentum", "baja_vol", "beta",
   "tamano"]].corr(method="spearman").round(2)
```
```output
          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,

$$
r_{i,t+1} = \gamma_{0,t} + \sum_k \gamma_{k,t}\, z_{k,i,t} + \varepsilon_{i,t+1},
$$

and then the $\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.

```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)
```
```output
          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.

```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)
```
```output
                            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):

$$
r^{\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.

```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)
```
```output
                 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.* Two monthly equity curves from 2006 to 2025 on a log scale, base 100. The beta-neutral one, highlighted, rises fairly steadily to 430. The dollar-neutral one advances less, with drops in market rebounds, and ends at 182.

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.

```mermaid
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.

```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()
```
```output
8066 announcements with SUE
array([-1.93, -1.13, -0.69, -0.4 ,  0.  ,  0.  ,  0.36,  0.68,  1.08,
        1.9 ])
```

```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)
```
```output
   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).* Three CAR curves over 60 sessions. The highest-surprise decile, highlighted, rises slowly to +1.37%; the lowest-surprise one falls to -0.98%; the middle deciles stay close to zero (-0.30%). The gap widens gradually over the whole window.

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)[^1]: 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.

```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)
```
```output
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.* Two CAR curves from 5 sessions before to 25 after the announcement, with vertical lines at the announcement and at the effective date, five sessions later. Additions, highlighted, rise 2.2% up to the effective date and then give part back to 0.5%. Deletions fall 4.0% up to the effective date and barely recover, to -4.5%.

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.

```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)
```
```output
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.**

```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))
```
```output
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).

2. 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.**

```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)")
```
```output
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.

3. 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.

[^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](https://doi.org/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](https://doi.org/10.1111/j.1540-6261.1986.tb04550.x).

## 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?

   - 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
   - B. Quality has a negative premium
   - C. Fama-MacBeth is miscalculated
   - D. Rank IC is useless

   *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?

   - A. Because of costs
   - B. Because the beta-neutral portfolio holds fewer stocks
   - C. By chance
   - 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

   *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?

   - A. Nothing
   - 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
   - C. It uses only large stocks
   - D. It does not need point-in-time data

   *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?

   - A. Because there is no data on the reaction day
   - B. To make the result larger
   - 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
   - D. By accounting convention

   *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?

   - 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
   - B. A real ESG premium
   - C. A data error
   - D. A size effect

   *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

### Research

- [Alpha Factors and Alpha Trading in Python](https://pythonparatrading.com/en/alpha-factors-and-alpha-trading-in-python/) · Research-0007 · 5 min
- [Price Discovery & Arbitrage Pricing Theory](https://pythonparatrading.com/en/arbitrage-pricing-theory-and-price-discovery-2/) · Research-0006 · 6 min

### Documentation

- [Fama and MacBeth (1973): Risk, Return, and Equilibrium: Empirical Tests, Journal of Political Economy 81(3)](https://doi.org/10.1086/260061) · doi.org
- [Frazzini and Pedersen (2014): Betting against beta, Journal of Financial Economics 111(1)](https://doi.org/10.1016/j.jfineco.2013.10.005) · doi.org
- [Bernard and Thomas (1989): Post-Earnings-Announcement Drift, Journal of Accounting Research 27](https://doi.org/10.2307/2491062) · doi.org
- [Harvey, Liu and Zhu (2016): … and the Cross-Section of Expected Returns, Review of Financial Studies 29(1)](https://doi.org/10.1093/rfs/hhv059) · doi.org

### Data

- [Level 2 data: monthly point-in-time characteristics of the universe (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/universo2_caracteristicas.parquet) · Download · universo2_caracteristicas.parquet

Previous lesson: [2.3.4 · Carry in currencies, futures and bonds; seasonality and calendar effects](https://pythonparatrading.com/en/python-trading-course/level-2/carry-seasonality-calendar-effects/)

Next lesson: [2.3.6 · Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)](https://pythonparatrading.com/en/python-trading-course/level-2/market-making-avellaneda-stoikov/)
