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
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
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
//! Seasonality corrections applied to inflation term structures.
//!
//! Port of `ql/termstructures/inflation/seasonality.{hpp,cpp}`:
//! [`Seasonality`] is the transformation an inflation curve folds into the
//! rates it publishes, and [`MultiplicativePriceSeasonality`] is the one
//! implementation QuantLib ships for price indexes (CPI/RPI/HICP), where the
//! factors multiply the index level itself.
//!
//! Seasonality fills in an inflation curve between the integer-year maturities
//! the market quotes. Stationary (one year of factors) multiplicative price
//! seasonality moves zero-coupon rates but leaves year-on-year rates alone;
//! multi-year factor sets move both. The correction is piecewise constant, so
//! it works with un-interpolated inflation indexes
//! (`seasonality.hpp:34-53,81-118`).
//!
//! ## Divergences from QuantLib
//!
//! - `KerkhofSeasonality` (`seasonality.hpp:170-187`,
//!   `seasonality.cpp:220-277`) is **not ported**. It is a distinct
//!   cumulative-product subclass, not a parametrization of
//!   [`MultiplicativePriceSeasonality`], and is out of scope here (#729).
//! - [`MultiplicativePriceSeasonality::is_consistent`] ports the two
//!   short-circuits (`seasonality.cpp:69-70`) but **not** the multi-year
//!   whole-year comparison loop (`seasonality.cpp:72-85`), which returns a
//!   typed error naming the deferral (#807) rather than a silent `true`. A
//!   stationary factor set - the same count as the frequency - is unaffected.
//! - C++'s `set` assigns the fields and *then* validates, leaving an invalid
//!   object behind on failure; [`MultiplicativePriceSeasonality::set`] builds
//!   the replacement first, so a rejected input leaves the receiver untouched.
//! - `iTS.dayCounter()` may be an empty day counter in C++; here it is
//!   [`TermStructure::require_day_counter`], which errors on a curve carrying
//!   none (D10).
//! - The factor lookup, the validation and the corrections all return
//!   [`QlResult`] where C++ throws, `inflation_period` being fallible.

use crate::errors::QlResult;
use crate::indexes::inflationindex::inflation_period;
use crate::termstructures::inflation::inflationtermstructure::InflationTermStructure;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::types::{Integer, Rate};
use crate::{fail, require};

/// A transformation of an existing inflation rate.
///
/// Mirrors QuantLib's abstract `Seasonality` (`seasonality.hpp:55-79`): the
/// two `correctXXXRate` methods return the rate with the correction folded in,
/// and [`is_consistent`](Self::is_consistent) reports whether the factor set
/// can coexist with the curve it is given to.
pub trait Seasonality {
    /// The zero-coupon inflation rate for `date`, corrected.
    fn correct_zero_rate(
        &self,
        date: Date,
        rate: Rate,
        its: &dyn InflationTermStructure,
    ) -> QlResult<Rate>;

    /// The year-on-year inflation rate for `date`, corrected.
    fn correct_yoy_rate(
        &self,
        date: Date,
        rate: Rate,
        its: &dyn InflationTermStructure,
    ) -> QlResult<Rate>;

    /// Whether the correction is consistent with `its`
    /// (`seasonality.cpp:29-31`).
    ///
    /// Multi-year seasonalities can contradict the quoted instruments a curve
    /// is built from: for price seasonality the corrections at whole years
    /// after the curve's base date must agree. Implementing the test is
    /// optional, and the default reports consistency.
    fn is_consistent(&self, _its: &dyn InflationTermStructure) -> QlResult<bool> {
        Ok(true)
    }
}

/// Multiplicative seasonality in the price index (CPI/RPI/HICP).
///
/// Port of `seasonality.hpp:119-167`. Factors come in whole multiples of the
/// count the frequency dictates - twelve for [`Monthly`](Frequency::Monthly) -
/// and are reused as long as needed, so twenty-four monthly factors repeat
/// every two years and twelve of them are stationary.
///
/// The factors are normalized against a reference date rather than used raw:
/// for a zero-coupon rate that is the curve's true base date, whose fixing is
/// known and whose factor must therefore divide out to one; for a year-on-year
/// rate it is always one year earlier.
///
/// Multi-year (non-stationary) factor sets are fragile: the corrections at
/// whole years either side of the curve's base date must match or the curve
/// contradicts its own quotes. See
/// [`is_consistent`](MultiplicativePriceSeasonality::is_consistent) for how
/// much of that check is ported.
#[derive(Debug)]
pub struct MultiplicativePriceSeasonality {
    seasonality_base_date: Date,
    frequency: Frequency,
    seasonality_factors: Vec<Rate>,
}

