use crate::compat::Instant;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::CorpFinanceError;
use crate::types::{with_metadata, ComputationOutput};
use crate::CorpFinanceResult;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatedExposure {
pub name: String,
pub rating: String,
pub exposure: Decimal,
pub maturity_years: Decimal,
pub coupon_rate: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionMatrix {
pub ratings: Vec<String>,
pub probabilities: Vec<Vec<Decimal>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatingSpread {
pub rating: String,
pub spread_bps: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationInput {
pub initial_ratings: Vec<RatedExposure>,
pub transition_matrix: TransitionMatrix,
pub time_horizon_years: u32,
pub spread_curve: Vec<RatingSpread>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationResult {
pub name: String,
pub current_rating: String,
pub expected_rating_distribution: Vec<(String, Decimal)>,
pub upgrade_probability: Decimal,
pub downgrade_probability: Decimal,
pub default_probability: Decimal,
pub stable_probability: Decimal,
pub expected_value_change_pct: Decimal,
pub credit_var_contribution: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixQuality {
pub is_valid_stochastic: bool,
pub is_monotone: bool,
pub absorbing_state: String,
pub max_row_deviation: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationOutput {
pub portfolio_results: Vec<MigrationResult>,
pub portfolio_expected_migration_loss: Decimal,
pub portfolio_migration_var: Decimal,
pub multi_year_default_prob: Vec<(String, Decimal)>,
pub matrix_quality: MatrixQuality,
pub methodology: String,
pub assumptions: HashMap<String, String>,
pub warnings: Vec<String>,
}
fn matrix_multiply(a: &[Vec<Decimal>], b: &[Vec<Decimal>]) -> Vec<Vec<Decimal>> {
let n = a.len();
let mut result = vec![vec![Decimal::ZERO; n]; n];
for i in 0..n {
for j in 0..n {
let mut s = Decimal::ZERO;
for k in 0..n {
s += a[i][k] * b[k][j];
}
result[i][j] = s;
}
}
result
}
fn matrix_power(m: &[Vec<Decimal>], exp: u32) -> Vec<Vec<Decimal>> {
let n = m.len();
if exp == 0 {
let mut id = vec![vec![Decimal::ZERO; n]; n];
for (i, row) in id.iter_mut().enumerate() {
row[i] = Decimal::ONE;
}
return id;
}
if exp == 1 {
return m.to_vec();
}
let mut base = m.to_vec();
let mut result: Option<Vec<Vec<Decimal>>> = None;
let mut e = exp;
while e > 0 {
if e & 1 == 1 {
result = Some(match result {
None => base.clone(),
Some(ref r) => matrix_multiply(r, &base),
});
}
base = matrix_multiply(&base, &base);
e >>= 1;
}
result.unwrap_or_else(|| {
let mut id = vec![vec![Decimal::ZERO; n]; n];
for (i, row) in id.iter_mut().enumerate() {
row[i] = Decimal::ONE;
}
id
})
}
fn validate_input(input: &MigrationInput) -> CorpFinanceResult<Vec<String>> {
let warnings = Vec::new();
let matrix = &input.transition_matrix;
let n = matrix.ratings.len();
if matrix.probabilities.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: "transition_matrix.probabilities".into(),
reason: format!(
"matrix has {} rows but {} ratings",
matrix.probabilities.len(),
n
),
});
}
for (i, row) in matrix.probabilities.iter().enumerate() {
if row.len() != n {
return Err(CorpFinanceError::InvalidInput {
field: format!("transition_matrix.probabilities[{}]", i),
reason: format!("row has {} columns but {} ratings", row.len(), n),
});
}
}
if input.initial_ratings.is_empty() {
return Err(CorpFinanceError::InsufficientData(
"at least one rated exposure is required".into(),
));
}
for exp in &input.initial_ratings {
if !matrix.ratings.contains(&exp.rating) {
return Err(CorpFinanceError::InvalidInput {
field: "initial_ratings".into(),
reason: format!(
"exposure '{}' has rating '{}' not in transition matrix",
exp.name, exp.rating
),
});
}
}
for sp in &input.spread_curve {
if !matrix.ratings.contains(&sp.rating) {
return Err(CorpFinanceError::InvalidInput {
field: "spread_curve".into(),
reason: format!("spread rating '{}' not in transition matrix", sp.rating),
});
}
}
if input.time_horizon_years == 0 {
return Err(CorpFinanceError::InvalidInput {
field: "time_horizon_years".into(),
reason: "must be at least 1".into(),
});
}
Ok(warnings)
}
fn assess_matrix_quality(matrix: &TransitionMatrix) -> (MatrixQuality, Vec<String>) {
let mut warnings = Vec::new();
let n = matrix.ratings.len();
let mut max_row_deviation = Decimal::ZERO;
let mut is_valid_stochastic = true;
for (i, row) in matrix.probabilities.iter().enumerate() {
let row_sum: Decimal = row.iter().copied().sum();
let dev = (row_sum - Decimal::ONE).abs();
if dev > max_row_deviation {
max_row_deviation = dev;
}
if dev > dec!(0.001) {
is_valid_stochastic = false;
warnings.push(format!(
"Row '{}' sums to {} (deviation {})",
matrix.ratings[i], row_sum, dev
));
}
for (j, &p) in row.iter().enumerate() {
if p < Decimal::ZERO {
is_valid_stochastic = false;
warnings.push(format!(
"Negative probability at [{},{}]: {}",
matrix.ratings[i], matrix.ratings[j], p
));
}
}
}
let default_idx = n - 1;
let absorbing_state = matrix.ratings[default_idx].clone();
if n > 0 {
let d_row = &matrix.probabilities[default_idx];
let self_prob = d_row[default_idx];
if (self_prob - Decimal::ONE).abs() > dec!(0.001) {
warnings.push(format!(
"Default state '{}' is not absorbing (self-transition = {})",
absorbing_state, self_prob
));
}
}
let mut is_monotone = true;
if n >= 2 {
for i in 0..(n - 2) {
let pd_i = matrix.probabilities[i][default_idx];
let pd_next = matrix.probabilities[i + 1][default_idx];
if pd_next < pd_i - dec!(0.0001) {
is_monotone = false;
warnings.push(format!(
"Non-monotone: '{}' default prob {} > '{}' default prob {}",
matrix.ratings[i],
pd_i,
matrix.ratings[i + 1],
pd_next
));
}
}
}
(
MatrixQuality {
is_valid_stochastic,
is_monotone,
absorbing_state,
max_row_deviation,
},
warnings,
)
}
pub fn calculate_migration(
input: &MigrationInput,
) -> CorpFinanceResult<ComputationOutput<MigrationOutput>> {
let start = Instant::now();
let mut warnings = validate_input(input)?;
let matrix = &input.transition_matrix;
let n = matrix.ratings.len();
let default_idx = n - 1;
let (quality, quality_warnings) = assess_matrix_quality(matrix);
warnings.extend(quality_warnings);
if !quality.is_monotone {
warnings.push("Transition matrix is not monotone".into());
}
let spread_map: HashMap<String, Decimal> = input
.spread_curve
.iter()
.map(|s| (s.rating.clone(), s.spread_bps))
.collect();
let multi_year_matrix = matrix_power(&matrix.probabilities, input.time_horizon_years);
let multi_year_default_prob: Vec<(String, Decimal)> = matrix
.ratings
.iter()
.zip(multi_year_matrix.iter())
.map(|(rating, row)| (rating.clone(), row[default_idx]))
.collect();
let mut portfolio_results: Vec<MigrationResult> = Vec::new();
let mut portfolio_expected_loss = Decimal::ZERO;
let mut portfolio_var_sum = Decimal::ZERO;
for exp in &input.initial_ratings {
let row_idx = matrix
.ratings
.iter()
.position(|r| r == &exp.rating)
.unwrap();
let row = &multi_year_matrix[row_idx];
let expected_distribution: Vec<(String, Decimal)> = matrix
.ratings
.iter()
.zip(row.iter())
.map(|(r, p)| (r.clone(), *p))
.collect();
let stable_prob = row[row_idx];
let default_prob = row[default_idx];
let mut upgrade_prob = Decimal::ZERO;
let mut downgrade_prob = Decimal::ZERO;
for (j, &prob) in row.iter().enumerate() {
if j < row_idx {
upgrade_prob += prob;
} else if j > row_idx && j != default_idx {
downgrade_prob += prob;
}
}
if row_idx != default_idx {
downgrade_prob += default_prob;
}
let current_spread = spread_map
.get(&exp.rating)
.copied()
.unwrap_or(Decimal::ZERO);
let y = exp.coupon_rate + current_spread / dec!(10000);
let duration = if y > Decimal::ZERO && exp.maturity_years > Decimal::ZERO {
let mat_int = decimal_to_u32(exp.maturity_years);
if mat_int > 0 {
let denom = iterative_pow(Decimal::ONE + y, mat_int);
if denom > Decimal::ZERO {
(Decimal::ONE - Decimal::ONE / denom) / y
} else {
exp.maturity_years / dec!(2)
}
} else {
exp.maturity_years / dec!(2)
}
} else {
exp.maturity_years / dec!(2)
};
let mut expected_value_change = Decimal::ZERO;
let mut loss_prob_pairs: Vec<(Decimal, Decimal)> = Vec::with_capacity(n);
for (j, (rating, &prob)) in matrix.ratings.iter().zip(row.iter()).enumerate() {
let new_spread = spread_map.get(rating).copied().unwrap_or(Decimal::ZERO);
let value_change_pct = if j == default_idx {
dec!(-100)
} else {
-duration * (new_spread - current_spread) / dec!(10000) * dec!(100)
};
expected_value_change += prob * value_change_pct;
loss_prob_pairs.push((value_change_pct, prob));
}
loss_prob_pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut cum_prob = Decimal::ZERO;
let mut var_contribution = Decimal::ZERO;
for (loss, prob) in &loss_prob_pairs {
cum_prob += prob;
if cum_prob >= dec!(0.01) {
var_contribution = -(*loss) * exp.exposure / dec!(100);
break;
}
}
if cum_prob < dec!(0.01) && !loss_prob_pairs.is_empty() {
let worst = loss_prob_pairs[0].0;
var_contribution = -worst * exp.exposure / dec!(100);
}
portfolio_expected_loss += exp.exposure * expected_value_change / dec!(100);
portfolio_var_sum += var_contribution;
portfolio_results.push(MigrationResult {
name: exp.name.clone(),
current_rating: exp.rating.clone(),
expected_rating_distribution: expected_distribution,
upgrade_probability: upgrade_prob,
downgrade_probability: downgrade_prob,
default_probability: default_prob,
stable_probability: stable_prob,
expected_value_change_pct: expected_value_change,
credit_var_contribution: var_contribution,
});
}
let mut assumptions = HashMap::new();
assumptions.insert("model".into(), "Rating migration / Gaussian copula".into());
assumptions.insert(
"time_horizon".into(),
format!("{} year(s)", input.time_horizon_years),
);
assumptions.insert("matrix_size".into(), format!("{} ratings", n));
let output = MigrationOutput {
portfolio_results,
portfolio_expected_migration_loss: portfolio_expected_loss,
portfolio_migration_var: portfolio_var_sum,
multi_year_default_prob,
matrix_quality: quality,
methodology: "Rating migration / Markov chain".into(),
assumptions,
warnings: warnings.clone(),
};
let elapsed = start.elapsed().as_micros() as u64;
let meta_assumptions = serde_json::json!({
"model": "Rating migration / Markov chain",
"time_horizon_years": input.time_horizon_years,
"matrix_ratings": input.transition_matrix.ratings,
});
Ok(with_metadata(
"Rating migration / Markov chain",
&meta_assumptions,
warnings,
elapsed,
output,
))
}
fn decimal_to_u32(d: Decimal) -> u32 {
let s = d.to_string();
if let Some(dot_idx) = s.find('.') {
s[..dot_idx].parse().unwrap_or(0)
} else {
s.parse().unwrap_or(0)
}
}
fn iterative_pow(base: Decimal, exp: u32) -> Decimal {
if exp == 0 {
return Decimal::ONE;
}
let mut result = Decimal::ONE;
let mut b = base;
let mut e = exp;
while e > 0 {
if e & 1 == 1 {
result *= b;
}
b *= b;
e >>= 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn approx_eq(a: Decimal, b: Decimal, tol: Decimal) -> bool {
let diff = a - b;
let abs_diff = if diff < Decimal::ZERO { -diff } else { diff };
abs_diff < tol
}
fn sp_transition_matrix() -> TransitionMatrix {
TransitionMatrix {
ratings: vec![
"AAA".into(),
"AA".into(),
"A".into(),
"BBB".into(),
"BB".into(),
"B".into(),
"CCC".into(),
"D".into(),
],
probabilities: vec![
vec![
dec!(0.9081),
dec!(0.0833),
dec!(0.0068),
dec!(0.0006),
dec!(0.0012),
dec!(0.0000),
dec!(0.0000),
dec!(0.0000),
],
vec![
dec!(0.0070),
dec!(0.9065),
dec!(0.0779),
dec!(0.0064),
dec!(0.0006),
dec!(0.0014),
dec!(0.0002),
dec!(0.0000),
],
vec![
dec!(0.0009),
dec!(0.0227),
dec!(0.9105),
dec!(0.0552),
dec!(0.0074),
dec!(0.0026),
dec!(0.0001),
dec!(0.0006),
],
vec![
dec!(0.0002),
dec!(0.0033),
dec!(0.0595),
dec!(0.8693),
dec!(0.0530),
dec!(0.0117),
dec!(0.0012),
dec!(0.0018),
],
vec![
dec!(0.0003),
dec!(0.0014),
dec!(0.0067),
dec!(0.0773),
dec!(0.8053),
dec!(0.0884),
dec!(0.0100),
dec!(0.0106),
],
vec![
dec!(0.0000),
dec!(0.0011),
dec!(0.0024),
dec!(0.0043),
dec!(0.0648),
dec!(0.8346),
dec!(0.0407),
dec!(0.0521),
],
vec![
dec!(0.0022),
dec!(0.0000),
dec!(0.0022),
dec!(0.0130),
dec!(0.0238),
dec!(0.1124),
dec!(0.6486),
dec!(0.1978),
],
vec![
dec!(0.0000),
dec!(0.0000),
dec!(0.0000),
dec!(0.0000),
dec!(0.0000),
dec!(0.0000),
dec!(0.0000),
dec!(1.0000),
],
],
}
}
fn standard_spread_curve() -> Vec<RatingSpread> {
vec![
RatingSpread {
rating: "AAA".into(),
spread_bps: dec!(20),
},
RatingSpread {
rating: "AA".into(),
spread_bps: dec!(40),
},
RatingSpread {
rating: "A".into(),
spread_bps: dec!(70),
},
RatingSpread {
rating: "BBB".into(),
spread_bps: dec!(120),
},
RatingSpread {
rating: "BB".into(),
spread_bps: dec!(250),
},
RatingSpread {
rating: "B".into(),
spread_bps: dec!(450),
},
RatingSpread {
rating: "CCC".into(),
spread_bps: dec!(800),
},
RatingSpread {
rating: "D".into(),
spread_bps: dec!(2000),
},
]
}
fn single_bbb_input() -> MigrationInput {
MigrationInput {
initial_ratings: vec![RatedExposure {
name: "Corp A".into(),
rating: "BBB".into(),
exposure: dec!(1000000),
maturity_years: dec!(5),
coupon_rate: dec!(0.05),
}],
transition_matrix: sp_transition_matrix(),
time_horizon_years: 1,
spread_curve: standard_spread_curve(),
}
}
#[test]
fn test_matrix_power_identity() {
let m = sp_transition_matrix();
let result = matrix_power(&m.probabilities, 0);
for i in 0..m.ratings.len() {
for j in 0..m.ratings.len() {
let expected = if i == j { Decimal::ONE } else { Decimal::ZERO };
assert!(
approx_eq(result[i][j], expected, dec!(0.0001)),
"Identity check failed at [{},{}]: {}",
i,
j,
result[i][j]
);
}
}
}
#[test]
fn test_matrix_power_one() {
let m = sp_transition_matrix();
let result = matrix_power(&m.probabilities, 1);
for i in 0..m.ratings.len() {
for j in 0..m.ratings.len() {
assert!(
approx_eq(result[i][j], m.probabilities[i][j], dec!(0.0001)),
"M^1 check failed at [{},{}]",
i,
j
);
}
}
}
#[test]
fn test_matrix_power_two_row_sums() {
let m = sp_transition_matrix();
let result = matrix_power(&m.probabilities, 2);
for (i, row) in result.iter().enumerate() {
let row_sum: Decimal = row.iter().copied().sum();
assert!(
approx_eq(row_sum, Decimal::ONE, dec!(0.01)),
"M^2 row {} sums to {}",
i,
row_sum
);
}
}
#[test]
fn test_matrix_power_five_cumulative_default() {
let m = sp_transition_matrix();
let one_year = matrix_power(&m.probabilities, 1);
let five_year = matrix_power(&m.probabilities, 5);
let bbb_idx = 3; let d_idx = 7; assert!(
five_year[bbb_idx][d_idx] > one_year[bbb_idx][d_idx],
"5-year default {} should exceed 1-year {}",
five_year[bbb_idx][d_idx],
one_year[bbb_idx][d_idx]
);
}
#[test]
fn test_bbb_migration_stable_dominant() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
assert!(
bbb.stable_probability > dec!(0.80),
"BBB stable prob {} should be > 0.80",
bbb.stable_probability
);
}
#[test]
fn test_bbb_upgrade_probability() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
assert!(
bbb.upgrade_probability > Decimal::ZERO,
"Upgrade probability should be positive"
);
assert!(
bbb.upgrade_probability < dec!(0.10),
"Upgrade probability {} should be < 10%",
bbb.upgrade_probability
);
}
#[test]
fn test_bbb_downgrade_includes_default() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
assert!(
bbb.downgrade_probability > bbb.default_probability,
"Downgrade prob {} should exceed default prob {}",
bbb.downgrade_probability,
bbb.default_probability
);
}
#[test]
fn test_bbb_probabilities_sum_to_one() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
let total = bbb.upgrade_probability + bbb.stable_probability + bbb.downgrade_probability;
assert!(
approx_eq(total, Decimal::ONE, dec!(0.01)),
"Probabilities sum to {} expected ~1",
total
);
}
#[test]
fn test_bbb_default_probability() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
assert!(
approx_eq(bbb.default_probability, dec!(0.0018), dec!(0.001)),
"BBB default prob {} expected ~0.0018",
bbb.default_probability
);
}
#[test]
fn test_bbb_distribution_length() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
assert_eq!(bbb.expected_rating_distribution.len(), 8);
}
#[test]
fn test_multi_year_default_ordering() {
let mut input = single_bbb_input();
input.time_horizon_years = 5;
let result = calculate_migration(&input).unwrap();
let defaults = &result.result.multi_year_default_prob;
for i in 0..(defaults.len() - 2) {
assert!(
defaults[i].1 <= defaults[i + 1].1 + dec!(0.01),
"{} default {} should be <= {} default {}",
defaults[i].0,
defaults[i].1,
defaults[i + 1].0,
defaults[i + 1].1
);
}
}
#[test]
fn test_multi_year_default_increases_with_horizon() {
let mut input1 = single_bbb_input();
input1.time_horizon_years = 1;
let mut input5 = single_bbb_input();
input5.time_horizon_years = 5;
let result1 = calculate_migration(&input1).unwrap();
let result5 = calculate_migration(&input5).unwrap();
let bbb1 = result1
.result
.multi_year_default_prob
.iter()
.find(|(r, _)| r == "BBB")
.unwrap()
.1;
let bbb5 = result5
.result
.multi_year_default_prob
.iter()
.find(|(r, _)| r == "BBB")
.unwrap()
.1;
assert!(
bbb5 > bbb1,
"5-year BBB default {} should exceed 1-year {}",
bbb5,
bbb1
);
}
#[test]
fn test_default_state_absorbing() {
let input = single_bbb_input();
let result = calculate_migration(&input).unwrap();
let d_default = result
.result
.multi_year_default_prob
.iter()
.find(|(r, _)| r == "D")
.unwrap()
.1;
assert!(
approx_eq(d_default, Decimal::ONE, dec!(0.001)),
"D default prob {} should be 1.0",
d_default
);
}
#[test]
fn test_sp_matrix_valid_stochastic() {
let input = single_bbb_input();
let result = calculate_migration(&input).unwrap();
assert!(
result.result.matrix_quality.is_valid_stochastic,
"S&P matrix should be valid stochastic"
);
}
#[test]
fn test_sp_matrix_monotone() {
let input = single_bbb_input();
let result = calculate_migration(&input).unwrap();
assert!(
result.result.matrix_quality.is_monotone,
"S&P matrix should be monotone"
);
}
#[test]
fn test_sp_matrix_absorbing_state() {
let input = single_bbb_input();
let result = calculate_migration(&input).unwrap();
assert_eq!(result.result.matrix_quality.absorbing_state, "D");
}
#[test]
fn test_sp_matrix_row_deviation_small() {
let input = single_bbb_input();
let result = calculate_migration(&input).unwrap();
assert!(
result.result.matrix_quality.max_row_deviation < dec!(0.001),
"Max row deviation {} should be < 0.001",
result.result.matrix_quality.max_row_deviation
);
}
#[test]
fn test_non_stochastic_matrix_detection() {
let mut input = single_bbb_input();
input.transition_matrix.probabilities[0] = vec![
dec!(0.5),
dec!(0.1),
dec!(0.1),
dec!(0.1),
dec!(0.1),
dec!(0.0),
dec!(0.0),
dec!(0.0),
]; let result = calculate_migration(&input).unwrap();
assert!(
!result.result.matrix_quality.is_valid_stochastic,
"Should detect non-stochastic matrix"
);
}
#[test]
fn test_identity_matrix_no_migration() {
let mut input = single_bbb_input();
let n = input.transition_matrix.ratings.len();
let mut id = vec![vec![Decimal::ZERO; n]; n];
for i in 0..n {
id[i][i] = Decimal::ONE;
}
input.transition_matrix.probabilities = id;
let result = calculate_migration(&input).unwrap();
let bbb = &result.result.portfolio_results[0];
assert!(
approx_eq(bbb.stable_probability, Decimal::ONE, dec!(0.001)),
"Identity matrix: stable prob {} should be 1.0",
bbb.stable_probability
);
assert!(
approx_eq(bbb.upgrade_probability, Decimal::ZERO, dec!(0.001)),
"Identity matrix: upgrade prob should be 0"
);
assert!(
approx_eq(bbb.default_probability, Decimal::ZERO, dec!(0.001)),
"Identity matrix: default prob should be 0"
);
}
#[test]
fn test_high_yield_portfolio() {
let input = MigrationInput {
initial_ratings: vec![
RatedExposure {
name: "HY1".into(),
rating: "BB".into(),
exposure: dec!(500000),
maturity_years: dec!(3),
coupon_rate: dec!(0.07),
},
RatedExposure {
name: "HY2".into(),
rating: "B".into(),
exposure: dec!(300000),
maturity_years: dec!(4),
coupon_rate: dec!(0.09),
},
RatedExposure {
name: "HY3".into(),
rating: "CCC".into(),
exposure: dec!(200000),
maturity_years: dec!(2),
coupon_rate: dec!(0.12),
},
],
transition_matrix: sp_transition_matrix(),
time_horizon_years: 1,
spread_curve: standard_spread_curve(),
};
let result = calculate_migration(&input).unwrap();
let ccc = result
.result
.portfolio_results
.iter()
.find(|r| r.current_rating == "CCC")
.unwrap();
let bb = result
.result
.portfolio_results
.iter()
.find(|r| r.current_rating == "BB")
.unwrap();
assert!(
ccc.default_probability > bb.default_probability,
"CCC default {} should exceed BB default {}",
ccc.default_probability,
bb.default_probability
);
}
#[test]
fn test_ig_portfolio_low_default() {
let input = MigrationInput {
initial_ratings: vec![
RatedExposure {
name: "IG1".into(),
rating: "AAA".into(),
exposure: dec!(500000),
maturity_years: dec!(7),
coupon_rate: dec!(0.03),
},
RatedExposure {
name: "IG2".into(),
rating: "AA".into(),
exposure: dec!(500000),
maturity_years: dec!(5),
coupon_rate: dec!(0.035),
},
],
transition_matrix: sp_transition_matrix(),
time_horizon_years: 1,
spread_curve: standard_spread_curve(),
};
let result = calculate_migration(&input).unwrap();
for r in &result.result.portfolio_results {
assert!(
r.default_probability < dec!(0.001),
"{} default prob {} should be < 0.1%",
r.name,
r.default_probability
);
}
}
#[test]
fn test_bbb_expected_value_change_small() {
let result = calculate_migration(&single_bbb_input()).unwrap();
let bbb = &result.result.portfolio_results[0];
assert!(
bbb.expected_value_change_pct.abs() < dec!(5),
"Expected value change {} should be small",
bbb.expected_value_change_pct
);
}
#[test]
fn test_migration_var_positive() {
let result = calculate_migration(&single_bbb_input()).unwrap();
assert!(
result.result.portfolio_migration_var >= Decimal::ZERO,
"Migration VaR {} should be non-negative",
result.result.portfolio_migration_var
);
}
#[test]
fn test_portfolio_migration_loss_finite() {
let input = MigrationInput {
initial_ratings: vec![
RatedExposure {
name: "A".into(),
rating: "A".into(),
exposure: dec!(500000),
maturity_years: dec!(5),
coupon_rate: dec!(0.04),
},
RatedExposure {
name: "B".into(),
rating: "BBB".into(),
exposure: dec!(500000),
maturity_years: dec!(5),
coupon_rate: dec!(0.05),
},
],
transition_matrix: sp_transition_matrix(),
time_horizon_years: 1,
spread_curve: standard_spread_curve(),
};
let result = calculate_migration(&input).unwrap();
assert!(
result.result.portfolio_expected_migration_loss.abs() < dec!(10000000),
"Migration loss {} should be finite",
result.result.portfolio_expected_migration_loss
);
}
#[test]
fn test_non_square_matrix_error() {
let mut input = single_bbb_input();
input.transition_matrix.probabilities.pop(); let result = calculate_migration(&input);
assert!(result.is_err());
}
#[test]
fn test_unknown_rating_error() {
let mut input = single_bbb_input();
input.initial_ratings[0].rating = "XYZ".into();
let result = calculate_migration(&input);
assert!(result.is_err());
}
#[test]
fn test_empty_exposures_error() {
let mut input = single_bbb_input();
input.initial_ratings.clear();
let result = calculate_migration(&input);
assert!(result.is_err());
}
#[test]
fn test_zero_horizon_error() {
let mut input = single_bbb_input();
input.time_horizon_years = 0;
let result = calculate_migration(&input);
assert!(result.is_err());
}
#[test]
fn test_bad_spread_rating_error() {
let mut input = single_bbb_input();
input.spread_curve.push(RatingSpread {
rating: "INVALID".into(),
spread_bps: dec!(100),
});
let result = calculate_migration(&input);
assert!(result.is_err());
}
#[test]
fn test_all_default_row() {
let input = single_bbb_input();
let n = input.transition_matrix.ratings.len();
let d_idx = n - 1;
let d_row = &input.transition_matrix.probabilities[d_idx];
assert!(
approx_eq(d_row[d_idx], Decimal::ONE, dec!(0.001)),
"Default row self-transition should be 1.0"
);
}
#[test]
fn test_migration_var_hy_exceeds_ig() {
let ig_input = MigrationInput {
initial_ratings: vec![RatedExposure {
name: "IG".into(),
rating: "AA".into(),
exposure: dec!(1000000),
maturity_years: dec!(5),
coupon_rate: dec!(0.04),
}],
transition_matrix: sp_transition_matrix(),
time_horizon_years: 1,
spread_curve: standard_spread_curve(),
};
let hy_input = MigrationInput {
initial_ratings: vec![RatedExposure {
name: "HY".into(),
rating: "B".into(),
exposure: dec!(1000000),
maturity_years: dec!(5),
coupon_rate: dec!(0.08),
}],
transition_matrix: sp_transition_matrix(),
time_horizon_years: 1,
spread_curve: standard_spread_curve(),
};
let ig_result = calculate_migration(&ig_input).unwrap();
let hy_result = calculate_migration(&hy_input).unwrap();
assert!(
hy_result.result.portfolio_migration_var >= ig_result.result.portfolio_migration_var,
"HY VaR {} should >= IG VaR {}",
hy_result.result.portfolio_migration_var,
ig_result.result.portfolio_migration_var
);
}
#[test]
fn test_metadata_populated() {
let result = calculate_migration(&single_bbb_input()).unwrap();
assert!(!result.methodology.is_empty());
assert!(!result.metadata.version.is_empty());
assert_eq!(result.metadata.precision, "rust_decimal_128bit");
}
}