Skip to main content

rustyqlib/rates/
term_structure.rs

1/// Term Structure Module
2///
3/// Provides types for managing interest rate term structures with various
4/// day count conventions and interpolation methods.
5use std::fmt;
6
7// ─── Day Count Convention ────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DayCountConvention {
11    Actual360,
12    Actual365,
13    ActualActual,
14    Thirty360,
15    Thirty360European,
16}
17
18impl fmt::Display for DayCountConvention {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        let s = match self {
21            Self::Actual360 => "Actual/360",
22            Self::Actual365 => "Actual/365",
23            Self::ActualActual => "Actual/Actual",
24            Self::Thirty360 => "30/360",
25            Self::Thirty360European => "30E/360",
26        };
27        write!(f, "{s}")
28    }
29}
30
31fn is_leap_year(year: i32) -> bool {
32    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
33}
34
35fn days_in_year(year: i32) -> f64 {
36    if is_leap_year(year) { 366.0 } else { 365.0 }
37}
38
39/// A simple date type (calendar date only, no time component).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub struct Date {
42    pub year: i32,
43    pub month: u32,
44    pub day: u32,
45}
46
47impl Date {
48    pub fn new(year: i32, month: u32, day: u32) -> Self {
49        Self { year, month, day }
50    }
51
52    /// Days since the Unix epoch (1970-01-01), proleptic Gregorian – used
53    /// for day differences and as the inverse of [`from_epoch`]. The
54    /// `- 719468` offset shifts the internal 0000-03-01 era count onto the
55    /// Unix epoch so that `from_epoch(days_since_epoch(d)) == d`.
56    fn days_since_epoch(self) -> i64 {
57        let y = self.year as i64;
58        let m = self.month as i64;
59        let d = self.day as i64;
60        // Shift months so March = 1, to simplify leap-year handling
61        let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
62        let era = y.div_euclid(400);
63        let yoe = y.rem_euclid(400); // year of era [0, 399]
64        let doy = (153 * m + 2) / 5 + d - 1; // day of year [0, 365]
65        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // day of era
66        era * 146097 + doe - 719468
67    }
68
69    /// Compute the number of calendar days between two dates (self is start).
70    pub fn days_until(self, other: Date) -> i64 {
71        other.days_since_epoch() - self.days_since_epoch()
72    }
73
74    /// Add a given number of days to this date.
75    pub fn add_days(self, days: i64) -> Date {
76        // Leverage epoch arithmetic
77        let epoch = self.days_since_epoch() + days;
78        Date::from_epoch(epoch)
79    }
80
81    fn from_epoch(z: i64) -> Date {
82        let z = z + 719468; // shift to civil epoch
83        let era = z.div_euclid(146097);
84        let doe = z.rem_euclid(146097);
85        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
86        let y = yoe + era * 400;
87        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
88        let mp = (5 * doy + 2) / 153;
89        let d = doy - (153 * mp + 2) / 5 + 1;
90        let (y, m) = if mp < 10 { (y, mp + 3) } else { (y + 1, mp - 9) };
91        Date::new(y as i32, m as u32, d as u32)
92    }
93
94    /// Last day of the month for this date.
95    pub fn days_in_month(self) -> u32 {
96        match self.month {
97            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
98            4 | 6 | 9 | 11 => 30,
99            2 => if is_leap_year(self.year) { 29 } else { 28 },
100            _ => panic!("invalid month {}", self.month),
101        }
102    }
103}
104
105impl fmt::Display for Date {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
108    }
109}
110
111impl DayCountConvention {
112    fn actual_actual(self, start: Date, end: Date) -> f64 {
113        let days = start.days_until(end);
114        let y1 = start.year;
115        let y2 = end.year;
116
117        if y1 == y2 {
118            return days as f64 / days_in_year(y1);
119        }
120
121        // Multi-year: accumulate fractional years
122        let mut total = 0.0_f64;
123        let mut current = start;
124
125        while current.year < y2 {
126            let year_end = Date::new(current.year, 12, 31);
127            let days_to_end = current.days_until(year_end) + 1;
128            total += days_to_end as f64 / days_in_year(current.year);
129            current = Date::new(current.year + 1, 1, 1);
130        }
131
132        let days_in_final = current.days_until(end);
133        total += days_in_final as f64 / days_in_year(y2);
134        total
135    }
136
137    /// Compute the year fraction between two dates under this convention.
138    pub fn year_fraction(self, start: Date, end: Date) -> f64 {
139        let days = start.days_until(end) as f64;
140
141        match self {
142            Self::Actual360 => days / 360.0,
143            Self::Actual365 => days / 365.0,
144            Self::ActualActual => self.actual_actual(start, end),
145
146            Self::Thirty360 => {
147                let (mut d1, m1, y1) = (start.day as i32, start.month as i32, start.year);
148                let (mut d2, m2, y2) = (end.day as i32, end.month as i32, end.year);
149                // 30/360 US (Bond Basis)
150                if d1 == 31 { d1 = 30; }
151                if d2 == 31 && d1 >= 30 { d2 = 30; }
152                // TODO: Add Feb end-of-month adjustment for 30/360 US
153                let days = 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
154                days as f64 / 360.0
155            }
156
157            Self::Thirty360European => {
158                let (mut d1, m1, y1) = (start.day as i32, start.month as i32, start.year);
159                let (mut d2, m2, y2) = (end.day as i32, end.month as i32, end.year);
160                if d1 == 31 { d1 = 30; }
161                if d2 == 31 { d2 = 30; }
162                let days = 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
163                days as f64 / 360.0
164            }
165        }
166    }
167}
168
169// ─── Interpolation Method ────────────────────────────────────────────────────
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum InterpolationMethod {
173    Linear,
174    LogLinear,
175    CubicSpline,
176    FlatForward,
177}
178
179impl fmt::Display for InterpolationMethod {
180    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181        let s = match self {
182            Self::Linear => "linear",
183            Self::LogLinear => "log_linear",
184            Self::CubicSpline => "cubic_spline",
185            Self::FlatForward => "flat_forward",
186        };
187        write!(f, "{s}")
188    }
189}
190
191// ─── Term Structure ──────────────────────────────────────────────────────────
192
193/// An interest rate term structure.
194///
195/// Stores a grid of (date, discount-factor) pairs and derives zero rates and
196/// an interpolator for off-grid queries.
197#[derive(Debug, Clone)]
198pub struct TermStructure {
199    pub dates: Vec<Date>,
200    pub discount_factors: Vec<f64>,
201    pub day_count_convention: DayCountConvention,
202    pub interpolation_method: InterpolationMethod,
203    pub asof_date: Date,
204
205    // Derived / cached fields
206    pub year_fractions: Vec<f64>,
207    pub zero_rates: Vec<f64>,
208
209    // For cubic-spline: precomputed second derivatives
210    spline_m: Vec<f64>,
211}
212
213impl TermStructure {
214    // ── Constructors ─────────────────────────────────────────────────────────
215
216    /// Create a new term structure from dates and discount factors.
217    pub fn new(
218        dates: Vec<Date>,
219        discount_factors: Vec<f64>,
220        day_count_convention: DayCountConvention,
221        interpolation_method: InterpolationMethod,
222        asof_date: Date,
223    ) -> Result<Self, String> {
224        // Validation
225        if dates.len() != discount_factors.len() {
226            return Err("dates and discount_factors must have the same length".into());
227        }
228        if dates.len() < 2 {
229            return Err("Need at least 2 points to define a term structure".into());
230        }
231        if !dates.windows(2).all(|w| w[0] < w[1]) {
232            return Err("dates must be strictly increasing".into());
233        }
234        if !discount_factors.iter().all(|&df| df > 0.0) {
235            return Err("Discount factors must be positive".into());
236        }
237        if !discount_factors.iter().all(|&df| df <= 1.0) {
238            return Err("Discount factors must be <= 1.0".into());
239        }
240
241        let year_fractions: Vec<f64> = dates
242            .iter()
243            .map(|&d| day_count_convention.year_fraction(asof_date, d))
244            .collect();
245
246        let zero_rates: Vec<f64> = discount_factors
247            .iter()
248            .zip(year_fractions.iter())
249            .map(|(&df, &t)| {
250                if t > 0.0 && df > 0.0 { -df.ln() / t } else { 0.0 }
251            })
252            .collect();
253
254        let spline_m = if interpolation_method == InterpolationMethod::CubicSpline {
255            compute_natural_spline(&year_fractions, &discount_factors)
256        } else {
257            vec![]
258        };
259
260        Ok(Self {
261            dates,
262            discount_factors,
263            day_count_convention,
264            interpolation_method,
265            asof_date,
266            year_fractions,
267            zero_rates,
268            spline_m,
269        })
270    }
271
272    /// Build a flat (constant-rate) term structure.
273    pub fn flat_curve(
274        rate: f64,
275        asof_date: Date,
276        day_count_convention: DayCountConvention,
277        max_tenor_years: f64,
278    ) -> Result<Self, String> {
279        let tenors = [0.001, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, max_tenor_years];
280        let dates: Vec<Date> = tenors
281            .iter()
282            .map(|&t| asof_date.add_days((t * 365.25) as i64))
283            .collect();
284        let discount_factors: Vec<f64> = tenors.iter().map(|&t| (-rate * t).exp()).collect();
285
286        Self::new(
287            dates,
288            discount_factors,
289            day_count_convention,
290            InterpolationMethod::LogLinear,
291            asof_date,
292        )
293    }
294
295    /// Build a term structure from continuously-compounded zero rates.
296    pub fn from_zero_rates(
297        dates: Vec<Date>,
298        zero_rates: Vec<f64>,
299        day_count_convention: DayCountConvention,
300        asof_date: Date,
301    ) -> Result<Self, String> {
302        if dates.len() != zero_rates.len() {
303            return Err("dates and zero_rates must have the same length".into());
304        }
305        let discount_factors: Vec<f64> = dates
306            .iter()
307            .zip(zero_rates.iter())
308            .map(|(&d, &r)| {
309                let t = day_count_convention.year_fraction(asof_date, d);
310                (-r * t).exp()
311            })
312            .collect();
313
314        Self::new(dates, discount_factors, day_count_convention, InterpolationMethod::LogLinear, asof_date)
315    }
316
317    // ── Core query methods ────────────────────────────────────────────────────
318
319    /// Discount factor for a given maturity date.
320    pub fn discount_factor(&self, maturity_date: Date) -> f64 {
321        let t = self.day_count_convention.year_fraction(self.asof_date, maturity_date);
322        self.discount_factor_at_time(t)
323    }
324
325    /// Discount factor at time `t` (in years).
326    pub fn discount_factor_at_time(&self, t: f64) -> f64 {
327        if t <= 0.0 {
328            return 1.0;
329        }
330        match self.interpolation_method {
331            InterpolationMethod::Linear => {
332                interp_linear(&self.year_fractions, &self.discount_factors, t)
333            }
334            InterpolationMethod::LogLinear => {
335                let log_dfs: Vec<f64> = self.discount_factors.iter().map(|df| df.ln()).collect();
336                interp_linear(&self.year_fractions, &log_dfs, t).exp()
337            }
338            InterpolationMethod::CubicSpline => {
339                interp_cubic(&self.year_fractions, &self.discount_factors, &self.spline_m, t)
340            }
341            InterpolationMethod::FlatForward => {
342                flat_forward_df(&self.year_fractions, &self.discount_factors, t)
343            }
344        }
345    }
346
347    /// Zero-coupon rate (continuously compounded) for a given maturity date.
348    pub fn zero_rate(&self, maturity_date: Date) -> f64 {
349        let t = self.day_count_convention.year_fraction(self.asof_date, maturity_date);
350        self.zero_rate_at_time(t)
351    }
352
353    /// Zero-coupon rate at time `t` (in years).
354    pub fn zero_rate_at_time(&self, t: f64) -> f64 {
355        if t <= 0.0 {
356            return 0.0;
357        }
358        let df = self.discount_factor_at_time(t);
359        if df > 0.0 { -df.ln() / t } else { 0.0 }
360    }
361
362    /// Continuously-compounded forward rate between two dates.
363    pub fn forward_rate(&self, start_date: Date, end_date: Date) -> Result<f64, String> {
364        let t1 = self.day_count_convention.year_fraction(self.asof_date, start_date);
365        let t2 = self.day_count_convention.year_fraction(self.asof_date, end_date);
366        self.forward_rate_at_time(t1, t2)
367    }
368
369    /// Continuously-compounded forward rate between two times.
370    pub fn forward_rate_at_time(&self, t1: f64, t2: f64) -> Result<f64, String> {
371        if t2 <= t1 {
372            return Err("t2 must be greater than t1".into());
373        }
374        let df1 = self.discount_factor_at_time(t1);
375        let df2 = self.discount_factor_at_time(t2);
376        if df1 > 0.0 && df2 > 0.0 {
377            Ok(-(df2 / df1).ln() / (t2 - t1))
378        } else {
379            Ok(0.0)
380        }
381    }
382
383    // ── Display helpers ───────────────────────────────────────────────────────
384
385    pub fn summary(&self) -> String {
386        let mut lines = vec![
387            "Term Structure Summary".to_string(),
388            "=".repeat(70),
389            format!("Reference Date:       {}", self.asof_date),
390            format!("Day Count Convention: {}", self.day_count_convention),
391            format!("Interpolation Method: {}", self.interpolation_method),
392            format!("Number of Points:     {}", self.dates.len()),
393            String::new(),
394            format!(
395                "{:<12} {:<12} {:<18} {:<12}",
396                "Date", "Year Frac", "Discount Factor", "Zero Rate"
397            ),
398            "-".repeat(70),
399        ];
400
401        for (((date, &t), &df), &rate) in self
402            .dates.iter()
403            .zip(self.year_fractions.iter())
404            .zip(self.discount_factors.iter())
405            .zip(self.zero_rates.iter())
406        {
407            lines.push(format!(
408                "{:<12} {:<12.6} {:<18.6} {:.4}%",
409                date.to_string(),
410                t,
411                df,
412                rate * 100.0
413            ));
414        }
415
416        lines.join("\n")
417    }
418}
419
420impl fmt::Display for TermStructure {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        write!(
423            f,
424            "TermStructure(asof_date={}, points={}, day_count={}, interpolation={})",
425            self.asof_date,
426            self.dates.len(),
427            self.day_count_convention,
428            self.interpolation_method,
429        )
430    }
431}
432
433// ─── Interpolation helpers ────────────────────────────────────────────────────
434
435/// Find the index `i` such that xs[i] <= x < xs[i+1].
436/// Clamps to valid range; extrapolates flat beyond endpoints.
437fn search_sorted(xs: &[f64], x: f64) -> usize {
438    match xs.binary_search_by(|probe| probe.partial_cmp(&x).unwrap()) {
439        Ok(i) => i.min(xs.len() - 2),
440        Err(i) => {
441            if i == 0 { 0 }
442            else if i >= xs.len() { xs.len() - 2 }
443            else { i - 1 }
444        }
445    }
446}
447
448/// Piecewise-linear interpolation (with linear extrapolation).
449fn interp_linear(xs: &[f64], ys: &[f64], x: f64) -> f64 {
450    let n = xs.len();
451    if x <= xs[0] {
452        // Linear extrapolation on the left
453        let slope = (ys[1] - ys[0]) / (xs[1] - xs[0]);
454        return ys[0] + slope * (x - xs[0]);
455    }
456    if x >= xs[n - 1] {
457        // Linear extrapolation on the right
458        let slope = (ys[n - 1] - ys[n - 2]) / (xs[n - 1] - xs[n - 2]);
459        return ys[n - 1] + slope * (x - xs[n - 1]);
460    }
461    let i = search_sorted(xs, x);
462    let t = (x - xs[i]) / (xs[i + 1] - xs[i]);
463    ys[i] * (1.0 - t) + ys[i + 1] * t
464}
465
466/// Compute second derivatives for a natural cubic spline (tridiagonal solve).
467fn compute_natural_spline(xs: &[f64], ys: &[f64]) -> Vec<f64> {
468    let n = xs.len();
469    let mut m = vec![0.0_f64; n]; // second derivatives (moments)
470    if n < 3 {
471        return m;
472    }
473
474    // Thomas algorithm for tridiagonal system
475    let mut h: Vec<f64> = (0..n - 1).map(|i| xs[i + 1] - xs[i]).collect();
476    let mut alpha: Vec<f64> = vec![0.0; n];
477    for i in 1..n - 1 {
478        alpha[i] = 3.0 / h[i] * (ys[i + 1] - ys[i]) - 3.0 / h[i - 1] * (ys[i] - ys[i - 1]);
479    }
480
481    let mut c = vec![0.0_f64; n];
482    let mut d = vec![0.0_f64; n];
483    let mut l = vec![1.0_f64; n];
484    let mut mu = vec![0.0_f64; n];
485    let mut z = vec![0.0_f64; n];
486
487    for i in 1..n - 1 {
488        l[i] = 2.0 * (xs[i + 1] - xs[i - 1]) - h[i - 1] * mu[i - 1];
489        mu[i] = h[i] / l[i];
490        z[i] = (alpha[i] - h[i - 1] * z[i - 1]) / l[i];
491    }
492
493    for j in (0..n - 1).rev() {
494        c[j] = z[j] - mu[j] * c[j + 1];
495    }
496    // c contains the second derivatives / 2; return 2*c = full second derivatives
497    c.iter().map(|&v| 2.0 * v).collect()
498}
499
500/// Evaluate a natural cubic spline at `x` given precomputed second derivatives `m`.
501fn interp_cubic(xs: &[f64], ys: &[f64], m: &[f64], x: f64) -> f64 {
502    let n = xs.len();
503    if x <= xs[0] { return ys[0]; }
504    if x >= xs[n - 1] { return ys[n - 1]; }
505
506    let i = search_sorted(xs, x);
507    let h = xs[i + 1] - xs[i];
508    let t = (x - xs[i]) / h;
509    let a = ys[i];
510    let b = (ys[i + 1] - ys[i]) / h - h * (2.0 * m[i] + m[i + 1]) / 6.0;
511    let c = m[i] / 2.0;
512    let d = (m[i + 1] - m[i]) / (6.0 * h);
513    let dt = x - xs[i];
514    a + b * dt + c * dt * dt + d * dt * dt * dt
515}
516
517/// Flat-forward discount-factor interpolation.
518fn flat_forward_df(xs: &[f64], dfs: &[f64], t: f64) -> f64 {
519    let n = xs.len();
520    if t <= xs[0] { return dfs[0]; }
521
522    for i in 0..n - 1 {
523        if xs[i] <= t && t <= xs[i + 1] {
524            let (t1, t2) = (xs[i], xs[i + 1]);
525            let (df1, df2) = (dfs[i], dfs[i + 1]);
526            if t2 > t1 {
527                let fwd_rate = -(df2 / df1).ln() / (t2 - t1);
528                return df1 * (-fwd_rate * (t - t1)).exp();
529            } else {
530                return df1;
531            }
532        }
533    }
534
535    // Beyond the last point: extrapolate with the last forward rate
536    if n >= 2 {
537        let (t1, t2) = (xs[n - 2], xs[n - 1]);
538        let (df1, df2) = (dfs[n - 2], dfs[n - 1]);
539        if t2 > t1 {
540            let fwd_rate = -(df2 / df1).ln() / (t2 - t1);
541            return dfs[n - 1] * (-fwd_rate * (t - t2)).exp();
542        }
543    }
544    dfs[n - 1]
545}
546
547// ─── Tests ───────────────────────────────────────────────────────────────────
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    fn sample_ts() -> TermStructure {
554        let asof = Date::new(2024, 1, 1);
555        TermStructure::flat_curve(0.05, asof, DayCountConvention::Actual365, 30.0).unwrap()
556    }
557
558    #[test]
559    fn test_date_arithmetic() {
560        // 2024 is a leap year (366 days), so 365 days after Jan 1 is Dec 31
561        assert_eq!(Date::new(2024, 1, 1).add_days(365), Date::new(2024, 12, 31));
562        assert_eq!(Date::new(2024, 1, 1).add_days(366), Date::new(2025, 1, 1));
563        // 2023 is not a leap year
564        assert_eq!(Date::new(2023, 1, 1).add_days(365), Date::new(2024, 1, 1));
565        // round trip through the epoch, including a month/leap-day boundary
566        let d = Date::new(2024, 2, 28);
567        assert_eq!(d.add_days(1), Date::new(2024, 2, 29));
568        assert_eq!(d.add_days(2), Date::new(2024, 3, 1));
569        assert_eq!(d.days_until(d.add_days(400)), 400);
570    }
571
572    #[test]
573    fn test_flat_curve_discount_factor() {
574        let ts = sample_ts();
575        let d = ts.asof_date.add_days(365);
576        let df = ts.discount_factor(d);
577        // For a flat 5% curve over 1 year: df ≈ exp(-0.05)
578        let expected = (-0.05_f64).exp();
579        assert!((df - expected).abs() < 1e-3, "df={df}, expected={expected}");
580    }
581
582    #[test]
583    fn test_zero_rate_roundtrip() {
584        let ts = sample_ts();
585        let d = ts.asof_date.add_days(730); // 2 years
586        let r = ts.zero_rate(d);
587        assert!((r - 0.05).abs() < 1e-3, "zero rate={r}");
588    }
589
590    #[test]
591    fn test_forward_rate() {
592        let ts = sample_ts();
593        let t1 = ts.asof_date.add_days(365);
594        let t2 = ts.asof_date.add_days(730);
595        let fwd = ts.forward_rate(t1, t2).unwrap();
596        // On a flat curve the forward rate equals the spot rate
597        assert!((fwd - 0.05).abs() < 1e-3, "fwd={fwd}");
598    }
599
600    #[test]
601    fn test_from_zero_rates() {
602        let asof = Date::new(2024, 1, 1);
603        let dates = vec![
604            asof.add_days(365),
605            asof.add_days(730),
606            asof.add_days(1825),
607        ];
608        let rates = vec![0.04, 0.045, 0.05];
609        let ts = TermStructure::from_zero_rates(
610            dates, rates, DayCountConvention::Actual365, asof,
611        ).unwrap();
612        assert!((ts.zero_rates[0] - 0.04).abs() < 1e-6);
613    }
614
615    #[test]
616    fn test_thirty_360() {
617        let d1 = Date::new(2020, 1, 31);
618        let d2 = Date::new(2020, 7, 31);
619        let yf = DayCountConvention::Thirty360.year_fraction(d1, d2);
620        assert!((yf - 0.5).abs() < 1e-9);
621    }
622
623    #[test]
624    fn test_discount_factor_at_zero() {
625        let ts = sample_ts();
626        assert_eq!(ts.discount_factor_at_time(0.0), 1.0);
627    }
628
629    #[test]
630    fn test_validation_errors() {
631        let asof = Date::new(2024, 1, 1);
632        let d1 = asof.add_days(365);
633        let d2 = asof.add_days(730);
634        // Mismatched lengths
635        assert!(TermStructure::new(
636            vec![d1, d2], vec![0.95], DayCountConvention::Actual365,
637            InterpolationMethod::LogLinear, asof,
638        ).is_err());
639        // Discount factor > 1
640        assert!(TermStructure::new(
641            vec![d1, d2], vec![1.1, 0.9], DayCountConvention::Actual365,
642            InterpolationMethod::LogLinear, asof,
643        ).is_err());
644    }
645}