impl MultiplicativePriceSeasonality {
    /// The seasonality whose factors start at `seasonality_base_date` and step
    /// at `frequency` (`seasonality.cpp:91-95`).
    ///
    /// # Errors
    ///
    /// Rejects a frequency outside semiannual-through-daily, an empty factor
    /// set, and a factor count that is not a whole multiple of the frequency.
    pub fn new(
        seasonality_base_date: Date,
        frequency: Frequency,
        seasonality_factors: Vec<Rate>,
    ) -> QlResult<MultiplicativePriceSeasonality> {
        let seasonality = MultiplicativePriceSeasonality {
            seasonality_base_date,
            frequency,
            seasonality_factors,
        };
        seasonality.validate()?;
        Ok(seasonality)
    }

    /// Replaces the whole factor specification (`seasonality.cpp:97-108`),
    /// leaving the receiver untouched if the new one is rejected.
    ///
    /// # Errors
    ///
    /// As [`new`](Self::new).
    pub fn set(
        &mut self,
        seasonality_base_date: Date,
        frequency: Frequency,
        seasonality_factors: Vec<Rate>,
    ) -> QlResult<()> {
        *self = MultiplicativePriceSeasonality::new(
            seasonality_base_date,
            frequency,
            seasonality_factors,
        )?;
        Ok(())
    }

    /// The date the factor set is anchored on (`seasonality.cpp:110-112`).
    pub fn seasonality_base_date(&self) -> Date {
        self.seasonality_base_date
    }

    /// The frequency the factors step at (`seasonality.cpp:114-116`).
    pub fn frequency(&self) -> Frequency {
        self.frequency
    }

    /// The factors, in order from the seasonality base date
    /// (`seasonality.cpp:118-120`).
    pub fn seasonality_factors(&self) -> &[Rate] {
        &self.seasonality_factors
    }

    /// The factor covering `to`, normalized against nothing at all
    /// (`seasonality.cpp:145-188`).
    ///
    /// The offset from the seasonality base date is counted in whole factor
    /// periods and wrapped modulo the factor count, so a set shorter than the
    /// span repeats. For a month-based frequency the offset is *stepped*: the
    /// first guess is `31 * length` days per period and the loop then advances
    /// one period at a time until it lands inside `to`'s inflation period,
    /// which is not the same as advancing by the multiple in one go (month-end
    /// clamping is not associative).
    ///
    /// # Errors
    ///
    /// Rejects a year-based factor period, which cannot express seasonality.
    pub fn seasonality_factor(&self, to: Date) -> QlResult<Rate> {
        let from = self.seasonality_base_date;
        let factor_frequency = self.frequency;
        let n_factors = self.seasonality_factors.len();
        let factor_period = Period::try_from(factor_frequency)?;

        let which = if from == to {
            0
        } else {
            let diff_days = (to - from).abs();
            let dir: Integer = if from > to { -1 } else { 1 };
            let diff = match factor_period.units() {
                TimeUnit::Days => dir * diff_days,
                TimeUnit::Weeks => dir * (diff_days / 7),
                TimeUnit::Months => {
                    let lim = inflation_period(to, factor_frequency)?;
                    let mut steps = diff_days / (31 * factor_period.length());
                    let mut go = from + factor_period * (dir * steps);
                    while !(lim.0 <= go && go <= lim.1) {
                        go = go + factor_period * dir;
                        steps += 1;
                    }
                    dir * steps
                }
                TimeUnit::Years => fail!(
                    "seasonality period time unit is not allowed to be : {}",
                    factor_period.units()
                ),
                unit => fail!("Unknown time unit: {unit}"),
            };
            if dir == 1 {
                (diff as usize) % n_factors
            } else {
                (n_factors - ((-diff) as usize) % n_factors) % n_factors
            }
        };

        Ok(self.seasonality_factors[which])
    }

