Skip to main content

libitofin/termstructures/volatility/swaption/
mod.rs

1//! Swaption volatility structures.
2//!
3//! Port of `ql/termstructures/volatility/swaption/`.
4//! [`SwaptionVolatilityStructure`] adds the swaption volatility, Black variance
5//! and shift queries on top of [`VolatilityTermStructure`]. Unlike the optionlet
6//! surface, which is indexed by option date and strike, the swaption surface is
7//! two-dimensional: it is indexed by option (exercise) date and swap length as
8//! well as strike, range- and strike-checked exactly as the C++ base performs
9//! them before dispatching to the volatility hook. The volatility type and shift
10//! select the pricing model the surface feeds the swaption engine.
11//!
12//! ## Divergences from QuantLib
13//!
14//! - The `smileSection` family and the `smileSectionImpl` hook are not ported:
15//!   the smile-section layer itself now exists
16//!   ([`SmileSection`](crate::termstructures::volatility::SmileSection), #584),
17//!   but wiring this surface's `smileSectionImpl` bridge to it stays unported. The
18//!   required hook is therefore [`volatility_impl`](SwaptionVolatilityStructure::volatility_impl)
19//!   alone, mirroring C++'s pure-virtual `volatilityImpl(Time, Time, Rate)`; the
20//!   `Date`-based volatility paths convert to time and dispatch to it, as the
21//!   C++ inline `volatility(Date, ...)` overloads do.
22//! - QuantLib overloads `volatility`, `blackVariance` and `shift` across six
23//!   argument shapes each (option tenor/date/time times swap tenor/length).
24//!   Rust has no overloading, so the canonical option-date and option-time forms
25//!   are ported, plus the option-tenor/swap-tenor convenience form; the remaining
26//!   mixed combinations compose these with the already-ported
27//!   [`option_date_from_tenor`](VolatilityTermStructure::option_date_from_tenor)
28//!   and [`swap_length_tenor`](SwaptionVolatilityStructure::swap_length_tenor)
29//!   conversions and are omitted.
30//! - QuantLib exposes the lognormal shift through `shift()`, not the optionlet's
31//!   `displacement()`; this port follows the swaption source and names it
32//!   [`shift`](SwaptionVolatilityStructure::shift).
33//! - The constant surface ([`ConstantSwaptionVolatility`]) and the
34//!   bilinear-interpolated at-the-money matrix ([`SwaptionVolatilityMatrix`])
35//!   are ported here, as is the swaption vol cube framework
36//!   ([`SwaptionVolatilityCube`]); the two concrete cubes it feeds (the
37//!   interpolated cube #595 and the SABR cube #596) and the stripped surface are
38//!   deferred.
39
40mod constantswaptionvol;
41mod interpolatedswaptionvolcube;
42mod sabrvolcube;
43mod swaptionvolcube;
44mod swaptionvoldiscrete;
45mod swaptionvolmatrix;
46
47pub use constantswaptionvol::ConstantSwaptionVolatility;
48pub use interpolatedswaptionvolcube::InterpolatedSwaptionVolatilityCube;
49pub use sabrvolcube::SabrSwaptionVolatilityCube;
50pub use swaptionvolcube::{SwaptionCubeSmileSection, SwaptionVolatilityCube};
51pub use swaptionvoldiscrete::SwaptionVolatilityDiscrete;
52pub use swaptionvolmatrix::SwaptionVolatilityMatrix;
53
54use crate::errors::QlResult;
55use crate::termstructures::volatility::{VolatilityTermStructure, VolatilityType};
56use crate::time::date::Date;
57use crate::time::period::Period;
58use crate::time::timeunit::TimeUnit;
59use crate::types::{Rate, Real, Time, Volatility};
60use crate::{fail, require};
61
62/// The discrete option/swap grid a grid-backed swaption vol structure exposes.
63///
64/// The SABR cube's dense ATM-calibration fill reads the ATM surface's own
65/// option/swap grid to widen the cube's node set. In QuantLib that grid is read
66/// through `dynamic_pointer_cast<SwaptionVolatilityDiscrete>(*atmVol_)`
67/// (sabrswaptionvolatilitycube.hpp:648-681); Rust `Handle<dyn
68/// SwaptionVolatilityStructure>` has no downcast, so a discrete-backed structure
69/// surfaces its grid through [`SwaptionVolatilityStructure::discrete_grid`]. The
70/// four axes are index-aligned exactly as the C++ base holds them: `option_dates[j]`
71/// is the date whose year fraction is `option_times[j]`, and `swap_tenors[k]` the
72/// tenor whose length is `swap_lengths[k]`.
73pub struct SwaptionVolatilityGrid {
74    /// The option (exercise) times (year fractions from the reference date).
75    pub option_times: Vec<Time>,
76    /// The swap lengths in years.
77    pub swap_lengths: Vec<Time>,
78    /// The option (exercise) dates.
79    pub option_dates: Vec<Date>,
80    /// The swap tenors.
81    pub swap_tenors: Vec<Period>,
82}
83
84/// Swaption volatility structure.
85///
86/// Mirrors QuantLib's `SwaptionVolatilityStructure`: concrete surfaces implement
87/// [`volatility_impl`](Self::volatility_impl); the provided queries run the swap,
88/// range and strike checks and dispatch to it, deriving the Black variance as
89/// `volatility^2 * time`. Volatilities are expressed on an annual basis.
90pub trait SwaptionVolatilityStructure: VolatilityTermStructure {
91    /// Volatility calculation hook; swap, range and strike checks have already
92    /// run.
93    fn volatility_impl(
94        &self,
95        option_time: Time,
96        swap_length: Time,
97        strike: Rate,
98    ) -> QlResult<Volatility>;
99
100    /// The largest swap tenor for which the surface can return vols.
101    fn max_swap_tenor(&self) -> Period;
102
103    /// The pricing model the quoted volatilities are expressed in.
104    fn volatility_type(&self) -> VolatilityType {
105        VolatilityType::ShiftedLognormal
106    }
107
108    /// Shift calculation hook. The default enforces that a shift only makes
109    /// sense for lognormal volatilities and returns `0.0`.
110    fn shift_impl(&self, _option_time: Time, _swap_length: Time) -> QlResult<Real> {
111        require_lognormal_for_shift(self.volatility_type())?;
112        Ok(0.0)
113    }
114
115    /// The structure's own discrete option/swap grid, for a consumer that needs to
116    /// read the node set (the SABR cube's dense ATM-calibration fill). The default
117    /// returns `Err`: only a grid-backed structure (one built on
118    /// [`SwaptionVolatilityDiscrete`], such as [`SwaptionVolatilityMatrix`]) can
119    /// answer. This stands in for QuantLib's
120    /// `dynamic_pointer_cast<SwaptionVolatilityDiscrete>(*atmVol_)`, surfacing an
121    /// `Err` exactly where the C++ cast would null-deref.
122    fn discrete_grid(&self) -> QlResult<SwaptionVolatilityGrid> {
123        fail!(
124            "swaption vol structure is not grid-backed (not a SwaptionVolatilityDiscrete); the \
125             SABR cube dense ATM-calibration fill requires a discrete ATM surface"
126        )
127    }
128
129    /// The largest swap length (in time) for which the surface can return vols.
130    fn max_swap_length(&self) -> QlResult<Time> {
131        self.swap_length_tenor(self.max_swap_tenor())
132    }
133
134    /// Conversion between a swap tenor and its swap length in years. Only
135    /// month- and year-denominated tenors are meaningful.
136    fn swap_length_tenor(&self, swap_tenor: Period) -> QlResult<Time> {
137        require!(
138            swap_tenor.length() > 0,
139            "non-positive swap tenor ({swap_tenor}) given"
140        );
141        match swap_tenor.units() {
142            TimeUnit::Months => Ok(swap_tenor.length() as Time / 12.0),
143            TimeUnit::Years => Ok(swap_tenor.length() as Time),
144            other => fail!("invalid time unit ({other}) for swap length"),
145        }
146    }
147
148    /// Conversion between swap start and end dates and swap length in years,
149    /// rounded to whole months as QuantLib does with `ClosestRounding(0)`.
150    fn swap_length(&self, start: Date, end: Date) -> QlResult<Time> {
151        require!(
152            end > start,
153            "swap end date ({end}) must be greater than start ({start})"
154        );
155        let months = ((end - start) as Time / 365.25 * 12.0).round();
156        Ok(months / 12.0)
157    }
158
159    /// Swap-tenor range check: `swap_tenor` must be positive and, unless
160    /// extrapolation applies, no longer than [`max_swap_tenor`](Self::max_swap_tenor).
161    fn check_swap_tenor(&self, swap_tenor: Period, extrapolate: bool) -> QlResult<()> {
162        require!(
163            swap_tenor.length() > 0,
164            "non-positive swap tenor ({swap_tenor}) given"
165        );
166        require!(
167            extrapolate || self.allows_extrapolation() || swap_tenor <= self.max_swap_tenor(),
168            "swap tenor ({swap_tenor}) is past max tenor ({max})",
169            max = self.max_swap_tenor()
170        );
171        Ok(())
172    }
173
174    /// Swap-length range check: `swap_length` must be positive and, unless
175    /// extrapolation applies, no longer than [`max_swap_length`](Self::max_swap_length).
176    fn check_swap_length(&self, swap_length: Time, extrapolate: bool) -> QlResult<()> {
177        if swap_length <= 0.0 {
178            fail!("non-positive swap length ({swap_length}) given");
179        }
180        require!(
181            extrapolate || self.allows_extrapolation() || swap_length <= self.max_swap_length()?,
182            "swap length ({swap_length}) is past max length ({max})",
183            max = self.max_swap_length()?
184        );
185        Ok(())
186    }
187
188    /// Volatility for a given option date, swap length and strike rate.
189    fn volatility(
190        &self,
191        option_date: Date,
192        swap_length: Time,
193        strike: Rate,
194        extrapolate: bool,
195    ) -> QlResult<Volatility> {
196        self.check_swap_length(swap_length, extrapolate)?;
197        self.check_range_date(option_date, extrapolate)?;
198        self.check_strike(strike, extrapolate)?;
199        let option_time = self.time_from_reference(option_date)?;
200        self.volatility_impl(option_time, swap_length, strike)
201    }
202
203    /// Volatility for a given option time, swap length and strike rate.
204    fn volatility_time(
205        &self,
206        option_time: Time,
207        swap_length: Time,
208        strike: Rate,
209        extrapolate: bool,
210    ) -> QlResult<Volatility> {
211        self.check_swap_length(swap_length, extrapolate)?;
212        self.check_range_time(option_time, extrapolate)?;
213        self.check_strike(strike, extrapolate)?;
214        self.volatility_impl(option_time, swap_length, strike)
215    }
216
217    /// Volatility for a given option tenor, swap tenor and strike rate.
218    fn volatility_tenors(
219        &self,
220        option_tenor: Period,
221        swap_tenor: Period,
222        strike: Rate,
223        extrapolate: bool,
224    ) -> QlResult<Volatility> {
225        let option_date = self.option_date_from_tenor(option_tenor)?;
226        let swap_length = self.swap_length_tenor(swap_tenor)?;
227        self.volatility(option_date, swap_length, strike, extrapolate)
228    }
229
230    /// Black variance for a given option date, swap length and strike rate.
231    fn black_variance(
232        &self,
233        option_date: Date,
234        swap_length: Time,
235        strike: Rate,
236        extrapolate: bool,
237    ) -> QlResult<Real> {
238        let v = self.volatility(option_date, swap_length, strike, extrapolate)?;
239        let option_time = self.time_from_reference(option_date)?;
240        Ok(v * v * option_time)
241    }
242
243    /// Black variance for a given option time, swap length and strike rate.
244    fn black_variance_time(
245        &self,
246        option_time: Time,
247        swap_length: Time,
248        strike: Rate,
249        extrapolate: bool,
250    ) -> QlResult<Real> {
251        let v = self.volatility_time(option_time, swap_length, strike, extrapolate)?;
252        Ok(v * v * option_time)
253    }
254
255    /// Black variance for a given option tenor, swap tenor and strike rate.
256    fn black_variance_tenors(
257        &self,
258        option_tenor: Period,
259        swap_tenor: Period,
260        strike: Rate,
261        extrapolate: bool,
262    ) -> QlResult<Real> {
263        let option_date = self.option_date_from_tenor(option_tenor)?;
264        let swap_length = self.swap_length_tenor(swap_tenor)?;
265        self.black_variance(option_date, swap_length, strike, extrapolate)
266    }
267
268    /// Lognormal shift for a given option date and swap length.
269    fn shift(&self, option_date: Date, swap_length: Time, extrapolate: bool) -> QlResult<Real> {
270        self.check_swap_length(swap_length, extrapolate)?;
271        self.check_range_date(option_date, extrapolate)?;
272        let option_time = self.time_from_reference(option_date)?;
273        self.shift_impl(option_time, swap_length)
274    }
275
276    /// Lognormal shift for a given option time and swap length.
277    fn shift_time(
278        &self,
279        option_time: Time,
280        swap_length: Time,
281        extrapolate: bool,
282    ) -> QlResult<Real> {
283        self.check_swap_length(swap_length, extrapolate)?;
284        self.check_range_time(option_time, extrapolate)?;
285        self.shift_impl(option_time, swap_length)
286    }
287}
288
289fn require_lognormal_for_shift(volatility_type: VolatilityType) -> QlResult<()> {
290    require!(
291        volatility_type == VolatilityType::ShiftedLognormal,
292        "shift parameter only makes sense for lognormal volatilities"
293    );
294    Ok(())
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::patterns::observable::{AsObservable, Observable};
301    use crate::termstructures::{TermStructure, TermStructureBase};
302    use crate::time::calendars::target::Target;
303    use crate::time::date::Month;
304    use crate::time::daycounters::actual360::Actual360;
305
306    struct MockSwaptionVol {
307        base: TermStructureBase,
308        vol: Volatility,
309        volatility_type: VolatilityType,
310        shift: Real,
311    }
312
313    impl MockSwaptionVol {
314        fn flat(vol: Volatility) -> MockSwaptionVol {
315            MockSwaptionVol {
316                base: TermStructureBase::with_reference_date(
317                    Date::new(15, Month::June, 2026),
318                    Some(Target::new()),
319                    Some(Actual360::new()),
320                ),
321                vol,
322                volatility_type: VolatilityType::ShiftedLognormal,
323                shift: 0.0,
324            }
325        }
326    }
327
328    impl AsObservable for MockSwaptionVol {
329        fn observable(&self) -> &Observable {
330            self.base.observable()
331        }
332    }
333
334    impl TermStructure for MockSwaptionVol {
335        fn base(&self) -> &TermStructureBase {
336            &self.base
337        }
338
339        fn max_date(&self) -> Date {
340            Date::max_date()
341        }
342    }
343
344    impl VolatilityTermStructure for MockSwaptionVol {
345        fn business_day_convention(
346            &self,
347        ) -> crate::time::businessdayconvention::BusinessDayConvention {
348            crate::time::businessdayconvention::BusinessDayConvention::Following
349        }
350
351        fn min_strike(&self) -> Rate {
352            Rate::MIN
353        }
354
355        fn max_strike(&self) -> Rate {
356            Rate::MAX
357        }
358    }
359
360    impl SwaptionVolatilityStructure for MockSwaptionVol {
361        fn volatility_impl(&self, _t: Time, _l: Time, _strike: Rate) -> QlResult<Volatility> {
362            Ok(self.vol)
363        }
364
365        fn max_swap_tenor(&self) -> Period {
366            Period::new(100, TimeUnit::Years)
367        }
368
369        fn volatility_type(&self) -> VolatilityType {
370            self.volatility_type
371        }
372
373        fn shift_impl(&self, option_time: Time, swap_length: Time) -> QlResult<Real> {
374            require_lognormal_for_shift(self.volatility_type())?;
375            let _ = (option_time, swap_length);
376            Ok(self.shift)
377        }
378    }
379
380    #[test]
381    fn discrete_grid_defaults_to_err_for_a_non_grid_structure() {
382        let s = MockSwaptionVol::flat(0.2);
383        assert!(
384            s.discrete_grid().is_err(),
385            "a structure not built on SwaptionVolatilityDiscrete must not answer discrete_grid"
386        );
387    }
388
389    #[test]
390    fn swap_length_from_tenor_uses_months_and_years() {
391        let s = MockSwaptionVol::flat(0.2);
392        assert_eq!(
393            s.swap_length_tenor(Period::new(6, TimeUnit::Months))
394                .unwrap(),
395            0.5
396        );
397        assert_eq!(
398            s.swap_length_tenor(Period::new(5, TimeUnit::Years))
399                .unwrap(),
400            5.0
401        );
402        assert!(
403            s.swap_length_tenor(Period::new(0, TimeUnit::Years))
404                .is_err()
405        );
406        assert!(s.swap_length_tenor(Period::new(7, TimeUnit::Days)).is_err());
407    }
408
409    #[test]
410    fn swap_length_from_dates_rounds_to_whole_months() {
411        let s = MockSwaptionVol::flat(0.2);
412        let start = Date::new(15, Month::June, 2026);
413        let five_years = s.swap_length(start, start + 5 * 365).unwrap();
414        assert!((five_years - 5.0).abs() < 1e-12);
415        let one_month = s.swap_length(start, start + 30).unwrap();
416        assert!((one_month - 1.0 / 12.0).abs() < 1e-12);
417        assert!(s.swap_length(start, start).is_err());
418    }
419
420    #[test]
421    fn black_variance_is_vol_squared_times_option_time() {
422        let s = MockSwaptionVol::flat(0.25);
423        let var = s.black_variance_time(2.0, 5.0, 0.03, false).unwrap();
424        assert!((var - 0.25 * 0.25 * 2.0).abs() < 1e-15);
425
426        let date = s.reference_date().unwrap() + 180;
427        let t = s.time_from_reference(date).unwrap();
428        let by_date = s.black_variance(date, 5.0, 0.03, false).unwrap();
429        let by_time = s.black_variance_time(t, 5.0, 0.03, false).unwrap();
430        assert!((by_date - by_time).abs() < 1e-15);
431        assert!((by_date - 0.25 * 0.25 * t).abs() < 1e-15);
432    }
433
434    #[test]
435    fn tenor_forms_convert_both_axes() {
436        let s = MockSwaptionVol::flat(0.2);
437        let vol = s
438            .volatility_tenors(
439                Period::new(1, TimeUnit::Years),
440                Period::new(5, TimeUnit::Years),
441                0.03,
442                false,
443            )
444            .unwrap();
445        assert_eq!(vol, 0.2);
446        let var = s
447            .black_variance_tenors(
448                Period::new(1, TimeUnit::Years),
449                Period::new(5, TimeUnit::Years),
450                0.03,
451                false,
452            )
453            .unwrap();
454        assert!(var > 0.0);
455    }
456
457    #[test]
458    fn shift_is_gated_by_volatility_type() {
459        let mut s = MockSwaptionVol::flat(0.2);
460        s.shift = 0.01;
461        assert_eq!(
462            s.shift(s.reference_date().unwrap() + 90, 5.0, false)
463                .unwrap(),
464            0.01
465        );
466
467        s.volatility_type = VolatilityType::Normal;
468        assert!(s.shift_time(1.0, 5.0, false).is_err());
469    }
470
471    #[test]
472    fn non_positive_swap_length_is_rejected() {
473        let s = MockSwaptionVol::flat(0.2);
474        assert!(s.volatility_time(1.0, 0.0, 0.03, false).is_err());
475        assert!(s.volatility_time(1.0, -1.0, 0.03, false).is_err());
476    }
477
478    #[test]
479    fn max_swap_length_follows_max_swap_tenor() {
480        let s = MockSwaptionVol::flat(0.2);
481        assert_eq!(s.max_swap_length().unwrap(), 100.0);
482    }
483}