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 let base_w = Decimal::try_from(config.base_scenario_weight).unwrap_or(dec!(0.50));
112 let base_m = Decimal::try_from(config.base_scenario_multiplier).unwrap_or(dec!(1.0));
113 let opt_w = Decimal::try_from(config.optimistic_scenario_weight).unwrap_or(dec!(0.30));
114 let opt_m = Decimal::try_from(config.optimistic_scenario_multiplier).unwrap_or(dec!(0.8));
115 let pes_w = Decimal::try_from(config.pessimistic_scenario_weight).unwrap_or(dec!(0.20));
116 let pes_m = Decimal::try_from(config.pessimistic_scenario_multiplier).unwrap_or(dec!(1.4));
117
118 let blended_multiplier = (base_w * base_m + opt_w * opt_m + pes_w * pes_m).round_dp(6);
119
120 let scenario_weights = ScenarioWeights {
121 base: base_w,
122 base_multiplier: base_m,
123 optimistic: opt_w,
124 optimistic_multiplier: opt_m,
125 pessimistic: pes_w,
126 pessimistic_multiplier: pes_m,
127 blended_multiplier,
128 };
129
130 let mut matrix_rows: Vec<ProvisionMatrixRow> = Vec::with_capacity(5);
132 let mut total_provision = Decimal::ZERO;
133 let mut total_exposure = Decimal::ZERO;
134
135 for bucket in AgingBucket::all() {
136 let exposure = bucket_exposures
137 .iter()
138 .find(|(b, _)| *b == bucket)
139 .map(|(_, e)| *e)
140 .unwrap_or(Decimal::ZERO);
141
142 let historical_rate = historical_rate_for_bucket(bucket);
143 let applied_rate = (historical_rate * blended_multiplier).round_dp(6);
144 let provision = (exposure * applied_rate).round_dp(2);
145
146 total_exposure += exposure;
147 total_provision += provision;
148
149 matrix_rows.push(ProvisionMatrixRow {
150 bucket,
151 historical_loss_rate: historical_rate,
152 forward_looking_adjustment: blended_multiplier,
153 applied_loss_rate: applied_rate,
154 exposure,
155 provision,
156 });
157 }
158
159 let blended_loss_rate = if total_exposure.is_zero() {
160 Decimal::ZERO
161 } else {
162 (total_provision / total_exposure).round_dp(6)
163 };
164
165 let provision_matrix = ProvisionMatrix {
166 entity_code: entity_code.to_string(),
167 measurement_date,
168 scenario_weights,
169 aging_buckets: matrix_rows,
170 total_provision,
171 total_exposure,
172 blended_loss_rate,
173 };
174
175 let stage_allocations =
182 build_stage_allocations(&provision_matrix.aging_buckets, blended_multiplier);
183
184 let segment = EclPortfolioSegment {
185 segment_name: "Trade Receivables".to_string(),
186 exposure_at_default: total_exposure,
187 total_ecl: total_provision,
188 staging: stage_allocations,
189 };
190
191 let model_id = self.uuid_factory.next().to_string();
193 let ecl_model = EclModel {
194 id: model_id,
195 entity_code: entity_code.to_string(),
196 approach: EclApproach::Simplified,
197 measurement_date,
198 framework: framework.to_string(),
199 portfolio_segments: vec![segment],
200 provision_matrix: Some(provision_matrix),
201 total_ecl: total_provision,
202 total_exposure,
203 };
204
205 let over90_provision = ecl_model
209 .provision_matrix
210 .as_ref()
211 .and_then(|m| {
212 m.aging_buckets
213 .iter()
214 .find(|r| r.bucket == AgingBucket::Over90Days)
215 .map(|r| r.provision)
216 })
217 .unwrap_or(Decimal::ZERO);
218
219 let estimated_write_offs = (over90_provision * dec!(0.20)).round_dp(2);
220 let recoveries = Decimal::ZERO;
221 let opening = Decimal::ZERO; let new_originations = total_provision;
227 let stage_transfers = Decimal::ZERO;
228 let closing = (opening + new_originations + stage_transfers - estimated_write_offs
229 + recoveries)
230 .round_dp(2);
231 let pl_charge =
232 (new_originations + stage_transfers + recoveries - estimated_write_offs).round_dp(2);
233
234 let movement_id = self.uuid_factory.next().to_string();
235 let movement = EclProvisionMovement {
236 id: movement_id,
237 entity_code: entity_code.to_string(),
238 period: period_label.to_string(),
239 opening,
240 new_originations,
241 stage_transfers,
242 write_offs: estimated_write_offs,
243 recoveries,
244 closing,
245 pl_charge,
246 };
247
248 let je = build_ecl_journal_entry(
250 &mut self.uuid_factory,
251 entity_code,
252 measurement_date,
253 pl_charge,
254 );
255
256 EclSnapshot {
257 ecl_models: vec![ecl_model],
258 provision_movements: vec![movement],
259 journal_entries: vec![je],
260 }
261 }
262}
263
264fn historical_rate_for_bucket(bucket: AgingBucket) -> Decimal {
270 match bucket {
271 AgingBucket::Current => RATE_CURRENT,
272 AgingBucket::Days1To30 => RATE_1_30,
273 AgingBucket::Days31To60 => RATE_31_60,
274 AgingBucket::Days61To90 => RATE_61_90,
275 AgingBucket::Over90Days => RATE_OVER_90,
276 }
277}
278
279fn build_stage_allocations(
286 rows: &[ProvisionMatrixRow],
287 forward_looking_adjustment: Decimal,
288) -> Vec<EclStageAllocation> {
289 let mut stage1_exposure = Decimal::ZERO;
290 let mut stage1_ecl = Decimal::ZERO;
291 let mut stage1_hist_rate = Decimal::ZERO;
292
293 let mut stage2_exposure = Decimal::ZERO;
294 let mut stage2_ecl = Decimal::ZERO;
295 let mut stage2_hist_rate = Decimal::ZERO;
296
297 let mut stage3_exposure = Decimal::ZERO;
298 let mut stage3_ecl = Decimal::ZERO;
299 let mut stage3_hist_rate = Decimal::ZERO;
300
301 for row in rows {
302 match row.bucket {
303 AgingBucket::Current => {
304 stage1_exposure += row.exposure;
305 stage1_ecl += row.provision;
306 stage1_hist_rate = row.historical_loss_rate;
307 }
308 AgingBucket::Days1To30 | AgingBucket::Days31To60 | AgingBucket::Days61To90 => {
309 stage2_exposure += row.exposure;
310 stage2_ecl += row.provision;
311 if row.historical_loss_rate > stage2_hist_rate {
313 stage2_hist_rate = row.historical_loss_rate;
314 }
315 }
316 AgingBucket::Over90Days => {
317 stage3_exposure += row.exposure;
318 stage3_ecl += row.provision;
319 stage3_hist_rate = row.historical_loss_rate;
320 }
321 }
322 }
323
324 let lgd_stage1 = dec!(1.0);
328 let lgd_stage2 = dec!(1.0);
329 let lgd_stage3 = dec!(0.60);
330
331 let pd_stage1 = (stage1_hist_rate * forward_looking_adjustment).round_dp(6);
332 let pd_stage2 = (stage2_hist_rate * forward_looking_adjustment).round_dp(6);
333 let pd_stage3 = if lgd_stage3.is_zero() {
334 Decimal::ZERO
335 } else {
336 (stage3_hist_rate * forward_looking_adjustment / lgd_stage3).round_dp(6)
337 };
338
339 vec![
340 EclStageAllocation {
341 stage: EclStage::Stage1Month12,
342 exposure: stage1_exposure,
343 probability_of_default: pd_stage1,
344 loss_given_default: lgd_stage1,
345 ecl_amount: stage1_ecl,
346 forward_looking_adjustment,
347 },
348 EclStageAllocation {
349 stage: EclStage::Stage2Lifetime,
350 exposure: stage2_exposure,
351 probability_of_default: pd_stage2,
352 loss_given_default: lgd_stage2,
353 ecl_amount: stage2_ecl,
354 forward_looking_adjustment,
355 },
356 EclStageAllocation {
357 stage: EclStage::Stage3CreditImpaired,
358 exposure: stage3_exposure,
359 probability_of_default: pd_stage3,
360 loss_given_default: lgd_stage3,
361 ecl_amount: stage3_ecl,
362 forward_looking_adjustment,
363 },
364 ]
365}
366
367fn build_ecl_journal_entry(
374 _uuid_factory: &mut DeterministicUuidFactory,
375 entity_code: &str,
376 posting_date: NaiveDate,
377 pl_charge: Decimal,
378) -> JournalEntry {
379 let amount = pl_charge.max(Decimal::ZERO);
381
382 let mut header = JournalEntryHeader::new(entity_code.to_string(), posting_date);
383 header.header_text = Some(format!(
384 "ECL provision — Bad Debt Expense / Allowance for Doubtful Accounts ({posting_date})"
385 ));
386 header.source = TransactionSource::Adjustment;
387 header.reference = Some("IFRS9/ASC326-ECL".to_string());
388 let _ = AR_CONTROL;
391
392 let doc_id = header.document_id;
393 let mut je = JournalEntry::new(header);
394
395 if amount > Decimal::ZERO {
396 je.add_line(JournalEntryLine::debit(
398 doc_id,
399 1,
400 BAD_DEBT.to_string(),
401 amount,
402 ));
403
404 je.add_line(JournalEntryLine::credit(
406 doc_id,
407 2,
408 ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS.to_string(),
409 amount,
410 ));
411 }
412
413 je
414}