datasynth_generators/audit/
subsequent_event_generator.rs1use chrono::{Duration, NaiveDate};
8use datasynth_core::models::audit::subsequent_events::{
9 EventClassification, SubsequentEvent, SubsequentEventType,
10};
11use datasynth_core::utils::seeded_rng;
12use rand::Rng;
13use rand_chacha::ChaCha8Rng;
14use rust_decimal::Decimal;
15
16#[derive(Debug, Clone)]
18pub struct SubsequentEventGeneratorConfig {
19 pub max_events_per_period: u32,
21 pub discovery_window_days: (i64, i64),
23 pub adjusting_probability: f64,
25 pub financial_impact_range: (f64, f64),
27}
28
29impl Default for SubsequentEventGeneratorConfig {
30 fn default() -> Self {
31 Self {
32 max_events_per_period: 5,
33 discovery_window_days: (60, 90),
34 adjusting_probability: 0.40,
35 financial_impact_range: (10_000.0, 5_000_000.0),
36 }
37 }
38}
39
40pub struct SubsequentEventGenerator {
42 rng: ChaCha8Rng,
43 config: SubsequentEventGeneratorConfig,
44}
45
46impl SubsequentEventGenerator {
47 pub fn new(seed: u64) -> Self {
49 Self {
50 rng: seeded_rng(seed, 0x560),
51 config: SubsequentEventGeneratorConfig::default(),
52 }
53 }
54
55 pub fn with_config(seed: u64, config: SubsequentEventGeneratorConfig) -> Self {
57 Self {
58 rng: seeded_rng(seed, 0x560),
59 config,
60 }
61 }
62
63 pub fn generate_for_entity(
69 &mut self,
70 entity_code: &str,
71 period_end_date: NaiveDate,
72 ) -> Vec<SubsequentEvent> {
73 let count = self.rng.random_range(0..=self.config.max_events_per_period);
74 let window_end_days = self.rng.random_range(
75 self.config.discovery_window_days.0..=self.config.discovery_window_days.1,
76 );
77 let window_end = period_end_date + Duration::days(window_end_days);
78
79 let mut events = Vec::with_capacity(count as usize);
80
81 for _ in 0..count {
82 let event_offset_days = self.rng.random_range(1..=window_end_days);
84 let event_date = period_end_date + Duration::days(event_offset_days);
85
86 let discovery_offset = self
88 .rng
89 .random_range(0..=(window_end - event_date).num_days());
90 let discovery_date = event_date + Duration::days(discovery_offset);
91 let discovery_date = discovery_date.min(window_end);
92
93 let event_type = self.random_event_type();
94 let classification = if self.rng.random::<f64>() < self.config.adjusting_probability {
95 EventClassification::Adjusting
96 } else {
97 EventClassification::NonAdjusting
98 };
99
100 let description = self.describe_event(event_type, &classification, entity_code);
101
102 let mut event = SubsequentEvent::new(
103 entity_code,
104 event_date,
105 discovery_date,
106 event_type,
107 classification,
108 description,
109 );
110
111 let has_impact = matches!(classification, EventClassification::Adjusting)
113 || self.rng.random::<f64>() < 0.50;
114
115 if has_impact {
116 let impact_raw = self.rng.random_range(
117 self.config.financial_impact_range.0..=self.config.financial_impact_range.1,
118 );
119 let impact = Decimal::try_from(impact_raw).unwrap_or(Decimal::new(100_000, 0));
120 event = event.with_financial_impact(impact);
121 }
122
123 events.push(event);
124 }
125
126 events
127 }
128
129 pub fn generate_for_entities(
131 &mut self,
132 entity_codes: &[String],
133 period_end_date: NaiveDate,
134 ) -> Vec<SubsequentEvent> {
135 entity_codes
136 .iter()
137 .flat_map(|code| self.generate_for_entity(code, period_end_date))
138 .collect()
139 }
140
141 fn random_event_type(&mut self) -> SubsequentEventType {
142 match self.rng.random_range(0u8..8) {
143 0 => SubsequentEventType::LitigationSettlement,
144 1 => SubsequentEventType::CustomerBankruptcy,
145 2 => SubsequentEventType::AssetImpairment,
146 3 => SubsequentEventType::RestructuringAnnouncement,
147 4 => SubsequentEventType::NaturalDisaster,
148 5 => SubsequentEventType::RegulatoryChange,
149 6 => SubsequentEventType::MergerAnnouncement,
150 _ => SubsequentEventType::DividendDeclaration,
151 }
152 }
153
154 fn describe_event(
155 &self,
156 event_type: SubsequentEventType,
157 classification: &EventClassification,
158 entity_code: &str,
159 ) -> String {
160 let class_str = match classification {
161 EventClassification::Adjusting => "Adjusting event (IAS 10.8)",
162 EventClassification::NonAdjusting => "Non-adjusting event (IAS 10.21)",
163 };
164
165 let event_desc = match event_type {
166 SubsequentEventType::LitigationSettlement => {
167 format!(
168 "Litigation settlement reached for proceedings against {} that were pending \
169 at the balance sheet date.",
170 entity_code
171 )
172 }
173 SubsequentEventType::CustomerBankruptcy => {
174 format!(
175 "A significant customer of {} filed for bankruptcy after the period-end, \
176 indicating a recoverability issue at the balance sheet date.",
177 entity_code
178 )
179 }
180 SubsequentEventType::AssetImpairment => {
181 format!(
182 "Indicator of impairment identified for assets held by {} that existed \
183 at the balance sheet date.",
184 entity_code
185 )
186 }
187 SubsequentEventType::RestructuringAnnouncement => {
188 format!(
189 "{} announced a restructuring programme after the balance sheet date \
190 that was not planned at that date.",
191 entity_code
192 )
193 }
194 SubsequentEventType::NaturalDisaster => {
195 format!(
196 "A natural disaster occurred after the period-end, causing damage to \
197 assets operated by {}.",
198 entity_code
199 )
200 }
201 SubsequentEventType::RegulatoryChange => {
202 format!(
203 "A significant regulatory change was enacted after the period-end that \
204 affects operations of {}.",
205 entity_code
206 )
207 }
208 SubsequentEventType::MergerAnnouncement => {
209 format!(
210 "{} announced a merger or acquisition after the balance sheet date.",
211 entity_code
212 )
213 }
214 SubsequentEventType::DividendDeclaration => {
215 format!(
216 "The board of {} declared a dividend after the balance sheet date.",
217 entity_code
218 )
219 }
220 };
221
222 format!("{} — {}", class_str, event_desc)
223 }
224}
225
226#[cfg(test)]
227#[allow(clippy::unwrap_used)]
228mod tests {
229 use super::*;
230
231 fn period_end() -> NaiveDate {
232 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()
233 }
234
235 #[test]
236 fn test_event_count_within_bounds() {
237 let mut gen = SubsequentEventGenerator::new(42);
238 let events = gen.generate_for_entity("C001", period_end());
239 assert!(
240 events.len() <= 5,
241 "count should be 0..=5, got {}",
242 events.len()
243 );
244 }
245
246 #[test]
247 fn test_event_dates_after_period_end() {
248 let mut gen = SubsequentEventGenerator::new(99);
249 let pe = period_end();
250 for seed in [1u64, 2, 3, 4, 5] {
252 let mut g = SubsequentEventGenerator::new(seed);
253 let events = g.generate_for_entity("C001", pe);
254 for event in &events {
255 assert!(
256 event.event_date > pe,
257 "event_date {} should be after period_end {}",
258 event.event_date,
259 pe
260 );
261 }
262 }
263 }
264
265 #[test]
266 fn test_approximately_40_percent_adjusting() {
267 let mut gen = SubsequentEventGenerator::new(42);
268 let pe = period_end();
269 let mut total = 0usize;
270 let mut adjusting = 0usize;
271
272 for i in 0..200u64 {
274 let mut g = SubsequentEventGenerator::new(i);
275 let events = g.generate_for_entity("C001", pe);
276 total += events.len();
277 adjusting += events
278 .iter()
279 .filter(|e| matches!(e.classification, EventClassification::Adjusting))
280 .count();
281 }
282
283 if total > 0 {
284 let ratio = adjusting as f64 / total as f64;
285 assert!(
287 ratio >= 0.25 && ratio <= 0.60,
288 "adjusting ratio = {:.2}, expected ~0.40",
289 ratio
290 );
291 }
292 }
293
294 #[test]
295 fn test_adjusting_events_have_financial_impact() {
296 let mut gen = SubsequentEventGenerator::new(42);
297 let pe = period_end();
298 for seed in 0..50u64 {
299 let mut g = SubsequentEventGenerator::new(seed);
300 let events = g.generate_for_entity("C001", pe);
301 for event in events
302 .iter()
303 .filter(|e| matches!(e.classification, EventClassification::Adjusting))
304 {
305 assert!(
306 event.financial_impact.is_some(),
307 "adjusting event should have a financial impact"
308 );
309 }
310 }
311 }
312}