Skip to main content

libitofin/termstructures/yields/
flatforward.rs

1//! Flat interest-rate curve.
2//!
3//! Port of `ql/termstructures/yield/flatforward.{hpp,cpp}`: a curve quoting
4//! one forward rate for every maturity, backed by a quote handle or a plain
5//! value.
6//!
7//! C++'s `LazyObject` half becomes a cached [`InterestRate`] invalidated by
8//! quote notifications: the curve's observer clears the cache *before*
9//! passing the notification on, so observers reading the curve during a
10//! notification wave see fresh values, whether the wave is quote-driven or
11//! (on a moving curve) evaluation-date-driven.
12//! The value-backed constructors wrap the rate in an unshared [`SimpleQuote`]
13//! like the C++ ones; the subscription they add is inert since nothing else
14//! can change that quote.
15
16use crate::errors::QlResult;
17use crate::handle::Handle;
18use crate::interestrate::{Compounding, InterestRate};
19use crate::patterns::observable::{AsObservable, Observable, Observer, ResetThenNotify};
20use crate::quotes::{Quote, SimpleQuote};
21use crate::settings::Settings;
22use crate::shared::{Shared, SharedMut, shared, shared_mut};
23use crate::termstructures::yieldtermstructure::YieldTermStructure;
24use crate::termstructures::{TermStructure, TermStructureBase};
25use crate::time::calendar::Calendar;
26use crate::time::date::Date;
27use crate::time::daycounter::DayCounter;
28use crate::time::frequency::Frequency;
29use crate::types::{DiscountFactor, Natural, Rate, Time};
30
31/// Flat interest-rate curve.
32pub struct FlatForward {
33    base: TermStructureBase,
34    forward: Handle<dyn Quote>,
35    compounding: Compounding,
36    frequency: Frequency,
37    rate: SharedMut<Option<InterestRate>>,
38    _listener: SharedMut<ResetThenNotify>,
39}
40
41impl FlatForward {
42    fn assemble(
43        base: TermStructureBase,
44        forward: Handle<dyn Quote>,
45        compounding: Compounding,
46        frequency: Frequency,
47    ) -> FlatForward {
48        let rate = shared_mut(None);
49        let listener = ResetThenNotify::delivering(base.updater(), {
50            let rate = SharedMut::clone(&rate);
51            move || {
52                rate.borrow_mut().take();
53            }
54        });
55        forward.register_observer(&(listener.clone() as SharedMut<dyn Observer>));
56        FlatForward {
57            base,
58            forward,
59            compounding,
60            frequency,
61            rate,
62            _listener: listener,
63        }
64    }
65
66    fn wrap(value: Rate) -> Handle<dyn Quote> {
67        Handle::new(shared(SimpleQuote::new(value)) as Shared<dyn Quote>)
68    }
69
70    /// Quote-backed curve with a fixed reference date.
71    pub fn new(
72        reference_date: Date,
73        forward: Handle<dyn Quote>,
74        day_counter: DayCounter,
75        compounding: Compounding,
76        frequency: Frequency,
77    ) -> FlatForward {
78        let base = TermStructureBase::with_reference_date(reference_date, None, Some(day_counter));
79        Self::assemble(base, forward, compounding, frequency)
80    }
81
82    /// Value-backed curve with a fixed reference date.
83    pub fn with_rate(
84        reference_date: Date,
85        forward: Rate,
86        day_counter: DayCounter,
87        compounding: Compounding,
88        frequency: Frequency,
89    ) -> FlatForward {
90        Self::new(
91            reference_date,
92            Self::wrap(forward),
93            day_counter,
94            compounding,
95            frequency,
96        )
97    }
98
99    /// Quote-backed curve whose reference date moves off the evaluation date.
100    pub fn moving(
101        settlement_days: Natural,
102        calendar: Calendar,
103        forward: Handle<dyn Quote>,
104        day_counter: DayCounter,
105        compounding: Compounding,
106        frequency: Frequency,
107        settings: Shared<Settings<Date>>,
108    ) -> FlatForward {
109        let base =
110            TermStructureBase::moving(settlement_days, calendar, Some(day_counter), settings);
111        Self::assemble(base, forward, compounding, frequency)
112    }
113
114    /// Value-backed curve whose reference date moves off the evaluation date.
115    pub fn moving_with_rate(
116        settlement_days: Natural,
117        calendar: Calendar,
118        forward: Rate,
119        day_counter: DayCounter,
120        compounding: Compounding,
121        frequency: Frequency,
122        settings: Shared<Settings<Date>>,
123    ) -> FlatForward {
124        Self::moving(
125            settlement_days,
126            calendar,
127            Self::wrap(forward),
128            day_counter,
129            compounding,
130            frequency,
131            settings,
132        )
133    }
134
135    /// The compounding convention of the quoted rate.
136    pub fn compounding(&self) -> Compounding {
137        self.compounding
138    }
139
140    /// The compounding frequency of the quoted rate.
141    pub fn compounding_frequency(&self) -> Frequency {
142        self.frequency
143    }
144
145    fn flat_rate(&self) -> QlResult<InterestRate> {
146        if let Some(rate) = self.rate.borrow().clone() {
147            return Ok(rate);
148        }
149        let value = self.forward.current_link()?.value()?;
150        let day_counter = self
151            .base
152            .day_counter()
153            .expect("a flat forward curve is constructed with a day counter");
154        let rate = InterestRate::new(value, day_counter, self.compounding, self.frequency)?;
155        *self.rate.borrow_mut() = Some(rate.clone());
156        Ok(rate)
157    }
158}
159
160impl AsObservable for FlatForward {
161    fn observable(&self) -> &Observable {
162        self.base.observable()
163    }
164}
165
166impl TermStructure for FlatForward {
167    fn base(&self) -> &TermStructureBase {
168        &self.base
169    }
170
171    fn max_date(&self) -> Date {
172        Date::max_date()
173    }
174}
175
176impl YieldTermStructure for FlatForward {
177    fn discount_impl(&self, t: Time) -> QlResult<DiscountFactor> {
178        self.flat_rate()?.discount_factor(t)
179    }
180}
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::shared::shared;
185    use crate::test_support::{Flag, as_observer};
186    use crate::time::calendars::target::Target;
187    use crate::time::date::Month;
188    use crate::time::daycounters::actual360::Actual360;
189
190    fn today() -> Date {
191        Date::new(17, Month::May, 1998)
192    }
193
194    #[test]
195    fn flat_curve_reproduces_the_continuous_discounts_european_options_use() {
196        let q = FlatForward::with_rate(
197            today(),
198            0.04,
199            Actual360::new(),
200            Compounding::Continuous,
201            Frequency::Annual,
202        );
203        let r = FlatForward::with_rate(
204            today(),
205            0.06,
206            Actual360::new(),
207            Compounding::Continuous,
208            Frequency::Annual,
209        );
210        for days in [90, 180, 360, 720] {
211            let t = Time::from(days) / 360.0;
212            let df_q = q.discount_date(today() + days, false).unwrap();
213            let df_r = r.discount_date(today() + days, false).unwrap();
214            assert!((df_q - (-0.04 * t).exp()).abs() < 1.0e-15);
215            assert!((df_r - (-0.06 * t).exp()).abs() < 1.0e-15);
216        }
217        assert_eq!(q.discount(0.0, false).unwrap(), 1.0);
218    }
219
220    #[test]
221    fn zero_and_forward_rates_are_flat_at_the_quoted_rate() {
222        let curve = FlatForward::with_rate(
223            today(),
224            0.06,
225            Actual360::new(),
226            Compounding::Continuous,
227            Frequency::Annual,
228        );
229        for t in [0.25, 1.0, 7.5] {
230            let zero = curve
231                .zero_rate(t, Compounding::Continuous, Frequency::Annual, false)
232                .unwrap();
233            assert!((zero.rate() - 0.06).abs() < 1.0e-12);
234        }
235        let forward = curve
236            .forward_rate(0.5, 2.5, Compounding::Continuous, Frequency::Annual, false)
237            .unwrap();
238        assert!((forward.rate() - 0.06).abs() < 1.0e-12);
239        let instantaneous = curve
240            .forward_rate(1.0, 1.0, Compounding::Continuous, Frequency::Annual, false)
241            .unwrap();
242        assert!((instantaneous.rate() - 0.06).abs() < 1.0e-9);
243    }
244
245    #[test]
246    fn compounded_quotes_discount_with_their_own_convention() {
247        let curve = FlatForward::with_rate(
248            today(),
249            0.06,
250            Actual360::new(),
251            Compounding::Compounded,
252            Frequency::Semiannual,
253        );
254        assert_eq!(curve.compounding(), Compounding::Compounded);
255        assert_eq!(curve.compounding_frequency(), Frequency::Semiannual);
256        let df = curve.discount(1.0, false).unwrap();
257        assert!((df - 1.0 / (1.0_f64 + 0.06 / 2.0).powi(2)).abs() < 1.0e-15);
258    }
259
260    #[test]
261    fn quote_change_notifies_observers_and_refreshes_the_rate() {
262        let quote = shared(SimpleQuote::new(0.05));
263        let curve = FlatForward::new(
264            today(),
265            Handle::new(quote.clone() as Shared<dyn Quote>),
266            Actual360::new(),
267            Compounding::Continuous,
268            Frequency::Annual,
269        );
270        assert!((curve.discount(2.0, false).unwrap() - (-0.10_f64).exp()).abs() < 1.0e-15);
271
272        let flag = Flag::new();
273        curve.observable().register_observer(&as_observer(&flag));
274        quote.set_value(0.07);
275
276        assert!(
277            Flag::is_up(&flag),
278            "quote change must reach curve observers"
279        );
280        assert!((curve.discount(2.0, false).unwrap() - (-0.14_f64).exp()).abs() < 1.0e-15);
281    }
282
283    #[test]
284    fn observers_reading_during_the_notification_see_the_fresh_rate() {
285        struct Reader {
286            curve: Shared<FlatForward>,
287            seen: SharedMut<Option<DiscountFactor>>,
288        }
289        impl Observer for Reader {
290            fn update(&mut self) {
291                *self.seen.borrow_mut() = Some(self.curve.discount(1.0, false).unwrap());
292            }
293        }
294
295        let quote = shared(SimpleQuote::new(0.05));
296        let curve = shared(FlatForward::new(
297            today(),
298            Handle::new(quote.clone() as Shared<dyn Quote>),
299            Actual360::new(),
300            Compounding::Continuous,
301            Frequency::Annual,
302        ));
303        curve.discount(1.0, false).unwrap();
304
305        let seen = shared_mut(None);
306        let reader = shared_mut(Reader {
307            curve: curve.clone(),
308            seen: SharedMut::clone(&seen),
309        });
310        curve
311            .observable()
312            .register_observer(&(reader.clone() as SharedMut<dyn Observer>));
313
314        quote.set_value(0.07);
315
316        let seen = seen.borrow().expect("reader must have been notified");
317        assert!(
318            (seen - (-0.07_f64).exp()).abs() < 1.0e-15,
319            "mid-notification read returned a stale discount ({seen})"
320        );
321    }
322
323    #[test]
324    fn relinking_the_handle_switches_the_curve_to_the_new_quote() {
325        let relinkable = crate::quotes::make_quote_handle(0.05);
326        let curve = FlatForward::new(
327            today(),
328            relinkable.handle(),
329            Actual360::new(),
330            Compounding::Continuous,
331            Frequency::Annual,
332        );
333        assert!((curve.discount(1.0, false).unwrap() - (-0.05_f64).exp()).abs() < 1.0e-15);
334
335        let flag = Flag::new();
336        curve.observable().register_observer(&as_observer(&flag));
337        relinkable.link_to(shared(SimpleQuote::new(0.08)));
338
339        assert!(Flag::is_up(&flag), "relink must reach curve observers");
340        assert!((curve.discount(1.0, false).unwrap() - (-0.08_f64).exp()).abs() < 1.0e-15);
341    }
342
343    #[test]
344    fn moving_curve_follows_the_evaluation_date() {
345        let settings = shared(Settings::new());
346        settings.set_evaluation_date(Date::new(15, Month::January, 2026));
347        let curve = FlatForward::moving_with_rate(
348            2,
349            Target::new(),
350            0.05,
351            Actual360::new(),
352            Compounding::Continuous,
353            Frequency::Annual,
354            settings.clone(),
355        );
356        assert_eq!(
357            curve.reference_date().unwrap(),
358            Date::new(19, Month::January, 2026)
359        );
360
361        let flag = Flag::new();
362        curve.observable().register_observer(&as_observer(&flag));
363        settings.set_evaluation_date(Date::new(16, Month::January, 2026));
364
365        assert!(Flag::is_up(&flag));
366        assert_eq!(
367            curve.reference_date().unwrap(),
368            Date::new(20, Month::January, 2026)
369        );
370        let df = curve
371            .discount_date(Date::new(20, Month::January, 2027), false)
372            .unwrap();
373        assert!((df - (-0.05_f64 * 365.0 / 360.0).exp()).abs() < 1.0e-15);
374    }
375
376    #[test]
377    fn quote_write_back_during_an_evaluation_date_wave_defers_instead_of_panicking() {
378        struct WriteBack {
379            quote: Shared<SimpleQuote>,
380            armed: bool,
381            notifications: usize,
382        }
383        impl Observer for WriteBack {
384            fn update(&mut self) {
385                self.notifications += 1;
386                if self.armed {
387                    self.armed = false;
388                    self.quote.set_value(0.09);
389                }
390            }
391        }
392
393        let settings = shared(Settings::new());
394        settings.set_evaluation_date(Date::new(15, Month::January, 2026));
395        let quote = shared(SimpleQuote::new(0.05));
396        let curve = FlatForward::moving(
397            2,
398            Target::new(),
399            Handle::new(quote.clone() as Shared<dyn Quote>),
400            Actual360::new(),
401            Compounding::Continuous,
402            Frequency::Annual,
403            settings.clone(),
404        );
405        curve.discount(1.0, false).unwrap();
406
407        let writer = shared_mut(WriteBack {
408            quote: quote.clone(),
409            armed: true,
410            notifications: 0,
411        });
412        curve
413            .observable()
414            .register_observer(&(writer.clone() as SharedMut<dyn Observer>));
415
416        settings.set_evaluation_date(Date::new(16, Month::January, 2026));
417
418        assert_eq!(
419            writer.borrow().notifications,
420            2,
421            "the write-back wave must re-notify curve observers exactly once more"
422        );
423        assert!((curve.discount(1.0, false).unwrap() - (-0.09_f64).exp()).abs() < 1.0e-15);
424    }
425
426    #[test]
427    fn empty_or_invalid_quotes_error_instead_of_pricing() {
428        let curve = FlatForward::new(
429            today(),
430            Handle::empty(),
431            Actual360::new(),
432            Compounding::Continuous,
433            Frequency::Annual,
434        );
435        assert!(curve.discount(1.0, false).is_err());
436
437        let unset = shared(SimpleQuote::default());
438        let curve = FlatForward::new(
439            today(),
440            Handle::new(unset as Shared<dyn Quote>),
441            Actual360::new(),
442            Compounding::Continuous,
443            Frequency::Annual,
444        );
445        assert!(curve.discount(1.0, false).is_err());
446    }
447}