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.

Fig. — A multi-asset fund (45% equities, 38% fixed income, 10% alternatives, 7% commodities) under the level's eight stress scenarios; asterisked, the hypothetical ones
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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:

Stress testsHistoricalHypotheticalReverseSensitivityRevalue portfolioShock that causes L
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.

IN [01]python
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)
OUT [01]Eight scenarios on 38 assets
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.2

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

IN [02]python
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}")
OUT [02]Where each type of scenario comes from
Historical 2008, maximum difference: 5.2e-06
Black Monday, maximum difference: 5.6e-17

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

IN [03]python
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)
OUT [03]A 500 million multi-asset fund under the eight scenarios
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.14

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

IN [04]python
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()})")
OUT [04]VaR versus stress
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 Σ\Sigma of the factors, is the one with the smallest Mahalanobis distance. If b=B⊤wb = B^\top w is the portfolio's exposure to each factor (the loadings weighted by the weights), the shock that loses LL with the smallest distance is

f∗=−L Σbb⊤Σb,d=Lb⊤Σb.f^* = -L\,\frac{\Sigma b}{b^\top \Sigma b}, \qquad d = \frac{L}{\sqrt{b^\top \Sigma b}}.

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

IN [05]python
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)
OUT [05]Reverse stress test: the most likely shock that costs 20%
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.5

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

IN [06]python
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)
OUT [06]Correlations by regime: sample versus truth
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.88
Fig. — 63-session correlation between US equities (RV_EEUU) and the 7-10 year bond (RF_T710) of the universe, at month end, 2006-2025; shaded, the stress and inflation stretches

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

IN [07]python
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)
OUT [07]The diversification that disappears
           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.218

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

