rustkernel_risk/
credit.rs

1//! Credit risk scoring kernels.
2//!
3//! This module provides credit risk analytics:
4//! - PD (Probability of Default) modeling
5//! - LGD (Loss Given Default) estimation
6//! - Expected Loss calculation
7//! - Risk-weighted asset calculation
8
9use crate::messages::{
10    CreditRiskBatchInput, CreditRiskBatchOutput, CreditRiskScoringInput, CreditRiskScoringOutput,
11};
12use crate::types::{CreditExposure, CreditFactors, CreditRiskResult};
13use async_trait::async_trait;
14use rustkernel_core::error::Result;
15use rustkernel_core::traits::BatchKernel;
16use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
17use std::time::Instant;
18
19// ============================================================================
20// Credit Risk Scoring Kernel
21// ============================================================================
22
23/// Credit risk scoring kernel.
24///
25/// Calculates PD, LGD, EL, and RWA for credit exposures.
26#[derive(Debug, Clone)]
27pub struct CreditRiskScoring {
28    metadata: KernelMetadata,
29}
30
31impl Default for CreditRiskScoring {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl CreditRiskScoring {
38    /// Create a new credit risk scoring kernel.
39    #[must_use]
40    pub fn new() -> Self {
41        Self {
42            metadata: KernelMetadata::ring("risk/credit-scoring", Domain::RiskAnalytics)
43                .with_description("PD/LGD/EAD credit risk calculation")
44                .with_throughput(50_000)
45                .with_latency_us(100.0),
46        }
47    }
48
49    /// Score credit risk for a single obligor.
50    ///
51    /// # Arguments
52    /// * `factors` - Credit scoring factors
53    /// * `ead` - Exposure at Default
54    /// * `maturity` - Loan maturity in years
55    pub fn compute(factors: &CreditFactors, ead: f64, maturity: f64) -> CreditRiskResult {
56        // Calculate credit score using a simplified scorecard model
57        let mut score = 600.0; // Base score
58        let mut contributions = Vec::new();
59
60        // Payment history (most important factor)
61        let payment_contrib = factors.payment_history * 0.35;
62        score += payment_contrib;
63        contributions.push(("Payment History".to_string(), payment_contrib));
64
65        // Credit utilization (lower is better)
66        let util_impact = (1.0 - factors.credit_utilization) * 100.0 * 0.30;
67        score += util_impact;
68        contributions.push(("Credit Utilization".to_string(), util_impact));
69
70        // Credit history length
71        let history_impact = factors.credit_history_years.min(30.0) * 2.0 * 0.15;
72        score += history_impact;
73        contributions.push(("Credit History Length".to_string(), history_impact));
74
75        // Debt-to-income (lower is better)
76        let dti_impact = (1.0 - factors.debt_to_income.min(1.0)) * 50.0 * 0.10;
77        score += dti_impact;
78        contributions.push(("Debt-to-Income".to_string(), dti_impact));
79
80        // Recent inquiries (fewer is better)
81        let inquiry_impact = (10 - factors.recent_inquiries.min(10)) as f64 * 3.0 * 0.05;
82        score += inquiry_impact;
83        contributions.push(("Recent Inquiries".to_string(), inquiry_impact));
84
85        // Delinquencies (none is best)
86        let delinq_impact = -((factors.delinquencies as f64) * 20.0 * 0.05);
87        score += delinq_impact;
88        contributions.push(("Delinquencies".to_string(), delinq_impact));
89
90        // Clamp score
91        let credit_score = score.clamp(300.0, 850.0);
92
93        // Convert score to PD (logistic function)
94        let pd = Self::score_to_pd(credit_score);
95
96        // Estimate LGD based on collateral (LTV ratio)
97        let lgd = Self::estimate_lgd(factors.loan_to_value);
98
99        // Expected Loss = PD * LGD * EAD
100        let expected_loss = pd * lgd * ead;
101
102        // Risk-weighted assets (simplified Basel formula)
103        let rwa = Self::calculate_rwa(pd, lgd, ead, maturity);
104
105        CreditRiskResult {
106            obligor_id: factors.obligor_id,
107            pd,
108            lgd,
109            expected_loss,
110            rwa,
111            credit_score,
112            factor_contributions: contributions,
113        }
114    }
115
116    /// Batch score multiple obligors.
117    pub fn compute_batch(
118        factors_list: &[CreditFactors],
119        eads: &[f64],
120        maturities: &[f64],
121    ) -> Vec<CreditRiskResult> {
122        factors_list
123            .iter()
124            .zip(eads.iter())
125            .zip(maturities.iter())
126            .map(|((f, &ead), &mat)| Self::compute(f, ead, mat))
127            .collect()
128    }
129
130    /// Score credit risk from existing exposure data.
131    pub fn compute_from_exposure(exposure: &CreditExposure) -> CreditRiskResult {
132        let rwa = Self::calculate_rwa(exposure.pd, exposure.lgd, exposure.ead, exposure.maturity);
133
134        CreditRiskResult {
135            obligor_id: exposure.obligor_id,
136            pd: exposure.pd,
137            lgd: exposure.lgd,
138            expected_loss: exposure.expected_loss(),
139            rwa,
140            credit_score: Self::pd_to_score(exposure.pd),
141            factor_contributions: Vec::new(),
142        }
143    }
144
145    /// Convert credit score to probability of default.
146    fn score_to_pd(score: f64) -> f64 {
147        // Logistic transformation: higher score = lower PD
148        // Calibrated so that score 700 ≈ 2% PD, 600 ≈ 10% PD
149        let x = (700.0 - score) / 50.0;
150        1.0 / (1.0 + (-x).exp()) * 0.30 // Cap at 30% PD
151    }
152
153    /// Convert PD back to approximate credit score.
154    fn pd_to_score(pd: f64) -> f64 {
155        // Inverse of score_to_pd
156        let clamped_pd = pd.clamp(0.001, 0.30);
157        let x = (clamped_pd / 0.30).ln() - (-clamped_pd / 0.30 + 1.0).ln();
158        700.0 - x * 50.0
159    }
160
161    /// Estimate LGD based on loan-to-value ratio.
162    fn estimate_lgd(ltv: f64) -> f64 {
163        // Higher LTV = higher LGD
164        // Assumes some recovery from collateral
165        let base_lgd = 0.45; // Unsecured baseline
166        let secured_reduction = (1.0 - ltv.min(1.0)) * 0.30;
167        (base_lgd - secured_reduction).max(0.10)
168    }
169
170    /// Calculate risk-weighted assets using Basel IRB formula (simplified).
171    fn calculate_rwa(pd: f64, lgd: f64, ead: f64, maturity: f64) -> f64 {
172        // Simplified Basel II IRB formula
173        let pd_clamped = pd.clamp(0.0003, 1.0);
174        let lgd_clamped = lgd.clamp(0.0, 1.0);
175
176        // Asset correlation (depends on PD)
177        let r = 0.12 * (1.0 - (-50.0 * pd_clamped).exp()) / (1.0 - (-50.0_f64).exp())
178            + 0.24 * (1.0 - (1.0 - (-50.0 * pd_clamped).exp()) / (1.0 - (-50.0_f64).exp()));
179
180        // Maturity adjustment
181        let b = (0.11852 - 0.05478 * pd_clamped.ln()).powi(2);
182        let m_adj = (1.0 + (maturity - 2.5) * b) / (1.0 - 1.5 * b);
183
184        // Capital requirement
185        let k = lgd_clamped
186            * (Self::norm_cdf(
187                Self::norm_inv(pd_clamped) / (1.0 - r).sqrt()
188                    + (r / (1.0 - r)).sqrt() * Self::norm_inv(0.999),
189            ) - pd_clamped)
190            * m_adj;
191
192        // RWA = 12.5 * K * EAD
193        12.5 * k * ead
194    }
195
196    /// Standard normal CDF approximation.
197    fn norm_cdf(x: f64) -> f64 {
198        let t = 1.0 / (1.0 + 0.2316419 * x.abs());
199        let d = 0.3989423 * (-x * x / 2.0).exp();
200        let p = d
201            * t
202            * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
203        if x > 0.0 { 1.0 - p } else { p }
204    }
205
206    /// Standard normal inverse CDF approximation.
207    fn norm_inv(p: f64) -> f64 {
208        // Rational approximation (Abramowitz & Stegun)
209        let p_clamped = p.clamp(1e-10, 1.0 - 1e-10);
210
211        let a = [
212            -3.969683028665376e+01,
213            2.209460984245205e+02,
214            -2.759285104469687e+02,
215            1.383_577_518_672_69e2,
216            -3.066479806614716e+01,
217            2.506628277459239e+00,
218        ];
219
220        let b = [
221            -5.447609879822406e+01,
222            1.615858368580409e+02,
223            -1.556989798598866e+02,
224            6.680131188771972e+01,
225            -1.328068155288572e+01,
226        ];
227
228        let c = [
229            -7.784894002430293e-03,
230            -3.223964580411365e-01,
231            -2.400758277161838e+00,
232            -2.549732539343734e+00,
233            4.374664141464968e+00,
234            2.938163982698783e+00,
235        ];
236
237        let d = [
238            7.784695709041462e-03,
239            3.224671290700398e-01,
240            2.445134137142996e+00,
241            3.754408661907416e+00,
242        ];
243
244        let p_low = 0.02425;
245        let p_high = 1.0 - p_low;
246
247        if p_clamped < p_low {
248            let q = (-2.0 * p_clamped.ln()).sqrt();
249            (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
250                / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
251        } else if p_clamped <= p_high {
252            let q = p_clamped - 0.5;
253            let r = q * q;
254            (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
255                / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
256        } else {
257            let q = (-2.0 * (1.0 - p_clamped).ln()).sqrt();
258            -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
259                / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
260        }
261    }
262}
263
264impl GpuKernel for CreditRiskScoring {
265    fn metadata(&self) -> &KernelMetadata {
266        &self.metadata
267    }
268}
269
270#[async_trait]
271impl BatchKernel<CreditRiskScoringInput, CreditRiskScoringOutput> for CreditRiskScoring {
272    async fn execute(&self, input: CreditRiskScoringInput) -> Result<CreditRiskScoringOutput> {
273        let start = Instant::now();
274        let result = Self::compute(&input.factors, input.ead, input.maturity);
275        Ok(CreditRiskScoringOutput {
276            result,
277            compute_time_us: start.elapsed().as_micros() as u64,
278        })
279    }
280}
281
282#[async_trait]
283impl BatchKernel<CreditRiskBatchInput, CreditRiskBatchOutput> for CreditRiskScoring {
284    async fn execute(&self, input: CreditRiskBatchInput) -> Result<CreditRiskBatchOutput> {
285        let start = Instant::now();
286        let results = input
287            .exposures
288            .iter()
289            .map(Self::compute_from_exposure)
290            .collect();
291        Ok(CreditRiskBatchOutput {
292            results,
293            compute_time_us: start.elapsed().as_micros() as u64,
294        })
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn create_good_obligor() -> CreditFactors {
303        CreditFactors {
304            obligor_id: 1,
305            debt_to_income: 0.25,
306            loan_to_value: 0.60,
307            credit_utilization: 0.15,
308            payment_history: 95.0,
309            employment_years: 10.0,
310            recent_inquiries: 1,
311            delinquencies: 0,
312            credit_history_years: 15.0,
313        }
314    }
315
316    fn create_risky_obligor() -> CreditFactors {
317        CreditFactors {
318            obligor_id: 2,
319            debt_to_income: 0.55,
320            loan_to_value: 0.95,
321            credit_utilization: 0.85,
322            payment_history: 60.0,
323            employment_years: 1.0,
324            recent_inquiries: 6,
325            delinquencies: 3,
326            credit_history_years: 2.0,
327        }
328    }
329
330    #[test]
331    fn test_credit_scoring_metadata() {
332        let kernel = CreditRiskScoring::new();
333        assert_eq!(kernel.metadata().id, "risk/credit-scoring");
334        assert_eq!(kernel.metadata().domain, Domain::RiskAnalytics);
335    }
336
337    #[test]
338    fn test_good_obligor_scoring() {
339        let factors = create_good_obligor();
340        let result = CreditRiskScoring::compute(&factors, 100_000.0, 5.0);
341
342        assert_eq!(result.obligor_id, 1);
343        assert!(
344            result.credit_score > 650.0,
345            "Good obligor should have score > 650, got {}",
346            result.credit_score
347        );
348        // PD maps from score via logistic function
349        assert!(
350            result.pd < 0.25,
351            "Good obligor should have PD < 25%, got {}",
352            result.pd
353        );
354        assert!(
355            result.lgd < 0.45,
356            "Secured loan should have LGD < 45%, got {}",
357            result.lgd
358        );
359        assert!(
360            result.expected_loss < 10000.0,
361            "Expected loss should be reasonable"
362        );
363    }
364
365    #[test]
366    fn test_risky_obligor_scoring() {
367        let factors = create_risky_obligor();
368        let result = CreditRiskScoring::compute(&factors, 100_000.0, 5.0);
369
370        assert_eq!(result.obligor_id, 2);
371        assert!(
372            result.credit_score < 650.0,
373            "Risky obligor should have score < 650, got {}",
374            result.credit_score
375        );
376        assert!(
377            result.pd > 0.05,
378            "Risky obligor should have PD > 5%, got {}",
379            result.pd
380        );
381        assert!(result.lgd > 0.35, "High LTV loan should have higher LGD");
382    }
383
384    #[test]
385    fn test_rwa_calculation() {
386        let good = create_good_obligor();
387        let risky = create_risky_obligor();
388
389        let good_result = CreditRiskScoring::compute(&good, 100_000.0, 5.0);
390        let risky_result = CreditRiskScoring::compute(&risky, 100_000.0, 5.0);
391
392        // Risky obligor should have higher RWA
393        assert!(
394            risky_result.rwa > good_result.rwa,
395            "Risky obligor should have higher RWA: {} vs {}",
396            risky_result.rwa,
397            good_result.rwa
398        );
399    }
400
401    #[test]
402    fn test_batch_scoring() {
403        let factors = vec![create_good_obligor(), create_risky_obligor()];
404        let eads = vec![100_000.0, 50_000.0];
405        let maturities = vec![5.0, 3.0];
406
407        let results = CreditRiskScoring::compute_batch(&factors, &eads, &maturities);
408
409        assert_eq!(results.len(), 2);
410        assert!(results[0].credit_score > results[1].credit_score);
411    }
412
413    #[test]
414    fn test_exposure_scoring() {
415        let exposure = CreditExposure::new(100, 50_000.0, 0.02, 0.40, 3.0, 2);
416
417        let result = CreditRiskScoring::compute_from_exposure(&exposure);
418
419        assert_eq!(result.obligor_id, 100);
420        assert!((result.pd - 0.02).abs() < 0.001);
421        assert!((result.lgd - 0.40).abs() < 0.001);
422        assert!((result.expected_loss - 400.0).abs() < 1.0); // 0.02 * 0.40 * 50000 = 400
423    }
424
425    #[test]
426    fn test_factor_contributions() {
427        let factors = create_good_obligor();
428        let result = CreditRiskScoring::compute(&factors, 100_000.0, 5.0);
429
430        assert!(!result.factor_contributions.is_empty());
431        assert!(
432            result
433                .factor_contributions
434                .iter()
435                .any(|(name, _)| name == "Payment History")
436        );
437    }
438
439    #[test]
440    fn test_pd_score_conversion() {
441        // Test round-trip conversion
442        let scores = [300.0, 500.0, 650.0, 700.0, 750.0, 800.0];
443
444        for &score in &scores {
445            let pd = CreditRiskScoring::score_to_pd(score);
446            assert!(
447                pd > 0.0 && pd <= 0.30,
448                "PD out of range for score {}: {}",
449                score,
450                pd
451            );
452
453            // Higher score should mean lower PD
454            let pd_low = CreditRiskScoring::score_to_pd(score + 50.0);
455            assert!(pd_low < pd, "Higher score should have lower PD");
456        }
457    }
458}