Skip to main content

yield_curves/
compounding.rs

1//! Compounding conventions, discount factors, and forward rates.
2//!
3//! Yield curve interpolation answers "what is the rate at time t?". This
4//! module answers "what is that rate worth?" and "what does it imply about
5//! shorter forward periods?".
6//!
7//! Functions are intentionally free-standing (not on the
8//! [`crate::YieldCurveInterpolator`] trait) because:
9//!
10//! - Interpolation methods know nothing about whether their output is decimal
11//!   or percent. The caller has that context.
12//! - Compounding is a *separate concern* from curve shape. Bundling them on
13//!   the trait would force every interpolator to grow a `Compounding`
14//!   parameter on every call.
15//!
16//! Rates passed to these functions must be in **decimal form** (e.g. `0.135`
17//! for 13.5%). Convert at the call site:
18//!
19//! ```
20//! use yield_curves::{compounding::{discount_factor, Compounding}, CubicSplineCurve, YieldCurveInterpolator};
21//!
22//! let curve = CubicSplineCurve::fit(&[(1.0, 13.0), (5.0, 13.5), (10.0, 13.8)]).unwrap();
23//! let rate_pct = curve.rate_at(3.0);
24//! let df = discount_factor(rate_pct / 100.0, 3.0, Compounding::Continuous);
25//! assert!(df > 0.0 && df < 1.0);
26//! ```
27
28use std::num::NonZeroU32;
29
30use crate::YieldCurveError;
31
32/// Compounding convention used to translate a yield into a discount factor or
33/// to compose forward rates.
34///
35/// `Periodic(n)` covers the common cases:
36/// - `Periodic(NonZeroU32::new(1).unwrap())` — annual compounding, `(1+r)^t`.
37/// - `Periodic(NonZeroU32::new(2).unwrap())` — semi-annual, `(1+r/2)^(2t)`.
38/// - `Periodic(NonZeroU32::new(12).unwrap())` — monthly.
39/// - `Periodic(NonZeroU32::new(252).unwrap())` — Brazilian business-day
40///   convention (one compounding per business day).
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Compounding {
43    /// Continuous compounding: `DF = exp(-r*t)`.
44    Continuous,
45    /// Periodic compounding `n` times per year: `DF = (1 + r/n)^(-n*t)`.
46    Periodic(NonZeroU32),
47    /// Simple (linear) interest: `DF = 1 / (1 + r*t)`.
48    Simple,
49}
50
51impl Compounding {
52    /// Convenience constructor for annual compounding (`Periodic(1)`).
53    #[must_use]
54    pub fn annual() -> Self {
55        Self::Periodic(NonZeroU32::new(1).expect("1 is non-zero"))
56    }
57
58    /// Convenience constructor for semi-annual compounding (`Periodic(2)`).
59    #[must_use]
60    pub fn semi_annual() -> Self {
61        Self::Periodic(NonZeroU32::new(2).expect("2 is non-zero"))
62    }
63}
64
65/// Discount factor for a rate that lasts `t_years` years.
66///
67/// Rate must be in **decimal form** (`0.135`, not `13.5`). Time must be
68/// non-negative.
69///
70/// Returns `f64::NAN` if either input is non-finite, or `1.0` for `t = 0`.
71///
72/// This function is infallible: NaN/Inf inputs propagate as NaN outputs so it
73/// composes cleanly with curves that hit a flat-extrapolation boundary.
74#[must_use]
75pub fn discount_factor(rate: f64, t_years: f64, comp: Compounding) -> f64 {
76    if !rate.is_finite() || !t_years.is_finite() {
77        return f64::NAN;
78    }
79    if t_years == 0.0 {
80        return 1.0;
81    }
82    match comp {
83        Compounding::Continuous => (-rate * t_years).exp(),
84        Compounding::Periodic(n) => {
85            let n = f64::from(n.get());
86            (1.0 + rate / n).powf(-n * t_years)
87        }
88        Compounding::Simple => 1.0 / (1.0 + rate * t_years),
89    }
90}
91
92/// Implied forward rate between `t1` and `t2`.
93///
94/// Solves: starting from a unit at time 0, accruing at `r1` to `t1` then at
95/// the forward rate `f` for the period `(t1, t2)`, must equal accruing at
96/// `r2` to `t2`. Equivalently, `DF(t1) * compound(f, t2-t1) = DF(t2)`.
97///
98/// Rates must be in **decimal form**. Returns an error if:
99/// - `t1 < 0` or `t2 < 0`
100/// - `t1 >= t2`
101/// - any input is non-finite
102/// - the implied forward is non-finite (e.g. negative discount factor under
103///   simple compounding for an unrealistic input)
104///
105/// # Example
106///
107/// ```
108/// use yield_curves::compounding::{forward_rate, Compounding};
109/// // Spot 5% for 1y and 6% for 2y, continuous → forward(1y, 2y) ≈ 7%.
110/// let f = forward_rate(0.05, 1.0, 0.06, 2.0, Compounding::Continuous).unwrap();
111/// assert!((f - 0.07).abs() < 1e-12);
112/// ```
113pub fn forward_rate(
114    r1: f64,
115    t1: f64,
116    r2: f64,
117    t2: f64,
118    comp: Compounding,
119) -> Result<f64, YieldCurveError> {
120    for (label, v) in [("r1", r1), ("t1", t1), ("r2", r2), ("t2", t2)] {
121        if !v.is_finite() {
122            return Err(YieldCurveError::InvalidTimeRange(format!(
123                "{label} is not finite ({v})"
124            )));
125        }
126    }
127    if t1 < 0.0 || t2 < 0.0 {
128        return Err(YieldCurveError::InvalidTimeRange(format!(
129            "negative time (t1={t1}, t2={t2})"
130        )));
131    }
132    if t1 >= t2 {
133        return Err(YieldCurveError::InvalidTimeRange(format!(
134            "t1 must be < t2 (t1={t1}, t2={t2})"
135        )));
136    }
137
138    let dt = t2 - t1;
139    let result = match comp {
140        Compounding::Continuous => (r2 * t2 - r1 * t1) / dt,
141        Compounding::Periodic(n) => {
142            let n = f64::from(n.get());
143            // ((1 + r2/n)^(n*t2) / (1 + r1/n)^(n*t1))^(1/(n*dt)) - 1, scaled by n
144            let num = (1.0 + r2 / n).powf(n * t2);
145            let den = (1.0 + r1 / n).powf(n * t1);
146            let ratio = num / den;
147            n * (ratio.powf(1.0 / (n * dt)) - 1.0)
148        }
149        Compounding::Simple => {
150            let df1 = 1.0 / (1.0 + r1 * t1);
151            let df2 = 1.0 / (1.0 + r2 * t2);
152            // df1 * (1 + f*dt) = 1/df2_inv ... derived from spot relations
153            (df1 / df2 - 1.0) / dt
154        }
155    };
156
157    if !result.is_finite() {
158        return Err(YieldCurveError::InvalidTimeRange(format!(
159            "forward rate is non-finite (r1={r1}, t1={t1}, r2={r2}, t2={t2}, comp={comp:?})"
160        )));
161    }
162    Ok(result)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn approx_eq(a: f64, b: f64, eps: f64) -> bool {
170        (a - b).abs() < eps
171    }
172
173    #[test]
174    fn df_continuous_zero_rate_is_one() {
175        assert!(approx_eq(
176            discount_factor(0.0, 5.0, Compounding::Continuous),
177            1.0,
178            1e-12
179        ));
180    }
181
182    #[test]
183    fn df_continuous_known_value() {
184        // exp(-0.05 * 1.0) ≈ 0.9512294
185        assert!(approx_eq(
186            discount_factor(0.05, 1.0, Compounding::Continuous),
187            (-0.05_f64).exp(),
188            1e-12
189        ));
190    }
191
192    #[test]
193    fn df_annual_known_value() {
194        // 1 / (1.05)^2 ≈ 0.9070295
195        assert!(approx_eq(
196            discount_factor(0.05, 2.0, Compounding::annual()),
197            1.0 / 1.05_f64.powi(2),
198            1e-12
199        ));
200    }
201
202    #[test]
203    fn df_semi_annual() {
204        // (1 + 0.06/2)^(-2*1) = 1/1.03^2
205        assert!(approx_eq(
206            discount_factor(0.06, 1.0, Compounding::semi_annual()),
207            1.0 / 1.03_f64.powi(2),
208            1e-12
209        ));
210    }
211
212    #[test]
213    fn df_simple() {
214        // 1 / (1 + 0.10 * 0.5) = 1 / 1.05
215        assert!(approx_eq(
216            discount_factor(0.10, 0.5, Compounding::Simple),
217            1.0 / 1.05,
218            1e-12
219        ));
220    }
221
222    #[test]
223    fn df_t_zero_is_one() {
224        assert_eq!(discount_factor(0.5, 0.0, Compounding::Continuous), 1.0);
225        assert_eq!(discount_factor(0.5, 0.0, Compounding::annual()), 1.0);
226        assert_eq!(discount_factor(0.5, 0.0, Compounding::Simple), 1.0);
227    }
228
229    #[test]
230    fn df_propagates_nan() {
231        assert!(discount_factor(f64::NAN, 1.0, Compounding::Continuous).is_nan());
232        assert!(discount_factor(0.05, f64::INFINITY, Compounding::annual()).is_nan());
233    }
234
235    #[test]
236    fn forward_continuous_classic() {
237        // 5% spot for 1y, 6% spot for 2y, continuous → forward = (0.06*2 - 0.05*1)/1 = 0.07
238        let f = forward_rate(0.05, 1.0, 0.06, 2.0, Compounding::Continuous).unwrap();
239        assert!(approx_eq(f, 0.07, 1e-12));
240    }
241
242    #[test]
243    fn forward_annual_inverse_of_df() {
244        // (1.06)^2 / (1.05)^1 = (1 + f)^1 → f ≈ 0.07009524
245        let f = forward_rate(0.05, 1.0, 0.06, 2.0, Compounding::annual()).unwrap();
246        let expected = 1.06_f64.powi(2) / 1.05 - 1.0;
247        assert!(approx_eq(f, expected, 1e-12));
248    }
249
250    #[test]
251    fn forward_simple_smoke() {
252        // Just check it returns finite for sane inputs.
253        let f = forward_rate(0.05, 0.5, 0.06, 1.0, Compounding::Simple).unwrap();
254        assert!(f.is_finite());
255        assert!(f > 0.0);
256    }
257
258    #[test]
259    fn forward_rejects_t1_ge_t2() {
260        let err = forward_rate(0.05, 2.0, 0.06, 1.0, Compounding::Continuous).unwrap_err();
261        assert!(matches!(err, YieldCurveError::InvalidTimeRange(_)));
262        let err = forward_rate(0.05, 1.0, 0.06, 1.0, Compounding::Continuous).unwrap_err();
263        assert!(matches!(err, YieldCurveError::InvalidTimeRange(_)));
264    }
265
266    #[test]
267    fn forward_rejects_negative_time() {
268        let err = forward_rate(0.05, -0.5, 0.06, 1.0, Compounding::Continuous).unwrap_err();
269        assert!(matches!(err, YieldCurveError::InvalidTimeRange(_)));
270    }
271
272    #[test]
273    fn forward_rejects_nan() {
274        let err = forward_rate(f64::NAN, 1.0, 0.06, 2.0, Compounding::Continuous).unwrap_err();
275        assert!(matches!(err, YieldCurveError::InvalidTimeRange(_)));
276    }
277}