Skip to main content

libitofin/termstructures/credit/
flathazardrate.rs

1//! Flat hazard-rate curve.
2//!
3//! Port of `ql/termstructures/credit/flathazardrate.{hpp,cpp}`: a credit curve
4//! quoting one hazard rate for every maturity, backed by a quote handle or a
5//! plain value, with the closed-form survival probability `exp(-h t)`
6//! (`flathazardrate.hpp:72-74`).
7//!
8//! ## Divergences from QuantLib
9//!
10//! - The C++ constructors register with the quote only on the two
11//!   `Handle<Quote>` overloads (`flathazardrate.cpp:32,47`); the two `Rate`
12//!   overloads wrap the value in a fresh `SimpleQuote` and skip the
13//!   registration (`flathazardrate.cpp:39,55`). This port registers uniformly
14//!   for all four, keeping the idiom of its direct sibling
15//!   [`FlatForward`](crate::termstructures::yields::FlatForward). The C++ skip
16//!   is an unobservable optimization rather than a behaviour: the wrapped quote
17//!   is private and unreachable, so nothing can ever call `set_value` on it and
18//!   the subscription can never fire.
19//! - Unlike [`FlatForward`](crate::termstructures::yields::FlatForward) this
20//!   curve caches nothing, since C++ reads the quote live on every
21//!   `hazardRateImpl` (`flathazardrate.hpp:59`). The subscription therefore
22//!   resets no state and exists only to forward quote notifications to the
23//!   structure's own observers, which a consumer caching off this curve needs.
24//! - The moving constructors take an explicit
25//!   [`Settings`] handle rather than reading a global evaluation date (D5), so
26//!   [`reference_date`](TermStructure::reference_date) returns an `Err` when no
27//!   evaluation date is set instead of falling back to the system clock.
28
29use crate::errors::QlResult;
30use crate::handle::Handle;
31use crate::patterns::observable::{AsObservable, Observable, Observer, ResetThenNotify};
32use crate::quotes::{Quote, SimpleQuote};
33use crate::settings::Settings;
34use crate::shared::{Shared, SharedMut, shared};
35use crate::termstructures::credit::defaulttermstructure::DefaultProbabilityTermStructure;
36use crate::termstructures::credit::hazardratestructure::HazardRateStructure;
37use crate::termstructures::{TermStructure, TermStructureBase};
38use crate::time::calendar::Calendar;
39use crate::time::date::Date;
40use crate::time::daycounter::DayCounter;
41use crate::types::{Natural, Probability, Rate, Real, Time};
42
43/// Flat hazard-rate curve.
44pub struct FlatHazardRate {
45    base: TermStructureBase,
46    hazard_rate: Handle<dyn Quote>,
47    _listener: SharedMut<ResetThenNotify>,
48}
49
50impl FlatHazardRate {
51    fn assemble(base: TermStructureBase, hazard_rate: Handle<dyn Quote>) -> FlatHazardRate {
52        let listener = ResetThenNotify::delivering(base.updater(), || {});
53        hazard_rate.register_observer(&(listener.clone() as SharedMut<dyn Observer>));
54        FlatHazardRate {
55            base,
56            hazard_rate,
57            _listener: listener,
58        }
59    }
60
61    fn wrap(value: Rate) -> Handle<dyn Quote> {
62        Handle::new(shared(SimpleQuote::new(value)) as Shared<dyn Quote>)
63    }
64
65    /// Quote-backed curve with a fixed reference date.
66    pub fn new(
67        reference_date: Date,
68        hazard_rate: Handle<dyn Quote>,
69        day_counter: DayCounter,
70    ) -> FlatHazardRate {
71        let base = TermStructureBase::with_reference_date(reference_date, None, Some(day_counter));
72        Self::assemble(base, hazard_rate)
73    }
74
75    /// Value-backed curve with a fixed reference date.
76    pub fn with_rate(
77        reference_date: Date,
78        hazard_rate: Rate,
79        day_counter: DayCounter,
80    ) -> FlatHazardRate {
81        Self::new(reference_date, Self::wrap(hazard_rate), day_counter)
82    }
83
84    /// Quote-backed curve whose reference date moves off the evaluation date.
85    pub fn moving(
86        settlement_days: Natural,
87        calendar: Calendar,
88        hazard_rate: Handle<dyn Quote>,
89        day_counter: DayCounter,
90        settings: Shared<Settings<Date>>,
91    ) -> FlatHazardRate {
92        let base =
93            TermStructureBase::moving(settlement_days, calendar, Some(day_counter), settings);
94        Self::assemble(base, hazard_rate)
95    }
96
97    /// Value-backed curve whose reference date moves off the evaluation date.
98    pub fn moving_with_rate(
99        settlement_days: Natural,
100        calendar: Calendar,
101        hazard_rate: Rate,
102        day_counter: DayCounter,
103        settings: Shared<Settings<Date>>,
104    ) -> FlatHazardRate {
105        Self::moving(
106            settlement_days,
107            calendar,
108            Self::wrap(hazard_rate),
109            day_counter,
110            settings,
111        )
112    }
113
114    fn hazard_rate_value(&self) -> QlResult<Rate> {
115        self.hazard_rate.current_link()?.value()
116    }
117}
118
119impl AsObservable for FlatHazardRate {
120    fn observable(&self) -> &Observable {
121        self.base.observable()
122    }
123}
124
125impl TermStructure for FlatHazardRate {
126    fn base(&self) -> &TermStructureBase {
127        &self.base
128    }
129
130    fn max_date(&self) -> Date {
131        Date::max_date()
132    }
133}
134
135impl HazardRateStructure for FlatHazardRate {
136    fn hazard_rate_curve_impl(&self, _t: Time) -> QlResult<Rate> {
137        self.hazard_rate_value()
138    }
139}
140
141impl DefaultProbabilityTermStructure for FlatHazardRate {
142    fn survival_probability_impl(&self, t: Time) -> QlResult<Probability> {
143        Ok((-self.hazard_rate_value()? * t).exp())
144    }
145
146    fn default_density_impl(&self, t: Time) -> QlResult<Real> {
147        self.default_density_from_hazard_rate(t)
148    }
149
150    fn hazard_rate_impl(&self, t: Time) -> QlResult<Rate> {
151        self.hazard_rate_curve_impl(t)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::test_support::{Flag, as_observer};
159    use crate::time::businessdayconvention::BusinessDayConvention;
160    use crate::time::calendars::target::Target;
161    use crate::time::date::Month;
162    use crate::time::daycounters::actual360::Actual360;
163    use crate::time::timeunit::TimeUnit;
164
165    const HAZARD_RATE: Rate = 0.0100;
166    const TOLERANCE: Real = 1.0e-10;
167    const N: usize = 20;
168
169    fn today() -> Date {
170        Date::new(15, Month::June, 2026)
171    }
172
173    fn handle(quote: &Shared<SimpleQuote>) -> Handle<dyn Quote> {
174        Handle::new(quote.clone() as Shared<dyn Quote>)
175    }
176
177    /// C++ `calendar.advance(d, 1, Years)`, whose defaults are `Following` and
178    /// `endOfMonth = false` (`ql/time/calendar.hpp:146-150`).
179    fn one_year_on(calendar: &Calendar, d: Date) -> Date {
180        calendar.advance(
181            d,
182            1,
183            TimeUnit::Years,
184            BusinessDayConvention::Following,
185            false,
186        )
187    }
188
189    /// `testFlatHazardRate` (`defaultprobabilitycurves.cpp:118-149`): the
190    /// default probability is `1 - exp(-h t)` at twenty annual maturities.
191    ///
192    /// C++ measures every `t` from `startDate`, which is pinned to `today` at
193    /// :131 and never reassigned inside the loop, while `endDate` walks forward
194    /// cumulatively.
195    #[test]
196    fn flat_hazard_rate_reproduces_the_closed_form_default_probability() {
197        let quote = shared(SimpleQuote::new(HAZARD_RATE));
198        let day_counter = Actual360::new();
199        let calendar = Target::new();
200        let start_date = today();
201        let curve = FlatHazardRate::new(today(), handle(&quote), day_counter.clone());
202
203        let mut end_date = start_date;
204        for _ in 0..N {
205            end_date = one_year_on(&calendar, end_date);
206            let t = day_counter.year_fraction(start_date, end_date);
207            let probability = 1.0 - (-HAZARD_RATE * t).exp();
208            let computed = curve.default_probability(t, false).unwrap();
209            assert!(
210                (probability - computed).abs() <= TOLERANCE,
211                "failed to reproduce probability for flat hazard rate at t = {t}: \
212                 calculated {computed}, expected {probability}"
213            );
214        }
215    }
216
217    /// `testDefaultProbability` (`defaultprobabilitycurves.cpp:55-116`): the
218    /// two-argument default probability is the difference of the one-argument
219    /// ones, and the time-argument overloads agree with the date-argument ones.
220    #[test]
221    fn default_probabilities_are_self_consistent_across_dates_and_times() {
222        let quote = shared(SimpleQuote::new(HAZARD_RATE));
223        let day_counter = Actual360::new();
224        let calendar = Target::new();
225        let curve = FlatHazardRate::new(today(), handle(&quote), day_counter.clone());
226
227        let mut end_date = today();
228        for _ in 0..N {
229            let start_date = end_date;
230            end_date = one_year_on(&calendar, end_date);
231
232            let p_start = curve.default_probability_date(start_date, false).unwrap();
233            let p_end = curve.default_probability_date(end_date, false).unwrap();
234            let p_between_computed = curve
235                .default_probability_between_dates(start_date, end_date, false)
236                .unwrap();
237            let p_between = p_end - p_start;
238            assert!(
239                (p_between - p_between_computed).abs() <= TOLERANCE,
240                "failed to reproduce probability(d1, d2): \
241                 calculated {p_between_computed}, expected {p_between}"
242            );
243
244            let t2 = day_counter.year_fraction(today(), end_date);
245            let time_probability = curve.default_probability(t2, false).unwrap();
246            assert!(
247                (time_probability - p_end).abs() <= TOLERANCE,
248                "single-time probability {time_probability} and single-date \
249                 probability {p_end} do not match"
250            );
251
252            let t1 = day_counter.year_fraction(today(), start_date);
253            let time_probability = curve.default_probability_between(t1, t2, false).unwrap();
254            assert!(
255                (time_probability - p_between_computed).abs() <= TOLERANCE,
256                "double-time probability {time_probability} and double-date \
257                 probability {p_between_computed} do not match"
258            );
259        }
260    }
261
262    /// Neither ported oracle reads the density or the hazard rate, so the
263    /// closed forms behind `hazardRateImpl` (`flathazardrate.hpp:59`) and the
264    /// adapter's `h(t) S(t)` are pinned directly.
265    #[test]
266    fn density_and_hazard_rate_match_their_closed_forms() {
267        let curve = FlatHazardRate::with_rate(today(), HAZARD_RATE, Actual360::new());
268        for t in [0.0_f64, 0.5, 1.0, 5.0, 20.0] {
269            let survival = (-HAZARD_RATE * t).exp();
270            assert!((curve.survival_probability(t, false).unwrap() - survival).abs() <= TOLERANCE);
271            assert!(
272                (curve.default_density(t, false).unwrap() - HAZARD_RATE * survival).abs()
273                    <= TOLERANCE
274            );
275            assert!((curve.hazard_rate(t, false).unwrap() - HAZARD_RATE).abs() <= TOLERANCE);
276        }
277    }
278
279    #[test]
280    fn quote_change_notifies_observers_and_refreshes_the_curve() {
281        let quote = shared(SimpleQuote::new(HAZARD_RATE));
282        let curve = FlatHazardRate::new(today(), handle(&quote), Actual360::new());
283        assert!(
284            (curve.survival_probability(2.0, false).unwrap() - (-0.02_f64).exp()).abs()
285                <= TOLERANCE
286        );
287
288        let flag = Flag::new();
289        curve.observable().register_observer(&as_observer(&flag));
290        quote.set_value(0.0200);
291
292        assert!(
293            Flag::is_up(&flag),
294            "quote change must reach curve observers"
295        );
296        assert!(
297            (curve.survival_probability(2.0, false).unwrap() - (-0.04_f64).exp()).abs()
298                <= TOLERANCE
299        );
300        assert!((curve.hazard_rate(1.0, false).unwrap() - 0.0200).abs() <= TOLERANCE);
301    }
302
303    #[test]
304    fn moving_curve_follows_the_evaluation_date() {
305        let settings = shared(Settings::new());
306        settings.set_evaluation_date(Date::new(15, Month::January, 2026));
307        let curve = FlatHazardRate::moving_with_rate(
308            2,
309            Target::new(),
310            HAZARD_RATE,
311            Actual360::new(),
312            settings.clone(),
313        );
314        assert_eq!(
315            curve.reference_date().unwrap(),
316            Date::new(19, Month::January, 2026)
317        );
318
319        let flag = Flag::new();
320        curve.observable().register_observer(&as_observer(&flag));
321        settings.set_evaluation_date(Date::new(16, Month::January, 2026));
322
323        assert!(Flag::is_up(&flag));
324        assert_eq!(
325            curve.reference_date().unwrap(),
326            Date::new(20, Month::January, 2026)
327        );
328        let survival = curve
329            .survival_probability_date(Date::new(20, Month::January, 2027), false)
330            .unwrap();
331        assert!((survival - (-HAZARD_RATE * 365.0 / 360.0).exp()).abs() <= TOLERANCE);
332    }
333
334    #[test]
335    fn empty_or_unset_quotes_error_instead_of_pricing() {
336        let curve = FlatHazardRate::new(today(), Handle::empty(), Actual360::new());
337        assert!(curve.survival_probability(1.0, false).is_err());
338        assert!(curve.hazard_rate(1.0, false).is_err());
339
340        let unset = shared(SimpleQuote::default());
341        let curve = FlatHazardRate::new(
342            today(),
343            Handle::new(unset as Shared<dyn Quote>),
344            Actual360::new(),
345        );
346        assert!(curve.default_density(1.0, false).is_err());
347    }
348}