Skip to main content

rustyqlib/core/
curves.rs

1//! Yield / discount curve infrastructure shared by all asset classes.
2//!
3//! Design invariant: **discount factors are state, rates are views.**
4//! A [`YieldCurve`] stores only pillar times and raw discount factors;
5//! zero and forward rates are derived on demand. The [`Compounding`] and
6//! [`DayCountConvention`] fields are *conventions* — they control how rates
7//! are converted in (at construction) and out (rate queries), never what
8//! `df(t)` returns.
9
10use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::core::daycount::DayCountConvention;
15
16/// Convention used to convert between rates and discount factors.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "snake_case")]
19pub enum Compounding {
20    /// df = exp(-z*t)
21    #[default]
22    Continuous,
23    /// df = (1+z)^(-t)
24    Annual,
25    /// df = 1/(1+z*t)  (money-market style, e.g. deposits)
26    Simple,
27}
28
29impl Compounding {
30    /// Discount factor over year fraction `t` for rate `z` under this convention.
31    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    /// Rate over year fraction `t` implied by discount factor `df` under this convention.
39    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/// Interpolation scheme between curve pillars.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "snake_case")]
51pub enum InterpolationMethod {
52    /// Linear in ln(df) — market-standard default; piecewise-flat forwards.
53    #[default]
54    LogLinearDf,
55    /// Linear in the continuously compounded zero rate.
56    LinearZero,
57}
58
59/// A curve pillar location: either an absolute date or a year fraction
60/// relative to the curve's reference date (e.g. `"2027-07-16"` or `0.25`).
61#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62#[serde(untagged)]
63pub enum Tenor {
64    Date(NaiveDate),
65    YearFraction(f64),
66}
67
68/// The accepted *input forms* for a curve. This is what deserializes from
69/// JSON; every form is canonicalized to discount factors at construction
70/// ([`YieldCurve::from_input`]), so pricing code sees a single representation.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(tag = "type", rename_all = "snake_case")]
73pub enum CurveInput {
74    /// A single constant rate for all maturities.
75    Flat {
76        rate: f64,
77        #[serde(default)]
78        compounding: Compounding,
79        #[serde(default)]
80        day_count: DayCountConvention,
81    },
82    /// Zero rates quoted in `compounding` at the given tenors.
83    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    /// Discount factors at the given tenors (`compounding` only sets the
94    /// quoting convention for rate queries on the resulting curve).
95    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    /// Forward rates, each applying from the previous tenor (or the
106    /// reference date for the first) to its own tenor.
107    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/// Errors from curve construction or queries.
120#[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/// One pillar of the curve, with the zero rate derived for inspection.
150#[derive(Debug, Clone, Copy)]
151pub struct CurvePillar {
152    /// Original pillar date, when the curve was built from date tenors.
153    pub date: Option<NaiveDate>,
154    pub time: f64,
155    pub df: f64,
156    /// Continuously compounded zero rate at this pillar.
157    pub zero_rate: f64,
158}
159
160/// A canonical discount curve anchored at `reference_date`.
161///
162/// State is the pillar `(times, dfs)` vectors only — `dfs[0] = 1.0` at
163/// `times[0] = 0.0` always. `compounding` is the quoting convention used by
164/// [`zero_rate`](Self::zero_rate) / [`forward_rate`](Self::forward_rate);
165/// changing it never changes discounting.
166#[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
177/// Pillar grid used to materialize a flat curve. Log-linear interpolation is
178/// exact between these pillars for continuous and annual compounding; for
179/// simple compounding the curve is exact at the pillars.
180const 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    // ── Constructors ────────────────────────────────────────────────────
198
199    /// Flat curve at a single `rate` quoted in `compounding`.
200    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    /// Curve from zero rates quoted in `compounding`.
219    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    /// Curve directly from discount factors.
236    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    /// Curve from forward rates: `forwards[i]` applies between tenor `i-1`
260    /// (or the reference date for `i = 0`) and tenor `i`, quoted in
261    /// `compounding`.
262    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    /// Build from a deserialized [`CurveInput`], anchored at `reference_date`.
287    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    // ── Queries ─────────────────────────────────────────────────────────
305
306    /// Discount factor at year fraction `t` from the reference date.
307    /// `t <= 0` returns 1.0; beyond the last pillar the last continuously
308    /// compounded zero rate is extrapolated flat.
309    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            // flat extrapolation of the last zero rate
317            let z_last = -self.dfs[n - 1].ln() / t_last;
318            return (-z_last * t).exp();
319        }
320        // first index with times[idx] >= t; idx >= 1 because times[0] = 0 < t
321        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    /// Discount factor at an absolute date (via the curve's day count).
339    pub fn df_date(&self, date: NaiveDate) -> f64 {
340        self.df(self.day_count.year_fraction(self.reference_date, date))
341    }
342
343    /// Zero rate at `t` in the curve's quoting convention.
344    pub fn zero_rate(&self, t: f64) -> f64 {
345        self.zero_rate_with(t, self.compounding)
346    }
347
348    /// Zero rate at `t` in an explicit convention.
349    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    /// Forward rate between `t1` and `t2` in the curve's quoting convention.
357    pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, CurveError> {
358        self.forward_rate_with(t1, t2, self.compounding)
359    }
360
361    /// Forward rate between `t1` and `t2` in an explicit convention
362    /// (`Simple` gives the FRA-style forward).
363    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    /// The curve's pillars (excluding the synthetic t=0 node) with derived
387    /// continuously compounded zero rates — for inspection and display;
388    /// always computed fresh from the stored dfs so it cannot disagree with
389    /// what `df(t)` returns.
390    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    // ── Internals ───────────────────────────────────────────────────────
402
403    /// Continuously compounded zero at pillar `i` (internal interpolation
404    /// math is always continuous, independent of the quoting convention).
405    fn pillar_zero(&self, i: usize) -> f64 {
406        if self.times[i] <= 0.0 {
407            // flat short end: use the first real pillar's zero
408            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 &times {
448            if t <= 0.0 {
449                return Err(CurveError::NonPositiveTime(t));
450            }
451        }
452        for &df in &dfs {
453            // dfs > 1 are allowed (negative rates); dfs <= 0 are not
454            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        // synthetic anchor node at t = 0
462        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        // exact everywhere under log-linear interpolation
517        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        // zero rate reported back in the curve's own convention
520        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        // the same flat 5% (continuous) curve expressed four ways
555        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(); // 365 days from asof
585        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        // ln df linear: at t=1.4, ln df = 0.6*(-0.05) + 0.4*(-0.12)
621        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        // FRA-style simple forward over 6M on a flat 5% cc curve
630        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        // empty
667        assert_eq!(
668            YieldCurve::from_zero_rates(&[], &[], asof(), dc, comp, interp).unwrap_err(),
669            CurveError::Empty
670        );
671        // length mismatch
672        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        // non-increasing times
685        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        // non-positive time
698        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        // non-positive df
704        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        // flat, minimal
721        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        // zero rates with mixed date / year-fraction tenors and explicit conventions
726        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        // discount factors
741        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")); // zero column shows the flat rate
754    }
755}