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)]
373mod tests {
374 use super::*;
375 use rust_decimal_macros::dec;
376
377 fn sample_assets() -> Vec<(String, String, Decimal)> {
378 vec![
379 (
380 "FA-001".to_string(),
381 "Manufacturing Equipment".to_string(),
382 dec!(500_000),
383 ),
384 (
385 "FA-002".to_string(),
386 "Office Building".to_string(),
387 dec!(2_000_000),
388 ),
389 (
390 "FA-003".to_string(),
391 "Software License".to_string(),
392 dec!(150_000),
393 ),
394 (
395 "FA-004".to_string(),
396 "Patent Portfolio".to_string(),
397 dec!(800_000),
398 ),
399 ]
400 }
401
402 fn default_config() -> ImpairmentConfig {
403 ImpairmentConfig {
404 enabled: true,
405 test_count: 15,
406 impairment_rate: 0.10,
407 generate_projections: true,
408 include_goodwill: false,
409 }
410 }
411
412 #[test]
413 fn test_basic_generation() {
414 let mut gen = ImpairmentGenerator::new(42);
415 let assets = sample_assets();
416 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
417
418 let results = gen.generate(
419 "C001",
420 &assets,
421 date,
422 &default_config(),
423 AccountingFramework::UsGaap,
424 );
425
426 assert_eq!(results.len(), 15);
427 for test in &results {
428 assert_eq!(test.company_code, "C001");
429 assert_eq!(test.test_date, date);
430 assert!(!test.impairment_indicators.is_empty());
431 assert!(test.carrying_amount > Decimal::ZERO);
432 assert!(!test.cash_flow_projections.is_empty());
434 assert!(test.undiscounted_cash_flows.is_some());
436 }
437 }
438
439 #[test]
440 fn test_deterministic() {
441 let assets = sample_assets();
442 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
443 let config = default_config();
444
445 let mut gen1 = ImpairmentGenerator::new(99);
446 let mut gen2 = ImpairmentGenerator::new(99);
447
448 let r1 = gen1.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
449 let r2 = gen2.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
450
451 assert_eq!(r1.len(), r2.len());
452 for (a, b) in r1.iter().zip(r2.iter()) {
453 assert_eq!(a.test_id, b.test_id);
454 assert_eq!(a.asset_id, b.asset_id);
455 assert_eq!(a.asset_type, b.asset_type);
456 assert_eq!(a.carrying_amount, b.carrying_amount);
457 assert_eq!(a.fair_value_less_costs, b.fair_value_less_costs);
458 assert_eq!(a.value_in_use, b.value_in_use);
459 assert_eq!(a.impairment_loss, b.impairment_loss);
460 assert_eq!(a.test_result, b.test_result);
461 assert_eq!(a.discount_rate, b.discount_rate);
462 assert_eq!(a.cash_flow_projections.len(), b.cash_flow_projections.len());
463 }
464 }
465
466 #[test]
467 fn test_impairment_rate_respected() {
468 let config = ImpairmentConfig {
470 enabled: true,
471 test_count: 50,
472 impairment_rate: 0.40,
473 generate_projections: true,
474 include_goodwill: true,
475 };
476
477 let assets = sample_assets();
478 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
479 let mut gen = ImpairmentGenerator::new(123);
480
481 let results = gen.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
482
483 let impaired_count = results
484 .iter()
485 .filter(|t| t.test_result == ImpairmentTestResult::Impaired)
486 .count();
487
488 let target = (50.0_f64 * 0.40).round() as usize; assert!(
491 impaired_count >= target.saturating_sub(1) && impaired_count <= target + 1,
492 "Expected ~{target} impaired, got {impaired_count}"
493 );
494 }
495
496 #[test]
497 fn test_us_gaap_vs_ifrs() {
498 let assets = sample_assets();
499 let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
500 let config = ImpairmentConfig {
501 enabled: true,
502 test_count: 10,
503 impairment_rate: 0.20,
504 generate_projections: true,
505 include_goodwill: false,
506 };
507
508 let mut gen_gaap = ImpairmentGenerator::new(77);
509 let mut gen_ifrs = ImpairmentGenerator::new(77);
510
511 let gaap_results =
512 gen_gaap.generate("C001", &assets, date, &config, AccountingFramework::UsGaap);
513 let ifrs_results =
514 gen_ifrs.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
515
516 assert_eq!(gaap_results.len(), ifrs_results.len());
518
519 for test in &gaap_results {
521 assert!(
522 test.undiscounted_cash_flows.is_some(),
523 "US GAAP test should have undiscounted cash flows"
524 );
525 assert_eq!(test.framework, AccountingFramework::UsGaap);
526 }
527
528 for test in &ifrs_results {
530 assert!(
531 test.undiscounted_cash_flows.is_none(),
532 "IFRS test should not have undiscounted cash flows"
533 );
534 assert_eq!(test.framework, AccountingFramework::Ifrs);
535 }
536
537 for test in gaap_results.iter().chain(ifrs_results.iter()) {
542 if test.test_result == ImpairmentTestResult::Impaired {
543 assert!(
544 test.impairment_loss > Decimal::ZERO,
545 "Impaired test should have positive loss"
546 );
547 } else {
548 assert_eq!(
549 test.impairment_loss,
550 Decimal::ZERO,
551 "Not-impaired test should have zero loss"
552 );
553 }
554 }
555 }
556}