libitofin 0.52.1

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
//! The compounding pricer for an overnight-indexed coupon.
//!
//! Port of `ql/cashflows/overnightindexedcouponpricer.{hpp,cpp}`, the
//! [`CompoundingOvernightIndexedCouponPricer`]. It compounds the daily overnight
//! fixings of an [`OvernightIndexedCoupon`] over the coupon's value-date
//! schedule (the [`OvernightSchedule`] this module also builds).
//!
//! ## The schedule
//!
//! [`OvernightSchedule`] is the coupon's rate-computation data
//! (`overnightindexedcoupon.cpp:92-179`): the value dates (business days
//! spanning the accrual, front-adjusted `Preceding` and back-adjusted
//! `Following`), the interest dates (the value dates with the true accrual start
//! and end pinned at the ends), the fixing dates (each period's value date, the
//! last excluded), and the daily accrual fractions `dt`. Both the coupon and its
//! pricer hold the one shared schedule.
//!
//! ## The bulk fixing read and the today border case (D11)
//!
//! `overnightindexedcouponpricer.cpp:96` bulk-reads `index->timeSeries()` and
//! indexes it directly, never calling `index->fixing(d)` for the already-fixed
//! part - so `enforcesTodaysHistoricFixings` (consulted only inside
//! `InterestRateIndex::fixing`) never applies inside the compounding loop. The
//! port reads each past fixing through [`Index::past_fixing`], the raw store read
//! that returns `None` on a miss (never enforcing, never forecasting). A past
//! fixing that is missing is an error; today's, when missing, deliberately falls
//! through to the forecast (`overnightindexedcouponpricer.cpp:141-156`) - which
//! is what pins `testCurrentCouponRate`. Only partial forward intervals call
//! [`Index::fixing`], so they still honor today's fixing enforcement.
//!
//! ## Telescoping
//!
//! `canApplyTelescopicFormula()` is true on the default path, so C++ replaces the
//! interior forward product with a discount ratio. This also bypasses today's
//! fixing enforcement and omits daily spread inside the telescoped range, as
//! QuantLib does. Partial first/last intervals use individual forward fixings
//! with daily spread and the usual fixing enforcement. Curve bounds cover the
//! full value-date interval, including the end of a partially accrued fixing.
//!
//! ## Divergences from QuantLib
//!
//! `swapletPrice`/`capletPrice`/`floorletPrice` and the caplet/floorlet rates
//! all `QL_FAIL` in C++ for this pricer; the port keeps only the ported surface
//! ([`swaplet_rate`](FloatingRateCouponPricer::swaplet_rate) and the concrete
//! [`average_rate`](Self::average_rate) / [`effective_spread`](Self::effective_spread)
//! / [`effective_index_fixing`](Self::effective_index_fixing)) and refuses the
//! rest. The `OvernightIndexedCouponPricer` base (its optionlet-volatility
//! members) is not ported; the default arithmetic pricer is implemented separately.
//! See the module
//! doc of [`overnightindexedcoupon`](super::overnightindexedcoupon).

use super::couponpricer::FloatingRateCouponPricer;
use super::floatingratecoupon::FloatingRateCoupon;
use crate::errors::QlResult;
use crate::indexes::iborindex::OvernightIndex;
use crate::indexes::index::Index;
use crate::indexes::interestrateindex::InterestRateIndex;
use crate::patterns::observable::{AsObservable, Observable};
use crate::shared::Shared;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::date::Date;
use crate::types::{Rate, Real, Spread, Time};
use crate::{fail, require};

/// The value-date schedule an [`OvernightIndexedCoupon`] compounds over.
///
/// Built by [`new`](Self::new) from the accrual `[start, end]` and the index's
/// fixing calendar and day counter. Held behind a [`Shared`] by both the coupon
/// (for its inspectors) and the pricer (for the compounding loop).
///
/// [`OvernightIndexedCoupon`]: super::overnightindexedcoupon::OvernightIndexedCoupon
pub struct OvernightSchedule {
    /// Value dates for the rates to be compounded (`valueDates_`).
    pub(crate) value_dates: Vec<Date>,
    /// Interest dates: the value dates with the accrual start/end pinned at the
    /// ends (`interestDates_`).
    pub(crate) interest_dates: Vec<Date>,
    /// Fixing dates for the rates to be compounded (`fixingDates_`).
    pub(crate) fixing_dates: Vec<Date>,
    /// Daily accrual (compounding) periods (`dt_`).
    pub(crate) dt: Vec<Time>,
}

