1use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::core::daycount::DayCountConvention;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "snake_case")]
19pub enum Compounding {
20 #[default]
22 Continuous,
23 Annual,
25 Simple,
27}
28
29impl Compounding {
30 pub fn df(&self, z: f64, t: f64) -> f64 {
32 match self {
33 Compounding::Continuous => (-z * t).exp(),
34 Compounding::Annual => (1.0 + z).powf(-t),
35 Compounding::Simple => 1.0 / (1.0 + z * t),
36 }
37 }
38 pub fn rate(&self, df: f64, t: f64) -> f64 {
40 match self {
41 Compounding::Continuous => -df.ln() / t,
42 Compounding::Annual => df.powf(-1.0 / t) - 1.0,
43 Compounding::Simple => (1.0 / df - 1.0) / t,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum InterpolationMethod {
52 #[default]
54 LogLinearDf,
55 LinearZero,
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62#[serde(untagged)]
63pub enum Tenor {
64 Date(NaiveDate),
65 YearFraction(f64),
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73pub enum CurveInput {
74 Flat {
76 rate: f64,
77 #[serde(default)]
78 compounding: Compounding,
79 #[serde(default)]
80 day_count: DayCountConvention,
81 },
82 ZeroRates {
84 tenors: Vec<Tenor>,
85 rates: Vec<f64>,
86 #[serde(default)]
87 compounding: Compounding,
88 #[serde(default)]
89 day_count: DayCountConvention,
90 #[serde(default)]
91 interpolation: InterpolationMethod,
92 },
93 DiscountFactors {
96 tenors: Vec<Tenor>,
97 dfs: Vec<f64>,
98 #[serde(default)]
99 compounding: Compounding,
100 #[serde(default)]
101 day_count: DayCountConvention,
102 #[serde(default)]
103 interpolation: InterpolationMethod,
104 },
105 ForwardRates {
108 tenors: Vec<Tenor>,
109 forwards: Vec<f64>,
110 #[serde(default)]
111 compounding: Compounding,
112 #[serde(default)]
113 day_count: DayCountConvention,
114 #[serde(default)]
115 interpolation: InterpolationMethod,
116 },
117}
118
119#[derive(Debug, Clone, PartialEq)]
121pub enum CurveError {
122 Empty,
123 LengthMismatch { tenors: usize, values: usize },
124 NonPositiveDf(f64),
125 NonPositiveTime(f64),
126 NonIncreasingTimes,
127 InvalidForwardPeriod { t1: f64, t2: f64 },
128 TenorCollision { t1: f64, t2: f64 },
131}
132
133impl fmt::Display for CurveError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 match self {
136 CurveError::Empty => write!(f, "curve needs at least one pillar"),
137 CurveError::LengthMismatch { tenors, values } => {
138 write!(f, "tenors ({tenors}) and values ({values}) differ in length")
139 }
140 CurveError::NonPositiveDf(df) => write!(f, "discount factor must be > 0, got {df}"),
141 CurveError::NonPositiveTime(t) => write!(f, "pillar time must be > 0, got {t}"),
142 CurveError::NonIncreasingTimes => write!(f, "pillar times must be strictly increasing"),
143 CurveError::InvalidForwardPeriod { t1, t2 } => {
144 write!(f, "forward period requires t2 > t1 >= 0, got t1={t1}, t2={t2}")
145 }
146 CurveError::TenorCollision { t1, t2 } => {
147 write!(f, "bump tenors {t1} and {t2} resolve to the same curve pillar")
148 }
149 }
150 }
151}
152
153impl std::error::Error for CurveError {}
154
155#[derive(Debug, Clone, Copy)]
157pub struct CurvePillar {
158 pub date: Option<NaiveDate>,
160 pub time: f64,
161 pub df: f64,
162 pub zero_rate: f64,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct ForwardSegment {
170 pub t1: f64,
171 pub t2: f64,
172 pub forward: f64,
173}
174
175#[derive(Debug, Clone, PartialEq)]
179pub enum RateShift {
180 ParallelAbsolute(f64),
182 ParallelRelative(f64),
184 KeyRateAbsolute { tenors: Vec<f64>, shifts: Vec<f64> },
193}
194
195pub const KEY_RATE_TENOR_TOLERANCE: f64 = 0.01;
200
201#[derive(Debug, Clone, Serialize)]
208pub struct YieldCurve {
209 reference_date: NaiveDate,
210 day_count: DayCountConvention,
211 compounding: Compounding,
212 interpolation: InterpolationMethod,
213 times: Vec<f64>,
214 dfs: Vec<f64>,
215 dates: Vec<Option<NaiveDate>>,
216}
217
218const FLAT_CURVE_GRID: [f64; 13] = [
222 1.0 / 365.0,
223 0.25,
224 0.5,
225 1.0,
226 2.0,
227 3.0,
228 5.0,
229 7.0,
230 10.0,
231 15.0,
232 20.0,
233 30.0,
234 50.0,
235];
236
237impl YieldCurve {
238 pub fn flat(
242 rate: f64,
243 reference_date: NaiveDate,
244 day_count: DayCountConvention,
245 compounding: Compounding,
246 ) -> Result<Self, CurveError> {
247 let tenors: Vec<Tenor> = FLAT_CURVE_GRID.iter().map(|&t| Tenor::YearFraction(t)).collect();
248 let rates = vec![rate; tenors.len()];
249 Self::from_zero_rates(
250 &tenors,
251 &rates,
252 reference_date,
253 day_count,
254 compounding,
255 InterpolationMethod::LogLinearDf,
256 )
257 }
258
259 pub fn from_zero_rates(
261 tenors: &[Tenor],
262 rates: &[f64],
263 reference_date: NaiveDate,
264 day_count: DayCountConvention,
265 compounding: Compounding,
266 interpolation: InterpolationMethod,
267 ) -> Result<Self, CurveError> {
268 let (times, dates) = Self::resolve_tenors(tenors, reference_date, day_count)?;
269 if rates.len() != times.len() {
270 return Err(CurveError::LengthMismatch { tenors: times.len(), values: rates.len() });
271 }
272 let dfs: Vec<f64> = times.iter().zip(rates).map(|(&t, &z)| compounding.df(z, t)).collect();
273 Self::from_parts(reference_date, day_count, compounding, interpolation, times, dfs, dates)
274 }
275
276 pub fn from_discount_factors(
278 tenors: &[Tenor],
279 dfs: &[f64],
280 reference_date: NaiveDate,
281 day_count: DayCountConvention,
282 compounding: Compounding,
283 interpolation: InterpolationMethod,
284 ) -> Result<Self, CurveError> {
285 let (times, dates) = Self::resolve_tenors(tenors, reference_date, day_count)?;
286 if dfs.len() != times.len() {
287 return Err(CurveError::LengthMismatch { tenors: times.len(), values: dfs.len() });
288 }
289 Self::from_parts(
290 reference_date,
291 day_count,
292 compounding,
293 interpolation,
294 times,
295 dfs.to_vec(),
296 dates,
297 )
298 }
299
300 pub fn from_forward_rates(
304 tenors: &[Tenor],
305 forwards: &[f64],
306 reference_date: NaiveDate,
307 day_count: DayCountConvention,
308 compounding: Compounding,
309 interpolation: InterpolationMethod,
310 ) -> Result<Self, CurveError> {
311 let (times, dates) = Self::resolve_tenors(tenors, reference_date, day_count)?;
312 if forwards.len() != times.len() {
313 return Err(CurveError::LengthMismatch { tenors: times.len(), values: forwards.len() });
314 }
315 let mut dfs = Vec::with_capacity(times.len());
316 let mut prev_t = 0.0;
317 let mut prev_df = 1.0;
318 for (&t, &fwd) in times.iter().zip(forwards) {
319 let df = prev_df * compounding.df(fwd, t - prev_t);
320 dfs.push(df);
321 prev_t = t;
322 prev_df = df;
323 }
324 Self::from_parts(reference_date, day_count, compounding, interpolation, times, dfs, dates)
325 }
326
327 pub fn from_input(input: &CurveInput, reference_date: NaiveDate) -> Result<Self, CurveError> {
329 match input {
330 CurveInput::Flat { rate, compounding, day_count } => {
331 Self::flat(*rate, reference_date, *day_count, *compounding)
332 }
333 CurveInput::ZeroRates { tenors, rates, compounding, day_count, interpolation } => {
334 Self::from_zero_rates(tenors, rates, reference_date, *day_count, *compounding, *interpolation)
335 }
336 CurveInput::DiscountFactors { tenors, dfs, compounding, day_count, interpolation } => {
337 Self::from_discount_factors(tenors, dfs, reference_date, *day_count, *compounding, *interpolation)
338 }
339 CurveInput::ForwardRates { tenors, forwards, compounding, day_count, interpolation } => {
340 Self::from_forward_rates(tenors, forwards, reference_date, *day_count, *compounding, *interpolation)
341 }
342 }
343 }
344
345 pub fn bumped(&self, shift: &RateShift) -> Result<YieldCurve, CurveError> {
353 let mut bumped = self.clone();
354 match shift {
355 RateShift::ParallelAbsolute(d) => {
356 for (df, &t) in bumped.dfs.iter_mut().zip(self.times.iter()) {
357 *df *= (-d * t).exp();
358 }
359 }
360 RateShift::ParallelRelative(r) => {
361 for df in bumped.dfs.iter_mut() {
362 *df = df.powf(1.0 + r);
363 }
364 }
365 RateShift::KeyRateAbsolute { tenors, shifts } => {
366 Self::validate_key_rate(tenors, shifts)?;
367 let mut targets: Vec<usize> = Vec::with_capacity(tenors.len());
372 let mut prev: Option<(usize, f64)> = None;
373 for &tenor in tenors {
374 let idx = match bumped.nearest_pillar(tenor) {
375 Some(i) => i,
376 None => bumped.insert_pillar(tenor, self.df(tenor)),
377 };
378 if let Some((prev_idx, prev_tenor)) = prev {
379 if idx <= prev_idx {
380 return Err(CurveError::TenorCollision { t1: prev_tenor, t2: tenor });
381 }
382 }
383 prev = Some((idx, tenor));
384 targets.push(idx);
385 }
386 for (&idx, &d) in targets.iter().zip(shifts) {
388 bumped.dfs[idx] *= (-d * bumped.times[idx]).exp();
389 }
390 }
391 }
392 Ok(bumped)
393 }
394
395 pub fn min_forward(&self) -> ForwardSegment {
403 let mut worst = ForwardSegment { t1: 0.0, t2: 0.0, forward: f64::INFINITY };
404 for i in 0..self.times.len() - 1 {
405 let (t1, t2) = (self.times[i], self.times[i + 1]);
406 let forward = (self.dfs[i] / self.dfs[i + 1]).ln() / (t2 - t1);
407 if forward < worst.forward {
408 worst = ForwardSegment { t1, t2, forward };
409 }
410 }
411 worst
412 }
413
414 pub fn df(&self, t: f64) -> f64 {
420 if t <= 0.0 {
421 return 1.0;
422 }
423 let n = self.times.len();
424 let t_last = self.times[n - 1];
425 if t >= t_last {
426 let z_last = -self.dfs[n - 1].ln() / t_last;
428 return (-z_last * t).exp();
429 }
430 let (idx, w) = crate::core::interpolation::bracket(&self.times, t);
432 let (df0, df1) = (self.dfs[idx - 1], self.dfs[idx]);
433 match self.interpolation {
434 InterpolationMethod::LogLinearDf => {
435 crate::core::interpolation::lerp(df0.ln(), df1.ln(), w).exp()
436 }
437 InterpolationMethod::LinearZero => {
438 let z0 = self.pillar_zero(idx - 1);
439 let z1 = self.pillar_zero(idx);
440 let z = crate::core::interpolation::lerp(z0, z1, w);
441 (-z * t).exp()
442 }
443 }
444 }
445
446 pub fn df_date(&self, date: NaiveDate) -> f64 {
448 self.df(self.day_count.year_fraction(self.reference_date, date))
449 }
450
451 pub fn zero_rate(&self, t: f64) -> f64 {
453 self.zero_rate_with(t, self.compounding)
454 }
455
456 pub fn zero_rate_with(&self, t: f64, compounding: Compounding) -> f64 {
458 if t <= 0.0 {
459 return 0.0;
460 }
461 compounding.rate(self.df(t), t)
462 }
463
464 pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, CurveError> {
466 self.forward_rate_with(t1, t2, self.compounding)
467 }
468
469 pub fn forward_rate_with(
472 &self,
473 t1: f64,
474 t2: f64,
475 compounding: Compounding,
476 ) -> Result<f64, CurveError> {
477 if !(t2 > t1 && t1 >= 0.0) {
478 return Err(CurveError::InvalidForwardPeriod { t1, t2 });
479 }
480 let df12 = self.df(t2) / self.df(t1);
481 Ok(compounding.rate(df12, t2 - t1))
482 }
483
484 pub fn reference_date(&self) -> NaiveDate {
485 self.reference_date
486 }
487 pub fn day_count(&self) -> DayCountConvention {
488 self.day_count
489 }
490 pub fn compounding(&self) -> Compounding {
491 self.compounding
492 }
493
494 pub fn pillars(&self) -> Vec<CurvePillar> {
499 (1..self.times.len())
500 .map(|i| CurvePillar {
501 date: self.dates[i],
502 time: self.times[i],
503 df: self.dfs[i],
504 zero_rate: self.pillar_zero(i),
505 })
506 .collect()
507 }
508
509 fn pillar_zero(&self, i: usize) -> f64 {
514 if self.times[i] <= 0.0 {
515 return -self.dfs[1].ln() / self.times[1];
517 }
518 -self.dfs[i].ln() / self.times[i]
519 }
520
521 fn validate_key_rate(tenors: &[f64], shifts: &[f64]) -> Result<(), CurveError> {
522 if tenors.is_empty() {
523 return Err(CurveError::Empty);
524 }
525 if tenors.len() != shifts.len() {
526 return Err(CurveError::LengthMismatch { tenors: tenors.len(), values: shifts.len() });
527 }
528 for &t in tenors {
529 if t <= 0.0 {
530 return Err(CurveError::NonPositiveTime(t));
531 }
532 }
533 if tenors.windows(2).any(|w| w[1] <= w[0]) {
534 return Err(CurveError::NonIncreasingTimes);
535 }
536 Ok(())
537 }
538
539 fn nearest_pillar(&self, t: f64) -> Option<usize> {
542 let mut best: Option<usize> = None;
543 for i in 1..self.times.len() {
544 let dist = (self.times[i] - t).abs();
545 if dist <= KEY_RATE_TENOR_TOLERANCE
546 && best.map_or(true, |j| dist < (self.times[j] - t).abs())
547 {
548 best = Some(i);
549 }
550 }
551 best
552 }
553
554 fn insert_pillar(&mut self, t: f64, df: f64) -> usize {
557 let idx = self.times.partition_point(|&x| x < t);
558 self.times.insert(idx, t);
559 self.dfs.insert(idx, df);
560 self.dates.insert(idx, None);
561 idx
562 }
563
564 fn resolve_tenors(
565 tenors: &[Tenor],
566 reference_date: NaiveDate,
567 day_count: DayCountConvention,
568 ) -> Result<(Vec<f64>, Vec<Option<NaiveDate>>), CurveError> {
569 if tenors.is_empty() {
570 return Err(CurveError::Empty);
571 }
572 let mut times = Vec::with_capacity(tenors.len());
573 let mut dates = Vec::with_capacity(tenors.len());
574 for tenor in tenors {
575 match tenor {
576 Tenor::Date(d) => {
577 times.push(day_count.year_fraction(reference_date, *d));
578 dates.push(Some(*d));
579 }
580 Tenor::YearFraction(t) => {
581 times.push(*t);
582 dates.push(None);
583 }
584 }
585 }
586 Ok((times, dates))
587 }
588
589 fn from_parts(
590 reference_date: NaiveDate,
591 day_count: DayCountConvention,
592 compounding: Compounding,
593 interpolation: InterpolationMethod,
594 mut times: Vec<f64>,
595 mut dfs: Vec<f64>,
596 mut dates: Vec<Option<NaiveDate>>,
597 ) -> Result<Self, CurveError> {
598 for &t in × {
599 if t <= 0.0 {
600 return Err(CurveError::NonPositiveTime(t));
601 }
602 }
603 for &df in &dfs {
604 if df <= 0.0 {
606 return Err(CurveError::NonPositiveDf(df));
607 }
608 }
609 if times.windows(2).any(|w| w[1] <= w[0]) {
610 return Err(CurveError::NonIncreasingTimes);
611 }
612 times.insert(0, 0.0);
614 dfs.insert(0, 1.0);
615 dates.insert(0, Some(reference_date));
616 Ok(YieldCurve { reference_date, day_count, compounding, interpolation, times, dfs, dates })
617 }
618}
619
620impl fmt::Display for YieldCurve {
621 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
622 writeln!(
623 f,
624 "YieldCurve (ref {}, {:?}, {:?}, {:?})",
625 self.reference_date, self.day_count, self.compounding, self.interpolation
626 )?;
627 writeln!(f, "{:>12} {:>12} {:>12} {:>12}", "date", "time", "df", "zero(cont)")?;
628 for p in self.pillars() {
629 let date = p.date.map_or_else(|| "-".to_string(), |d| d.to_string());
630 writeln!(f, "{:>12} {:>12.6} {:>12.8} {:>12.6}", date, p.time, p.df, p.zero_rate)?;
631 }
632 let worst = self.min_forward();
633 writeln!(
634 f,
635 "min forward (cont): {:.6} on [{:.4}, {:.4}]",
636 worst.forward, worst.t1, worst.t2
637 )?;
638 Ok(())
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 fn asof() -> NaiveDate {
647 NaiveDate::from_ymd_opt(2026, 7, 16).unwrap()
648 }
649
650 fn flat_5pct() -> YieldCurve {
651 YieldCurve::flat(0.05, asof(), DayCountConvention::Act365, Compounding::Continuous).unwrap()
652 }
653
654 #[test]
655 fn bumped_shifts_continuous_zeros_exactly() {
656 let curve = flat_5pct();
657 let up = curve.bumped(&RateShift::ParallelAbsolute(0.01)).unwrap();
658 for t in [0.1, 1.0, 4.2, 10.0, 30.0, 60.0] {
659 assert!(
661 (up.df(t) - (-0.06_f64 * t).exp()).abs() < 1e-12,
662 "df({t}) = {}",
663 up.df(t)
664 );
665 assert!((up.zero_rate_with(t, Compounding::Continuous) - 0.06).abs() < 1e-12);
666 }
667 let scaled = curve.bumped(&RateShift::ParallelRelative(0.20)).unwrap();
669 assert!((scaled.zero_rate_with(1.0, Compounding::Continuous) - 0.06).abs() < 1e-12);
670 assert_eq!(up.df(0.0), 1.0);
672 assert!((curve.zero_rate_with(1.0, Compounding::Continuous) - 0.05).abs() < 1e-12);
673 }
674
675 fn key_rate(tenors: &[f64], shifts: &[f64]) -> RateShift {
676 RateShift::KeyRateAbsolute { tenors: tenors.to_vec(), shifts: shifts.to_vec() }
677 }
678
679 #[test]
680 fn key_rate_bump_moves_target_pillar_and_decays_to_neighbours() {
681 let curve = flat_5pct();
682 let up = curve.bumped(&key_rate(&[2.0], &[0.01])).unwrap();
683 let z = |c: &YieldCurve, t: f64| c.zero_rate_with(t, Compounding::Continuous);
684 assert!((z(&up, 2.0) - 0.06).abs() < 1e-12);
686 assert!((z(&up, 1.0) - 0.05).abs() < 1e-12);
687 assert!((z(&up, 3.0) - 0.05).abs() < 1e-12);
688 let mid = z(&up, 2.5);
690 assert!(mid > 0.05 + 1e-6 && mid < 0.06 - 1e-6, "mid-tent zero {mid}");
691 assert_eq!(up.pillars().len(), curve.pillars().len());
693 }
694
695 #[test]
696 fn key_rate_plateau_between_equally_bumped_tenors() {
697 let curve = flat_5pct();
698 let up = curve.bumped(&key_rate(&[1.0, 2.0], &[0.005, 0.005])).unwrap();
699 for t in [1.0, 1.25, 1.5, 1.75, 2.0] {
702 assert!(
703 (up.zero_rate_with(t, Compounding::Continuous) - 0.055).abs() < 1e-12,
704 "plateau broken at t={t}"
705 );
706 }
707 assert!((up.zero_rate_with(0.5, Compounding::Continuous) - 0.05).abs() < 1e-12);
709 assert!((up.zero_rate_with(3.0, Compounding::Continuous) - 0.05).abs() < 1e-12);
710 }
711
712 #[test]
713 fn key_rate_bumps_sum_exactly_to_parallel() {
714 let curve = flat_5pct();
715 let d = 0.0025;
716 let pillar_times: Vec<f64> = curve.pillars().iter().map(|p| p.time).collect();
717 let mut laddered = curve.clone();
720 for &t in &pillar_times {
721 laddered = laddered.bumped(&key_rate(&[t], &[d])).unwrap();
722 }
723 let parallel = curve.bumped(&RateShift::ParallelAbsolute(d)).unwrap();
724 for t in [0.1, 0.7, 1.0, 2.5, 9.0, 30.0, 55.0] {
725 assert!(
726 (laddered.df(t) - parallel.df(t)).abs() < 1e-14,
727 "ladder != parallel at t={t}: {} vs {}",
728 laddered.df(t),
729 parallel.df(t)
730 );
731 }
732 }
733
734 #[test]
735 fn key_rate_tenor_off_grid_inserts_a_pillar_exactly() {
736 let curve = flat_5pct();
737 let up = curve.bumped(&key_rate(&[1.5], &[0.01])).unwrap();
738 assert_eq!(up.pillars().len(), curve.pillars().len() + 1);
739 assert!((up.zero_rate_with(1.5, Compounding::Continuous) - 0.06).abs() < 1e-12);
742 assert!((up.zero_rate_with(1.0, Compounding::Continuous) - 0.05).abs() < 1e-12);
743 assert!((up.zero_rate_with(2.0, Compounding::Continuous) - 0.05).abs() < 1e-12);
744 let noop = curve.bumped(&key_rate(&[1.5], &[0.0])).unwrap();
746 for t in [0.3, 1.2, 1.5, 1.9, 4.0] {
747 assert!((noop.df(t) - curve.df(t)).abs() < 1e-15, "insertion changed df({t})");
748 }
749 }
750
751 #[test]
752 fn key_rate_tolerance_matches_nearby_pillar_instead_of_inserting() {
753 let pillar_date = NaiveDate::from_ymd_opt(2027, 7, 18).unwrap(); let curve = YieldCurve::from_zero_rates(
756 &[Tenor::Date(pillar_date), Tenor::YearFraction(2.0)],
757 &[0.05, 0.05],
758 asof(),
759 DayCountConvention::Act365,
760 Compounding::Continuous,
761 InterpolationMethod::LogLinearDf,
762 )
763 .unwrap();
764 let up = curve.bumped(&key_rate(&[1.0], &[0.01])).unwrap();
765 assert_eq!(up.pillars().len(), curve.pillars().len(), "must not insert");
766 let t_pillar = 367.0 / 365.0;
767 assert!((up.zero_rate_with(t_pillar, Compounding::Continuous) - 0.06).abs() < 1e-12);
768 }
769
770 #[test]
771 fn key_rate_validation_errors() {
772 let curve = flat_5pct();
773 assert_eq!(curve.bumped(&key_rate(&[], &[])).unwrap_err(), CurveError::Empty);
774 assert!(matches!(
775 curve.bumped(&key_rate(&[1.0], &[0.01, 0.02])).unwrap_err(),
776 CurveError::LengthMismatch { .. }
777 ));
778 assert!(matches!(
779 curve.bumped(&key_rate(&[-1.0], &[0.01])).unwrap_err(),
780 CurveError::NonPositiveTime(_)
781 ));
782 assert_eq!(
783 curve.bumped(&key_rate(&[2.0, 1.0], &[0.01, 0.01])).unwrap_err(),
784 CurveError::NonIncreasingTimes
785 );
786 assert!(matches!(
788 curve.bumped(&key_rate(&[1.0, 1.005], &[0.01, 0.01])).unwrap_err(),
789 CurveError::TenorCollision { .. }
790 ));
791 }
792
793 #[test]
794 fn min_forward_flags_negative_forwards_from_a_hard_down_bump() {
795 let curve = flat_5pct();
796 assert!((curve.min_forward().forward - 0.05).abs() < 1e-10, "flat curve forward");
797 let down = curve.bumped(&key_rate(&[10.0], &[-0.02])).unwrap();
800 let worst = down.min_forward();
801 assert!(worst.forward < 0.0, "expected negative forward, got {}", worst.forward);
802 assert!((worst.t1 - 7.0).abs() < 1e-12 && (worst.t2 - 10.0).abs() < 1e-12);
803 let gentle = curve.bumped(&key_rate(&[1.0, 2.0], &[-0.02, -0.02])).unwrap();
806 assert!(gentle.min_forward().forward > 0.0, "got {:?}", gentle.min_forward());
807 }
808
809 #[test]
810 fn flat_curve_matches_closed_form() {
811 let curve = flat_5pct();
812 for t in [0.1, 0.5, 1.0, 1.7, 4.2, 10.0, 30.0, 60.0] {
813 let expected = (-0.05_f64 * t).exp();
814 assert!(
815 (curve.df(t) - expected).abs() < 1e-12,
816 "t={t}: {} vs {expected}",
817 curve.df(t)
818 );
819 }
820 assert_eq!(curve.df(0.0), 1.0);
821 assert_eq!(curve.df(-1.0), 1.0);
822 }
823
824 #[test]
825 fn flat_curve_annual_compounding() {
826 let curve =
827 YieldCurve::flat(0.04, asof(), DayCountConvention::Act365, Compounding::Annual).unwrap();
828 assert!((curve.df(2.0) - 0.924556213018).abs() < 1e-10);
830 assert!((curve.df(1.3) - 1.04_f64.powf(-1.3)).abs() < 1e-12);
831 assert!((curve.zero_rate(2.0) - 0.04).abs() < 1e-12);
833 }
834
835 #[test]
836 fn simple_compounding_exact_at_pillars() {
837 let tenors = [Tenor::YearFraction(0.5), Tenor::YearFraction(2.0)];
838 let curve = YieldCurve::from_zero_rates(
839 &tenors,
840 &[0.04, 0.04],
841 asof(),
842 DayCountConvention::Act365,
843 Compounding::Simple,
844 InterpolationMethod::LogLinearDf,
845 )
846 .unwrap();
847 assert!((curve.df(2.0) - 0.925925925926).abs() < 1e-10);
848 assert!((curve.zero_rate(2.0) - 0.04).abs() < 1e-12);
849 }
850
851 #[test]
852 fn zero_rate_round_trip_all_compoundings() {
853 for comp in [Compounding::Continuous, Compounding::Annual, Compounding::Simple] {
854 for (z, t) in [(0.03, 0.5), (0.05, 1.0), (-0.005, 2.0), (0.07, 10.0)] {
855 let df = comp.df(z, t);
856 assert!(
857 (comp.rate(df, t) - z).abs() < 1e-12,
858 "{comp:?} z={z} t={t}"
859 );
860 }
861 }
862 }
863
864 #[test]
865 fn input_forms_agree_on_flat_curve() {
866 let tenors = [Tenor::YearFraction(1.0), Tenor::YearFraction(2.0), Tenor::YearFraction(5.0)];
868 let dc = DayCountConvention::Act365;
869 let comp = Compounding::Continuous;
870 let interp = InterpolationMethod::LogLinearDf;
871
872 let from_flat = YieldCurve::flat(0.05, asof(), dc, comp).unwrap();
873 let from_zeros =
874 YieldCurve::from_zero_rates(&tenors, &[0.05; 3], asof(), dc, comp, interp).unwrap();
875 let dfs: Vec<f64> = [1.0_f64, 2.0, 5.0].iter().map(|t| (-0.05 * t).exp()).collect();
876 let from_dfs =
877 YieldCurve::from_discount_factors(&tenors, &dfs, asof(), dc, comp, interp).unwrap();
878 let from_fwds =
879 YieldCurve::from_forward_rates(&tenors, &[0.05; 3], asof(), dc, comp, interp).unwrap();
880
881 for t in [0.3, 1.0, 1.7, 4.9] {
882 let reference = from_flat.df(t);
883 for (name, curve) in
884 [("zeros", &from_zeros), ("dfs", &from_dfs), ("fwds", &from_fwds)]
885 {
886 assert!(
887 (curve.df(t) - reference).abs() < 1e-12,
888 "{name} disagrees at t={t}"
889 );
890 }
891 }
892 }
893
894 #[test]
895 fn date_and_yearfraction_tenors_agree() {
896 let one_year_date = NaiveDate::from_ymd_opt(2027, 7, 16).unwrap(); let by_date = YieldCurve::from_zero_rates(
898 &[Tenor::Date(one_year_date)],
899 &[0.05],
900 asof(),
901 DayCountConvention::Act365,
902 Compounding::Continuous,
903 InterpolationMethod::LogLinearDf,
904 )
905 .unwrap();
906 let by_time = YieldCurve::from_zero_rates(
907 &[Tenor::YearFraction(1.0)],
908 &[0.05],
909 asof(),
910 DayCountConvention::Act365,
911 Compounding::Continuous,
912 InterpolationMethod::LogLinearDf,
913 )
914 .unwrap();
915 assert!((by_date.df(1.0) - by_time.df(1.0)).abs() < 1e-14);
916 assert!((by_date.df_date(one_year_date) - (-0.05_f64).exp()).abs() < 1e-14);
917 }
918
919 #[test]
920 fn log_linear_interpolation_between_pillars() {
921 let tenors = [Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)];
922 let dfs = [(-0.05_f64).exp(), (-0.12_f64).exp()];
923 let curve = YieldCurve::from_discount_factors(
924 &tenors,
925 &dfs,
926 asof(),
927 DayCountConvention::Act365,
928 Compounding::Continuous,
929 InterpolationMethod::LogLinearDf,
930 )
931 .unwrap();
932 assert!((curve.df(1.4) - 0.924964426544).abs() < 1e-10);
934 }
935
936 #[test]
937 fn forward_rate_on_flat_curve_equals_rate() {
938 let curve = flat_5pct();
939 let fwd = curve.forward_rate_with(1.0, 2.0, Compounding::Continuous).unwrap();
940 assert!((fwd - 0.05).abs() < 1e-10);
941 let fwd_simple = curve.forward_rate_with(1.0, 1.5, Compounding::Simple).unwrap();
943 let expected = ((0.05_f64 * 0.5).exp() - 1.0) / 0.5;
944 assert!((fwd_simple - expected).abs() < 1e-12);
945 assert!(curve.forward_rate_with(2.0, 1.0, Compounding::Simple).is_err());
946 }
947
948 #[test]
949 fn extrapolation_is_flat_in_zero_rate() {
950 let tenors = [Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)];
951 let curve = YieldCurve::from_zero_rates(
952 &tenors,
953 &[0.03, 0.05],
954 asof(),
955 DayCountConvention::Act365,
956 Compounding::Continuous,
957 InterpolationMethod::LogLinearDf,
958 )
959 .unwrap();
960 assert!((curve.zero_rate_with(7.0, Compounding::Continuous) - 0.05).abs() < 1e-12);
961 assert!((curve.df(7.0) - (-0.05_f64 * 7.0).exp()).abs() < 1e-12);
962 }
963
964 #[test]
965 fn negative_rates_allowed() {
966 let curve =
967 YieldCurve::flat(-0.005, asof(), DayCountConvention::Act365, Compounding::Continuous)
968 .unwrap();
969 assert!(curve.df(2.0) > 1.0);
970 assert!((curve.zero_rate(2.0) + 0.005).abs() < 1e-12);
971 }
972
973 #[test]
974 fn validation_errors() {
975 let dc = DayCountConvention::Act365;
976 let comp = Compounding::Continuous;
977 let interp = InterpolationMethod::LogLinearDf;
978 assert_eq!(
980 YieldCurve::from_zero_rates(&[], &[], asof(), dc, comp, interp).unwrap_err(),
981 CurveError::Empty
982 );
983 assert!(matches!(
985 YieldCurve::from_zero_rates(
986 &[Tenor::YearFraction(1.0)],
987 &[0.05, 0.06],
988 asof(),
989 dc,
990 comp,
991 interp
992 )
993 .unwrap_err(),
994 CurveError::LengthMismatch { .. }
995 ));
996 assert_eq!(
998 YieldCurve::from_zero_rates(
999 &[Tenor::YearFraction(2.0), Tenor::YearFraction(1.0)],
1000 &[0.05, 0.05],
1001 asof(),
1002 dc,
1003 comp,
1004 interp
1005 )
1006 .unwrap_err(),
1007 CurveError::NonIncreasingTimes
1008 );
1009 assert!(matches!(
1011 YieldCurve::from_zero_rates(&[Tenor::YearFraction(0.0)], &[0.05], asof(), dc, comp, interp)
1012 .unwrap_err(),
1013 CurveError::NonPositiveTime(_)
1014 ));
1015 assert!(matches!(
1017 YieldCurve::from_discount_factors(
1018 &[Tenor::YearFraction(1.0)],
1019 &[0.0],
1020 asof(),
1021 dc,
1022 comp,
1023 interp
1024 )
1025 .unwrap_err(),
1026 CurveError::NonPositiveDf(_)
1027 ));
1028 }
1029
1030 #[test]
1031 fn curve_input_deserializes_from_json() {
1032 let flat: CurveInput = serde_json::from_str(r#"{"type": "flat", "rate": 0.05}"#).unwrap();
1034 let curve = YieldCurve::from_input(&flat, asof()).unwrap();
1035 assert!((curve.df(1.0) - (-0.05_f64).exp()).abs() < 1e-12);
1036
1037 let zeros: CurveInput = serde_json::from_str(
1039 r#"{
1040 "type": "zero_rates",
1041 "tenors": [0.5, "2027-07-16", 5.0],
1042 "rates": [0.03, 0.04, 0.05],
1043 "compounding": "annual",
1044 "day_count": "Act365"
1045 }"#,
1046 )
1047 .unwrap();
1048 let curve = YieldCurve::from_input(&zeros, asof()).unwrap();
1049 assert!((curve.df(1.0) - 1.04_f64.powf(-1.0)).abs() < 1e-12);
1050 assert!((curve.zero_rate(1.0) - 0.04).abs() < 1e-12);
1051
1052 let dfs: CurveInput = serde_json::from_str(
1054 r#"{"type": "discount_factors", "tenors": [1.0, 2.0], "dfs": [0.95, 0.90]}"#,
1055 )
1056 .unwrap();
1057 let curve = YieldCurve::from_input(&dfs, asof()).unwrap();
1058 assert!((curve.df(1.0) - 0.95).abs() < 1e-12);
1059 }
1060
1061 #[test]
1062 fn display_prints_pillar_table() {
1063 let text = format!("{}", flat_5pct());
1064 assert!(text.contains("zero(cont)"));
1065 assert!(text.contains("0.05000")); }
1067}