libitofin 0.14.0

A ground-up Rust port of QuantLib: quantitative-finance primitives for pricing, risk, and numerical methods.
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
//! Flat year-on-year inflation optionlet volatility.
//!
//! Port of `ConstantYoYOptionletVolatility`
//! (`yoyinflationoptionletvolatilitystructure.hpp:154-203`): one volatility for
//! every strike and every date, and the date arithmetic of the base surface
//! underneath it (`.cpp:51-166`). The volatility is either a fixed value
//! (wrapped in an unobservable quote, as in C++) or a quote handle whose
//! changes propagate to the surface's observers.
//!
//! Both C++ constructors compute the reference date off the evaluation date;
//! there is no fixed-reference-date form, so neither is there one here. The
//! shared [`Settings`] handle is taken explicitly, per D5.
//!
//! ## Omitted (visible)
//!
//! The C++ constructors also take a `VolatilityType` and a `displacement`
//! restricted to 0 or 1 (`hpp:158-185`, `.cpp:40-46`), which the surface only
//! reports back through `volatilityType()`/`displacement()`. Nothing in this
//! batch reads them: the coupon pricer names its own distribution, as C++'s
//! three pricer classes do, rather than asking the surface which one to use.
//! They are omitted rather than accepted and ignored, so a caller cannot quote
//! a normal volatility here and be silently priced lognormally; the pricer
//! constructor is where that choice is made. They return with the readers that
//! need them, the optionlet strippers and the cap/floor engines (`#851`).

use crate::errors::QlResult;
use crate::handle::Handle;
use crate::indexes::inflationindex::inflation_period;
use crate::patterns::observable::{AsObservable, Observable};
use crate::quotes::{Quote, make_quote_handle};
use crate::require;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::termstructures::volatility::VolatilityTermStructure;
use crate::termstructures::{TermStructure, TermStructureBase};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::types::{Natural, Rate, Real, Time, Volatility};

use super::YoYOptionletVolatilitySurface;

/// Constant year-on-year optionlet volatility, no strike or date dependence.
pub struct ConstantYoYOptionletVolatility {
    base: TermStructureBase,
    business_day_convention: BusinessDayConvention,
    volatility: Handle<dyn Quote>,
    observation_lag: Period,
    frequency: Frequency,
    index_is_interpolated: bool,
    min_strike: Rate,
    max_strike: Rate,
}

impl ConstantYoYOptionletVolatility {
    #[allow(clippy::too_many_arguments)]
    fn assemble(
        base: TermStructureBase,
        business_day_convention: BusinessDayConvention,
        volatility: Handle<dyn Quote>,
        observation_lag: Period,
        frequency: Frequency,
        index_is_interpolated: bool,
        min_strike: Rate,
        max_strike: Rate,
        observe: bool,
    ) -> ConstantYoYOptionletVolatility {
        if observe {
            volatility.register_observer(&base.updater());
        }
        ConstantYoYOptionletVolatility {
            base,
            business_day_convention,
            volatility,
            observation_lag,
            frequency,
            index_is_interpolated,
            min_strike,
            max_strike,
        }
    }

