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)]
264mod tests {
265 use super::*;
266
267 fn fixture_config() -> FairValueConfig {
268 FairValueConfig {
269 enabled: true,
270 measurement_count: 25,
271 level1_percent: 0.60,
272 level2_percent: 0.30,
273 level3_percent: 0.10,
274 include_sensitivity_analysis: true,
275 }
276 }
277
278 #[test]
279 fn generates_requested_count() {
280 let mut gen = FairValueGenerator::new(42);
281 let cfg = fixture_config();
282 let m = gen.generate(
283 "C001",
284 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
285 "USD",
286 &cfg,
287 AccountingFramework::UsGaap,
288 );
289 assert_eq!(m.len(), cfg.measurement_count);
290 }
291
292 #[test]
293 fn hierarchy_distribution_is_approximately_configured() {
294 let mut gen = FairValueGenerator::new(7);
295 let mut cfg = fixture_config();
296 cfg.measurement_count = 500;
297 let m = gen.generate(
298 "C001",
299 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
300 "USD",
301 &cfg,
302 AccountingFramework::Ifrs,
303 );
304 let l1 = m
305 .iter()
306 .filter(|x| x.hierarchy_level == FairValueHierarchyLevel::Level1)
307 .count();
308 let l3 = m
309 .iter()
310 .filter(|x| x.hierarchy_level == FairValueHierarchyLevel::Level3)
311 .count();
312 assert!(l1 > l3, "Level 1 should dominate over Level 3");
313 assert!(l1 > 250, "Level 1 ~60 % of 500 = 300 expected, got {l1}");
314 }
315
316 #[test]
317 fn sensitivity_analysis_only_on_level3() {
318 let mut gen = FairValueGenerator::new(13);
319 let cfg = fixture_config();
320 let m = gen.generate(
321 "C001",
322 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
323 "USD",
324 &cfg,
325 AccountingFramework::Ifrs,
326 );
327 for entry in &m {
328 let has_sens = entry.sensitivity_analysis.is_some();
329 let is_l3 = entry.hierarchy_level == FairValueHierarchyLevel::Level3;
330 assert_eq!(
331 has_sens, is_l3,
332 "sensitivity analysis should be present iff level 3"
333 );
334 }
335 }
336
337 #[test]
338 fn level1_uses_market_approach() {
339 let mut gen = FairValueGenerator::new(5);
340 let cfg = fixture_config();
341 let m = gen.generate(
342 "C001",
343 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
344 "USD",
345 &cfg,
346 AccountingFramework::UsGaap,
347 );
348 for entry in &m {
349 if entry.hierarchy_level == FairValueHierarchyLevel::Level1 {
350 assert_eq!(
351 entry.valuation_technique,
352 ValuationTechnique::MarketApproach
353 );
354 }
355 }
356 }
357}