1use 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
24const 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
34const 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
45const 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
59const PROJECTION_YEARS: u32 = 5;
61
62pub struct ImpairmentGenerator {
64 rng: ChaCha8Rng,
65 uuid_factory: DeterministicUuidFactory,
66}
67
68impl ImpairmentGenerator {
69 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 pub fn with_config(seed: u64, _config: &ImpairmentConfig) -> Self {
80 Self::new(seed)
83 }
84
85 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 test.test_id = self.uuid_factory.next();
129
130 self.add_indicators(&mut test, asset_type);
132
133 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 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 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 if matches!(framework, AccountingFramework::UsGaap) {
152 test.calculate_undiscounted_cash_flows();
153 }
154
155 test.perform_test();
157
158 tests.push(test);
159 }
160
161 self.enforce_impairment_rate(&mut tests, config.impairment_rate, framework);
163
164 tests
165 }
166
167 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 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 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 if !test.impairment_indicators.contains(&indicator) {
206 test.add_indicator(indicator);
207 }
208 }
209 }
210
211 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 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 let opex_ratio = self.rng.random_range(0.60..=0.80);
225
226 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 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 if year == PROJECTION_YEARS {
247 proj.is_terminal_value = true;
248 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 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 current_revenue_f64 *= 1.0 + growth_rate_f64;
274 }
275
276 projections
277 }
278
279 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 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 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 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 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 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 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#[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 assert!(!test.cash_flow_projections.is_empty());
435 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 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; 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 assert_eq!(gaap_results.len(), ifrs_results.len());
519
520 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 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 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}