Lesson 4.2.4 · 75 min
Stress testing and correlation, liquidity, gap and counterparty risk
VaR describes normal and bad days; stress tests ask what happens on the worst ones. This lesson puts a multi-asset fund through eight scenarios (2008, 2020, 2022, a Black Monday like that of 1987 and four hypothetical ones), looks for the most likely shock that would cost it 20%, measures how diversification is lost when correlations change and computes how much it costs to get out, what a stop does in a gap and how much money is in the hands of each counterparty.
- Module
- 4.2 · Risk management and position sizing
- Duration
- 75 min
- Prerequisites
- 2.5.7 · Synthetic data for stress testing: GBM, GARCH and bootstrapping, 4.2.2 · Kelly criterion and volatility targeting: how much to risk, 4.2.3 · VaR, Expected Shortfall and VaR backtesting
- 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
What VaR does not ask
The VaR of lesson 4.2.3 is estimated with recent history and says how much is lost on a bad day of a distribution assumed to be known. A stress test asks another question: what happens to the portfolio if something specific and severe occurs, whether or not it has occurred in the data window. The Basel Committee's stress testing principles (2018) sum it up in one condition: scenarios must be “sufficiently severe but plausible” and cover the material risks of the institution, and they must be reviewed so that they remain relevant (principle 4). There are four families:
Mermaid source
flowchart TB E[Stress tests] --> H[Historical] E --> P[Hypothetical] E --> I[Reverse] E --> S[Sensitivity] H --> R[Revalue portfolio] P --> R I --> C[Shock that causes L] S --> R
- Historical: the moves of a real crisis are replayed (2008, 2020, 2022). They are plausible by definition, but the next crisis will not be the same.
- Hypothetical: a coherent shock is invented (rates rise 200 bp, the dollar 10%) and carried over to each asset with a model, here the factor loadings.
- Reverse: you start from the loss that cannot be tolerated and look for the scenario that produces it.
- Sensitivity: a single factor is moved (one volatility point, 1 bp in rates) to know the exposure. The Greeks of lesson 4.2.6 are exactly that for derivatives.
The level's scenarios
riesgo4_escenarios.csv contains eight scenarios as shock vectors (simple total return) on the 38 assets of the
level's synthetic universe. Four are historical and come from the prices themselves: the 2008 crisis from the Lehman
bankruptcy to the low, covid in February and March 2020, the 2022 inflation and the worst day of RV_EEUU in the
sample. Four are hypothetical: a Black Monday like that of October 19, 1987 (the universe starts in 2006), a parallel
rate rise of 200 bp, a 10% appreciation of the dollar and a credit crisis.
import numpy as np
import pandas as pd
esc = pd.read_csv("riesgo4_escenarios.csv")
choques = esc.pivot(index="activo", columns="escenario",
values="choque")
orden = ["crisis_2008", "covid_2020", "inflacion_2022", "peor_dia",
"lunes_negro_1987", "tipos_mas_200pb", "dolar_mas_10",
"crisis_credito"]
choques = choques[orden]
print(esc.groupby("escenario")["tipo"].first().loc[orden].to_string())
ver = ["RV_EEUU", "RV_EMG", "ALT_PRI", "RF_T710", "RF_T20", "RF_CHY",
"MP_ORO"]
(100 * choques.loc[ver]).T.round(1)escenario
crisis_2008 historico
covid_2020 historico
inflacion_2022 historico
peor_dia historico
lunes_negro_1987 hipotetico
tipos_mas_200pb hipotetico
dolar_mas_10 hipotetico
crisis_credito hipotetico
activo RV_EEUU RV_EMG ALT_PRI RF_T710 RF_T20 RF_CHY MP_ORO
escenario
crisis_2008 -47.3 -59.8 -63.5 12.5 29.5 -24.6 8.5
covid_2020 -34.3 -41.0 -46.8 5.3 14.0 -20.9 -1.7
inflacion_2022 -25.9 -41.1 -29.9 -14.5 -33.5 -13.6 -1.5
peor_dia -11.4 -14.0 -15.2 -0.3 -0.6 -2.0 -1.7
lunes_negro_1987 -19.8 -22.8 -28.3 2.0 4.6 -10.3 1.5
tipos_mas_200pb -5.1 -4.5 -6.5 -14.0 -32.2 -4.2 -5.6
dolar_mas_10 0.0 -8.6 0.0 0.0 0.0 0.0 -6.4
crisis_credito -15.0 -13.5 -25.5 3.0 6.9 -32.7 1.2The rows say what matters in each scenario. In 2008 Treasury bonds rise (12.5% for the 7-10 year bond, 29.5% for the
20+ year bond) while equities fall 47%; in 2022 both fall (−14.5% and −33.5%). Listed private equity (ALT_PRI)
falls more than the index in every equity scenario. Gold rises in 2008 and barely moves in the others. The
hypothetical Black Monday is a −19.8% day for RV_EEUU; the real one in 1987, according to the Federal Reserve, was a
22.6% drop in the Dow Jones in a single session.
The second cell checks where both types come from. The historical ones are the price ratio between the two dates of
the scenario; the hypothetical ones, the true loadings of the factor model (universo4_verdad.csv) multiplied by the
factor shock, without an idiosyncratic component:
precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha")
verdad = pd.read_csv("universo4_verdad.csv", index_col="activo")
B = verdad.filter(like="b_").rename(columns=lambda c: c[2:])
# historical: price at the close of `fin` / at the close of `inicio` − 1
ini, fin = "2008-09-12", "2009-03-09"
hist = precios.loc[fin] / precios.loc[ini] - 1
print("Historical 2008, maximum difference:",
f"{(hist - choques['crisis_2008']).abs().max():.1e}")
# hypothetical: true loadings × factor shock
lunes = pd.Series({"mercado": -0.20, "tamano": -0.02, "tipos": 0.02,
"credito": -0.03, "materias": -0.05,
"dolar": -0.02, "emergentes": -0.05,
"europa": -0.03, "japon": -0.03}
).reindex(B.columns, fill_value=0.0)
print("Black Monday, maximum difference:",
f"{(B @ lunes - choques['lunes_negro_1987']).abs().max():.1e}")Historical 2008, maximum difference: 5.2e-06
Black Monday, maximum difference: 5.6e-17A hypothetical scenario is only as good as its model: if the loadings of an asset change in a crisis, or if the asset has its own risk that the model does not capture, the revaluation is wrong. In the data, the model is the true one; in a real portfolio, the loadings are estimated (lesson 4.1.6).
A multi-asset fund under stress
The lesson's portfolio is a 500 million USD fund spread across fourteen assets: 45% equities, 38% fixed income, 10% alternatives (listed private equity, infrastructure and real estate) and 7% commodities. Its loss in each scenario, by asset class, in percentage points of the fund:
pesos = pd.Series({
"RV_EEUU": .25, "RV_EUR": .08, "RV_EMG": .07, "RV_EEUU_P": .05,
"ALT_PRI": .03, "INFRA": .03, "INM_EEUU": .04,
"RF_T710": .15, "RF_T20": .05, "RF_CIG": .08, "RF_CHY": .07,
"RF_EMG": .03, "MP_ORO": .04, "MP_AMP": .03})
capital = 500e6
activos = pd.read_csv("universo4_activos.csv", index_col="activo")
clase = activos["clase"].replace({"inmobiliario": "alternativo"})
aporte = choques.loc[pesos.index].mul(pesos, axis=0) # as a fraction
por_clase = 100 * aporte.groupby(clase.loc[pesos.index]).sum()
por_clase.loc["total"] = por_clase.sum()
print("Weights by class:",
pesos.groupby(clase.loc[pesos.index]).sum().round(2).to_dict())
por_clase.T.round(2)Weights by class: {'alternativo': 0.1, 'materias_primas': 0.07, 'renta_fija': 0.38, 'renta_variable': 0.45}
clase alternativo materias_primas renta_fija renta_variable total
escenario
crisis_2008 -5.52 -0.78 1.62 -23.09 -27.78
covid_2020 -3.81 -0.68 -0.41 -16.42 -21.31
inflacion_2022 -2.88 0.32 -6.56 -12.22 -21.35
peor_dia -1.22 -0.12 -0.28 -5.75 -7.37
lunes_negro_1987 -2.06 -0.06 -0.41 -9.26 -11.79
tipos_mas_200pb -1.06 -0.22 -5.30 -2.18 -8.76
dolar_mas_10 -0.13 -0.45 -0.02 -1.40 -2.01
crisis_credito -1.90 0.05 -2.68 -6.60 -11.14The header figure ranks the result. The worst loss is that of 2008 (−27.8%, 139 million), with fixed income adding 1.6 points. In covid (−21.3%) fixed income already subtracts 0.4: high-yield credit falls more than Treasury bonds rise. And in the 2022 inflation (−21.4%) fixed income subtracts 6.6 points: the fund loses almost the same as in covid with a much smaller equity drop, because the leg that should have protected it falls with them. The hypothetical rate rise (−8.8%) confirms it: fixed income is here the largest source of loss.
What would VaR have said? With the 20 years of data, the portfolio's historical VaR:
rend = precios / precios.shift(1).fillna(100) - 1
r_cartera = rend[pesos.index] @ pesos # simple returns
mes = (1 + r_cartera).rolling(21).apply(np.prod, raw=True) - 1
print(f"1-day 99% VaR (20-year historical): "
f"{-r_cartera.quantile(0.01):.2%}")
print(f"21-day 99% VaR (20-year historical): "
f"{-mes.quantile(0.01):.2%}")
print(f"Worst day: {r_cartera.min():.2%} ({r_cartera.idxmin().date()})")
print(f"Worst rolling month: {mes.min():.2%} ({mes.idxmin().date()})")1-day 99% VaR (20-year historical): 2.27%
21-day 99% VaR (20-year historical): 15.62%
Worst day: -7.44% (2008-09-04)
Worst rolling month: -21.62% (2011-10-24)The one-day 99% VaR is 2.27% and the worst day of the sample, −7.44%; the hypothetical Black Monday would cost 11.8%. At one month, the VaR is 15.6% and the worst rolling month in history, −21.6% (in 2011). The 2008 and 2022 scenarios last months and lose more than any monthly VaR. This does not make VaR useless: it describes the distribution of normal and bad days; scenarios describe the worst ones. A serious risk limit needs both, as was seen with premium selling in lesson 3.6.1.
The reverse stress test
A reverse stress test starts from the unacceptable loss, for example 20% of the fund in one month, and looks for what produces it. There are infinitely many combinations; the most likely one, with a covariance of the factors, is the one with the smallest Mahalanobis distance. If is the portfolio's exposure to each factor (the loadings weighted by the weights), the shock that loses with the smallest distance is
The cell uses the true covariance of the factors in the stress regime, scaled to one month (21 sessions), and compares the result with the cumulative move of the factors in covid (February 20 to March 23, 2020):
fv = pd.read_csv("universo4_factores_verdad.csv")
def cov_factores(regimen):
t = fv[fv.regimen == regimen].set_index("factor")
corr = t.filter(like="corr_").rename(columns=lambda c: c[5:])
vol = t["vol_anual"]
return corr.loc[B.columns, B.columns] * np.outer(vol, vol)
S = cov_factores("estres") * 21 / 252 # one month of stress
b = B.loc[pesos.index].T @ pesos # exposure to factors
perdida = 0.20
f_inv = -perdida * (S @ b) / (b @ S @ b) # minimum distance
d = perdida / np.sqrt(b @ S @ b) # in standard deviations
fd = pd.read_parquet("universo4_factores_diarios.parquet")
fd = fd.set_index("fecha")
f20 = fd.loc["2020-02-20":"2020-03-23"].filter(like="f_").sum()
f20.index = [c[2:] for c in f20.index]
print(f"Exposure to market {b['mercado']:.2f}, "
f"rates {b['tipos']:.2f}, credit {b['credito']:.2f}; "
f"distance {d:.2f} standard deviations")
pd.DataFrame({"inverso_%": 100 * f_inv,
"covid_2020_%": 100 * f20}).round(1)Exposure to market 0.55, rates 0.43, credit 0.35; distance 2.54 standard deviations
inverso_% covid_2020_%
mercado -32.6 -42.0
tamano -2.3 -7.3
valor -1.2 -9.1
tipos 2.2 5.5
credito -3.9 -6.8
materias -12.4 -22.8
dolar 3.5 4.6
emergentes -5.6 -9.1
europa -0.2 1.7
japon -0.0 -4.5The fund has an exposure of 0.55 to the market, 0.43 to rates (because of the bonds) and 0.35 to credit. The most likely shock that costs it 20% in a month of stress is a 32.6% drop in the market factor accompanied by commodities (−12.4%), emerging markets (−5.6%) and credit (−3.9%), with the dollar rising 3.5% and bonds 2.2%: it lies 2.54 standard deviations from a month of stress. It is a crisis like covid, which was worse in the market factor (−42.0%). The reverse stress test does not say that this will happen; it says that the unacceptable loss is one covid away and that the bond hedge barely changes the figure.
Correlations that change in crises
Correlations estimated with the full sample are, above all, those of the calm: 4,367 of the 5,031 sessions. By regime, in the sample and in the model's truth:
exceso = rend.sub(fd["rf"], axis=0)
cov = pd.read_csv("universo4_cov_verdad.csv")
def cov_verdad(regimen, nombres):
c = cov[cov.regimen == regimen].set_index("activo")
return c.loc[nombres, nombres]
pares = [("RV_EEUU", "RF_T710"), ("RV_EEUU", "RF_CHY"),
("RV_EEUU", "MP_ORO"), ("RV_EEUU", "RV_EMG")]
filas = {}
for reg in ("calma", "estres", "inflacion"):
dias = fd.index[fd["regimen"] == reg]
m = exceso.loc[dias].corr()
for a, b_ in pares:
c = cov_verdad(reg, [a, b_])
filas[(f"{a}-{b_}", reg)] = {
"muestra": m.at[a, b_],
"verdad": c.iat[0, 1] / np.sqrt(c.iat[0, 0] * c.iat[1, 1])}
print("Sessions by regime:", fd["regimen"].value_counts().to_dict())
pd.DataFrame(filas).T.unstack().round(2)Sessions by regime: {'calma': 4367, 'estres': 469, 'inflacion': 195}
muestra verdad
calma estres inflacion calma estres inflacion
RV_EEUU-MP_ORO 0.08 0.14 0.26 0.06 0.15 0.19
RV_EEUU-RF_CHY 0.62 0.71 0.75 0.62 0.73 0.75
RV_EEUU-RF_T710 -0.23 -0.41 0.53 -0.24 -0.46 0.51
RV_EEUU-RV_EMG 0.84 0.96 0.85 0.84 0.96 0.88The correlation between RV_EEUU and the 7-10 year bond is −0.23 in calm, −0.41 under stress (flight to quality)
and +0.53 in the inflation regime. That of equities with high yield rises from 0.62 to 0.75; that of the US with
emerging markets, from 0.84 to 0.96 under stress: in a crisis, the geographic diversification of equities almost
disappears. The figure shows the rolling stock-bond correlation from 2006 to 2025: almost always negative, more so in
crises, and positive only in 2022. The sample figures look like the true ones because the regime is known; in
reality, the regime is known afterwards.
How much risk does the change in correlations add? The cell computes the fund's true volatility in each regime and what it would have with the volatilities of that regime and the correlations of the calm:
nombres = list(pesos.index)
w = pesos.values
filas = {}
Cc = cov_verdad("calma", nombres).values
corr_calma = Cc / np.outer(np.sqrt(np.diag(Cc)), np.sqrt(np.diag(Cc)))
for reg in ("calma", "estres", "inflacion"):
C = cov_verdad(reg, nombres).values
vol = np.sqrt(np.diag(C))
mixta = np.outer(vol, vol) * corr_calma # regime vols,
filas[reg] = { # calm correlation
"vol_cartera": np.sqrt(w @ C @ w),
"vol_con_corr_calma": np.sqrt(w @ mixta @ w),
"suma_vols": w @ vol,
"ratio_diversif": (w @ vol) / np.sqrt(w @ C @ w)}
pd.DataFrame(filas).T.round(3) vol_cartera vol_con_corr_calma suma_vols ratio_diversif
calma 0.093 0.093 0.131 1.398
estres 0.273 0.259 0.327 1.198
inflacion 0.158 0.136 0.192 1.218In calm, the fund has a volatility of 9.3% and the weighted sum of its assets' volatilities is 13.1%: a diversification ratio of 1.40. Under stress, 27.3% (ratio 1.20): with the correlations of the calm it would be 25.9%, so almost all the increase comes from volatilities. In inflation, 15.8% versus 13.6% with the correlations of the calm: here correlations explain a third of the increase (2.2 of the 6.5 points), because the correlation between the two largest legs changes sign. A risk model estimated in calm underestimates both effects; the stress test must move volatilities and correlations at the same time.
Liquidity: how much it costs to get out
The losses of a scenario assume that the portfolio can be valued and sold at those prices. With a large portfolio or
illiquid assets that is not the case: selling it takes days and each sale moves the price. riesgo4_liquidez.csv
gives, for each asset (as a fictitious ETF), the average daily volume (ADV), the normal and stress bid-ask spread, how
much the ADV falls in a crisis and the coefficient of the square-root law, which lesson 4.3.1 studies with the orders
of a trading desk:
Each answer is folded under its question
Self-assessment
In the 2022 inflation scenario the fund loses 21.4%, almost the same as in covid (−21.3%), even though equities fall less (−26% versus −34% in
RV_EEUU). Why?- ABecause in 2022 the fund held more equities
- BBecause fixed income, which cushioned the fall in 2008 and 2020, falls at the same time as equities: it contributes −6.6 points instead of adding
- CBecause of commodities
- DBecause the 2022 scenario is longer and accumulates costs
Show answer
Answer B. Because fixed income, which cushioned the fall in 2008 and 2020, falls at the same time as equities: it contributes −6.6 points instead of adding
The stock-bond correlation goes from negative to +0.5 in the inflation regime. The hedge the portfolio relied on disappears precisely in that scenario.
What is a reverse stress test?
- AA scenario in which the market goes up
- BReplaying a historical scenario backwards
- CStarting from an unacceptable loss and looking for the combination of moves that produces it; with a covariance, the most likely one is the one with the smallest Mahalanobis distance
- DA scenario without correlations
Show answer
Answer C. Starting from an unacceptable loss and looking for the combination of moves that produces it; with a covariance, the most likely one is the one with the smallest Mahalanobis distance
Instead of asking how much is lost in a given scenario, you ask which scenario makes you lose what you cannot afford to lose. With the covariance of the factors under stress, the most likely shock is f = −L·Σb/(b′Σb).
In the stress regime the fund's true volatility is 27.3%; with stress volatilities but calm correlations it would be 25.9%. What does that difference say?
- AThat correlations never matter
- BThat the model is wrong
- CThat the portfolio is better diversified under stress
- DThat in this fund most of the increase in risk under stress comes from volatilities; correlations add 1.4 points. In the inflation regime their share is larger (13.6% versus 15.8%), because of the stock-bond correlation
Show answer
Answer D. That in this fund most of the increase in risk under stress comes from volatilities; correlations add 1.4 points. In the inflation regime their share is larger (13.6% versus 15.8%), because of the stock-bond correlation
Separating volatilities and correlations tells you which hedge fails. Under stress both rise; in inflation the one that weighs most in a mixed portfolio changes sign.
A sell stop 1% below the previous close triggers 276 times in the KAPA data; 18 of them at the open. What price does it get in those 18?
- AThe opening price, worse than the stop: the gap goes through it without trading at its price; the worst case costs 87.6 bp more than planned
- BThe stop price
- CThe day's close
- DIt is not executed
Show answer
Answer A. The opening price, worse than the stop: the gap goes through it without trading at its price; the worst case costs 87.6 bp more than planned
A stop is an order that becomes a market order when the price is touched. If the price opens below it, it is executed at the first available price. A stop limits the expected loss, not the maximum loss.
Kestrel Exchange holds 2.3 million in cash and 0.9 million in non-segregated margin: an exposure of 3.2 million against a limit of 1.5. Why does all the margin count?
- ABecause it is not segregated: if the entity goes bankrupt, that money is on its balance sheet and the client is just another creditor; the segregated margin at Corredora Atlas does not count
- BBecause margin always counts
- CBecause it is a crypto exchange
- DBecause it has no rating
Show answer
Answer A. Because it is not segregated: if the entity goes bankrupt, that money is on its balance sheet and the client is just another creditor; the segregated margin at Corredora Atlas does not count
The exposure to a counterparty is what is lost if it goes bankrupt tomorrow: cash, margin in non-segregated accounts and the net value in your favor of derivatives not covered by collateral.
Key points
- A stress test revalues the portfolio in severe but plausible scenarios: historical ones, which come from prices, and hypothetical ones, which come from a factor shock and the loadings.
- The lesson's multi-asset fund loses 27.8% in 2008 and 21.4% in the 2022 inflation, versus a one-month 99% VaR of 15.6%: VaR is not a loss limit.
- The reverse stress test looks for the most likely shock that causes an unacceptable loss: for 20% in a month of stress, a 33% market drop together with commodities, at 2.5 standard deviations.
- Correlations change with the regime: the stock-bond correlation goes from −0.24 in calm to +0.51 in inflation. In a mixed portfolio, the hedge that fails is precisely the one that weighs most.
- Liquidity is measured in days and in cost: 15 million in an illiquid ETF take 6 days to sell under stress and cost 468 bp; in a normal year, 58.
- A stop does not limit the maximum loss (a gap goes through it) and counterparty exposure is aggregated by entity: two of the six exceed their limit.