Skip to main content

datasynth_core/models/
expected_credit_loss.rs

1//! Expected Credit Loss (ECL) models — IFRS 9 / ASC 326.
2//!
3//! This module provides data structures for the simplified approach ECL model
4//! applied to trade receivables via a provision matrix based on AR aging.
5//!
6//! Key IFRS 9 / ASC 326 concepts modelled:
7//! - Simplified approach (trade receivables): lifetime ECL at all times
8//! - Provision matrix: historical loss rates by aging bucket + forward-looking adjustment
9//! - ECL = Exposure × PD × LGD (Stage 1/2/3 for completeness)
10//! - Provision movement: opening → new originations → stage transfers → write-offs → closing
11
12use chrono::NaiveDate;
13use rust_decimal::Decimal;
14use serde::{Deserialize, Serialize};
15
16use crate::models::subledger::ar::AgingBucket;
17
18// ============================================================================
19// Enums
20// ============================================================================
21
22/// ECL measurement approach.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum EclApproach {
25    /// Simplified approach — lifetime ECL at all times (used for trade receivables).
26    Simplified,
27    /// General approach — 3-stage model based on credit deterioration.
28    General,
29}
30
31/// IFRS 9 / ASC 326 stage classification.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum EclStage {
34    /// Stage 1 — performing; 12-month ECL.
35    Stage1Month12,
36    /// Stage 2 — significant credit deterioration; lifetime ECL.
37    Stage2Lifetime,
38    /// Stage 3 — credit-impaired; lifetime ECL, interest on net carrying amount.
39    Stage3CreditImpaired,
40}
41
42// ============================================================================
43// Core model structs
44// ============================================================================
45
46/// Top-level ECL model for one entity / measurement date.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct EclModel {
49    /// Unique model ID.
50    pub id: String,
51
52    /// Entity (company) code.
53    pub entity_code: String,
54
55    /// Measurement approach.
56    pub approach: EclApproach,
57
58    /// Measurement date (balance-sheet date).
59    pub measurement_date: NaiveDate,
60
61    /// Accounting framework ("IFRS_9" or "ASC_326").
62    pub framework: String,
63
64    /// Portfolio segments within this model.
65    pub portfolio_segments: Vec<EclPortfolioSegment>,
66
67    /// Provision matrix (simplified approach only).
68    pub provision_matrix: Option<ProvisionMatrix>,
69
70    /// Total ECL across all segments.
71    #[serde(with = "rust_decimal::serde::str")]
72    pub total_ecl: Decimal,
73
74    /// Total gross exposure.
75    #[serde(with = "rust_decimal::serde::str")]
76    pub total_exposure: Decimal,
77}
78
79/// A portfolio segment within the ECL model (e.g. "Trade Receivables", "Intercompany").
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct EclPortfolioSegment {
82    /// Segment name.
83    pub segment_name: String,
84
85    /// Gross exposure at the measurement date.
86    #[serde(with = "rust_decimal::serde::str")]
87    pub exposure_at_default: Decimal,
88
89    /// Stage allocations within this segment.
90    pub staging: Vec<EclStageAllocation>,
91
92    /// Total ECL for this segment (sum of stage ECLs).
93    #[serde(with = "rust_decimal::serde::str")]
94    pub total_ecl: Decimal,
95}
96
97/// ECL split by IFRS 9 stage.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct EclStageAllocation {
100    /// IFRS 9 / ASC 326 stage.
101    pub stage: EclStage,
102
103    /// Gross exposure in this stage.
104    #[serde(with = "rust_decimal::serde::str")]
105    pub exposure: Decimal,
106
107    /// Probability of default (0–1).
108    #[serde(with = "rust_decimal::serde::str")]
109    pub probability_of_default: Decimal,
110
111    /// Loss given default (0–1).
112    #[serde(with = "rust_decimal::serde::str")]
113    pub loss_given_default: Decimal,
114
115    /// Computed ECL = exposure × PD × LGD × forward_looking_adjustment.
116    #[serde(with = "rust_decimal::serde::str")]
117    pub ecl_amount: Decimal,
118
119    /// Forward-looking multiplier applied to historical rate (1.0 = no adjustment).
120    #[serde(with = "rust_decimal::serde::str")]
121    pub forward_looking_adjustment: Decimal,
122}
123
124// ============================================================================
125// Provision matrix
126// ============================================================================
127
128/// Provision matrix for the simplified approach — one row per aging bucket.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct ProvisionMatrix {
131    /// Entity code.
132    pub entity_code: String,
133
134    /// Measurement date.
135    pub measurement_date: NaiveDate,
136
137    /// Scenario weights used for forward-looking adjustment.
138    pub scenario_weights: ScenarioWeights,
139
140    /// One row per AR aging bucket.
141    pub aging_buckets: Vec<ProvisionMatrixRow>,
142
143    /// Sum of all provisions across all buckets.
144    #[serde(with = "rust_decimal::serde::str")]
145    pub total_provision: Decimal,
146
147    /// Sum of all exposures across all buckets.
148    #[serde(with = "rust_decimal::serde::str")]
149    pub total_exposure: Decimal,
150
151    /// Blended loss rate = total_provision / total_exposure.
152    #[serde(with = "rust_decimal::serde::str")]
153    pub blended_loss_rate: Decimal,
154}
155
156/// Scenario weights for forward-looking macro adjustment.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ScenarioWeights {
159    /// Weight for base scenario.
160    #[serde(with = "rust_decimal::serde::str")]
161    pub base: Decimal,
162
163    /// Multiplier applied to historical rates under base scenario (typically 1.0).
164    #[serde(with = "rust_decimal::serde::str")]
165    pub base_multiplier: Decimal,
166
167    /// Weight for optimistic scenario.
168    #[serde(with = "rust_decimal::serde::str")]
169    pub optimistic: Decimal,
170
171    /// Multiplier applied to historical rates under optimistic scenario (< 1.0).
172    #[serde(with = "rust_decimal::serde::str")]
173    pub optimistic_multiplier: Decimal,
174
175    /// Weight for pessimistic scenario.
176    #[serde(with = "rust_decimal::serde::str")]
177    pub pessimistic: Decimal,
178
179    /// Multiplier applied to historical rates under pessimistic scenario (> 1.0).
180    #[serde(with = "rust_decimal::serde::str")]
181    pub pessimistic_multiplier: Decimal,
182
183    /// Resulting blended forward-looking multiplier
184    /// = base*base_m + optimistic*opt_m + pessimistic*pes_m.
185    #[serde(with = "rust_decimal::serde::str")]
186    pub blended_multiplier: Decimal,
187}
188
189/// One row of the provision matrix, corresponding to an AR aging bucket.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ProvisionMatrixRow {
192    /// Aging bucket this row covers.
193    pub bucket: AgingBucket,
194
195    /// Historical loss rate for this bucket (e.g. 0.005 = 0.5%).
196    #[serde(with = "rust_decimal::serde::str")]
197    pub historical_loss_rate: Decimal,
198
199    /// Forward-looking adjustment multiplier (scenario-weighted).
200    #[serde(with = "rust_decimal::serde::str")]
201    pub forward_looking_adjustment: Decimal,
202
203    /// Applied loss rate = historical_loss_rate × forward_looking_adjustment.
204    #[serde(with = "rust_decimal::serde::str")]
205    pub applied_loss_rate: Decimal,
206
207    /// Gross exposure in this bucket.
208    #[serde(with = "rust_decimal::serde::str")]
209    pub exposure: Decimal,
210
211    /// Provision = exposure × applied_loss_rate.
212    #[serde(with = "rust_decimal::serde::str")]
213    pub provision: Decimal,
214}
215
216// ============================================================================
217// Provision movement
218// ============================================================================
219
220/// Provision movement (roll-forward) for one fiscal period.
221///
222/// Reconciles the opening and closing allowance for doubtful accounts.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct EclProvisionMovement {
225    /// Unique movement record ID.
226    pub id: String,
227
228    /// Entity code.
229    pub entity_code: String,
230
231    /// Fiscal period label (e.g. "2024-Q1", "2024-12").
232    pub period: String,
233
234    /// Opening allowance balance.
235    #[serde(with = "rust_decimal::serde::str")]
236    pub opening: Decimal,
237
238    /// New originations charged to P&L (increase in allowance).
239    #[serde(with = "rust_decimal::serde::str")]
240    pub new_originations: Decimal,
241
242    /// Stage-transfer adjustments (positive = provision increase).
243    #[serde(with = "rust_decimal::serde::str")]
244    pub stage_transfers: Decimal,
245
246    /// Write-offs charged against the allowance (reduces allowance balance).
247    #[serde(with = "rust_decimal::serde::str")]
248    pub write_offs: Decimal,
249
250    /// Cash recoveries on previously written-off receivables (increases allowance).
251    #[serde(with = "rust_decimal::serde::str")]
252    pub recoveries: Decimal,
253
254    /// Closing allowance = opening + new_originations + stage_transfers - write_offs + recoveries.
255    #[serde(with = "rust_decimal::serde::str")]
256    pub closing: Decimal,
257
258    /// P&L charge for the period = new_originations + stage_transfers + recoveries - write_offs.
259    #[serde(with = "rust_decimal::serde::str")]
260    pub pl_charge: Decimal,
261}