1use std::num::NonZeroU32;
29
30use crate::YieldCurveError;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Compounding {
43 Continuous,
45 Periodic(NonZeroU32),
47 Simple,
49}
50
51impl Compounding {
52 #[must_use]
54 pub fn annual() -> Self {
55 Self::Periodic(NonZeroU32::new(1).expect("1 is non-zero"))
56 }
57
58 #[must_use]
60 pub fn semi_annual() -> Self {
61 Self::Periodic(NonZeroU32::new(2).expect("2 is non-zero"))
62 }
63}
64
65#[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
92pub 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 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 / 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 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 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 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 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 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 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 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}