    /// The factor set as it applies to `frequency`, rejecting the frequencies
    /// QuantLib refuses (`seasonality.cpp:36-61`).
    fn validate(&self) -> QlResult<()> {
        match self.frequency {
            Frequency::Semiannual
            | Frequency::EveryFourthMonth
            | Frequency::Quarterly
            | Frequency::Bimonthly
            | Frequency::Monthly
            | Frequency::Biweekly
            | Frequency::Weekly
            | Frequency::Daily => {
                require!(
                    !self.seasonality_factors.is_empty(),
                    "no seasonality factors given"
                );
                let per_year = self.frequency as i16;
                require!(
                    self.seasonality_factors
                        .len()
                        .is_multiple_of(per_year as usize),
                    "For frequency {} require multiple of {per_year} factors {} were given.",
                    self.frequency,
                    self.seasonality_factors.len()
                );
                Ok(())
            }
            frequency => fail!(
                "bad frequency specified: {frequency}, \
                 only semi-annual through daily permitted."
            ),
        }
    }

    /// `rate` with the correction folded in (`seasonality.cpp:191-217`).
    ///
    /// Two factors are needed, not one: the raw factor at `at_date` divided by
    /// the one at the reference date, which is `curve_base_date` for a zero
    /// rate (whose fixing is known there, so the correction must be the
    /// identity) and one year earlier for a year-on-year rate. The zero path
    /// then spreads the ratio over the time from the curve base to the start
    /// of `at_date`'s inflation period.
    ///
    /// At the curve's own base date that time is zero and the ratio is exactly
    /// one, so `1.powf(inf)` returns one and the correction is the identity -
    /// the path every bootstrap takes on its first node. C++ has no guard
    /// there and neither does this.
    fn seasonality_correction(
        &self,
        rate: Rate,
        at_date: Date,
        day_counter: &DayCounter,
        curve_base_date: Date,
        is_zero_rate: bool,
    ) -> QlResult<Rate> {
        let factor_at = self.seasonality_factor(at_date)?;

        let f = if is_zero_rate {
            let factor_base = self.seasonality_factor(curve_base_date)?;
            let seasonality_at = factor_at / factor_base;
            let (period_start, _) = inflation_period(at_date, self.frequency)?;
            let time_from_curve_base = day_counter.year_fraction(curve_base_date, period_start);
            seasonality_at.powf(1.0 / time_from_curve_base)
        } else {
            let a_year_before = at_date - Period::new(1, TimeUnit::Years);
            factor_at / self.seasonality_factor(a_year_before)?
        };

        Ok((rate + 1.0) * f - 1.0)
    }
}

impl Seasonality for MultiplicativePriceSeasonality {
    /// The zero rate corrected against the curve's *true* base date
    /// (`seasonality.cpp:123-133`).
    ///
    /// The reference is `its.base_date()` itself, not the end of its inflation
    /// period, and `date` is quantized to the start of its own period, so this
    /// picks the same base date and effective fixing date
    /// [`ZeroInflationIndex::forecast_fixing`] does and the input seasonality
    /// adjustment is recovered by their ratio.
    ///
    /// [`ZeroInflationIndex::forecast_fixing`]: crate::indexes::inflationindex::ZeroInflationIndex
    fn correct_zero_rate(
        &self,
        date: Date,
        rate: Rate,
        its: &dyn InflationTermStructure,
    ) -> QlResult<Rate> {
        let curve_base_date = its.base_date();
        let (effective_fixing_date, _) = inflation_period(date, its.frequency())?;
        self.seasonality_correction(
            rate,
            effective_fixing_date,
            &its.require_day_counter()?,
            curve_base_date,
            true,
        )
    }

    /// The year-on-year rate corrected against one year earlier
    /// (`seasonality.cpp:136-142`).
    ///
    /// The reference here is the *end* of the base date's inflation period,
    /// and `date` reaches the correction unquantized. A stationary factor set
    /// leaves the rate alone: the factor a year earlier is the same one.
    fn correct_yoy_rate(
        &self,
        date: Date,
        rate: Rate,
        its: &dyn InflationTermStructure,
    ) -> QlResult<Rate> {
        let (_, curve_base_date) = inflation_period(its.base_date(), its.frequency())?;
        self.seasonality_correction(
            rate,
            date,
            &its.require_day_counter()?,
            curve_base_date,
            false,
        )
    }

