use chrono::NaiveDate;
use datasynth_config::schema::ImpairmentConfig;
use datasynth_core::utils::{seeded_rng, weighted_select};
use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
use datasynth_standards::accounting::impairment::{
CashFlowProjection, ImpairmentAssetType, ImpairmentIndicator, ImpairmentTest,
ImpairmentTestResult,
};
use datasynth_standards::framework::AccountingFramework;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::prelude::*;
use rust_decimal::Decimal;
const ASSET_TYPE_WEIGHTS_NO_GOODWILL: [(ImpairmentAssetType, f64); 6] = [
(ImpairmentAssetType::PropertyPlantEquipment, 40.0),
(ImpairmentAssetType::IntangibleFinite, 20.0),
(ImpairmentAssetType::IntangibleIndefinite, 15.0),
(ImpairmentAssetType::RightOfUseAsset, 10.0),
(ImpairmentAssetType::EquityInvestment, 10.0),
(ImpairmentAssetType::CashGeneratingUnit, 5.0),
];
const ASSET_TYPE_WEIGHTS_WITH_GOODWILL: [(ImpairmentAssetType, f64); 7] = [
(ImpairmentAssetType::PropertyPlantEquipment, 30.0),
(ImpairmentAssetType::IntangibleFinite, 15.0),
(ImpairmentAssetType::IntangibleIndefinite, 12.0),
(ImpairmentAssetType::Goodwill, 15.0),
(ImpairmentAssetType::RightOfUseAsset, 10.0),
(ImpairmentAssetType::EquityInvestment, 10.0),
(ImpairmentAssetType::CashGeneratingUnit, 8.0),
];
const GENERAL_INDICATORS: [ImpairmentIndicator; 10] = [
ImpairmentIndicator::MarketValueDecline,
ImpairmentIndicator::AdverseEnvironmentChanges,
ImpairmentIndicator::InterestRateIncrease,
ImpairmentIndicator::MarketCapBelowBookValue,
ImpairmentIndicator::ObsolescenceOrDamage,
ImpairmentIndicator::AdverseUseChanges,
ImpairmentIndicator::OperatingLosses,
ImpairmentIndicator::DiscontinuationPlans,
ImpairmentIndicator::EarlyDisposal,
ImpairmentIndicator::WorsePerformance,
];
const PROJECTION_YEARS: u32 = 5;
pub struct ImpairmentGenerator {
rng: ChaCha8Rng,
uuid_factory: DeterministicUuidFactory,
}
impl ImpairmentGenerator {
pub fn new(seed: u64) -> Self {
Self {
rng: seeded_rng(seed, 0),
uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::ImpairmentTest),
}
}
pub fn with_config(seed: u64, _config: &ImpairmentConfig) -> Self {
Self::new(seed)
}
pub fn generate(
&mut self,
company_code: &str,
asset_ids: &[(String, String, Decimal)],
test_date: NaiveDate,
config: &ImpairmentConfig,
framework: AccountingFramework,
) -> Vec<ImpairmentTest> {
if asset_ids.is_empty() || config.test_count == 0 {
return Vec::new();
}
let mut tests: Vec<ImpairmentTest> = Vec::with_capacity(config.test_count);
for i in 0..config.test_count {
let (asset_id, description, carrying_amount) = &asset_ids[i % asset_ids.len()];
let asset_type = self.pick_asset_type(config.include_goodwill);
let mut test = ImpairmentTest::new(
company_code,
asset_id.clone(),
description.clone(),
asset_type,
test_date,
*carrying_amount,
framework,
);
test.test_id = self.uuid_factory.next();
self.add_indicators(&mut test, asset_type);
let discount_rate_f64 = self.rng.random_range(0.08..=0.15);
test.discount_rate =
Decimal::from_f64_retain(discount_rate_f64).unwrap_or(Decimal::ONE);
if config.generate_projections {
let projections = self.generate_projections(*carrying_amount, discount_rate_f64);
test.cash_flow_projections = projections;
test.calculate_value_in_use();
}
let fv_factor = self.rng.random_range(0.5..=1.1);
let fv_decimal = Decimal::from_f64_retain(fv_factor).unwrap_or(Decimal::ONE);
test.fair_value_less_costs = *carrying_amount * fv_decimal;
if matches!(framework, AccountingFramework::UsGaap) {
test.calculate_undiscounted_cash_flows();
}
test.perform_test();
tests.push(test);
}
self.enforce_impairment_rate(&mut tests, config.impairment_rate, framework);
tests
}
fn pick_asset_type(&mut self, include_goodwill: bool) -> ImpairmentAssetType {
if include_goodwill {
*weighted_select(&mut self.rng, &ASSET_TYPE_WEIGHTS_WITH_GOODWILL)
} else {
*weighted_select(&mut self.rng, &ASSET_TYPE_WEIGHTS_NO_GOODWILL)
}
}
fn add_indicators(&mut self, test: &mut ImpairmentTest, asset_type: ImpairmentAssetType) {
let requires_annual = matches!(
asset_type,
ImpairmentAssetType::Goodwill | ImpairmentAssetType::IntangibleIndefinite
);
if requires_annual {
test.add_indicator(ImpairmentIndicator::AnnualTest);
}
let extra_count = self.rng.random_range(1..=3_usize);
let count_needed = if requires_annual {
extra_count.saturating_sub(1)
} else {
extra_count
};
for _ in 0..count_needed {
let idx = self.rng.random_range(0..GENERAL_INDICATORS.len());
let indicator = GENERAL_INDICATORS[idx];
if !test.impairment_indicators.contains(&indicator) {
test.add_indicator(indicator);
}
}
}
fn generate_projections(
&mut self,
carrying_amount: Decimal,
_discount_rate: f64,
) -> Vec<CashFlowProjection> {
let mut projections = Vec::with_capacity(PROJECTION_YEARS as usize);
let base_factor = self.rng.random_range(0.3..=0.6);
let base_revenue_f64 = carrying_amount.to_f64().unwrap_or(100_000.0) * base_factor;
let opex_ratio = self.rng.random_range(0.60..=0.80);
let growth_rate_f64 = self.rng.random_range(-0.05..=0.05);
let growth_decimal = Decimal::from_f64_retain(growth_rate_f64).unwrap_or(Decimal::ZERO);
let mut current_revenue_f64 = base_revenue_f64;
for year in 1..=PROJECTION_YEARS {
let revenue = Decimal::from_f64_retain(current_revenue_f64).unwrap_or(Decimal::ZERO);
let opex =
Decimal::from_f64_retain(current_revenue_f64 * opex_ratio).unwrap_or(Decimal::ZERO);
let mut proj = CashFlowProjection::new(year, revenue, opex);
proj.growth_rate = growth_decimal;
let capex_ratio = self.rng.random_range(0.05..=0.15);
proj.capital_expenditures = Decimal::from_f64_retain(current_revenue_f64 * capex_ratio)
.unwrap_or(Decimal::ZERO);
if year == PROJECTION_YEARS {
proj.is_terminal_value = true;
let terminal_multiplier = self.rng.random_range(3.0..=5.0);
let terminal_revenue =
Decimal::from_f64_retain(current_revenue_f64 * terminal_multiplier)
.unwrap_or(Decimal::ZERO);
let terminal_opex = Decimal::from_f64_retain(
current_revenue_f64 * terminal_multiplier * opex_ratio,
)
.unwrap_or(Decimal::ZERO);
proj.revenue = terminal_revenue;
proj.operating_expenses = terminal_opex;
proj.capital_expenditures = Decimal::from_f64_retain(
current_revenue_f64 * terminal_multiplier * capex_ratio,
)
.unwrap_or(Decimal::ZERO);
}
proj.calculate_net_cash_flow();
projections.push(proj);
current_revenue_f64 *= 1.0 + growth_rate_f64;
}
projections
}
fn enforce_impairment_rate(
&mut self,
tests: &mut [ImpairmentTest],
target_rate: f64,
framework: AccountingFramework,
) {
if tests.is_empty() {
return;
}
let target_impaired = ((tests.len() as f64) * target_rate).round() as usize;
let current_impaired = tests
.iter()
.filter(|t| t.test_result == ImpairmentTestResult::Impaired)
.count();
if current_impaired < target_impaired {
let deficit = target_impaired - current_impaired;
let mut converted = 0usize;
for test in tests.iter_mut() {
if converted >= deficit {
break;
}
if test.test_result == ImpairmentTestResult::NotImpaired {
let reduction_factor = self.rng.random_range(0.3..=0.6);
let factor_dec =
Decimal::from_f64_retain(reduction_factor).unwrap_or(Decimal::ONE);
test.fair_value_less_costs = test.carrying_amount * factor_dec;
if matches!(
framework,
AccountingFramework::Ifrs
| AccountingFramework::DualReporting
| AccountingFramework::FrenchGaap
) {
test.value_in_use = test.fair_value_less_costs
- Decimal::from_f64_retain(self.rng.random_range(1000.0..=10000.0))
.unwrap_or(Decimal::ZERO);
}
if matches!(framework, AccountingFramework::UsGaap) {
let low_factor = self.rng.random_range(0.5..=0.8);
test.undiscounted_cash_flows = Some(
test.carrying_amount
* Decimal::from_f64_retain(low_factor).unwrap_or(Decimal::ONE),
);
}
test.perform_test();
converted += 1;
}
}
} else if current_impaired > target_impaired {
let surplus = current_impaired - target_impaired;
let mut converted = 0usize;
for test in tests.iter_mut() {
if converted >= surplus {
break;
}
if test.test_result == ImpairmentTestResult::Impaired {
let boost_factor = self.rng.random_range(1.05..=1.30);
let factor_dec = Decimal::from_f64_retain(boost_factor).unwrap_or(Decimal::ONE);
test.fair_value_less_costs = test.carrying_amount * factor_dec;
test.value_in_use = test.fair_value_less_costs;
if matches!(framework, AccountingFramework::UsGaap) {
test.undiscounted_cash_flows = Some(test.carrying_amount * factor_dec);
}
test.perform_test();
converted += 1;
}
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn sample_assets() -> Vec<(String, String, Decimal)> {
vec![
(
"FA-001".to_string(),
"Manufacturing Equipment".to_string(),
dec!(500_000),
),
(
"FA-002".to_string(),
"Office Building".to_string(),
dec!(2_000_000),
),
(
"FA-003".to_string(),
"Software License".to_string(),
dec!(150_000),
),
(
"FA-004".to_string(),
"Patent Portfolio".to_string(),
dec!(800_000),
),
]
}
fn default_config() -> ImpairmentConfig {
ImpairmentConfig {
enabled: true,
test_count: 15,
impairment_rate: 0.10,
generate_projections: true,
include_goodwill: false,
}
}
#[test]
fn test_basic_generation() {
let mut gen = ImpairmentGenerator::new(42);
let assets = sample_assets();
let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let results = gen.generate(
"C001",
&assets,
date,
&default_config(),
AccountingFramework::UsGaap,
);
assert_eq!(results.len(), 15);
for test in &results {
assert_eq!(test.company_code, "C001");
assert_eq!(test.test_date, date);
assert!(!test.impairment_indicators.is_empty());
assert!(test.carrying_amount > Decimal::ZERO);
assert!(!test.cash_flow_projections.is_empty());
assert!(test.undiscounted_cash_flows.is_some());
}
}
#[test]
fn test_deterministic() {
let assets = sample_assets();
let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let config = default_config();
let mut gen1 = ImpairmentGenerator::new(99);
let mut gen2 = ImpairmentGenerator::new(99);
let r1 = gen1.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
let r2 = gen2.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
assert_eq!(r1.len(), r2.len());
for (a, b) in r1.iter().zip(r2.iter()) {
assert_eq!(a.test_id, b.test_id);
assert_eq!(a.asset_id, b.asset_id);
assert_eq!(a.asset_type, b.asset_type);
assert_eq!(a.carrying_amount, b.carrying_amount);
assert_eq!(a.fair_value_less_costs, b.fair_value_less_costs);
assert_eq!(a.value_in_use, b.value_in_use);
assert_eq!(a.impairment_loss, b.impairment_loss);
assert_eq!(a.test_result, b.test_result);
assert_eq!(a.discount_rate, b.discount_rate);
assert_eq!(a.cash_flow_projections.len(), b.cash_flow_projections.len());
}
}
#[test]
fn test_impairment_rate_respected() {
let config = ImpairmentConfig {
enabled: true,
test_count: 50,
impairment_rate: 0.40,
generate_projections: true,
include_goodwill: true,
};
let assets = sample_assets();
let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let mut gen = ImpairmentGenerator::new(123);
let results = gen.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
let impaired_count = results
.iter()
.filter(|t| t.test_result == ImpairmentTestResult::Impaired)
.count();
let target = (50.0_f64 * 0.40).round() as usize; assert!(
impaired_count >= target.saturating_sub(1) && impaired_count <= target + 1,
"Expected ~{target} impaired, got {impaired_count}"
);
}
#[test]
fn test_us_gaap_vs_ifrs() {
let assets = sample_assets();
let date = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
let config = ImpairmentConfig {
enabled: true,
test_count: 10,
impairment_rate: 0.20,
generate_projections: true,
include_goodwill: false,
};
let mut gen_gaap = ImpairmentGenerator::new(77);
let mut gen_ifrs = ImpairmentGenerator::new(77);
let gaap_results =
gen_gaap.generate("C001", &assets, date, &config, AccountingFramework::UsGaap);
let ifrs_results =
gen_ifrs.generate("C001", &assets, date, &config, AccountingFramework::Ifrs);
assert_eq!(gaap_results.len(), ifrs_results.len());
for test in &gaap_results {
assert!(
test.undiscounted_cash_flows.is_some(),
"US GAAP test should have undiscounted cash flows"
);
assert_eq!(test.framework, AccountingFramework::UsGaap);
}
for test in &ifrs_results {
assert!(
test.undiscounted_cash_flows.is_none(),
"IFRS test should not have undiscounted cash flows"
);
assert_eq!(test.framework, AccountingFramework::Ifrs);
}
for test in gaap_results.iter().chain(ifrs_results.iter()) {
if test.test_result == ImpairmentTestResult::Impaired {
assert!(
test.impairment_loss > Decimal::ZERO,
"Impaired test should have positive loss"
);
} else {
assert_eq!(
test.impairment_loss,
Decimal::ZERO,
"Not-impaired test should have zero loss"
);
}
}
}
}