Skip to main content

libitofin/termstructures/credit/
defaultprobabilityhelpers.rs

1//! Bootstrap helpers for default-probability term structures.
2//!
3//! Port of the two typedefs at the head of
4//! `ql/termstructures/credit/defaultprobabilityhelpers.hpp:41-44`:
5//! `DefaultProbabilityHelper` is `BootstrapHelper<DefaultProbabilityTermStructure>`
6//! and `RelativeDateDefaultProbabilityHelper` is
7//! `RelativeDateBootstrapHelper<DefaultProbabilityTermStructure>`. They are the
8//! credit twins of
9//! [`RateHelper`](crate::termstructures::bootstraphelper::RateHelper) and
10//! [`RelativeDateRateHelper`](crate::termstructures::bootstraphelper::RelativeDateRateHelper),
11//! and they carry no behaviour of their own: everything is inherited from the
12//! shared [`BootstrapHelperBase`], instantiated here over
13//! [`DefaultProbabilityTermStructure`] instead of the yield curve.
14//!
15//! Where C++ gets both families from one class template, this port needs two
16//! traits, because the yield layer is typed on the bare `dyn RateHelper` object
17//! and a trait generic over its term structure cannot be made into one. The
18//! shared driver reaches both through [`BootstrapHelperShared`], implemented
19//! below on `dyn DefaultProbabilityHelper`.
20//!
21//! [`SpreadCdsHelper`] follows them: the running-spread CDS helper the credit
22//! bootstrap is driven by. `UpfrontCdsHelper` (`defaultprobabilityhelpers.hpp:170`)
23//! is not here yet and follows within EPIC Credit (#676).
24
25use std::cell::{Cell, RefCell};
26use std::rc::Weak;
27
28use crate::errors::{QlError, QlResult};
29use crate::handle::{Handle, RelinkableHandle};
30use crate::instrument::Instrument;
31use crate::instruments::{CdsTerms, CreditDefaultSwap, ProtectionSide};
32use crate::patterns::observable::{AsObservable, Observable};
33use crate::pricingengine::PricingEngine;
34use crate::pricingengines::credit::MidPointCdsEngine;
35use crate::quotes::Quote;
36use crate::require;
37use crate::settings::Settings;
38use crate::shared::{Shared, SharedMut, shared_mut};
39use crate::termstructures::bootstraphelper::{BootstrapHelperBase, BootstrapHelperShared};
40use crate::termstructures::credit::defaulttermstructure::DefaultProbabilityTermStructure;
41use crate::termstructures::yieldtermstructure::YieldTermStructure;
42use crate::time::businessdayconvention::BusinessDayConvention;
43use crate::time::calendar::Calendar;
44use crate::time::date::Date;
45use crate::time::dategenerationrule::DateGeneration;
46use crate::time::daycounter::DayCounter;
47use crate::time::frequency::Frequency;
48use crate::time::period::Period;
49use crate::time::schedule::{MakeSchedule, Schedule};
50use crate::types::{Integer, Real};
51
52/// The shared state of a credit bootstrap helper: a
53/// [`BootstrapHelperBase`] whose back-pointer is a default-probability curve.
54pub type DefaultProbabilityHelperBase = BootstrapHelperBase<dyn DefaultProbabilityTermStructure>;
55
56/// Bootstrap helper for the credit-curve bootstrap
57/// (`DefaultProbabilityHelper`).
58///
59/// Mirrors [`RateHelper`](crate::termstructures::bootstraphelper::RateHelper)
60/// exactly, over [`DefaultProbabilityTermStructure`]: a concrete helper embeds
61/// a [`DefaultProbabilityHelperBase`], returns it from [`base`](Self::base) and
62/// supplies [`implied_quote`](Self::implied_quote); the rest of the interface is
63/// derived from the base. The same ownership contract holds - the curve is held
64/// [`Weak`](std::rc::Weak) and never observed - since it is the one base that
65/// enforces it.
66pub trait DefaultProbabilityHelper: AsObservable {
67    /// The embedded shared state.
68    fn base(&self) -> &DefaultProbabilityHelperBase;
69
70    /// The quote implied by the current curve, computed by the concrete helper.
71    ///
72    /// The helper does not observe the curve, so this must force any
73    /// recalculation it needs itself rather than trusting a cached value.
74    fn implied_quote(&self) -> QlResult<Real>;
75
76    /// The market quote the helper fits the curve to.
77    fn quote(&self) -> &Handle<dyn Quote> {
78        self.base().quote()
79    }
80
81    /// The bootstrap's root: market quote minus implied quote, driven to zero.
82    fn quote_error(&self) -> QlResult<Real> {
83        Ok(self.base().quote_value()? - self.implied_quote()?)
84    }
85
86    /// Sets the curve being bootstrapped (non-owning, unobserved).
87    ///
88    /// A concrete helper that hands the curve to a pricing engine overrides
89    /// this to relink that handle first, then delegates here.
90    fn set_term_structure(&self, term_structure: &Shared<dyn DefaultProbabilityTermStructure>) {
91        self.base().set_term_structure(term_structure);
92    }
93
94    /// The earliest date data are needed at.
95    fn earliest_date(&self) -> Date {
96        self.base().earliest_date()
97    }
98
99    /// The instrument's maturity date.
100    fn maturity_date(&self) -> Date {
101        self.base().maturity_date()
102    }
103
104    /// The latest date data are needed at.
105    fn latest_relevant_date(&self) -> Date {
106        self.base().latest_relevant_date()
107    }
108
109    /// The pillar date, at which the curve node this helper sets sits.
110    fn pillar_date(&self) -> Date {
111        self.base().pillar_date()
112    }
113
114    /// The latest date, equal to the pillar date.
115    fn latest_date(&self) -> Date {
116        self.base().latest_date()
117    }
118}
119
120/// Credit bootstrap helper whose date schedule is relative to the evaluation
121/// date (`RelativeDateDefaultProbabilityHelper`).
122///
123/// `CdsHelper` derives from this: a CDS schedule is rebuilt whenever the
124/// evaluation date moves. The concrete helper builds its base with
125/// [`BootstrapHelperBase::new_relative`], passing a closure that calls
126/// [`initialize_dates`](Self::initialize_dates).
127pub trait RelativeDateDefaultProbabilityHelper: DefaultProbabilityHelper {
128    /// Rebuilds the helper's date schedule off the current evaluation date.
129    fn initialize_dates(&self);
130}
131
132/// The credit half of the driver bound. Like the yield impl, every method
133/// routes through the [`DefaultProbabilityHelper`] trait rather than straight
134/// to the base, so a concrete helper's override still runs.
135impl BootstrapHelperShared for dyn DefaultProbabilityHelper {
136    type TS = dyn DefaultProbabilityTermStructure;
137
138    fn set_term_structure(&self, term_structure: &Shared<dyn DefaultProbabilityTermStructure>) {
139        DefaultProbabilityHelper::set_term_structure(self, term_structure);
140    }
141
142    fn quote_value(&self) -> QlResult<Real> {
143        self.base().quote_value()
144    }
145
146    fn quote_error(&self) -> QlResult<Real> {
147        DefaultProbabilityHelper::quote_error(self)
148    }
149
150    fn pillar_date(&self) -> Date {
151        DefaultProbabilityHelper::pillar_date(self)
152    }
153
154    fn latest_relevant_date(&self) -> Date {
155        DefaultProbabilityHelper::latest_relevant_date(self)
156    }
157
158    fn maturity_date(&self) -> Date {
159        DefaultProbabilityHelper::maturity_date(self)
160    }
161}
162
163/// The terms a [`SpreadCdsHelper`] defaults when they are not quoted.
164///
165/// One field per defaulted argument of the C++ `CdsHelper` constructor
166/// (`defaultprobabilityhelpers.hpp:90-94`); [`Default`] carries their C++
167/// values. The `model` argument has no field: only
168/// [`Midpoint`](crate::pricingengines::credit::MidPointCdsEngine) is ported, the
169/// ISDA arm of `resetEngine` (`defaultprobabilityhelpers.cpp:143-148`) staying
170/// deferred within EPIC Credit (#676).
171pub struct CdsHelperTerms {
172    /// Whether the accrued coupon is due on a default.
173    pub settles_accrual: bool,
174    /// Whether a default pays at default time rather than at the end of the
175    /// accrual period.
176    pub pays_at_default_time: bool,
177    /// An explicit schedule start, for an off-the-run contract; the protection
178    /// start when absent.
179    pub start_date: Option<Date>,
180    /// The day counter the last coupon accrues with, overriding the spread's.
181    pub last_period_day_counter: Option<DayCounter>,
182    /// Whether the protection seller rebates the accrued current coupon.
183    pub rebates_accrual: bool,
184}
185
186impl Default for CdsHelperTerms {
187    fn default() -> CdsHelperTerms {
188        CdsHelperTerms {
189            settles_accrual: true,
190            pays_at_default_time: true,
191            start_date: None,
192            last_period_day_counter: None,
193            rebates_accrual: true,
194        }
195    }
196}
197
198/// A spread-quoted CDS as a credit bootstrap helper (`SpreadCdsHelper`,
199/// `defaultprobabilityhelpers.hpp:128`).
200///
201/// The helper prices a par CDS on its own schedule against the curve being
202/// bootstrapped and reports that contract's fair spread as
203/// [`implied_quote`](DefaultProbabilityHelper::implied_quote); the bootstrap
204/// drives `quoted spread - fair spread` to zero.
205///
206/// The C++ `CdsHelper` base (`defaultprobabilityhelpers.hpp:47-126`) has no
207/// separate type here. It exists in C++ only to share state and
208/// `initializeDates` with `UpfrontCdsHelper`, and that sibling is deferred
209/// (#676), so its state and `initializeDates` live directly in this struct.
210/// Porting `UpfrontCdsHelper` means factoring out the fields below plus
211/// [`initialize_dates`](RelativeDateDefaultProbabilityHelper::initialize_dates)
212/// and [`set_term_structure`](DefaultProbabilityHelper::set_term_structure),
213/// leaving only `reset_engine` and `implied_quote` per subclass.
214pub struct SpreadCdsHelper {
215    base: DefaultProbabilityHelperBase,
216    tenor: Period,
217    settlement_days: Integer,
218    calendar: Calendar,
219    frequency: Frequency,
220    payment_convention: BusinessDayConvention,
221    rule: DateGeneration,
222    day_counter: DayCounter,
223    recovery_rate: Real,
224    discount_curve: Handle<dyn YieldTermStructure>,
225    settles_accrual: bool,
226    pays_at_default_time: bool,
227    start_date: Option<Date>,
228    last_period_day_counter: Option<DayCounter>,
229    rebates_accrual: bool,
230    settings: Shared<Settings<Date>>,
231    schedule: RefCell<Schedule>,
232    protection_start: Cell<Date>,
233    probability: RelinkableHandle<dyn DefaultProbabilityTermStructure>,
234    swap: RefCell<QlResult<CreditDefaultSwap>>,
235}
236
237/// The state the helper is in before a curve has been handed to it: C++ leaves
238/// `swap_` null there and would dereference it, where this reports the reason.
239fn engine_not_reset() -> QlError {
240    QlError::new(
241        "the helper's credit default swap is built when the bootstrapping curve is set",
242        file!(),
243        line!(),
244    )
245}
246
247impl SpreadCdsHelper {
248    /// A helper on the C++ default terms (`defaultprobabilityhelpers.cpp:109`).
249    ///
250    /// The C++ quote is a `std::variant<Rate, Handle<Quote>>` (`hpp:129`); a
251    /// quoted spread takes the `Rate` arm here as
252    /// [`make_quote_handle(spread).handle()`](crate::quotes::make_quote_handle).
253    ///
254    /// # Errors
255    ///
256    /// As [`with_terms`](SpreadCdsHelper::with_terms).
257    #[allow(clippy::too_many_arguments)]
258    pub fn new(
259        running_spread: Handle<dyn Quote>,
260        tenor: Period,
261        settlement_days: Integer,
262        calendar: Calendar,
263        frequency: Frequency,
264        payment_convention: BusinessDayConvention,
265        rule: DateGeneration,
266        day_counter: DayCounter,
267        recovery_rate: Real,
268        discount_curve: Handle<dyn YieldTermStructure>,
269        settings: Shared<Settings<Date>>,
270    ) -> QlResult<Shared<SpreadCdsHelper>> {
271        SpreadCdsHelper::with_terms(
272            running_spread,
273            tenor,
274            settlement_days,
275            calendar,
276            frequency,
277            payment_convention,
278            rule,
279            day_counter,
280            recovery_rate,
281            discount_curve,
282            CdsHelperTerms::default(),
283            settings,
284        )
285    }
286
287    /// A helper on the given `terms` (`defaultprobabilityhelpers.cpp:40-58`).
288    ///
289    /// The helper observes its quote and its discount curve (the constructor's
290    /// `registerWith(discountCurve)`, `cpp:57`) and tracks the evaluation date,
291    /// rebuilding its schedule and its contract whenever that date moves.
292    ///
293    /// # Errors
294    ///
295    /// Rejects the three CDS date-generation rules. Their maturity comes from
296    /// `cdsMaturity` (`cpp:87`), which is not ported, and taking the other arm
297    /// for them would silently produce a schedule ending on the wrong date; that
298    /// branch is deferred within EPIC Credit (#676).
299    #[allow(clippy::too_many_arguments)]
300    pub fn with_terms(
301        running_spread: Handle<dyn Quote>,
302        tenor: Period,
303        settlement_days: Integer,
304        calendar: Calendar,
305        frequency: Frequency,
306        payment_convention: BusinessDayConvention,
307        rule: DateGeneration,
308        day_counter: DayCounter,
309        recovery_rate: Real,
310        discount_curve: Handle<dyn YieldTermStructure>,
311        terms: CdsHelperTerms,
312        settings: Shared<Settings<Date>>,
313    ) -> QlResult<Shared<SpreadCdsHelper>> {
314        require!(
315            !matches!(
316                rule,
317                DateGeneration::CDS | DateGeneration::CDS2015 | DateGeneration::OldCDS
318            ),
319            "the post-Big-Bang date-generation rules need cdsMaturity, which is not ported yet \
320             (defaultprobabilityhelpers.cpp:85-88)"
321        );
322        Ok(Shared::new_cyclic(|weak: &Weak<SpreadCdsHelper>| {
323            let weak = weak.clone();
324            let on_eval_change = Box::new(move || {
325                if let Some(helper) = weak.upgrade() {
326                    helper.initialize_dates();
327                    helper.reset_engine();
328                }
329            });
330            let base = BootstrapHelperBase::new_relative(
331                running_spread,
332                Shared::clone(&settings),
333                true,
334                on_eval_change,
335            );
336            discount_curve.register_observer(&base.observer());
337            let helper = SpreadCdsHelper {
338                base,
339                tenor,
340                settlement_days,
341                calendar,
342                frequency,
343                payment_convention,
344                rule,
345                day_counter,
346                recovery_rate,
347                discount_curve,
348                settles_accrual: terms.settles_accrual,
349                pays_at_default_time: terms.pays_at_default_time,
350                start_date: terms.start_date,
351                last_period_day_counter: terms.last_period_day_counter,
352                rebates_accrual: terms.rebates_accrual,
353                settings,
354                schedule: RefCell::new(Schedule::from_dates(Vec::new())),
355                protection_start: Cell::new(Date::null()),
356                probability: RelinkableHandle::empty(),
357                swap: RefCell::new(Err(engine_not_reset())),
358            };
359            helper.initialize_dates();
360            helper
361        }))
362    }
363
364    /// The date protection starts, `settlement_days` past the evaluation date
365    /// (`cpp:79`).
366    pub fn protection_start(&self) -> Date {
367        self.protection_start.get()
368    }
369
370    /// Rebuilds the priced contract and its engine (`resetEngine`, `cpp:137-153`).
371    ///
372    /// Called from [`set_term_structure`](DefaultProbabilityHelper::set_term_structure)
373    /// and, after [`initialize_dates`](RelativeDateDefaultProbabilityHelper::initialize_dates),
374    /// on an evaluation-date move - the two C++ call sites (`cpp:67` and
375    /// `cpp:72`). The order matters on the second: the contract is built from the
376    /// schedule and protection start, so rebuilding it first would price the
377    /// stale schedule.
378    ///
379    /// C++ calls this on *every* notification `update()` carries, where the
380    /// evaluation-date guard sits inside `RelativeDateBootstrapHelper::update()`;
381    /// this port has that guard in the base and hooks the rebuild to it. The
382    /// unconditional arm rebuilds an identical contract - its notional and
383    /// spread are the fixed `100.0` and `0.01`, never the quote - and a discount
384    /// or curve move still reaches the contract through the engine it observes.
385    ///
386    /// A contract that cannot be built is stored as the error and surfaces at
387    /// [`implied_quote`](DefaultProbabilityHelper::implied_quote), since the C++
388    /// signature this mirrors returns nothing.
389    fn reset_engine(&self) {
390        *self.swap.borrow_mut() = self.build_swap();
391    }
392
393    /// The par contract the helper prices: a protection-buyer CDS on a notional
394    /// of 100 paying a 1% running spread (`cpp:138-141`), under a fresh midpoint
395    /// engine over the helper's own probability handle (`cpp:151-152`).
396    ///
397    /// C++ passes its `evaluationDate_` as the contract's trade date; this
398    /// leaves the trade date to the contract, which deduces `protection start -
399    /// 1` for a pre-Big-Bang rule (`creditdefaultswap.rs:365-371`). The two
400    /// differ only when `settlement_days` is zero, and only in the accrual
401    /// rebate and upfront payment, both zero-amount flows on a contract with no
402    /// upfront: the deduced date keeps the helper clear of the rebate
403    /// arithmetic deferred by #689, which the C++ date would enter with nothing
404    /// to show for it.
405    fn build_swap(&self) -> QlResult<CreditDefaultSwap> {
406        let mut swap = CreditDefaultSwap::with_terms(
407            ProtectionSide::Buyer,
408            100.0,
409            0.01,
410            self.schedule.borrow().clone(),
411            self.payment_convention,
412            self.day_counter.clone(),
413            CdsTerms {
414                settles_accrual: self.settles_accrual,
415                pays_at_default_time: self.pays_at_default_time,
416                protection_start: Some(self.protection_start.get()),
417                last_period_day_counter: self.last_period_day_counter.clone(),
418                rebates_accrual: self.rebates_accrual,
419                ..CdsTerms::default()
420            },
421            Shared::clone(&self.settings),
422        )?;
423        let engine = MidPointCdsEngine::new(
424            self.probability.handle(),
425            self.recovery_rate,
426            self.discount_curve.clone(),
427            None,
428            Shared::clone(&self.settings),
429        );
430        swap.base_mut()
431            .set_pricing_engine(shared_mut(engine) as SharedMut<dyn PricingEngine>);
432        Ok(swap)
433    }
434}
435
436impl AsObservable for SpreadCdsHelper {
437    fn observable(&self) -> &Observable {
438        self.base.observable()
439    }
440}
441
442impl DefaultProbabilityHelper for SpreadCdsHelper {
443    fn base(&self) -> &DefaultProbabilityHelperBase {
444        &self.base
445    }
446
447    /// The contract's fair spread (`impliedQuote`, `cpp:132-135`).
448    ///
449    /// The recalculation is forced rather than left to the cache: the helper
450    /// weak-links the curve handle the engine prices over, so a node the
451    /// bootstrap has just moved does not notify the contract, and a plain
452    /// `fair_spread` would answer from the previous iteration's results.
453    fn implied_quote(&self) -> QlResult<Real> {
454        let mut swap = self.swap.borrow_mut();
455        let swap = swap.as_mut().map_err(|error| error.clone())?;
456        swap.recalculate()?;
457        swap.fair_spread()
458    }
459
460    /// Records the curve, hands it to the engine, and rebuilds the contract
461    /// (`setTermStructure`, `cpp:60-68`).
462    ///
463    /// The engine's handle is linked weakly, the port of the C++
464    /// `linkTo(..., false)` at `cpp:63-65`: the curve owns this helper, which
465    /// owns the contract, which owns the engine, and a strong link would close
466    /// that ring.
467    fn set_term_structure(&self, term_structure: &Shared<dyn DefaultProbabilityTermStructure>) {
468        self.base.set_term_structure(term_structure);
469        self.probability
470            .link_to_weak(Shared::downgrade(term_structure));
471        self.reset_engine();
472    }
473}
474
475impl RelativeDateDefaultProbabilityHelper for SpreadCdsHelper {
476    /// Rebuilds the schedule off the current evaluation date (`initializeDates`,
477    /// `cpp:75-108`).
478    ///
479    /// Protection starts `settlement_days` past the evaluation date and, absent
480    /// an explicit start, the schedule starts there too, rolled to a business
481    /// day. The maturity is the tenor past that same reference date - the
482    /// `cdsMaturity` arm above it (`cpp:86-88`) covers only the three CDS rules,
483    /// which the constructor rejects; `TwentiethIMM` takes this arm.
484    ///
485    /// The earliest date is the schedule's first date and the latest its last,
486    /// rolled. C++ then adds a day under the ISDA model (`cpp:105-106`); the
487    /// midpoint model, the only one ported, does not.
488    fn initialize_dates(&self) {
489        let evaluation_date = self
490            .base
491            .evaluation_date()
492            .expect("a relative-date helper always tracks an evaluation date");
493        let protection_start = evaluation_date + self.settlement_days;
494        self.protection_start.set(protection_start);
495
496        let mut start_date = self.start_date.unwrap_or(protection_start);
497        if self.rule != DateGeneration::CDS && self.rule != DateGeneration::CDS2015 {
498            start_date = self.calendar.adjust(start_date, self.payment_convention);
499        }
500        let reference_date = match self.start_date {
501            Some(date) => date + self.settlement_days,
502            None => protection_start,
503        };
504        let end_date = reference_date + self.tenor;
505
506        let schedule = MakeSchedule::new()
507            .from(start_date)
508            .to(end_date)
509            .with_frequency(self.frequency)
510            .with_calendar(self.calendar.clone())
511            .with_convention(self.payment_convention)
512            .with_termination_date_convention(BusinessDayConvention::Unadjusted)
513            .with_rule(self.rule)
514            .build();
515
516        self.base.set_earliest_date(schedule.date(0));
517        self.base.set_latest_date(
518            self.calendar
519                .adjust(schedule.date(schedule.len() - 1), self.payment_convention),
520        );
521        *self.schedule.borrow_mut() = schedule;
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    /// The credit family satisfies the bound the bootstrap driver puts on
530    /// `PiecewiseCurve::Helper`, so a credit piecewise curve can name
531    /// `dyn DefaultProbabilityHelper` there against a
532    /// `dyn DefaultProbabilityTermStructure` curve. The two associated types
533    /// must agree, which is the whole point of the generalization, and only a
534    /// paired instantiation checks it. This is a compile-time assertion: the
535    /// credit bootstrap itself ports no behaviour yet.
536    #[test]
537    fn credit_helpers_satisfy_the_driver_bound() {
538        fn accepts_driver_helper<H>()
539        where
540            H: BootstrapHelperShared<TS = dyn DefaultProbabilityTermStructure> + ?Sized,
541        {
542        }
543        accepts_driver_helper::<dyn DefaultProbabilityHelper>();
544    }
545
546    use crate::interestrate::Compounding;
547    use crate::quotes::SimpleQuote;
548    use crate::shared::shared;
549    use crate::termstructures::credit::flathazardrate::FlatHazardRate;
550    use crate::termstructures::yields::FlatForward;
551    use crate::test_support::{Flag, as_observer};
552    use crate::time::calendars::target::Target;
553    use crate::time::date::Month;
554    use crate::time::daycounters::actual360::Actual360;
555    use crate::time::daycounters::actual365fixed::Actual365Fixed;
556    use crate::time::timeunit::TimeUnit;
557
558    /// A Monday, so the schedule's start is a business day and the roll the
559    /// helper applies to it is visible as the identity it is.
560    fn today() -> Date {
561        Date::new(15, Month::June, 2026)
562    }
563
564    fn five_years() -> Period {
565        Period::new(5, TimeUnit::Years)
566    }
567
568    fn settings_at(evaluation_date: Date) -> Shared<Settings<Date>> {
569        let settings = shared(Settings::new());
570        settings.set_evaluation_date(evaluation_date);
571        settings
572    }
573
574    fn discount(settlement: Date) -> Handle<dyn YieldTermStructure> {
575        Handle::new(shared(FlatForward::with_rate(
576            settlement,
577            0.03,
578            Actual365Fixed::new(),
579            Compounding::Continuous,
580            Frequency::Annual,
581        )) as Shared<dyn YieldTermStructure>)
582    }
583
584    /// A one-settlement-day helper on a five-year `TwentiethIMM` schedule, the
585    /// pre-Big-Bang convention the ported date arm serves.
586    fn helper(settings: &Shared<Settings<Date>>, terms: CdsHelperTerms) -> Shared<SpreadCdsHelper> {
587        SpreadCdsHelper::with_terms(
588            Handle::new(shared(SimpleQuote::new(0.01)) as Shared<dyn Quote>),
589            five_years(),
590            1,
591            Target::new(),
592            Frequency::Quarterly,
593            BusinessDayConvention::Following,
594            DateGeneration::TwentiethIMM,
595            Actual360::new(),
596            0.4,
597            discount(today()),
598            terms,
599            Shared::clone(settings),
600        )
601        .unwrap()
602    }
603
604    /// The schedule `initialize_dates` is expected to have built, derived from
605    /// the two dates the C++ arm computes rather than read back off the helper.
606    fn expected_schedule(start_date: Date, end_date: Date) -> Schedule {
607        MakeSchedule::new()
608            .from(Target::new().adjust(start_date, BusinessDayConvention::Following))
609            .to(end_date)
610            .with_frequency(Frequency::Quarterly)
611            .with_calendar(Target::new())
612            .with_convention(BusinessDayConvention::Following)
613            .with_termination_date_convention(BusinessDayConvention::Unadjusted)
614            .with_rule(DateGeneration::TwentiethIMM)
615            .build()
616    }
617
618    fn last_date(schedule: &Schedule) -> Date {
619        schedule.date(schedule.len() - 1)
620    }
621
622    /// `initializeDates` (`defaultprobabilityhelpers.cpp:75-108`): protection
623    /// starts `settlementDays` past the evaluation date, the schedule starts
624    /// there rolled, and the maturity is the tenor past the protection start -
625    /// the `cdsMaturity` arm (`cpp:86-88`) covers only the rejected CDS rules,
626    /// so `TwentiethIMM` reaches this one.
627    #[test]
628    fn initialize_dates_spans_protection_start_to_the_tenor() {
629        let settings = settings_at(today());
630        let helper = helper(&settings, CdsHelperTerms::default());
631        let calendar = Target::new();
632
633        let protection_start = today() + 1;
634        assert_eq!(helper.protection_start(), protection_start);
635
636        let schedule = expected_schedule(protection_start, protection_start + five_years());
637        assert_eq!(
638            helper.earliest_date(),
639            calendar.adjust(protection_start, BusinessDayConvention::Following)
640        );
641        assert_eq!(helper.earliest_date(), schedule.date(0));
642        assert_eq!(
643            helper.latest_date(),
644            calendar.adjust(last_date(&schedule), BusinessDayConvention::Following)
645        );
646    }
647
648    /// The latest date is the rolled last coupon date and nothing more: C++ adds
649    /// a day only under the ISDA model (`cpp:105-106`), which is not ported.
650    /// Pillar and latest-relevant date follow it, which is what the bootstrap
651    /// driver orders the helpers on.
652    #[test]
653    fn the_node_sits_on_the_rolled_maturity() {
654        let settings = settings_at(today());
655        let helper = helper(&settings, CdsHelperTerms::default());
656
657        let schedule = expected_schedule(today() + 1, today() + 1 + five_years());
658        let rolled = Target::new().adjust(last_date(&schedule), BusinessDayConvention::Following);
659        assert_eq!(helper.latest_date(), rolled);
660        assert_eq!(helper.pillar_date(), rolled);
661        assert_eq!(helper.latest_relevant_date(), rolled);
662        assert_eq!(helper.maturity_date(), rolled);
663    }
664
665    /// An explicit start date replaces the protection start on both sides of the
666    /// schedule, but the maturity is measured from `startDate + settlementDays`
667    /// rather than from the start date itself (`cpp:90`) - the one place the two
668    /// arms of that ternary differ by more than the branch they take.
669    #[test]
670    fn an_explicit_start_date_offsets_the_maturity_by_the_settlement_days() {
671        let settings = settings_at(today());
672        let start_date = Date::new(20, Month::March, 2026);
673        let helper = helper(
674            &settings,
675            CdsHelperTerms {
676                start_date: Some(start_date),
677                ..CdsHelperTerms::default()
678            },
679        );
680
681        let schedule = expected_schedule(start_date, start_date + 1 + five_years());
682        assert_eq!(helper.earliest_date(), schedule.date(0));
683        assert_eq!(
684            helper.latest_date(),
685            Target::new().adjust(last_date(&schedule), BusinessDayConvention::Following)
686        );
687        assert_eq!(helper.protection_start(), today() + 1);
688    }
689
690    /// The relative-date rebuild (`cpp:70-73`): a moved evaluation date reruns
691    /// `initialize_dates` through the base's `new_relative` closure, so the whole
692    /// schedule shifts with it.
693    #[test]
694    fn an_evaluation_date_move_rebuilds_the_schedule() {
695        let settings = settings_at(today());
696        let helper = helper(&settings, CdsHelperTerms::default());
697        let (earliest, latest) = (helper.earliest_date(), helper.latest_date());
698
699        let moved = Date::new(15, Month::December, 2026);
700        settings.set_evaluation_date(moved);
701
702        assert_eq!(helper.protection_start(), moved + 1);
703        assert!(helper.earliest_date() > earliest);
704        assert!(helper.latest_date() > latest);
705        assert_eq!(
706            helper.earliest_date(),
707            Target::new().adjust(moved + 1, BusinessDayConvention::Following)
708        );
709    }
710
711    /// The three CDS rules need `cdsMaturity`, which is not ported; taking the
712    /// other arm for them would build a schedule ending on the wrong date, so
713    /// they are refused rather than silently mispriced (#676).
714    #[test]
715    fn the_post_big_bang_rules_are_refused() {
716        let settings = settings_at(today());
717        for rule in [
718            DateGeneration::CDS,
719            DateGeneration::CDS2015,
720            DateGeneration::OldCDS,
721        ] {
722            let result = SpreadCdsHelper::new(
723                Handle::new(shared(SimpleQuote::new(0.01)) as Shared<dyn Quote>),
724                five_years(),
725                1,
726                Target::new(),
727                Frequency::Quarterly,
728                BusinessDayConvention::Following,
729                rule,
730                Actual360::new(),
731                0.4,
732                discount(today()),
733                Shared::clone(&settings),
734            );
735            assert!(
736                result
737                    .err()
738                    .is_some_and(|error| { error.message().contains("cdsMaturity") })
739            );
740        }
741    }
742
743    /// `impliedQuote` (`cpp:132-135`) prices the helper's own contract against
744    /// the curve it was handed, which `set_term_structure` weak-links into the
745    /// engine and rebuilds the contract for (`cpp:60-68`).
746    ///
747    /// The forced recalculation is load-bearing, and this is what shows it: the
748    /// weak link registers no observer on the curve
749    /// ([`Link::link_weak`](crate::handle)), so moving the curve leaves the
750    /// contract still flagged as calculated. A plain `fair_spread` would answer
751    /// from that stale cache; the fresh number can only come from the
752    /// `recalculate` this makes first. During the bootstrap it is the solver
753    /// moving a curve node, which reaches the contract the same way: not at all.
754    #[test]
755    fn implied_quote_reprices_a_curve_it_does_not_observe() {
756        let settings = settings_at(today());
757        let helper = helper(&settings, CdsHelperTerms::default());
758
759        let hazard = shared(SimpleQuote::new(0.02));
760        let curve: Shared<dyn DefaultProbabilityTermStructure> = shared(FlatHazardRate::new(
761            today(),
762            Handle::new(Shared::clone(&hazard) as Shared<dyn Quote>),
763            Actual365Fixed::new(),
764        ));
765        helper.set_term_structure(&curve);
766
767        let first = helper.implied_quote().unwrap();
768        assert!(first.is_finite() && first > 0.0);
769        assert_eq!(helper.implied_quote().unwrap(), first);
770
771        hazard.set_value(0.05);
772        assert!(
773            helper
774                .swap
775                .borrow()
776                .as_ref()
777                .unwrap()
778                .base()
779                .is_calculated(),
780            "the weak link must leave the contract unnotified by the curve"
781        );
782
783        let second = helper.implied_quote().unwrap();
784        assert!(
785            second > first,
786            "a higher hazard rate must widen the fair spread, not repeat {first}"
787        );
788    }
789
790    /// The second `resetEngine` call site (`cpp:72`): the contract is rebuilt
791    /// after the schedule is, so the helper prices the moved dates rather than
792    /// the ones it was handed the curve on. Nothing else in the type system
793    /// enforces that ordering, and a contract left behind still prices - just
794    /// against the wrong schedule.
795    #[test]
796    fn an_evaluation_date_move_rebuilds_the_contract_too() {
797        let settings = settings_at(today());
798        let helper = helper(&settings, CdsHelperTerms::default());
799        let curve: Shared<dyn DefaultProbabilityTermStructure> = shared(FlatHazardRate::with_rate(
800            today(),
801            0.02,
802            Actual365Fixed::new(),
803        ));
804        helper.set_term_structure(&curve);
805        helper.implied_quote().unwrap();
806
807        let moved = Date::new(15, Month::December, 2026);
808        settings.set_evaluation_date(moved);
809
810        let schedule = expected_schedule(moved + 1, moved + 1 + five_years());
811        let swap = helper.swap.borrow();
812        let swap = swap.as_ref().unwrap();
813        assert_eq!(swap.protection_start_date(), moved + 1);
814        assert_eq!(swap.maturity(), last_date(&schedule));
815    }
816
817    /// `registerWith(discountCurve)` (`cpp:57`): the discount curve is the one
818    /// input the helper prices against that it does observe - unlike the
819    /// bootstrapping curve - so a move there re-broadcasts and reaches the curve
820    /// being built. This is what makes it safe for the rebuild to hang off the
821    /// evaluation date alone.
822    #[test]
823    fn a_discount_curve_move_notifies_the_helper() {
824        let settings = settings_at(today());
825        let rate = shared(SimpleQuote::new(0.03));
826        let discount_curve = Handle::new(shared(FlatForward::new(
827            today(),
828            Handle::new(Shared::clone(&rate) as Shared<dyn Quote>),
829            Actual365Fixed::new(),
830            Compounding::Continuous,
831            Frequency::Annual,
832        )) as Shared<dyn YieldTermStructure>);
833        let helper = SpreadCdsHelper::new(
834            Handle::new(shared(SimpleQuote::new(0.01)) as Shared<dyn Quote>),
835            five_years(),
836            1,
837            Target::new(),
838            Frequency::Quarterly,
839            BusinessDayConvention::Following,
840            DateGeneration::TwentiethIMM,
841            Actual360::new(),
842            0.4,
843            discount_curve,
844            Shared::clone(&settings),
845        )
846        .unwrap();
847
848        let flag = Flag::new();
849        helper.observable().register_observer(&as_observer(&flag));
850
851        rate.set_value(0.04);
852        assert!(Flag::is_up(&flag));
853    }
854
855    /// Before a curve arrives there is no contract to price, where C++ would
856    /// dereference its null `swap_`.
857    #[test]
858    fn implied_quote_without_a_curve_reports_the_missing_contract() {
859        let settings = settings_at(today());
860        let helper = helper(&settings, CdsHelperTerms::default());
861        assert!(
862            helper
863                .implied_quote()
864                .err()
865                .is_some_and(|error| error.message().contains("bootstrapping curve is set"))
866        );
867    }
868}