RustyQLib 0.0.3

RustyQLib is a lightweight yet robust quantitative finance library designed to price derivatives and perform risk analysis
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
//! Spot risk ladders: the desk risk slide.
//!
//! Point Greeks are a local Taylor expansion — valid near today's spot
//! and silent about what gamma does three percent away, which is exactly
//! where barrier products (knock-outs, autocallables, geared
//! accumulators) hide their risk. A **ladder** completes the picture by
//! full revaluation at a grid of spot levels: per rung, the book's MtM
//! and P&L, with ladder **delta** and **gamma** read off adjacent rungs
//! by (unevenly spaced) central differences — the non-local versions of
//! delta/gamma that a desk trusts where speed and higher-order Greeks
//! get noisy.
//!
//! Mechanically a ladder is a parametric family of relative spot
//! [`Shock`]s applied through the pricing context: snapshot once
//! ([`EquityPortfolio::snapshot_market`]), bump per rung
//! ([`Market::bumped`](crate::core::market::Market::bumped)), revalue
//! every position on its own engine. Monte Carlo positions keep their
//! seed through the rebind, so rung-to-rung differences are free of
//! sampling noise (common random numbers).

use crate::core::errors::RustyQLibError;
use crate::core::market::{BumpMode, RiskFactor, Shock, Spot};
use crate::equity::portfolio::EquityPortfolio;

/// One rung of the ladder.
#[derive(Debug, Clone)]
pub struct LadderPoint {
    /// Relative spot move of this rung (e.g. `-0.10` = spot down 10%).
    pub move_rel: f64,
    /// Absolute spot level at this rung, `S0 * (1 + move_rel)`.
    pub spot: f64,
    /// Book MtM under the bumped market (quantity-weighted).
    pub mtm: f64,
    /// `mtm - base_mtm`.
    pub pnl: f64,
    /// Per-position MtM at this rung, in book order — the drill-down for
    /// "which trade drives the flip".
    pub position_mtm: Vec<f64>,
    /// Ladder delta `dV/dS` at this rung from the neighbouring rungs
    /// (central differences, uneven spacing supported); `None` at the
    /// endpoints, which have only one neighbour.
    pub delta: Option<f64>,
    /// Ladder gamma `d²V/dS²` at this rung; `None` at the endpoints.
    pub gamma: Option<f64>,
}

/// A spot ladder over one book: base MtM plus one [`LadderPoint`] per
/// requested move, in ascending move order.
#[derive(Debug, Clone)]
pub struct SpotLadder {
    /// The book's underlying symbol (EquityPortfolio books are
    /// single-underlying).
    pub symbol: String,
    /// Unbumped spot the moves are relative to.
    pub base_spot: f64,
    /// Book MtM under the unbumped snapshot.
    pub base_mtm: f64,
    pub points: Vec<LadderPoint>,
}

/// A symmetric uniform move grid: `rungs_per_side` rungs each side of
/// zero in steps of `step`, zero included — e.g. `(0.05, 4)` gives
/// `[-0.20, -0.15, ..., 0.20]`.
pub fn symmetric_moves(step: f64, rungs_per_side: usize) -> Vec<f64> {
    let n = rungs_per_side as i64;
    (-n..=n).map(|k| k as f64 * step).collect()
}

