1use std::fmt;
6
7#[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#[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 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 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); let doy = (153 * m + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146097 + doe - 719468
67 }
68
69 pub fn days_until(self, other: Date) -> i64 {
71 other.days_since_epoch() - self.days_since_epoch()
72 }
73
74 pub fn add_days(self, days: i64) -> Date {
76 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; 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 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 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 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 if d1 == 31 { d1 = 30; }
151 if d2 == 31 && d1 >= 30 { d2 = 30; }
152 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#[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#[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 pub year_fractions: Vec<f64>,
207 pub zero_rates: Vec<f64>,
208
209 spline_m: Vec<f64>,
211}
212
213impl TermStructure {
214 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 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 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 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 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 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 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 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 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 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 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
433fn 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
448fn interp_linear(xs: &[f64], ys: &[f64], x: f64) -> f64 {
450 let n = xs.len();
451 if x <= xs[0] {
452 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 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
466fn compute_natural_spline(xs: &[f64], ys: &[f64]) -> Vec<f64> {
468 let n = xs.len();
469 let mut m = vec![0.0_f64; n]; if n < 3 {
471 return m;
472 }
473
474 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.iter().map(|&v| 2.0 * v).collect()
498}
499
500fn 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
517fn 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 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#[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 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 assert_eq!(Date::new(2023, 1, 1).add_days(365), Date::new(2024, 1, 1));
565 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 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); 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 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 assert!(TermStructure::new(
636 vec![d1, d2], vec![0.95], DayCountConvention::Actual365,
637 InterpolationMethod::LogLinear, asof,
638 ).is_err());
639 assert!(TermStructure::new(
641 vec![d1, d2], vec![1.1, 0.9], DayCountConvention::Actual365,
642 InterpolationMethod::LogLinear, asof,
643 ).is_err());
644 }
645}