Skip to main content

datasynth_generators/standards/
fair_value_generator.rs

1//! Fair Value Measurement Generator (IFRS 13 / ASC 820).
2//!
3//! Generates `FairValueMeasurement` records across the three-level
4//! hierarchy:
5//!
6//! - **Level 1**: Quoted prices in active markets (exchange-traded
7//!   securities, commodities with daily quotes).
8//! - **Level 2**: Observable inputs other than Level 1 prices
9//!   (similar assets in less active markets, yield curves,
10//!   interest-rate-derived valuations).
11//! - **Level 3**: Unobservable inputs — discounted cash flow with
12//!   internal projections, privately-held investments. Optionally
13//!   carries a `SensitivityAnalysis` when `config.include_sensitivity_analysis`
14//!   is `true`.
15//!
16//! The distribution across levels is driven by
17//! `config.{level1,level2,level3}_percent`. Valuation technique is
18//! chosen so that Level 1 uses Market Approach, Level 2 uses a mix
19//! of Market / Income, and Level 3 defaults to Income Approach.
20
21use 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
35/// Generator for fair value measurements.
36pub struct FairValueGenerator {
37    rng: ChaCha8Rng,
38}
39
40impl FairValueGenerator {
41    /// Create a new generator with the given seed.
42    pub fn new(seed: u64) -> Self {
43        Self {
44            rng: seeded_rng(seed, 0),
45        }
46    }
47
48    /// Generate `config.measurement_count` fair value measurements.
49    ///
50    /// # Arguments
51    ///
52    /// * `company_code` — Entity code to stamp on the item_id prefix.
53    /// * `measurement_date` — The balance-sheet date of the measurement.
54    /// * `currency` — ISO 4217 currency code.
55    /// * `config` — Fair-value section from the accounting standards config.
56    /// * `framework` — Accounting framework (US GAAP / IFRS / Dual).
57    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            // Attach observable inputs for Level 1/2; unobservable for Level 3.
100            self.attach_inputs(&mut measurement);
101
102            // Sensitivity analysis only for Level 3 when requested.
103            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        // Level 1 favours trading/AFS securities; Level 2 covers
129        // derivatives + pension plan assets; Level 3 leans toward
130        // investment property, contingent consideration, private
131        // instruments.
132        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        // ~85 % recurring is the typical mix for portfolio + derivatives
179        // valuations; non-recurring is mostly impairment / held-for-sale.
180        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        // Shift the primary input ±50 bps → ~±10 % fair value swing.
249        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}