Chapter 18 — Market Risk

"Risk management is the art of knowing what you don't know and pricing it."


After this chapter you will be able to:

  • Explain the three main approaches to VaR (parametric, historical simulation, Monte Carlo) and the tradeoffs between them
  • Distinguish VaR from Expected Shortfall and explain why regulators replaced VaR with ES in the Fundamental Review of the Trading Book
  • Implement historical simulation VaR and Expected Shortfall from a time series of returns
  • Build a factor risk model to decompose portfolio variance into systematic and idiosyncratic components
  • Conduct stress testing and backtesting, including the Basel Traffic Light test for VaR model validity

In October 1994, J.P. Morgan published a document called RiskMetrics, offering the financial industry a standardised methodology for measuring market risk. At its centre was a single number: the Value at Risk, defined as the loss that would not be exceeded with 99% probability over a given horizon. The idea was elegant in its simplicity. A desk could reduce its entire risk exposure to one number, and traders could be given limits expressed in that number. By 1998, VaR had been embedded in the Basel II Accord, and banks worldwide were required to hold capital proportional to their calculated VaR. The era of quantitative risk management had arrived.

The 2008 financial crisis was, in part, a story about the limitations of VaR. The models assumed that returns were approximately normally distributed and that correlations were stable. Neither assumption held. The losses experienced by major banks exceeded their 99% 10-day VaR on far more than 1% of days — some institutions reported 25-standard-deviation events occurring on multiple consecutive days, which under the model assumptions were essentially impossible over the lifetime of the universe. The fundamental problem is that VaR tells you nothing about what happens in the 1% tail: a 99% VaR of \$100M says only that losses exceed \$100M on 1% of days, not whether they average \$105M or \$500M in those extreme cases.

This chapter implements the full market risk toolkit: historical simulation VaR, parametric VaR, Expected Shortfall (the coherent alternative that measures the average tail loss), factor risk models, and stress testing. We implement backtesting, which checks whether a VaR model is well-calibrated against realised losses.


18.1 Value at Risk — Three Methods

Value at Risk (VaR) at confidence level $\alpha$ and horizon $h$ is the loss $L$ such that:

$$P(L > \text{VaR}_\alpha) = 1 - \alpha$$

For example, a 1-day 99% VaR of \$10M means there is a 1% chance the portfolio loses more than \$10M on any given day. Three main estimation approaches exist, each with distinct tradeoffs:

1. Parametric (Normal) VaR assumes returns are normally distributed. Given daily portfolio mean $\mu$ and standard deviation $\sigma$, the 1-day VaR at confidence $\alpha$ is $z_\alpha \sigma - \mu$ where $z_\alpha$ is the standard normal quantile. This is fast (requires only two parameters) and gives a closed-form expression, but it badly underestimates tail risk when returns are fat-tailed or skewed — as they typically are for options portfolios and during crises. The Cornish-Fisher expansion (see Exercise 18.2) partially corrects for skewness and excess kurtosis.

2. Historical Simulation (HS) VaR uses the actual empirical distribution of returns over a historical window (typically 250–500 days). The 1-day VaR is simply the return at the appropriate quantile of the historical distribution — no distributional assumption required. This automatically captures fat tails, skewness, and non-linear risk from options. The weakness is mean-reversion in the historical window: a 1-year window has only 252 observations, giving an imprecise estimate of the 99th percentile, and the window may not include the particular type of crisis that occurs next. Filtered Historical Simulation (FHS) improves this by scaling historical returns by the ratio of current to historical GARCH volatility, allowing older observations to contribute at the appropriate current risk level.

3. Monte Carlo VaR simulates millions of future scenarios using a risk factor model, reprices the entire portfolio under each scenario, and computes the quantile of the loss distribution. It is the most flexible approach — it can capture any distributional assumption and handles complex non-linear portfolios including options and structured products. The cost is computational: a full Monte Carlo for a large bank requires repricing thousands of instruments across millions of risk factor paths.

Expected Shortfall (ES) at confidence $\alpha$ is the average loss conditional on exceeding VaR:

$$\text{ES}\alpha = E[L \mid L > \text{VaR}\alpha]$$

P&L Distribution with VaR Tail Figure 18.1 — A fat-tailed daily P&L distribution. The 99% VaR marks the threshold (dashed red line) where the worst 1% of outcomes begin, but Expected Shortfall averages the entire shaded red tail.

