1use crate::core::errors::RustyQLibError;
22use crate::core::market::{BumpMode, RiskFactor, Shock, Spot};
23use crate::equity::portfolio::EquityPortfolio;
24
25#[derive(Debug, Clone)]
27pub struct LadderPoint {
28 pub move_rel: f64,
30 pub spot: f64,
32 pub mtm: f64,
34 pub pnl: f64,
36 pub position_mtm: Vec<f64>,
39 pub delta: Option<f64>,
43 pub gamma: Option<f64>,
45}
46
47#[derive(Debug, Clone)]
50pub struct SpotLadder {
51 pub symbol: String,
54 pub base_spot: f64,
56 pub base_mtm: f64,
58 pub points: Vec<LadderPoint>,
59}
60
61pub fn symmetric_moves(step: f64, rungs_per_side: usize) -> Vec<f64> {
65 let n = rungs_per_side as i64;
66 (-n..=n).map(|k| k as f64 * step).collect()
67}
68
69pub fn spot_ladder(
74 book: &EquityPortfolio,
75 moves: &[f64],
76) -> Result<SpotLadder, RustyQLibError> {
77 let symbol = match book.positions.first() {
78 Some(p) => p.option.base.symbol.clone(),
79 None => {
80 return Err(RustyQLibError::invalid_input("book", "cannot ladder an empty book"));
81 }
82 };
83 if moves.is_empty() {
84 return Err(RustyQLibError::invalid_input("moves", "the ladder needs at least one rung"));
85 }
86 if moves.iter().any(|x| !x.is_finite() || *x <= -1.0) {
87 return Err(RustyQLibError::invalid_input(
88 "moves",
89 "moves must be finite relative bumps above -100%",
90 ));
91 }
92 if moves.windows(2).any(|w| w[1] <= w[0]) {
93 return Err(RustyQLibError::invalid_input(
94 "moves",
95 "moves must be strictly increasing",
96 ));
97 }
98
99 let base_market = book.snapshot_market();
100 let base_spot = base_market.get(&Spot(symbol.clone()))?.mid();
101 let base_values = book.position_values_in(&base_market)?;
102 let base_mtm: f64 = base_values.iter().sum();
103
104 let mut points = Vec::with_capacity(moves.len());
105 for &x in moves {
106 let shock = Shock {
107 factor: RiskFactor::Spot,
108 mode: BumpMode::Relative,
109 size: x,
110 underlying: None,
111 tenors: None,
112 shifts: None,
113 };
114 let bumped = base_market.bumped(std::slice::from_ref(&shock))?;
115 let position_mtm = book.position_values_in(&bumped)?;
116 let mtm: f64 = position_mtm.iter().sum();
117 points.push(LadderPoint {
118 move_rel: x,
119 spot: base_spot * (1.0 + x),
120 mtm,
121 pnl: mtm - base_mtm,
122 position_mtm,
123 delta: None,
124 gamma: None,
125 });
126 }
127
128 let xs: Vec<f64> = points.iter().map(|p| p.spot).collect();
129 let vs: Vec<f64> = points.iter().map(|p| p.mtm).collect();
130 for (point, (d1, d2)) in points.iter_mut().zip(ladder_derivatives(&xs, &vs)) {
131 point.delta = d1;
132 point.gamma = d2;
133 }
134
135 Ok(SpotLadder { symbol, base_spot, base_mtm, points })
136}
137
138fn ladder_derivatives(xs: &[f64], vs: &[f64]) -> Vec<(Option<f64>, Option<f64>)> {
143 let mut out = vec![(None, None); xs.len()];
144 for i in 1..xs.len().saturating_sub(1) {
145 let (h1, h2) = (xs[i] - xs[i - 1], xs[i + 1] - xs[i]);
146 let (v_prev, v_mid, v_next) = (vs[i - 1], vs[i], vs[i + 1]);
147 let d1 = -h2 / (h1 * (h1 + h2)) * v_prev + (h2 - h1) / (h1 * h2) * v_mid
148 + h1 / (h2 * (h1 + h2)) * v_next;
149 let d2 =
150 2.0 * (v_prev / (h1 * (h1 + h2)) - v_mid / (h1 * h2) + v_next / (h2 * (h1 + h2)));
151 out[i] = (Some(d1), Some(d2));
152 }
153 out
154}
155
156#[derive(Debug, Clone)]
158pub struct VolLadderPoint {
159 pub shift: f64,
162 pub mtm: f64,
164 pub pnl: f64,
166 pub position_mtm: Vec<f64>,
168 pub vega: Option<f64>,
171 pub volga: Option<f64>,
173}
174
175#[derive(Debug, Clone)]
179pub struct VolLadder {
180 pub symbol: String,
181 pub base_mtm: f64,
183 pub points: Vec<VolLadderPoint>,
184}
185
186pub fn vol_ladder(book: &EquityPortfolio, shifts: &[f64]) -> Result<VolLadder, RustyQLibError> {
192 let symbol = match book.positions.first() {
193 Some(p) => p.option.base.symbol.clone(),
194 None => {
195 return Err(RustyQLibError::invalid_input("book", "cannot ladder an empty book"));
196 }
197 };
198 if shifts.is_empty() {
199 return Err(RustyQLibError::invalid_input("shifts", "the ladder needs at least one rung"));
200 }
201 if shifts.iter().any(|x| !x.is_finite()) {
202 return Err(RustyQLibError::invalid_input("shifts", "shifts must be finite vol points"));
203 }
204 if shifts.windows(2).any(|w| w[1] <= w[0]) {
205 return Err(RustyQLibError::invalid_input(
206 "shifts",
207 "shifts must be strictly increasing",
208 ));
209 }
210
211 let base_market = book.snapshot_market();
212 let base_values = book.position_values_in(&base_market)?;
213 let base_mtm: f64 = base_values.iter().sum();
214
215 let mut points = Vec::with_capacity(shifts.len());
216 for &shift in shifts {
217 let shock = Shock {
218 factor: RiskFactor::Vol,
219 mode: BumpMode::Absolute,
220 size: shift,
221 underlying: None,
222 tenors: None,
223 shifts: None,
224 };
225 let bumped = base_market.bumped(std::slice::from_ref(&shock))?;
226 let position_mtm = book.position_values_in(&bumped)?;
227 let mtm: f64 = position_mtm.iter().sum();
228 points.push(VolLadderPoint {
229 shift,
230 mtm,
231 pnl: mtm - base_mtm,
232 position_mtm,
233 vega: None,
234 volga: None,
235 });
236 }
237
238 let vs: Vec<f64> = points.iter().map(|p| p.mtm).collect();
239 for (point, (d1, d2)) in points.iter_mut().zip(ladder_derivatives(shifts, &vs)) {
240 point.vega = d1;
241 point.volga = d2;
242 }
243
244 Ok(VolLadder { symbol, base_mtm, points })
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::core::trade::PutOrCall;
251 use crate::core::traits::Instrument;
252 use crate::equity::builder::EquityOptionBuilder;
253 use crate::equity::utils::Engine;
254 use chrono::NaiveDate;
255
256 fn call_book(quantity: f64) -> EquityPortfolio {
257 let option = EquityOptionBuilder::new()
258 .symbol("ACME")
259 .spot(100.0)
260 .strike(100.0)
261 .flat_vol(0.25)
262 .flat_rate(0.03)
263 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
264 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
265 .vanilla(PutOrCall::Call)
266 .engine(Engine::BlackScholes)
267 .build()
268 .expect("option must build");
269 let mut book = EquityPortfolio::new();
270 book.add(option, quantity);
271 book
272 }
273
274 #[test]
275 fn symmetric_moves_span_zero_uniformly() {
276 let moves = symmetric_moves(0.05, 4);
277 assert_eq!(moves.len(), 9);
278 assert!((moves[0] + 0.20).abs() < 1e-12);
279 assert!((moves[4]).abs() < 1e-12);
280 assert!((moves[8] - 0.20).abs() < 1e-12);
281 }
282
283 #[test]
284 fn ladder_greeks_match_the_closed_forms_at_the_centre() {
285 let quantity = 100.0;
286 let book = call_book(quantity);
287 let ladder = spot_ladder(&book, &symmetric_moves(0.02, 2)).unwrap();
288 assert_eq!(ladder.symbol, "ACME");
289 assert!((ladder.base_spot - 100.0).abs() < 1e-12);
290 let centre = &ladder.points[2];
292 assert!((centre.move_rel).abs() < 1e-12);
293 assert!((centre.mtm - ladder.base_mtm).abs() < 1e-10);
294 assert!((centre.pnl).abs() < 1e-10);
295 let greeks = book.positions[0].option.price().unwrap().greeks;
298 let delta = centre.delta.expect("interior rung has delta");
299 let gamma = centre.gamma.expect("interior rung has gamma");
300 assert!(
301 (delta - quantity * greeks.delta).abs() < 0.01 * quantity * greeks.delta.abs(),
302 "ladder delta {delta} vs analytic {}",
303 quantity * greeks.delta
304 );
305 assert!(
306 (gamma - quantity * greeks.gamma).abs() < 0.01 * quantity * greeks.gamma.abs(),
307 "ladder gamma {gamma} vs analytic {}",
308 quantity * greeks.gamma
309 );
310 assert!(ladder.points[0].delta.is_none() && ladder.points[4].gamma.is_none());
312 assert!(ladder.points.windows(2).all(|w| w[1].mtm > w[0].mtm));
314 assert!(ladder.points[1].gamma.unwrap() > 0.0);
315 assert!(ladder.points[3].gamma.unwrap() > 0.0);
316 for p in &ladder.points {
318 let sum: f64 = p.position_mtm.iter().sum();
319 assert!((sum - p.mtm).abs() < 1e-10);
320 }
321 }
322
323 #[test]
324 fn uneven_grids_reproduce_the_same_centre_greeks() {
325 let book = call_book(1.0);
328 let ladder = spot_ladder(&book, &[-0.05, -0.02, 0.0, 0.02, 0.05]).unwrap();
329 let greeks = book.positions[0].option.price().unwrap().greeks;
330 let centre = &ladder.points[2];
331 assert!((centre.delta.unwrap() - greeks.delta).abs() < 0.01 * greeks.delta.abs());
332 assert!((centre.gamma.unwrap() - greeks.gamma).abs() < 0.015 * greeks.gamma.abs());
333 }
334
335 #[test]
336 fn accumulator_ladder_shows_the_toxic_tail_and_the_knockout_relief() {
337 let option = EquityOptionBuilder::new()
339 .symbol("ACCU")
340 .spot(100.0)
341 .strike(95.0)
342 .flat_vol(0.25)
343 .flat_rate(0.03)
344 .years_to_maturity(1.0)
345 .accumulator(110.0, 12, 1.0, 2.0)
346 .engine(Engine::MonteCarlo)
347 .paths(4_000)
348 .seed(42)
349 .build()
350 .expect("accumulator must build");
351 let mut book = EquityPortfolio::new();
352 book.add(option, 1.0);
353 let ladder = spot_ladder(&book, &[-0.20, -0.10, 0.0, 0.10, 0.20]).unwrap();
354 let down = ladder.points[0].pnl;
355 let up = ladder.points[4].pnl;
356 assert!(down < 0.0, "toxic tail pnl {down}");
358 assert!(up > 0.0, "knock-out relief pnl {up}");
361 assert!(up.abs() < down.abs(), "asymmetry: relief is capped, the tail is not");
362 }
363
364 #[test]
365 fn vol_ladder_vega_matches_the_closed_form_and_shows_convexity() {
366 let quantity = 100.0;
367 let book = call_book(quantity);
368 let ladder = vol_ladder(&book, &[-0.04, -0.02, 0.0, 0.02, 0.04]).unwrap();
369 assert_eq!(ladder.symbol, "ACME");
370 let centre = &ladder.points[2];
371 assert!((centre.shift).abs() < 1e-12);
372 assert!((centre.mtm - ladder.base_mtm).abs() < 1e-10);
373 let greeks = book.positions[0].option.price().unwrap().greeks;
375 let vega = centre.vega.expect("interior rung has vega");
376 assert!(
377 (vega - quantity * greeks.vega).abs() < 0.01 * quantity * greeks.vega.abs(),
378 "ladder vega {vega} vs analytic {}",
379 quantity * greeks.vega
380 );
381 assert!(ladder.points.windows(2).all(|w| w[1].mtm > w[0].mtm));
383 assert!(ladder.points[0].vega.is_none() && ladder.points[4].volga.is_none());
385 for p in &ladder.points {
387 let sum: f64 = p.position_mtm.iter().sum();
388 assert!((sum - p.mtm).abs() < 1e-10);
389 }
390
391 let otm = EquityOptionBuilder::new()
394 .symbol("ACME")
395 .spot(100.0)
396 .strike(140.0)
397 .flat_vol(0.25)
398 .flat_rate(0.03)
399 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
400 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
401 .vanilla(PutOrCall::Call)
402 .engine(Engine::BlackScholes)
403 .build()
404 .unwrap();
405 let mut otm_book = EquityPortfolio::new();
406 otm_book.add(otm, 1.0);
407 let otm_ladder = vol_ladder(&otm_book, &[-0.05, 0.0, 0.05]).unwrap();
408 assert!(otm_ladder.points[1].volga.unwrap() > 0.0, "OTM option is long volga");
409 }
410
411 #[test]
412 fn accumulator_vol_ladder_confirms_the_short_vol_holder() {
413 let option = EquityOptionBuilder::new()
417 .symbol("ACCU")
418 .spot(100.0)
419 .strike(95.0)
420 .flat_vol(0.25)
421 .flat_rate(0.03)
422 .years_to_maturity(1.0)
423 .accumulator(110.0, 12, 1.0, 2.0)
424 .engine(Engine::MonteCarlo)
425 .paths(4_000)
426 .seed(42)
427 .build()
428 .expect("accumulator must build");
429 let mut book = EquityPortfolio::new();
430 book.add(option, 1.0);
431 let ladder = vol_ladder(&book, &[-0.05, 0.0, 0.05]).unwrap();
432 assert!(ladder.points[0].pnl > 0.0, "vols down relieves the short-vol holder");
433 assert!(ladder.points[2].pnl < 0.0, "vols up hurts the short-vol holder");
434 assert!(ladder.points[1].vega.unwrap() < 0.0, "book vega is short");
435 }
436
437 #[test]
438 fn vol_ladder_rejects_invalid_grids_and_impossible_shifts() {
439 let book = call_book(1.0);
440 assert!(vol_ladder(&book, &[]).is_err(), "empty grid");
441 assert!(vol_ladder(&book, &[0.02, 0.01]).is_err(), "descending");
442 assert!(vol_ladder(&book, &[f64::INFINITY]).is_err(), "non-finite");
443 assert!(vol_ladder(&book, &[-0.30, 0.0]).is_err(), "negative vol");
446 let empty = EquityPortfolio::new();
447 assert!(vol_ladder(&empty, &[0.0]).is_err(), "empty book");
448 }
449
450 #[test]
451 fn invalid_grids_and_empty_books_are_rejected() {
452 let book = call_book(1.0);
453 assert!(spot_ladder(&book, &[]).is_err(), "empty grid");
454 assert!(spot_ladder(&book, &[-0.1, -0.1, 0.1]).is_err(), "not strictly increasing");
455 assert!(spot_ladder(&book, &[0.1, -0.1]).is_err(), "descending");
456 assert!(spot_ladder(&book, &[-1.5, 0.0]).is_err(), "below -100%");
457 assert!(spot_ladder(&book, &[f64::NAN]).is_err(), "non-finite");
458 let empty = EquityPortfolio::new();
459 assert!(spot_ladder(&empty, &[0.0]).is_err(), "empty book");
460 }
461}