impl OvernightSchedule {
    /// Builds the schedule over `[start_date, end_date]` on `index`'s fixing
    /// calendar (`overnightindexedcoupon.cpp:92-179`, the default path).
    ///
    /// The value dates are the business days from the `Preceding`-adjusted start
    /// to the `Following`-adjusted end; the interest dates copy them with the
    /// true accrual start and end pinned at the ends; the fixing dates are each
    /// period's value date (the last value date has no fixing, as the index's
    /// fixing days are zero); and each `dt` is the index day counter's fraction
    /// between successive interest dates. Lookback, lockout, observation shift,
    /// and telescopic value-date optimisation are not ported (see the coupon
    /// module doc).
    pub(crate) fn new(
        index: &OvernightIndex,
        start_date: Date,
        end_date: Date,
    ) -> QlResult<OvernightSchedule> {
        let fixing_calendar = index.fixing_calendar();
        let value_dates = fixing_calendar.business_day_list(
            fixing_calendar.adjust(start_date, BusinessDayConvention::Preceding),
            fixing_calendar.adjust(end_date, BusinessDayConvention::Following),
        );
        require!(value_dates.len() >= 2, "degenerate schedule");
        let n = value_dates.len() - 1;

        let mut interest_dates = value_dates.clone();
        interest_dates[0] = start_date;
        interest_dates[n] = end_date;

        let fixing_dates = value_dates[..n].to_vec();

        let day_counter = index.day_counter();
        let dt = (0..n)
            .map(|i| day_counter.year_fraction(interest_dates[i], interest_dates[i + 1]))
            .collect();

        Ok(OvernightSchedule {
            value_dates,
            interest_dates,
            fixing_dates,
            dt,
        })
    }

    /// The accrual end (last interest date), the date the swaplet rate is
    /// compounded to.
    pub(super) fn accrual_end(&self) -> Date {
        *self
            .interest_dates
            .last()
            .expect("an overnight schedule always has at least two interest dates")
    }
}

/// The number of fixings whose interest date falls before `date`
/// (`determineNumberOfFixings`): the lower bound of `date` in the interest dates
/// excluding the last.
pub(super) fn determine_number_of_fixings(interest_dates: &[Date], date: Date) -> usize {
    let searchable = &interest_dates[..interest_dates.len() - 1];
    searchable.partition_point(|&d| d < date)
}

/// Prices an [`OvernightIndexedCoupon`] by daily compounding
/// (`CompoundingOvernightIndexedCouponPricer`).
///
/// It captures at construction everything the compounding needs - the overnight
/// index, the shared [`OvernightSchedule`], and the coupon's gearing, spread and
/// spread-compounding flag - so [`swaplet_rate`](FloatingRateCouponPricer::swaplet_rate)
/// is a pure function of those and the current evaluation date, fixing store and
/// forwarding curve.
///
/// [`OvernightIndexedCoupon`]: super::overnightindexedcoupon::OvernightIndexedCoupon
pub struct CompoundingOvernightIndexedCouponPricer {
    index: Shared<OvernightIndex>,
    schedule: Shared<OvernightSchedule>,
    gearing: Real,
    spread: Spread,
    compound_spread_daily: bool,
    observable: Observable,
}

impl CompoundingOvernightIndexedCouponPricer {
    /// Builds a pricer capturing the coupon's compounding inputs.
    pub(crate) fn new(
        index: Shared<OvernightIndex>,
        schedule: Shared<OvernightSchedule>,
        gearing: Real,
        spread: Spread,
        compound_spread_daily: bool,
    ) -> CompoundingOvernightIndexedCouponPricer {
        CompoundingOvernightIndexedCouponPricer {
            index,
            schedule,
            gearing,
            spread,
            compound_spread_daily,
            observable: Observable::new(),
        }
    }

    /// The compounded rate accrued up to `date` (`averageRate`): gearing and
    /// spread folded in, the daily fixings compounded over the schedule up to
    /// `date`.
    pub fn average_rate(&self, date: Date) -> QlResult<Rate> {
        Ok(self.compute(date)?.0)
    }

