---
title: "4.2.4 Stress testing: correlation, liquidity, counterparty"
description: "Historical and hypothetical scenarios, reverse stress test, correlations that change in crises, liquidity, gaps and counterparties of a portfolio."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/stress-testing-correlation-liquidity-counterparty-risk/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/stress-testing-correlation-liquidity-counterparty-risk.png"
---

# 4.2.4 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- Module: 4.2 · Risk management and position sizing
- Duration: 75 min
- Prerequisites: [2.5.7 · Synthetic data for stress testing: GBM, GARCH and bootstrapping](https://pythonparatrading.com/en/python-trading-course/level-2/synthetic-data-for-stress-testing/), [4.2.2 · Kelly criterion and volatility targeting: how much to risk](https://pythonparatrading.com/en/python-trading-course/level-4/kelly-criterion-and-volatility-targeting/), [4.2.3 · VaR, Expected Shortfall and VaR backtesting](https://pythonparatrading.com/en/python-trading-course/level-4/var-expected-shortfall-and-var-backtesting/)

## By the end of this lesson

- Build historical (from prices) and hypothetical (from factor loadings) stress scenarios and apply them to a portfolio by asset and by class.
- Solve a reverse stress test: the most likely factor shock that produces a given loss.
- Measure how much correlations change by regime and separate the effect of volatilities from that of correlations on the risk of a portfolio.
- Compute the days and the cost of liquidating a portfolio in normal and crisis conditions with the square-root law.
- Quantify the gap risk of a stop and the exposure to counterparties with segregated margin, collateral and limits per entity.

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

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

```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}")
```
```output
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:

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

```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()})")
```
```output
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^\top w$ is the portfolio's exposure to each
factor (the loadings weighted by the weights), the shock that loses $L$ with the smallest distance is

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

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

```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)
```
```output
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.* A line that is almost always negative, between −0.63 and 0, more negative in the stress stretches (shaded grey), when bonds hedge equities. In 2022 (inflation stretch, highlighted) it rises to 0.65: equities and bonds fall together and the hedge disappears.

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:

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

$$\text{cost} \approx \frac{\text{spread}}{2} + Y\,\sigma_{\text{daily}}\sqrt{\frac{q}{\text{ADV}}},
\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:

```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
```

*Fig. — Days needed to sell each position of a 500 million USD fund at 20% of average daily volume, in normal conditions and with crisis volume.* Bars per asset, from most to fewest stress days (normal / stress): INFRA 3.2 / 6.4; ALT_PRI 3.0 / 5.9; RF_T710 0.8 / 1.1; INM_EEUU 0.5 / 1.0; RF_CHY 0.5 / 0.7; MP_AMP 0.5 / 0.7; RF_T20 0.4 / 0.5. The two small positions in illiquid ETFs, infrastructure and listed private equity (highlighted), take six days in stress; the US equity position, five times larger, less than a day.

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:

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

```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?

**Solution.**

```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?

**Solution.**

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

**Solution.**

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.

## 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?

   - A. Because in 2022 the fund held more equities
   - 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
   - C. Because of commodities
   - D. Because the 2022 scenario is longer and accumulates costs

   *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?

   - A. A scenario in which the market goes up
   - B. Replaying a historical scenario backwards
   - 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
   - D. A scenario without correlations

   *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?

   - A. That correlations never matter
   - B. That the model is wrong
   - C. That the portfolio is better diversified under stress
   - 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

   *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?

   - 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
   - B. The stop price
   - C. The day's close
   - D. It is not executed

   *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?

   - 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
   - B. Because margin always counts
   - C. Because it is a crypto exchange
   - D. Because it has no rating

   *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

### Research

- [Limit Order Book - Aggregated Market Liquidity](https://pythonparatrading.com/en/limit-order-book-and-market-liquidity/) · Research-0008 · 11 min

### Documentation

- [BCBS (2018): Stress testing principles](https://www.bis.org/bcbs/publ/d450.htm) · bis.org
- [Federal Reserve History: Stock Market Crash of 1987](https://www.federalreservehistory.org/essays/stock-market-crash-of-1987) · federalreservehistory.org

### Data

- [Level 4 data: stress scenarios on 38 assets (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/riesgo4_escenarios.csv) · Download · riesgo4_escenarios.csv
- [Level 4 data: liquidity of the 38 assets (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/riesgo4_liquidez.csv) · Download · riesgo4_liquidez.csv
- [Level 4 data: counterparty exposure (CSV)](https://pythonparatrading.com/curso/datos/nivel-4/riesgo4_contrapartes.csv) · Download · riesgo4_contrapartes.csv

Previous lesson: [4.2.3 · VaR, Expected Shortfall and VaR backtesting](https://pythonparatrading.com/en/python-trading-course/level-4/var-expected-shortfall-and-var-backtesting/)

Next lesson: [4.2.5 · Drawdown control and kill switch](https://pythonparatrading.com/en/python-trading-course/level-4/drawdown-control-and-kill-switch/)