ES is strictly more informative than VaR: it measures not just the threshold but the magnitude of losses in the tail. A portfolio with 99% VaR = \$100M but 99% ES = \$300M has a much more dangerous tail than one with the same VaR but ES = \$110M.

Why ES replaced VaR in regulation: VaR is not sub-additive. It is mathematically possible for the VaR of a combined portfolio to exceed the sum of the VaRs of its constituent parts — which contradicts diversification intuition and creates perverse incentives in risk decomposition. Artzner, Delbaen, Eber, and Heath (1999) defined the properties of a coherent risk measure: monotonicity, sub-additivity, positive homogeneity, and cash invariance. VaR fails sub-additivity; ES satisfies all four. The Basel Committee's Fundamental Review of the Trading Book (FRTB, finalised 2016, implemented 2025) replaced 99% VaR with 97.5% ES — the confidence level was chosen so that in a normal distribution the two measures are approximately equivalent in magnitude, but ES provides the correct incentives.

Expected Shortfall vs VaR Figure 18.2 — Value at Risk vs Expected Shortfall. While VaR establishes a binary threshold, ES provides the conditional expectation of losses residing specifically within that tail.

module Var = struct

  (** Historical simulation VaR: sort losses and take quantile *)
  let historical ~returns ~confidence =
    let n      = Array.length returns in
    let losses = Array.map (fun r -> -. r) returns in
    Array.sort compare losses;
    let idx    = int_of_float (float_of_int n *. (1.0 -. confidence)) in
    losses.(n - 1 - idx)

  (** Parametric (normal) VaR *)
  let parametric_normal ~mean ~std_dev ~confidence ~notional =
    let z    = Numerics.norm_ppf confidence in
    notional *. (z *. std_dev -. mean)

  (** Expected Shortfall (CVaR): expected loss beyond VaR *)
  let expected_shortfall ~returns ~confidence =
    let n      = Array.length returns in
    let losses = Array.map (fun r -> -. r) returns in
    Array.sort compare losses;
    let idx    = int_of_float (float_of_int n *. (1.0 -. confidence)) in
    let tail   = Array.sub losses (n - idx) idx in
    Array.fold_left (+.) 0.0 tail /. float_of_int idx

  (** Multi-step VaR scaling: σ_T = σ_1 * sqrt(T) under iid assumption *)
  let scale_var ~var_1d ~horizon = var_1d *. sqrt (float_of_int horizon)

  (** Filtered Historical Simulation: rescale returns by GARCH volatility *)
  let filtered_hs ~returns ~garch_params ~confidence =
    let sigma2 = Garch.filter garch_params returns in
    let n      = Array.length returns in
    let standardised = Array.init n (fun i ->
      returns.(i) /. sqrt sigma2.(i)
    ) in
    (* Scale by current (predicted) volatility *)
    let sigma_today = sqrt sigma2.(n - 1) in
    let var_std = historical ~returns:standardised ~confidence in
    var_std *. sigma_today

  type risk_report = {
    var_95  : float;
    var_99  : float;
    es_95   : float;
    es_99   : float;
    max_drawdown : float;
  }

  let max_drawdown returns =
    let n    = Array.length returns in
    let cum  = Array.make n 1.0 in
    for i = 1 to n - 1 do
      cum.(i) <- cum.(i-1) *. (1.0 +. returns.(i))
    done;
    let peak = ref cum.(0) and mdd = ref 0.0 in
    Array.iter (fun v ->
      if v > !peak then peak := v;
      mdd := Float.max !mdd ((!peak -. v) /. !peak)
    ) cum;
    !mdd

end

The max_drawdown function computes the maximum peak-to-trough decline as a fraction of the peak value — this is an important complementary risk measure that captures path-dependent risk not reflected in VaR or ES. A portfolio can have low daily VaR (each day is calm) but high maximum drawdown if the losses are serially correlated and prolonged.


18.2 Factor Risk Models

A factor model decomposes asset returns into systematic (factor-driven) and idiosyncratic (stock-specific) components:

$$r_i = \alpha_i + \sum_{k=1}^K \beta_{ik} f_k + \epsilon_i$$

where $f_k$ are common risk factors (e.g., market return, value, momentum, sector) and $\epsilon_i$ is idiosyncratic noise with $\text{Cov}(\epsilon_i, \epsilon_j) = 0$ for $i \neq j$. The portfolio variance then decomposes as:

