Skip to main content

regit_curves/
bootstrap.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Sequential iterative bootstrap engine.
5//!
6//! The bootstrap engine constructs a [`DiscountCurve`] from a list of market
7//! instruments (deposits, FRAs, futures, fixed-float swaps, OIS swaps, basis
8//! swaps) by **sequentially solving for the discount factor at each
9//! instrument's pillar date**, so that the instrument re-prices to within
10//! `tolerance` against the in-progress curve.
11//!
12//! ```text
13//! anchor:    (t_0, D_0) = (0, 1)
14//! for k = 1, 2, ..., N:
15//!     t_k    = daycount.year_fraction(reference_date, instruments[k-1].pillar())
16//!     residual_fn(D) builds an interim CurveSnapshot over (t_0..t_{k-1}, t_k)
17//!         with (D_0..D_{k-1}, D) and asks instruments[k-1] for its residual
18//!     solve residual_fn(D_k) = 0 by Brent's method, bracketing around the
19//!         previous-anchor forward extrapolation
20//!     append (t_k, D_k) to the running curve
21//! return DiscountCurve::from_times_and_discounts(reference_date, daycount,
22//!                                                times, discounts, method)
23//! ```
24//!
25//! For interpolation methods whose value at one pillar depends on the value
26//! at later pillars (cubic spline, Hermite-Bessel, Hyman-filtered cubic), the
27//! single-pass sweep is not sufficient — the global interpolant shifts as
28//! later pillars are added. The engine handles this with an **outer
29//! iteration**: starting from the single-pass solution it re-solves each
30//! pillar against the latest curve until the maximum nodal change drops below
31//! `iter_tol`. For local interpolants (linear, log-linear, linear-in-zero,
32//! piecewise-constant forward, the monotone Hermite cubics) one pass
33//! suffices.
34//!
35//! Convergence of the outer iteration on consistent market data follows from
36//! the contractivity of the residual map under reasonable curve shapes; the
37//! formal argument is laid out in Andersen & Piterbarg (2010, Vol. 1 §6.4)
38//! and Hagan & West (2006, §3).
39//!
40//! # Re-pricing certificate
41//!
42//! On every successful `build`, every input instrument's residual against the
43//! returned curve is `< config.tolerance`. This is the bootstrap's contract
44//! with the caller: a curve returned by `Bootstrap::build` is one that
45//! re-prices its inputs.
46//!
47//! # References
48//!
49//! - Hagan, P. S. & West, G., "Interpolation methods for curve construction",
50//!   *Applied Mathematical Finance* 13(2):89-129 (2006), §3. The canonical
51//!   "sequential anchor-by-anchor solve" formulation.
52//! - Andersen, L. B. G. & Piterbarg, V. V., *Interest Rate Modeling*,
53//!   Volume I: Foundations and Vanilla Models, Atlantic Financial Press
54//!   (2010), §6.4. Outer-iteration treatment for non-local interpolators in
55//!   the single-currency bootstrap.
56
57use crate::curves::DiscountCurve;
58use crate::errors::BootstrapError;
59use crate::instruments::{CurveSnapshot, Instrument, InstrumentLike};
60use crate::interpolation::Interpolation;
61use crate::math::MathError;
62use crate::math::brent::{BrentConfig, brent_root};
63use crate::types::{Date, Daycount};
64
65/// Configuration for [`Bootstrap`].
66///
67/// The defaults match the values documented in `WORKING.md` §3.10:
68/// `tolerance = 1e-12`, `max_iter = 100`, `bracket = 0.5`, `iterative = true`,
69/// `iter_max = 8`, `iter_tol = 1e-14`.
70///
71/// # Examples
72///
73/// ```
74/// use regit_curves::bootstrap::BootstrapConfig;
75///
76/// let cfg = BootstrapConfig::default();
77/// assert!((cfg.tolerance - 1e-12).abs() < 1e-18);
78/// assert_eq!(cfg.max_iter, 100);
79/// assert!((cfg.bracket - 0.5).abs() < 1e-15);
80/// assert!(cfg.iterative);
81/// assert_eq!(cfg.iter_max, 8);
82/// assert!((cfg.iter_tol - 1e-14).abs() < 1e-20);
83/// ```
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct BootstrapConfig {
86    /// Per-leg residual tolerance handed to Brent as `ftol`.
87    pub tolerance: f64,
88    /// Per-leg iteration cap handed to Brent as `max_iter`.
89    pub max_iter: u32,
90    /// Initial half-width of the discount-factor bracket. The bracket is
91    /// `[D_guess * exp(-bracket), D_guess * exp(+bracket)]` and is widened on
92    /// failure by doubling up to five times.
93    pub bracket: f64,
94    /// Run the outer iteration for non-local interpolation methods. Has no
95    /// effect for local methods (linear, log-linear, linear-in-zero,
96    /// piecewise-constant forward, monotone cubic, Steffen).
97    pub iterative: bool,
98    /// Outer-iteration cap.
99    pub iter_max: u32,
100    /// Outer-iteration convergence tolerance on the maximum nodal change
101    /// `max_i |D_i^{new} - D_i^{old}|`.
102    pub iter_tol: f64,
103}
104
105impl Default for BootstrapConfig {
106    fn default() -> Self {
107        Self {
108            tolerance: 1e-12,
109            max_iter: 100,
110            bracket: 0.5,
111            iterative: true,
112            iter_max: 8,
113            iter_tol: 1e-14,
114        }
115    }
116}
117
118/// Sequential iterative bootstrap engine.
119///
120/// Constructs a [`DiscountCurve`] from a list of [`Instrument`] quotes by
121/// driving each instrument's residual to zero against the in-progress curve.
122/// See the module-level documentation for the algorithm.
123///
124/// # Examples
125///
126/// ```
127/// use regit_curves::bootstrap::{Bootstrap, BootstrapConfig};
128/// use regit_curves::instruments::{Deposit, Instrument};
129/// use regit_curves::interpolation::Interpolation;
130/// use regit_curves::types::{Date, Daycount};
131///
132/// let reference = Date::from_ymd(2024, 1, 2).unwrap();
133/// let dep1 = Deposit::new(
134///     reference,
135///     Date::from_ymd(2024, 4, 2).unwrap(),
136///     0.05,
137///     Daycount::Act360,
138/// )
139/// .unwrap();
140/// let dep2 = Deposit::new(
141///     reference,
142///     Date::from_ymd(2024, 7, 2).unwrap(),
143///     0.05,
144///     Daycount::Act360,
145/// )
146/// .unwrap();
147/// let instruments = [Instrument::Deposit(dep1), Instrument::Deposit(dep2)];
148///
149/// let bootstrap = Bootstrap::new(reference, Daycount::Act360);
150/// let curve = bootstrap
151///     .build(&instruments, Interpolation::LogLinear)
152///     .unwrap();
153/// assert_eq!(curve.reference_date(), reference);
154/// ```
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub struct Bootstrap {
157    /// Curve anchor / reference date.
158    pub reference_date: Date,
159    /// Day-count convention for the curve's `t`-axis.
160    pub daycount: Daycount,
161    /// Solver configuration.
162    pub config: BootstrapConfig,
163}
164
165impl Bootstrap {
166    /// Constructs a bootstrap engine with [`BootstrapConfig::default`].
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use regit_curves::bootstrap::Bootstrap;
172    /// use regit_curves::types::{Date, Daycount};
173    ///
174    /// let reference = Date::from_ymd(2024, 1, 2).unwrap();
175    /// let bs = Bootstrap::new(reference, Daycount::Act360);
176    /// assert_eq!(bs.reference_date, reference);
177    /// assert_eq!(bs.daycount, Daycount::Act360);
178    /// ```
179    #[must_use]
180    pub fn new(reference_date: Date, daycount: Daycount) -> Self {
181        Self {
182            reference_date,
183            daycount,
184            config: BootstrapConfig::default(),
185        }
186    }
187
188    /// Returns the bootstrap engine with the supplied configuration.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use regit_curves::bootstrap::{Bootstrap, BootstrapConfig};
194    /// use regit_curves::types::{Date, Daycount};
195    ///
196    /// let reference = Date::from_ymd(2024, 1, 2).unwrap();
197    /// let cfg = BootstrapConfig {
198    ///     tolerance: 1e-10,
199    ///     ..BootstrapConfig::default()
200    /// };
201    /// let bs = Bootstrap::new(reference, Daycount::Act360).with_config(cfg);
202    /// assert!((bs.config.tolerance - 1e-10).abs() < 1e-18);
203    /// ```
204    #[must_use]
205    pub fn with_config(mut self, config: BootstrapConfig) -> Self {
206        self.config = config;
207        self
208    }
209
210    /// Builds a [`DiscountCurve`] that re-prices every instrument in
211    /// `instruments` to within [`BootstrapConfig::tolerance`].
212    ///
213    /// Instruments must be ordered by [`Instrument::pillar`], strictly
214    /// increasing. The pillar of every instrument must lie strictly after
215    /// [`Bootstrap::reference_date`].
216    ///
217    /// # Errors
218    ///
219    /// - [`BootstrapError::InvalidInstrument`] if `instruments` is empty, or if
220    ///   any pillar is on or before the reference date.
221    /// - [`BootstrapError::NonIncreasingAnchor`] if pillars are not strictly
222    ///   increasing.
223    /// - [`BootstrapError::NoBracket`] if no sign change can be found in the
224    ///   discount-factor search interval for a leg, even after widening.
225    /// - [`BootstrapError::LegDidNotConverge`] if Brent fails to converge, or
226    ///   if the outer iteration fails to converge within
227    ///   [`BootstrapConfig::iter_max`] passes.
228    /// - [`BootstrapError::Curve`] if the final curve construction rejects
229    ///   the bootstrapped knots (should not happen given the validation
230    ///   above).
231    /// - [`BootstrapError::Type`] if a day-count query fails.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use regit_curves::bootstrap::Bootstrap;
237    /// use regit_curves::instruments::{Deposit, Instrument};
238    /// use regit_curves::interpolation::Interpolation;
239    /// use regit_curves::types::{Date, Daycount};
240    ///
241    /// let reference = Date::from_ymd(2024, 1, 2).unwrap();
242    /// let dep = Deposit::new(
243    ///     reference,
244    ///     Date::from_ymd(2024, 4, 2).unwrap(),
245    ///     0.05,
246    ///     Daycount::Act360,
247    /// )
248    /// .unwrap();
249    /// let curve = Bootstrap::new(reference, Daycount::Act360)
250    ///     .build(&[Instrument::Deposit(dep)], Interpolation::LogLinear)
251    ///     .unwrap();
252    /// assert!(curve.discounts().len() == 2);
253    /// ```
254    pub fn build(
255        &self,
256        instruments: &[Instrument],
257        method: Interpolation,
258    ) -> Result<DiscountCurve, BootstrapError> {
259        // Step 0 — validation.
260        if instruments.is_empty() {
261            return Err(BootstrapError::InvalidInstrument {
262                at_index: 0,
263                reason: "no instruments supplied",
264            });
265        }
266        for (i, inst) in instruments.iter().enumerate() {
267            let pillar = inst.pillar();
268            if pillar.days_between(self.reference_date) >= 0 {
269                // pillar <= reference_date
270                return Err(BootstrapError::InvalidInstrument {
271                    at_index: i,
272                    reason: "instrument pillar must be after reference_date",
273                });
274            }
275            if i > 0 {
276                let prev_pillar = instruments[i - 1].pillar();
277                if pillar.days_between(prev_pillar) >= 0 {
278                    // pillar <= prev_pillar
279                    return Err(BootstrapError::NonIncreasingAnchor { at_index: i });
280                }
281            }
282        }
283
284        // Step 1 — initial nodes (anchor + scaffold).
285        let n = instruments.len();
286        let mut times: Vec<f64> = Vec::with_capacity(n + 1);
287        let mut discounts: Vec<f64> = Vec::with_capacity(n + 1);
288        times.push(0.0);
289        discounts.push(1.0);
290        for inst in instruments {
291            let t = self
292                .daycount
293                .year_fraction(self.reference_date, inst.pillar())?;
294            times.push(t);
295            discounts.push(1.0); // placeholder; overwritten in the sweep
296        }
297
298        // Step 2 — single-pass sequential bootstrap.
299        self.sweep(instruments, method, &times, &mut discounts, true)?;
300
301        // Step 3 — outer iteration for non-local interpolators.
302        if self.config.iterative && method_is_nonlocal(method) {
303            let mut converged = false;
304            let mut last_change = 0.0_f64;
305            for _ in 0..self.config.iter_max {
306                let previous = discounts.clone();
307                self.sweep(instruments, method, &times, &mut discounts, false)?;
308                let mut max_change = 0.0_f64;
309                for (a, b) in discounts.iter().zip(previous.iter()) {
310                    let delta = (a - b).abs();
311                    if delta > max_change {
312                        max_change = delta;
313                    }
314                }
315                last_change = max_change;
316                if max_change < self.config.iter_tol {
317                    converged = true;
318                    break;
319                }
320            }
321            if !converged {
322                return Err(BootstrapError::LegDidNotConverge {
323                    at_index: usize::MAX,
324                    residual: last_change,
325                });
326            }
327        }
328
329        // Step 4 — final curve.
330        let curve = DiscountCurve::from_times_and_discounts(
331            self.reference_date,
332            self.daycount,
333            &times,
334            &discounts,
335            method,
336        )?;
337        Ok(curve)
338    }
339
340    /// Re-solves the discount factor at each instrument's pillar against the
341    /// current `discounts` vector. When `is_initial` is `true`, the previous-
342    /// anchor forward extrapolation is used as the initial guess for the
343    /// segment; when `false`, the current value at that pillar is used as the
344    /// warm start (outer iteration).
345    fn sweep(
346        &self,
347        instruments: &[Instrument],
348        _method: Interpolation,
349        times: &[f64],
350        discounts: &mut [f64],
351        is_initial: bool,
352    ) -> Result<(), BootstrapError> {
353        for (k, inst) in instruments.iter().enumerate() {
354            let idx = k + 1; // discounts index (skip anchor)
355            let t_k = times[idx];
356            let t_prev = times[idx - 1];
357            let d_prev = discounts[idx - 1];
358
359            let d_guess = if is_initial {
360                let r_prev = if k == 0 {
361                    0.05_f64
362                } else {
363                    // Previous-segment forward rate (continuous).
364                    let t_p2 = times[idx - 2];
365                    let d_p2 = discounts[idx - 2];
366                    // r = ln(d_p2 / d_prev) / (t_prev - t_p2)
367                    let dt = t_prev - t_p2;
368                    if dt > 0.0 && d_prev > 0.0 && d_p2 > 0.0 {
369                        (d_p2 / d_prev).ln() / dt
370                    } else {
371                        0.05_f64
372                    }
373                };
374                let dt = t_k - t_prev;
375                d_prev * (-r_prev * dt).exp()
376            } else {
377                // Warm start at the existing value, but guard against zero or
378                // non-positive values from a corrupted previous pass.
379                let warm = discounts[idx];
380                if warm.is_finite() && warm > 0.0 {
381                    warm
382                } else {
383                    d_prev
384                }
385            };
386
387            // Bracket: [d_guess * exp(-bracket), d_guess * exp(+bracket)].
388            // Expand by doubling up to 5 times if no sign change found.
389            let ctx = LegContext {
390                index: k,
391                instrument: inst,
392                pillar_idx: idx,
393                d_guess,
394                times,
395                discounts,
396            };
397            let solved = self.solve_leg(&ctx)?;
398            discounts[idx] = solved;
399        }
400        Ok(())
401    }
402
403    /// Solves for the discount factor at `ctx.pillar_idx` so that the
404    /// instrument's residual is zero against the curve whose `(times,
405    /// discounts)` are the supplied slices with the candidate discount factor
406    /// substituted at `pillar_idx`.
407    fn solve_leg(&self, ctx: &LegContext<'_>) -> Result<f64, BootstrapError> {
408        let mut bracket = self.config.bracket;
409        // Tracks the most recently seen endpoint residual, so that an
410        // unexpected `Err(_)` from Brent has something concrete to surface.
411        // The initial assignment is overwritten on the first iteration
412        // before any read; the `let mut` plus seed value lets us avoid an
413        // `Option<f64>` round-trip.
414        #[allow(unused_assignments)]
415        let mut last_residual: f64 = 0.0;
416        for attempt in 0..=5_u32 {
417            let lo = (ctx.d_guess * (-bracket).exp()).max(f64::MIN_POSITIVE);
418            let hi = ctx.d_guess * bracket.exp();
419
420            let residual_fn = |d: f64| -> f64 {
421                let mut probe = ctx.discounts.to_vec();
422                probe[ctx.pillar_idx] = d;
423                let snapshot = CurveSnapshot {
424                    reference_date: self.reference_date,
425                    daycount: self.daycount,
426                    times: ctx.times,
427                    discounts: &probe,
428                };
429                // A `Result::Err` from the instrument is reported as a large
430                // positive sentinel so Brent does not see a NaN; the surrounding
431                // sweep loop catches the real error on the final settled call.
432                instrument_residual(ctx.instrument, self.reference_date, &snapshot)
433                    .unwrap_or(f64::INFINITY)
434            };
435
436            // Probe endpoints once to detect a same-sign bracket cheaply.
437            let f_lo = residual_fn(lo);
438            let f_hi = residual_fn(hi);
439            last_residual = f_lo;
440            if !f_lo.is_finite() || !f_hi.is_finite() || f_lo * f_hi > 0.0 {
441                if attempt == 5 {
442                    return Err(BootstrapError::NoBracket {
443                        at_index: ctx.index,
444                    });
445                }
446                bracket *= 2.0;
447                continue;
448            }
449
450            let brent_cfg = BrentConfig {
451                xtol: 1e-15,
452                ftol: self.config.tolerance,
453                max_iter: self.config.max_iter,
454            };
455            match brent_root(residual_fn, lo, hi, brent_cfg) {
456                Ok(root) => {
457                    // Confirm the residual is within tolerance — Brent may
458                    // return on xtol convergence without ftol being met for
459                    // pathological functions.
460                    let mut probe = ctx.discounts.to_vec();
461                    probe[ctx.pillar_idx] = root;
462                    let snapshot = CurveSnapshot {
463                        reference_date: self.reference_date,
464                        daycount: self.daycount,
465                        times: ctx.times,
466                        discounts: &probe,
467                    };
468                    let final_residual =
469                        instrument_residual(ctx.instrument, self.reference_date, &snapshot)?;
470                    if final_residual.abs() > self.config.tolerance {
471                        return Err(BootstrapError::LegDidNotConverge {
472                            at_index: ctx.index,
473                            residual: final_residual,
474                        });
475                    }
476                    return Ok(root);
477                }
478                Err(MathError::BracketNotStraddling) => {
479                    if attempt == 5 {
480                        return Err(BootstrapError::NoBracket {
481                            at_index: ctx.index,
482                        });
483                    }
484                    bracket *= 2.0;
485                }
486                Err(_) => {
487                    return Err(BootstrapError::LegDidNotConverge {
488                        at_index: ctx.index,
489                        residual: last_residual,
490                    });
491                }
492            }
493        }
494        Err(BootstrapError::NoBracket {
495            at_index: ctx.index,
496        })
497    }
498}
499
500/// Bundled inputs for a single leg's solve. Keeps `solve_leg`'s signature
501/// short and clippy-clean.
502struct LegContext<'a> {
503    /// Instrument index in the input list (used to populate `BootstrapError`).
504    index: usize,
505    /// The instrument being re-priced.
506    instrument: &'a Instrument,
507    /// Index of the candidate discount factor in the running `discounts`
508    /// vector.
509    pillar_idx: usize,
510    /// Initial guess for the discount factor at the pillar.
511    d_guess: f64,
512    /// Full running `times` vector (curve `t`-axis).
513    times: &'a [f64],
514    /// Full running `discounts` vector with the previous best estimate at
515    /// `pillar_idx`.
516    discounts: &'a [f64],
517}
518
519/// Dispatches the instrument variant to its `InstrumentLike::residual`
520/// implementation. Internal helper used by the residual function passed to
521/// Brent.
522fn instrument_residual(
523    inst: &Instrument,
524    reference_date: Date,
525    snapshot: &CurveSnapshot<'_>,
526) -> Result<f64, BootstrapError> {
527    match inst {
528        Instrument::Bond(b) => b.residual(reference_date, snapshot),
529        Instrument::Deposit(d) => d.residual(reference_date, snapshot),
530        Instrument::Fra(f) => f.residual(reference_date, snapshot),
531        Instrument::Future(f) => f.residual(reference_date, snapshot),
532        Instrument::SwapFixedFloat(s) => s.residual(reference_date, snapshot),
533        Instrument::OisSwap(s) => s.residual(reference_date, snapshot),
534        Instrument::BasisSwap(b) => b.residual(reference_date, snapshot),
535    }
536}
537
538/// Returns `true` if the interpolation method's value at one pillar depends
539/// on the value at later pillars (i.e. it is a globally-coupled interpolant
540/// and the bootstrap requires an outer iteration to reach a self-consistent
541/// fixed point).
542fn method_is_nonlocal(method: Interpolation) -> bool {
543    match method {
544        Interpolation::CubicSpline(_)
545        | Interpolation::HermiteBessel
546        | Interpolation::MonotoneHyman => true,
547        // ConvexMonotone is local — each segment depends only on its four
548        // adjacent knots — so the sequential bootstrap converges without an
549        // outer iteration.
550        Interpolation::ConvexMonotone
551        | Interpolation::Linear
552        | Interpolation::LogLinear
553        | Interpolation::LinearInZero
554        | Interpolation::PiecewiseConstantForward
555        | Interpolation::MonotoneCubic
556        | Interpolation::MonotoneSteffen => false,
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::instruments::{Bond, Deposit, Fra, OisSwap, SwapFixedFloat, SwapSchedule};
564    use crate::interpolation::SplineBoundary;
565    use crate::types::Frequency;
566
567    fn d(y: i32, m: u32, day: u32) -> Date {
568        Date::from_ymd(y, m, day).unwrap()
569    }
570
571    // ─── Default configuration ────────────────────────────────────────────
572
573    #[test]
574    fn bootstrap_config_default_values_match_spec() {
575        let cfg = BootstrapConfig::default();
576        assert!((cfg.tolerance - 1e-12).abs() < 1e-18);
577        assert_eq!(cfg.max_iter, 100);
578        assert!((cfg.bracket - 0.5).abs() < 1e-15);
579        assert!(cfg.iterative);
580        assert_eq!(cfg.iter_max, 8);
581        assert!((cfg.iter_tol - 1e-14).abs() < 1e-20);
582    }
583
584    #[test]
585    fn bootstrap_with_config_round_trip() {
586        let reference = d(2024, 1, 2);
587        let cfg = BootstrapConfig {
588            tolerance: 1e-10,
589            max_iter: 50,
590            bracket: 0.25,
591            iterative: false,
592            iter_max: 4,
593            iter_tol: 1e-12,
594        };
595        let bs = Bootstrap::new(reference, Daycount::Act360).with_config(cfg);
596        assert_eq!(bs.config, cfg);
597    }
598
599    // ─── Validation errors ────────────────────────────────────────────────
600
601    #[test]
602    fn build_rejects_empty_instrument_list() {
603        let reference = d(2024, 1, 2);
604        let bs = Bootstrap::new(reference, Daycount::Act360);
605        let err = bs.build(&[], Interpolation::LogLinear).unwrap_err();
606        assert!(matches!(
607            err,
608            BootstrapError::InvalidInstrument {
609                at_index: 0,
610                reason: "no instruments supplied",
611            }
612        ));
613    }
614
615    #[test]
616    fn build_rejects_pillar_on_or_before_reference_date() {
617        let reference = d(2024, 1, 2);
618        let dep = Deposit::new(reference, reference, 0.05, Daycount::Act360).unwrap();
619        let bs = Bootstrap::new(reference, Daycount::Act360);
620        let err = bs
621            .build(&[Instrument::Deposit(dep)], Interpolation::LogLinear)
622            .unwrap_err();
623        assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
624    }
625
626    #[test]
627    fn build_rejects_non_increasing_pillars() {
628        let reference = d(2024, 1, 2);
629        let dep_late = Deposit::new(reference, d(2024, 7, 2), 0.05, Daycount::Act360).unwrap();
630        let dep_early = Deposit::new(reference, d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
631        let bs = Bootstrap::new(reference, Daycount::Act360);
632        let err = bs
633            .build(
634                &[
635                    Instrument::Deposit(dep_late),
636                    Instrument::Deposit(dep_early),
637                ],
638                Interpolation::LogLinear,
639            )
640            .unwrap_err();
641        assert!(matches!(
642            err,
643            BootstrapError::NonIncreasingAnchor { at_index: 1 }
644        ));
645    }
646
647    // ─── Deposit-only bootstrap on a flat 5% curve ────────────────────────
648
649    #[test]
650    fn deposit_only_bootstrap_flat_5pct_log_linear() {
651        let reference = d(2024, 1, 2);
652        let dc = Daycount::Act360;
653        let rate = 0.05_f64;
654        let tenors = [
655            (d(2024, 2, 2)), // ~1M
656            (d(2024, 3, 2)), // ~2M
657            (d(2024, 4, 2)), // ~3M
658            (d(2024, 7, 2)), // ~6M
659        ];
660        let instruments: Vec<Instrument> = tenors
661            .iter()
662            .map(|&payment| {
663                Instrument::Deposit(Deposit::new(reference, payment, rate, dc).unwrap())
664            })
665            .collect();
666
667        let bs = Bootstrap::new(reference, dc);
668        let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
669
670        // Re-pricing certificate: every deposit's residual against the final
671        // curve must be < tolerance.
672        let times: Vec<f64> = curve.times().to_vec();
673        let discounts: Vec<f64> = curve.discounts().to_vec();
674        let snapshot = CurveSnapshot {
675            reference_date: reference,
676            daycount: dc,
677            times: &times,
678            discounts: &discounts,
679        };
680        for inst in &instruments {
681            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
682            assert!(
683                residual.abs() < 1e-12,
684                "deposit residual must be < 1e-12, got {residual}",
685            );
686        }
687
688        // Also confirm D(payment) ~ 1 / (1 + rate * tau).
689        for &payment in &tenors {
690            let tau = dc.year_fraction(reference, payment).unwrap();
691            let expected = 1.0 / (1.0 + rate * tau);
692            let got = curve.discount_at(payment).unwrap();
693            assert!(
694                (got - expected).abs() < 1e-12,
695                "D({payment:?}) -> {got}, expected {expected}",
696            );
697        }
698    }
699
700    // ─── Mixed instruments: deposits + FRAs + swap ────────────────────────
701
702    /// Builds a flat continuously-compounded curve at rate `r_c` evaluated on
703    /// a daily grid. Used to compute consistent par quotes for synthetic
704    /// instruments.
705    fn flat_curve_quotes_for_dep(reference: Date, dc: Daycount, r_c: f64, payment: Date) -> f64 {
706        // Simply-compounded deposit rate consistent with D(t) = exp(-r_c * t).
707        let tau = dc.year_fraction(reference, payment).unwrap();
708        let d_pay = (-r_c * tau).exp();
709        (1.0 / d_pay - 1.0) / tau
710    }
711
712    fn flat_curve_quotes_for_fra(dc: Daycount, r_c: f64, start: Date, end: Date) -> f64 {
713        let tau = dc.year_fraction(start, end).unwrap();
714        ((r_c * tau).exp() - 1.0) / tau
715    }
716
717    fn flat_par_swap_rate(
718        reference: Date,
719        start: Date,
720        maturity: Date,
721        freq: Frequency,
722        fixed_dc: Daycount,
723        curve_dc: Daycount,
724        r_c: f64,
725    ) -> f64 {
726        let schedule = SwapSchedule::from_regular(start, maturity, freq).unwrap();
727        let mut annuity = 0.0_f64;
728        for i in 0..schedule.len() {
729            let p_end = schedule.period_end(i);
730            let tau_i = fixed_dc
731                .year_fraction(schedule.period_start(i), p_end)
732                .unwrap();
733            let t_pay = curve_dc.year_fraction(reference, p_end).unwrap();
734            annuity += tau_i * (-r_c * t_pay).exp();
735        }
736        let t_start = curve_dc.year_fraction(reference, start).unwrap();
737        let t_mat = curve_dc.year_fraction(reference, maturity).unwrap();
738        ((-r_c * t_start).exp() - (-r_c * t_mat).exp()) / annuity
739    }
740
741    #[test]
742    fn mixed_deposits_fras_swap_bootstrap_log_linear() {
743        let reference = d(2024, 1, 2);
744        let dc = Daycount::Act360;
745        let r_c = 0.04_f64;
746
747        let dep1_pay = d(2024, 4, 2);
748        let dep2_pay = d(2024, 7, 2);
749        let fra1_start = d(2024, 7, 2);
750        let fra1_end = d(2024, 10, 2);
751        let fra2_start = d(2024, 10, 2);
752        let fra2_end = d(2025, 1, 2);
753        let swap_start = reference;
754        let swap_maturity = d(2026, 1, 2);
755
756        let dep1 = Deposit::new(
757            reference,
758            dep1_pay,
759            flat_curve_quotes_for_dep(reference, dc, r_c, dep1_pay),
760            dc,
761        )
762        .unwrap();
763        let dep2 = Deposit::new(
764            reference,
765            dep2_pay,
766            flat_curve_quotes_for_dep(reference, dc, r_c, dep2_pay),
767            dc,
768        )
769        .unwrap();
770        let fra1 = Fra::new(
771            fra1_start,
772            fra1_end,
773            flat_curve_quotes_for_fra(dc, r_c, fra1_start, fra1_end),
774            dc,
775        )
776        .unwrap();
777        let fra2 = Fra::new(
778            fra2_start,
779            fra2_end,
780            flat_curve_quotes_for_fra(dc, r_c, fra2_start, fra2_end),
781            dc,
782        )
783        .unwrap();
784        let par = flat_par_swap_rate(
785            reference,
786            swap_start,
787            swap_maturity,
788            Frequency::SemiAnnual,
789            Daycount::Act360,
790            dc,
791            r_c,
792        );
793        let swap = SwapFixedFloat::new(
794            swap_start,
795            swap_maturity,
796            par,
797            Frequency::SemiAnnual,
798            Daycount::Act360,
799            Frequency::Quarterly,
800            Daycount::Act360,
801        )
802        .unwrap();
803
804        let instruments = [
805            Instrument::Deposit(dep1),
806            Instrument::Deposit(dep2),
807            Instrument::Fra(fra1),
808            Instrument::Fra(fra2),
809            Instrument::SwapFixedFloat(swap),
810        ];
811        let bs = Bootstrap::new(reference, dc);
812        let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
813
814        let times: Vec<f64> = curve.times().to_vec();
815        let discounts: Vec<f64> = curve.discounts().to_vec();
816        let snapshot = CurveSnapshot {
817            reference_date: reference,
818            daycount: dc,
819            times: &times,
820            discounts: &discounts,
821        };
822        for inst in &instruments {
823            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
824            assert!(
825                residual.abs() < 1e-10,
826                "mixed residual must be < 1e-10, got {residual}",
827            );
828        }
829    }
830
831    // ─── OIS-only bootstrap ───────────────────────────────────────────────
832
833    #[test]
834    fn ois_only_bootstrap_log_linear() {
835        let reference = d(2024, 1, 2);
836        let dc = Daycount::Act360;
837        let r_c = 0.03_f64;
838
839        let m_1y = d(2025, 1, 2);
840        let m_2y = d(2026, 1, 2);
841        let m_5y = d(2029, 1, 2);
842
843        let par_1y = flat_par_swap_rate(reference, reference, m_1y, Frequency::Annual, dc, dc, r_c);
844        let par_2y = flat_par_swap_rate(reference, reference, m_2y, Frequency::Annual, dc, dc, r_c);
845        let par_5y = flat_par_swap_rate(reference, reference, m_5y, Frequency::Annual, dc, dc, r_c);
846
847        let ois_1y = OisSwap::new(reference, m_1y, par_1y, Frequency::Annual, dc).unwrap();
848        let ois_2y = OisSwap::new(reference, m_2y, par_2y, Frequency::Annual, dc).unwrap();
849        let ois_5y = OisSwap::new(reference, m_5y, par_5y, Frequency::Annual, dc).unwrap();
850        let instruments = [
851            Instrument::OisSwap(ois_1y),
852            Instrument::OisSwap(ois_2y),
853            Instrument::OisSwap(ois_5y),
854        ];
855
856        let bs = Bootstrap::new(reference, dc);
857        let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
858        let times: Vec<f64> = curve.times().to_vec();
859        let discounts: Vec<f64> = curve.discounts().to_vec();
860        let snapshot = CurveSnapshot {
861            reference_date: reference,
862            daycount: dc,
863            times: &times,
864            discounts: &discounts,
865        };
866        for inst in &instruments {
867            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
868            assert!(
869                residual.abs() < 1e-10,
870                "OIS residual must be < 1e-10, got {residual}",
871            );
872        }
873    }
874
875    // ─── Cubic spline bootstrap with outer iteration ──────────────────────
876
877    #[test]
878    fn cubic_spline_bootstrap_converges_via_outer_iteration() {
879        let reference = d(2024, 1, 2);
880        let dc = Daycount::Act360;
881        let r_c = 0.04_f64;
882
883        let dep1_pay = d(2024, 4, 2);
884        let dep2_pay = d(2024, 7, 2);
885        let fra1_start = d(2024, 7, 2);
886        let fra1_end = d(2024, 10, 2);
887        let fra2_start = d(2024, 10, 2);
888        let fra2_end = d(2025, 1, 2);
889        let swap_start = reference;
890        let swap_maturity = d(2026, 1, 2);
891
892        let dep1 = Deposit::new(
893            reference,
894            dep1_pay,
895            flat_curve_quotes_for_dep(reference, dc, r_c, dep1_pay),
896            dc,
897        )
898        .unwrap();
899        let dep2 = Deposit::new(
900            reference,
901            dep2_pay,
902            flat_curve_quotes_for_dep(reference, dc, r_c, dep2_pay),
903            dc,
904        )
905        .unwrap();
906        let fra1 = Fra::new(
907            fra1_start,
908            fra1_end,
909            flat_curve_quotes_for_fra(dc, r_c, fra1_start, fra1_end),
910            dc,
911        )
912        .unwrap();
913        let fra2 = Fra::new(
914            fra2_start,
915            fra2_end,
916            flat_curve_quotes_for_fra(dc, r_c, fra2_start, fra2_end),
917            dc,
918        )
919        .unwrap();
920        let par = flat_par_swap_rate(
921            reference,
922            swap_start,
923            swap_maturity,
924            Frequency::SemiAnnual,
925            Daycount::Act360,
926            dc,
927            r_c,
928        );
929        let swap = SwapFixedFloat::new(
930            swap_start,
931            swap_maturity,
932            par,
933            Frequency::SemiAnnual,
934            Daycount::Act360,
935            Frequency::Quarterly,
936            Daycount::Act360,
937        )
938        .unwrap();
939
940        let instruments = [
941            Instrument::Deposit(dep1),
942            Instrument::Deposit(dep2),
943            Instrument::Fra(fra1),
944            Instrument::Fra(fra2),
945            Instrument::SwapFixedFloat(swap),
946        ];
947        let bs = Bootstrap::new(reference, dc);
948        let curve = bs
949            .build(
950                &instruments,
951                Interpolation::CubicSpline(SplineBoundary::NotAKnot),
952            )
953            .unwrap();
954
955        let times: Vec<f64> = curve.times().to_vec();
956        let discounts: Vec<f64> = curve.discounts().to_vec();
957        let snapshot = CurveSnapshot {
958            reference_date: reference,
959            daycount: dc,
960            times: &times,
961            discounts: &discounts,
962        };
963        for inst in &instruments {
964            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
965            assert!(
966                residual.abs() < 1e-10,
967                "cubic spline residual must be < 1e-10, got {residual}",
968            );
969        }
970    }
971
972    // ─── Every interpolation method works on a small bootstrap ────────────
973
974    #[test]
975    fn every_interpolation_method_converges_on_three_deposits() {
976        let reference = d(2024, 1, 2);
977        let dc = Daycount::Act360;
978        let r_c = 0.04_f64;
979        let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2)];
980        let instruments: Vec<Instrument> = payments
981            .iter()
982            .map(|&p| {
983                Instrument::Deposit(
984                    Deposit::new(
985                        reference,
986                        p,
987                        flat_curve_quotes_for_dep(reference, dc, r_c, p),
988                        dc,
989                    )
990                    .unwrap(),
991                )
992            })
993            .collect();
994
995        let methods = [
996            Interpolation::Linear,
997            Interpolation::LogLinear,
998            Interpolation::LinearInZero,
999            Interpolation::PiecewiseConstantForward,
1000            Interpolation::CubicSpline(SplineBoundary::NotAKnot),
1001            Interpolation::ConvexMonotone,
1002            Interpolation::HermiteBessel,
1003            Interpolation::MonotoneCubic,
1004            Interpolation::MonotoneHyman,
1005            Interpolation::MonotoneSteffen,
1006        ];
1007        let bs = Bootstrap::new(reference, dc);
1008        for method in methods {
1009            let curve = bs.build(&instruments, method).unwrap_or_else(|e| {
1010                panic!("method {method:?} failed: {e:?}");
1011            });
1012            let times: Vec<f64> = curve.times().to_vec();
1013            let discounts: Vec<f64> = curve.discounts().to_vec();
1014            let snapshot = CurveSnapshot {
1015                reference_date: reference,
1016                daycount: dc,
1017                times: &times,
1018                discounts: &discounts,
1019            };
1020            for inst in &instruments {
1021                let r = instrument_residual(inst, reference, &snapshot).unwrap();
1022                assert!(r.abs() < 1e-10, "method {method:?}: residual {r}");
1023            }
1024        }
1025    }
1026
1027    // ─── No-bracket failure path ──────────────────────────────────────────
1028
1029    #[test]
1030    fn no_bracket_failure_on_pathological_deposit_rate() {
1031        // A deposit with rate = -100 over ~91 days has growth factor
1032        // 1 + r * tau ≈ 1 - 25.28 = -24.28. The residual
1033        // `D(fix)/D(pay) - (1 + r*tau)` is `positive/positive + 24.28`,
1034        // which is always strictly positive — no sign change exists. The
1035        // bracket-expanding search should give up with NoBracket.
1036        let reference = d(2024, 1, 2);
1037        let dc = Daycount::Act360;
1038        let dep = Deposit::new(reference, d(2024, 4, 2), -100.0, dc).unwrap();
1039        let bs = Bootstrap::new(reference, dc);
1040        let res = bs.build(&[Instrument::Deposit(dep)], Interpolation::LogLinear);
1041        match res {
1042            Err(BootstrapError::NoBracket { at_index: 0 }) => {}
1043            Err(other) => panic!("expected NoBracket, got {other:?}"),
1044            Ok(_) => panic!("expected NoBracket error, got Ok"),
1045        }
1046    }
1047
1048    // ─── method_is_nonlocal helper coverage ───────────────────────────────
1049
1050    #[test]
1051    fn method_is_nonlocal_distinguishes_local_and_global_methods() {
1052        assert!(method_is_nonlocal(Interpolation::CubicSpline(
1053            SplineBoundary::NotAKnot
1054        )));
1055        assert!(method_is_nonlocal(Interpolation::HermiteBessel));
1056        assert!(method_is_nonlocal(Interpolation::MonotoneHyman));
1057        assert!(!method_is_nonlocal(Interpolation::ConvexMonotone));
1058        assert!(!method_is_nonlocal(Interpolation::Linear));
1059        assert!(!method_is_nonlocal(Interpolation::LogLinear));
1060        assert!(!method_is_nonlocal(Interpolation::LinearInZero));
1061        assert!(!method_is_nonlocal(Interpolation::PiecewiseConstantForward));
1062        assert!(!method_is_nonlocal(Interpolation::MonotoneCubic));
1063        assert!(!method_is_nonlocal(Interpolation::MonotoneSteffen));
1064    }
1065
1066    // ─── Debug formatting ─────────────────────────────────────────────────
1067
1068    #[test]
1069    fn bootstrap_debug_includes_reference_date() {
1070        let bs = Bootstrap::new(d(2024, 1, 2), Daycount::Act360);
1071        let dbg = format!("{bs:?}");
1072        assert!(dbg.contains("Bootstrap"));
1073    }
1074
1075    #[test]
1076    fn bootstrap_config_debug_includes_struct_name() {
1077        let cfg = BootstrapConfig::default();
1078        let dbg = format!("{cfg:?}");
1079        assert!(dbg.contains("BootstrapConfig"));
1080    }
1081
1082    // ─── Iterative flag disables outer iteration ──────────────────────────
1083
1084    #[test]
1085    fn iterative_off_skips_outer_iteration_local_methods_still_work() {
1086        // For local methods, the outer iteration is a no-op anyway. Verify
1087        // the engine produces the same curve regardless of `iterative`.
1088        let reference = d(2024, 1, 2);
1089        let dc = Daycount::Act360;
1090        let r_c = 0.04_f64;
1091        let p1 = d(2024, 4, 2);
1092        let p2 = d(2024, 7, 2);
1093        let dep1 = Deposit::new(
1094            reference,
1095            p1,
1096            flat_curve_quotes_for_dep(reference, dc, r_c, p1),
1097            dc,
1098        )
1099        .unwrap();
1100        let dep2 = Deposit::new(
1101            reference,
1102            p2,
1103            flat_curve_quotes_for_dep(reference, dc, r_c, p2),
1104            dc,
1105        )
1106        .unwrap();
1107        let instruments = [Instrument::Deposit(dep1), Instrument::Deposit(dep2)];
1108
1109        let bs_on = Bootstrap::new(reference, dc);
1110        let bs_off = Bootstrap::new(reference, dc).with_config(BootstrapConfig {
1111            iterative: false,
1112            ..BootstrapConfig::default()
1113        });
1114        let curve_on = bs_on.build(&instruments, Interpolation::LogLinear).unwrap();
1115        let curve_off = bs_off
1116            .build(&instruments, Interpolation::LogLinear)
1117            .unwrap();
1118        for (a, b) in curve_on
1119            .discounts()
1120            .iter()
1121            .zip(curve_off.discounts().iter())
1122        {
1123            assert!((a - b).abs() < 1e-14);
1124        }
1125    }
1126
1127    // ─── Anchor on bootstrapped curve ─────────────────────────────────────
1128
1129    #[test]
1130    fn bootstrapped_curve_has_canonical_anchor() {
1131        // The returned curve must satisfy D(reference_date) = 1 to f64
1132        // round-off. The anchor is appended internally before any leg is
1133        // solved.
1134        let reference = d(2024, 1, 2);
1135        let dc = Daycount::Act360;
1136        let dep = Deposit::new(reference, d(2024, 7, 2), 0.05, Daycount::Act360).unwrap();
1137        let bs = Bootstrap::new(reference, dc);
1138        let curve = bs
1139            .build(&[Instrument::Deposit(dep)], Interpolation::LogLinear)
1140            .unwrap();
1141        assert!((curve.times()[0] - 0.0).abs() < 1e-15);
1142        assert!((curve.discounts()[0] - 1.0).abs() < 1e-15);
1143        assert!((curve.discount(0.0).unwrap() - 1.0).abs() < 1e-15);
1144    }
1145
1146    // ─── Bootstrapped curve has the expected pillar count ─────────────────
1147
1148    #[test]
1149    fn bootstrapped_curve_pillar_count_equals_anchor_plus_n_instruments() {
1150        let reference = d(2024, 1, 2);
1151        let dc = Daycount::Act360;
1152        let r_c = 0.04_f64;
1153        let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2), d(2025, 1, 2)];
1154        let instruments: Vec<Instrument> = payments
1155            .iter()
1156            .map(|&p| {
1157                Instrument::Deposit(
1158                    Deposit::new(
1159                        reference,
1160                        p,
1161                        flat_curve_quotes_for_dep(reference, dc, r_c, p),
1162                        dc,
1163                    )
1164                    .unwrap(),
1165                )
1166            })
1167            .collect();
1168        let bs = Bootstrap::new(reference, dc);
1169        let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
1170        // Anchor + four deposits = five knots.
1171        assert_eq!(curve.times().len(), 5);
1172        assert_eq!(curve.discounts().len(), 5);
1173    }
1174
1175    // ─── Outer iteration finishes within the spec's 3-5 iteration budget ──
1176
1177    #[test]
1178    fn cubic_spline_outer_iteration_succeeds_with_small_iter_max() {
1179        // The spec claims that the consistent-input cubic spline test
1180        // converges within 3-5 outer iterations. Set iter_max = 5 and
1181        // verify it succeeds.
1182        let reference = d(2024, 1, 2);
1183        let dc = Daycount::Act360;
1184        let r_c = 0.04_f64;
1185        let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2), d(2025, 1, 2)];
1186        let instruments: Vec<Instrument> = payments
1187            .iter()
1188            .map(|&p| {
1189                Instrument::Deposit(
1190                    Deposit::new(
1191                        reference,
1192                        p,
1193                        flat_curve_quotes_for_dep(reference, dc, r_c, p),
1194                        dc,
1195                    )
1196                    .unwrap(),
1197                )
1198            })
1199            .collect();
1200        let bs = Bootstrap::new(reference, dc).with_config(BootstrapConfig {
1201            iter_max: 5,
1202            ..BootstrapConfig::default()
1203        });
1204        let curve = bs
1205            .build(
1206                &instruments,
1207                Interpolation::CubicSpline(SplineBoundary::NotAKnot),
1208            )
1209            .unwrap();
1210        // Re-pricing certificate still holds.
1211        let times: Vec<f64> = curve.times().to_vec();
1212        let discounts: Vec<f64> = curve.discounts().to_vec();
1213        let snapshot = CurveSnapshot {
1214            reference_date: reference,
1215            daycount: dc,
1216            times: &times,
1217            discounts: &discounts,
1218        };
1219        for inst in &instruments {
1220            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
1221            assert!(residual.abs() < 1e-10);
1222        }
1223    }
1224
1225    // ─── Outer iteration cap respected ────────────────────────────────────
1226
1227    #[test]
1228    fn cubic_spline_outer_iteration_caps_at_zero_returns_did_not_converge_or_ok() {
1229        // With iter_max = 0 the engine performs only the initial sweep and
1230        // skips the outer iteration entirely (the `for 0..0` loop body
1231        // never runs). On consistent inputs the initial sweep is already
1232        // very close to the fixed point, but the convergence check sees
1233        // zero changes (since no outer pass ran) — the function returns
1234        // `LegDidNotConverge` because `iter_max` ran out before a self-
1235        // consistent pass was observed.
1236        let reference = d(2024, 1, 2);
1237        let dc = Daycount::Act360;
1238        let r_c = 0.04_f64;
1239        let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2)];
1240        let instruments: Vec<Instrument> = payments
1241            .iter()
1242            .map(|&p| {
1243                Instrument::Deposit(
1244                    Deposit::new(
1245                        reference,
1246                        p,
1247                        flat_curve_quotes_for_dep(reference, dc, r_c, p),
1248                        dc,
1249                    )
1250                    .unwrap(),
1251                )
1252            })
1253            .collect();
1254        let bs = Bootstrap::new(reference, dc).with_config(BootstrapConfig {
1255            iter_max: 0,
1256            ..BootstrapConfig::default()
1257        });
1258        let res = bs.build(
1259            &instruments,
1260            Interpolation::CubicSpline(SplineBoundary::NotAKnot),
1261        );
1262        // With iter_max = 0 the outer loop never executes; the cap-exceeded
1263        // branch fires.
1264        assert!(matches!(
1265            res,
1266            Err(BootstrapError::LegDidNotConverge {
1267                at_index: usize::MAX,
1268                ..
1269            })
1270        ));
1271    }
1272
1273    // ─── Bond bootstrap integration ───────────────────────────────────────
1274
1275    /// Closed-form par-bond coupon consistent with `D(t) = exp(-r_c * t)`:
1276    /// solves `coupon * SUM_i tau_i * D(t_i) + D(t_N) = 1` for `coupon`.
1277    fn flat_par_bond_coupon(
1278        reference: Date,
1279        issue: Date,
1280        maturity: Date,
1281        freq: Frequency,
1282        coupon_dc: Daycount,
1283        curve_dc: Daycount,
1284        r_c: f64,
1285    ) -> f64 {
1286        let schedule = SwapSchedule::from_regular(issue, maturity, freq).unwrap();
1287        let mut annuity = 0.0_f64;
1288        for i in 0..schedule.len() {
1289            let s = schedule.period_start(i);
1290            let e = schedule.period_end(i);
1291            let tau = coupon_dc.year_fraction(s, e).unwrap();
1292            let t_pay = curve_dc.year_fraction(reference, e).unwrap();
1293            annuity += tau * (-r_c * t_pay).exp();
1294        }
1295        let t_n = curve_dc.year_fraction(reference, maturity).unwrap();
1296        (1.0 - (-r_c * t_n).exp()) / annuity
1297    }
1298
1299    #[test]
1300    fn bond_bootstrap_deposits_plus_three_bonds_reprices_all() {
1301        // 4 deposits + 3 par bonds (1y, 3y, 5y) at flat-curve-consistent
1302        // par coupons. Verify every instrument re-prices with residual
1303        // < 1e-10.
1304        let reference = d(2024, 1, 2);
1305        let dc = Daycount::Act360;
1306        let r_c = 0.04_f64;
1307
1308        // Four deposits to pin the short end.
1309        let dep_pays = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2), d(2025, 1, 2)];
1310        let mut instruments: Vec<Instrument> = dep_pays
1311            .iter()
1312            .map(|&p| {
1313                Instrument::Deposit(
1314                    Deposit::new(
1315                        reference,
1316                        p,
1317                        flat_curve_quotes_for_dep(reference, dc, r_c, p),
1318                        dc,
1319                    )
1320                    .unwrap(),
1321                )
1322            })
1323            .collect();
1324
1325        // Three bonds: 1y is the last deposit pillar, so use 2y/3y/5y to
1326        // keep pillars strictly increasing past the deposits.
1327        let bond_mats = [d(2026, 1, 2), d(2027, 1, 2), d(2029, 1, 2)];
1328        for &m in &bond_mats {
1329            let coupon =
1330                flat_par_bond_coupon(reference, reference, m, Frequency::Annual, dc, dc, r_c);
1331            let bond =
1332                Bond::new(reference, m, coupon, Frequency::Annual, dc, 1.0, 1.0, 0.0).unwrap();
1333            instruments.push(Instrument::Bond(bond));
1334        }
1335
1336        let bs = Bootstrap::new(reference, dc);
1337        let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
1338        let times: Vec<f64> = curve.times().to_vec();
1339        let discounts: Vec<f64> = curve.discounts().to_vec();
1340        let snapshot = CurveSnapshot {
1341            reference_date: reference,
1342            daycount: dc,
1343            times: &times,
1344            discounts: &discounts,
1345        };
1346        for inst in &instruments {
1347            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
1348            assert!(
1349                residual.abs() < 1e-10,
1350                "bond bootstrap residual must be < 1e-10, got {residual}",
1351            );
1352        }
1353    }
1354
1355    #[test]
1356    fn bond_bootstrap_mixed_with_swap_reprices_all() {
1357        // 2 deposits + 1 bond (3y) + 1 swap (5y). Verify all re-price.
1358        let reference = d(2024, 1, 2);
1359        let dc = Daycount::Act360;
1360        let r_c = 0.035_f64;
1361
1362        let dep1_pay = d(2024, 4, 2);
1363        let dep2_pay = d(2024, 7, 2);
1364        let bond_mat = d(2027, 1, 2);
1365        let swap_mat = d(2029, 1, 2);
1366
1367        let dep1 = Deposit::new(
1368            reference,
1369            dep1_pay,
1370            flat_curve_quotes_for_dep(reference, dc, r_c, dep1_pay),
1371            dc,
1372        )
1373        .unwrap();
1374        let dep2 = Deposit::new(
1375            reference,
1376            dep2_pay,
1377            flat_curve_quotes_for_dep(reference, dc, r_c, dep2_pay),
1378            dc,
1379        )
1380        .unwrap();
1381        let bond_coupon = flat_par_bond_coupon(
1382            reference,
1383            reference,
1384            bond_mat,
1385            Frequency::Annual,
1386            dc,
1387            dc,
1388            r_c,
1389        );
1390        let bond = Bond::new(
1391            reference,
1392            bond_mat,
1393            bond_coupon,
1394            Frequency::Annual,
1395            dc,
1396            1.0,
1397            1.0,
1398            0.0,
1399        )
1400        .unwrap();
1401        let par = flat_par_swap_rate(
1402            reference,
1403            reference,
1404            swap_mat,
1405            Frequency::SemiAnnual,
1406            Daycount::Act360,
1407            dc,
1408            r_c,
1409        );
1410        let swap = SwapFixedFloat::new(
1411            reference,
1412            swap_mat,
1413            par,
1414            Frequency::SemiAnnual,
1415            Daycount::Act360,
1416            Frequency::Quarterly,
1417            Daycount::Act360,
1418        )
1419        .unwrap();
1420
1421        let instruments = [
1422            Instrument::Deposit(dep1),
1423            Instrument::Deposit(dep2),
1424            Instrument::Bond(bond),
1425            Instrument::SwapFixedFloat(swap),
1426        ];
1427        let bs = Bootstrap::new(reference, dc);
1428        let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
1429        let times: Vec<f64> = curve.times().to_vec();
1430        let discounts: Vec<f64> = curve.discounts().to_vec();
1431        let snapshot = CurveSnapshot {
1432            reference_date: reference,
1433            daycount: dc,
1434            times: &times,
1435            discounts: &discounts,
1436        };
1437        for inst in &instruments {
1438            let residual = instrument_residual(inst, reference, &snapshot).unwrap();
1439            assert!(
1440                residual.abs() < 1e-10,
1441                "mixed bond+swap residual must be < 1e-10, got {residual}",
1442            );
1443        }
1444    }
1445}