/// Revalue `book` at every relative spot move in `moves` (strictly
/// increasing, each above -100%) and read ladder delta/gamma off the
/// rungs. Errors on an empty book, an invalid grid, or a position the
/// snapshot cannot reprice.
pub fn spot_ladder(
    book: &EquityPortfolio,
    moves: &[f64],
) -> Result<SpotLadder, RustyQLibError> {
    let symbol = match book.positions.first() {
        Some(p) => p.option.base.symbol.clone(),
        None => {
            return Err(RustyQLibError::invalid_input("book", "cannot ladder an empty book"));
        }
    };
    if moves.is_empty() {
        return Err(RustyQLibError::invalid_input("moves", "the ladder needs at least one rung"));
    }
    if moves.iter().any(|x| !x.is_finite() || *x <= -1.0) {
        return Err(RustyQLibError::invalid_input(
            "moves",
            "moves must be finite relative bumps above -100%",
        ));
    }
    if moves.windows(2).any(|w| w[1] <= w[0]) {
        return Err(RustyQLibError::invalid_input(
            "moves",
            "moves must be strictly increasing",
        ));
    }

    let base_market = book.snapshot_market();
    let base_spot = base_market.get(&Spot(symbol.clone()))?.mid();
    let base_values = book.position_values_in(&base_market)?;
    let base_mtm: f64 = base_values.iter().sum();

    let mut points = Vec::with_capacity(moves.len());
    for &x in moves {
        let shock = Shock {
            factor: RiskFactor::Spot,
            mode: BumpMode::Relative,
            size: x,
            underlying: None,
            tenors: None,
            shifts: None,
        };
        let bumped = base_market.bumped(std::slice::from_ref(&shock))?;
        let position_mtm = book.position_values_in(&bumped)?;
        let mtm: f64 = position_mtm.iter().sum();
        points.push(LadderPoint {
            move_rel: x,
            spot: base_spot * (1.0 + x),
            mtm,
            pnl: mtm - base_mtm,
            position_mtm,
            delta: None,
            gamma: None,
        });
    }

    let xs: Vec<f64> = points.iter().map(|p| p.spot).collect();
    let vs: Vec<f64> = points.iter().map(|p| p.mtm).collect();
    for (point, (d1, d2)) in points.iter_mut().zip(ladder_derivatives(&xs, &vs)) {
        point.delta = d1;
        point.gamma = d2;
    }

    Ok(SpotLadder { symbol, base_spot, base_mtm, points })
}

/// First and second derivatives of `vs` w.r.t. `xs` at every point by
/// three-point central differences on a possibly uneven grid (the
/// standard unequal-spacing stencil, second-order accurate); the
/// endpoints, having one neighbour, get `(None, None)`.
fn ladder_derivatives(xs: &[f64], vs: &[f64]) -> Vec<(Option<f64>, Option<f64>)> {
    let mut out = vec![(None, None); xs.len()];
    for i in 1..xs.len().saturating_sub(1) {
        let (h1, h2) = (xs[i] - xs[i - 1], xs[i + 1] - xs[i]);
        let (v_prev, v_mid, v_next) = (vs[i - 1], vs[i], vs[i + 1]);
        let d1 = -h2 / (h1 * (h1 + h2)) * v_prev + (h2 - h1) / (h1 * h2) * v_mid
            + h1 / (h2 * (h1 + h2)) * v_next;
        let d2 =
            2.0 * (v_prev / (h1 * (h1 + h2)) - v_mid / (h1 * h2) + v_next / (h2 * (h1 + h2)));
        out[i] = (Some(d1), Some(d2));
    }
    out
}

/// One rung of a [`vol_ladder`].
#[derive(Debug, Clone)]
pub struct VolLadderPoint {
    /// Absolute vol-point shift of this rung (e.g. `-0.05` = every
    /// implied vol down 5 points), applied as a parallel surface shift.
    pub shift: f64,
    /// Book MtM under the shifted surface (quantity-weighted).
    pub mtm: f64,
    /// `mtm - base_mtm`.
    pub pnl: f64,
    /// Per-position MtM at this rung, in book order.
    pub position_mtm: Vec<f64>,
    /// Ladder vega `dV/dσ` (per unit vol; divide by 100 for per-vol-point)
    /// from the neighbouring rungs; `None` at the endpoints.
    pub vega: Option<f64>,
    /// Ladder volga `d²V/dσ²`; `None` at the endpoints.
    pub volga: Option<f64>,
}

/// A vol ladder over one book: the vega profile across parallel shifts
/// of the implied surface — the non-local view of vega/volga, as
/// [`spot_ladder`] is for delta/gamma.
#[derive(Debug, Clone)]
pub struct VolLadder {
    pub symbol: String,
    /// Book MtM under the unshifted snapshot.
    pub base_mtm: f64,
    pub points: Vec<VolLadderPoint>,
}

