datasynth_generators/standards/
ecl_generator.rs1use chrono::NaiveDate;
25use datasynth_config::schema::EclConfig;
26use datasynth_core::accounts::{control_accounts::AR_CONTROL, expense_accounts::BAD_DEBT};
27use datasynth_core::models::expected_credit_loss::{
28 EclApproach, EclModel, EclPortfolioSegment, EclProvisionMovement, EclStage, EclStageAllocation,
29 ProvisionMatrix, ProvisionMatrixRow, ScenarioWeights,
30};
31use datasynth_core::models::journal_entry::{
32 JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
33};
34use datasynth_core::models::subledger::ar::AgingBucket;
35use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
36use rust_decimal::Decimal;
37use rust_decimal_macros::dec;
38
39const ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS: &str = "1105";
44
45const RATE_CURRENT: Decimal = dec!(0.005);
51const RATE_1_30: Decimal = dec!(0.02);
53const RATE_31_60: Decimal = dec!(0.05);
55const RATE_61_90: Decimal = dec!(0.10);
57const RATE_OVER_90: Decimal = dec!(0.25);
59
60#[derive(Debug, Default)]
66pub struct EclSnapshot {
67 pub ecl_models: Vec<EclModel>,
69 pub provision_movements: Vec<EclProvisionMovement>,
71 pub journal_entries: Vec<JournalEntry>,
73}
74
75pub struct EclGenerator {
81 uuid_factory: DeterministicUuidFactory,
82}
83
84impl EclGenerator {
85 pub fn new(seed: u64) -> Self {
87 Self {
88 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ExpectedCreditLoss),
89 }
90 }
91
92 pub fn generate(
102 &mut self,
103 entity_code: &str,
104 measurement_date: NaiveDate,
105 bucket_exposures: &[(AgingBucket, Decimal)],
106 config: &EclConfig,
107 period_label: &str,
108 framework: &str,
109 ) -> EclSnapshot {
110 self.generate_with_prior(
111 entity_code,
112 measurement_date,
113 bucket_exposures,
114 config,
115 period_label,
116 framework,
117 None,
118 )
119 }
120
121 pub fn generate_with_prior(
130 &mut self,
131 entity_code: &str,
132 measurement_date: NaiveDate,
133 bucket_exposures: &[(AgingBucket, Decimal)],
134 config: &EclConfig,
135 period_label: &str,
136 framework: &str,
137 prior_closing: Option<Decimal>,
138 ) -> EclSnapshot {
139 let base_w = Decimal::try_from(config.base_scenario_weight).unwrap_or(dec!(0.50));
141 let base_m = Decimal::try_from(config.base_scenario_multiplier).unwrap_or(dec!(1.0));
142 let opt_w = Decimal::try_from(config.optimistic_scenario_weight).unwrap_or(dec!(0.30));
143 let opt_m = Decimal::try_from(config.optimistic_scenario_multiplier).unwrap_or(dec!(0.8));
144 let pes_w = Decimal::try_from(config.pessimistic_scenario_weight).unwrap_or(dec!(0.20));
145 let pes_m = Decimal::try_from(config.pessimistic_scenario_multiplier).unwrap_or(dec!(1.4));
146
147 let blended_multiplier = (base_w * base_m + opt_w * opt_m + pes_w * pes_m).round_dp(6);
148
149 let scenario_weights = ScenarioWeights {
150 base: base_w,
151 base_multiplier: base_m,
152 optimistic: opt_w,
153 optimistic_multiplier: opt_m,
154 pessimistic: pes_w,
155 pessimistic_multiplier: pes_m,
156 blended_multiplier,
157 };
158
159 let mut matrix_rows: Vec<ProvisionMatrixRow> = Vec::with_capacity(5);
161 let mut total_provision = Decimal::ZERO;
162 let mut total_exposure = Decimal::ZERO;
163
164 for bucket in AgingBucket::all() {
165 let exposure = bucket_exposures
166 .iter()
167 .find(|(b, _)| *b == bucket)
168 .map(|(_, e)| *e)
169 .unwrap_or(Decimal::ZERO);
170
171 let historical_rate = historical_rate_for_bucket(bucket);
172 let applied_rate = (historical_rate * blended_multiplier).round_dp(6);
173 let provision = (exposure * applied_rate).round_dp(2);
174
175 total_exposure += exposure;
176 total_provision += provision;
177
178 matrix_rows.push(ProvisionMatrixRow {
179 bucket,
180 historical_loss_rate: historical_rate,
181 forward_looking_adjustment: blended_multiplier,
182 applied_loss_rate: applied_rate,
183 exposure,
184 provision,
185 });
186 }
187
188 let blended_loss_rate = if total_exposure.is_zero() {
189 Decimal::ZERO
190 } else {
191 (total_provision / total_exposure).round_dp(6)
192 };
193
194 let provision_matrix = ProvisionMatrix {
195 entity_code: entity_code.to_string(),
196 measurement_date,
197 scenario_weights,
198 aging_buckets: matrix_rows,
199 total_provision,
200 total_exposure,
201 blended_loss_rate,
202 };
203
204 let stage_allocations =
211 build_stage_allocations(&provision_matrix.aging_buckets, blended_multiplier);
212
213 let segment = EclPortfolioSegment {
214 segment_name: "Trade Receivables".to_string(),
215 exposure_at_default: total_exposure,
216 total_ecl: total_provision,
217 staging: stage_allocations,
218 };
219
220 let model_id = self.uuid_factory.next().to_string();
222 let ecl_model = EclModel {
223 id: model_id,
224 entity_code: entity_code.to_string(),
225 approach: EclApproach::Simplified,
226 measurement_date,
227 framework: framework.to_string(),
228 portfolio_segments: vec![segment],
229 provision_matrix: Some(provision_matrix),
230 total_ecl: total_provision,
231 total_exposure,
232 };
233
234 let over90_provision = ecl_model
238 .provision_matrix
239 .as_ref()
240 .and_then(|m| {
241 m.aging_buckets
242 .iter()
243 .find(|r| r.bucket == AgingBucket::Over90Days)
244 .map(|r| r.provision)
245 })
246 .unwrap_or(Decimal::ZERO);
247
248 let estimated_write_offs = (over90_provision * dec!(0.20)).round_dp(2);
249 let recoveries = Decimal::ZERO;
250 let opening = prior_closing.unwrap_or(Decimal::ZERO);
254 let new_originations = total_provision;
255 let stage_transfers = Decimal::ZERO;
256 let closing = (opening + new_originations + stage_transfers - estimated_write_offs
257 + recoveries)
258 .round_dp(2);
259 let pl_charge =
260 (new_originations + stage_transfers + recoveries - estimated_write_offs).round_dp(2);
261
262 let movement_id = self.uuid_factory.next().to_string();
263 let movement = EclProvisionMovement {
264 id: movement_id,
265 entity_code: entity_code.to_string(),
266 period: period_label.to_string(),
267 opening,
268 new_originations,
269 stage_transfers,
270 write_offs: estimated_write_offs,
271 recoveries,
272 closing,
273 pl_charge,
274 };
275
276 let je = build_ecl_journal_entry(
278 &mut self.uuid_factory,
279 entity_code,
280 measurement_date,
281 pl_charge,
282 );
283
284 EclSnapshot {
285 ecl_models: vec![ecl_model],
286 provision_movements: vec![movement],
287 journal_entries: vec![je],
288 }
289 }
290}
291
292fn historical_rate_for_bucket(bucket: AgingBucket) -> Decimal {
298 match bucket {
299 AgingBucket::Current => RATE_CURRENT,
300 AgingBucket::Days1To30 => RATE_1_30,
301 AgingBucket::Days31To60 => RATE_31_60,
302 AgingBucket::Days61To90 => RATE_61_90,
303 AgingBucket::Over90Days => RATE_OVER_90,
304 }
305}
306
307fn build_stage_allocations(
314 rows: &[ProvisionMatrixRow],
315 forward_looking_adjustment: Decimal,
316) -> Vec<EclStageAllocation> {
317 let mut stage1_exposure = Decimal::ZERO;
318 let mut stage1_ecl = Decimal::ZERO;
319 let mut stage1_hist_rate = Decimal::ZERO;
320
321 let mut stage2_exposure = Decimal::ZERO;
322 let mut stage2_ecl = Decimal::ZERO;
323 let mut stage2_hist_rate = Decimal::ZERO;
324
325 let mut stage3_exposure = Decimal::ZERO;
326 let mut stage3_ecl = Decimal::ZERO;
327 let mut stage3_hist_rate = Decimal::ZERO;
328
329 for row in rows {
330 match row.bucket {
331 AgingBucket::Current => {
332 stage1_exposure += row.exposure;
333 stage1_ecl += row.provision;
334 stage1_hist_rate = row.historical_loss_rate;
335 }
336 AgingBucket::Days1To30 | AgingBucket::Days31To60 | AgingBucket::Days61To90 => {
337 stage2_exposure += row.exposure;
338 stage2_ecl += row.provision;
339 if row.historical_loss_rate > stage2_hist_rate {
341 stage2_hist_rate = row.historical_loss_rate;
342 }
343 }
344 AgingBucket::Over90Days => {
345 stage3_exposure += row.exposure;
346 stage3_ecl += row.provision;
347 stage3_hist_rate = row.historical_loss_rate;
348 }
349 }
350 }
351
352 let lgd_stage1 = dec!(1.0);
356 let lgd_stage2 = dec!(1.0);
357 let lgd_stage3 = dec!(0.60);
358
359 let pd_stage1 = (stage1_hist_rate * forward_looking_adjustment).round_dp(6);
360 let pd_stage2 = (stage2_hist_rate * forward_looking_adjustment).round_dp(6);
361 let pd_stage3 = if lgd_stage3.is_zero() {
362 Decimal::ZERO
363 } else {
364 (stage3_hist_rate * forward_looking_adjustment / lgd_stage3).round_dp(6)
365 };
366
367 vec![
368 EclStageAllocation {
369 stage: EclStage::Stage1Month12,
370 exposure: stage1_exposure,
371 probability_of_default: pd_stage1,
372 loss_given_default: lgd_stage1,
373 ecl_amount: stage1_ecl,
374 forward_looking_adjustment,
375 },
376 EclStageAllocation {
377 stage: EclStage::Stage2Lifetime,
378 exposure: stage2_exposure,
379 probability_of_default: pd_stage2,
380 loss_given_default: lgd_stage2,
381 ecl_amount: stage2_ecl,
382 forward_looking_adjustment,
383 },
384 EclStageAllocation {
385 stage: EclStage::Stage3CreditImpaired,
386 exposure: stage3_exposure,
387 probability_of_default: pd_stage3,
388 loss_given_default: lgd_stage3,
389 ecl_amount: stage3_ecl,
390 forward_looking_adjustment,
391 },
392 ]
393}
394
395fn build_ecl_journal_entry(
402 _uuid_factory: &mut DeterministicUuidFactory,
403 entity_code: &str,
404 posting_date: NaiveDate,
405 pl_charge: Decimal,
406) -> JournalEntry {
407 let amount = pl_charge.max(Decimal::ZERO);
409
410 let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
411 header.header_text = Some(format!(
412 "ECL provision — Bad Debt Expense / Allowance for Doubtful Accounts ({posting_date})"
413 ));
414 header.source = TransactionSource::Adjustment;
415 header.reference = Some("IFRS9/ASC326-ECL".to_string());
416 let _ = AR_CONTROL;
419
420 let doc_id = header.document_id;
421 let mut je = JournalEntry::new(header);
422
423 if amount > Decimal::ZERO {
424 je.add_line(JournalEntryLine::debit(
426 doc_id,
427 1,
428 BAD_DEBT.to_string(),
429 amount,
430 ));
431
432 je.add_line(JournalEntryLine::credit(
434 doc_id,
435 2,
436 ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS.to_string(),
437 amount,
438 ));
439 }
440
441 je
442}