    /// The coupon's spread, or its compounded contribution when applied daily.
    ///
    /// With daily compounding, the rate is
    /// `gearing * (effectiveIndexFixing + effectiveSpread)`; otherwise it is
    /// `gearing * effectiveIndexFixing + effectiveSpread`.
    pub fn effective_spread(&self) -> QlResult<Spread> {
        if !self.compound_spread_daily {
            return Ok(self.spread);
        }
        Ok(self.compute(self.schedule.accrual_end())?.1)
    }

    /// The index fixing that reproduces the coupon amount alongside
    /// [`effective_spread`](Self::effective_spread) (`effectiveIndexFixing`).
    pub fn effective_index_fixing(&self) -> QlResult<Rate> {
        Ok(self.compute(self.schedule.accrual_end())?.2)
    }

    /// The compounded rate, effective spread and effective index fixing at
    /// `date` (`CompoundingOvernightIndexedCouponPricer::compute`).
    fn compute(&self, date: Date) -> QlResult<(Rate, Spread, Rate)> {
        let index = &self.index;
        let today = match index.settings().evaluation_date() {
            Some(today) => today,
            None => fail!("no evaluation date set: an overnight coupon needs a reference date"),
        };
        let schedule = &self.schedule;
        let day_counter = index.day_counter();
        let coupon_spread = self.spread;
        let compound_spread_daily = self.compound_spread_daily;

        let n = determine_number_of_fixings(&schedule.interest_dates, date);

        let growth_factor = |fixing: Rate, idx: usize| -> (Real, Real) {
            let span = if date >= schedule.interest_dates[idx + 1] {
                schedule.dt[idx]
            } else {
                day_counter.year_fraction(schedule.interest_dates[idx], date)
            };
            let gf = 1.0 + fixing * span;
            let gf_spread = if compound_spread_daily {
                gf + coupon_spread * span
            } else {
                gf
            };
            (gf, gf_spread)
        };

        let mut compound_factor = 1.0;
        let mut compound_factor_without_spread = 1.0;
        let mut i = 0;

        while i < n && schedule.fixing_dates[i] < today {
            let fixing = match index.past_fixing(schedule.fixing_dates[i])? {
                Some(fixing) => fixing,
                None => fail!(
                    "Missing {} fixing for {:?}",
                    index.name(),
                    schedule.fixing_dates[i]
                ),
            };
            let (gf, gf_spread) = growth_factor(fixing, i);
            compound_factor_without_spread *= gf;
            compound_factor *= gf_spread;
            i += 1;
        }

        if i < n
            && schedule.fixing_dates[i] == today
            && let Some(fixing) = index.past_fixing(schedule.fixing_dates[i])?
        {
            let (gf, gf_spread) = growth_factor(fixing, i);
            compound_factor_without_spread *= gf;
            compound_factor *= gf_spread;
            i += 1;
        }

        if i < n {
            let curve_handle = index.forwarding_term_structure();
            require!(
                !curve_handle.is_empty(),
                "null term structure set to this instance of {}",
                index.name()
            );
            let curve = curve_handle.current_link()?;
            curve.check_range_date(schedule.value_dates[i], false)?;
            curve.check_range_date(schedule.value_dates[n], false)?;

            let telescopic_start = if i == 0 && schedule.value_dates[0] < schedule.interest_dates[0]
            {
                1
            } else {
                i
            };
            let telescopic_end = n - usize::from(schedule.value_dates[n] > date);
            if telescopic_start < telescopic_end {
                while i < telescopic_start {
                    let fixing = index.fixing(schedule.fixing_dates[i], false)?;
                    let (gf, gf_spread) = growth_factor(fixing, i);
                    compound_factor_without_spread *= gf;
                    compound_factor *= gf_spread;
                    i += 1;
                }
                let forward_growth = curve
                    .discount_date(schedule.value_dates[telescopic_start], false)?
                    / curve.discount_date(schedule.value_dates[telescopic_end], false)?;
                compound_factor *= forward_growth;
                compound_factor_without_spread *= forward_growth;
                i = telescopic_end;
            }
            while i < n {
                let fixing = index.fixing(schedule.fixing_dates[i], false)?;
                let (gf, gf_spread) = growth_factor(fixing, i);
                compound_factor_without_spread *= gf;
                compound_factor *= gf_spread;
                i += 1;
            }
        }

        let rate_accrual_end = date.min(schedule.interest_dates[n]);
        let tau = day_counter.year_fraction(schedule.interest_dates[0], rate_accrual_end);
        let rate = (compound_factor - 1.0) / tau;

        let mut swaplet_rate = self.gearing * rate;
        let effective_spread;
        let effective_index_fixing;
        if !compound_spread_daily {
            swaplet_rate += coupon_spread;
            effective_spread = coupon_spread;
            effective_index_fixing = rate;
        } else {
            effective_spread = rate - (compound_factor_without_spread - 1.0) / tau;
            effective_index_fixing = rate - effective_spread;
        }

        Ok((swaplet_rate, effective_spread, effective_index_fixing))
    }
}

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

