Lesson 4.2.2 · 70 min
Kelly criterion and volatility targeting: how much to risk
Kelly says what fraction of capital maximizes long-term growth; volatility targeting keeps the risk of the position constant. This lesson derives both, applies them to the level's strategies and universe and measures them by what they cost: the growth lost by misestimating the mean, the drawdowns of full Kelly and the day when the volatility control had its highest exposure right before the blow.
- Module
- 4.2 · Risk management and position sizing
- Duration
- 70 min
- Prerequisites
- 2.1.4 · Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library, 2.2.2 · Universe, signal, filter, entry, exit, sizing and risk management, 2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO), 2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index, 4.2.1 · Risk per trade, stops and risk of ruin
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
Kelly: how much to bet when you have an edge
Lesson 2.2.2 spread risk across markets with a hand-set volatility target, and 4.2.1 limits what is risked on each trade. The question above them is missing: how much total risk is worth taking with a given edge. John L. Kelly answered it in 1956 for a gambler who repeatedly bets a fraction of his capital: the one that maximizes the expected logarithmic growth, which is the rate at which capital grows in the long run. For a bet that wins per unit with probability and loses the unit with :
A coin with a 55% hit rate and an even-money payout gives : bet 10% of the capital on each toss.
import numpy as np
import pandas as pd
p, b = 0.55, 1.0 # probability of winning and payout per unit
f_kelly = p - (1 - p) / b
print(f"Kelly fraction: {f_kelly:.2f}")
def g(f):
"""Expected logarithmic growth per bet."""
return p * np.log1p(f * b) + (1 - p) * np.log1p(-f)
fracciones = [0.025, 0.05, 0.10, 0.15, 0.20, 0.30]
pd.Series({f: g(f) for f in fracciones}, name="g").round(5)Kelly fraction: 0.10
0.025 0.00219
0.050 0.00375
0.100 0.00501
0.150 0.00374
0.200 -0.00014
0.300 -0.01620
Name: g, dtype: float64Growth per bet is maximal at (0.00501 in logarithms) and almost symmetric around it: 0.05 and 0.15 give the same (0.00375 and 0.00374). With 0.20, twice Kelly, growth is already negative (−0.00014): you play with an edge and, even so, capital tends to shrink. With 0.30 the capital vanishes. An average does not show it; a thousand repeated bets by 4,000 players do:
rng = np.random.default_rng(7)
gana = rng.random((4000, 1000)) < p # 4,000 paths of 1,000
filas = {}
for f in (0.05, 0.10, 0.20, 0.30):
paso = np.where(gana, np.log1p(f * b), np.log1p(-f))
lw = np.cumsum(paso, axis=1) # log of wealth
pico = np.maximum.accumulate(np.maximum(lw, 0), axis=1)
caida = np.exp(lw - pico) - 1
filas[f] = {"mediana_final": np.exp(np.median(lw[:, -1])),
"p_pierde": (lw[:, -1] < 0).mean(),
"p_mitad_inicial": (lw.min(axis=1)
<= np.log(0.5)).mean(),
"p_caida_50": (caida.min(axis=1) <= -0.5).mean(),
"p_caida_80": (caida.min(axis=1) <= -0.8).mean()}
pd.DataFrame(filas).T.round(3) mediana_final p_pierde p_mitad_inicial p_caida_50 p_caida_80
0.05 42.632 0.009 0.116 0.854 0.064
0.10 149.660 0.061 0.483 1.000 0.850
0.20 0.871 0.514 0.911 1.000 1.000
0.30 0.000 0.954 0.997 1.000 1.000With Kelly (0.10) the median player multiplies their capital by 150, but 6.1% end up losing, 48.3% touch half the initial capital at some point and all of them suffer a 50% drawdown from a peak. With half Kelly (0.05) the median multiplies by 43, only 0.9% lose, 11.6% touch half the initial capital and 6.4% suffer an 80% drawdown, versus 85% with Kelly. With twice Kelly (0.20) half of the players lose money with a coin in their favor.
In continuous time: the optimal volatility is the Sharpe ratio
For a strategy with expected excess return and volatility (annual), leveraged times and rebalanced often, the logarithmic growth of the excess is , a parabola. Its maximum:
The third equality is the most useful one: at Kelly, account volatility equals the Sharpe ratio. A strategy with a Sharpe of 0.5 calls for 50% annual volatility, whatever its own volatility. With a fraction of Kelly, growth is and volatility . The logarithm of capital is then a Brownian motion with drift and variance , and the probability that it ever falls to a fraction of the initial capital is (the probability of touching a lower barrier, with known parameters and an infinite horizon):
c = np.array([0.25, 0.5, 0.75, 1.0, 1.5, 2.0])
expo = 2 / c - 1 # P(ever falling to x) = x ** expo
pd.DataFrame({"crecimiento_vs_max": 2 * c - c**2,
"vol_en_sharpes": c,
"p_caer_a_la_mitad": 0.5 ** expo,
"p_caer_al_20": 0.2 ** expo},
index=pd.Index(c, name="fraccion")).round(3) crecimiento_vs_max vol_en_sharpes p_caer_a_la_mitad p_caer_al_20
fraccion
0.25 0.438 0.25 0.008 0.000
0.50 0.750 0.50 0.125 0.008
0.75 0.938 0.75 0.315 0.068
1.00 1.000 1.00 0.500 0.200
1.50 0.750 1.50 0.794 0.585
2.00 0.000 2.00 1.000 1.000The header figure draws those two curves. Growth is flat near the maximum and the drawdown probability is not: going from Kelly to half Kelly costs 25% of the growth and divides by four the probability of losing half (from 50% to 12.5%); a quarter Kelly keeps 44% of the growth with 0.8%. Above Kelly everything gets worse at once: with 1.5 times Kelly growth is the same as with half Kelly (0.75) and the probability of losing half, 79%. The coin simulation agrees with the formula (0.116 and 0.483 versus 0.125 and 0.5). The error is not symmetric: underbetting costs little; overbetting, a lot.
Kelly on the level's strategies
The ten strategies in estrategias4_diarias.parquet are simple daily returns in excess of the risk-free rate,
2006-2025, with a known true mean (exceso_anual_esperado in estrategias4_verdad.csv). The lesson leaves out
degradada, which stops working in 2019 and is the case of lesson 4.2.5. In addition to , the cell
computes the “exact” Kelly of the data (the that maximizes the mean of with the actual daily
returns, tails included) and the leverage that ruins the account on the worst day of the sample:
from scipy.optimize import minimize_scalar
est = pd.read_parquet("estrategias4_diarias.parquet")
est = est.set_index("fecha").drop(columns="degradada")
verdad = pd.read_csv("estrategias4_verdad.csv",
index_col="estrategia").reindex(est.columns)
def kelly_log(r):
"""f that maximizes the mean of log(1 + f·r) in the data."""
tope = 0.999 / abs(r.min()) # beyond this, ruin
res = minimize_scalar(lambda f: -np.log1p(f * r).mean(),
bounds=(0, tope), method="bounded")
return res.x
mu, var = est.mean() * 252, est.var() * 252
tabla = pd.DataFrame({
"mu": mu, "vol": np.sqrt(var),
"kelly": mu / var,
"kelly_verdad": verdad["exceso_anual_esperado"] / var,
"kelly_log": est.apply(kelly_log),
"ruina": 1 / est.min().abs()})
tabla.round(2) mu vol kelly kelly_verdad kelly_log ruina
tendencia_futuros 0.06 0.11 4.97 4.30 4.94 20.25
carry_futuros 0.03 0.08 5.06 5.63 5.02 31.35
carry_divisas 0.05 0.09 5.81 3.81 5.73 21.78
venta_puts 0.04 0.12 2.56 2.82 2.54 15.25
pares 0.02 0.06 6.72 8.43 6.61 25.65
momentum_acciones 0.05 0.12 3.19 2.83 3.12 12.44
valor_acciones 0.02 0.09 3.01 3.06 3.01 31.49
venta_volatilidad 0.04 0.16 1.55 2.61 1.47 5.19
reversion_corto 0.03 0.07 6.41 5.61 6.36 24.95Three readings:
- The Kelly fractions are large: from 1.5 to 6.7 times the capital for single strategies with volatilities from 6 to 16%. It is the consequence of : with a Sharpe of 0.4, Kelly calls for 40% volatility.
- The sample mean drifts away from the true one even with 20 years: in
carry_divisasthe sample Kelly is 5.81 and the true one 3.81; inventa_volatilidad, 1.55 versus 2.61. With the sample mean you bet 50% too much on the first and 40% too little on the second. - Tails dominate in volatility selling. Its exact Kelly (1.47) is smaller than the formula's, and the ruin leverage, 5.19 times, is only twice the true Kelly: the worst day of the sample loses 19.3%. In the rest, ruin lies at 12-31 times the capital and the formula and the exact Kelly almost coincide.
With nine weakly correlated strategies, the equal-weight portfolio has a higher Sharpe and a much lower volatility, and Kelly shoots up:
cartera = est.mean(axis=1) # equal-weight, simple returns
k_cartera = cartera.mean() / cartera.var()
print(f"Portfolio Kelly: {k_cartera:.1f} times the capital")
print(f"Volatility at Kelly: {k_cartera * cartera.std() * 252**.5:.0%}")
def resultado(r):
w = (1 + r).cumprod()
años = len(r) / 252
return {"cagr": w.iloc[-1] ** (1 / años) - 1
if w.iloc[-1] > 0 else -1.0,
"max_caida": (w / w.cummax() - 1).min(),
"peor_dia": r.min(),
"ruina": bool((w <= 0).any())}
fr = {c: resultado(c * k_cartera * cartera)
for c in (0.125, 0.25, 0.5, 1.0, 2.0)}
print("Worst day:", cartera.idxmin().date())
pd.DataFrame(fr).T.astype(float).round(3)Portfolio Kelly: 18.8 times the capital
Volatility at Kelly: 84%
Worst day: 2018-02-05
cagr max_caida peor_dia ruina
0.125 0.086 -0.315 -0.053 0.0
0.250 0.167 -0.544 -0.106 0.0
0.500 0.303 -0.816 -0.213 0.0
1.000 0.417 -0.979 -0.426 0.0
2.000 -0.081 -1.000 -0.851 0.0Kelly calls for 18.8 times the capital and 84% volatility. With the full-sample Kelly (which uses the 20-year mean from the first day: look-ahead) the result is the theoretical one with a nuance: full Kelly gives the highest CAGR (41.7%), but with a maximum drawdown of −97.9% and one day, February 5, 2018, on which it loses 42.6% of the capital. One eighth of Kelly (2.3 times) gives 8.6% with a drawdown of −31.5%. Twice Kelly loses 8.1% per year and ends up with a 100% drawdown (rounded). No fraction reaches strict ruin (the account does not go below zero because the portfolio's worst day loses 2.3%), but a 98% drawdown is ruin for practical purposes.
With what was known each day (mean and variance since the start, with a three-year warm-up and no short positions), the portfolio Kelly starts at zero in 2009, because the 2008 crisis left the sample mean negative, and rises to 5.9 in 2013, 14.6 in 2017 and 18.7 in 2025:
mu_t = cartera.expanding(756).mean().shift(1)
var_t = cartera.expanding(756).var().shift(1)
k_t = (mu_t / var_t).clip(lower=0) # no short positions
print(k_t.resample("YE").last().loc["2009":"2025":4].round(1))
pd.DataFrame({c: resultado((c * k_t * cartera).dropna())
for c in (0.25, 0.5, 1.0)}).T.astype(float).round(3)fecha
2009-12-31 0.0
2013-12-31 5.9
2017-12-31 14.6
2021-12-31 16.2
2025-12-31 18.7
Freq: 4YE-DEC, dtype: float64
cagr max_caida peor_dia ruina
0.25 0.129 -0.346 -0.084 0.0
0.50 0.251 -0.581 -0.168 0.0
1.00 0.450 -0.839 -0.337 0.0The CAGR of full Kelly comes out even higher (45.0%) because it reached the strategy late and with little leverage in the 2009-2012 drawdowns; the maximum drawdown, −83.9%, and the worst day, −33.7%, are still those of an account that nobody would keep. Calendar luck decides more here than the rule.
Estimation error
Kelly assumes that and are known. Volatility is estimated well with little data; the mean is not. With years of data, the sample mean has a standard error of , so the estimated Kelly is with . Betting a fraction of the estimated Kelly gives an expected growth, relative to that of the true Kelly, of
c^{\text{opt}} = \frac{\mathrm{SR}^2 T}{1 + \mathrm{SR}^2 T}.$$ The term $1/(\mathrm{SR}^2 T)$ is the relative variance of the estimated mean: a Sharpe of 0.4 measured with 5 years has a t-statistic of $0.4\sqrt{5} = 0.89$, and the estimated Kelly can be anything between zero and twice the true one. The cell checks it by simulation: :::cell{title="Estimation error: how much growth is lost"} ```python rng = np.random.default_rng(11) sr = 0.4 # true annual Sharpe filas = {} for años in (3, 5, 10, 20): # estimated f / true f = estimated mean / true mean u = rng.normal(1, 1 / (sr * np.sqrt(años)), 200_000) for c in (0.25, 0.5, 0.75, 1.0): rel = 2 * c * u - (c * u) ** 2 # g / maximum g filas[(años, c)] = {"g_rel_media": rel.mean(), "p_g_negativo": (rel < 0).mean()} res = pd.DataFrame(filas).T res.index.names = ["años", "fraccion"] c_opt = {a: sr**2 * a / (1 + sr**2 * a) for a in (3, 5, 10, 20)} print("Optimal fraction:", {a: round(v, 2) for a, v in c_opt.items()}) res.unstack("fraccion")["g_rel_media"].round(2) ``` ```output Optimal fraction: {3: 0.32, 5: 0.44, 10: 0.62, 20: 0.76} fraccion 0.25 0.50 0.75 1.00 años 3 0.31 0.23 -0.23 -1.08 5 0.36 0.44 0.24 -0.24 10 0.40 0.59 0.59 0.38 20 0.42 0.67 0.76 0.69 ``` ::: ::figure{vega="n4-m2-kelly-estimacion"} With 20 years of data, the full estimated Kelly keeps 69% of the maximum growth and the best fraction is 0.76 (76%). With 5 years, the full estimated Kelly has a negative expected growth (−24%) and the best fraction is 0.44. With 3 years, 0.32: the practical rule of “half Kelly” or “quarter Kelly” is not gratuitous caution, it is what the error in the mean demands. And the formula is still optimistic for three reasons: - The Sharpe of a backtest is usually inflated by selection among many variants (lesson 2.5.4): the mean from which $\hat f$ comes is not a random sample but the best of several. - Fat tails and jumps (volatility selling) push the exact Kelly below $\mu/\sigma^2$. - Portfolio correlations rise in crises (lesson 4.2.4): the volatility of the portfolio of the nine is not that of 2006-2025 when it matters most. ## Volatility targeting: constant risk **Volatility targeting** does not decide how much risk to take, but keeps it constant: it sets a target volatility $\sigma^*$ and adjusts the exposure each day with the forecast volatility, computed only with data up to yesterday's close: $$w_t = \min\!\left(\frac{\sigma^*}{\hat\sigma_{t-1}},\; w_{\max}\right), \qquad \hat\sigma^2_{t-1} = \text{EWMA of } r^2 \text{ up to } t-1.$$ ```mermaid flowchart LR R[Returns] --> V[EWMA vol. yesterday] V --> E[Target / vol.] E --> T[Cap and band] T --> O[Order at the close] O --> R ``` Applied to `RV_EEUU` (the universe's US equities, which mimic the total-return S&P 500) with a 15% target, a 20-session half-life and a cap of 2 times: :::cell{title="Volatility targeting on equities"} ```python precios = pd.read_parquet("universo4_precios.parquet") precios = precios.set_index("fecha") fac = pd.read_parquet("universo4_factores_diarios.parquet") fac = fac.set_index("fecha") rend = precios / precios.shift(1).fillna(100) - 1 # base 100 exceso = rend.sub(fac["rf"], axis=0) def vol_target(x, objetivo, vida_media=20, tope=2.0): """Exposure = target / YESTERDAY's EWMA vol., with a cap.""" sig = np.sqrt((x**2).ewm(halflife=vida_media, min_periods=60).mean() * 252) expo = (objetivo / sig.shift(1)).clip(upper=tope) return (expo * x).dropna(), expo def metricas(r): w = (1 + r).cumprod() vol_año = r.groupby(r.index.year).std() * 252**0.5 return {"exceso": r.mean() * 252, "vol": r.std() * 252**0.5, "sharpe": r.mean() / r.std() * 252**0.5, "max_caida": (w / w.cummax() - 1).min(), "peor_dia": r.min(), "vol_año_min": vol_año.min(), "vol_año_max": vol_año.max()} rv = exceso["RV_EEUU"] vt, expo = vol_target(rv, 0.15) print(f"Mean exposure {expo.mean():.2f}, max {expo.max():.2f};" f" turnover {expo.diff().abs().sum() / (len(expo) / 252):.1f}" " times per year") pd.DataFrame({"buy and hold": metricas(rv.loc[vt.index]), "vol. target 15%": metricas(vt)}).round(3) ``` ```output Mean exposure 0.95, max 1.84; turnover 4.4 times per year buy and hold vol. target 15% exceso 0.088 0.132 vol 0.207 0.157 sharpe 0.427 0.837 max_caida -0.609 -0.346 peor_dia -0.115 -0.090 vol_año_min 0.110 0.125 vol_año_max 0.318 0.195 ``` ::: ::figure{vega="n4-m2-vol-target-rv"} The control does what it promises with risk: realized annual volatility moves between 12.5 and 19.5% instead of between 11 and 31.8%, and the maximum drawdown drops from −60.9 to −34.6%. The figure shows why it is not exact: the control's 63-session volatility jumps at the start of each crisis, when exposure is still that of the calm, and goes back to 15% as soon as the EWMA catches up. Mean exposure is 0.95 and the maximum 1.84; turnover, 4.4 times the capital per year. And the Sharpe goes from 0.43 to 0.84. That last figure calls for an explanation before believing it. ## Why it improves the Sharpe, and when it does not Reducing exposure when volatility rises improves the Sharpe only if the expected return **does not rise in proportion** to volatility. If the mean were proportional to volatility (constant Sharpe), the control would change nothing; if it were proportional to variance, it would make it worse. This is what Moreira and Muir (2017) say: volatility-managed portfolios raise the Sharpe “because changes in volatility are not offset by proportional changes in expected returns”. In the data it can be checked directly, grouping days by terciles of forecast volatility: :::cell{title="Return by forecast volatility"} ```python pyg = pd.read_csv("riesgo4_pyg.csv", index_col="fecha", parse_dates=True)["rendimiento"] def por_tercil(x): sig = np.sqrt((x**2).ewm(halflife=20).mean()).shift(1) t = pd.qcut(sig, 3, labels=["low", "medium", "high"]) t.name = "vol_prevista" grupos = x.groupby(t, observed=True) return pd.DataFrame({"media_anual": grupos.mean() * 252, "vol_anual": grupos.std() * 252**0.5, "sharpe": grupos.mean() / grupos.std() * 252**0.5}) vv = est["venta_volatilidad"] pd.concat({"GARCH book": por_tercil(pyg), "RV_EEUU": por_tercil(rv), "trend": por_tercil(est["tendencia_futuros"])} ).round(2) ``` ```output media_anual vol_anual sharpe vol_prevista GARCH book low 0.10 0.08 1.24 medium 0.05 0.11 0.49 high 0.17 0.25 0.67 RV_EEUU low 0.14 0.13 1.05 medium 0.26 0.16 1.60 high -0.13 0.29 -0.44 trend low 0.06 0.08 0.76 medium 0.00 0.10 0.01 high 0.11 0.13 0.83 ``` ::: - **GARCH book** (`riesgo4_pyg.csv`, lesson 4.2.3): the true mean is constant and volatility varies. The measured Sharpe falls from 1.24 in the calm tercile to 0.67 in the high-volatility one (the means, 0.10 and 0.17, are noise around 7.6% per year). It is the textbook case in which the control helps. - **`RV_EEUU`**: in the high-volatility tercile the mean is **−13%** per year. It is not a law of the market, it is the generator: the scripted crises have a negative drift and the calm a premium of around 20% per year, calibrated so that the 20-year mean is reasonable. The control wins mainly because it avoids the stretches that the generator made losers. - **`tendencia_futuros`**: the high-volatility tercile has the best Sharpe (0.83), because the strategy wins in crises by construction. There the control cuts back precisely when it wins. With the same control on several series (target equal to each one's volatility, cap of 3 times): :::cell{title="The same control on other series"} ```python series = {"GARCH book": pyg, "RV_EEUU": rv, "RF_T710": exceso["RF_T710"], "MP_ORO": exceso["MP_ORO"], "trend": est["tendencia_futuros"], "venta_vol": vv, "portfolio 9": cartera} filas, fechas = {}, {} for nombre, x in series.items(): y, _ = vol_target(x, x.std() * 252**0.5, tope=3.0) a, d = metricas(x.loc[y.index]), metricas(y) filas[nombre] = {"sharpe_antes": a["sharpe"], "sharpe_vt": d["sharpe"], "peor_dia_antes": a["peor_dia"], "peor_dia_vt": d["peor_dia"]} fechas[nombre] = str(y.idxmin().date()) tabla_vt = pd.DataFrame(filas).T.round(3) tabla_vt["fecha_peor_vt"] = pd.Series(fechas) tabla_vt ``` ```output sharpe_antes sharpe_vt peor_dia_antes peor_dia_vt fecha_peor_vt GARCH book 0.626 0.842 -0.087 -0.066 2010-06-18 RV_EEUU 0.427 0.837 -0.115 -0.123 2018-10-03 RF_T710 0.117 0.134 -0.025 -0.026 2015-10-20 MP_ORO 0.711 0.701 -0.079 -0.045 2008-12-22 trend 0.472 0.536 -0.049 -0.039 2018-09-28 venta_vol 0.303 0.537 -0.193 -0.279 2018-02-05 portfolio 9 0.855 0.988 -0.023 -0.025 2018-02-05 ``` ::: In the GARCH book the Sharpe rises from 0.63 to 0.84 and the worst day improves. In bonds (`RF_T710`) and gold, the control barely changes the Sharpe (0.12 to 0.13; 0.71 to 0.70). It is the pattern described by Harvey et al. (2018) with more than 60 assets and daily data since 1926: the Sharpe improvement is limited to risk assets (equities and credit) and is negligible in fixed income, currencies and commodities; the effect that is general is that it **reduces the probability of extreme returns**. And Cederburg et al. (2020), with 103 equity strategies, find that the volatility-managed versions do not systematically beat the originals and that, with versions implementable in real time, they tend to have a lower Sharpe. In the course data, which have the relationship planted, the control looks better than what you should expect in a real market. <aside class="callout" data-kind="riesgo"> The average improvement is not the figure that matters for sizing. The `peor_dia_vt` column of the table says that the control **worsens** the worst day in `RV_EEUU` (−11.5% to −12.3%), in volatility selling (−19.3% to −27.9%) and in the portfolio of the nine. A volatility control raises exposure in the calm, and jumps arrive in the calm. </aside> ## The worst episode and what it costs The control's two worst days, with forecast volatility and the previous day's exposure: :::cell{title="The control's two worst days"} ```python for nombre, x, ini, fin in [("venta_vol", vv, "2018-02-01", "2018-02-07"), ("RV_EEUU", rv, "2018-09-28", "2018-10-05")]: _, e = vol_target(x, x.std() * 252**0.5, tope=3.0) sig = np.sqrt((x**2).ewm(halflife=20, min_periods=60).mean()) t = pd.DataFrame({"rend": x, "sigmas": x / sig.shift(1), "exposicion": e, "resultado": e * x}) print(nombre) print(t.loc[ini:fin].round(3).to_string(), end="\n\n") ``` ```output venta_vol rend sigmas exposicion resultado fecha 2018-02-01 -0.004 -0.504 1.410 -0.005 2018-02-02 -0.004 -0.542 1.428 -0.006 2018-02-05 -0.193 -27.025 1.445 -0.279 2018-02-06 -0.002 -0.059 0.284 -0.001 2018-02-07 0.007 0.197 0.289 0.002 RV_EEUU rend sigmas exposicion resultado fecha 2018-09-28 0.006 0.498 1.107 0.006 2018-10-01 -0.024 -2.115 1.122 -0.027 2018-10-02 0.005 0.415 1.061 0.005 2018-10-03 -0.115 -9.505 1.076 -0.123 2018-10-04 -0.038 -1.572 0.535 -0.020 2018-10-05 -0.037 -1.476 0.522 -0.019 ``` ::: On February 5, 2018, volatility selling came from quiet months: exposure was 1.45 times. It loses 19.3%, **−27 standard deviations** of what the EWMA forecast, and the control turns it into −27.9%. The next day exposure drops to 0.28, with the loss already taken. On October 3, 2018, `RV_EEUU` falls 11.5% (−9.5 forecast standard deviations) with an exposure of 1.08: −12.3%. In both cases the control reacts afterwards, which is all it can do with closing data. Its protection works in crises that arrive slowly (2008, 2022) and not in jumps: for those there are the scenario limits of lesson 4.2.4, the exposure cap and the kill switch of 4.2.5. The control is not free either. Each exposure change is traded, and whatever goes above 1 has to be financed: :::cell{title="Costs: turnover, bid-ask spread and financing"} ```python liq = pd.read_csv("riesgo4_liquidez.csv", index_col="activo") media_horq = liq.at["RV_EEUU", "horquilla_pb"] / 2 / 1e4 diferencial = 0.01 # assumption: rf + 1% on the borrowed amount def con_costes(x, objetivo, banda=0.0): _, e = vol_target(x, objetivo) e = e.dropna() real = e.copy() for i in range(1, len(e)): # no-trade band prev = real.iloc[i - 1] if abs(e.iloc[i] / prev - 1) <= banda: real.iloc[i] = prev r = (real * x.loc[real.index] - real.diff().abs().fillna(0) * media_horq - (real - 1).clip(lower=0) * diferencial / 252) rot = real.diff().abs().sum() / (len(real) / 252) return {"sharpe": r.mean() / r.std() * 252**0.5, "rotacion_anual": rot, "coste_pb_año": (r.mean() - (real * x).mean()) * -252 * 1e4} pd.DataFrame({f"band {b:.0%}": con_costes(rv, 0.15, b) for b in (0.0, 0.10, 0.25)}).T.round(3) ``` ```output sharpe rotacion_anual coste_pb_año band 0% 0.829 4.440 11.989 band 10% 0.824 1.901 9.981 band 25% 0.746 1.200 10.160 ``` ::: With the half spread of the `RV_EEUU` ETF (0.435 bp, `riesgo4_liquidez.csv`) and an assumed financing spread of 1% on the borrowed amount, the cost is about 12 bp per year and the Sharpe goes from 0.837 to 0.829. A 10% no-trade band reduces turnover from 4.4 to 1.9 times per year almost at no cost in Sharpe (0.824); a 25% band tracks the target worse and the Sharpe drops to 0.746. In a liquid asset the cost is small; in one with a 20 bp bid-ask spread, the same turnover would cost about 44 bp per year in spread alone (lesson 2.4.7). ## The method from the site's research The research “Volatility Targeting in Python” (link under “Resources”) proposes another rule on a portfolio of stocks and long bonds: for each asset, $P = \hat\sigma_{100}/\sigma_{\text{hist}}$, the ratio between the 100-session volatility and that of the whole series, and a weight $\max(1 - P, 0)$: full exposure when volatility is at its minimum and zero when it exceeds the historical mean. It is a more aggressive volatility control than $\sigma^*/\hat\sigma$, which exits the market instead of scaling down. Replicated with `RV_EEUU` and `RF_T20` (total returns, as in the research): :::cell{title="The research's 1 − P method, with and without look-ahead"} ```python def metodo_1_menos_p(s, sin_anticipacion): """Weight = max(1 − 100-session vol / reference vol, 0).""" if sin_anticipacion: v = s.rolling(100).std().shift(1) ref = s.expanding(252).std().shift(1) else: # as in the research v = s.rolling(100).std().shift(-2) ref = s.std() return (1 - v / ref).clip(lower=0) * s def sharpe(r): r = r.dropna() return round(r.mean() / r.std() * 252**0.5, 3) acc, bono = rend["RV_EEUU"], rend["RF_T20"] print("fixed 60/40: ", sharpe(0.6 * acc + 0.4 * bono)) print("1 − P, shift(-2): ", sharpe(metodo_1_menos_p(acc, False) + metodo_1_menos_p(bono, False))) print("1 − P, shift(1): ", sharpe(metodo_1_menos_p(acc, True) + metodo_1_menos_p(bono, True))) ``` ```output fixed 60/40: 0.619 1 − P, shift(-2): 1.18 1 − P, shift(1): 0.938 ``` ::: In the research's code the rolling volatility is shifted with `.shift(-2)`. In pandas a **negative** shift brings in values from the future: row $t$ receives the volatility of the window ending at $t+2$, which includes the returns of $t$, $t+1$ and $t+2$ that the rule is meant to weight. The reference, the volatility of the whole series, also uses later data. With those two look-aheads the Sharpe is 1.18; computing volatility up to yesterday (`shift(1)`) and the reference only with the past, 0.94. Both versions beat the fixed 60/40 (0.62) on these data, because of the planted relationship between volatility and return seen above, but the difference between them, 0.24 of Sharpe, is look-ahead and could not be collected. It is the same error that lesson 2.5.1 looks for in any backtest: the rule must be computed with what was known when deciding. ## A complete sizing rule Kelly and the volatility control combine: Kelly gives the volatility that can be justified (a fraction of the Sharpe that is truly expected, not the backtest one) and the control maintains it. The limits from the neighboring lessons cap it from above: ```mermaid flowchart TB M[Expected Sharpe] --> F[Fraction c] F --> S[Target vol.] S --> V[Vol. control] V --> X[Cap and stress] X --> K[Kill switch] K --> P[Position] ``` | Step | What it decides | Cost or worst case in this lesson | |---|---|---| | Expected Sharpe | The backtest one, discounted for selection (2.5.4) and regime | A mean estimated with 5 years can be worth zero | | Fraction $c$ | $\mathrm{SR}^2T/(1+\mathrm{SR}^2T)$ at most; less with tails | Full Kelly: a −97.9% drawdown in the portfolio of the nine | | Target volatility | $c \cdot \mathrm{SR}$ | With a Sharpe of 0.8 and $c = 1/4$, 20%: already high for a personal account | | Volatility control | Exposure = target / yesterday's volatility | Jumps after the calm: −27.9% in one day in volatility selling | | Cap and stress | Maximum exposure and maximum loss per scenario (4.2.4) | The cap limits the exposure with which the jump arrives | | Kill switch | Cut off if the strategy stops working (4.2.5) | A Kelly estimated with old data leverages a dead strategy | In practice, the target volatility of a personal account is usually set well below Kelly: the one you can stand without changing plans in the worst reasonable drawdown, which is a psychological and financial number before a statistical one. Kelly serves above all as a **ceiling**: above it there is nothing to gain. ## Exercises 1. **Kelly on a strategy that degrades.** Estimate the Kelly of `degradada` with data up to 2019-06-30 and apply half Kelly, with that figure fixed, from 2019-07-01 onwards. Compute the CAGR and the maximum drawdown of both periods. What does this say about the order of the steps in the previous table? ::::solucion :::cell{title="Fixed half Kelly on the degraded strategy"} ```python deg = pd.read_parquet("estrategias4_diarias.parquet") deg = deg.set_index("fecha")["degradada"] antes, despues = deg[:"2019-06-30"], deg["2019-07-01":] k_deg = antes.mean() / antes.var() print(f"Kelly estimated up to 2019-06: {k_deg:.1f}") pd.DataFrame({"before": resultado(0.5 * k_deg * antes), "after": resultado(0.5 * k_deg * despues)} ).T.astype(float).round(3) ``` ```output Kelly estimated up to 2019-06: 16.8 cagr max_caida peor_dia ruina before 0.873 -0.830 -0.339 0.0 after -0.440 -0.997 -0.520 0.0 ``` ::: With thirteen and a half years of a strategy with a Sharpe of 1.30, the estimated Kelly is 16.8 times and half Kelly (8.4 times) gives a CAGR of 87.3% before the cutoff, already with a drawdown of −83%. Afterwards, the same exposure on a strategy without an edge loses 44% per year, with a drawdown of −99.7% and a −52% day: the volatility stays and the mean disappears. Kelly multiplies the edge it is given; if the edge no longer exists, it multiplies the noise. That is why the kill switch (lesson 4.2.5) comes before any size optimization and the estimate of the mean should be reviewed with recent data. :::: 2. **The EWMA half-life.** Apply the volatility control to `venta_volatilidad` with half-lives of 5, 20 and 60 sessions (target equal to its volatility, cap of 3). Compare the Sharpe, the maximum drawdown and the worst day. Is there a half-life that avoids February 5, 2018? ::::solucion :::cell{title="EWMA half-life in volatility selling"} ```python obj = vv.std() * 252**0.5 pd.DataFrame({vm: metricas(vol_target(vv, obj, vm, 3.0)[0]) for vm in (5, 20, 60)}).T[ ["sharpe", "max_caida", "peor_dia"]].round(3) ``` ```output sharpe max_caida peor_dia 5 0.550 -0.509 -0.470 20 0.537 -0.404 -0.279 60 0.445 -0.382 -0.249 ``` ::: No half-life avoids the jump: the worst day is always February 5, 2018. With 5 sessions it is much worse (−47.0%): the short half-life forgets the last crisis sooner, so after a few weeks of calm the exposure is the highest. With 60 sessions the worst day is −24.9% and the maximum drawdown the smallest (−38.2%), in exchange for a lower Sharpe (0.445 versus 0.537), because it reacts late and stays with low exposure for longer after each crisis. A one-day jump can only be controlled with the size you hold before it happens: exposure cap and scenario limits. :::: 3. **A short, brilliant backtest.** A system has a Sharpe of 1.5 in a 2-year backtest and its author wants to trade half Kelly. What fraction of the estimated Kelly does the estimation error formula recommend? What else would you discount and why? :::solucion With $\mathrm{SR} = 1.5$ and $T = 2$, $\mathrm{SR}^2T = 4.5$ and $c^{\text{opt}} = 4.5/5.5 \approx 0.82$: the formula, taken literally, would allow more than half Kelly. But it assumes that the Sharpe of 1.5 is the truth, measured without bias. A 2-year backtest chosen among several variants has an inflated Sharpe (lesson 2.5.4). If the true Sharpe were 0.5, the Kelly estimated with 1.5 would be three times the true one, and half the estimated Kelly would be 1.5 times the true Kelly: above the growth maximum, with 75% of the possible growth and a 79% probability of ever falling to half (table of fractions). In addition, two years contain no crisis, so volatility is underestimated and the correlations are those of the calm. The reasonable thing is to set the target volatility with a prudent expected Sharpe (not the backtest one), start with paper trading (lesson 4.5.3) and increase the size only when the strategy confirms live. :::Each answer is folded under its question
Self-assessment
A strategy has an expected excess return of 6% per year and a volatility of 12%. What leverage does Kelly give and what volatility would the account have?
- A4.2 times the capital and 50% volatility, which is the Sharpe ratio (0.5)
- B0.5 times the capital and 6% volatility
- C1 times the capital: Kelly never uses leverage
- D12 times the capital
Show answer
Answer A. 4.2 times the capital and 50% volatility, which is the Sharpe ratio (0.5)
f* = μ/σ² = 0.06/0.0144 ≈ 4.2. The account volatility is f*·σ = μ/σ = Sharpe = 50%. With fat tails and an estimated mean, nobody trades that: hence the fractions.
With half Kelly, what share of the maximum growth is kept and what is the probability of ever falling to half the initial capital (continuous case, known parameters)?
- A50% of the growth and a 50% probability
- B75% of the growth and a 12.5% probability, versus 50% for full Kelly
- C100% of the growth and no probability
- D25% of the growth and a 1% probability
Show answer
Answer B. 75% of the growth and a 12.5% probability, versus 50% for full Kelly
Relative growth is 2c − c² = 0.75 and the probability of touching x is x raised to 2/c − 1: 0.5³ = 0.125. The coin simulation gives 0.116 with a thousand bets.
A strategy with a Sharpe of 0.4 has been measured with 5 years of data. If you bet the full estimated Kelly, what growth do you expect relative to that of the true Kelly?
- A100%: the estimate is unbiased
- B75%
- C50%
- DNegative (−24%): the error in the mean makes the estimated Kelly often exceed twice the true one
Show answer
Answer D. Negative (−24%): the error in the mean makes the estimated Kelly often exceed twice the true one
With 5 years, estimated f / true f has a standard deviation of 1/(0.4·√5) ≈ 1.1. Expected growth is 2c − c²(1 + 1/(SR²T)) = 2 − 2.25 < 0. The fraction that maximizes it is 0.44.
In the data, volatility targeting raises the Sharpe of
RV_EEUUfrom 0.43 to 0.84. Why should you not expect the same in a real market?- ABecause the data have errors
- BBecause the control uses future information
- CBecause in the generator the high-volatility stretches have a negative drift and calm periods a high premium (≈ 20% per year): the control wins mainly because of that planted relationship; in the literature the improvement is small or does not survive out of sample
- DBecause real equities do not have time-varying volatility
Show answer
Answer C. Because in the generator the high-volatility stretches have a negative drift and calm periods a high premium (≈ 20% per year): the control wins mainly because of that planted relationship; in the literature the improvement is small or does not survive out of sample
The control improves the Sharpe when the expected return does not rise in proportion to volatility. In
RV_EEUUthe high-volatility tercile returns −13% per year by construction. Harvey et al. (2018) find an improvement in risk assets and almost none in bonds, currencies and commodities; Cederburg et al. (2020), that out of sample it does not systematically beat the unmanaged portfolio.On February 5, 2018, volatility selling loses 19.3% without the control and 27.9% with volatility targeting. What happened?
- AA data error
- BThe control sold at the worst moment
- CForecast volatility was low after months of calm, so exposure was 1.45 times; a jump of −27 forecast standard deviations leaves no time to reduce it
- DThe exposure cap did not work
Show answer
Answer C. Forecast volatility was low after months of calm, so exposure was 1.45 times; a jump of −27 forecast standard deviations leaves no time to reduce it
The control reacts to past volatility. A jump without warning arrives with exposure at its maximum and the control amplifies it. The next day exposure drops to 0.28, when the loss has already been taken.
Key points
- Kelly maximizes long-term growth with
f* = μ/σ²; at Kelly, account volatility is the Sharpe ratio. For the portfolio of the nine strategies that is 18.8 times the capital and 84% volatility. - Betting above Kelly is a bad deal: at 2 times Kelly growth is zero with all the risk. Below it, little growth and a lot of drawdown are given up: half Kelly keeps 75% of the growth with half the volatility.
- The mean is poorly estimated: with a sample of T years, the fraction that maximizes expected growth is SR²T/(1 + SR²T) of the estimated Kelly (0.44 with 5 years and a Sharpe of 0.4), and less if the backtest Sharpe is inflated.
- Volatility targeting keeps risk stable and reduces the maximum drawdown, but it only raises the Sharpe if return does not grow with volatility; in the data that relationship is planted and in the literature it is fragile.
- Its worst episode is the jump after the calm: high exposure and a larger loss than without the control (−27.9% versus −19.3% in volatility selling).
- A sizing rule is computed with what was known each day: a
shift(-2)in volatility raises the Sharpe of the 1 − P method from 0.94 to 1.18 without the rule being better.
Resources
Research
Books
- Kelly, J. L. (1956): A New Interpretation of Information Rate. Bell System Technical Journal 35(4), 917-926
- Thorp, E. O. (2006): The Kelly Criterion in Blackjack, Sports Betting, and the Stock Market. Handbook of Asset and Liability Management, ch. 9
- Moreira, A. and Muir, T. (2017): Volatility-Managed Portfolios. The Journal of Finance 72(4), 1611-1644
- Harvey, C. R. et al. (2018): The Impact of Volatility Targeting. The Journal of Portfolio Management 45(1), 14-33
- Cederburg, S. et al. (2020): On the performance of volatility-managed portfolios. Journal of Financial Economics 138(1), 95-117