1use std::fmt;
6use crate::core::errors::RustyQLibError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DayCountConvention {
12 Actual360,
13 Actual365,
14 ActualActual,
15 Thirty360,
16 Thirty360European,
17}
18
19impl fmt::Display for DayCountConvention {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 let s = match self {
22 Self::Actual360 => "Actual/360",
23 Self::Actual365 => "Actual/365",
24 Self::ActualActual => "Actual/Actual",
25 Self::Thirty360 => "30/360",
26 Self::Thirty360European => "30E/360",
27 };
28 write!(f, "{s}")
29 }
30}
31
32fn is_leap_year(year: i32) -> bool {
33 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
34}
35
36fn days_in_year(year: i32) -> f64 {
37 if is_leap_year(year) { 366.0 } else { 365.0 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub struct Date {
43 pub year: i32,
44 pub month: u32,
45 pub day: u32,
46}
47
48impl Date {
49 pub fn new(year: i32, month: u32, day: u32) -> Self {
50 Self { year, month, day }
51 }
52
53 fn days_since_epoch(self) -> i64 {
58 let y = self.year as i64;
59 let m = self.month as i64;
60 let d = self.day as i64;
61 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
63 let era = y.div_euclid(400);
64 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
68 }
69
70 pub fn days_until(self, other: Date) -> i64 {
72 other.days_since_epoch() - self.days_since_epoch()
73 }
74
75 pub fn add_days(self, days: i64) -> Date {
77 let epoch = self.days_since_epoch() + days;
79 Date::from_epoch(epoch)
80 }
81
82 fn from_epoch(z: i64) -> Date {
83 let z = z + 719468; let era = z.div_euclid(146097);
85 let doe = z.rem_euclid(146097);
86 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
87 let y = yoe + era * 400;
88 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
89 let mp = (5 * doy + 2) / 153;
90 let d = doy - (153 * mp + 2) / 5 + 1;
91 let (y, m) = if mp < 10 { (y, mp + 3) } else { (y + 1, mp - 9) };
92 Date::new(y as i32, m as u32, d as u32)
93 }
94
95 pub fn days_in_month(self) -> u32 {
97 match self.month {
98 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
99 4 | 6 | 9 | 11 => 30,
100 2 => if is_leap_year(self.year) { 29 } else { 28 },
101 _ => panic!("invalid month {}", self.month),
102 }
103 }
104}
105
106impl fmt::Display for Date {
107 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
109 }
110}
111
112impl DayCountConvention {
113 fn actual_actual(self, start: Date, end: Date) -> f64 {
114 let days = start.days_until(end);
115 let y1 = start.year;
116 let y2 = end.year;
117
118 if y1 == y2 {
119 return days as f64 / days_in_year(y1);
120 }
121
122 let mut total = 0.0_f64;
124 let mut current = start;
125
126 while current.year < y2 {
127 let year_end = Date::new(current.year, 12, 31);
128 let days_to_end = current.days_until(year_end) + 1;
129 total += days_to_end as f64 / days_in_year(current.year);
130 current = Date::new(current.year + 1, 1, 1);
131 }
132
133 let days_in_final = current.days_until(end);
134 total += days_in_final as f64 / days_in_year(y2);
135 total
136 }
137
138 pub fn year_fraction(self, start: Date, end: Date) -> f64 {
140 let days = start.days_until(end) as f64;
141
142 match self {
143 Self::Actual360 => days / 360.0,
144 Self::Actual365 => days / 365.0,
145 Self::ActualActual => self.actual_actual(start, end),
146
147 Self::Thirty360 => {
148 let (mut d1, m1, y1) = (start.day as i32, start.month as i32, start.year);
149 let (mut d2, m2, y2) = (end.day as i32, end.month as i32, end.year);
150 if d1 == 31 { d1 = 30; }
152 if d2 == 31 && d1 >= 30 { d2 = 30; }
153 let days = 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
155 days as f64 / 360.0
156 }
157
158 Self::Thirty360European => {
159 let (mut d1, m1, y1) = (start.day as i32, start.month as i32, start.year);
160 let (mut d2, m2, y2) = (end.day as i32, end.month as i32, end.year);
161 if d1 == 31 { d1 = 30; }
162 if d2 == 31 { d2 = 30; }
163 let days = 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
164 days as f64 / 360.0
165 }
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum InterpolationMethod {
174 Linear,
175 LogLinear,
176 CubicSpline,
177 FlatForward,
178}
179
180impl fmt::Display for InterpolationMethod {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 let s = match self {
183 Self::Linear => "linear",
184 Self::LogLinear => "log_linear",
185 Self::CubicSpline => "cubic_spline",
186 Self::FlatForward => "flat_forward",
187 };
188 write!(f, "{s}")
189 }
190}
191
192#[derive(Debug, Clone)]
199pub struct TermStructure {
200 pub dates: Vec<Date>,
201 pub discount_factors: Vec<f64>,
202 pub day_count_convention: DayCountConvention,
203 pub interpolation_method: InterpolationMethod,
204 pub asof_date: Date,
205
206 pub year_fractions: Vec<f64>,
208 pub zero_rates: Vec<f64>,
209
210 spline_m: Vec<f64>,
212}
213
214impl TermStructure {
215 pub fn new(
219 dates: Vec<Date>,
220 discount_factors: Vec<f64>,
221 day_count_convention: DayCountConvention,
222 interpolation_method: InterpolationMethod,
223 asof_date: Date,
224 ) -> Result<Self, RustyQLibError> {
225 if dates.len() != discount_factors.len() {
227 return Err(RustyQLibError::invalid_input("term structure", "dates and discount_factors must have the same length"));
228 }
229 if dates.len() < 2 {
230 return Err(RustyQLibError::invalid_input("term structure", "Need at least 2 points to define a term structure"));
231 }
232 if !dates.windows(2).all(|w| w[0] < w[1]) {
233 return Err(RustyQLibError::invalid_input("term structure", "dates must be strictly increasing"));
234 }
235 if !discount_factors.iter().all(|&df| df > 0.0) {
236 return Err(RustyQLibError::invalid_input("term structure", "Discount factors must be positive"));
237 }
238 if !discount_factors.iter().all(|&df| df <= 1.0) {
239 return Err(RustyQLibError::invalid_input("term structure", "Discount factors must be <= 1.0"));
240 }
241
242 let year_fractions: Vec<f64> = dates
243 .iter()
244 .map(|&d| day_count_convention.year_fraction(asof_date, d))
245 .collect();
246
247 let zero_rates: Vec<f64> = discount_factors
248 .iter()
249 .zip(year_fractions.iter())
250 .map(|(&df, &t)| {
251 if t > 0.0 && df > 0.0 { -df.ln() / t } else { 0.0 }
252 })
253 .collect();
254
255 let spline_m = if interpolation_method == InterpolationMethod::CubicSpline {
256 compute_natural_spline(&year_fractions, &discount_factors)
257 } else {
258 vec![]
259 };
260
261 Ok(Self {
262 dates,
263 discount_factors,
264 day_count_convention,
265 interpolation_method,
266 asof_date,
267 year_fractions,
268 zero_rates,
269 spline_m,
270 })
271 }
272
273 pub fn flat_curve(
275 rate: f64,
276 asof_date: Date,
277 day_count_convention: DayCountConvention,
278 max_tenor_years: f64,
279 ) -> Result<Self, RustyQLibError> {
280 let tenors = [0.001, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, max_tenor_years];
281 let dates: Vec<Date> = tenors
282 .iter()
283 .map(|&t| asof_date.add_days((t * 365.25) as i64))
284 .collect();
285 let discount_factors: Vec<f64> = tenors.iter().map(|&t| (-rate * t).exp()).collect();
286
287 Self::new(
288 dates,
289 discount_factors,
290 day_count_convention,
291 InterpolationMethod::LogLinear,
292 asof_date,
293 )
294 }
295
296 pub fn from_zero_rates(
298 dates: Vec<Date>,
299 zero_rates: Vec<f64>,
300 day_count_convention: DayCountConvention,
301 asof_date: Date,
302 ) -> Result<Self, RustyQLibError> {
303 if dates.len() != zero_rates.len() {
304 return Err(RustyQLibError::invalid_input("term structure", "dates and zero_rates must have the same length"));
305 }
306 let discount_factors: Vec<f64> = dates
307 .iter()
308 .zip(zero_rates.iter())
309 .map(|(&d, &r)| {
310 let t = day_count_convention.year_fraction(asof_date, d);
311 (-r * t).exp()
312 })
313 .collect();
314
315 Self::new(dates, discount_factors, day_count_convention, InterpolationMethod::LogLinear, asof_date)
316 }
317
318 pub fn discount_factor(&self, maturity_date: Date) -> f64 {
322 let t = self.day_count_convention.year_fraction(self.asof_date, maturity_date);
323 self.discount_factor_at_time(t)
324 }
325
326 pub fn discount_factor_at_time(&self, t: f64) -> f64 {
328 if t <= 0.0 {
329 return 1.0;
330 }
331 match self.interpolation_method {
332 InterpolationMethod::Linear => {
333 interp_linear(&self.year_fractions, &self.discount_factors, t)
334 }
335 InterpolationMethod::LogLinear => {
336 let log_dfs: Vec<f64> = self.discount_factors.iter().map(|df| df.ln()).collect();
337 interp_linear(&self.year_fractions, &log_dfs, t).exp()
338 }
339 InterpolationMethod::CubicSpline => {
340 interp_cubic(&self.year_fractions, &self.discount_factors, &self.spline_m, t)
341 }
342 InterpolationMethod::FlatForward => {
343 flat_forward_df(&self.year_fractions, &self.discount_factors, t)
344 }
345 }
346 }
347
348 pub fn zero_rate(&self, maturity_date: Date) -> f64 {
350 let t = self.day_count_convention.year_fraction(self.asof_date, maturity_date);
351 self.zero_rate_at_time(t)
352 }
353
354 pub fn zero_rate_at_time(&self, t: f64) -> f64 {
356 if t <= 0.0 {
357 return 0.0;
358 }
359 let df = self.discount_factor_at_time(t);
360 if df > 0.0 { -df.ln() / t } else { 0.0 }
361 }
362
363 pub fn forward_rate(&self, start_date: Date, end_date: Date) -> Result<f64, RustyQLibError> {
365 let t1 = self.day_count_convention.year_fraction(self.asof_date, start_date);
366 let t2 = self.day_count_convention.year_fraction(self.asof_date, end_date);
367 self.forward_rate_at_time(t1, t2)
368 }
369
370 pub fn forward_rate_at_time(&self, t1: f64, t2: f64) -> Result<f64, RustyQLibError> {
372 if t2 <= t1 {
373 return Err(RustyQLibError::invalid_input("term structure", "t2 must be greater than t1"));
374 }
375 let df1 = self.discount_factor_at_time(t1);
376 let df2 = self.discount_factor_at_time(t2);
377 if df1 > 0.0 && df2 > 0.0 {
378 Ok(-(df2 / df1).ln() / (t2 - t1))
379 } else {
380 Ok(0.0)
381 }
382 }
383
384 pub fn summary(&self) -> String {
387 let mut lines = vec![
388 "Term Structure Summary".to_string(),
389 "=".repeat(70),
390 format!("Reference Date: {}", self.asof_date),
391 format!("Day Count Convention: {}", self.day_count_convention),
392 format!("Interpolation Method: {}", self.interpolation_method),
393 format!("Number of Points: {}", self.dates.len()),
394 String::new(),
395 format!(
396 "{:<12} {:<12} {:<18} {:<12}",
397 "Date", "Year Frac", "Discount Factor", "Zero Rate"
398 ),
399 "-".repeat(70),
400 ];
401
402 for (((date, &t), &df), &rate) in self
403 .dates.iter()
404 .zip(self.year_fractions.iter())
405 .zip(self.discount_factors.iter())
406 .zip(self.zero_rates.iter())
407 {
408 lines.push(format!(
409 "{:<12} {:<12.6} {:<18.6} {:.4}%",
410 date.to_string(),
411 t,
412 df,
413 rate * 100.0
414 ));
415 }
416
417 lines.join("\n")
418 }
419}
420
421impl fmt::Display for TermStructure {
422 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
423 write!(
424 f,
425 "TermStructure(asof_date={}, points={}, day_count={}, interpolation={})",
426 self.asof_date,
427 self.dates.len(),
428 self.day_count_convention,
429 self.interpolation_method,
430 )
431 }
432}
433
434fn search_sorted(xs: &[f64], x: f64) -> usize {
439 match xs.binary_search_by(|probe| probe.partial_cmp(&x).unwrap()) {
440 Ok(i) => i.min(xs.len() - 2),
441 Err(i) => {
442 if i == 0 { 0 }
443 else if i >= xs.len() { xs.len() - 2 }
444 else { i - 1 }
445 }
446 }
447}
448
449fn interp_linear(xs: &[f64], ys: &[f64], x: f64) -> f64 {
451 let n = xs.len();
452 if x <= xs[0] {
453 let slope = (ys[1] - ys[0]) / (xs[1] - xs[0]);
455 return ys[0] + slope * (x - xs[0]);
456 }
457 if x >= xs[n - 1] {
458 let slope = (ys[n - 1] - ys[n - 2]) / (xs[n - 1] - xs[n - 2]);
460 return ys[n - 1] + slope * (x - xs[n - 1]);
461 }
462 let i = search_sorted(xs, x);
463 let t = (x - xs[i]) / (xs[i + 1] - xs[i]);
464 ys[i] * (1.0 - t) + ys[i + 1] * t
465}
466
467fn compute_natural_spline(xs: &[f64], ys: &[f64]) -> Vec<f64> {
469 let n = xs.len();
470 let m = vec![0.0_f64; n]; if n < 3 {
472 return m;
473 }
474
475 let h: Vec<f64> = (0..n - 1).map(|i| xs[i + 1] - xs[i]).collect();
477 let mut alpha: Vec<f64> = vec![0.0; n];
478 for i in 1..n - 1 {
479 alpha[i] = 3.0 / h[i] * (ys[i + 1] - ys[i]) - 3.0 / h[i - 1] * (ys[i] - ys[i - 1]);
480 }
481
482 let mut c = 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 a = ys[i];
509 let b = (ys[i + 1] - ys[i]) / h - h * (2.0 * m[i] + m[i + 1]) / 6.0;
510 let c = m[i] / 2.0;
511 let d = (m[i + 1] - m[i]) / (6.0 * h);
512 let dt = x - xs[i];
513 a + b * dt + c * dt * dt + d * dt * dt * dt
514}
515
516fn flat_forward_df(xs: &[f64], dfs: &[f64], t: f64) -> f64 {
518 let n = xs.len();
519 if t <= xs[0] { return dfs[0]; }
520
521 for i in 0..n - 1 {
522 if xs[i] <= t && t <= xs[i + 1] {
523 let (t1, t2) = (xs[i], xs[i + 1]);
524 let (df1, df2) = (dfs[i], dfs[i + 1]);
525 if t2 > t1 {
526 let fwd_rate = -(df2 / df1).ln() / (t2 - t1);
527 return df1 * (-fwd_rate * (t - t1)).exp();
528 } else {
529 return df1;
530 }
531 }
532 }
533
534 if n >= 2 {
536 let (t1, t2) = (xs[n - 2], xs[n - 1]);
537 let (df1, df2) = (dfs[n - 2], dfs[n - 1]);
538 if t2 > t1 {
539 let fwd_rate = -(df2 / df1).ln() / (t2 - t1);
540 return dfs[n - 1] * (-fwd_rate * (t - t2)).exp();
541 }
542 }
543 dfs[n - 1]
544}
545
546#[cfg(test)]
549mod tests {
550 use super::*;
551
552 fn sample_ts() -> TermStructure {
553 let asof = Date::new(2024, 1, 1);
554 TermStructure::flat_curve(0.05, asof, DayCountConvention::Actual365, 30.0).unwrap()
555 }
556
557 #[test]
558 fn test_date_arithmetic() {
559 assert_eq!(Date::new(2024, 1, 1).add_days(365), Date::new(2024, 12, 31));
561 assert_eq!(Date::new(2024, 1, 1).add_days(366), Date::new(2025, 1, 1));
562 assert_eq!(Date::new(2023, 1, 1).add_days(365), Date::new(2024, 1, 1));
564 let d = Date::new(2024, 2, 28);
566 assert_eq!(d.add_days(1), Date::new(2024, 2, 29));
567 assert_eq!(d.add_days(2), Date::new(2024, 3, 1));
568 assert_eq!(d.days_until(d.add_days(400)), 400);
569 }
570
571 #[test]
572 fn test_flat_curve_discount_factor() {
573 let ts = sample_ts();
574 let d = ts.asof_date.add_days(365);
575 let df = ts.discount_factor(d);
576 let expected = (-0.05_f64).exp();
578 assert!((df - expected).abs() < 1e-3, "df={df}, expected={expected}");
579 }
580
581 #[test]
582 fn test_zero_rate_roundtrip() {
583 let ts = sample_ts();
584 let d = ts.asof_date.add_days(730); let r = ts.zero_rate(d);
586 assert!((r - 0.05).abs() < 1e-3, "zero rate={r}");
587 }
588
589 #[test]
590 fn test_forward_rate() {
591 let ts = sample_ts();
592 let t1 = ts.asof_date.add_days(365);
593 let t2 = ts.asof_date.add_days(730);
594 let fwd = ts.forward_rate(t1, t2).unwrap();
595 assert!((fwd - 0.05).abs() < 1e-3, "fwd={fwd}");
597 }
598
599 #[test]
600 fn test_from_zero_rates() {
601 let asof = Date::new(2024, 1, 1);
602 let dates = vec![
603 asof.add_days(365),
604 asof.add_days(730),
605 asof.add_days(1825),
606 ];
607 let rates = vec![0.04, 0.045, 0.05];
608 let ts = TermStructure::from_zero_rates(
609 dates, rates, DayCountConvention::Actual365, asof,
610 ).unwrap();
611 assert!((ts.zero_rates[0] - 0.04).abs() < 1e-6);
612 }
613
614 #[test]
615 fn test_thirty_360() {
616 let d1 = Date::new(2020, 1, 31);
617 let d2 = Date::new(2020, 7, 31);
618 let yf = DayCountConvention::Thirty360.year_fraction(d1, d2);
619 assert!((yf - 0.5).abs() < 1e-9);
620 }
621
622 #[test]
623 fn test_discount_factor_at_zero() {
624 let ts = sample_ts();
625 assert_eq!(ts.discount_factor_at_time(0.0), 1.0);
626 }
627
628 #[test]
629 fn test_validation_errors() {
630 let asof = Date::new(2024, 1, 1);
631 let d1 = asof.add_days(365);
632 let d2 = asof.add_days(730);
633 assert!(TermStructure::new(
635 vec![d1, d2], vec![0.95], DayCountConvention::Actual365,
636 InterpolationMethod::LogLinear, asof,
637 ).is_err());
638 assert!(TermStructure::new(
640 vec![d1, d2], vec![1.1, 0.9], DayCountConvention::Actual365,
641 InterpolationMethod::LogLinear, asof,
642 ).is_err());
643 }
644}