/// Revalue `book` under parallel **absolute** vol-point shifts of every
/// implied surface (strictly increasing `shifts`, e.g. `-0.10..=0.10`)
/// and read ladder vega/volga off the rungs. A shift that drives any
/// vol non-positive surfaces as the surface's own bump error. Errors on
/// an empty book or an invalid grid.
pub fn vol_ladder(book: &EquityPortfolio, shifts: &[f64]) -> Result<VolLadder, RustyQLibError> {
    let symbol = match book.positions.first() {
        Some(p) => p.option.base.symbol.clone(),
        None => {
            return Err(RustyQLibError::invalid_input("book", "cannot ladder an empty book"));
        }
    };
    if shifts.is_empty() {
        return Err(RustyQLibError::invalid_input("shifts", "the ladder needs at least one rung"));
    }
    if shifts.iter().any(|x| !x.is_finite()) {
        return Err(RustyQLibError::invalid_input("shifts", "shifts must be finite vol points"));
    }
    if shifts.windows(2).any(|w| w[1] <= w[0]) {
        return Err(RustyQLibError::invalid_input(
            "shifts",
            "shifts must be strictly increasing",
        ));
    }

    let base_market = book.snapshot_market();
    let base_values = book.position_values_in(&base_market)?;
    let base_mtm: f64 = base_values.iter().sum();

    let mut points = Vec::with_capacity(shifts.len());
    for &shift in shifts {
        let shock = Shock {
            factor: RiskFactor::Vol,
            mode: BumpMode::Absolute,
            size: shift,
            underlying: None,
            tenors: None,
            shifts: None,
        };
        let bumped = base_market.bumped(std::slice::from_ref(&shock))?;
        let position_mtm = book.position_values_in(&bumped)?;
        let mtm: f64 = position_mtm.iter().sum();
        points.push(VolLadderPoint {
            shift,
            mtm,
            pnl: mtm - base_mtm,
            position_mtm,
            vega: None,
            volga: None,
        });
    }

    let vs: Vec<f64> = points.iter().map(|p| p.mtm).collect();
    for (point, (d1, d2)) in points.iter_mut().zip(ladder_derivatives(shifts, &vs)) {
        point.vega = d1;
        point.volga = d2;
    }

    Ok(VolLadder { symbol, base_mtm, points })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::trade::PutOrCall;
    use crate::core::traits::Instrument;
    use crate::equity::builder::EquityOptionBuilder;
    use crate::equity::utils::Engine;
    use chrono::NaiveDate;

    fn call_book(quantity: f64) -> EquityPortfolio {
        let option = EquityOptionBuilder::new()
            .symbol("ACME")
            .spot(100.0)
            .strike(100.0)
            .flat_vol(0.25)
            .flat_rate(0.03)
            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
            .vanilla(PutOrCall::Call)
            .engine(Engine::BlackScholes)
            .build()
            .expect("option must build");
        let mut book = EquityPortfolio::new();
        book.add(option, quantity);
        book
    }

    #[test]
    fn symmetric_moves_span_zero_uniformly() {
        let moves = symmetric_moves(0.05, 4);
        assert_eq!(moves.len(), 9);
        assert!((moves[0] + 0.20).abs() < 1e-12);
        assert!((moves[4]).abs() < 1e-12);
        assert!((moves[8] - 0.20).abs() < 1e-12);
    }

    #[test]
    fn ladder_greeks_match_the_closed_forms_at_the_centre() {
        let quantity = 100.0;
        let book = call_book(quantity);
        let ladder = spot_ladder(&book, &symmetric_moves(0.02, 2)).unwrap();
        assert_eq!(ladder.symbol, "ACME");
        assert!((ladder.base_spot - 100.0).abs() < 1e-12);
        // the zero rung reprices to the base MtM exactly
        let centre = &ladder.points[2];
        assert!((centre.move_rel).abs() < 1e-12);
        assert!((centre.mtm - ladder.base_mtm).abs() < 1e-10);
        assert!((centre.pnl).abs() < 1e-10);
        // ladder delta/gamma at the centre against the analytic Greeks
        // (quantity-weighted); 2% spot steps keep the FD error small
        let greeks = book.positions[0].option.price().unwrap().greeks;
        let delta = centre.delta.expect("interior rung has delta");
        let gamma = centre.gamma.expect("interior rung has gamma");
        assert!(
            (delta - quantity * greeks.delta).abs() < 0.01 * quantity * greeks.delta.abs(),
            "ladder delta {delta} vs analytic {}",
            quantity * greeks.delta
        );
        assert!(
            (gamma - quantity * greeks.gamma).abs() < 0.01 * quantity * greeks.gamma.abs(),
            "ladder gamma {gamma} vs analytic {}",
            quantity * greeks.gamma
        );
        // endpoints have no neighbours on both sides
        assert!(ladder.points[0].delta.is_none() && ladder.points[4].gamma.is_none());
        // long call: P&L monotone in spot, positive gamma on every rung
        assert!(ladder.points.windows(2).all(|w| w[1].mtm > w[0].mtm));
        assert!(ladder.points[1].gamma.unwrap() > 0.0);
        assert!(ladder.points[3].gamma.unwrap() > 0.0);
        // per-position drill-down sums to the book at every rung
        for p in &ladder.points {
            let sum: f64 = p.position_mtm.iter().sum();
            assert!((sum - p.mtm).abs() < 1e-10);
        }
    }

    #[test]
    fn uneven_grids_reproduce_the_same_centre_greeks() {
        // desk-style uneven grid: the uneven-spacing stencil must agree
        // with the closed forms just like the uniform one
        let book = call_book(1.0);
        let ladder = spot_ladder(&book, &[-0.05, -0.02, 0.0, 0.02, 0.05]).unwrap();
        let greeks = book.positions[0].option.price().unwrap().greeks;
        let centre = &ladder.points[2];
        assert!((centre.delta.unwrap() - greeks.delta).abs() < 0.01 * greeks.delta.abs());
        assert!((centre.gamma.unwrap() - greeks.gamma).abs() < 0.015 * greeks.gamma.abs());
    }

    #[test]
    fn accumulator_ladder_shows_the_toxic_tail_and_the_knockout_relief() {
        // the product the ladder exists for: geared accumulator, KO 110
        let option = EquityOptionBuilder::new()
            .symbol("ACCU")
            .spot(100.0)
            .strike(95.0)
            .flat_vol(0.25)
            .flat_rate(0.03)
            .years_to_maturity(1.0)
            .accumulator(110.0, 12, 1.0, 2.0)
            .engine(Engine::MonteCarlo)
            .paths(4_000)
            .seed(42)
            .build()
            .expect("accumulator must build");
        let mut book = EquityPortfolio::new();
        book.add(option, 1.0);
        let ladder = spot_ladder(&book, &[-0.20, -0.10, 0.0, 0.10, 0.20]).unwrap();
        let down = ladder.points[0].pnl;
        let up = ladder.points[4].pnl;
        // down 20%: deep in the geared zone — the toxic tail
        assert!(down < 0.0, "toxic tail pnl {down}");
        // up 20%: spot starts above the KO, the structure dies almost
        // immediately — relief for the short-the-wings holder
        assert!(up > 0.0, "knock-out relief pnl {up}");
        assert!(up.abs() < down.abs(), "asymmetry: relief is capped, the tail is not");
    }

    #[test]
    fn vol_ladder_vega_matches_the_closed_form_and_shows_convexity() {
        let quantity = 100.0;
        let book = call_book(quantity);
        let ladder = vol_ladder(&book, &[-0.04, -0.02, 0.0, 0.02, 0.04]).unwrap();
        assert_eq!(ladder.symbol, "ACME");
        let centre = &ladder.points[2];
        assert!((centre.shift).abs() < 1e-12);
        assert!((centre.mtm - ladder.base_mtm).abs() < 1e-10);
        // ladder vega at the centre against the analytic vega
        let greeks = book.positions[0].option.price().unwrap().greeks;
        let vega = centre.vega.expect("interior rung has vega");
        assert!(
            (vega - quantity * greeks.vega).abs() < 0.01 * quantity * greeks.vega.abs(),
            "ladder vega {vega} vs analytic {}",
            quantity * greeks.vega
        );
        // a long option gains monotonically as vols rise
        assert!(ladder.points.windows(2).all(|w| w[1].mtm > w[0].mtm));
        // endpoints have no both-sided neighbours
        assert!(ladder.points[0].vega.is_none() && ladder.points[4].volga.is_none());
        // per-position drill-down sums to the book at every rung
        for p in &ladder.points {
            let sum: f64 = p.position_mtm.iter().sum();
            assert!((sum - p.mtm).abs() < 1e-10);
        }

        // volga: an OTM option is vol-convex (long volga), visibly so on
        // a coarser grid
        let otm = EquityOptionBuilder::new()
            .symbol("ACME")
            .spot(100.0)
            .strike(140.0)
            .flat_vol(0.25)
            .flat_rate(0.03)
            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
            .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
            .vanilla(PutOrCall::Call)
            .engine(Engine::BlackScholes)
            .build()
            .unwrap();
        let mut otm_book = EquityPortfolio::new();
        otm_book.add(otm, 1.0);
        let otm_ladder = vol_ladder(&otm_book, &[-0.05, 0.0, 0.05]).unwrap();
        assert!(otm_ladder.points[1].volga.unwrap() > 0.0, "OTM option is long volga");
    }

    #[test]
    fn accumulator_vol_ladder_confirms_the_short_vol_holder() {
        // the geared holder is short the wings: vols down is relief,
        // vols up is pain — the vega profile the stress test sampled at
        // one point, now as a curve
        let option = EquityOptionBuilder::new()
            .symbol("ACCU")
            .spot(100.0)
            .strike(95.0)
            .flat_vol(0.25)
            .flat_rate(0.03)
            .years_to_maturity(1.0)
            .accumulator(110.0, 12, 1.0, 2.0)
            .engine(Engine::MonteCarlo)
            .paths(4_000)
            .seed(42)
            .build()
            .expect("accumulator must build");
        let mut book = EquityPortfolio::new();
        book.add(option, 1.0);
        let ladder = vol_ladder(&book, &[-0.05, 0.0, 0.05]).unwrap();
        assert!(ladder.points[0].pnl > 0.0, "vols down relieves the short-vol holder");
        assert!(ladder.points[2].pnl < 0.0, "vols up hurts the short-vol holder");
        assert!(ladder.points[1].vega.unwrap() < 0.0, "book vega is short");
    }

    #[test]
    fn vol_ladder_rejects_invalid_grids_and_impossible_shifts() {
        let book = call_book(1.0);
        assert!(vol_ladder(&book, &[]).is_err(), "empty grid");
        assert!(vol_ladder(&book, &[0.02, 0.01]).is_err(), "descending");
        assert!(vol_ladder(&book, &[f64::INFINITY]).is_err(), "non-finite");
        // a shift that drives the 25% surface negative surfaces the
        // surface's own bump error rather than pricing nonsense
        assert!(vol_ladder(&book, &[-0.30, 0.0]).is_err(), "negative vol");
        let empty = EquityPortfolio::new();
        assert!(vol_ladder(&empty, &[0.0]).is_err(), "empty book");
    }

    #[test]
    fn invalid_grids_and_empty_books_are_rejected() {
        let book = call_book(1.0);
        assert!(spot_ladder(&book, &[]).is_err(), "empty grid");
        assert!(spot_ladder(&book, &[-0.1, -0.1, 0.1]).is_err(), "not strictly increasing");
        assert!(spot_ladder(&book, &[0.1, -0.1]).is_err(), "descending");
        assert!(spot_ladder(&book, &[-1.5, 0.0]).is_err(), "below -100%");
        assert!(spot_ladder(&book, &[f64::NAN]).is_err(), "non-finite");
        let empty = EquityPortfolio::new();
        assert!(spot_ladder(&empty, &[0.0]).is_err(), "empty book");
    }
}