1use 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#[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 #[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 pub fn compute(factors: &CreditFactors, ead: f64, maturity: f64) -> CreditRiskResult {
56 let mut score = 600.0; let mut contributions = Vec::new();
59
60 let payment_contrib = factors.payment_history * 0.35;
62 score += payment_contrib;
63 contributions.push(("Payment History".to_string(), payment_contrib));
64
65 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 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 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 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 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 let credit_score = score.clamp(300.0, 850.0);
92
93 let pd = Self::score_to_pd(credit_score);
95
96 let lgd = Self::estimate_lgd(factors.loan_to_value);
98
99 let expected_loss = pd * lgd * ead;
101
102 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 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 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 fn score_to_pd(score: f64) -> f64 {
147 let x = (700.0 - score) / 50.0;
150 1.0 / (1.0 + (-x).exp()) * 0.30 }
152
153 fn pd_to_score(pd: f64) -> f64 {
155 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 fn estimate_lgd(ltv: f64) -> f64 {
163 let base_lgd = 0.45; let secured_reduction = (1.0 - ltv.min(1.0)) * 0.30;
167 (base_lgd - secured_reduction).max(0.10)
168 }
169
170 fn calculate_rwa(pd: f64, lgd: f64, ead: f64, maturity: f64) -> f64 {
172 let pd_clamped = pd.clamp(0.0003, 1.0);
174 let lgd_clamped = lgd.clamp(0.0, 1.0);
175
176 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 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 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 12.5 * k * ead
194 }
195
196 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 fn norm_inv(p: f64) -> f64 {
208 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 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 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); }
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 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 let pd_low = CreditRiskScoring::score_to_pd(score + 50.0);
455 assert!(pd_low < pd, "Higher score should have lower PD");
456 }
457 }
458}