Skip to main content

rustyqlib/risk/
ladder.rs

1//! Spot risk ladders: the desk risk slide.
2//!
3//! Point Greeks are a local Taylor expansion — valid near today's spot
4//! and silent about what gamma does three percent away, which is exactly
5//! where barrier products (knock-outs, autocallables, geared
6//! accumulators) hide their risk. A **ladder** completes the picture by
7//! full revaluation at a grid of spot levels: per rung, the book's MtM
8//! and P&L, with ladder **delta** and **gamma** read off adjacent rungs
9//! by (unevenly spaced) central differences — the non-local versions of
10//! delta/gamma that a desk trusts where speed and higher-order Greeks
11//! get noisy.
12//!
13//! Mechanically a ladder is a parametric family of relative spot
14//! [`Shock`]s applied through the pricing context: snapshot once
15//! ([`EquityPortfolio::snapshot_market`]), bump per rung
16//! ([`Market::bumped`](crate::core::market::Market::bumped)), revalue
17//! every position on its own engine. Monte Carlo positions keep their
18//! seed through the rebind, so rung-to-rung differences are free of
19//! sampling noise (common random numbers).
20
21use crate::core::errors::RustyQLibError;
22use crate::core::market::{BumpMode, RiskFactor, Shock, Spot};
23use crate::equity::portfolio::EquityPortfolio;
24
25/// One rung of the ladder.
26#[derive(Debug, Clone)]
27pub struct LadderPoint {
28    /// Relative spot move of this rung (e.g. `-0.10` = spot down 10%).
29    pub move_rel: f64,
30    /// Absolute spot level at this rung, `S0 * (1 + move_rel)`.
31    pub spot: f64,
32    /// Book MtM under the bumped market (quantity-weighted).
33    pub mtm: f64,
34    /// `mtm - base_mtm`.
35    pub pnl: f64,
36    /// Per-position MtM at this rung, in book order — the drill-down for
37    /// "which trade drives the flip".
38    pub position_mtm: Vec<f64>,
39    /// Ladder delta `dV/dS` at this rung from the neighbouring rungs
40    /// (central differences, uneven spacing supported); `None` at the
41    /// endpoints, which have only one neighbour.
42    pub delta: Option<f64>,
43    /// Ladder gamma `d²V/dS²` at this rung; `None` at the endpoints.
44    pub gamma: Option<f64>,
45}
46
47/// A spot ladder over one book: base MtM plus one [`LadderPoint`] per
48/// requested move, in ascending move order.
49#[derive(Debug, Clone)]
50pub struct SpotLadder {
51    /// The book's underlying symbol (EquityPortfolio books are
52    /// single-underlying).
53    pub symbol: String,
54    /// Unbumped spot the moves are relative to.
55    pub base_spot: f64,
56    /// Book MtM under the unbumped snapshot.
57    pub base_mtm: f64,
58    pub points: Vec<LadderPoint>,
59}
60
61/// A symmetric uniform move grid: `rungs_per_side` rungs each side of
62/// zero in steps of `step`, zero included — e.g. `(0.05, 4)` gives
63/// `[-0.20, -0.15, ..., 0.20]`.
64pub 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
69/// Revalue `book` at every relative spot move in `moves` (strictly
70/// increasing, each above -100%) and read ladder delta/gamma off the
71/// rungs. Errors on an empty book, an invalid grid, or a position the
72/// snapshot cannot reprice.
73pub 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
138/// First and second derivatives of `vs` w.r.t. `xs` at every point by
139/// three-point central differences on a possibly uneven grid (the
140/// standard unequal-spacing stencil, second-order accurate); the
141/// endpoints, having one neighbour, get `(None, None)`.
142fn 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/// One rung of a [`vol_ladder`].
157#[derive(Debug, Clone)]
158pub struct VolLadderPoint {
159    /// Absolute vol-point shift of this rung (e.g. `-0.05` = every
160    /// implied vol down 5 points), applied as a parallel surface shift.
161    pub shift: f64,
162    /// Book MtM under the shifted surface (quantity-weighted).
163    pub mtm: f64,
164    /// `mtm - base_mtm`.
165    pub pnl: f64,
166    /// Per-position MtM at this rung, in book order.
167    pub position_mtm: Vec<f64>,
168    /// Ladder vega `dV/dσ` (per unit vol; divide by 100 for per-vol-point)
169    /// from the neighbouring rungs; `None` at the endpoints.
170    pub vega: Option<f64>,
171    /// Ladder volga `d²V/dσ²`; `None` at the endpoints.
172    pub volga: Option<f64>,
173}
174
175/// A vol ladder over one book: the vega profile across parallel shifts
176/// of the implied surface — the non-local view of vega/volga, as
177/// [`spot_ladder`] is for delta/gamma.
178#[derive(Debug, Clone)]
179pub struct VolLadder {
180    pub symbol: String,
181    /// Book MtM under the unshifted snapshot.
182    pub base_mtm: f64,
183    pub points: Vec<VolLadderPoint>,
184}
185
186/// Revalue `book` under parallel **absolute** vol-point shifts of every
187/// implied surface (strictly increasing `shifts`, e.g. `-0.10..=0.10`)
188/// and read ladder vega/volga off the rungs. A shift that drives any
189/// vol non-positive surfaces as the surface's own bump error. Errors on
190/// an empty book or an invalid grid.
191pub 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        // the zero rung reprices to the base MtM exactly
291        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        // ladder delta/gamma at the centre against the analytic Greeks
296        // (quantity-weighted); 2% spot steps keep the FD error small
297        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        // endpoints have no neighbours on both sides
311        assert!(ladder.points[0].delta.is_none() && ladder.points[4].gamma.is_none());
312        // long call: P&L monotone in spot, positive gamma on every rung
313        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        // per-position drill-down sums to the book at every rung
317        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        // desk-style uneven grid: the uneven-spacing stencil must agree
326        // with the closed forms just like the uniform one
327        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        // the product the ladder exists for: geared accumulator, KO 110
338        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        // down 20%: deep in the geared zone — the toxic tail
357        assert!(down < 0.0, "toxic tail pnl {down}");
358        // up 20%: spot starts above the KO, the structure dies almost
359        // immediately — relief for the short-the-wings holder
360        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        // ladder vega at the centre against the analytic vega
374        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        // a long option gains monotonically as vols rise
382        assert!(ladder.points.windows(2).all(|w| w[1].mtm > w[0].mtm));
383        // endpoints have no both-sided neighbours
384        assert!(ladder.points[0].vega.is_none() && ladder.points[4].volga.is_none());
385        // per-position drill-down sums to the book at every rung
386        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        // volga: an OTM option is vol-convex (long volga), visibly so on
392        // a coarser grid
393        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        // the geared holder is short the wings: vols down is relief,
414        // vols up is pain — the vega profile the stress test sampled at
415        // one point, now as a curve
416        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        // a shift that drives the 25% surface negative surfaces the
444        // surface's own bump error rather than pricing nonsense
445        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}