Skip to main content

rustyqlib/risk/
portfolio_risk.rs

1//! VaR and Expected Shortfall for an options book
2//! ([`EquityPortfolio`]), by scenario simulation over the underlying
3//! and its implied volatility:
4//!
5//! - **Delta-gamma(-vega-theta)**: P&L approximated from the book's
6//!   aggregated Greeks — fast, and exactly the Taylor expansion the
7//!   portfolio's PnL attribution uses;
8//! - **Full revaluation**: every scenario reprices every position
9//!   through [`EquityPortfolio::price_with`] — exact payoff convexity,
10//!   at pricing cost.
11//!
12//! Scenarios are joint lognormal-spot / normal-vol moves with a
13//! spot-vol correlation (negative in equities), deterministic per seed.
14//! Both estimators share scenarios, so their difference is purely the
15//! Taylor truncation — a direct read on how non-linear the book is.
16
17use crate::core::montecarlo::path_rng;
18use crate::equity::portfolio::EquityPortfolio;
19use rand::Rng;
20use rand_distr::StandardNormal;
21
22use super::measures::{historical_expected_shortfall, historical_var};
23
24/// Scenario-generation settings for portfolio VaR.
25#[derive(Debug, Clone, Copy)]
26pub struct RiskConfig {
27    /// Horizon in years (1 trading day = 1/252).
28    pub horizon: f64,
29    /// Annualized volatility of the underlying's return.
30    pub spot_vol: f64,
31    /// Annualized volatility of the implied-vol move (absolute, e.g.
32    /// 0.8 means a 1-day vol move of ~0.8/sqrt(252) ~ 5 vol points).
33    pub vol_of_vol: f64,
34    /// Spot-vol move correlation (negative in equity markets).
35    pub spot_vol_corr: f64,
36    pub scenarios: usize,
37    pub confidence: f64,
38    pub seed: u64,
39}
40
41impl Default for RiskConfig {
42    fn default() -> Self {
43        RiskConfig {
44            horizon: 1.0 / 252.0,
45            spot_vol: 0.2,
46            vol_of_vol: 0.0,
47            spot_vol_corr: -0.5,
48            scenarios: 20_000,
49            confidence: 0.99,
50            seed: 42,
51        }
52    }
53}
54
55/// VaR / ES output with the scenario P&L retained for inspection.
56#[derive(Debug, Clone)]
57pub struct PortfolioRisk {
58    pub var: f64,
59    pub expected_shortfall: f64,
60    pub mean_pnl: f64,
61    pub scenarios: usize,
62}
63
64fn scenario_moves(cfg: &RiskConfig, spot: f64, i: u64) -> (f64, f64) {
65    let mut rng = path_rng(cfg.seed, i);
66    let z1: f64 = rng.sample(StandardNormal);
67    let z2: f64 = rng.sample(StandardNormal);
68    let zv = cfg.spot_vol_corr * z1
69        + (1.0 - cfg.spot_vol_corr * cfg.spot_vol_corr).sqrt() * z2;
70    let sq = cfg.horizon.sqrt();
71    // lognormal spot move, arithmetic vol move
72    let d_spot = spot * ((-0.5 * cfg.spot_vol * cfg.spot_vol * cfg.horizon
73        + cfg.spot_vol * sq * z1)
74        .exp()
75        - 1.0);
76    let d_vol = cfg.vol_of_vol * sq * zv;
77    (d_spot, d_vol)
78}
79
80/// Delta-gamma-vega-theta VaR: scenario P&L from the book's aggregated
81/// Greeks (one Greeks computation, then arithmetic per scenario).
82pub fn delta_gamma_var(book: &EquityPortfolio, spot: f64, cfg: &RiskConfig) -> PortfolioRisk {
83    let g = book.greeks();
84    let pnl: Vec<f64> = (0..cfg.scenarios as u64)
85        .map(|i| {
86            let (ds, dv) = scenario_moves(cfg, spot, i);
87            g.delta * ds
88                + 0.5 * g.gamma * ds * ds
89                + g.vega * dv
90                + 0.5 * g.volga * dv * dv
91                + g.vanna * ds * dv
92                + g.theta * cfg.horizon
93        })
94        .collect();
95    summarize(&pnl, cfg)
96}
97
98/// Full-revaluation VaR: every scenario reprices the whole book via
99/// [`EquityPortfolio::price_with`] (same scenarios as
100/// [`delta_gamma_var`], so the difference isolates the Taylor error).
101pub fn full_revaluation_var(
102    book: &EquityPortfolio,
103    spot: f64,
104    cfg: &RiskConfig,
105) -> PortfolioRisk {
106    let base: f64 = book
107        .positions
108        .iter()
109        .map(|p| p.quantity * p.option.price_with(0.0, 0.0, 0.0, 0.0))
110        .sum();
111    let pnl: Vec<f64> = (0..cfg.scenarios as u64)
112        .map(|i| {
113            let (ds, dv) = scenario_moves(cfg, spot, i);
114            let revalued: f64 = book
115                .positions
116                .iter()
117                .map(|p| p.quantity * p.option.price_with(ds, dv, 0.0, cfg.horizon))
118                .sum();
119            revalued - base
120        })
121        .collect();
122    summarize(&pnl, cfg)
123}
124
125fn summarize(pnl: &[f64], cfg: &RiskConfig) -> PortfolioRisk {
126    PortfolioRisk {
127        var: historical_var(pnl, cfg.confidence),
128        expected_shortfall: historical_expected_shortfall(pnl, cfg.confidence),
129        mean_pnl: pnl.iter().sum::<f64>() / pnl.len() as f64,
130        scenarios: pnl.len(),
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::core::trade::PutOrCall;
138    use crate::equity::builder::EquityOptionBuilder;
139    use crate::equity::utils::Engine;
140    use crate::core::traits::Instrument;
141    use crate::equity::vanilla_option::EquityOption;
142    use chrono::NaiveDate;
143
144    const SPOT: f64 = 100.0;
145
146    fn option(pc: PutOrCall, strike: f64, qty_engine: Engine) -> EquityOption {
147        EquityOptionBuilder::new()
148            .symbol("RISK")
149            .spot(SPOT)
150            .strike(strike)
151            .flat_vol(0.25)
152            .flat_rate(0.03)
153            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
154            .maturity_date(NaiveDate::from_ymd_opt(2026, 7, 2).unwrap())
155            .vanilla(pc)
156            .engine(qty_engine)
157            .build().expect("option must build")
158    }
159
160    fn book(positions: &[(PutOrCall, f64, f64)]) -> EquityPortfolio {
161        let mut b = EquityPortfolio::new();
162        for &(pc, k, qty) in positions {
163            b.add(option(pc, k, Engine::BlackScholes), qty);
164        }
165        b
166    }
167
168    #[test]
169    fn long_option_var_is_bounded_by_premium_and_es_dominates() {
170        // a long call can lose at most its value
171        let b = book(&[(PutOrCall::Call, 100.0, 100.0)]);
172        let value: f64 = 100.0 * option(PutOrCall::Call, 100.0, Engine::BlackScholes).npv();
173        let cfg = RiskConfig { scenarios: 10_000, ..RiskConfig::default() };
174        let full = full_revaluation_var(&b, SPOT, &cfg);
175        assert!(full.var > 0.0 && full.var < value, "var {} value {value}", full.var);
176        assert!(full.expected_shortfall >= full.var);
177        let dg = delta_gamma_var(&b, SPOT, &cfg);
178        assert!(dg.expected_shortfall >= dg.var);
179    }
180
181    #[test]
182    fn delta_gamma_tracks_full_revaluation_for_one_day() {
183        let b = book(&[(PutOrCall::Call, 100.0, 100.0), (PutOrCall::Put, 95.0, 50.0)]);
184        let cfg = RiskConfig { scenarios: 10_000, vol_of_vol: 0.5, ..RiskConfig::default() };
185        let dg = delta_gamma_var(&b, SPOT, &cfg);
186        let full = full_revaluation_var(&b, SPOT, &cfg);
187        // one-day moves: the Taylor truncation is small
188        assert!(
189            (dg.var - full.var).abs() < 0.10 * full.var.max(1.0),
190            "dg {} vs full {}",
191            dg.var,
192            full.var
193        );
194    }
195
196    #[test]
197    fn hedging_reduces_var_and_gamma_shows_in_the_comparison() {
198        let cfg = RiskConfig { scenarios: 10_000, ..RiskConfig::default() };
199        // naked short call vs the same with a long ATM call hedge
200        let naked = book(&[(PutOrCall::Call, 100.0, -100.0)]);
201        let hedged = book(&[
202            (PutOrCall::Call, 100.0, -100.0),
203            (PutOrCall::Call, 105.0, 100.0),
204        ]);
205        let naked_var = full_revaluation_var(&naked, SPOT, &cfg).var;
206        let hedged_var = full_revaluation_var(&hedged, SPOT, &cfg).var;
207        assert!(hedged_var < naked_var, "hedged {hedged_var} vs naked {naked_var}");
208        // for the short book the delta-gamma estimate must not report a
209        // negative-loss (profit) VaR
210        assert!(delta_gamma_var(&naked, SPOT, &cfg).var > 0.0);
211    }
212
213    #[test]
214    fn vol_scenarios_add_risk_to_a_vega_book() {
215        // long straddle: pure spot scenarios miss the vega risk of a
216        // vol crush; adding vol scenarios raises the VaR
217        let b = book(&[(PutOrCall::Call, 100.0, 100.0), (PutOrCall::Put, 100.0, 100.0)]);
218        let no_vol = RiskConfig { scenarios: 10_000, vol_of_vol: 0.0, ..RiskConfig::default() };
219        let with_vol = RiskConfig { scenarios: 10_000, vol_of_vol: 0.8, ..RiskConfig::default() };
220        let base = full_revaluation_var(&b, SPOT, &no_vol).var;
221        let vol_aware = full_revaluation_var(&b, SPOT, &with_vol).var;
222        assert!(vol_aware > base, "with vol {vol_aware} vs without {base}");
223    }
224}