    /// Consistency with the curve (`seasonality.cpp:64-88`), as far as it is
    /// ported.
    ///
    /// Daily seasonality is consistent by fiat: weekends, holidays and leap
    /// years make it otherwise never so (`seasonality.cpp:67-69`). A
    /// stationary set - one factor per period of the year - is consistent
    /// because it repeats exactly on whole years (`seasonality.cpp:70`).
    ///
    /// # Errors
    ///
    /// Any other (multi-year) factor count errors: the whole-year comparison
    /// loop that decides those (`seasonality.cpp:72-85`) is deferred to #807,
    /// and reporting consistency without running it would let an inconsistent
    /// curve through unnoticed.
    fn is_consistent(&self, _its: &dyn InflationTermStructure) -> QlResult<bool> {
        if self.frequency == Frequency::Daily {
            return Ok(true);
        }
        if (self.frequency as i16 as usize) == self.seasonality_factors.len() {
            return Ok(true);
        }
        fail!(
            "multi-year seasonality consistency check is not ported (#807): \
             {} factors at {} frequency",
            self.seasonality_factors.len(),
            self.frequency
        )
    }
}

#[cfg(test)]
mod tests {
    //! The factor set is anchored on 31 January 2007 while the curve's base
    //! date is 1 July 2007, so the two reference factors differ and the
    //! end-to-end zero correction discriminates which of them divides which.

    use super::*;
    use crate::math::interpolations::linear::Linear;
    use crate::termstructures::inflation::interpolatedzeroinflationcurve::ZeroInflationCurve;
    use crate::time::date::Month::{August, December, January, July};
    use crate::time::daycounters::thirty360::{Convention, Thirty360};

    /// Readable factors: the value names its own index.
    fn factors(count: usize) -> Vec<Rate> {
        (0..count).map(|i| 1.0 + i as Rate / 1000.0).collect()
    }

    fn seasonality_base_date() -> Date {
        Date::new(31, January, 2007)
    }

    fn curve_base_date() -> Date {
        Date::new(1, July, 2007)
    }

    fn monthly(count: usize) -> MultiplicativePriceSeasonality {
        MultiplicativePriceSeasonality::new(
            seasonality_base_date(),
            Frequency::Monthly,
            factors(count),
        )
        .expect("a whole multiple of twelve factors")
    }

    /// A carrier for the three properties a correction reads off a curve: the
    /// base date, the frequency and the day counter.
    fn a_curve() -> ZeroInflationCurve {
        ZeroInflationCurve::new(
            Date::new(13, August, 2007),
            vec![curve_base_date(), Date::new(13, August, 2012)],
            vec![0.02, 0.03],
            Frequency::Monthly,
            Thirty360::with_convention(Convention::BondBasis),
            Linear,
            None,
        )
        .expect("two sorted nodes and plausible rates")
    }

    /// The offset is counted in whole months from the seasonality base date
    /// and wraps modulo twelve, in both directions.
    ///
    /// The four dates are hand-counted off 31 January 2007: 1 July 2007 is six
    /// stepped months later, 1 August 2008 nineteen (so it wraps to seven), 1
    /// December 2006 one earlier (wrapping to eleven) and 1 December 2005
    /// thirteen earlier (wrapping past a whole year, to eleven again).
    #[test]
    fn the_factor_is_picked_by_stepped_month_offset_and_wraps_both_ways() {
        let seasonality = monthly(12);

        assert_eq!(
            seasonality
                .seasonality_factor(seasonality_base_date())
                .unwrap(),
            factors(12)[0]
        );
        assert_eq!(
            seasonality.seasonality_factor(curve_base_date()).unwrap(),
            factors(12)[6]
        );
        assert_eq!(
            seasonality
                .seasonality_factor(Date::new(1, August, 2008))
                .unwrap(),
            factors(12)[7]
        );
        assert_eq!(
            seasonality
                .seasonality_factor(Date::new(1, December, 2006))
                .unwrap(),
            factors(12)[11]
        );
        assert_eq!(
            seasonality
                .seasonality_factor(Date::new(1, December, 2005))
                .unwrap(),
            factors(12)[11]
        );
    }

