Skip to main content

datasynth_generators/standards/
impairment_generator.rs

1//! Impairment test generator (ASC 360 / IAS 36).
2//!
3//! Generates synthetic impairment tests for long-lived assets, including:
4//! - Asset type distribution (PPE, intangibles, goodwill, ROU, etc.)
5//! - Impairment indicator selection
6//! - 5-year cash flow projections with discounting
7//! - Framework-specific test logic (US GAAP two-step vs IFRS one-step)
8//! - Configurable impairment rate targeting
9
10use chrono::NaiveDate;
11use datasynth_config::schema::ImpairmentConfig;
12use datasynth_core::utils::{seeded_rng, weighted_select};
13use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
14use datasynth_standards::accounting::impairment::{
15    CashFlowProjection, ImpairmentAssetType, ImpairmentIndicator, ImpairmentTest,
16    ImpairmentTestResult,
17};
18use datasynth_standards::framework::AccountingFramework;
19use rand::prelude::*;
20use rand_chacha::ChaCha8Rng;
21use rust_decimal::prelude::*;
22use rust_decimal::Decimal;
23
24/// All non-goodwill asset types with their approximate probability weights.
25const ASSET_TYPE_WEIGHTS_NO_GOODWILL: [(ImpairmentAssetType, f64); 6] = [
26    (ImpairmentAssetType::PropertyPlantEquipment, 40.0),
27    (ImpairmentAssetType::IntangibleFinite, 20.0),
28    (ImpairmentAssetType::IntangibleIndefinite, 15.0),
29    (ImpairmentAssetType::RightOfUseAsset, 10.0),
30    (ImpairmentAssetType::EquityInvestment, 10.0),
31    (ImpairmentAssetType::CashGeneratingUnit, 5.0),
32];
33
34/// Asset types with goodwill included (redistributed weights).
35const ASSET_TYPE_WEIGHTS_WITH_GOODWILL: [(ImpairmentAssetType, f64); 7] = [
36    (ImpairmentAssetType::PropertyPlantEquipment, 30.0),
37    (ImpairmentAssetType::IntangibleFinite, 15.0),
38    (ImpairmentAssetType::IntangibleIndefinite, 12.0),
39    (ImpairmentAssetType::Goodwill, 15.0),
40    (ImpairmentAssetType::RightOfUseAsset, 10.0),
41    (ImpairmentAssetType::EquityInvestment, 10.0),
42    (ImpairmentAssetType::CashGeneratingUnit, 8.0),
43];
44
45/// Indicators that can be randomly assigned to any asset test.
46const GENERAL_INDICATORS: [ImpairmentIndicator; 10] = [
47    ImpairmentIndicator::MarketValueDecline,
48    ImpairmentIndicator::AdverseEnvironmentChanges,
49    ImpairmentIndicator::InterestRateIncrease,
50    ImpairmentIndicator::MarketCapBelowBookValue,
51    ImpairmentIndicator::ObsolescenceOrDamage,
52    ImpairmentIndicator::AdverseUseChanges,
53    ImpairmentIndicator::OperatingLosses,
54    ImpairmentIndicator::DiscontinuationPlans,
55    ImpairmentIndicator::EarlyDisposal,
56    ImpairmentIndicator::WorsePerformance,
57];
58
59/// Projection horizon in years for value-in-use calculations.
60const PROJECTION_YEARS: u32 = 5;
61
62/// Generates impairment tests for long-lived assets.
63pub struct ImpairmentGenerator {
64    rng: ChaCha8Rng,
65    uuid_factory: DeterministicUuidFactory,
66}
67
68impl ImpairmentGenerator {
69    /// Create a new impairment generator with a deterministic seed.
70    pub fn new(seed: u64) -> Self {
71        Self {
72            rng: seeded_rng(seed, 0),
73            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ImpairmentTest),
74        }
75    }
76
77    /// Create a new impairment generator with custom configuration (seed only;
78    /// the per-run [`ImpairmentConfig`] is passed to [`generate`]).
79    pub fn with_config(seed: u64, _config: &ImpairmentConfig) -> Self {
80        // Config is used at generation time, not construction time.
81        // The constructor signature is kept for consistency with other generators.
82        Self::new(seed)
83    }
84
85    /// Generate impairment tests for the given assets.
86    ///
87    /// # Arguments
88    ///
89    /// * `company_code` - Company identifier
90    /// * `asset_ids` - Slice of `(asset_id, description, carrying_amount)` tuples
91    /// * `test_date` - Date of the impairment assessment
92    /// * `config` - Impairment-specific configuration
93    /// * `framework` - Accounting framework (US GAAP, IFRS, or Dual Reporting)
94    ///
95    /// # Returns
96    ///
97    /// A vector of [`ImpairmentTest`] records, sized according to `config.test_count`.
98    pub fn generate(
99        &mut self,
100        company_code: &str,
101        asset_ids: &[(String, String, Decimal)],
102        test_date: NaiveDate,
103        config: &ImpairmentConfig,
104        framework: AccountingFramework,
105    ) -> Vec<ImpairmentTest> {
106        if asset_ids.is_empty() || config.test_count == 0 {
107            return Vec::new();
108        }
109
110        let mut tests: Vec<ImpairmentTest> = Vec::with_capacity(config.test_count);
111
112        for i in 0..config.test_count {
113            let (asset_id, description, carrying_amount) = &asset_ids[i % asset_ids.len()];
114
115            let asset_type = self.pick_asset_type(config.include_goodwill);
116
117            let mut test = ImpairmentTest::new(
118                company_code,
119                asset_id.clone(),
120                description.clone(),
121                asset_type,
122                test_date,
123                *carrying_amount,
124                framework,
125            );
126
127            // Overwrite the v7 test_id with a deterministic UUID from our factory.
128            test.test_id = self.uuid_factory.next();
129
130            // --- Indicators ---
131            self.add_indicators(&mut test, asset_type);
132
133            // --- Discount rate (8-15%) ---
134            let discount_rate_f64 = self.rng.random_range(0.08..=0.15);
135            test.discount_rate =
136                Decimal::from_f64_retain(discount_rate_f64).unwrap_or(Decimal::ONE);
137
138            // --- Cash flow projections ---
139            if config.generate_projections {
140                let projections = self.generate_projections(*carrying_amount, discount_rate_f64);
141                test.cash_flow_projections = projections;
142                test.calculate_value_in_use();
143            }
144
145            // --- Fair value less costs to sell ---
146            let fv_factor = self.rng.random_range(0.5..=1.1);
147            let fv_decimal = Decimal::from_f64_retain(fv_factor).unwrap_or(Decimal::ONE);
148            test.fair_value_less_costs = *carrying_amount * fv_decimal;
149
150            // --- US GAAP: undiscounted cash flows for Step 1 ---
151            if matches!(framework, AccountingFramework::UsGaap) {
152                test.calculate_undiscounted_cash_flows();
153            }
154
155            // --- Perform the framework-specific test ---
156            test.perform_test();
157
158            tests.push(test);
159        }
160
161        // --- Enforce impairment rate target ---
162        self.enforce_impairment_rate(&mut tests, config.impairment_rate, framework);
163
164        tests
165    }
166
167    // -----------------------------------------------------------------------
168    // Private helpers
169    // -----------------------------------------------------------------------
170
171    /// Pick an asset type using the configured weight tables.
172    fn pick_asset_type(&mut self, include_goodwill: bool) -> ImpairmentAssetType {
173        if include_goodwill {
174            *weighted_select(&mut self.rng, &ASSET_TYPE_WEIGHTS_WITH_GOODWILL)
175        } else {
176            *weighted_select(&mut self.rng, &ASSET_TYPE_WEIGHTS_NO_GOODWILL)
177        }
178    }
179
180    /// Add 1-3 impairment indicators to a test.
181    ///
182    /// Goodwill and indefinite-life intangibles always receive [`AnnualTest`].
183    fn add_indicators(&mut self, test: &mut ImpairmentTest, asset_type: ImpairmentAssetType) {
184        let requires_annual = matches!(
185            asset_type,
186            ImpairmentAssetType::Goodwill | ImpairmentAssetType::IntangibleIndefinite
187        );
188
189        if requires_annual {
190            test.add_indicator(ImpairmentIndicator::AnnualTest);
191        }
192
193        let extra_count = self.rng.random_range(1..=3_usize);
194        let count_needed = if requires_annual {
195            // Already added AnnualTest; add 0-2 more for up to 3 total.
196            extra_count.saturating_sub(1)
197        } else {
198            extra_count
199        };
200
201        for _ in 0..count_needed {
202            let idx = self.rng.random_range(0..GENERAL_INDICATORS.len());
203            let indicator = GENERAL_INDICATORS[idx];
204            // Avoid duplicates.
205            if !test.impairment_indicators.contains(&indicator) {
206                test.add_indicator(indicator);
207            }
208        }
209    }
210
211    /// Build 5-year cash flow projections with an optional terminal value.
212    fn generate_projections(
213        &mut self,
214        carrying_amount: Decimal,
215        _discount_rate: f64,
216    ) -> Vec<CashFlowProjection> {
217        let mut projections = Vec::with_capacity(PROJECTION_YEARS as usize);
218
219        // Base revenue: carrying_amount * random(0.3 .. 0.6)
220        let base_factor = self.rng.random_range(0.3..=0.6);
221        let base_revenue_f64 = carrying_amount.to_f64().unwrap_or(100_000.0) * base_factor;
222
223        // Operating expense ratio: 60-80% of revenue.
224        let opex_ratio = self.rng.random_range(0.60..=0.80);
225
226        // Annual growth rate: -5% to +5%.
227        let growth_rate_f64 = self.rng.random_range(-0.05..=0.05);
228        let growth_decimal = Decimal::from_f64_retain(growth_rate_f64).unwrap_or(Decimal::ZERO);
229
230        let mut current_revenue_f64 = base_revenue_f64;
231
232        for year in 1..=PROJECTION_YEARS {
233            let revenue = Decimal::from_f64_retain(current_revenue_f64).unwrap_or(Decimal::ZERO);
234            let opex =
235                Decimal::from_f64_retain(current_revenue_f64 * opex_ratio).unwrap_or(Decimal::ZERO);
236
237            let mut proj = CashFlowProjection::new(year, revenue, opex);
238            proj.growth_rate = growth_decimal;
239
240            // Capital expenditures: 5-15% of revenue.
241            let capex_ratio = self.rng.random_range(0.05..=0.15);
242            proj.capital_expenditures = Decimal::from_f64_retain(current_revenue_f64 * capex_ratio)
243                .unwrap_or(Decimal::ZERO);
244
245            // Terminal value bump in year 5.
246            if year == PROJECTION_YEARS {
247                proj.is_terminal_value = true;
248                // Terminal value approximation: year 5 net CF / discount rate,
249                // but since we already add it to the projection stream we simply
250                // boost revenue by a terminal multiplier (3-5x) to approximate
251                // a perpetuity.
252                let terminal_multiplier = self.rng.random_range(3.0..=5.0);
253                let terminal_revenue =
254                    Decimal::from_f64_retain(current_revenue_f64 * terminal_multiplier)
255                        .unwrap_or(Decimal::ZERO);
256                let terminal_opex = Decimal::from_f64_retain(
257                    current_revenue_f64 * terminal_multiplier * opex_ratio,
258                )
259                .unwrap_or(Decimal::ZERO);
260                proj.revenue = terminal_revenue;
261                proj.operating_expenses = terminal_opex;
262                // Recalculate capex for terminal year as well.
263                proj.capital_expenditures = Decimal::from_f64_retain(
264                    current_revenue_f64 * terminal_multiplier * capex_ratio,
265                )
266                .unwrap_or(Decimal::ZERO);
267            }
268
269            proj.calculate_net_cash_flow();
270            projections.push(proj);
271
272            // Apply growth for next year.
273            current_revenue_f64 *= 1.0 + growth_rate_f64;
274        }
275
276        projections
277    }
278
279    /// Adjust tests so that the observed impairment rate meets the target.
280    ///
281    /// If natural generation produced fewer impairments than desired, force
282    /// some not-impaired tests to be impaired by lowering their fair value.
283    /// If too many are impaired, convert some back to not-impaired by raising
284    /// fair value above carrying amount.
285    fn enforce_impairment_rate(
286        &mut self,
287        tests: &mut [ImpairmentTest],
288        target_rate: f64,
289        framework: AccountingFramework,
290    ) {
291        if tests.is_empty() {
292            return;
293        }
294
295        let target_impaired = ((tests.len() as f64) * target_rate).round() as usize;
296        let current_impaired = tests
297            .iter()
298            .filter(|t| t.test_result == ImpairmentTestResult::Impaired)
299            .count();
300
301        if current_impaired < target_impaired {
302            // Need more impairments -- lower fair value on not-impaired tests.
303            let deficit = target_impaired - current_impaired;
304            let mut converted = 0usize;
305            for test in tests.iter_mut() {
306                if converted >= deficit {
307                    break;
308                }
309                if test.test_result == ImpairmentTestResult::NotImpaired {
310                    // Set fair value well below carrying amount.
311                    let reduction_factor = self.rng.random_range(0.3..=0.6);
312                    let factor_dec =
313                        Decimal::from_f64_retain(reduction_factor).unwrap_or(Decimal::ONE);
314                    test.fair_value_less_costs = test.carrying_amount * factor_dec;
315
316                    // Also lower value_in_use for IFRS and French GAAP (one-step test).
317                    if matches!(
318                        framework,
319                        AccountingFramework::Ifrs
320                            | AccountingFramework::DualReporting
321                            | AccountingFramework::FrenchGaap
322                    ) {
323                        test.value_in_use = test.fair_value_less_costs
324                            - Decimal::from_f64_retain(self.rng.random_range(1000.0..=10000.0))
325                                .unwrap_or(Decimal::ZERO);
326                    }
327
328                    // For US GAAP, ensure undiscounted CFs fail Step 1.
329                    if matches!(framework, AccountingFramework::UsGaap) {
330                        let low_factor = self.rng.random_range(0.5..=0.8);
331                        test.undiscounted_cash_flows = Some(
332                            test.carrying_amount
333                                * Decimal::from_f64_retain(low_factor).unwrap_or(Decimal::ONE),
334                        );
335                    }
336
337                    test.perform_test();
338                    converted += 1;
339                }
340            }
341        } else if current_impaired > target_impaired {
342            // Too many impairments -- raise fair value on some impaired tests.
343            let surplus = current_impaired - target_impaired;
344            let mut converted = 0usize;
345            for test in tests.iter_mut() {
346                if converted >= surplus {
347                    break;
348                }
349                if test.test_result == ImpairmentTestResult::Impaired {
350                    // Set fair value above carrying amount.
351                    let boost_factor = self.rng.random_range(1.05..=1.30);
352                    let factor_dec = Decimal::from_f64_retain(boost_factor).unwrap_or(Decimal::ONE);
353                    test.fair_value_less_costs = test.carrying_amount * factor_dec;
354                    test.value_in_use = test.fair_value_less_costs;
355
356                    if matches!(framework, AccountingFramework::UsGaap) {
357                        test.undiscounted_cash_flows = Some(test.carrying_amount * factor_dec);
358                    }
359
360                    test.perform_test();
361                    converted += 1;
362                }
363            }
364        }
365    }
366}
367
368// ===========================================================================
369// Tests
370// ===========================================================================
371
372#[cfg(test)]
373#[allow(clippy::unwrap_used)]
374mod tests {
375    use super::*;
376    use rust_decimal_macros::dec;
377
378    fn sample_assets() -> Vec<(String, String, Decimal)> {
379        vec![
380            (
381                "FA-001".to_string(),
382                "Manufacturing Equipment".to_string(),
383                dec!(500_000),
384            ),
385            (
386                "FA-002".to_string(),
387                "Office Building".to_string(),
388                dec!(2_000_000),
389            ),
390            (
391                "FA-003".to_string(),
392                "Software License".to_string(),
393                dec!(150_000),
394            ),
395            (
396                "FA-004".to_string(),
397                "Patent Portfolio".to_string(),
398                dec!(800_000),
399            ),
400        ]
401    }
402
403    fn default_config() -> ImpairmentConfig {
404        ImpairmentConfig {
405            enabled: true,
406            test_count: 15,
407            impairment_rate: 0.10,
408            generate_projections: true,
409            include_goodwill: false,
410        }
411    }
412
413    #[test]
414    fn test_basic_generation() {
415        let mut gen = ImpairmentGenerator::new(42);
416        let assets = sample_assets();
417        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
418
419        let results = gen.generate(
420            "C001",
421            &assets,
422            date,
423            &default_config(),
424            AccountingFramework::UsGaap,
425        );
426
427        assert_eq!(results.len(), 15);
428        for test in &results {
429            assert_eq!(test.company_code, "C001");
430            assert_eq!(test.test_date, date);
431            assert!(!test.impairment_indicators.is_empty());
432            assert!(test.carrying_amount > Decimal::ZERO);
433            // Each test should have cash flow projections.
434            assert!(!test.cash_flow_projections.is_empty());
435            // US GAAP tests should have undiscounted cash flows.
436            assert!(test.undiscounted_cash_flows.is_some());
437        }
438    }
439
440    #[test]
441    fn test_deterministic() {
442        let assets = sample_assets();
443        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
444        let config = default_config();
445
446        let mut gen1 = ImpairmentGenerator::new(99);
447        let mut gen2 = ImpairmentGenerator::new(99);
448
449        let r1 = gen1.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
450        let r2 = gen2.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
451
452        assert_eq!(r1.len(), r2.len());
453        for (a, b) in r1.iter().zip(r2.iter()) {
454            assert_eq!(a.test_id, b.test_id);
455            assert_eq!(a.asset_id, b.asset_id);
456            assert_eq!(a.asset_type, b.asset_type);
457            assert_eq!(a.carrying_amount, b.carrying_amount);
458            assert_eq!(a.fair_value_less_costs, b.fair_value_less_costs);
459            assert_eq!(a.value_in_use, b.value_in_use);
460            assert_eq!(a.impairment_loss, b.impairment_loss);
461            assert_eq!(a.test_result, b.test_result);
462            assert_eq!(a.discount_rate, b.discount_rate);
463            assert_eq!(a.cash_flow_projections.len(), b.cash_flow_projections.len());
464        }
465    }
466
467    #[test]
468    fn test_impairment_rate_respected() {
469        // Use a higher rate so we can verify the enforcement logic.
470        let config = ImpairmentConfig {
471            enabled: true,
472            test_count: 50,
473            impairment_rate: 0.40,
474            generate_projections: true,
475            include_goodwill: true,
476        };
477
478        let assets = sample_assets();
479        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
480        let mut gen = ImpairmentGenerator::new(123);
481
482        let results = gen.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
483
484        let impaired_count = results
485            .iter()
486            .filter(|t| t.test_result == ImpairmentTestResult::Impaired)
487            .count();
488
489        let target = (50.0_f64 * 0.40).round() as usize; // 20
490                                                         // Allow +/- 1 tolerance since enforcement works iteratively.
491        assert!(
492            impaired_count >= target.saturating_sub(1) && impaired_count <= target + 1,
493            "Expected ~{target} impaired, got {impaired_count}"
494        );
495    }
496
497    #[test]
498    fn test_us_gaap_vs_ifrs() {
499        let assets = sample_assets();
500        let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
501        let config = ImpairmentConfig {
502            enabled: true,
503            test_count: 10,
504            impairment_rate: 0.20,
505            generate_projections: true,
506            include_goodwill: false,
507        };
508
509        let mut gen_gaap = ImpairmentGenerator::new(77);
510        let mut gen_ifrs = ImpairmentGenerator::new(77);
511
512        let gaap_results =
513            gen_gaap.generate("C001", &assets, date, &config, AccountingFramework::UsGaap);
514        let ifrs_results =
515            gen_ifrs.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
516
517        // Both should produce the same number of tests.
518        assert_eq!(gaap_results.len(), ifrs_results.len());
519
520        // US GAAP tests must all have undiscounted_cash_flows set.
521        for test in &gaap_results {
522            assert!(
523                test.undiscounted_cash_flows.is_some(),
524                "US GAAP test should have undiscounted cash flows"
525            );
526            assert_eq!(test.framework, AccountingFramework::UsGaap);
527        }
528
529        // IFRS tests should NOT have undiscounted_cash_flows.
530        for test in &ifrs_results {
531            assert!(
532                test.undiscounted_cash_flows.is_none(),
533                "IFRS test should not have undiscounted cash flows"
534            );
535            assert_eq!(test.framework, AccountingFramework::Ifrs);
536        }
537
538        // Due to different framework logic, impairment losses may differ even
539        // with the same seed -- the RNG sequence is identical, but US GAAP
540        // uses the two-step model while IFRS uses recoverable amount directly.
541        // We just verify structural correctness here.
542        for test in gaap_results.iter().chain(ifrs_results.iter()) {
543            if test.test_result == ImpairmentTestResult::Impaired {
544                assert!(
545                    test.impairment_loss > Decimal::ZERO,
546                    "Impaired test should have positive loss"
547                );
548            } else {
549                assert_eq!(
550                    test.impairment_loss,
551                    Decimal::ZERO,
552                    "Not-impaired test should have zero loss"
553                );
554            }
555        }
556    }
557}