impl FloatingRateCouponPricer for CompoundingOvernightIndexedCouponPricer {
    fn initialize(&mut self, _coupon: &FloatingRateCoupon) {}

    fn swaplet_rate(&self) -> QlResult<Rate> {
        Ok(self.compute(self.schedule.accrual_end())?.0)
    }

    fn swaplet_rate_for(&self, _index_fixing: QlResult<Rate>) -> QlResult<Rate> {
        fail!(
            "swaplet_rate_for not applicable: the overnight compounding pricer reads the whole daily schedule"
        )
    }

    fn caplet_rate(&self, _effective_cap: Rate, _forward: QlResult<Rate>) -> QlResult<Rate> {
        fail!("caplet rate not ported: overnight cap/floor slice")
    }

    fn floorlet_rate(&self, _effective_floor: Rate, _forward: QlResult<Rate>) -> QlResult<Rate> {
        fail!("floorlet rate not ported: overnight cap/floor slice")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::handle::Handle;
    use crate::indexes::ibor::Sofr;
    use crate::settings::Settings;
    use crate::shared::{shared, shared_mut};
    use crate::time::date::Month;

    fn sofr() -> Shared<OvernightIndex> {
        let settings = shared(Settings::<Date>::new());
        settings.set_evaluation_date(Date::new(23, Month::November, 2021));
        shared(Sofr::new(Handle::empty(), settings))
    }

    fn schedule(index: &OvernightIndex) -> Shared<OvernightSchedule> {
        shared(
            OvernightSchedule::new(
                index,
                Date::new(18, Month::October, 2021),
                Date::new(18, Month::November, 2021),
            )
            .unwrap(),
        )
    }

    /// `OvernightSchedule::new` (overnightindexedcoupon.cpp:92-179, default path):
    /// one more value date than fixing date, the true accrual start and end pinned
    /// at the interest-date ends, and one accrual fraction per fixing.
    #[test]
    fn the_schedule_has_consistent_lengths_and_endpoints() {
        let index = sofr();
        let start = Date::new(18, Month::October, 2021);
        let end = Date::new(18, Month::November, 2021);
        let schedule = OvernightSchedule::new(&index, start, end).unwrap();

        assert!(schedule.value_dates.len() >= 2);
        assert_eq!(schedule.fixing_dates.len(), schedule.value_dates.len() - 1);
        assert_eq!(schedule.dt.len(), schedule.fixing_dates.len());
        assert_eq!(schedule.interest_dates.len(), schedule.value_dates.len());
        assert_eq!(*schedule.interest_dates.first().unwrap(), start);
        assert_eq!(*schedule.interest_dates.last().unwrap(), end);
    }

    /// A start date after the end date has no business days between: a degenerate
    /// schedule is refused.
    #[test]
    fn a_degenerate_schedule_is_refused() {
        let index = sofr();
        let day = Date::new(18, Month::October, 2021);
        assert!(OvernightSchedule::new(&index, day, day).is_err());
    }

    /// The compounding pricer prices only the swaplet path; the caplet, floorlet
    /// and per-fixing entry points refuse (the cap/floor slice is not ported).
    #[test]
    fn the_pricer_refuses_the_unported_entry_points() {
        let index = sofr();
        let schedule = schedule(&index);
        let pricer = shared_mut(CompoundingOvernightIndexedCouponPricer::new(
            index, schedule, 1.0, 0.0, false,
        ));
        let pricer = pricer.borrow();
        assert!(pricer.caplet_rate(0.03, Ok(0.01)).is_err());
        assert!(pricer.floorlet_rate(0.01, Ok(0.01)).is_err());
        assert!(pricer.swaplet_rate_for(Ok(0.01)).is_err());
    }
}