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}
129
130impl fmt::Display for CurveError {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 CurveError::Empty => write!(f, "curve needs at least one pillar"),
134 CurveError::LengthMismatch { tenors, values } => {
135 write!(f, "tenors ({tenors}) and values ({values}) differ in length")
136 }
137 CurveError::NonPositiveDf(df) => write!(f, "discount factor must be > 0, got {df}"),
138 CurveError::NonPositiveTime(t) => write!(f, "pillar time must be > 0, got {t}"),
139 CurveError::NonIncreasingTimes => write!(f, "pillar times must be strictly increasing"),
140 CurveError::InvalidForwardPeriod { t1, t2 } => {
141 write!(f, "forward period requires t2 > t1 >= 0, got t1={t1}, t2={t2}")
142 }
143 }
144 }
145}
146
147impl std::error::Error for CurveError {}
148
149#[derive(Debug, Clone, Copy)]
151pub struct CurvePillar {
152 pub date: Option<NaiveDate>,
154 pub time: f64,
155 pub df: f64,
156 pub zero_rate: f64,
158}
159
160#[derive(Debug, Clone, Serialize)]
167pub struct YieldCurve {
168 reference_date: NaiveDate,
169 day_count: DayCountConvention,
170 compounding: Compounding,
171 interpolation: InterpolationMethod,
172 times: Vec<f64>,
173 dfs: Vec<f64>,
174 dates: Vec<Option<NaiveDate>>,
175}
176
177const FLAT_CURVE_GRID: [f64; 13] = [
181 1.0 / 365.0,
182 0.25,
183 0.5,
184 1.0,
185 2.0,
186 3.0,
187 5.0,
188 7.0,
189 10.0,
190 15.0,
191 20.0,
192 30.0,
193 50.0,
194];
195
196impl YieldCurve {
197 pub fn flat(
201 rate: f64,
202 reference_date: NaiveDate,
203 day_count: DayCountConvention,
204 compounding: Compounding,
205 ) -> Result<Self, CurveError> {
206 let tenors: Vec<Tenor> = FLAT_CURVE_GRID.iter().map(|&t| Tenor::YearFraction(t)).collect();
207 let rates = vec![rate; tenors.len()];
208 Self::from_zero_rates(
209 &tenors,
210 &rates,
211 reference_date,
212 day_count,
213 compounding,
214 InterpolationMethod::LogLinearDf,
215 )
216 }
217
218 pub fn from_zero_rates(
220 tenors: &[Tenor],
221 rates: &[f64],
222 reference_date: NaiveDate,
223 day_count: DayCountConvention,
224 compounding: Compounding,
225 interpolation: InterpolationMethod,
226 ) -> Result<Self, CurveError> {
227 let (times, dates) = Self::resolve_tenors(tenors, reference_date, day_count)?;
228 if rates.len() != times.len() {
229 return Err(CurveError::LengthMismatch { tenors: times.len(), values: rates.len() });
230 }
231 let dfs: Vec<f64> = times.iter().zip(rates).map(|(&t, &z)| compounding.df(z, t)).collect();
232 Self::from_parts(reference_date, day_count, compounding, interpolation, times, dfs, dates)
233 }
234
235 pub fn from_discount_factors(
237 tenors: &[Tenor],
238 dfs: &[f64],
239 reference_date: NaiveDate,
240 day_count: DayCountConvention,
241 compounding: Compounding,
242 interpolation: InterpolationMethod,
243 ) -> Result<Self, CurveError> {
244 let (times, dates) = Self::resolve_tenors(tenors, reference_date, day_count)?;
245 if dfs.len() != times.len() {
246 return Err(CurveError::LengthMismatch { tenors: times.len(), values: dfs.len() });
247 }
248 Self::from_parts(
249 reference_date,
250 day_count,
251 compounding,
252 interpolation,
253 times,
254 dfs.to_vec(),
255 dates,
256 )
257 }
258
259 pub fn from_forward_rates(
263 tenors: &[Tenor],
264 forwards: &[f64],
265 reference_date: NaiveDate,
266 day_count: DayCountConvention,
267 compounding: Compounding,
268 interpolation: InterpolationMethod,
269 ) -> Result<Self, CurveError> {
270 let (times, dates) = Self::resolve_tenors(tenors, reference_date, day_count)?;
271 if forwards.len() != times.len() {
272 return Err(CurveError::LengthMismatch { tenors: times.len(), values: forwards.len() });
273 }
274 let mut dfs = Vec::with_capacity(times.len());
275 let mut prev_t = 0.0;
276 let mut prev_df = 1.0;
277 for (&t, &fwd) in times.iter().zip(forwards) {
278 let df = prev_df * compounding.df(fwd, t - prev_t);
279 dfs.push(df);
280 prev_t = t;
281 prev_df = df;
282 }
283 Self::from_parts(reference_date, day_count, compounding, interpolation, times, dfs, dates)
284 }
285
286 pub fn from_input(input: &CurveInput, reference_date: NaiveDate) -> Result<Self, CurveError> {
288 match input {
289 CurveInput::Flat { rate, compounding, day_count } => {
290 Self::flat(*rate, reference_date, *day_count, *compounding)
291 }
292 CurveInput::ZeroRates { tenors, rates, compounding, day_count, interpolation } => {
293 Self::from_zero_rates(tenors, rates, reference_date, *day_count, *compounding, *interpolation)
294 }
295 CurveInput::DiscountFactors { tenors, dfs, compounding, day_count, interpolation } => {
296 Self::from_discount_factors(tenors, dfs, reference_date, *day_count, *compounding, *interpolation)
297 }
298 CurveInput::ForwardRates { tenors, forwards, compounding, day_count, interpolation } => {
299 Self::from_forward_rates(tenors, forwards, reference_date, *day_count, *compounding, *interpolation)
300 }
301 }
302 }
303
304 pub fn df(&self, t: f64) -> f64 {
310 if t <= 0.0 {
311 return 1.0;
312 }
313 let n = self.times.len();
314 let t_last = self.times[n - 1];
315 if t >= t_last {
316 let z_last = -self.dfs[n - 1].ln() / t_last;
318 return (-z_last * t).exp();
319 }
320 let idx = self.times.partition_point(|&x| x < t);
322 let (t0, t1) = (self.times[idx - 1], self.times[idx]);
323 let (df0, df1) = (self.dfs[idx - 1], self.dfs[idx]);
324 let w = (t - t0) / (t1 - t0);
325 match self.interpolation {
326 InterpolationMethod::LogLinearDf => {
327 (df0.ln() * (1.0 - w) + df1.ln() * w).exp()
328 }
329 InterpolationMethod::LinearZero => {
330 let z0 = self.pillar_zero(idx - 1);
331 let z1 = self.pillar_zero(idx);
332 let z = z0 * (1.0 - w) + z1 * w;
333 (-z * t).exp()
334 }
335 }
336 }
337
338 pub fn df_date(&self, date: NaiveDate) -> f64 {
340 self.df(self.day_count.year_fraction(self.reference_date, date))
341 }
342
343 pub fn zero_rate(&self, t: f64) -> f64 {
345 self.zero_rate_with(t, self.compounding)
346 }
347
348 pub fn zero_rate_with(&self, t: f64, compounding: Compounding) -> f64 {
350 if t <= 0.0 {
351 return 0.0;
352 }
353 compounding.rate(self.df(t), t)
354 }
355
356 pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, CurveError> {
358 self.forward_rate_with(t1, t2, self.compounding)
359 }
360
361 pub fn forward_rate_with(
364 &self,
365 t1: f64,
366 t2: f64,
367 compounding: Compounding,
368 ) -> Result<f64, CurveError> {
369 if !(t2 > t1 && t1 >= 0.0) {
370 return Err(CurveError::InvalidForwardPeriod { t1, t2 });
371 }
372 let df12 = self.df(t2) / self.df(t1);
373 Ok(compounding.rate(df12, t2 - t1))
374 }
375
376 pub fn reference_date(&self) -> NaiveDate {
377 self.reference_date
378 }
379 pub fn day_count(&self) -> DayCountConvention {
380 self.day_count
381 }
382 pub fn compounding(&self) -> Compounding {
383 self.compounding
384 }
385
386 pub fn pillars(&self) -> Vec<CurvePillar> {
391 (1..self.times.len())
392 .map(|i| CurvePillar {
393 date: self.dates[i],
394 time: self.times[i],
395 df: self.dfs[i],
396 zero_rate: self.pillar_zero(i),
397 })
398 .collect()
399 }
400
401 fn pillar_zero(&self, i: usize) -> f64 {
406 if self.times[i] <= 0.0 {
407 return -self.dfs[1].ln() / self.times[1];
409 }
410 -self.dfs[i].ln() / self.times[i]
411 }
412
413 fn resolve_tenors(
414 tenors: &[Tenor],
415 reference_date: NaiveDate,
416 day_count: DayCountConvention,
417 ) -> Result<(Vec<f64>, Vec<Option<NaiveDate>>), CurveError> {
418 if tenors.is_empty() {
419 return Err(CurveError::Empty);
420 }
421 let mut times = Vec::with_capacity(tenors.len());
422 let mut dates = Vec::with_capacity(tenors.len());
423 for tenor in tenors {
424 match tenor {
425 Tenor::Date(d) => {
426 times.push(day_count.year_fraction(reference_date, *d));
427 dates.push(Some(*d));
428 }
429 Tenor::YearFraction(t) => {
430 times.push(*t);
431 dates.push(None);
432 }
433 }
434 }
435 Ok((times, dates))
436 }
437
438 fn from_parts(
439 reference_date: NaiveDate,
440 day_count: DayCountConvention,
441 compounding: Compounding,
442 interpolation: InterpolationMethod,
443 mut times: Vec<f64>,
444 mut dfs: Vec<f64>,
445 mut dates: Vec<Option<NaiveDate>>,
446 ) -> Result<Self, CurveError> {
447 for &t in × {
448 if t <= 0.0 {
449 return Err(CurveError::NonPositiveTime(t));
450 }
451 }
452 for &df in &dfs {
453 if df <= 0.0 {
455 return Err(CurveError::NonPositiveDf(df));
456 }
457 }
458 if times.windows(2).any(|w| w[1] <= w[0]) {
459 return Err(CurveError::NonIncreasingTimes);
460 }
461 times.insert(0, 0.0);
463 dfs.insert(0, 1.0);
464 dates.insert(0, Some(reference_date));
465 Ok(YieldCurve { reference_date, day_count, compounding, interpolation, times, dfs, dates })
466 }
467}
468
469impl fmt::Display for YieldCurve {
470 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
471 writeln!(
472 f,
473 "YieldCurve (ref {}, {:?}, {:?}, {:?})",
474 self.reference_date, self.day_count, self.compounding, self.interpolation
475 )?;
476 writeln!(f, "{:>12} {:>12} {:>12} {:>12}", "date", "time", "df", "zero(cont)")?;
477 for p in self.pillars() {
478 let date = p.date.map_or_else(|| "-".to_string(), |d| d.to_string());
479 writeln!(f, "{:>12} {:>12.6} {:>12.8} {:>12.6}", date, p.time, p.df, p.zero_rate)?;
480 }
481 Ok(())
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 fn asof() -> NaiveDate {
490 NaiveDate::from_ymd_opt(2026, 7, 16).unwrap()
491 }
492
493 fn flat_5pct() -> YieldCurve {
494 YieldCurve::flat(0.05, asof(), DayCountConvention::Act365, Compounding::Continuous).unwrap()
495 }
496
497 #[test]
498 fn flat_curve_matches_closed_form() {
499 let curve = flat_5pct();
500 for t in [0.1, 0.5, 1.0, 1.7, 4.2, 10.0, 30.0, 60.0] {
501 let expected = (-0.05_f64 * t).exp();
502 assert!(
503 (curve.df(t) - expected).abs() < 1e-12,
504 "t={t}: {} vs {expected}",
505 curve.df(t)
506 );
507 }
508 assert_eq!(curve.df(0.0), 1.0);
509 assert_eq!(curve.df(-1.0), 1.0);
510 }
511
512 #[test]
513 fn flat_curve_annual_compounding() {
514 let curve =
515 YieldCurve::flat(0.04, asof(), DayCountConvention::Act365, Compounding::Annual).unwrap();
516 assert!((curve.df(2.0) - 0.924556213018).abs() < 1e-10);
518 assert!((curve.df(1.3) - 1.04_f64.powf(-1.3)).abs() < 1e-12);
519 assert!((curve.zero_rate(2.0) - 0.04).abs() < 1e-12);
521 }
522
523 #[test]
524 fn simple_compounding_exact_at_pillars() {
525 let tenors = [Tenor::YearFraction(0.5), Tenor::YearFraction(2.0)];
526 let curve = YieldCurve::from_zero_rates(
527 &tenors,
528 &[0.04, 0.04],
529 asof(),
530 DayCountConvention::Act365,
531 Compounding::Simple,
532 InterpolationMethod::LogLinearDf,
533 )
534 .unwrap();
535 assert!((curve.df(2.0) - 0.925925925926).abs() < 1e-10);
536 assert!((curve.zero_rate(2.0) - 0.04).abs() < 1e-12);
537 }
538
539 #[test]
540 fn zero_rate_round_trip_all_compoundings() {
541 for comp in [Compounding::Continuous, Compounding::Annual, Compounding::Simple] {
542 for (z, t) in [(0.03, 0.5), (0.05, 1.0), (-0.005, 2.0), (0.07, 10.0)] {
543 let df = comp.df(z, t);
544 assert!(
545 (comp.rate(df, t) - z).abs() < 1e-12,
546 "{comp:?} z={z} t={t}"
547 );
548 }
549 }
550 }
551
552 #[test]
553 fn input_forms_agree_on_flat_curve() {
554 let tenors = [Tenor::YearFraction(1.0), Tenor::YearFraction(2.0), Tenor::YearFraction(5.0)];
556 let dc = DayCountConvention::Act365;
557 let comp = Compounding::Continuous;
558 let interp = InterpolationMethod::LogLinearDf;
559
560 let from_flat = YieldCurve::flat(0.05, asof(), dc, comp).unwrap();
561 let from_zeros =
562 YieldCurve::from_zero_rates(&tenors, &[0.05; 3], asof(), dc, comp, interp).unwrap();
563 let dfs: Vec<f64> = [1.0_f64, 2.0, 5.0].iter().map(|t| (-0.05 * t).exp()).collect();
564 let from_dfs =
565 YieldCurve::from_discount_factors(&tenors, &dfs, asof(), dc, comp, interp).unwrap();
566 let from_fwds =
567 YieldCurve::from_forward_rates(&tenors, &[0.05; 3], asof(), dc, comp, interp).unwrap();
568
569 for t in [0.3, 1.0, 1.7, 4.9] {
570 let reference = from_flat.df(t);
571 for (name, curve) in
572 [("zeros", &from_zeros), ("dfs", &from_dfs), ("fwds", &from_fwds)]
573 {
574 assert!(
575 (curve.df(t) - reference).abs() < 1e-12,
576 "{name} disagrees at t={t}"
577 );
578 }
579 }
580 }
581
582 #[test]
583 fn date_and_yearfraction_tenors_agree() {
584 let one_year_date = NaiveDate::from_ymd_opt(2027, 7, 16).unwrap(); let by_date = YieldCurve::from_zero_rates(
586 &[Tenor::Date(one_year_date)],
587 &[0.05],
588 asof(),
589 DayCountConvention::Act365,
590 Compounding::Continuous,
591 InterpolationMethod::LogLinearDf,
592 )
593 .unwrap();
594 let by_time = YieldCurve::from_zero_rates(
595 &[Tenor::YearFraction(1.0)],
596 &[0.05],
597 asof(),
598 DayCountConvention::Act365,
599 Compounding::Continuous,
600 InterpolationMethod::LogLinearDf,
601 )
602 .unwrap();
603 assert!((by_date.df(1.0) - by_time.df(1.0)).abs() < 1e-14);
604 assert!((by_date.df_date(one_year_date) - (-0.05_f64).exp()).abs() < 1e-14);
605 }
606
607 #[test]
608 fn log_linear_interpolation_between_pillars() {
609 let tenors = [Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)];
610 let dfs = [(-0.05_f64).exp(), (-0.12_f64).exp()];
611 let curve = YieldCurve::from_discount_factors(
612 &tenors,
613 &dfs,
614 asof(),
615 DayCountConvention::Act365,
616 Compounding::Continuous,
617 InterpolationMethod::LogLinearDf,
618 )
619 .unwrap();
620 assert!((curve.df(1.4) - 0.924964426544).abs() < 1e-10);
622 }
623
624 #[test]
625 fn forward_rate_on_flat_curve_equals_rate() {
626 let curve = flat_5pct();
627 let fwd = curve.forward_rate_with(1.0, 2.0, Compounding::Continuous).unwrap();
628 assert!((fwd - 0.05).abs() < 1e-10);
629 let fwd_simple = curve.forward_rate_with(1.0, 1.5, Compounding::Simple).unwrap();
631 let expected = ((0.05_f64 * 0.5).exp() - 1.0) / 0.5;
632 assert!((fwd_simple - expected).abs() < 1e-12);
633 assert!(curve.forward_rate_with(2.0, 1.0, Compounding::Simple).is_err());
634 }
635
636 #[test]
637 fn extrapolation_is_flat_in_zero_rate() {
638 let tenors = [Tenor::YearFraction(1.0), Tenor::YearFraction(2.0)];
639 let curve = YieldCurve::from_zero_rates(
640 &tenors,
641 &[0.03, 0.05],
642 asof(),
643 DayCountConvention::Act365,
644 Compounding::Continuous,
645 InterpolationMethod::LogLinearDf,
646 )
647 .unwrap();
648 assert!((curve.zero_rate_with(7.0, Compounding::Continuous) - 0.05).abs() < 1e-12);
649 assert!((curve.df(7.0) - (-0.05_f64 * 7.0).exp()).abs() < 1e-12);
650 }
651
652 #[test]
653 fn negative_rates_allowed() {
654 let curve =
655 YieldCurve::flat(-0.005, asof(), DayCountConvention::Act365, Compounding::Continuous)
656 .unwrap();
657 assert!(curve.df(2.0) > 1.0);
658 assert!((curve.zero_rate(2.0) + 0.005).abs() < 1e-12);
659 }
660
661 #[test]
662 fn validation_errors() {
663 let dc = DayCountConvention::Act365;
664 let comp = Compounding::Continuous;
665 let interp = InterpolationMethod::LogLinearDf;
666 assert_eq!(
668 YieldCurve::from_zero_rates(&[], &[], asof(), dc, comp, interp).unwrap_err(),
669 CurveError::Empty
670 );
671 assert!(matches!(
673 YieldCurve::from_zero_rates(
674 &[Tenor::YearFraction(1.0)],
675 &[0.05, 0.06],
676 asof(),
677 dc,
678 comp,
679 interp
680 )
681 .unwrap_err(),
682 CurveError::LengthMismatch { .. }
683 ));
684 assert_eq!(
686 YieldCurve::from_zero_rates(
687 &[Tenor::YearFraction(2.0), Tenor::YearFraction(1.0)],
688 &[0.05, 0.05],
689 asof(),
690 dc,
691 comp,
692 interp
693 )
694 .unwrap_err(),
695 CurveError::NonIncreasingTimes
696 );
697 assert!(matches!(
699 YieldCurve::from_zero_rates(&[Tenor::YearFraction(0.0)], &[0.05], asof(), dc, comp, interp)
700 .unwrap_err(),
701 CurveError::NonPositiveTime(_)
702 ));
703 assert!(matches!(
705 YieldCurve::from_discount_factors(
706 &[Tenor::YearFraction(1.0)],
707 &[0.0],
708 asof(),
709 dc,
710 comp,
711 interp
712 )
713 .unwrap_err(),
714 CurveError::NonPositiveDf(_)
715 ));
716 }
717
718 #[test]
719 fn curve_input_deserializes_from_json() {
720 let flat: CurveInput = serde_json::from_str(r#"{"type": "flat", "rate": 0.05}"#).unwrap();
722 let curve = YieldCurve::from_input(&flat, asof()).unwrap();
723 assert!((curve.df(1.0) - (-0.05_f64).exp()).abs() < 1e-12);
724
725 let zeros: CurveInput = serde_json::from_str(
727 r#"{
728 "type": "zero_rates",
729 "tenors": [0.5, "2027-07-16", 5.0],
730 "rates": [0.03, 0.04, 0.05],
731 "compounding": "annual",
732 "day_count": "Act365"
733 }"#,
734 )
735 .unwrap();
736 let curve = YieldCurve::from_input(&zeros, asof()).unwrap();
737 assert!((curve.df(1.0) - 1.04_f64.powf(-1.0)).abs() < 1e-12);
738 assert!((curve.zero_rate(1.0) - 0.04).abs() < 1e-12);
739
740 let dfs: CurveInput = serde_json::from_str(
742 r#"{"type": "discount_factors", "tenors": [1.0, 2.0], "dfs": [0.95, 0.90]}"#,
743 )
744 .unwrap();
745 let curve = YieldCurve::from_input(&dfs, asof()).unwrap();
746 assert!((curve.df(1.0) - 0.95).abs() < 1e-12);
747 }
748
749 #[test]
750 fn display_prints_pillar_table() {
751 let text = format!("{}", flat_5pct());
752 assert!(text.contains("zero(cont)"));
753 assert!(text.contains("0.05000")); }
755}