\qquad Y = 0.7.$$ The cell assumes that 20% of the ADV is sold each day (or everything in one day if the position is smaller) and compares normal conditions with stress ones: stress spread, daily volatility of the stress regime and reduced ADV: :::cell{title="Liquidity: days and cost to unwind the portfolio"} ```python liq = pd.read_csv("riesgo4_liquidez.csv", index_col="activo") liq = liq.loc[pesos.index] vol_estres = verdad.loc[pesos.index, "vol_estres"] / np.sqrt(252) Q = pesos * capital part = 0.20 # 20% of the ADV is sold each day def coste_pb(horq, sigma, q_adv): """Half spread + square-root law (Y = 0.7).""" return 1e4 * (horq / 2e4 + liq["impacto_y"] * sigma * np.sqrt(q_adv)) adv_e = liq["adv_usd"] * liq["adv_estres_factor"] q_n = np.minimum(part, Q / liq["adv_usd"]) # less than a day: all of it q_e = np.minimum(part, Q / adv_e) t = pd.DataFrame({ "posicion_m": Q / 1e6, "dias": Q / (part * liq["adv_usd"]), "dias_estres": Q / (part * adv_e), "coste_pb": coste_pb(liq["horquilla_pb"], liq["vol_diaria"], q_n), "coste_pb_estres": coste_pb(liq["horquilla_pb_estres"], vol_estres, q_e), "un_dia_estres_pb": coste_pb(liq["horquilla_pb_estres"], vol_estres, Q / adv_e)}) total = {"normal": (t.coste_pb * Q).sum() / 1e6 / 1e4, "stress": (t.coste_pb_estres * Q).sum() / 1e6 / 1e4, "stress_one_day": (t.un_dia_estres_pb * Q).sum() / 1e6 / 1e4} print("Total cost (millions of USD):", {k: round(float(v), 2) for k, v in total.items()}) t.sort_values("dias_estres", ascending=False).head(7).round(1) ``` ```output Total cost (millions of USD): {'normal': 0.7, 'stress': 3.1, 'stress_one_day': 3.54} posicion_m dias dias_estres coste_pb coste_pb_estres un_dia_estres_pb INFRA 15.0 3.2 6.4 38.3 349.8 454.2 ALT_PRI 15.0 3.0 5.9 57.7 468.4 647.0 RF_T710 75.0 0.8 1.1 11.1 26.0 26.9 INM_EEUU 20.0 0.5 1.0 28.8 125.8 128.1 RF_CHY 35.0 0.5 0.7 12.6 55.6 55.6 MP_AMP 15.0 0.5 0.7 25.8 104.0 104.0 RF_T20 25.0 0.4 0.5 17.1 37.4 37.4 ``` ::: ::figure{vega="n4-m2-estres-liquidez"} The large assets are not the problem: 125 million in `RV_EEUU` are 1.7% of its ADV. The problem is the two small positions in low-volume ETFs. The 15 million in infrastructure (`INFRA`) take 3.2 days to sell in normal conditions and 6.4 under stress; those in listed private equity (`ALT_PRI`), 3.0 and 5.9. Their cost goes from 58 to 468 bp in `ALT_PRI` because the stress spread is 688 bp. For the whole fund, liquidating costs 0.70 million USD in normal conditions and 3.10 under stress; selling everything in one day of stress, 3.54. And while it is being sold over six days of stress, the price keeps moving: with a daily volatility of 4% in `ALT_PRI`, six days are a 10% standard deviation on what remains to be sold. A liquidity stress test adds up both things. ## Gaps: what a stop does when the price jumps A sell stop is an order that becomes a market order when the price touches a level. If the price jumps past the level without trading at it (an opening gap, an announcement outside trading hours, a day like that of 1987), the stop is executed at the first available price. With the 5-minute bars of the level 2 synthetic ETF `KAPA` (2022-2025, with the overnight gap in the first bar of each session), three stops resting in the market below the previous close: :::cell{title="Gaps: what a stop collects when the price jumps"} ```python kapa = pd.read_parquet("indice2_5min.parquet") kapa["dia"] = kapa["ts"].dt.tz_convert("America/New_York").dt.date dia = kapa.groupby("dia").agg(apertura=("apertura", "first"), minimo=("minimo", "min"), cierre=("cierre", "last")) dia["previo"] = dia["cierre"].shift(1) dia = dia.dropna() filas = {} for x in (0.005, 0.01, 0.015): # sell stop below the previous close stop = dia["previo"] * (1 - x) salta = dia["minimo"] <= stop en_gap = dia["apertura"] <= stop precio = np.where(en_gap, dia["apertura"], stop) extra = (stop - precio) / dia["previo"] * 1e4 filas[f"stop {x:.1%}"] = {"dias_que_salta": int(salta.sum()), "en_la_apertura": int(en_gap.sum()), "extra_medio_pb": extra[salta].mean(), "extra_max_pb": extra.max()} print(f"Worst opening gap: " f"{(dia.apertura / dia.previo - 1).min():.2%}") pd.DataFrame(filas).T.round(1) ``` ```output Worst opening gap: -1.88% dias_que_salta en_la_apertura extra_medio_pb extra_max_pb stop 0.5% 561.0 121.0 6.0 137.6 stop 1.0% 276.0 18.0 1.8 87.6 stop 1.5% 127.0 4.0 0.6 37.6 ``` ::: The 1% stop triggers 276 times; 18 of them at the open, because of a gap that goes through it, with 1.8 bp of extra cost on average and 87.6 bp in the worst case (a 1% stop that loses 1.88%). The closer the stop, the more often a gap jumps over it: the 0.5% stop gets gapped 121 times out of 561. In `KAPA` the worst gap is −1.88% because the index is quiet; in a stock the day after reporting earnings or on the hypothetical Black Monday (−19.8% in one day), the 1% stop would lose the whole gap. A stop limits the typical loss, not the maximum: the position size must withstand the plausible gap, which is what the one-day scenario measures (lesson 4.2.1 for risk per trade). ## Counterparty: who holds the money Counterparty risk is losing what someone else owes you if they go bankrupt: the cash deposited with a broker or an exchange, the margin posted if it is not in a segregated client account and the value in your favor of derivatives not covered by the collateral received. ```mermaid flowchart LR E[Cash] --> X[Exposure] M[Non-segregated margin] --> X D[MtM minus collateral] --> X X --> L{Over the limit?} ``` `riesgo4_contrapartes.csv` has eight relationships with six fictitious entities as of December 31, 2025. Exposure is computed by relationship and aggregated **by entity**, because bankruptcy happens to the entity, not to the account: :::cell{title="Counterparty exposure"} ```python cp = pd.read_csv("riesgo4_contrapartes.csv") cp["margen_en_riesgo"] = cp["margen_entregado"].where(~cp["segregado"], 0) cp["neto_derivados"] = (cp["mtm_derivados"] - cp["colateral_recibido"]).clip(lower=0) cp["exposicion"] = (cp["efectivo"] + cp["margen_en_riesgo"] + cp["neto_derivados"]) print("Same as the truth:", bool((cp["exposicion"] == cp["exposicion_verdad"]).all())) ent = cp.groupby("contraparte").agg( exposicion=("exposicion", "sum"), limite=("limite", "sum"), cds_pb=("cds_pb", "first"), tipo=("tipo", "first")) ent["uso_limite"] = ent["exposicion"] / ent["limite"] ent["pd_1a"] = ent["cds_pb"] / 1e4 / (1 - 0.40) # 40% recovery ent["perdida_esperada"] = ent["pd_1a"] * 0.60 * ent["exposicion"] cols = ["exposicion", "limite", "uso_limite", "pd_1a", "perdida_esperada"] ent.sort_values("exposicion", ascending=False)[cols].round(3) ``` ```output Same as the truth: True exposicion limite uso_limite pd_1a perdida_esperada contraparte Corredora Atlas 5120000 16000000 0.320 0.012 35840.0 Nordbank Capital 3400000 3000000 1.133 0.018 37400.0 Kestrel Exchange 3200000 1500000 2.133 NaN NaN Banco Meridiano 2200000 25000000 0.088 0.008 9900.0 Bolt Prime 1800000 4000000 0.450 0.035 37800.0 Cámara Central Europea 250000 50000000 0.005 0.003 500.0 ``` ::: The calculation reproduces the truth column. Two entities exceed their limit: Nordbank Capital, an OTC counterparty with 0.5 million in non-segregated margin and 2.9 million of uncollateralized value in our favor (3.4 million against a limit of 3), and Kestrel Exchange, a crypto exchange with no rating or CDS with 2.3 million in cash and 0.9 million in non-segregated margin (3.2 million against 1.5: more than double). Corredora Atlas appears twice (prime broker and CFD account): separately neither account stands out; together they are the largest exposure, 5.1 million, even though its prime brokerage margin (2.6 million) is segregated and does not count. With the CDS a one-year probability of default can be approximated, $PD \approx s/(1-R)$ with a recovery $R$ of 40%: Bolt Prime (210 bp) has 3.5% and the largest expected loss (37,800 USD) with only 1.8 million of exposure. For Kestrel there is no CDS: not having a market price for the risk does not make it small. Lesson 3.7.2 measured with synthetic data what is left of a position when the exchange holding it in custody goes bankrupt. ## Exercises 1. **A 60/40 portfolio and one with gold.** Compute the loss in the eight scenarios of a 60/40 (`RV_EEUU` / `RF_T710`) and of a variant with 10% gold (`MP_ORO`) taken out of the bonds (60/30/10). In which scenarios does gold help and in which does it not? ::::solucion :::cell{title="60/40 versus 60/30/10 with gold"} ```python c6040 = pd.Series({"RV_EEUU": 0.6, "RF_T710": 0.4}) c_oro = pd.Series({"RV_EEUU": 0.6, "RF_T710": 0.3, "MP_ORO": 0.1}) pd.DataFrame({ "60/40": 100 * choques.loc[c6040.index].T @ c6040, "60/30/10": 100 * choques.loc[c_oro.index].T @ c_oro}).round(2) ``` ```output 60/40 60/30/10 escenario crisis_2008 -23.37 -23.76 covid_2020 -18.42 -19.13 inflacion_2022 -21.33 -20.03 peor_dia -6.97 -7.11 lunes_negro_1987 -11.08 -11.13 tipos_mas_200pb -8.66 -7.82 dolar_mas_10 0.00 -0.64 crisis_credito -7.80 -7.98 ``` ::: Gold helps little and not everywhere. It improves the 2022 inflation (−21.33% to −20.03%) and the rate rise (−8.66% to −7.82%), the scenarios in which bonds fail, and slightly worsens 2008, covid and the worst day, in which Treasury bonds rise more than gold. In the dollar rally it subtracts: gold in USD falls 6.4%. Neither variant avoids the large loss: in 2008 the 60/40 loses 23.4%. Diversification between risk assets and bonds reduces the loss in the scenarios in which the correlation is negative; in the rest, only size reduces it. :::: 2. **Reverse stress test in a month of inflation.** Repeat the 20% reverse stress test with the covariance of the factors in the inflation regime. Which factors change role and what is the distance? ::::solucion :::cell{title="Reverse stress test with the inflation covariance"} ```python S_inf = cov_factores("inflacion") * 21 / 252 f_inf = -perdida * (S_inf @ b) / (b @ S_inf @ b) print(f"Distance: {perdida / np.sqrt(b @ S_inf @ b):.2f}" " standard deviations") pd.DataFrame({"estres_%": 100 * f_inv, "inflacion_%": 100 * f_inf}).round(1) ``` ```output Distance: 4.40 standard deviations estres_% inflacion_% mercado -32.6 -27.5 tamano -2.3 -2.2 valor -1.2 3.4 tipos 2.2 -6.5 credito -3.9 -2.8 materias -12.4 -1.6 dolar 3.5 3.4 emergentes -5.6 -5.5 europa -0.2 -0.4 japon -0.0 -0.0 ``` ::: In inflation **rates** change role: the reverse stress test requires bonds to fall 6.5% instead of rising 2.2%, because in that regime they fall together with equities, and the market falls less (−27.5%). Commodities, which rose in 2022, barely play a part. The distance is 4.40 standard deviations because the volatility of the inflation regime is lower than that of stress: losing 20% in a month of inflation is much less likely than in one of stress, but when it happens, bonds do not protect. :::: 3. **A counterparty policy.** With the counterparty table, propose three rules for the lesson's trading system and say what you would do today with Kestrel Exchange and with Nordbank Capital. :::solucion Three reasonable rules: (1) limits **by entity**, not by account, computed with the exposure that would be lost in a bankruptcy (cash, non-segregated margin and net value of derivatives) and reviewed every day; (2) lower limits for entities with no rating, no CDS or outside an asset segregation regime that has been verified in their terms and in the applicable regulation (lessons 3.7.5 and 4.4.1), and the cash not needed for margin kept outside the entity; (3) mandatory collateral on OTC derivatives above a small threshold, with daily valuation. Today: at Kestrel, withdraw cash until below the limit (1.7 million at least) and leave on the exchange no more than the margin of the open positions; at Nordbank, ask for collateral for the value in our favor (2.9 million) or reduce positions, and ask why the posted margin is not segregated. :::

Each answer is folded under its question

Self-assessment

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

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

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

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

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

Resources

Data