---
title: "2.2.1 Edge hypotheses: where a trading edge comes from"
description: "Risk, behavior, structure and information: four sources of edge, measured on data with planted effects and with their risk profile."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/trading-edge-hypothesis/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/trading-edge-hypothesis.png"
---

# 2.2.1 · Edge hypotheses: risk, behavior, structure and information

> A trading system makes money only if someone on the other side agrees to lose it or pay it. This lesson sorts the four reasons why that happens and measures four edges planted in the level's data, with their return, their skewness, their crashes and their size.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.2 · Anatomy of a trading system
- Duration: 60 min
- Prerequisites: [1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage](https://pythonparatrading.com/en/python-trading-course/level-1/stylized-facts-of-financial-markets/), [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/), [0.7.2 · Probability and distributions, correlation versus causation and simple regression](https://pythonparatrading.com/en/python-trading-course/level-0/probability-distributions-and-regression/)

## By the end of this lesson

- State an edge hypothesis with its mechanism, its counterparty and the conditions under which it stops working.
- Classify an edge by its source (risk premium, behavioral bias, structural constraint or slow diffusion of information).
- Measure in Python four planted edges (currency carry, momentum, index rebalances and post-earnings drift) and a placebo with no effect.
- Compare their risk profile: Sharpe, skewness, kurtosis, worst month, number of bets per year and liquidity of what is traded.

## An edge is a hypothesis, not a pattern

An **edge** is a reason to expect a trading rule to make money in the future, after costs. The important word is
“reason”. A pattern found in a backtest (“the stocks that rise on Tuesday keep rising on Wednesday”) is not an edge:
it is an observation, and most observations in a backtest are chance (lessons 2.1.8 and 2.2.4). To turn it into a
hypothesis, three questions have to be answered:

1. **Who is on the other side?** Every trade has a counterparty. If the system wins, someone wins less or loses.
2. **Why do they accept it?** Because they get something in return (they offload a risk), because they are wrong,
   because they are forced to or because they do not yet know what the system has already priced in.
3. **Why has nobody corrected it?** Arbitraging it carries a risk, a cost or a capital limit that others do not want
   to or cannot take on.

The answers to the second question give a taxonomy of four sources:

```mermaid
flowchart TB
  E[Edge] --> R[Risk]
  E --> C[Behavior]
  E --> S[Structure]
  E --> I[Information]
  R --> R1[Carry]
  C --> C1[Momentum]
  S --> S1[Rebalances]
  I --> I1[PEAD drift]
```

| Source | Why the counterparty pays | Example in the level's data | What the hypothesis predicts |
|---|---|---|---|
| Risk premium | It offloads a risk it does not want to bear and pays for that | Currency carry: long high rates, short low rates | Persists, but with large losses in crises (negative skewness) |
| Behavior | It reacts late or too much to news, or sells losers too early | 12-1 stock momentum | Larger where arbitrage is costly; may shrink; crashes in rebounds |
| Structure | A rule or a mandate forces it to trade on a date or at a price | Index additions and deletions | Concentrated on known dates; little capacity; competes with those who know the calendar |
| Information | It has not yet incorporated public news into the price | Post-earnings drift (PEAD) | Decays with time after the news and with the speed of the market |

The classification is not rigid. Momentum has behavioral explanations (underreaction to news) and risk explanations
(its crashes come when the market rebounds after a crisis, the worst moment); carry, besides a premium, can be read as
a constraint on investors who cannot borrow in cheap currencies. What matters is that each explanation **predicts
something different and testable**: where the edge should be larger, when it should fail and what the distribution of
its returns should look like.

## The data: planted effects and a placebo

The level 2 data are synthetic and carry effects with known parameters (`/curso/datos/nivel-2/README.md`). This lesson
measures four of them and a control:

- **Currency carry** (`fx2_carry.parquet`): the high-rate currency gives back only 30% of the differential in the
  exchange rate, and in five stress windows the high-rate currencies drop sharply.
- **Momentum** (`universo2_caracteristicas.parquet`): a monthly premium of `0.0025 × z` on the 12-1 return, with a
  negative coefficient in the three months after the 2009 and 2020 bottoms.
- **Index rebalances** (`eventos2_rebalanceos.csv`): additions rise 3% between the announcement and the effective date
  and deletions fall by the same amount; in the next 20 sessions half of it reverts.
- **Post-earnings drift** (`eventos2_resultados.csv`): for 60 sessions after the reaction, the price drifts in the
  direction of the surprise.
- **Placebo**: `puntuacion_esg` is an AR(1) independent of everything. Its true premium is zero.

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

The effects in these data were put there by the generator. That the lesson's code recovers them shows the method
works, not that the strategies work in a real market. That is what the literature cited in the resources is for, with
real data, and even then with the caveats of lesson 2.5.1.

</aside>

All the edges are compared with the same yardstick: a series of monthly returns of a portfolio, and the same summary of
its profile.

```python
import numpy as np
import pandas as pd

def perfil(m):
    """m: monthly returns (as decimals)."""
    sr = m.mean() / m.std() * np.sqrt(12)
    return pd.Series({
        "meses": len(m),
        "rent_anual": 12 * m.mean(),
        "vol_anual": np.sqrt(12) * m.std(),
        "sharpe": sr,
        "t": sr * np.sqrt(len(m) / 12),
        "asimetria": m.skew(),
        "curtosis": m.kurt(),
        "peor_mes": m.min(),
        "meses_pos": (m > 0).mean(),
    })

def a_mensual(r_diaria):
    return (1 + r_diaria).resample("ME").prod() - 1
```

`t` is the annual Sharpe times the square root of the number of years: the t statistic of the mean return.
`curtosis` is the excess kurtosis (0 for a normal) and `asimetria`, the skewness of the months.

## Risk: currency carry

The carry hypothesis is a **risk premium**. Whoever lends in the high-rate currency and borrows in the low-rate one
collects the rate differential. Uncovered interest parity would say that the high-rate currency depreciates exactly
enough to cancel it; if it only does so in part, an average gain remains. The explanation for why it lasts is that
carry loses a lot exactly when everything loses: in crises, investors unwind their positions at the same time and the
high-rate currencies collapse[^1].

The portfolio is the classic one: at each month end, long the three currencies with the highest rates and short the
three with the lowest, equally weighted. The daily return of being long a currency is the change in the exchange rate
plus the accrued rate differential (README, section d).

```python
fx = pd.read_parquet("fx2_carry.parquet")
spot = fx.pivot(index="fecha", columns="divisa", values="spot")
tipo = fx.pivot(index="fecha", columns="divisa", values="tipo_divisa")
usd = fx.groupby("fecha")["tipo_usd"].first()
dif = tipo.sub(usd, axis=0)                     # % per year

dias = spot.index.to_series().diff().dt.days
interes = dif.shift(1).div(100).mul(dias / 360, axis=0)
r_fx = spot.pct_change() + interes              # long the currency

# last session of each month: long 3 high-rate, short 3 low-rate
fin_mes = dif.groupby(dif.index.to_period("M")).tail(1)
rango = fin_mes.rank(axis=1)
peso = (rango >= 8).astype(float) / 3 - (rango <= 3) / 3
peso_d = peso.reindex(r_fx.index).ffill().shift(1)
carry_d = (peso_d * r_fx).sum(axis=1).loc["2005-02":]
carry = a_mensual(carry_d)
perfil(carry).round(3)
```
```output
meses         251.000
rent_anual      0.044
vol_anual       0.108
sharpe          0.409
t               1.871
asimetria      -1.384
curtosis        7.389
peor_mes       -0.199
meses_pos       0.574
dtype: float64
```

The final `shift(1)` makes the weight decided with the rates at one month's close apply from the next session:
without it, the portfolio would collect on the decision day a return it could not yet have.

Where are the losses? The README sets five crash windows. The sum of what the portfolio gains or loses inside each
window, and outside all of them:

```python
tramos = [("2008-09-15", "2008-12-31"), ("2010-05-03", "2010-05-31"),
          ("2011-08-01", "2011-09-30"), ("2015-08-17", "2015-09-30"),
          ("2020-02-24", "2020-03-31")]
en_crash = pd.Series(False, index=carry_d.index)
for ini, fin in tramos:
    en_crash.loc[ini:fin] = True
    print(ini[:7], f"{carry_d.loc[ini:fin].sum():+.1%}")
print(f"crash sessions: {en_crash.mean():.1%} of the total")
print(f"sum in crashes: {carry_d[en_crash].sum():+.1%}")
print(f"sum outside: {carry_d[~en_crash].sum():+.1%}")
carry.nsmallest(4).round(3)
```
```output
2008-09 -23.4%
2010-05 -7.5%
2011-08 -7.8%
2015-08 -4.3%
2020-02 -15.5%
crash sessions: 3.7% of the total
sum in crashes: -58.6%
sum outside: +147.2%
fecha
2009-02-28   -0.199
2020-03-31   -0.124
2008-11-30   -0.083
2022-08-31   -0.077
dtype: float64
```

*Fig. — FX carry in the level 2 data, 2005–2025: value of 1 USD invested, with the generator's five crash windows shaded.* Monthly curve that starts at 1 in 2005, rises until 2008, falls hard in the autumn 2008 crash window and hits its worst month, highlighted, in February 2009 (−19.9%), already outside the window. It then recovers with smaller falls in 2010, 2011 and 2015 and another sharp one in March 2020; it ends at 2.22.

The portfolio earns 4.4% a year with 10.8% volatility: a Sharpe of 0.41 and a t of 1.87, at the edge of what tells a
premium apart from chance with 21 years. The monthly skewness is −1.38 and the excess kurtosis, 7.4. The sessions in
the five crash windows are 3.7% of the total, and in them the portfolio adds up −58.6 points; outside, +147.2. The 2008
window alone takes 23.4 points and the 2020 one, 15.5.

The profile is that of someone who sells insurance: it earns little and almost always, and gives back a large part at
once. The figure shows it on the portfolio's curve. Two details of the result matter for a real system:

- The worst month, February 2009 (−19.9%), does not fall inside a planted window but after one: the GARCH volatility
  of the high-rate currencies stays elevated after the crash. Crises do not end when the fall ends; tail risk lasts
  longer.
- A five-year backtest with no crisis inside would show an almost straight curve and a high Sharpe. The premium is only
  seen in full with samples that include the episodes it pays for.

## Behavior (or risk): momentum

Cross-sectional momentum buys the stocks that rose the most over the last twelve months, excluding the last one, and
sells those that fell the most[^2]. Behavioral explanations say that investors underreact to news and that the price
takes months to reflect it; risk explanations, that the long-short portfolio loses a lot in the rebounds after a
crisis, when the losers (heavily beaten, with high beta) rise more than anyone[^3].

In the level's data the signal follows the first hypothesis and the crash, the second. With the point-in-time monthly
returns of `universo2_caracteristicas.parquet`:

```python
car = pd.read_parquet("universo2_caracteristicas.parquet")
rm = car.pivot(index="fecha", columns="ticker",
               values="rentabilidad_mes")
hay = car.pivot(index="fecha", columns="ticker",
                values="cierre").notna()    # False: delisted
# return from m-11 to m-1 (skips the last month), only
# where there is a price at the close of m
mom = np.expm1(np.log1p(rm.where(hay)).shift(1)
               .rolling(11).sum()).where(hay)
sig_mes = rm.shift(-1)          # m+1, with the delisting included

ic = mom.rank(axis=1).corrwith(sig_mes.rank(axis=1), axis=1).dropna()
print(f"mean rank IC {ic.mean():.3f}, "
      f"t = {ic.mean() / ic.std() * np.sqrt(len(ic)):.2f}")

def quintiles(senal, fut, excluir=()):
    s, f = senal.drop(columns=list(excluir)), fut.drop(
        columns=list(excluir))
    rk = s.rank(axis=1, pct=True)
    m = (f[rk > 0.8].mean(axis=1) - f[rk <= 0.2].mean(axis=1))
    m = m.dropna()
    # label: end of the month of the return (the next one)
    sig = m.index.to_period("M") + 1
    m.index = sig.to_timestamp() + pd.offsets.MonthEnd(0)
    return m

momentum = quintiles(mom, sig_mes)
perfil(momentum).round(3)
```
```output
mean rank IC 0.028, t = 2.96
meses         227.000
rent_anual      0.101
vol_anual       0.120
sharpe          0.838
t               3.644
asimetria       0.136
curtosis        0.082
peor_mes       -0.090
meses_pos       0.573
dtype: float64
```

The signal and the future return do not use the same rows. For each stock that stops trading, the file has a last row
without a price in the month it leaves, with a `rentabilidad_mes` that chains the month-to-date return with the
delisting return (−100% in the four bankruptcies). That row cannot be used to form a signal (on that day the stock can
no longer be bought), which is why `mom` is computed only where there is a price (`hay`). But it has to go into
`sig_mes`: it is what whoever held the stock in the portfolio the previous month gains or loses. A backtest that
discarded it would see no bankruptcy at all.

The rank IC (the Spearman correlation between one month's signal and the next month's return, lesson 2.3.5) measures
the relationship without being dragged by a few extreme values. The quintile portfolio is dragged: the README warns
that four guided bankruptcies fall for months to almost zero and always end up in the loser quintile, on the short
side, where the −100% of the delisting is a 100% gain. Their contribution is measured by removing them:

```python
quiebras = ["QXQT", "QXKV", "QXZT", "QXYX"]
sin_q = quintiles(mom, sig_mes, excluir=quiebras)
print(f"with bankruptcies {12 * momentum.mean():.1%} per year, "
      f"without them {12 * sin_q.mean():.1%}")
crash = ["2009-04", "2009-05", "2009-06",
         "2020-04", "2020-05", "2020-06"]
momentum.loc[momentum.index.strftime("%Y-%m").isin(crash)].round(3)
```
```output
with bankruptcies 10.1% per year, without them 6.9%
fecha
2009-04-30   -0.060
2009-05-31   -0.031
2009-06-30   -0.058
2020-04-30   -0.044
2020-05-31   -0.006
2020-06-30   -0.059
dtype: float64
```

The mean IC is 0.028 with a t of 2.96, close to the expected +0.03. The quintile portfolio earns 10.1% a year with a
Sharpe of 0.84; without the bankruptcies, 6.9%: almost a third of the return (3.2 points) comes from four companies,
which in their last month with a price sit in the 1st percentile of the signal. With real data the same thing happens
with a dangerous sign: without the delisting returns, or with a universe of survivors (lesson 1.4.4), the short side
looks worse than it was, or disappears.

The six planted months are negative, but none is the worst in the series (−9.0%, in February 2021). June 2009 stays at
−5.8% for a reason that has nothing to do with the signal: that month QXKV, one of the bankruptcies, stops trading, and
the short side collects its −100% while the losers rebound. The crash in these data is moderate: the portfolio's
monthly skewness is 0.14, almost zero, and the excess kurtosis, 0.08. In real momentum the crashes have been more
violent than here[^3]; a data model carries the tail risk its author decided to give it.

## Structure: index rebalances

A **structural edge** does not depend on anyone being wrong: it depends on someone being forced to trade. A fund that
replicates an index has to buy the stocks that enter and sell those that leave at the close of the effective date,
and that date is announced days in advance. Whoever buys between the announcement and the effective date later sells
to the indexer; if the demand for stocks is not perfectly elastic, the price rises with the forced buying and partly
reverts afterwards[^4].

Each event is measured as the stock's return from the close of the announcement day to that of the effective date,
signed (long additions, short deletions) and in excess of the universe mean. Deletions due to delisting have no
planted effect and are removed:

```python
aj = pd.read_parquet("universo2_ajustados.parquet")
px = aj.pivot(index="fecha", columns="ticker",
              values="cierre_ajustado")
r = px.pct_change(fill_method=None)
mercado = r.mean(axis=1)
sesiones = px.index

rb = pd.read_csv("eventos2_rebalanceos.csv",
                 parse_dates=["fecha_anuncio", "fecha_efectiva"])
rb = rb[rb["motivo"] != "deslistado"]

def evento(e):
    ia = sesiones.searchsorted(e.fecha_anuncio)
    ie = sesiones.searchsorted(e.fecha_efectiva)
    s = 1 if e.tipo == "alta" else -1
    p, m = px[e.ticker], (1 + mercado.iloc[ia + 1:ie + 1]).prod()
    return pd.Series({
        "sesiones": ie - ia,
        "exceso": s * (p.iloc[ie] / p.iloc[ia] - m),
        "despues_20": s * (p.iloc[ie + 20] / p.iloc[ie] - 1),
    })

ev = pd.concat([rb, rb.apply(evento, axis=1)], axis=1)
ev.groupby("tipo")[["sesiones", "exceso", "despues_20"]].agg(
    ["mean", "count"]).round(3)
```
```output
     sesiones       exceso       despues_20
         mean count   mean count       mean count
tipo
alta    4.741    27  0.022    27     -0.012    27
baja    5.000    20  0.040    20     -0.003    20
```

Additions earn an average excess of 2.2% in 4.7 sessions and deletions, held short, 4.0% in 5, against the planted
3%: with 27 and 20 events and a standard deviation of 3-4 points per event, the error of each mean is 0.7 and 0.9
points. The reversal over the next 20 sessions comes out with the expected sign (−1.2% and −0.3% for the event's
position), but with an error of 2 points: with so few events it cannot be told apart from zero. Measuring an event
edge well requires many events.

To compare it with the others, the edge is turned into a daily portfolio: long the additions and short the deletions
while each window lasts, and in cash the rest of the time.

```python
pos = pd.DataFrame(0.0, index=sesiones, columns=px.columns)
for e in ev.itertuples():
    ia = sesiones.searchsorted(e.fecha_anuncio)
    ie = sesiones.searchsorted(e.fecha_efectiva)
    pos.iloc[ia + 1:ie + 1, px.columns.get_loc(e.ticker)] = (
        1 if e.tipo == "alta" else -1)
n_pos = pos.abs().sum(axis=1)
r_exc = r.sub(mercado, axis=0).fillna(0)
rebal_d = (pos * r_exc).sum(axis=1) / n_pos.where(n_pos > 0, 1)
rebal = a_mensual(rebal_d)
print(f"sessions with a position: {(n_pos > 0).mean():.1%}")
print(f"events per year: {len(ev) / 20:.1f}")
perfil(rebal).round(3)
```
```output
sessions with a position: 2.4%
events per year: 2.4
meses         240.000
rent_anual      0.035
vol_anual       0.046
sharpe          0.755
t               3.375
asimetria       3.223
curtosis       14.768
peor_mes       -0.046
meses_pos       0.092
dtype: float64
```

The profile is the opposite of carry: only 9.2% of the months are positive (almost all the others are zero), a few
earn a lot and the skewness is +3.2. The Sharpe, at 0.75, misleads in another way: the portfolio only holds a position
in 2.4% of the sessions, one in forty. That is the **capacity** of a structural edge: few opportunities, short and
known to everyone who reads the same calendar. The more the effect is published, the more capital chases it, and its
entry price rises.

## Information: post-earnings drift

**Post-earnings-announcement drift** (PEAD) is the tendency of the price to keep moving, weeks after the release, in
the direction of the surprise[^5]. The hypothesis is about information: the market incorporates public news slowly. It
predicts that the drift is larger the larger the surprise and that it decays with time.

The surprise is standardized with those of previous quarters (SUE: the surprise divided by the standard deviation of
the previous 8, with at least 4). The reaction is the same session if it is released before the open and the next one
if after the close; the portfolio enters at the close of the reaction, so as not to count the jump of the day, and
measures the next 60 sessions in excess of the universe:

```python
res = pd.read_csv("eventos2_resultados.csv",
                  parse_dates=["periodo_fin", "fecha_anuncio"])
res = res.sort_values(["ticker", "fecha_anuncio"])
previa = res.groupby("ticker")["sorpresa"].transform(
    lambda s: s.shift(1).rolling(8, min_periods=4).std())
res["sue"] = res["sorpresa"] / previa
tras = (res["momento"] == "tras_cierre").to_numpy()
res["i"] = sesiones.searchsorted(res["fecha_anuncio"]) + tras
res = res.dropna(subset=["sue"])
res = res[res["i"] + 61 < len(sesiones)]
res["decil"] = res.groupby("periodo_fin")["sue"].transform(
    lambda s: pd.qcut(s.rank(method="first"), 10, labels=False))

log_exc = np.log1p(r_exc).cumsum().to_numpy()
j = px.columns.get_indexer(res["ticker"])
i = res["i"].to_numpy()
res["deriva_60"] = np.expm1(log_exc[i + 60, j] - log_exc[i, j])
(100 * res.groupby("decil")["deriva_60"].mean()).round(2)
```
```output
decil
0   -1.05
1   -0.20
2   -0.31
3    0.54
4   -0.53
5    0.02
6    0.29
7   -0.11
8    0.97
9    1.41
Name: deriva_60, dtype: float64
```

The extreme deciles separate: the one with the most positive surprises drifts 1.41% up and the one with the most
negative, 1.05% down; the difference, 2.5 points, is within the 2-3 in the README. In between the relationship is
noisy: the planted drift is small per unit of surprise, and a stock's 60-session volatility is large. As a portfolio,
long the top decile and short the bottom one during their 60 sessions:

```python
L = np.zeros(px.shape)
S = np.zeros(px.shape)
for ii, jj, d in zip(i, j, res["decil"]):
    if d == 9:
        L[ii + 1:ii + 61, jj] = 1
    elif d == 0:
        S[ii + 1:ii + 61, jj] = 1
R = r.fillna(0).to_numpy()
largo = (L * R).sum(1) / np.maximum(L.sum(1), 1)
corto = (S * R).sum(1) / np.maximum(S.sum(1), 1)
pead = a_mensual(pd.Series(largo - corto, index=sesiones))
print(f"events per year: {(res['decil'].isin([0, 9])).sum() / 20:.0f}")
perfil(pead.loc["2006-04":]).round(3)
```
```output
events per year: 82
meses         237.000
rent_anual      0.089
vol_anual       0.117
sharpe          0.768
t               3.412
asimetria      -0.149
curtosis        0.296
peor_mes       -0.100
meses_pos       0.595
dtype: float64
```

The portfolio earns 8.9% a year with a Sharpe of 0.77, behind only momentum (0.84, which owes part of it to the
bankruptcies), and it is among the most diversified: 82 events per year, spread over different dates, with almost zero
skewness. Its risk is not a crash but that it disappears: if the drift is information that is incorporated slowly, the
faster and more closely watched the market is, the less drift is left.

## The risk profile of each edge

The control is still missing. The quintile portfolio of `puntuacion_esg`, a variable with no effect at all:

```python
esg = car.pivot(index="fecha", columns="ticker",
                values="puntuacion_esg").where(hay)
placebo = quintiles(esg, sig_mes)

ventajas = {"carry_fx": carry, "momentum": momentum,
            "rebalanceos": rebal, "pead": pead.loc["2006-04":],
            "placebo_esg": placebo}
tabla = pd.DataFrame({k: perfil(v) for k, v in ventajas.items()}).T
tabla[["rent_anual", "vol_anual", "sharpe", "t", "asimetria",
       "curtosis", "peor_mes"]].round(2)
```
```output
             rent_anual  vol_anual  sharpe     t  asimetria  curtosis  peor_mes
carry_fx           0.04       0.11    0.41  1.87      -1.38      7.39     -0.20
momentum           0.10       0.12    0.84  3.64       0.14      0.08     -0.09
rebalanceos        0.03       0.05    0.75  3.37       3.22     14.77     -0.05
pead               0.09       0.12    0.77  3.41      -0.15      0.30     -0.10
placebo_esg        0.03       0.10    0.33  1.46      -0.42      1.31     -0.12
```

The figure at the top puts the five on a plane: Sharpe on the horizontal axis, skewness of the months on the vertical
one. Three readings:

- **Sharpe does not rank risk.** Carry (0.41) and the placebo (0.33) have a similar Sharpe; the first hides a −20%
  month and the second, a −12% one. The placebo's is not a tail of the signal: it is July 2020, when QXYX goes bankrupt
  while sitting, by chance, in its top quintile. Carry's is the premium itself. A Sharpe of 0.4 with skewness −1.4 is
  not the same product as one of 0.3 with skewness −0.4.
- **The placebo passes for a small edge.** It earns 3.1% a year with a t of 1.46, with no premium behind it. Without a
  prior hypothesis, a t like that distinguishes nothing: with twenty independent variables with no effect, the
  probability that one of them gives |t| > 2 is around 60% (lesson 2.1.8).
- **Kurtosis gives away the event edge.** Rebalances have the highest kurtosis (14.8): many months at zero and a few
  large ones. It is a different profile from carry even though both have tails: one tail in favor and the other
  against.

The comparison of the full distributions, with the monthly returns standardized (divided by their standard deviation)
so they can be seen on the same scale:

```python
q = [0.01, 0.05, 0.5, 0.95, 0.99]
z = {k: (v - v.mean()) / v.std() for k, v in ventajas.items()}
cuant = pd.DataFrame({k: v.quantile(q) for k, v in z.items()}).T
cuant["min"] = [v.min() for v in z.values()]
cuant["max"] = [v.max() for v in z.values()]
cuant.round(2)
```
```output
             0.01  0.05   0.5  0.95  0.99   min   max
carry_fx    -2.69 -1.51 -0.01  1.46  1.89 -6.49  2.48
momentum    -2.09 -1.61 -0.06  1.72  2.52 -2.85  2.75
rebalanceos -1.18 -0.22 -0.22  1.84  4.39 -3.67  6.01
pead        -2.54 -1.62  0.01  1.67  2.23 -3.20  2.77
placebo_esg -2.43 -1.69  0.05  1.55  2.29 -4.49  2.58
```

*Fig. — Distribution of each portfolio's months, standardised: worst to best month (thin line), 5th to 95th percentile (bar) and median (tick).* Five rows on a standard-deviation scale from −7 to +7. FX carry has its worst month, highlighted, at −6.5 and its best at +2.5; momentum runs from −2.85 to +2.75; post-earnings drift from −3.2 to +2.8; index rebalances from −3.7 to +6.0, with the 5–95% bar between −0.2 and +1.8; the ESG placebo from −4.5 (the month of a bankruptcy in its long leg) to +2.6.

Carry's worst month is 6.5 standard deviations below its mean; post-earnings drift's, 3.2, and momentum's, 2.85. The
placebo has one at 4.5: the QXYX bankruptcy. A portfolio with no edge also collects or pays the delistings that come
its way, and removing them from the data would make it look cleaner than it is. Under a normal, a month at −6.5σ has a
probability of $4 \times 10^{-11}$: less than once in a billion years. In the carry of these data it has happened once
in 21. Rebalances are the mirror image: their long tail is on the right (+6.0σ). A sizing rule that uses only
volatility (lesson 2.2.2) assigns the same risk to both and gets the first one wrong.

The last dimension is **size**. A high Sharpe on little tradable volume does not take capital. With the 21-session
average dollar volume of what each stock portfolio trades:

```python
car["adv_musd"] = car["volumen_medio_21d"] * car["cierre"] / 1e6
adv = car.pivot(index="fecha", columns="ticker", values="adv_musd")

def adv_eventos(fechas, tickers):
    """ADV at the last month end before each event."""
    previo = adv.reindex(fechas, method="ffill")
    return np.nanmedian([previo.iloc[k][t]
                      for k, t in enumerate(tickers)])

rk = mom.rank(axis=1, pct=True)
ext = res[res["decil"].isin([0, 9])]
liq = pd.DataFrame({
    "apuestas_año": [12 * ((rk > 0.8) | (rk <= 0.2)).sum(axis=1)
                     .mean(), len(ev) / 20, len(ext) / 20],
    "sesiones": [21, ev["sesiones"].mean(), 60],
    "adv_musd": [adv[(rk > 0.8) | (rk <= 0.2)].stack().median(),
                 adv_eventos(ev["fecha_anuncio"], ev["ticker"]),
                 adv_eventos(ext["fecha_anuncio"], ext["ticker"])],
}, index=["momentum", "rebalanceos", "pead"])
liq.round(1)
```
```output
             apuestas_año  sesiones  adv_musd
momentum            491.8      21.0     111.0
rebalanceos           2.4       4.9      20.0
pead                 82.5      60.0     118.7
```

Momentum spreads its risk across about 41 stocks each month (492 positions a year, although overlapping and not
independent) with a median volume of USD 111 million a day. Post-earnings drift opens 82 positions a year in similar
stocks (119 million). Rebalances, 2.4 a year in stocks five times less liquid (20 million): the ones that enter the
index come from outside, from among the small ones. How much capital each edge takes with these figures is the subject
of lesson 2.2.3.

## How to write an edge hypothesis

Before opening a backtest, the hypothesis is written down. A template that forces the important questions to be
answered:

```mermaid
flowchart TB
  M[Mechanism] --> C[Counterparty]
  C --> P[Predictions]
  P --> T[Test and control]
  T --> F[When it fails]
  F --> M
```

| Field | Example: currency carry |
|---|---|
| Mechanism | Premium for bearing the crash risk of high-rate currencies |
| Counterparty | Whoever hedges or does not want that risk; whoever borrows in the high-rate currency |
| Why it persists | Arbitraging it requires bearing the same crash; capital pulls out exactly when it pays the most |
| Predictions | Positive average gain; negative skewness; losses concentrated in crises; larger with larger differentials |
| Test | Long-short portfolio by differential, return inside and outside the stress windows |
| Control | Same portfolio with an unrelated variable (placebo) or with the differentials shuffled |
| When it fails | Liquidity crises; rate convergence (no differential, no premium) |
| Capacity | Very liquid currency markets; the limit is set by tail risk, not volume |

The template has two uses. The first is to filter before testing: an idea with no plausible counterparty and no reason
to persist does not deserve the hours of backtesting, and each test saved is one test fewer in the multiple-testing
count (lesson 2.2.4). The second is to read the result: if the hypothesis predicts negative skewness and the backtest
comes out with positive skewness, something does not add up, however nice the Sharpe.

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

The figures in this lesson come from synthetic data with planted effects. In a real market, published edges tend to
shrink after publication, costs eat a large part of the high-turnover ones and any backtest overstates what will be
obtained live. No figure here is an expected return.

</aside>

## Exercises

1. Repeat the carry portfolio with two currencies per side instead of three. Does the return go up or down? And the
   skewness and the worst month? Explain the result with the risk premium hypothesis.

**Solution.**

```python
peso2 = (rango >= 9).astype(float) / 2 - (rango <= 2) / 2
carry2_d = (peso2.reindex(r_fx.index).ffill().shift(1)
            * r_fx).sum(axis=1).loc["2005-02":]
pd.DataFrame({"3 per side": perfil(carry),
              "2 per side": perfil(a_mensual(carry2_d))}).round(3)
```
```output
            3 per side  2 per side
meses          251.000     251.000
rent_anual       0.044       0.055
vol_anual        0.108       0.139
sharpe           0.409       0.393
t                1.871       1.798
asimetria       -1.384      -1.011
curtosis         7.389       5.388
peor_mes        -0.199      -0.238
meses_pos        0.574       0.542
```

With two per side the portfolio concentrates on the extremes of the differential. The return rises from 4.4% to 5.5%
and the worst month worsens from −19.9% to −23.8%: more differential, more premium and more crash, as the risk premium
hypothesis predicts. Volatility rises more than return (a Sharpe of 0.39 against 0.41) and skewness is less negative
(−1.01): with two currencies per side, the volatility of each one weighs more in normal months and dilutes the tail in
relative terms. The hypothesis's prediction holds for the return and the maximum loss; skewness, being a ratio, also
depends on the portfolio's noise.

2. Measure post-earnings drift separately in the first half of the sample (up to 2015) and in the second. If the edge
   were information the market learns to price in, what would you expect? What do you find in these data and why?

**Solution.**

```python
mitades = {"2006-2015": pead.loc["2006-04":"2015-12"],
           "2016-2025": pead.loc["2016-01":]}
pd.DataFrame({k: perfil(v) for k, v in mitades.items()}).T[
    ["rent_anual", "sharpe", "t"]].round(2)
```
```output
           rent_anual  sharpe     t
2006-2015        0.07    0.56  1.75
2016-2025        0.11    0.99  3.13
```

If it were an inefficiency being arbitraged away, the second half should be weaker. Here it comes out the other way:
a Sharpe of 0.56 up to 2015 and 0.99 afterwards. In these data the drift is planted with the same coefficient across
the whole sample, so the difference is sampling noise: with ten years per half, the standard error of each Sharpe is
about 0.3 and that of the difference, about 0.45. With real data, a drop between halves would be consistent with the
information hypothesis, but it would not prove it on its own either.

3. Write the momentum edge hypothesis with the lesson's template. What prediction would distinguish it from a pure
   risk explanation?

**Solution.**

Mechanism: underreaction to news (behavior) or a premium for the crash risk in rebounds (risk). Counterparty: whoever
sells winners early and holds losers, or whoever does not want the rebound risk. Common predictions: a positive average
return of the 12-1 portfolio and crashes after market bottoms. A prediction that separates them: if it is
underreaction, the edge should be larger in thinly followed stocks (small, with few analysts) and in gradual news, and
shrink over time; if it is a risk premium, it should be tied to exposure to those rebounds and not to analyst coverage.
Control: the same portfolio with a placebo signal and with the last month's return (which 12-1 skips on purpose).

[^1]: Brunnermeier, Nagel and Pedersen (2008), in the resources, document the negative skewness of carry and attribute
it to positions being unwound at once when risk appetite and funding liquidity fall.

[^2]: Jegadeesh and Titman (1993), in the resources, is the classic study of winner and loser portfolios.

[^3]: Daniel and Moskowitz (2016), in the resources, study momentum crashes in the rebounds after declines.

[^4]: Shleifer (1986), in the resources, uses S&P 500 inclusions to test whether the demand for stocks slopes
downward.

[^5]: Bernard and Thomas (1989), in the resources, test whether the drift is a delayed response or a risk premium.

## Self-assessment

1. A backtest finds that buying the stocks that rise on Tuesday is profitable. What is missing for it to be an edge hypothesis?

   - A. More years of data
   - B. A Sharpe ratio above 1
   - C. A mechanism: who is on the other side, why they accept losing and why nobody has corrected it; without that it is a pattern, not a hypothesis
   - D. Testing it in another market

   *Answer C: A mechanism: who is on the other side, why they accept losing and why nobody has corrected it; without that it is a pattern, not a hypothesis.* More data and other markets help test it, but first there has to be something to test. A hypothesis explains where the money comes from and predicts things that can be checked (in which assets, in which periods and with what risk it should appear).

2. The lesson's currency carry has a positive Sharpe and a very negative monthly skewness. What fits that profile best?

   - A. A behavioral bias the market will be slow to correct
   - B. A data error
   - C. An information edge
   - D. A risk premium: it collects a little almost every month in exchange for large losses in crises, when money is worth more

   *Answer D: A risk premium: it collects a little almost every month in exchange for large losses in crises, when money is worth more.* Earning little and often and losing a lot at once, right in the crises, is the profile of someone who sells insurance. That is the classic explanation of carry (Brunnermeier, Nagel and Pedersen, 2008), and the level's data plant it that way: the crashes of the high-rate currencies fall in the stress windows.

3. Why does the index rebalance edge have little capacity even though its return per event is high?

   - A. Because there are few events per year, each lasts a few sessions and in those sessions everyone who knows the calendar competes for the same stocks
   - B. Because it is a false effect
   - C. Because the index stocks are illiquid
   - D. Because the return is negative

   *Answer A: Because there are few events per year, each lasts a few sessions and in those sessions everyone who knows the calendar competes for the same stocks.* The data hold 47 events with an effect in 20 years, about 2.4 per year, of about 5 sessions each. Even if each event returns between 2% and 4%, the capital that can be deployed is limited by the volume of those few sessions. It is a structural edge: it exists because index funds have to trade on those dates.

4. The ESG score in the data is a placebo with no effect, and its long-short portfolio earns 3% a year with t ≈ 1.5. What does it teach?

   - A. That ESG works
   - B. That a portfolio with no edge at all can produce reasonable-looking figures by chance; without a mechanism and without correcting for the number of tests, it cannot be told apart from a small edge
   - C. That the data are wrong
   - D. That ESG should be removed from the data

   *Answer B: That a portfolio with no edge at all can produce reasonable-looking figures by chance; without a mechanism and without correcting for the number of tests, it cannot be told apart from a small edge.* With 20 years of monthly data, the standard error of the annual mean of a portfolio with almost 10% volatility is about 2 points. A t of 1.5 is within what chance produces; with many variables tested, several like it will show up (lesson 2.1.8).

5. What sets an information edge, such as post-earnings drift, apart from a risk premium?

   - A. That the information edge has no risk
   - B. That it exists because the price incorporates the news slowly, so it should shrink when more people exploit it or the information arrives faster; a risk premium is the compensation for bearing something nobody wants and can last
   - C. That the information edge only works in crypto
   - D. Nothing: they are the same

   *Answer B: That it exists because the price incorporates the news slowly, so it should shrink when more people exploit it or the information arrives faster; a risk premium is the compensation for bearing something nobody wants and can last.* The hypotheses predict different things. If the edge is a premium, it persists but with crashes; if it is an inefficiency (behavior or slow information), it should be larger where arbitrage costs more (small, thinly followed stocks) and shrink over time.

## Key points

- An edge is a hypothesis with a mechanism: who is on the other side, why they accept losing and why nobody has arbitraged it away. A pattern without a mechanism is a candidate false positive.
- Four sources: a premium for bearing a risk, a behavioral bias of other participants, a structural constraint that forces someone to trade, and slow diffusion of information.
- Each source leaves a profile: the carry in the data earns 4.4% a year with a monthly skewness of −1.4 and its worst month drops 19.9%; post-earnings drift has a Sharpe of 0.77 and almost zero skewness.
- Return is not enough: capacity (bets per year, liquidity and duration) decides how much capital it takes. Rebalances return between 2% and 4% per event, but there are 2.4 a year.
- The ESG placebo earns 3.1% a year (t = 1.46) with no effect at all. The level's data are synthetic and their effects are planted: they teach how to measure, they do not prove that anything works in a real market.

## Resources

### Research

- [Alpha factors and alpha trading in Python](https://pythonparatrading.com/en/alpha-factors-and-alpha-trading-in-python/) · Research-0007 · 5 min

### Books

- [Brunnermeier, Nagel and Pedersen (2008): Carry Trades and Currency Crashes. NBER Macroeconomics Annual 23, 313-348](https://doi.org/10.1086/593088) · doi.org
- [Jegadeesh and Titman (1993): Returns to Buying Winners and Selling Losers. Journal of Finance 48(1), 65-91](https://doi.org/10.1111/j.1540-6261.1993.tb04702.x) · doi.org
- [Daniel and Moskowitz (2016): Momentum crashes. Journal of Financial Economics 122(2), 221-247](https://doi.org/10.1016/j.jfineco.2015.12.002) · doi.org
- [Bernard and Thomas (1989): Post-Earnings-Announcement Drift: Delayed Price Response or Risk Premium? Journal of Accounting Research 27](https://doi.org/10.2307/2491062) · doi.org
- [Shleifer (1986): Do Demand Curves for Stocks Slope Down? Journal of Finance 41(3), 579-590](https://doi.org/10.1111/j.1540-6261.1986.tb04518.x) · doi.org

### Data

- [Level 2 data: currencies with carry (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/fx2_carry.parquet) · Download · fx2_carry.parquet

Previous lesson: [2.1.8 · The multiple testing problem: Bonferroni, FDR and the “factor zoo”](https://pythonparatrading.com/en/python-trading-course/level-2/multiple-testing-bonferroni-fdr-factor-zoo/)

Next lesson: [2.2.2 · Universe, signal, filter, entry, exit, sizing and risk management](https://pythonparatrading.com/en/python-trading-course/level-2/universe-signal-entry-exit-position-sizing/)
