1use crate::types::{GapBucket, IRInstrumentType, IRPosition, IRRiskMetrics};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone)]
20pub struct InterestRateRisk {
21 metadata: KernelMetadata,
22}
23
24impl Default for InterestRateRisk {
25 fn default() -> Self {
26 Self::new()
27 }
28}
29
30impl InterestRateRisk {
31 #[must_use]
33 pub fn new() -> Self {
34 Self {
35 metadata: KernelMetadata::batch("treasury/ir-risk", Domain::TreasuryManagement)
36 .with_description("Interest rate risk analysis")
37 .with_throughput(10_000)
38 .with_latency_us(500.0),
39 }
40 }
41
42 pub fn calculate_metrics(positions: &[IRPosition], config: &IRRiskConfig) -> IRRiskMetrics {
44 let current_date = config.valuation_date;
45
46 let mut total_pv = 0.0;
47 let mut weighted_duration = 0.0;
48 let mut weighted_convexity = 0.0;
49 let mut pv01_by_currency: HashMap<String, f64> = HashMap::new();
50
51 for pos in positions {
52 let time_to_maturity = (pos.maturity - current_date) as f64 / (365.0 * 86400.0);
53 if time_to_maturity <= 0.0 {
54 continue;
55 }
56
57 let pv = Self::calculate_present_value(pos, config);
58 let duration = Self::calculate_duration(pos, time_to_maturity, config);
59 let convexity = Self::calculate_convexity(pos, time_to_maturity, config);
60
61 total_pv += pv;
62 weighted_duration += duration * pv;
63 weighted_convexity += convexity * pv;
64
65 let mod_dur = duration / (1.0 + pos.rate / config.compounding_frequency as f64);
67 let pv01 = pv * mod_dur * 0.0001;
68 *pv01_by_currency.entry(pos.currency.clone()).or_default() += pv01;
69 }
70
71 let duration = if total_pv != 0.0 {
73 weighted_duration / total_pv
74 } else {
75 0.0
76 };
77
78 let convexity = if total_pv != 0.0 {
79 weighted_convexity / total_pv
80 } else {
81 0.0
82 };
83
84 let modified_duration =
85 duration / (1.0 + config.market_rate / config.compounding_frequency as f64);
86 let dv01 = total_pv * modified_duration * 0.0001;
87
88 let gap_by_bucket = Self::gap_analysis(positions, config);
90
91 IRRiskMetrics {
92 duration,
93 modified_duration,
94 convexity,
95 dv01,
96 pv01_by_currency,
97 gap_by_bucket,
98 }
99 }
100
101 fn calculate_present_value(pos: &IRPosition, config: &IRRiskConfig) -> f64 {
103 let current_date = config.valuation_date;
105 let time_to_maturity = (pos.maturity - current_date) as f64 / (365.0 * 86400.0);
106
107 if time_to_maturity <= 0.0 {
108 return pos.notional;
109 }
110
111 let discount = (1.0 + config.market_rate / config.compounding_frequency as f64)
113 .powf(-time_to_maturity * config.compounding_frequency as f64);
114
115 pos.notional * discount
116 }
117
118 fn calculate_duration(pos: &IRPosition, ttm: f64, config: &IRRiskConfig) -> f64 {
120 match pos.instrument_type {
121 IRInstrumentType::FixedBond | IRInstrumentType::FixedLoan => {
122 let coupon_rate = pos.rate;
124 let yield_rate = config.market_rate;
125 let n = (ttm * config.compounding_frequency as f64).ceil() as i32;
126
127 if n <= 0 || yield_rate <= 0.0 {
128 return ttm;
129 }
130
131 let y = yield_rate / config.compounding_frequency as f64;
132 let c = coupon_rate / config.compounding_frequency as f64;
133
134 let mut numerator = 0.0;
136 let mut denominator = 0.0;
137
138 for t in 1..=n {
139 let df = 1.0 / (1.0 + y).powi(t);
140 let cf = if t == n { c + 1.0 } else { c };
141 numerator += (t as f64) * cf * df;
142 denominator += cf * df;
143 }
144
145 if denominator > 0.0 {
146 numerator / denominator / config.compounding_frequency as f64
147 } else {
148 ttm
149 }
150 }
151 IRInstrumentType::FloatingNote | IRInstrumentType::FloatingLoan => {
152 if let Some(next_reset) = pos.next_reset {
154 let reset_ttm = (next_reset - config.valuation_date) as f64 / (365.0 * 86400.0);
155 reset_ttm.max(0.0)
156 } else {
157 0.25 }
159 }
160 IRInstrumentType::Swap => {
161 let float_duration = 0.25; let swap_rate = pos.rate.max(0.01);
171 let fixed_duration = if swap_rate > 0.0001 {
172 (1.0 - (-swap_rate * ttm).exp()) / swap_rate
173 } else {
174 ttm
175 };
176
177 (fixed_duration - float_duration).abs()
179 }
180 IRInstrumentType::Deposit => {
181 ttm
183 }
184 }
185 }
186
187 fn calculate_convexity(pos: &IRPosition, ttm: f64, config: &IRRiskConfig) -> f64 {
189 match pos.instrument_type {
190 IRInstrumentType::FixedBond | IRInstrumentType::FixedLoan => {
191 let coupon_rate = pos.rate;
193 let yield_rate = config.market_rate;
194 let n = (ttm * config.compounding_frequency as f64).ceil() as i32;
195
196 if n <= 0 || yield_rate <= 0.0 {
197 return ttm * ttm;
198 }
199
200 let y = yield_rate / config.compounding_frequency as f64;
201 let c = coupon_rate / config.compounding_frequency as f64;
202
203 let mut numerator = 0.0;
204 let mut denominator = 0.0;
205
206 for t in 1..=n {
207 let df = 1.0 / (1.0 + y).powi(t);
208 let cf = if t == n { c + 1.0 } else { c };
209 numerator += (t as f64) * ((t + 1) as f64) * cf * df;
210 denominator += cf * df;
211 }
212
213 if denominator > 0.0 {
214 numerator
215 / denominator
216 / (1.0 + y).powi(2)
217 / (config.compounding_frequency as f64).powi(2)
218 } else {
219 ttm * ttm
220 }
221 }
222 IRInstrumentType::FloatingNote | IRInstrumentType::FloatingLoan => {
223 0.01
225 }
226 IRInstrumentType::Swap | IRInstrumentType::Deposit => {
227 ttm * ttm * 0.5
229 }
230 }
231 }
232
233 pub fn gap_analysis(positions: &[IRPosition], config: &IRRiskConfig) -> Vec<GapBucket> {
235 let buckets = &config.gap_buckets;
236 let current_date = config.valuation_date;
237
238 let mut results: Vec<GapBucket> = buckets
239 .iter()
240 .map(|b| GapBucket {
241 bucket: b.name.clone(),
242 start_days: b.start_days,
243 end_days: b.end_days,
244 assets: 0.0,
245 liabilities: 0.0,
246 gap: 0.0,
247 cumulative_gap: 0.0,
248 })
249 .collect();
250
251 for pos in positions {
253 let maturity_date = match pos.instrument_type {
254 IRInstrumentType::FloatingNote | IRInstrumentType::FloatingLoan => {
255 pos.next_reset.unwrap_or(pos.maturity)
257 }
258 _ => pos.maturity,
259 };
260
261 let days_to_maturity = if maturity_date > current_date {
262 ((maturity_date - current_date) / 86400) as u32
263 } else {
264 0
265 };
266
267 for bucket in &mut results {
269 if days_to_maturity >= bucket.start_days && days_to_maturity < bucket.end_days {
270 if pos.notional > 0.0 {
271 bucket.assets += pos.notional;
272 } else {
273 bucket.liabilities += pos.notional.abs();
274 }
275 break;
276 }
277 }
278 }
279
280 let mut cumulative = 0.0;
282 for bucket in &mut results {
283 bucket.gap = bucket.assets - bucket.liabilities;
284 cumulative += bucket.gap;
285 bucket.cumulative_gap = cumulative;
286 }
287
288 results
289 }
290
291 pub fn parallel_shift_sensitivity(
293 positions: &[IRPosition],
294 config: &IRRiskConfig,
295 shift_bps: f64,
296 ) -> ShiftSensitivity {
297 let shift = shift_bps / 10000.0;
298
299 let pv_before: f64 = positions
301 .iter()
302 .map(|p| Self::calculate_present_value(p, config))
303 .sum();
304
305 let mut shifted_config = config.clone();
307 shifted_config.market_rate += shift;
308 let pv_after: f64 = positions
309 .iter()
310 .map(|p| Self::calculate_present_value(p, &shifted_config))
311 .sum();
312
313 let pv_change = pv_after - pv_before;
314 let pct_change = if pv_before != 0.0 {
315 pv_change / pv_before
316 } else {
317 0.0
318 };
319
320 ShiftSensitivity {
321 shift_bps,
322 pv_before,
323 pv_after,
324 pv_change,
325 pct_change,
326 }
327 }
328
329 pub fn key_rate_durations(
331 positions: &[IRPosition],
332 config: &IRRiskConfig,
333 tenors: &[u32],
334 ) -> HashMap<u32, f64> {
335 let mut krd: HashMap<u32, f64> = HashMap::new();
336 let shift_bps = 1.0;
337
338 for &tenor in tenors {
339 let tenor_positions: Vec<_> = positions
341 .iter()
342 .filter(|p| {
343 let days = ((p.maturity - config.valuation_date) / 86400) as u32;
344 let tenor_days = tenor * 365;
345 days >= tenor_days.saturating_sub(180) && days <= tenor_days + 180
346 })
347 .cloned()
348 .collect();
349
350 if tenor_positions.is_empty() {
351 krd.insert(tenor, 0.0);
352 continue;
353 }
354
355 let sensitivity = Self::parallel_shift_sensitivity(&tenor_positions, config, shift_bps);
356 let krd_value = -sensitivity.pct_change * 10000.0; krd.insert(tenor, krd_value);
358 }
359
360 krd
361 }
362}
363
364impl GpuKernel for InterestRateRisk {
365 fn metadata(&self) -> &KernelMetadata {
366 &self.metadata
367 }
368}
369
370#[derive(Debug, Clone)]
372pub struct IRRiskConfig {
373 pub valuation_date: u64,
375 pub market_rate: f64,
377 pub compounding_frequency: u32,
379 pub gap_buckets: Vec<GapBucketDef>,
381}
382
383impl Default for IRRiskConfig {
384 fn default() -> Self {
385 Self {
386 valuation_date: 0,
387 market_rate: 0.05,
388 compounding_frequency: 2,
389 gap_buckets: vec![
390 GapBucketDef {
391 name: "0-30d".to_string(),
392 start_days: 0,
393 end_days: 30,
394 },
395 GapBucketDef {
396 name: "30-90d".to_string(),
397 start_days: 30,
398 end_days: 90,
399 },
400 GapBucketDef {
401 name: "90-180d".to_string(),
402 start_days: 90,
403 end_days: 180,
404 },
405 GapBucketDef {
406 name: "180d-1y".to_string(),
407 start_days: 180,
408 end_days: 365,
409 },
410 GapBucketDef {
411 name: "1-2y".to_string(),
412 start_days: 365,
413 end_days: 730,
414 },
415 GapBucketDef {
416 name: "2-5y".to_string(),
417 start_days: 730,
418 end_days: 1825,
419 },
420 GapBucketDef {
421 name: ">5y".to_string(),
422 start_days: 1825,
423 end_days: u32::MAX,
424 },
425 ],
426 }
427 }
428}
429
430#[derive(Debug, Clone)]
432pub struct GapBucketDef {
433 pub name: String,
435 pub start_days: u32,
437 pub end_days: u32,
439}
440
441#[derive(Debug, Clone)]
443pub struct ShiftSensitivity {
444 pub shift_bps: f64,
446 pub pv_before: f64,
448 pub pv_after: f64,
450 pub pv_change: f64,
452 pub pct_change: f64,
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn create_test_positions() -> Vec<IRPosition> {
461 let base_date: u64 = 1700000000;
462 vec![
463 IRPosition {
464 id: "BOND_001".to_string(),
465 instrument_type: IRInstrumentType::FixedBond,
466 notional: 1_000_000.0,
467 rate: 0.05,
468 maturity: base_date + 2 * 365 * 86400, next_reset: None,
470 currency: "USD".to_string(),
471 },
472 IRPosition {
473 id: "FRN_001".to_string(),
474 instrument_type: IRInstrumentType::FloatingNote,
475 notional: 500_000.0,
476 rate: 0.04,
477 maturity: base_date + 3 * 365 * 86400, next_reset: Some(base_date + 90 * 86400), currency: "USD".to_string(),
480 },
481 IRPosition {
482 id: "LOAN_001".to_string(),
483 instrument_type: IRInstrumentType::FixedLoan,
484 notional: -750_000.0, rate: 0.06,
486 maturity: base_date + 5 * 365 * 86400, next_reset: None,
488 currency: "USD".to_string(),
489 },
490 ]
491 }
492
493 #[test]
494 fn test_ir_metadata() {
495 let kernel = InterestRateRisk::new();
496 assert_eq!(kernel.metadata().id, "treasury/ir-risk");
497 assert_eq!(kernel.metadata().domain, Domain::TreasuryManagement);
498 }
499
500 #[test]
501 fn test_calculate_metrics() {
502 let base_date: u64 = 1700000000;
504 let positions = vec![
505 IRPosition {
506 id: "BOND_001".to_string(),
507 instrument_type: IRInstrumentType::FixedBond,
508 notional: 1_000_000.0,
509 rate: 0.05,
510 maturity: base_date + 2 * 365 * 86400,
511 next_reset: None,
512 currency: "USD".to_string(),
513 },
514 IRPosition {
515 id: "FRN_001".to_string(),
516 instrument_type: IRInstrumentType::FloatingNote,
517 notional: 500_000.0,
518 rate: 0.04,
519 maturity: base_date + 3 * 365 * 86400,
520 next_reset: Some(base_date + 90 * 86400),
521 currency: "USD".to_string(),
522 },
523 ];
524
525 let config = IRRiskConfig {
526 valuation_date: base_date,
527 market_rate: 0.05,
528 ..Default::default()
529 };
530
531 let metrics = InterestRateRisk::calculate_metrics(&positions, &config);
532
533 assert!(metrics.duration > 0.0);
535 assert!(metrics.duration < 10.0);
536
537 assert!(metrics.modified_duration < metrics.duration);
539
540 assert!(metrics.dv01 > 0.0);
542
543 assert!(metrics.pv01_by_currency.contains_key("USD"));
545 }
546
547 #[test]
548 fn test_duration_fixed_vs_floating() {
549 let base_date: u64 = 1700000000;
550 let fixed_bond = IRPosition {
551 id: "FIXED".to_string(),
552 instrument_type: IRInstrumentType::FixedBond,
553 notional: 1_000_000.0,
554 rate: 0.05,
555 maturity: base_date + 5 * 365 * 86400,
556 next_reset: None,
557 currency: "USD".to_string(),
558 };
559
560 let floating = IRPosition {
561 id: "FLOAT".to_string(),
562 instrument_type: IRInstrumentType::FloatingNote,
563 notional: 1_000_000.0,
564 rate: 0.05,
565 maturity: base_date + 5 * 365 * 86400,
566 next_reset: Some(base_date + 90 * 86400),
567 currency: "USD".to_string(),
568 };
569
570 let config = IRRiskConfig {
571 valuation_date: base_date,
572 ..Default::default()
573 };
574
575 let fixed_metrics = InterestRateRisk::calculate_metrics(&[fixed_bond], &config);
576 let float_metrics = InterestRateRisk::calculate_metrics(&[floating], &config);
577
578 assert!(fixed_metrics.duration > float_metrics.duration);
580 }
581
582 #[test]
583 fn test_gap_analysis() {
584 let positions = create_test_positions();
585 let config = IRRiskConfig {
586 valuation_date: 1700000000,
587 ..Default::default()
588 };
589
590 let gaps = InterestRateRisk::gap_analysis(&positions, &config);
591
592 assert!(!gaps.is_empty());
593
594 assert_eq!(gaps.len(), config.gap_buckets.len());
596
597 let total_gap: f64 = gaps.iter().map(|g| g.gap).sum();
599 let final_cumulative = gaps.last().unwrap().cumulative_gap;
600 assert!((total_gap - final_cumulative).abs() < 0.01);
601 }
602
603 #[test]
604 fn test_parallel_shift_sensitivity() {
605 let positions = create_test_positions();
606 let config = IRRiskConfig {
607 valuation_date: 1700000000,
608 market_rate: 0.05,
609 ..Default::default()
610 };
611
612 let sensitivity = InterestRateRisk::parallel_shift_sensitivity(&positions, &config, 100.0);
613
614 assert!(sensitivity.pv_before != 0.0);
617 assert!(sensitivity.pv_after != 0.0);
618 }
619
620 #[test]
621 fn test_key_rate_durations() {
622 let positions = create_test_positions();
623 let config = IRRiskConfig {
624 valuation_date: 1700000000,
625 ..Default::default()
626 };
627
628 let tenors = vec![1, 2, 5, 10];
629 let krd = InterestRateRisk::key_rate_durations(&positions, &config, &tenors);
630
631 assert_eq!(krd.len(), tenors.len());
632
633 for tenor in &tenors {
635 assert!(krd.contains_key(tenor));
636 }
637 }
638
639 #[test]
640 fn test_convexity_positive() {
641 let base_date: u64 = 1700000000;
642 let bond = IRPosition {
643 id: "BOND".to_string(),
644 instrument_type: IRInstrumentType::FixedBond,
645 notional: 1_000_000.0,
646 rate: 0.05,
647 maturity: base_date + 10 * 365 * 86400, next_reset: None,
649 currency: "USD".to_string(),
650 };
651
652 let config = IRRiskConfig {
653 valuation_date: base_date,
654 ..Default::default()
655 };
656
657 let metrics = InterestRateRisk::calculate_metrics(&[bond], &config);
658
659 assert!(metrics.convexity > 0.0);
661 }
662
663 #[test]
664 fn test_empty_positions() {
665 let positions: Vec<IRPosition> = vec![];
666 let config = IRRiskConfig::default();
667
668 let metrics = InterestRateRisk::calculate_metrics(&positions, &config);
669
670 assert_eq!(metrics.duration, 0.0);
671 assert_eq!(metrics.modified_duration, 0.0);
672 assert_eq!(metrics.dv01, 0.0);
673 }
674
675 #[test]
676 fn test_pv01_by_currency() {
677 let base_date: u64 = 1700000000;
678 let positions = vec![
679 IRPosition {
680 id: "USD_BOND".to_string(),
681 instrument_type: IRInstrumentType::FixedBond,
682 notional: 1_000_000.0,
683 rate: 0.05,
684 maturity: base_date + 2 * 365 * 86400,
685 next_reset: None,
686 currency: "USD".to_string(),
687 },
688 IRPosition {
689 id: "EUR_BOND".to_string(),
690 instrument_type: IRInstrumentType::FixedBond,
691 notional: 500_000.0,
692 rate: 0.04,
693 maturity: base_date + 3 * 365 * 86400,
694 next_reset: None,
695 currency: "EUR".to_string(),
696 },
697 ];
698
699 let config = IRRiskConfig {
700 valuation_date: base_date,
701 ..Default::default()
702 };
703
704 let metrics = InterestRateRisk::calculate_metrics(&positions, &config);
705
706 assert!(metrics.pv01_by_currency.contains_key("USD"));
707 assert!(metrics.pv01_by_currency.contains_key("EUR"));
708 }
709}