    /// The year-on-year branch is the plain ratio of the factor at the date to
    /// the one a year earlier - `curve_base_date` never reaches it - and the
    /// zero branch spreads its ratio over the time from the curve base.
    #[test]
    fn the_correction_is_the_rate_grossed_up_by_the_factor_ratio() {
        let seasonality = monthly(24);
        let day_counter = Thirty360::with_convention(Convention::BondBasis);
        let at = Date::new(1, August, 2008);
        let rate = 0.03;

        let factor_at = seasonality.seasonality_factor(at).unwrap();
        let a_year_before = seasonality
            .seasonality_factor(Date::new(1, August, 2007))
            .unwrap();
        assert_ne!(factor_at, a_year_before, "a two-year factor set moves YoY");
        assert_eq!(
            seasonality
                .seasonality_correction(rate, at, &day_counter, Date::null(), false)
                .unwrap(),
            (rate + 1.0) * (factor_at / a_year_before) - 1.0
        );

        let factor_base = seasonality.seasonality_factor(curve_base_date()).unwrap();
        let f = (factor_at / factor_base).powf(1.0 / (390.0 / 360.0));
        assert_eq!(
            seasonality
                .seasonality_correction(rate, at, &day_counter, curve_base_date(), true)
                .unwrap(),
            (rate + 1.0) * f - 1.0
        );
    }

    /// The end-to-end zero correction off a real curve, pinned to a factor
    /// ratio and a year fraction computed by hand.
    ///
    /// The reference factor is the one at the curve's base date, 1 July 2007
    /// (index six), and the corrected factor the one at the start of the
    /// query's inflation period, 1 August 2008 (index seven). Dividing the
    /// other way round, or referencing the *end* of the base period
    /// (31 July 2007, index six as well but a different year fraction),
    /// changes the answer. `Thirty360(BondBasis)` from 1 July 2007 to 1 August
    /// 2008 is `(360 + 30) / 360`.
    #[test]
    fn the_zero_correction_normalizes_against_the_curves_true_base_date() {
        let seasonality = monthly(12);
        let curve = a_curve();
        let rate = 0.03;

        let expected_f = (factors(12)[7] / factors(12)[6]).powf(1.0 / (390.0 / 360.0));
        assert_eq!(
            seasonality
                .correct_zero_rate(Date::new(15, August, 2008), rate, &curve)
                .unwrap(),
            (rate + 1.0) * expected_f - 1.0
        );
        assert!(expected_f > 1.0, "the factor set rises over that span");
        assert_ne!(
            seasonality
                .correct_zero_rate(Date::new(15, August, 2008), rate, &curve)
                .unwrap(),
            rate,
            "the correction must move the rate at all"
        );
    }

    /// Stationary price seasonality leaves year-on-year rates untouched: the
    /// factor a year earlier is the very same one, so the ratio is exactly one
    /// and only the `(rate + 1) * 1 - 1` round trip separates the answer from
    /// the input.
    #[test]
    fn a_stationary_factor_set_does_not_move_a_year_on_year_rate() {
        let seasonality = monthly(12);
        let curve = a_curve();
        let at = Date::new(1, August, 2008);

        assert_eq!(
            seasonality.seasonality_factor(at).unwrap(),
            seasonality
                .seasonality_factor(Date::new(1, August, 2007))
                .unwrap()
        );
        let corrected = seasonality.correct_yoy_rate(at, 0.03, &curve).unwrap();
        assert!((corrected - 0.03).abs() < 1.0e-15, "moved to {corrected}");
    }

    /// Twelve monthly factors are consistent with any curve; twenty-four are
    /// the deferred multi-year branch; thirteen never build at all.
    #[test]
    fn consistency_covers_the_stationary_case_and_defers_the_multi_year_one() {
        let curve = a_curve();

        assert!(monthly(12).is_consistent(&curve).unwrap());
        let deferred = monthly(24).is_consistent(&curve).unwrap_err();
        assert!(deferred.message().contains("#807"));

        let rejected = MultiplicativePriceSeasonality::new(
            seasonality_base_date(),
            Frequency::Monthly,
            factors(13),
        )
        .unwrap_err();
        assert!(
            rejected
                .message()
                .contains("require multiple of 12 factors 13 were given")
        );
    }

    #[test]
    fn only_semiannual_through_daily_frequencies_are_accepted() {
        let annual = MultiplicativePriceSeasonality::new(
            seasonality_base_date(),
            Frequency::Annual,
            factors(1),
        )
        .unwrap_err();
        assert!(annual.message().contains("bad frequency specified"));

        let empty = MultiplicativePriceSeasonality::new(
            seasonality_base_date(),
            Frequency::Monthly,
            vec![],
        )
        .unwrap_err();
        assert!(empty.message().contains("no seasonality factors given"));
    }
}