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    /// Two key-rate bump tenors resolve to the same curve pillar (they are
129    /// closer together than twice [`KEY_RATE_TENOR_TOLERANCE`]).
130    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/// One pillar of the curve, with the zero rate derived for inspection.
156#[derive(Debug, Clone, Copy)]
157pub struct CurvePillar {
158    /// Original pillar date, when the curve was built from date tenors.
159    pub date: Option<NaiveDate>,
160    pub time: f64,
161    pub df: f64,
162    /// Continuously compounded zero rate at this pillar.
163    pub zero_rate: f64,
164}
165
166/// One inter-pillar segment and its discrete continuously compounded
167/// forward rate, as reported by [`YieldCurve::min_forward`].
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct ForwardSegment {
170    pub t1: f64,
171    pub t2: f64,
172    pub forward: f64,
173}
174
175/// A shift applied to a curve by [`YieldCurve::bumped`]. Shifts act on the
176/// **continuously compounded zero rates** (the curve's discount factors
177/// are re-derived exactly), independent of the curve's quoting convention.
178#[derive(Debug, Clone, PartialEq)]
179pub enum RateShift {
180    /// Add `d` to every continuous zero rate (e.g. `0.0001` = +1bp).
181    ParallelAbsolute(f64),
182    /// Scale every continuous zero rate by `1 + r`.
183    ParallelRelative(f64),
184    /// Key-rate bump: add `shifts[i]` to the continuous zero rate at pillar
185    /// `tenors[i]` (year fractions, strictly increasing). Each tenor reuses
186    /// the nearest existing pillar within [`KEY_RATE_TENOR_TOLERANCE`];
187    /// otherwise a pillar is inserted on the base curve first, so the bump
188    /// is represented exactly. Off-pillar shape follows the curve's own
189    /// interpolation — no separate shift interpolation exists — which makes
190    /// node bumps exactly additive: single-tenor bumps over the full pillar
191    /// set sum to the parallel bump to machine precision.
192    KeyRateAbsolute { tenors: Vec<f64>, shifts: Vec<f64> },
193}
194
195/// A bump tenor within this distance (in years, ~3.7 days) of an existing
196/// pillar reuses that pillar; farther away, a new pillar is inserted.
197/// Prevents needle-thin tents when date-built pillars sit at times like
198/// `1.0027` and the bump asks for `1.0`.
199pub const KEY_RATE_TENOR_TOLERANCE: f64 = 0.01;
200
201/// A canonical discount curve anchored at `reference_date`.
202///
203/// State is the pillar `(times, dfs)` vectors only — `dfs[0] = 1.0` at
204/// `times[0] = 0.0` always. `compounding` is the quoting convention used by
205/// [`zero_rate`](Self::zero_rate) / [`forward_rate`](Self::forward_rate);
206/// changing it never changes discounting.
207#[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
218/// Pillar grid used to materialize a flat curve. Log-linear interpolation is
219/// exact between these pillars for continuous and annual compounding; for
220/// simple compounding the curve is exact at the pillars.
221const 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    // ── Constructors ────────────────────────────────────────────────────
239
240    /// Flat curve at a single `rate` quoted in `compounding`.
241    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    /// Curve from zero rates quoted in `compounding`.
260    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    /// Curve directly from discount factors.
277    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    /// Curve from forward rates: `forwards[i]` applies between tenor `i-1`
301    /// (or the reference date for `i = 0`) and tenor `i`, quoted in
302    /// `compounding`.
303    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    /// Build from a deserialized [`CurveInput`], anchored at `reference_date`.
328    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    /// This curve with `shift` applied to its continuous zero rates.
346    /// The discount factors are re-derived exactly at the affected pillars:
347    /// `z -> z + d` gives `df -> df * exp(-d*t)`, `z -> z*(1+r)` gives
348    /// `df -> df^(1+r)`. Day count, quoting convention and interpolation
349    /// are unchanged; `df(0) = 1` is preserved. A key-rate shift may add
350    /// pillars (see [`RateShift::KeyRateAbsolute`]); parallel shifts never
351    /// do. Errors only on a malformed key-rate shift.
352    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                // Pass 1: give every bump tenor a pillar. Missing ones are
368                // inserted with the *base* curve's df — both interpolation
369                // methods are piecewise linear in a transform, so inserting
370                // an on-curve point leaves df(t) unchanged everywhere.
371                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                // Pass 2: shift each target pillar's continuous zero.
387                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    /// The inter-pillar segment with the smallest discrete continuous
396    /// forward `ln(df(t1)/df(t2)) / (t2 - t1)` — the no-arbitrage
397    /// diagnostic: a value below zero means the discount factors increase
398    /// somewhere. Checking consecutive pillars suffices: under
399    /// [`InterpolationMethod::LogLinearDf`] this *is* the instantaneous
400    /// forward on the segment; under `LinearZero` it is the segment
401    /// average. The first segment starts at the `t = 0` anchor.
402    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    // ── Queries ─────────────────────────────────────────────────────────
415
416    /// Discount factor at year fraction `t` from the reference date.
417    /// `t <= 0` returns 1.0; beyond the last pillar the last continuously
418    /// compounded zero rate is extrapolated flat.
419    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            // flat extrapolation of the last zero rate
427            let z_last = -self.dfs[n - 1].ln() / t_last;
428            return (-z_last * t).exp();
429        }
430        // shared pillar bracketing; idx >= 1 because times[0] = 0 < t
431        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    /// Discount factor at an absolute date (via the curve's day count).
447    pub fn df_date(&self, date: NaiveDate) -> f64 {
448        self.df(self.day_count.year_fraction(self.reference_date, date))
449    }
450
451    /// Zero rate at `t` in the curve's quoting convention.
452    pub fn zero_rate(&self, t: f64) -> f64 {
453        self.zero_rate_with(t, self.compounding)
454    }
455
456    /// Zero rate at `t` in an explicit convention.
457    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    /// Forward rate between `t1` and `t2` in the curve's quoting convention.
465    pub fn forward_rate(&self, t1: f64, t2: f64) -> Result<f64, CurveError> {
466        self.forward_rate_with(t1, t2, self.compounding)
467    }
468
469    /// Forward rate between `t1` and `t2` in an explicit convention
470    /// (`Simple` gives the FRA-style forward).
471    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    /// The curve's pillars (excluding the synthetic t=0 node) with derived
495    /// continuously compounded zero rates — for inspection and display;
496    /// always computed fresh from the stored dfs so it cannot disagree with
497    /// what `df(t)` returns.
498    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    // ── Internals ───────────────────────────────────────────────────────
510
511    /// Continuously compounded zero at pillar `i` (internal interpolation
512    /// math is always continuous, independent of the quoting convention).
513    fn pillar_zero(&self, i: usize) -> f64 {
514        if self.times[i] <= 0.0 {
515            // flat short end: use the first real pillar's zero
516            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    /// The real pillar (index >= 1, never the t=0 anchor) nearest to `t`
540    /// within [`KEY_RATE_TENOR_TOLERANCE`], if any.
541    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    /// Insert a pillar at time `t` with discount factor `df`, keeping the
555    /// grids sorted; returns its index. The date is unknown (`None`).
556    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 &times {
599            if t <= 0.0 {
600                return Err(CurveError::NonPositiveTime(t));
601            }
602        }
603        for &df in &dfs {
604            // dfs > 1 are allowed (negative rates); dfs <= 0 are not
605            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        // synthetic anchor node at t = 0
613        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            // +100bp on a 5% flat curve = a 6% flat curve, including extrapolation
660            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        // relative: zeros scale, 5% * 1.2 = 6%
668        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        // df(0) = 1 preserved, original untouched
671        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        // full bump at the target pillar, neighbours (1y, 3y pillars) untouched
685        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        // strictly between: partial bump, shaped by the curve interpolation
689        let mid = z(&up, 2.5);
690        assert!(mid > 0.05 + 1e-6 && mid < 0.06 - 1e-6, "mid-tent zero {mid}");
691        // pillar count unchanged: 2.0 is an existing grid point
692        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        // interior of [1y, 2y] carries exactly the full bump (log-linear df
700        // interpolation is linear in z*t, so equal node bumps lerp exactly)
701        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        // decays outside toward the adjacent unbumped pillars (0.5y, 3y)
708        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        // apply single-tenor bumps successively: composition = sum, since
718        // each bump touches only its own pillar
719        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        // the inserted pillar carries base + bump exactly; grid pillars around
740        // it are untouched
741        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        // a zero-size bump at an off-grid tenor reproduces the base curve
745        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        // date-built pillar at 367/365 ≈ 1.0055; a 1.0 bump tenor must reuse it
754        let pillar_date = NaiveDate::from_ymd_opt(2027, 7, 18).unwrap(); // 367 days
755        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        // 1.0 and 1.005 both resolve to the 1y pillar
787        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        // -200bp at 10y: the zero bump amplifies into the preceding forward
798        // by t/dt = 10/3 => forward 5% - 2%*10/3 < 0 on [7, 10]
799        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        // the same size at the short end stays arbitrage-free: entering the
804        // [1y, 2y] plateau costs only d * t/dt = 2% * 1/0.5 = 4% < 5%
805        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        // exact everywhere under log-linear interpolation
829        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        // zero rate reported back in the curve's own convention
832        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        // the same flat 5% (continuous) curve expressed four ways
867        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(); // 365 days from asof
897        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        // ln df linear: at t=1.4, ln df = 0.6*(-0.05) + 0.4*(-0.12)
933        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        // FRA-style simple forward over 6M on a flat 5% cc curve
942        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        // empty
979        assert_eq!(
980            YieldCurve::from_zero_rates(&[], &[], asof(), dc, comp, interp).unwrap_err(),
981            CurveError::Empty
982        );
983        // length mismatch
984        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        // non-increasing times
997        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        // non-positive time
1010        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        // non-positive df
1016        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        // flat, minimal
1033        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        // zero rates with mixed date / year-fraction tenors and explicit conventions
1038        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        // discount factors
1053        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")); // zero column shows the flat rate
1066    }
1067}