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