1use 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
41#[derive(Debug, Clone)]
46pub struct SubsequentEventInput {
47 pub total_revenue: Decimal,
49 pub total_assets: Decimal,
51 pub pretax_income: Decimal,
53 pub high_risk_areas: Vec<String>,
55 pub going_concern_doubt: bool,
57}
58
59pub struct SubsequentEventGenerator {
61 rng: ChaCha8Rng,
62 config: SubsequentEventGeneratorConfig,
63}
64
65impl SubsequentEventGenerator {
66 pub fn new(seed: u64) -> Self {
68 Self {
69 rng: seeded_rng(seed, 0x560),
70 config: SubsequentEventGeneratorConfig::default(),
71 }
72 }
73
74 pub fn with_config(seed: u64, config: SubsequentEventGeneratorConfig) -> Self {
76 Self {
77 rng: seeded_rng(seed, 0x560),
78 config,
79 }
80 }
81
82 pub fn generate_for_entity(
88 &mut self,
89 entity_code: &str,
90 period_end_date: NaiveDate,
91 ) -> Vec<SubsequentEvent> {
92 info!(
93 "Generating subsequent events for entity {} period-end {}",
94 entity_code, period_end_date
95 );
96 let count = self.rng.random_range(0..=self.config.max_events_per_period);
97 let window_end_days = self.rng.random_range(
98 self.config.discovery_window_days.0..=self.config.discovery_window_days.1,
99 );
100 let window_end = period_end_date + Duration::days(window_end_days);
101
102 let mut events = Vec::with_capacity(count as usize);
103
104 for _ in 0..count {
105 let event_offset_days = self.rng.random_range(1..=window_end_days);
107 let event_date = period_end_date + Duration::days(event_offset_days);
108
109 let discovery_offset = self
111 .rng
112 .random_range(0..=(window_end - event_date).num_days());
113 let discovery_date = event_date + Duration::days(discovery_offset);
114 let discovery_date = discovery_date.min(window_end);
115
116 let event_type = self.random_event_type();
117 let classification = if self.rng.random::<f64>() < self.config.adjusting_probability {
118 EventClassification::Adjusting
119 } else {
120 EventClassification::NonAdjusting
121 };
122
123 let description = self.describe_event(event_type, &classification, entity_code);
124
125 let mut event = SubsequentEvent::new(
126 entity_code,
127 event_date,
128 discovery_date,
129 event_type,
130 classification,
131 description,
132 );
133
134 let has_impact = matches!(classification, EventClassification::Adjusting)
136 || self.rng.random::<f64>() < 0.50;
137
138 if has_impact {
139 let impact_raw = self.rng.random_range(
140 self.config.financial_impact_range.0..=self.config.financial_impact_range.1,
141 );
142 let impact = Decimal::try_from(impact_raw).unwrap_or(Decimal::new(100_000, 0));
143 event = event.with_financial_impact(impact);
144 }
145
146 events.push(event);
147 }
148
149 info!(
150 "Generated {} subsequent events for entity {}",
151 events.len(),
152 entity_code
153 );
154 events
155 }
156
157 pub fn generate_for_entities(
159 &mut self,
160 entity_codes: &[String],
161 period_end_date: NaiveDate,
162 ) -> Vec<SubsequentEvent> {
163 entity_codes
164 .iter()
165 .flat_map(|code| self.generate_for_entity(code, period_end_date))
166 .collect()
167 }
168
169 pub fn generate_for_entity_with_context(
181 &mut self,
182 entity_code: &str,
183 period_end_date: NaiveDate,
184 input: &SubsequentEventInput,
185 ) -> Vec<SubsequentEvent> {
186 info!(
187 "Generating context-aware subsequent events for entity {} period-end {}",
188 entity_code, period_end_date
189 );
190
191 let mut count = self.rng.random_range(0..=self.config.max_events_per_period);
193 if input.going_concern_doubt {
194 count += self.rng.random_range(1..=2);
195 }
196
197 let adjusting_prob = if input.going_concern_doubt {
199 0.60
200 } else {
201 self.config.adjusting_probability
202 };
203
204 let base = std::cmp::max(input.total_revenue, input.total_assets);
206 let base_f64 = base
207 .to_string()
208 .parse::<f64>()
209 .unwrap_or(1_000_000.0)
210 .abs()
211 .max(100_000.0); let impact_lo = base_f64 * 0.005;
213 let impact_hi = base_f64 * 0.05;
214
215 let window_end_days = self.rng.random_range(
217 self.config.discovery_window_days.0..=self.config.discovery_window_days.1,
218 );
219 let window_end = period_end_date + Duration::days(window_end_days);
220
221 let mut events = Vec::with_capacity(count as usize);
222
223 for _ in 0..count {
224 let event_offset_days = self.rng.random_range(1..=window_end_days);
225 let event_date = period_end_date + Duration::days(event_offset_days);
226
227 let discovery_offset = self
228 .rng
229 .random_range(0..=(window_end - event_date).num_days());
230 let discovery_date = (event_date + Duration::days(discovery_offset)).min(window_end);
231
232 let event_type = self.weighted_event_type(
233 &input.high_risk_areas,
234 input.pretax_income.is_sign_negative(),
235 );
236 let classification = if self.rng.random::<f64>() < adjusting_prob {
237 EventClassification::Adjusting
238 } else {
239 EventClassification::NonAdjusting
240 };
241
242 let description = self.describe_event(event_type, &classification, entity_code);
243
244 let mut event = SubsequentEvent::new(
245 entity_code,
246 event_date,
247 discovery_date,
248 event_type,
249 classification,
250 description,
251 );
252
253 let has_impact = matches!(classification, EventClassification::Adjusting)
254 || self.rng.random::<f64>() < 0.50;
255
256 if has_impact {
257 let impact_raw = self.rng.random_range(impact_lo..=impact_hi);
258 let impact = Decimal::try_from(impact_raw).unwrap_or(Decimal::new(100_000, 0));
259 event = event.with_financial_impact(impact);
260 }
261
262 events.push(event);
263 }
264
265 info!(
266 "Generated {} context-aware subsequent events for entity {}",
267 events.len(),
268 entity_code
269 );
270 events
271 }
272
273 fn random_event_type(&mut self) -> SubsequentEventType {
274 match self.rng.random_range(0u8..8) {
275 0 => SubsequentEventType::LitigationSettlement,
276 1 => SubsequentEventType::CustomerBankruptcy,
277 2 => SubsequentEventType::AssetImpairment,
278 3 => SubsequentEventType::RestructuringAnnouncement,
279 4 => SubsequentEventType::NaturalDisaster,
280 5 => SubsequentEventType::RegulatoryChange,
281 6 => SubsequentEventType::MergerAnnouncement,
282 _ => SubsequentEventType::DividendDeclaration,
283 }
284 }
285
286 fn weighted_event_type(
290 &mut self,
291 high_risk_areas: &[String],
292 is_loss_making: bool,
293 ) -> SubsequentEventType {
294 let has_risk = |keywords: &[&str]| -> bool {
295 high_risk_areas.iter().any(|area| {
296 let lower = area.to_lowercase();
297 keywords.iter().any(|kw| lower.contains(kw))
298 })
299 };
300
301 let mut weights: Vec<(SubsequentEventType, f64)> = vec![
302 (SubsequentEventType::LitigationSettlement, 1.0),
303 (SubsequentEventType::CustomerBankruptcy, 1.0),
304 (SubsequentEventType::AssetImpairment, 1.0),
305 (SubsequentEventType::RestructuringAnnouncement, 1.0),
306 (SubsequentEventType::NaturalDisaster, 1.0),
307 (SubsequentEventType::RegulatoryChange, 1.0),
308 (SubsequentEventType::MergerAnnouncement, 1.0),
309 (SubsequentEventType::DividendDeclaration, 1.0),
310 ];
311
312 if has_risk(&["inventory", "fixed asset", "ppe", "property"]) {
314 weights[2].1 += 3.0;
315 }
316 if has_risk(&["receivable", "trade receivable", "revenue"]) {
318 weights[1].1 += 3.0;
319 }
320 if is_loss_making {
322 weights[0].1 += 2.0; weights[3].1 += 2.0; }
325
326 let total: f64 = weights.iter().map(|(_, w)| w).sum();
327 let r: f64 = self.rng.random::<f64>() * total;
328 let mut cumulative = 0.0;
329 for (et, w) in &weights {
330 cumulative += w;
331 if r < cumulative {
332 return *et;
333 }
334 }
335 SubsequentEventType::DividendDeclaration
336 }
337
338 fn describe_event(
339 &self,
340 event_type: SubsequentEventType,
341 classification: &EventClassification,
342 entity_code: &str,
343 ) -> String {
344 let class_str = match classification {
345 EventClassification::Adjusting => "Adjusting event (IAS 10.8)",
346 EventClassification::NonAdjusting => "Non-adjusting event (IAS 10.21)",
347 };
348
349 let event_desc = match event_type {
350 SubsequentEventType::LitigationSettlement => {
351 format!(
352 "Litigation settlement reached for proceedings against {} that were pending \
353 at the balance sheet date.",
354 entity_code
355 )
356 }
357 SubsequentEventType::CustomerBankruptcy => {
358 format!(
359 "A significant customer of {} filed for bankruptcy after the period-end, \
360 indicating a recoverability issue at the balance sheet date.",
361 entity_code
362 )
363 }
364 SubsequentEventType::AssetImpairment => {
365 format!(
366 "Indicator of impairment identified for assets held by {} that existed \
367 at the balance sheet date.",
368 entity_code
369 )
370 }
371 SubsequentEventType::RestructuringAnnouncement => {
372 format!(
373 "{} announced a restructuring programme after the balance sheet date \
374 that was not planned at that date.",
375 entity_code
376 )
377 }
378 SubsequentEventType::NaturalDisaster => {
379 format!(
380 "A natural disaster occurred after the period-end, causing damage to \
381 assets operated by {}.",
382 entity_code
383 )
384 }
385 SubsequentEventType::RegulatoryChange => {
386 format!(
387 "A significant regulatory change was enacted after the period-end that \
388 affects operations of {}.",
389 entity_code
390 )
391 }
392 SubsequentEventType::MergerAnnouncement => {
393 format!(
394 "{} announced a merger or acquisition after the balance sheet date.",
395 entity_code
396 )
397 }
398 SubsequentEventType::DividendDeclaration => {
399 format!(
400 "The board of {} declared a dividend after the balance sheet date.",
401 entity_code
402 )
403 }
404 };
405
406 format!("{} — {}", class_str, event_desc)
407 }
408}
409
410#[cfg(test)]
411#[allow(clippy::unwrap_used)]
412mod tests {
413 use super::*;
414
415 fn period_end() -> NaiveDate {
416 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()
417 }
418
419 #[test]
420 fn test_event_count_within_bounds() {
421 let mut gen = SubsequentEventGenerator::new(42);
422 let events = gen.generate_for_entity("C001", period_end());
423 assert!(
424 events.len() <= 5,
425 "count should be 0..=5, got {}",
426 events.len()
427 );
428 }
429
430 #[test]
431 fn test_event_dates_after_period_end() {
432 let _gen = SubsequentEventGenerator::new(99);
433 let pe = period_end();
434 for seed in [1u64, 2, 3, 4, 5] {
436 let mut g = SubsequentEventGenerator::new(seed);
437 let events = g.generate_for_entity("C001", pe);
438 for event in &events {
439 assert!(
440 event.event_date > pe,
441 "event_date {} should be after period_end {}",
442 event.event_date,
443 pe
444 );
445 }
446 }
447 }
448
449 #[test]
450 fn test_approximately_40_percent_adjusting() {
451 let _gen = SubsequentEventGenerator::new(42);
452 let pe = period_end();
453 let mut total = 0usize;
454 let mut adjusting = 0usize;
455
456 for i in 0..200u64 {
458 let mut g = SubsequentEventGenerator::new(i);
459 let events = g.generate_for_entity("C001", pe);
460 total += events.len();
461 adjusting += events
462 .iter()
463 .filter(|e| matches!(e.classification, EventClassification::Adjusting))
464 .count();
465 }
466
467 if total > 0 {
468 let ratio = adjusting as f64 / total as f64;
469 assert!(
471 ratio >= 0.25 && ratio <= 0.60,
472 "adjusting ratio = {:.2}, expected ~0.40",
473 ratio
474 );
475 }
476 }
477
478 fn default_input() -> SubsequentEventInput {
479 SubsequentEventInput {
480 total_revenue: Decimal::new(200_000_000, 0),
481 total_assets: Decimal::new(350_000_000, 0),
482 pretax_income: Decimal::new(15_000_000, 0),
483 high_risk_areas: vec![],
484 going_concern_doubt: false,
485 }
486 }
487
488 #[test]
489 fn test_context_aware_scales_impact() {
490 let _gen = SubsequentEventGenerator::new(42);
491 let input = default_input();
492 let mut impacts = Vec::new();
494 for seed in 0..50u64 {
495 let mut g = SubsequentEventGenerator::new(seed);
496 let events = g.generate_for_entity_with_context("C001", period_end(), &input);
497 for e in &events {
498 if let Some(impact) = e.financial_impact {
499 impacts.push(impact);
500 }
501 }
502 }
503 let lower = Decimal::new(1_750_000, 0);
506 let upper = Decimal::new(17_500_000, 0);
507 for impact in &impacts {
508 assert!(
509 *impact >= lower * Decimal::new(95, 2) && *impact <= upper * Decimal::new(105, 2),
510 "impact {} should be roughly between {} and {}",
511 impact,
512 lower,
513 upper
514 );
515 }
516 }
517
518 #[test]
519 fn test_going_concern_increases_events() {
520 let mut counts_no_gc = Vec::new();
521 let mut counts_gc = Vec::new();
522 for seed in 0..100u64 {
523 let mut g1 = SubsequentEventGenerator::new(seed);
524 let input_no_gc = default_input();
525 let events = g1.generate_for_entity_with_context("C001", period_end(), &input_no_gc);
526 counts_no_gc.push(events.len());
527
528 let mut g2 = SubsequentEventGenerator::new(seed);
529 let input_gc = SubsequentEventInput {
530 going_concern_doubt: true,
531 ..default_input()
532 };
533 let events = g2.generate_for_entity_with_context("C001", period_end(), &input_gc);
534 counts_gc.push(events.len());
535 }
536 let avg_no_gc: f64 = counts_no_gc.iter().sum::<usize>() as f64 / counts_no_gc.len() as f64;
537 let avg_gc: f64 = counts_gc.iter().sum::<usize>() as f64 / counts_gc.len() as f64;
538 assert!(
539 avg_gc > avg_no_gc,
540 "going concern should produce more events on average ({} vs {})",
541 avg_gc,
542 avg_no_gc
543 );
544 }
545
546 #[test]
547 fn test_adjusting_events_have_financial_impact() {
548 let _gen = SubsequentEventGenerator::new(42);
549 let pe = period_end();
550 for seed in 0..50u64 {
551 let mut g = SubsequentEventGenerator::new(seed);
552 let events = g.generate_for_entity("C001", pe);
553 for event in events
554 .iter()
555 .filter(|e| matches!(e.classification, EventClassification::Adjusting))
556 {
557 assert!(
558 event.financial_impact.is_some(),
559 "adjusting event should have a financial impact"
560 );
561 }
562 }
563 }
564}