$$\sigma_P^2 = \mathbf{w}^T (B \Sigma_F B^T + D) \mathbf{w}$$

where $B$ is the $n \times K$ matrix of factor loadings (betas), $\Sigma_F$ is the $K \times K$ factor covariance matrix, and $D = \text{diag}(\sigma^2_{\epsilon_1}, \ldots, \sigma^2_{\epsilon_n})$ is the diagonal matrix of idiosyncratic variances. This decomposition is computationally powerful: for a 500-stock portfolio and 5 factors, the stored covariance matrix shrinks from $500 \times 500 = 250{,}000$ entries to $5 \times 5 + 500 = 525$ entries, enabling real-time risk calculations across large portfolios.

Factor models also enable risk attribution: the systematic variance component $(B\Sigma_F B^T)$ tells us how much portfolio risk comes from exposure to common factors, while the idiosyncratic component $D$ captures stock-specific risk. A well-diversified equity portfolio typically has 60–80% of its variance explained by a handful of factors (market, value, momentum, quality), with the remainder in idiosyncratic risk that averages away.

The marginal contribution to risk (MCR) of asset $i$ quantifies how much the portfolio's total volatility changes if we increase weight $w_i$ by a small amount. This is the key input to a risk-parity portfolio construction (Chapter 21).

module Factor_model = struct

  type t = {
    betas       : float array array;   (* n_assets × n_factors *)
    factor_cov  : float array array;   (* n_factors × n_factors *)
    idiosync_var: float array;         (* idiosyncratic variance per asset *)
  }

  (** Portfolio variance decomposition *)
  let portfolio_variance model weights =
    let n = Array.length weights in
    let k = Array.length model.factor_cov in
    (* Factor exposures: e = B^T w *)
    let exposures = Array.init k (fun j ->
      Array.fold_left (fun acc i ->
        acc +. weights.(i) *. model.betas.(i).(j)
      ) 0.0 (Array.init n Fun.id)
    ) in
    (* Systematic variance: e^T Σ_F e *)
    let sys_var = ref 0.0 in
    for j1 = 0 to k - 1 do
      for j2 = 0 to k - 1 do
        sys_var := !sys_var +. exposures.(j1) *. model.factor_cov.(j1).(j2) *. exposures.(j2)
      done
    done;
    (* Idiosyncratic variance: w^T D w *)
    let idio_var = Array.fold_left (fun acc i ->
      acc +. weights.(i) *. weights.(i) *. model.idiosync_var.(i)
    ) 0.0 (Array.init n Fun.id) in
    !sys_var +. idio_var

  (** Marginal contribution to risk of each asset *)
  let marginal_risk model weights =
    let port_vol = sqrt (portfolio_variance model weights) in
    let n = Array.length weights in
    let k = Array.length model.factor_cov in
    let exposures = Array.init k (fun j ->
      Array.fold_left (fun acc i -> acc +. weights.(i) *. model.betas.(i).(j))
        0.0 (Array.init n Fun.id)
    ) in
    Array.init n (fun i ->
      let factor_cov_e =
        Array.fold_left (fun acc j ->
          acc +. model.betas.(i).(j)
                 *. (Array.fold_left (fun a m -> a +. model.factor_cov.(j).(m) *. exposures.(m))
                      0.0 (Array.init k Fun.id))
        ) 0.0 (Array.init k Fun.id)
      in
      let idio = weights.(i) *. model.idiosync_var.(i) in
      (factor_cov_e +. idio) /. port_vol
    )

end

