use datasynth_config::schema::EclConfig;
use datasynth_core::models::expected_credit_loss::{EclApproach, EclStage};
use datasynth_core::models::subledger::ar::AgingBucket;
use datasynth_generators::EclGenerator;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
fn default_config() -> EclConfig {
EclConfig::default()
}
fn sample_bucket_exposures() -> Vec<(AgingBucket, Decimal)> {
vec![
(AgingBucket::Current, dec!(500_000)),
(AgingBucket::Days1To30, dec!(120_000)),
(AgingBucket::Days31To60, dec!(45_000)),
(AgingBucket::Days61To90, dec!(15_000)),
(AgingBucket::Over90Days, dec!(8_000)),
]
}
fn measurement_date() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2024, 12, 31).expect("valid date")
}
#[test]
fn test_provision_per_bucket_equals_exposure_times_rate() {
let config = default_config();
let mut gen = EclGenerator::new(42);
let snap = gen.generate(
"C001",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-12",
"IFRS_9",
);
assert_eq!(snap.ecl_models.len(), 1);
let model = &snap.ecl_models[0];
let matrix = model
.provision_matrix
.as_ref()
.expect("provision matrix present");
for row in &matrix.aging_buckets {
let expected_provision = (row.exposure * row.applied_loss_rate).round_dp(2);
assert_eq!(
row.provision, expected_provision,
"Bucket {:?}: provision {} != exposure {} × applied_rate {}",
row.bucket, row.provision, row.exposure, row.applied_loss_rate
);
}
}
#[test]
fn test_sum_of_provisions_equals_total_ecl() {
let config = default_config();
let mut gen = EclGenerator::new(99);
let snap = gen.generate(
"C001",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-12",
"IFRS_9",
);
let model = &snap.ecl_models[0];
let matrix = model
.provision_matrix
.as_ref()
.expect("provision matrix present");
let sum_provisions: Decimal = matrix.aging_buckets.iter().map(|r| r.provision).sum();
assert_eq!(
sum_provisions, matrix.total_provision,
"Sum of bucket provisions {} != total_provision {}",
sum_provisions, matrix.total_provision
);
assert_eq!(
model.total_ecl, sum_provisions,
"Model total_ecl {} != sum of provisions {}",
model.total_ecl, sum_provisions
);
}
#[test]
fn test_forward_looking_adjustment_changes_provision() {
let buckets = sample_bucket_exposures();
let base_config = EclConfig::default();
let pessimistic_config = EclConfig {
base_scenario_weight: 0.0,
base_scenario_multiplier: 1.0,
optimistic_scenario_weight: 0.0,
optimistic_scenario_multiplier: 0.8,
pessimistic_scenario_weight: 1.0,
pessimistic_scenario_multiplier: 1.4,
..EclConfig::default()
};
let optimistic_config = EclConfig {
base_scenario_weight: 0.0,
base_scenario_multiplier: 1.0,
optimistic_scenario_weight: 1.0,
optimistic_scenario_multiplier: 0.8,
pessimistic_scenario_weight: 0.0,
pessimistic_scenario_multiplier: 1.4,
..EclConfig::default()
};
let mut gen = EclGenerator::new(1);
let date = measurement_date();
let base_snap = gen.generate("C001", date, &buckets, &base_config, "2024-12", "IFRS_9");
let pess_snap = gen.generate(
"C001",
date,
&buckets,
&pessimistic_config,
"2024-12",
"IFRS_9",
);
let opt_snap = gen.generate(
"C001",
date,
&buckets,
&optimistic_config,
"2024-12",
"IFRS_9",
);
let base_ecl = base_snap.ecl_models[0].total_ecl;
let pess_ecl = pess_snap.ecl_models[0].total_ecl;
let opt_ecl = opt_snap.ecl_models[0].total_ecl;
assert!(
pess_ecl > base_ecl,
"Pessimistic ECL {} should exceed base ECL {}",
pess_ecl,
base_ecl
);
assert!(
opt_ecl < pess_ecl,
"Optimistic ECL {} should be less than pessimistic ECL {}",
opt_ecl,
pess_ecl
);
}
#[test]
fn test_provision_movement_closing_balance() {
let mut gen = EclGenerator::new(7);
let config = default_config();
let snap = gen.generate(
"C001",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-12",
"IFRS_9",
);
assert_eq!(snap.provision_movements.len(), 1);
let mov = &snap.provision_movements[0];
let expected_closing = (mov.opening + mov.new_originations + mov.stage_transfers
- mov.write_offs
+ mov.recoveries)
.round_dp(2);
assert_eq!(
mov.closing, expected_closing,
"closing {} != opening {} + new_originations {} + stage_transfers {} - write_offs {} + recoveries {}",
mov.closing, mov.opening, mov.new_originations, mov.stage_transfers, mov.write_offs, mov.recoveries
);
}
#[test]
fn test_ecl_journal_entry_is_balanced() {
let mut gen = EclGenerator::new(5);
let config = default_config();
let snap = gen.generate(
"C001",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-12",
"IFRS_9",
);
assert_eq!(snap.journal_entries.len(), 1);
let je = &snap.journal_entries[0];
assert!(
je.is_balanced(),
"JE should be balanced; debits = {:?}, credits = {:?}",
je.total_debit(),
je.total_credit()
);
}
#[test]
fn test_ecl_model_metadata() {
let mut gen = EclGenerator::new(3);
let config = default_config();
let snap = gen.generate(
"ACME",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-Q4",
"ASC_326",
);
let model = &snap.ecl_models[0];
assert_eq!(model.entity_code, "ACME");
assert_eq!(model.framework, "ASC_326");
assert_eq!(model.approach, EclApproach::Simplified);
assert_eq!(model.measurement_date, measurement_date());
assert!(!model.id.is_empty());
assert_eq!(model.portfolio_segments.len(), 1);
assert_eq!(
model.portfolio_segments[0].segment_name,
"Trade Receivables"
);
assert_eq!(model.portfolio_segments[0].staging.len(), 3);
let stages: Vec<EclStage> = model.portfolio_segments[0]
.staging
.iter()
.map(|s| s.stage)
.collect();
assert!(stages.contains(&EclStage::Stage1Month12));
assert!(stages.contains(&EclStage::Stage2Lifetime));
assert!(stages.contains(&EclStage::Stage3CreditImpaired));
}
#[test]
fn test_zero_exposure_produces_zero_ecl() {
let zero_buckets: Vec<(AgingBucket, Decimal)> = AgingBucket::all()
.into_iter()
.map(|b| (b, Decimal::ZERO))
.collect();
let mut gen = EclGenerator::new(11);
let config = default_config();
let snap = gen.generate(
"C001",
measurement_date(),
&zero_buckets,
&config,
"2024-12",
"IFRS_9",
);
let model = &snap.ecl_models[0];
assert_eq!(model.total_ecl, Decimal::ZERO);
assert_eq!(model.total_exposure, Decimal::ZERO);
let je = &snap.journal_entries[0];
assert!(
je.lines.is_empty(),
"Zero ECL should produce empty JE lines"
);
}
#[test]
fn test_provision_movement_pl_charge() {
let mut gen = EclGenerator::new(13);
let config = default_config();
let snap = gen.generate(
"C001",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-12",
"IFRS_9",
);
let mov = &snap.provision_movements[0];
let expected_pl =
(mov.new_originations + mov.stage_transfers + mov.recoveries - mov.write_offs).round_dp(2);
assert_eq!(
mov.pl_charge, expected_pl,
"P&L charge {} should equal new_originations {} + stage_transfers {} + recoveries {} - write_offs {}",
mov.pl_charge, mov.new_originations, mov.stage_transfers, mov.recoveries, mov.write_offs
);
}
#[test]
fn test_applied_rate_is_historical_times_adjustment() {
let config = default_config();
let mut gen = EclGenerator::new(17);
let snap = gen.generate(
"C001",
measurement_date(),
&sample_bucket_exposures(),
&config,
"2024-12",
"IFRS_9",
);
let matrix = snap.ecl_models[0]
.provision_matrix
.as_ref()
.expect("provision matrix present");
for row in &matrix.aging_buckets {
let expected_rate = (row.historical_loss_rate * row.forward_looking_adjustment).round_dp(6);
assert_eq!(
row.applied_loss_rate,
expected_rate,
"Bucket {:?}: applied_rate {} != historical {} × fla {}",
row.bucket,
row.applied_loss_rate,
row.historical_loss_rate,
row.forward_looking_adjustment
);
}
}