    /// A flat surface at `volatility`, its reference date moving off the
    /// evaluation date.
    ///
    /// `min_strike` and `max_strike` bound the strike domain; C++ defaults them
    /// to `-1.0` and `100.0` (`hpp:161-171`), which the port has no default
    /// arguments to carry.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        volatility: Volatility,
        settlement_days: Natural,
        calendar: Calendar,
        business_day_convention: BusinessDayConvention,
        day_counter: DayCounter,
        observation_lag: Period,
        frequency: Frequency,
        index_is_interpolated: bool,
        min_strike: Rate,
        max_strike: Rate,
        settings: Shared<Settings<Date>>,
    ) -> ConstantYoYOptionletVolatility {
        Self::assemble(
            TermStructureBase::moving(settlement_days, calendar, Some(day_counter), settings),
            business_day_convention,
            make_quote_handle(volatility).handle(),
            observation_lag,
            frequency,
            index_is_interpolated,
            min_strike,
            max_strike,
            false,
        )
    }

    /// A flat surface quoted by `volatility`; quote changes notify the surface's
    /// observers. See [`new`](Self::new).
    #[allow(clippy::too_many_arguments)]
    pub fn with_quote(
        volatility: Handle<dyn Quote>,
        settlement_days: Natural,
        calendar: Calendar,
        business_day_convention: BusinessDayConvention,
        day_counter: DayCounter,
        observation_lag: Period,
        frequency: Frequency,
        index_is_interpolated: bool,
        min_strike: Rate,
        max_strike: Rate,
        settings: Shared<Settings<Date>>,
    ) -> ConstantYoYOptionletVolatility {
        Self::assemble(
            TermStructureBase::moving(settlement_days, calendar, Some(day_counter), settings),
            business_day_convention,
            volatility,
            observation_lag,
            frequency,
            index_is_interpolated,
            min_strike,
            max_strike,
            true,
        )
    }

    /// The lag the surface itself observes inflation with (`observationLag`).
    pub fn observation_lag(&self) -> Period {
        self.observation_lag
    }

    /// How often the observed index publishes (`frequency`).
    pub fn frequency(&self) -> Frequency {
        self.frequency
    }

    /// Whether the observed index interpolates between publications
    /// (`indexIsInterpolated`).
    pub fn index_is_interpolated(&self) -> bool {
        self.index_is_interpolated
    }

    /// `date` as the surface observes it: itself for an interpolated index, the
    /// start of its publication period otherwise (`.cpp:57-63`, `:145-151`).
    ///
    /// # Errors
    ///
    /// When the frequency admits no publication period.
    fn observed(&self, date: Date) -> QlResult<Date> {
        if self.index_is_interpolated {
            Ok(date)
        } else {
            Ok(inflation_period(date, self.frequency)?.0)
        }
    }

    /// The time from [`base_date`](YoYOptionletVolatilitySurface::base_date) to
    /// the date the surface observes for an exercise on `date` (`timeFromBase`,
    /// `.cpp:134-156`).
    ///
    /// # Errors
    ///
    /// As [`base_date`](YoYOptionletVolatilitySurface::base_date), plus a
    /// surface built without a day counter.
    pub fn time_from_base(&self, date: Date, obs_lag: Period) -> QlResult<Time> {
        let observed = self.observed(date - obs_lag)?;
        Ok(self
            .require_day_counter()?
            .year_fraction(self.base_date()?, observed))
    }

    /// The date and strike checks C++ runs before every volatility query
    /// (`checkRange`, `.cpp:66-77`).
    ///
    /// The max-date clause is omitted: this surface's
    /// [`max_date`](TermStructure::max_date) is [`Date::max_date`], so it can
    /// never fire. The `extrapolate` argument is not part of the lean trait, so
    /// the strike check runs as C++ does with the default `extrapolate = false`,
    /// still yielding to
    /// [`enable_extrapolation`](TermStructure::enable_extrapolation).
    fn check_range(&self, date: Date, strike: Rate) -> QlResult<()> {
        let base_date = self.base_date()?;
        require!(
            date >= base_date,
            "date ({date}) is before base date ({base_date})"
        );
        require!(
            self.allows_extrapolation() || (strike >= self.min_strike && strike <= self.max_strike),
            "strike ({strike}) is outside the curve domain [{min},{max}] at date = {date}",
            min = self.min_strike,
            max = self.max_strike
        );
        Ok(())
    }
}

impl AsObservable for ConstantYoYOptionletVolatility {
    fn observable(&self) -> &Observable {
        self.base.observable()
    }
}

impl TermStructure for ConstantYoYOptionletVolatility {
    fn base(&self) -> &TermStructureBase {
        &self.base
    }

    fn max_date(&self) -> Date {
        Date::max_date()
    }
}

impl VolatilityTermStructure for ConstantYoYOptionletVolatility {
    fn business_day_convention(&self) -> BusinessDayConvention {
        self.business_day_convention
    }

    fn min_strike(&self) -> Rate {
        self.min_strike
    }

    fn max_strike(&self) -> Rate {
        self.max_strike
    }
}

impl YoYOptionletVolatilitySurface for ConstantYoYOptionletVolatility {
    fn base_date(&self) -> QlResult<Date> {
        self.observed(self.reference_date()? - self.observation_lag)
    }

    /// Flat: the quote, whatever the date and strike, once both have passed
    /// [`check_range`](Self::check_range). C++ derives an option time and hands
    /// it to `volatilityImpl`, which discards it (`.cpp:104-114`).
    fn volatility(&self, date: Date, strike: Rate, obs_lag: Period) -> QlResult<Volatility> {
        let observed = self.observed(date - obs_lag)?;
        self.check_range(observed, strike)?;
        self.volatility.current_link()?.value()
    }

    fn total_variance(&self, date: Date, strike: Rate, obs_lag: Period) -> QlResult<Real> {
        let volatility = self.volatility(date, strike, obs_lag)?;
        Ok(volatility * volatility * self.time_from_base(date, obs_lag)?)
    }
}

#[cfg(test)]
mod tests {
    //! QuantLib prices no bare surface: `inflationcapfloor.cpp` reaches one only
    //! through a cap/floor instrument, whose oracle lands with `#851`. The
    //! numbers below are therefore the date arithmetic of `.cpp:51-166` read
    //! directly - which lag is applied, which date it snaps to, and which
    //! interval the variance accrues over - rather than a C++ premium.

    use super::*;
    use crate::quotes::SimpleQuote;
    use crate::shared::shared;
    use crate::test_support::{Flag, as_observer};
    use crate::time::calendars::unitedkingdom::{self, UnitedKingdom};
    use crate::time::date::Month::{April, July, June, March, May};
    use crate::time::daycounters::actual365fixed::Actual365Fixed;
    use crate::time::timeunit::TimeUnit;

    const VOL: Volatility = 0.01;

