Lesson 2.2.1 · 60 min
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.
- Module
- 2.2 · Anatomy of a trading system
- Duration
- 60 min
- Prerequisites
- 1.6.2 · Stylized facts: fat tails, clustering, autocorrelation and leverage, 1.4.4 · Universes without survivorship bias: delistings, mergers and symbol changes, 0.7.2 · Probability and distributions, correlation versus causation and simple regression
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
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:
- Who is on the other side? Every trade has a counterparty. If the system wins, someone wins less or loses.
- 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.
- 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 source
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 of0.0025 × zon 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_esgis an AR(1) independent of everything. Its true premium is zero.
All the edges are compared with the same yardstick: a series of monthly returns of a portfolio, and the same summary of its profile.
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() - 1t 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 collapse1Note 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..
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).
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)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: float64The 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:
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)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: float64The 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 most2Note 2 · Jegadeesh and Titman (1993), in the resources, is the classic study of winner and loser portfolios.. 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 anyone3Note 3 · Daniel and Moskowitz (2016), in the resources, study momentum crashes in the rebounds after declines..
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:
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)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: float64The 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:
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)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: float64The 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 here3; 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 afterwards4Note 4 · Shleifer (1986), in the resources, uses S&P 500 inclusions to test whether the demand for stocks slopes downward..
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:
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) 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 20Additions 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.
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)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: float64The 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 surprise5Note 5 · Bernard and Thomas (1989), in the resources, test whether the drift is a delayed response or a risk premium.. 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:
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)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: float64The 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:
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)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: float64The 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:
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) 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.12The 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:
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) 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.58Carry'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 : 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:
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) 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.7Momentum 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 source
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.
Exercises
- 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
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) 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.542With 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.
- 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
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) rent_anual sharpe t
2006-2015 0.07 0.56 1.75
2016-2025 0.11 0.99 3.13If 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.
- 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).
Footnotes
-
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. ↩
-
Jegadeesh and Titman (1993), in the resources, is the classic study of winner and loser portfolios. ↩
-
Daniel and Moskowitz (2016), in the resources, study momentum crashes in the rebounds after declines. ↩ ↩2
-
Shleifer (1986), in the resources, uses S&P 500 inclusions to test whether the demand for stocks slopes downward. ↩
-
Bernard and Thomas (1989), in the resources, test whether the drift is a delayed response or a risk premium. ↩
Each answer is folded under its question
Self-assessment
A backtest finds that buying the stocks that rise on Tuesday is profitable. What is missing for it to be an edge hypothesis?
- AMore years of data
- BA Sharpe ratio above 1
- CA 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
- DTesting it in another market
Show answer
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).
The lesson's currency carry has a positive Sharpe and a very negative monthly skewness. What fits that profile best?
- AA behavioral bias the market will be slow to correct
- BA data error
- CAn information edge
- DA risk premium: it collects a little almost every month in exchange for large losses in crises, when money is worth more
Show answer
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.
Why does the index rebalance edge have little capacity even though its return per event is high?
- ABecause 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
- BBecause it is a false effect
- CBecause the index stocks are illiquid
- DBecause the return is negative
Show answer
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.
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?
- AThat ESG works
- BThat 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
- CThat the data are wrong
- DThat ESG should be removed from the data
Show answer
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).
What sets an information edge, such as post-earnings drift, apart from a risk premium?
- AThat the information edge has no risk
- BThat 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
- CThat the information edge only works in crypto
- DNothing: they are the same
Show answer
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
Books
- Brunnermeier, Nagel and Pedersen (2008): Carry Trades and Currency Crashes. NBER Macroeconomics Annual 23, 313-348
- Jegadeesh and Titman (1993): Returns to Buying Winners and Selling Losers. Journal of Finance 48(1), 65-91
- Daniel and Moskowitz (2016): Momentum crashes. Journal of Financial Economics 122(2), 221-247
- Bernard and Thomas (1989): Post-Earnings-Announcement Drift: Delayed Price Response or Risk Premium? Journal of Accounting Research 27
- Shleifer (1986): Do Demand Curves for Stocks Slope Down? Journal of Finance 41(3), 579-590