The marginal_risk function returns the marginal contribution to volatility — the derivative $\partial \sigma_P / \partial w_i$ — for each asset. The sum $\sum_i w_i \cdot \text{MCR}_i = \sigma_P$ (Euler's theorem for homogeneous functions), which provides a natural decomposition of portfolio volatility into per-asset contributions.


18.3 Stress Testing and Scenario Analysis

Statistical VaR measures assume that future returns come from the same distribution as historical returns. Stress testing takes a fundamentally different approach: it asks "what would happen if a specific bad scenario occurred?" The scenario does not need to be statistically likely — it just needs to represent a plausible severe event that the portfolio may not survive.

The four historical episodes most commonly used in stress testing are:

  • 1987 Black Monday (October 19, 1987): S&P 500 fell 22.6% in a single day, unprecedented in modern history. The crash was amplified by portfolio insurance strategies (dynamic delta hedging) that created a feedback loop: as prices fell, the strategies sold more futures, driving prices further down. This scenario tests concentrated equity long positions and structured products with embedded delta hedging.

  • 1998 LTCM / Russia crisis: Russia defaulted on domestic debt and devalued the ruble in August 1998. This caused a global "flight to quality," widening credit spreads dramatically and collapsing the correlation structure on which LTCM's convergence trades depended. LTCM lost \$4.6 billion in 6 weeks, threatening systemic collapse and requiring a Federal Reserve-coordinated private bailout. This scenario tests relative value and credit spread strategies.

  • 2008 Lehman / Global Financial Crisis: S&P 500 fell ~40% in six months, equity correlations rose sharply toward 1.0 (diversification disappeared), credit spreads widened by hundreds of basis points on investment-grade names and thousands on high yield, interbank funding markets froze (LIBOR-OIS spread reached 365bp), and short-term rates were cut to near zero. This is the canonical stress scenario for any institution with credit, funding, or equity exposure.

  • 2020 COVID crash: S&P 500 fell 34% in 33 calendar days (the fastest 30%+ decline in history), VIX reached 82, oil entered negative territory for the first time ever (WTI crude: -$37/barrel), and rates were cut to zero and massive QE begun. This scenario is notable for the speed of the drawdown, the extreme commodity behaviour, and the subsequent rapid recovery — the full recovery took only 148 days.

The stress testing module maintains a library of these scenarios as shocks to risk factors. In a real system, applying a scenario means repricing every position with the perturbed market data and aggregating the P&L. The scenarios below are defined as factor shocks; the apply_scenario function is a stub to be filled with the portfolio system's repricing engine.

module Stress_test = struct

  type scenario = {
    name    : string;
    shocks  : (string * float) list;  (* (factor_name, shock_magnitude) *)
  }

  let known_scenarios = [
    { name = "2008 Crisis";
      shocks = [("equities", -0.40); ("credit_spread", 0.03); ("vix", 0.60);
                ("rates_10y", -0.015); ("usd_index", 0.08)] };
    { name = "2020 COVID (peak drawdown)";
      shocks = [("equities", -0.34); ("rates_10y", -0.01); ("vix", 0.55);
                ("oil", -0.65); ("investment_grade_spread", 0.018)] };
    { name = "1987 Black Monday";
      shocks = [("equities", -0.226); ("vix", 1.50); ("equity_corr", 0.40)] };
    { name = "1998 Russia/LTCM";
      shocks = [("hy_spread", 0.06); ("ig_spread", 0.015);
                ("equities", -0.15); ("em_debt", -0.25)] };
    { name = "EUR taper tantrum 2013";
      shocks = [("rates_10y", 0.02); ("equities", -0.05);
                ("em_equities", -0.12); ("em_currencies", -0.08)] };
  ]

  let apply_scenario portfolio _market scenario =
    Printf.printf "Scenario: %s\n" scenario.name;
    List.iter (fun (factor, shock) ->
      Printf.printf "  %s: %+.1f%%\n" factor (shock *. 100.0)
    ) scenario.shocks;
    (* In a real system: reprice all positions with perturbed market data *)
    ignore portfolio;
    0.0   (* placeholder P&L *)

end

18.4 Backtesting and the Basel Traffic Light

Backtesting asks: does the model's predicted VaR actually contain losses at the stated confidence level? If a 99% 1-day VaR model is correctly calibrated, we expect losses to exceed VaR on approximately 1% of days — that is, approximately 2–3 days per year for a daily model.

VaR Backtesting Traffic Light Figure 18.3 — A 250-day VaR backtest showing profit and loss against the 99% VaR limit. Exceedances (red dots) are counted to determine the model's "Traffic Light" zone.

Kupiec's Proportion of Failures (POF) test formalises this. Under $H_0: p = 1 - \alpha$, the number of exceedances $x$ out of $T$ days follows a Binomial$(T, 1-\alpha)$ distribution. The likelihood ratio statistic:

$$\text{LR}_{\text{POF}} = -2\ln\left[\frac{(1-\alpha)^{T-x}\alpha^x}{(1-x/T)^{T-x}(x/T)^x}\right] \sim \chi^2(1)$$

rejects $H_0$ at 5% significance when $\text{LR} > 3.84$.

The Basel Traffic Light System classifies VaR models into three zones based on the number of annual exceedances (out of 250 business days):

ExceedancesZoneCapital multiplierInterpretation
0–4Green3.0 (baseline)Model passes; no penalty
5–9Yellow3.0 + incrementalModel under scrutiny; penalty may apply
10+Red4.0Model fails; must be revised

The yellow zone penalty increments from 0.4 (5 exceptions) to 0.85 (9 exceptions), bringing the effective multiplier from 3.4 to 3.85. A bank in the red zone faces a 33% increase in Market Risk capital requirements, creating strong incentives for model accuracy. Note that a model can also over-estimate risk (too few exceptions, well below the 1% rate), which results in unnecessarily high capital requirements — the ideal is calibration to exactly 1% exceedances.

module Backtest = struct

  (** Count VaR exceedances over a test period *)
  let count_exceedances ~var_series ~pnl_series =
    assert (Array.length var_series = Array.length pnl_series);
    let n = Array.length var_series in
    let exc = ref 0 in
    for i = 0 to n - 1 do
      (* VaR is positive, losses are positive when PnL < -VaR *)
      if -. pnl_series.(i) > var_series.(i) then incr exc
    done;
    !exc

  (** Kupiec POF test statistic *)
  let kupiec_pof ~exceedances ~n_days ~confidence =
    let p_hat = float_of_int exceedances /. float_of_int n_days in
    let p     = 1.0 -. confidence in
    let x     = float_of_int exceedances in
    let nt    = float_of_int n_days in
    let ll_null = (nt -. x) *. log (1.0 -. p) +. x *. log p in
    let ll_alt  = (nt -. x) *. log (1.0 -. p_hat) +. x *. log p_hat in
    let lr = -2.0 *. (ll_null -. ll_alt) in
    lr  (* compare against chi2(1) critical value 3.84 for 5% significance *)

  (** Basel traffic light zone *)
  let traffic_light ~exceedances =
    if exceedances <= 4 then ("Green", 3.0)
    else if exceedances <= 9 then
      let penalty = 0.4 +. 0.09 *. float_of_int (exceedances - 5) in
      ("Yellow", 3.0 +. penalty)
    else ("Red", 4.0)

  let print_report ~exceedances ~n_days ~confidence =
    let lr    = kupiec_pof ~exceedances ~n_days ~confidence in
    let zone, mult = traffic_light ~exceedances in
    Printf.printf "Backtesting Report (%d days, %.0f%% VaR)\n" n_days (confidence *. 100.0);
    Printf.printf "  Exceedances: %d (expected: %.1f)\n"
      exceedances (float_of_int n_days *. (1.0 -. confidence));
    Printf.printf "  Kupiec LR: %.2f (%s)\n" lr
      (if lr > 3.84 then "REJECT H0" else "fail to reject");
    Printf.printf "  Basel zone: %s, capital multiplier: %.2f\n" zone mult

end

The traffic_light function returns both the zone label and the capital multiplier. A 99% VaR model on 250 days with only 3 exceptions (1.2%) is technically in the green zone despite having fewer exceedances than expected — regulators accept this because too-few exceptions mean over-conservatism, not under-estimation.


18.5 The Fundamental Review of the Trading Book (FRTB)

The Fundamental Review of the Trading Book (Basel IV, BCBS 352, finalised 2019, implemented in major jurisdictions 2025) is the most significant overhaul of market risk capital since Basel II. Its key changes:

1. VaR → Expected Shortfall at 97.5%: The confidence level shift from 99% VaR to 97.5% ES was chosen to give approximately equivalent capital levels for normal return distributions, but ES correctly measures tail losses and is sub-additive.

2. Liquidity Horizons by Risk Factor Class: Different risk factors have different market liquidity and therefore different horizons over which positions cannot be hedged. FRTB assigns a liquidity horizon to each risk factor class:

Risk Factor ClassLiquidity Horizon
Large-cap equity / IG credit10 days
Small-cap equity / FX20 days
High-yield credit / EM equity40 days
EM sovereign debt60 days
Structured credit (RMBS, CLO)120 days

The 10-day ES for a structured credit position is therefore scaled by $\sqrt{120/10} = 3.46\times$ relative to a large-cap equity position with the same statistical risk.

3. Trading Desk Approval: Under FRTB, capital approval is granted at the trading desk level rather than the bank level. Each desk must pass quantitative tests (P&L attribution, backtesting) quarterly or lose the right to use the Internal Models Approach (IMA) and revert to the more punitive Standardised Approach (SA).

4. Non-Modellable Risk Factors (NMRFs): Risk factors must pass a "modellability" test based on market data availability. For illiquid or structured products where the bank has insufficient price observations, the risk factor cannot be included in the IMA model and must be capitalised using a stress scenario approach — typically much more punitive than the modelled approach.

The practical implication is that FRTB significantly increased the cost of holding complex or illiquid structured products in the trading book, accelerating the shift toward simpler, more liquid instruments and central clearing.



18.7 Persistent Snapshots for Zero-Copy Scenario Analysis

VaR and stress testing both require pricing the same portfolio under many different market states: historical scenarios (for historical simulation VaR), parametric bumps (for delta-gamma VaR), or named stress tests (equity down 30%, vol spike, rate shift). In a mutable-data architecture, each scenario requires either a deep copy of the entire market state or careful undo-redo logic after pricing. Both approaches are error-prone and expensive.

OCaml's persistent (immutable) maps from Core.Map provide a third path: each scenario is a persistent update of the base snapshot, sharing all unchanged data via structural sharing (§2.14). The base snapshot is never modified; branching from it costs $O(\log n)$ new allocations regardless of the size of the market data store:

open Core

(** Market snapshot: completely immutable — all maps are persistent *)
type market_snapshot = {
  equity_spots  : float String.Map.t;
  equity_vols   : float String.Map.t;   (* ATM vol by ticker *)
  ir_rates      : float String.Map.t;   (* OIS rate by currency *)
  credit_spreads: float String.Map.t;   (* CDS spread bps by issuer *)
  date          : string;
}

(** Smart constructors: each produces a fresh snapshot sharing base structure *)
let with_equity_spot base ticker new_spot =
  { base with equity_spots = Map.set base.equity_spots ~key:ticker ~data:new_spot }

let with_vol_bump base ticker delta_vol =
  { base with equity_vols =
      Map.update base.equity_vols ticker ~f:(function
        | None   -> delta_vol
        | Some v -> v +. delta_vol) }

let with_parallel_rate_shift base ccy shift =
  { base with ir_rates =
      Map.update base.ir_rates ccy ~f:(function
        | None   -> shift
        | Some r -> r +. shift) }

(** Generate the standard regulatory stress scenario set in one line each *)
let historical_simulation_scenarios base date_range historical_db =
  List.map date_range ~f:(fun d ->
    let spots = Historical_db.equity_spots historical_db d in
    let vols  = Historical_db.equity_vols  historical_db d in
    { base with
      equity_spots = spots;
      equity_vols  = vols;
      date         = d })

(** Standard parametric scenarios: none copy the original data — they share it *)
let generate_stress_scenarios base =
  let name_snapshot name s = (name, s) in
  [
    name_snapshot "base"             base;
    name_snapshot "equity_crash_20"  (with_equity_spot base "SPX" (Map.find_exn base.equity_spots "SPX" *. 0.80));
    name_snapshot "vol_spike"        (with_vol_bump base "SPX" 0.20);
    name_snapshot "rates_up_100bp"   (with_parallel_rate_shift base "USD" 0.01);
    name_snapshot "rates_down_100bp" (with_parallel_rate_shift base "USD" (-0.01));
    name_snapshot "credit_widen"     { base with credit_spreads =
      Map.map base.credit_spreads ~f:(fun s -> s *. 2.0) };
  ]

(** Price the full portfolio under each scenario — safe for parallel execution *)
let var_stress_test portfolio base_snapshot =
  let scenarios = generate_stress_scenarios base_snapshot in
  (* Scenarios are independently immutable: safe to price in parallel *)
  List.map scenarios ~f:(fun (name, snap) ->
    let pv = Portfolio.price portfolio snap in
    (name, pv)
  )
  |> List.sort ~compare:(fun (_, a) (_, b) -> Float.compare a b)

The generate_stress_scenarios function produces six named snapshots from base. Each snapshot modifies at most one or two map entries, creating $O(\log n)$ new tree nodes per scenario. For a market data store with 10,000 entries (equity spots, vols, rates, spreads), each scenario allocation is approximately 13–14 new tree nodes — not 10,000 copies. The six scenarios together allocate roughly 80–90 new nodes, regardless of portfolio size.

Because each scenario is a distinct, immutable value, they can be priced in parallel on OCaml 5 domains with no locking, no coordination, and no risk of one scenario's pricing code accidentally mutating another scenario's market data. This is architecturally impossible in a mutable-snapshot design without explicit copy-on-write infrastructure.


18.8 Chapter Summary

Market risk measurement has evolved substantially since RiskMetrics in 1994, driven by repeated episodes of model failure that exposed gaps between the theoretical framework and the behaviour of real markets.

Value at Risk remains widely used but has known limitations. It is backward-looking (only as good as the historical data used), assumes stable distributions, and critically, is not sub-additive. Expected Shortfall addresses the sub-additivity problem and provides a more complete picture of tail risk by measuring the average loss beyond the VaR threshold. The FRTB regulatory framework has mandated the shift from VaR to ES at 97.5% confidence for regulatory capital purposes, along with a liquidity-horizon adjustment that scales risk by the time needed to hedge each position in stressed markets.

Historical simulation VaR is model-free and captures non-Gaussian features automatically, but is limited to the length and representativeness of the historical window. Filtered Historical Simulation improves recency-weighting by scaling historical returns to reflect current conditional volatility. Parametric VaR is fast but wrong in the tails; the Cornish-Fisher expansion provides a first-order correction for skewness and fat tails.

Factor risk models enable efficient computation and meaningful attribution. Decomposing portfolio variance into systematic (factor-driven) and idiosyncratic (stock-specific) components allows risk managers to identify which common exposures dominate a portfolio's risk budget and to target hedges accordingly.

Backtesting provides the empirical discipline to market risk models. Kupiec's POF test and the Basel Traffic Light System create a formal framework for assessing whether a VaR model is well-calibrated, with financial consequences (capital add-ons) for models that fail. Stress testing complements statistical measures by asking qualitative "what-if" questions about specific adverse scenarios that may not appear in the historical window but represent genuine vulnerabilities.

The persistent snapshot architecture (§18.7) demonstrates how OCaml's immutable data structures enable scenarios to be branched from a base market state at $O(\log n)$ cost per scenario, with structural sharing for unchanged data and zero locking overhead for parallel pricing.


Exercises

18.1 [Basic] Generate 252 daily returns from a GBM ($\sigma=20%$) and compute 1-day 99% VaR via historical simulation and parametric normal. Compare both to the true value $z_{0.99} \sigma / \sqrt{252}$. Which estimate is closer? Run 100 simulations and plot the distribution of both estimators.

18.2 [Intermediate] Implement the Cornish-Fisher expansion for skewed/fat-tailed VaR: $z_{\text{CF}} = z + (z^2-1)\gamma_1/6 + (z^3 - 3z)\gamma_2/24 - (2z^3-5z)\gamma_1^2/36$ where $\gamma_1$ = skewness, $\gamma_2$ = excess kurtosis. Apply it to a simulated portfolio of short options (which exhibit negative skewness and positive kurtosis) and compare to plain parametric VaR.

18.3 [Intermediate] Build a 2-factor risk model for a 10-stock portfolio using OLS regressions on market and sector factor returns. Decompose each stock's variance into systematic and idiosyncratic. Compute the portfolio's marginal risk contributions and interpret which stocks dominate portfolio risk.

18.4 [Intermediate] Backtest 99% 1-day VaR on 5 years of daily returns. Count annual exceedances and run the Kupiec POF test. Apply the Basel Traffic Light and report the capital multiplier. What is the 95% confidence interval on the annual exceedance count under the null hypothesis of a correctly calibrated model?

18.5 [Advanced] Implement Filtered Historical Simulation: estimate a GARCH(1,1) model on the return series, standardise historical returns by their conditional GARCH volatility, compute the historical VaR on standardised returns, and scale by today's conditional GARCH vol. Compare one-day-ahead VaR forecasts (HS vs FHS) over a 2-year backtesting period.

18.6 [Advanced] Using the persistent snapshot pattern from §18.7, extend generate_stress_scenarios to include 5-year historical scenarios by iterating over a map of daily historical market data. Measure: (a) memory allocation per scenario (using Gc.stat); (b) total pricing time for 500 historical scenarios on a 50-instrument portfolio. Compare memory and runtime to a deep-copy-based baseline.


Next: Chapter 19 — Greeks and Hedging