rustkernel_treasury/
interest_rate.rs

1//! Interest rate risk kernel.
2//!
3//! This module provides interest rate risk analysis for treasury:
4//! - Duration and convexity calculation
5//! - DV01/PV01 sensitivity measures
6//! - Gap analysis by time buckets
7
8use crate::types::{GapBucket, IRInstrumentType, IRPosition, IRRiskMetrics};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12// ============================================================================
13// Interest Rate Risk Kernel
14// ============================================================================
15
16/// Interest rate risk kernel.
17///
18/// Calculates duration, convexity, and gap analysis for IR-sensitive positions.
19#[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    /// Create a new interest rate risk kernel.
32    #[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    /// Calculate interest rate risk metrics.
43    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            // PV01 calculation: pv * modified_duration * 0.0001
66            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        // Normalize weighted metrics
72        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        // Gap analysis
89        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    /// Calculate present value of a position.
102    fn calculate_present_value(pos: &IRPosition, config: &IRRiskConfig) -> f64 {
103        // Simplified PV calculation
104        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        // Discount factor
112        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    /// Calculate Macaulay duration.
119    fn calculate_duration(pos: &IRPosition, ttm: f64, config: &IRRiskConfig) -> f64 {
120        match pos.instrument_type {
121            IRInstrumentType::FixedBond | IRInstrumentType::FixedLoan => {
122                // Approximate bond duration
123                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                // Duration formula for bond
135                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                // Floating rate: duration to next reset
153                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 // Default quarterly reset assumption
158                }
159            }
160            IRInstrumentType::Swap => {
161                // Interest rate swap duration
162                // Pay fixed: Duration = -Duration_fixed + Duration_float ≈ -Duration_fixed
163                // Receive fixed: Duration = Duration_fixed - Duration_float ≈ Duration_fixed
164                // For receive-fixed (typical): fixed leg duration - floating leg duration
165                // Floating leg duration ≈ time to next reset (typically ~0.25 for quarterly)
166                let float_duration = 0.25; // Quarterly reset assumed
167
168                // Fixed leg duration approximation (zero-coupon equivalent)
169                // For an at-par swap, fixed leg duration ≈ (1 - e^(-y*T)) / y where y is swap rate
170                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                // Net duration (receive fixed positive, pay fixed negative encoded in notional sign)
178                (fixed_duration - float_duration).abs()
179            }
180            IRInstrumentType::Deposit => {
181                // Deposit duration = time to maturity
182                ttm
183            }
184        }
185    }
186
187    /// Calculate convexity.
188    fn calculate_convexity(pos: &IRPosition, ttm: f64, config: &IRRiskConfig) -> f64 {
189        match pos.instrument_type {
190            IRInstrumentType::FixedBond | IRInstrumentType::FixedLoan => {
191                // Approximate convexity
192                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                // Very low convexity for floaters
224                0.01
225            }
226            IRInstrumentType::Swap | IRInstrumentType::Deposit => {
227                // Simplified convexity
228                ttm * ttm * 0.5
229            }
230        }
231    }
232
233    /// Perform gap analysis.
234    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        // Classify positions into buckets
252        for pos in positions {
253            let maturity_date = match pos.instrument_type {
254                IRInstrumentType::FloatingNote | IRInstrumentType::FloatingLoan => {
255                    // Use next reset date for floating
256                    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            // Find appropriate bucket
268            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        // Calculate gaps and cumulative gaps
281        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    /// Calculate sensitivity to parallel shift.
292    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        // Calculate PV before shift
300        let pv_before: f64 = positions
301            .iter()
302            .map(|p| Self::calculate_present_value(p, config))
303            .sum();
304
305        // Calculate PV after shift
306        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    /// Calculate key rate duration (sensitivity to specific tenor).
330    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            // Filter positions maturing around this tenor
340            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; // Duration per 100bp
357            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/// Interest rate risk configuration.
371#[derive(Debug, Clone)]
372pub struct IRRiskConfig {
373    /// Valuation date (Unix timestamp).
374    pub valuation_date: u64,
375    /// Current market rate.
376    pub market_rate: f64,
377    /// Compounding frequency per year.
378    pub compounding_frequency: u32,
379    /// Gap analysis buckets.
380    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/// Gap bucket definition.
431#[derive(Debug, Clone)]
432pub struct GapBucketDef {
433    /// Bucket name.
434    pub name: String,
435    /// Start days.
436    pub start_days: u32,
437    /// End days.
438    pub end_days: u32,
439}
440
441/// Parallel shift sensitivity result.
442#[derive(Debug, Clone)]
443pub struct ShiftSensitivity {
444    /// Shift amount in basis points.
445    pub shift_bps: f64,
446    /// PV before shift.
447    pub pv_before: f64,
448    /// PV after shift.
449    pub pv_after: f64,
450    /// PV change.
451    pub pv_change: f64,
452    /// Percentage change.
453    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, // 2 years
469                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, // 3 years
478                next_reset: Some(base_date + 90 * 86400), // 90 days
479                currency: "USD".to_string(),
480            },
481            IRPosition {
482                id: "LOAN_001".to_string(),
483                instrument_type: IRInstrumentType::FixedLoan,
484                notional: -750_000.0, // Liability
485                rate: 0.06,
486                maturity: base_date + 5 * 365 * 86400, // 5 years
487                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        // Use only asset positions for this test
503        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        // Duration should be positive and reasonable for asset-only portfolio
534        assert!(metrics.duration > 0.0);
535        assert!(metrics.duration < 10.0);
536
537        // Modified duration should be less than Macaulay duration
538        assert!(metrics.modified_duration < metrics.duration);
539
540        // DV01 should be positive for asset portfolio
541        assert!(metrics.dv01 > 0.0);
542
543        // Should have PV01 for USD
544        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        // Fixed bond should have higher duration than floater
579        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        // All buckets should be present
595        assert_eq!(gaps.len(), config.gap_buckets.len());
596
597        // Cumulative gap should be sum of individual gaps
598        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        // PV should decrease when rates increase (for net asset positions)
615        // This depends on the mix of assets/liabilities
616        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        // Should have entry for each tenor
634        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, // 10 years
648            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        // Convexity should be positive for standard bonds
660        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}