    fn lag() -> Period {
        Period::new(2, TimeUnit::Months)
    }

    fn zero_lag() -> Period {
        Period::new(0, TimeUnit::Days)
    }

    /// A surface as of 15 June 2026 with a two-month observation lag, monthly
    /// publication. `settlement_days` is zero, so the reference date is the
    /// evaluation date itself.
    fn surface(index_is_interpolated: bool) -> ConstantYoYOptionletVolatility {
        let settings = shared(Settings::<Date>::new());
        settings.set_evaluation_date(Date::new(15, June, 2026));
        ConstantYoYOptionletVolatility::new(
            VOL,
            0,
            UnitedKingdom::new(unitedkingdom::Market::Settlement),
            BusinessDayConvention::ModifiedFollowing,
            Actual365Fixed::new(),
            lag(),
            Frequency::Monthly,
            index_is_interpolated,
            -1.0,
            100.0,
            settings,
        )
    }

    /// `baseDate` is the reference date pulled back by the surface's own lag,
    /// then snapped to the start of the publication month unless the index
    /// interpolates (`.cpp:57-63`). 15 June less two months is 15 April, which
    /// snaps to 1 April.
    #[test]
    fn the_base_date_snaps_to_the_publication_period_unless_interpolated() {
        assert_eq!(
            surface(false).base_date().unwrap(),
            Date::new(1, April, 2026)
        );
        assert_eq!(
            surface(true).base_date().unwrap(),
            Date::new(15, April, 2026)
        );
    }

    /// `totalVariance` is `vol * vol * timeFromBase`, and `timeFromBase` applies
    /// the lag it is *handed*, not the surface's own (`.cpp:136-152`): at the
    /// zero lag the pricer passes, the variance accrues from the base date to
    /// the exercise month itself.
    #[test]
    fn the_total_variance_accrues_over_the_handed_lag() {
        let surface = surface(false);
        let exercise = Date::new(20, July, 2026);

        let time = surface.time_from_base(exercise, zero_lag()).unwrap();
        let expected = Actual365Fixed::new()
            .year_fraction(Date::new(1, April, 2026), Date::new(1, July, 2026));
        assert!((time - expected).abs() < 1e-15, "time was {time}");

        let variance = surface.total_variance(exercise, 0.03, zero_lag()).unwrap();
        assert!(
            (variance - VOL * VOL * expected).abs() < 1e-18,
            "variance was {variance}"
        );

        let lagged = surface.time_from_base(exercise, lag()).unwrap();
        let lagged_expected =
            Actual365Fixed::new().year_fraction(Date::new(1, April, 2026), Date::new(1, May, 2026));
        assert!(
            (lagged - lagged_expected).abs() < 1e-15,
            "the surface's own lag gives {lagged}"
        );
    }

    /// Flat in both arguments, and live to its quote.
    #[test]
    fn the_volatility_is_flat_and_follows_its_quote() {
        let surface = surface(false);
        for date in [Date::new(1, July, 2026), Date::new(20, July, 2030)] {
            for strike in [-0.5, 0.0, 0.03, 50.0] {
                assert_eq!(surface.volatility(date, strike, zero_lag()).unwrap(), VOL);
            }
        }

        let settings = shared(Settings::<Date>::new());
        settings.set_evaluation_date(Date::new(15, June, 2026));
        let quote = make_quote_handle(0.02);
        let quoted = ConstantYoYOptionletVolatility::with_quote(
            quote.handle(),
            0,
            UnitedKingdom::new(unitedkingdom::Market::Settlement),
            BusinessDayConvention::ModifiedFollowing,
            Actual365Fixed::new(),
            lag(),
            Frequency::Monthly,
            false,
            -1.0,
            100.0,
            settings,
        );
        let flag = Flag::new();
        quoted.observable().register_observer(&as_observer(&flag));

        quote.link_to(shared(SimpleQuote::new(0.05)) as Shared<dyn Quote>);
        assert!(Flag::is_up(&flag));
        assert_eq!(
            quoted
                .volatility(Date::new(20, July, 2026), 0.03, zero_lag())
                .unwrap(),
            0.05
        );
    }

    /// A date before the base date, and a strike outside the domain, are both
    /// refused (`checkRange`, `.cpp:67-76`) - unless extrapolation is enabled.
    #[test]
    fn a_date_before_the_base_date_or_a_strike_off_the_domain_is_rejected() {
        let surface = surface(false);

        let early = surface
            .volatility(Date::new(20, March, 2026), 0.03, zero_lag())
            .expect_err("March 2026 precedes the April base date");
        assert!(early.message().contains("before base date"), "err: {early}");

        let wide = surface
            .volatility(Date::new(20, July, 2026), 200.0, zero_lag())
            .expect_err("200 is past the 100 maximum strike");
        assert!(wide.message().contains("outside the curve"), "err: {wide}");

        surface.enable_extrapolation();
        assert_eq!(
            surface
                .volatility(Date::new(20, July, 2026), 200.0, zero_lag())
                .unwrap(),
            VOL
        );
    }
}