1use 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#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct BootstrapConfig {
86 pub tolerance: f64,
88 pub max_iter: u32,
90 pub bracket: f64,
94 pub iterative: bool,
98 pub iter_max: u32,
100 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#[derive(Debug, Clone, Copy, PartialEq)]
156pub struct Bootstrap {
157 pub reference_date: Date,
159 pub daycount: Daycount,
161 pub config: BootstrapConfig,
163}
164
165impl Bootstrap {
166 #[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 #[must_use]
205 pub fn with_config(mut self, config: BootstrapConfig) -> Self {
206 self.config = config;
207 self
208 }
209
210 pub fn build(
255 &self,
256 instruments: &[Instrument],
257 method: Interpolation,
258 ) -> Result<DiscountCurve, BootstrapError> {
259 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 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 return Err(BootstrapError::NonIncreasingAnchor { at_index: i });
280 }
281 }
282 }
283
284 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); }
297
298 self.sweep(instruments, method, ×, &mut discounts, true)?;
300
301 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, ×, &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 let curve = DiscountCurve::from_times_and_discounts(
331 self.reference_date,
332 self.daycount,
333 ×,
334 &discounts,
335 method,
336 )?;
337 Ok(curve)
338 }
339
340 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; 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 let t_p2 = times[idx - 2];
365 let d_p2 = discounts[idx - 2];
366 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 let warm = discounts[idx];
380 if warm.is_finite() && warm > 0.0 {
381 warm
382 } else {
383 d_prev
384 }
385 };
386
387 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 fn solve_leg(&self, ctx: &LegContext<'_>) -> Result<f64, BootstrapError> {
408 let mut bracket = self.config.bracket;
409 #[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 instrument_residual(ctx.instrument, self.reference_date, &snapshot)
433 .unwrap_or(f64::INFINITY)
434 };
435
436 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 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
500struct LegContext<'a> {
503 index: usize,
505 instrument: &'a Instrument,
507 pillar_idx: usize,
510 d_guess: f64,
512 times: &'a [f64],
514 discounts: &'a [f64],
517}
518
519fn 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
538fn method_is_nonlocal(method: Interpolation) -> bool {
543 match method {
544 Interpolation::CubicSpline(_)
545 | Interpolation::HermiteBessel
546 | Interpolation::MonotoneHyman => true,
547 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 #[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 #[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 #[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)), (d(2024, 3, 2)), (d(2024, 4, 2)), (d(2024, 7, 2)), ];
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 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: ×,
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 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 fn flat_curve_quotes_for_dep(reference: Date, dc: Daycount, r_c: f64, payment: Date) -> f64 {
706 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: ×,
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 #[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: ×,
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 #[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: ×,
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 #[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: ×,
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 #[test]
1030 fn no_bracket_failure_on_pathological_deposit_rate() {
1031 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 #[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 #[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 #[test]
1085 fn iterative_off_skips_outer_iteration_local_methods_still_work() {
1086 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 #[test]
1130 fn bootstrapped_curve_has_canonical_anchor() {
1131 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 #[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 assert_eq!(curve.times().len(), 5);
1172 assert_eq!(curve.discounts().len(), 5);
1173 }
1174
1175 #[test]
1178 fn cubic_spline_outer_iteration_succeeds_with_small_iter_max() {
1179 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 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: ×,
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 #[test]
1228 fn cubic_spline_outer_iteration_caps_at_zero_returns_did_not_converge_or_ok() {
1229 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 assert!(matches!(
1265 res,
1266 Err(BootstrapError::LegDidNotConverge {
1267 at_index: usize::MAX,
1268 ..
1269 })
1270 ));
1271 }
1272
1273 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 let reference = d(2024, 1, 2);
1305 let dc = Daycount::Act360;
1306 let r_c = 0.04_f64;
1307
1308 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 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: ×,
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 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: ×,
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}