1use 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#[derive(Debug, Clone, Copy)]
26pub struct RiskConfig {
27 pub horizon: f64,
29 pub spot_vol: f64,
31 pub vol_of_vol: f64,
34 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#[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 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
80pub 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
98pub 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 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 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 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 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 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}