datasynth_generators/standards/
fair_value_generator.rs1use chrono::NaiveDate;
22use datasynth_config::schema::FairValueConfig;
23use datasynth_core::utils::seeded_rng;
24use datasynth_standards::accounting::fair_value::{
25 FairValueCategory, FairValueHierarchyLevel, FairValueMeasurement, MeasurementType,
26 SensitivityAnalysis, ValuationInput, ValuationTechnique,
27};
28use datasynth_standards::framework::AccountingFramework;
29use rand::prelude::*;
30use rand_chacha::ChaCha8Rng;
31use rand_distr::LogNormal;
32use rust_decimal::prelude::*;
33use rust_decimal::Decimal;
34
35pub struct FairValueGenerator {
37 rng: ChaCha8Rng,
38}
39
40impl FairValueGenerator {
41 pub fn new(seed: u64) -> Self {
43 Self {
44 rng: seeded_rng(seed, 0),
45 }
46 }
47
48 pub fn generate(
58 &mut self,
59 company_code: &str,
60 measurement_date: NaiveDate,
61 currency: &str,
62 config: &FairValueConfig,
63 framework: AccountingFramework,
64 ) -> Vec<FairValueMeasurement> {
65 if config.measurement_count == 0 {
66 return Vec::new();
67 }
68
69 let value_dist = LogNormal::new(13.0, 1.5).expect("valid lognormal");
70 let mut measurements = Vec::with_capacity(config.measurement_count);
71
72 for i in 0..config.measurement_count {
73 let hierarchy_level = self.pick_level(config);
74 let category = self.pick_category(hierarchy_level);
75 let valuation_technique = self.technique_for(hierarchy_level);
76
77 let value_sample: f64 = value_dist.sample(&mut self.rng);
78 let value_raw = value_sample.max(100.0_f64);
79 let fair_value = Decimal::from_f64(value_raw)
80 .unwrap_or_else(|| Decimal::from(100_000))
81 .round_dp(2);
82
83 let item_id = format!("{}-FV-{:05}", company_code, i + 1);
84 let item_description = Self::description_for(category);
85
86 let mut measurement = FairValueMeasurement::new(
87 item_id,
88 item_description,
89 category,
90 hierarchy_level,
91 fair_value,
92 measurement_date,
93 currency,
94 framework,
95 );
96 measurement.valuation_technique = valuation_technique;
97 measurement.measurement_type = self.pick_measurement_type();
98
99 self.attach_inputs(&mut measurement);
101
102 if hierarchy_level == FairValueHierarchyLevel::Level3
104 && config.include_sensitivity_analysis
105 {
106 measurement.sensitivity_analysis =
107 Some(self.build_sensitivity_analysis(fair_value));
108 }
109
110 measurements.push(measurement);
111 }
112
113 measurements
114 }
115
116 fn pick_level(&mut self, config: &FairValueConfig) -> FairValueHierarchyLevel {
117 let roll: f64 = self.rng.random();
118 if roll < config.level1_percent {
119 FairValueHierarchyLevel::Level1
120 } else if roll < config.level1_percent + config.level2_percent {
121 FairValueHierarchyLevel::Level2
122 } else {
123 FairValueHierarchyLevel::Level3
124 }
125 }
126
127 fn pick_category(&mut self, level: FairValueHierarchyLevel) -> FairValueCategory {
128 let options: &[FairValueCategory] = match level {
133 FairValueHierarchyLevel::Level1 => &[
134 FairValueCategory::TradingSecurities,
135 FairValueCategory::AvailableForSale,
136 FairValueCategory::PensionAssets,
137 ],
138 FairValueHierarchyLevel::Level2 => &[
139 FairValueCategory::Derivatives,
140 FairValueCategory::AvailableForSale,
141 FairValueCategory::PensionAssets,
142 FairValueCategory::Other,
143 ],
144 FairValueHierarchyLevel::Level3 => &[
145 FairValueCategory::InvestmentProperty,
146 FairValueCategory::ContingentConsideration,
147 FairValueCategory::BiologicalAssets,
148 FairValueCategory::ImpairedAssets,
149 FairValueCategory::Other,
150 ],
151 };
152 *options
153 .choose(&mut self.rng)
154 .unwrap_or(&FairValueCategory::Other)
155 }
156
157 fn technique_for(&mut self, level: FairValueHierarchyLevel) -> ValuationTechnique {
158 match level {
159 FairValueHierarchyLevel::Level1 => ValuationTechnique::MarketApproach,
160 FairValueHierarchyLevel::Level2 => {
161 if self.rng.random_bool(0.5) {
162 ValuationTechnique::MarketApproach
163 } else {
164 ValuationTechnique::IncomeApproach
165 }
166 }
167 FairValueHierarchyLevel::Level3 => {
168 if self.rng.random_bool(0.7) {
169 ValuationTechnique::IncomeApproach
170 } else {
171 ValuationTechnique::MultipleApproaches
172 }
173 }
174 }
175 }
176
177 fn pick_measurement_type(&mut self) -> MeasurementType {
178 if self.rng.random_bool(0.85) {
181 MeasurementType::Recurring
182 } else {
183 MeasurementType::NonRecurring
184 }
185 }
186
187 fn description_for(category: FairValueCategory) -> String {
188 match category {
189 FairValueCategory::TradingSecurities => "Trading portfolio — listed equities",
190 FairValueCategory::AvailableForSale => "AFS portfolio — government bonds",
191 FairValueCategory::Derivatives => "Interest rate swap — receive fixed / pay float",
192 FairValueCategory::InvestmentProperty => "Investment property — commercial building",
193 FairValueCategory::BiologicalAssets => "Biological assets — forestry plantation",
194 FairValueCategory::PensionAssets => "Defined-benefit plan assets — pooled equities",
195 FairValueCategory::ContingentConsideration => {
196 "Contingent consideration — business combination earnout"
197 }
198 FairValueCategory::ImpairedAssets => "Impaired fixed asset — held for sale",
199 FairValueCategory::Other => "Other financial instrument",
200 }
201 .to_string()
202 }
203
204 fn attach_inputs(&mut self, measurement: &mut FairValueMeasurement) {
205 match measurement.hierarchy_level {
206 FairValueHierarchyLevel::Level1 => {
207 measurement.add_input(ValuationInput::new(
208 "Quoted Price",
209 measurement.fair_value,
210 "USD",
211 true,
212 "Listed exchange close price",
213 ));
214 }
215 FairValueHierarchyLevel::Level2 => {
216 measurement.add_input(ValuationInput::discount_rate(
217 Decimal::from_f64(self.rng.random_range(0.03_f64..0.07_f64))
218 .unwrap_or(Decimal::ZERO)
219 .round_dp(4),
220 "Broker-quoted yield curve",
221 ));
222 measurement.add_input(ValuationInput::new(
223 "Comparable Bid-Ask Midpoint",
224 measurement.fair_value,
225 measurement.currency.clone(),
226 true,
227 "Trade publication",
228 ));
229 }
230 FairValueHierarchyLevel::Level3 => {
231 measurement.add_input(ValuationInput::discount_rate(
232 Decimal::from_f64(self.rng.random_range(0.08_f64..0.15_f64))
233 .unwrap_or(Decimal::ZERO)
234 .round_dp(4),
235 "Internal cost of capital",
236 ));
237 measurement.add_input(ValuationInput::growth_rate(
238 Decimal::from_f64(self.rng.random_range(0.01_f64..0.05_f64))
239 .unwrap_or(Decimal::ZERO)
240 .round_dp(4),
241 "Management projections",
242 ));
243 }
244 }
245 }
246
247 fn build_sensitivity_analysis(&mut self, fair_value: Decimal) -> SensitivityAnalysis {
248 let low_factor = Decimal::from_str_exact("0.90").expect("const");
250 let high_factor = Decimal::from_str_exact("1.10").expect("const");
251 let low_rate = Decimal::from_str_exact("0.08").expect("const");
252 let high_rate = Decimal::from_str_exact("0.12").expect("const");
253 SensitivityAnalysis {
254 input_name: "Discount Rate".to_string(),
255 input_range: (low_rate, high_rate),
256 fair_value_low: (fair_value * low_factor).round_dp(2),
257 fair_value_high: (fair_value * high_factor).round_dp(2),
258 correlated_inputs: vec!["Expected Growth Rate".to_string()],
259 }
260 }
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used)]
265mod tests {
266 use super::*;
267
268 fn fixture_config() -> FairValueConfig {
269 FairValueConfig {
270 enabled: true,
271 measurement_count: 25,
272 level1_percent: 0.60,
273 level2_percent: 0.30,
274 level3_percent: 0.10,
275 include_sensitivity_analysis: true,
276 }
277 }
278
279 #[test]
280 fn generates_requested_count() {
281 let mut gen = FairValueGenerator::new(42);
282 let cfg = fixture_config();
283 let m = gen.generate(
284 "C001",
285 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
286 "USD",
287 &cfg,
288 AccountingFramework::UsGaap,
289 );
290 assert_eq!(m.len(), cfg.measurement_count);
291 }
292
293 #[test]
294 fn hierarchy_distribution_is_approximately_configured() {
295 let mut gen = FairValueGenerator::new(7);
296 let mut cfg = fixture_config();
297 cfg.measurement_count = 500;
298 let m = gen.generate(
299 "C001",
300 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
301 "USD",
302 &cfg,
303 AccountingFramework::Ifrs,
304 );
305 let l1 = m
306 .iter()
307 .filter(|x| x.hierarchy_level == FairValueHierarchyLevel::Level1)
308 .count();
309 let l3 = m
310 .iter()
311 .filter(|x| x.hierarchy_level == FairValueHierarchyLevel::Level3)
312 .count();
313 assert!(l1 > l3, "Level 1 should dominate over Level 3");
314 assert!(l1 > 250, "Level 1 ~60 % of 500 = 300 expected, got {l1}");
315 }
316
317 #[test]
318 fn sensitivity_analysis_only_on_level3() {
319 let mut gen = FairValueGenerator::new(13);
320 let cfg = fixture_config();
321 let m = gen.generate(
322 "C001",
323 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
324 "USD",
325 &cfg,
326 AccountingFramework::Ifrs,
327 );
328 for entry in &m {
329 let has_sens = entry.sensitivity_analysis.is_some();
330 let is_l3 = entry.hierarchy_level == FairValueHierarchyLevel::Level3;
331 assert_eq!(
332 has_sens, is_l3,
333 "sensitivity analysis should be present iff level 3"
334 );
335 }
336 }
337
338 #[test]
339 fn level1_uses_market_approach() {
340 let mut gen = FairValueGenerator::new(5);
341 let cfg = fixture_config();
342 let m = gen.generate(
343 "C001",
344 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
345 "USD",
346 &cfg,
347 AccountingFramework::UsGaap,
348 );
349 for entry in &m {
350 if entry.hierarchy_level == FairValueHierarchyLevel::Level1 {
351 assert_eq!(
352 entry.valuation_technique,
353 ValuationTechnique::MarketApproach
354 );
355 }
356 }
357 }
358}