Skip to main content

datasynth_generators/standards/
ecl_generator.rs

1//! Expected Credit Loss generator — IFRS 9 / ASC 326.
2//!
3//! Implements the **simplified approach** for trade receivables using a
4//! provision matrix based on AR aging data.
5//!
6//! # Loss rates by aging bucket (historical baseline)
7//!
8//! | Bucket      | Rate  |
9//! |-------------|-------|
10//! | Current     | 0.5%  |
11//! | 1–30 days   | 2.0%  |
12//! | 31–60 days  | 5.0%  |
13//! | 61–90 days  | 10.0% |
14//! | Over 90 days| 25.0% |
15//!
16//! Forward-looking adjustments are applied via scenario-weighted multipliers
17//! (base / optimistic / pessimistic) configured in [`EclConfig`].
18//!
19//! # Outputs
20//! - [`EclModel`] — complete ECL model with provision matrix
21//! - [`EclProvisionMovement`] — provision roll-forward for the period
22//! - [`JournalEntry`] — Bad Debt Expense DR / Allowance for Doubtful Accounts CR
23
24use 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
39// ============================================================================
40// GL account for "Allowance for Doubtful Accounts" (contra-asset)
41// We use a sub-account of AR control: 1105
42// ============================================================================
43const ALLOWANCE_FOR_DOUBTFUL_ACCOUNTS: &str = "1105";
44
45// ============================================================================
46// Historical loss rates (as decimal fractions, NOT percentages)
47// ============================================================================
48
49/// Historical loss rate for the Current bucket (0.5%).
50const RATE_CURRENT: Decimal = dec!(0.005);
51/// Historical loss rate for the 1-30 days bucket (2.0%).
52const RATE_1_30: Decimal = dec!(0.02);
53/// Historical loss rate for the 31-60 days bucket (5.0%).
54const RATE_31_60: Decimal = dec!(0.05);
55/// Historical loss rate for the 61-90 days bucket (10.0%).
56const RATE_61_90: Decimal = dec!(0.10);
57/// Historical loss rate for the Over 90 days bucket (25.0%).
58const RATE_OVER_90: Decimal = dec!(0.25);
59
60// ============================================================================
61// Snapshot
62// ============================================================================
63
64/// All outputs from one ECL generation run.
65#[derive(Debug, Default)]
66pub struct EclSnapshot {
67    /// ECL models (one per company processed).
68    pub ecl_models: Vec<EclModel>,
69    /// Provision movement roll-forwards.
70    pub provision_movements: Vec<EclProvisionMovement>,
71    /// Journal entries (Bad Debt Expense / Allowance).
72    pub journal_entries: Vec<JournalEntry>,
73}
74
75// ============================================================================
76// Generator
77// ============================================================================
78
79/// Generates ECL models using the simplified approach for trade receivables.
80pub struct EclGenerator {
81    uuid_factory: DeterministicUuidFactory,
82}
83
84impl EclGenerator {
85    /// Create a new ECL generator with a deterministic seed.
86    pub fn new(seed: u64) -> Self {
87        Self {
88            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ExpectedCreditLoss),
89        }
90    }
91
92    /// Generate ECL model from AR aging bucket totals.
93    ///
94    /// # Parameters
95    /// - `entity_code`: company / entity identifier
96    /// - `measurement_date`: balance-sheet date
97    /// - `bucket_exposures`: gross AR balance per aging bucket (ordered: Current, 1-30, 31-60, 61-90, Over90)
98    /// - `config`: ECL configuration (scenario weights / multipliers)
99    /// - `period_label`: label for the provision movement (e.g. "2024-Q4")
100    /// - `framework`: "IFRS_9" or "ASC_326"
101    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        // ---- Step 1: compute scenario-weighted forward-looking multiplier ------
111        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        // ---- Step 2: build provision matrix rows --------------------------------
131        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        // ---- Step 3: build portfolio segment (simplified: single segment) ------
176        // Under simplified approach we map the entire AR portfolio to Stage 2
177        // (lifetime ECL is always recognised, no stage 1/12-month ECL).
178        // We distribute the provision matrix total into stage allocations for
179        // reporting completeness (Stage 1 = Current bucket, Stage 2 = 1-90 days,
180        // Stage 3 = Over 90 days).
181        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        // ---- Step 4: top-level ECL model ----------------------------------------
192        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        // ---- Step 5: provision movement ------------------------------------------
206        // For a first-period run opening = 0; closing = total provision.
207        // Write-offs are estimated at 20% of the Over 90 bucket provision.
208        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        // TODO: multi-period continuity — opening balance always starts at zero because the
222        // current single-period generation model has no prior-period state.  Proper ECL
223        // rollforward continuity requires a persistent state store shared across generation
224        // runs, which is a larger architectural change (see Fix 12 documentation).
225        let opening = Decimal::ZERO; // first period only
226        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        // ---- Step 6: journal entry ----------------------------------------------
249        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
264// ============================================================================
265// Helpers
266// ============================================================================
267
268/// Returns the historical loss rate for each aging bucket.
269fn 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
279/// Build stage allocations from provision matrix rows.
280///
281/// Mapping:
282/// - Stage 1 (12-month ECL): Current bucket
283/// - Stage 2 (Lifetime): 1-30, 31-60, 61-90 days
284/// - Stage 3 (Credit-impaired): Over 90 days
285fn 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                // Use the highest historical rate in the group for the summary
312                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    // PD / LGD: simplified approach doesn't separate PD and LGD, but we
325    // model as PD × LGD = applied_loss_rate. For Stage 1/2 assume LGD = 1.0;
326    // for Stage 3 assume LGD = 0.60 (40% recovery).
327    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
367/// Build the ECL journal entry:
368///
369/// ```text
370/// DR  Bad Debt Expense (6900)                    pl_charge
371///   CR  Allowance for Doubtful Accounts (1105)   pl_charge
372/// ```
373fn build_ecl_journal_entry(
374    _uuid_factory: &mut DeterministicUuidFactory,
375    entity_code: &str,
376    posting_date: NaiveDate,
377    pl_charge: Decimal,
378) -> JournalEntry {
379    // Ignore zero or negative charges (no JE needed)
380    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    // Suppress unused-import warning: AR_CONTROL is documented but not in
389    // this JE because the allowance is a contra-asset sub-account.
390    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        // DR Bad Debt Expense
397        je.add_line(JournalEntryLine::debit(
398            doc_id,
399            1,
400            BAD_DEBT.to_string(),
401            amount,
402        ));
403
404        // CR Allowance for Doubtful Accounts
405        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}