Skip to main content

datasynth_generators/audit/
subsequent_event_generator.rs

1//! Subsequent event generator per ISA 560 and IAS 10.
2//!
3//! Generates 0–5 subsequent events per period-end.  Events fall within the
4//! window from the period-end date to period-end + 60–90 days.  Approximately
5//! 40% of events are adjusting (IAS 10.8); 60% are non-adjusting (IAS 10.21).
6
7use 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/// Configuration for subsequent event generation.
18#[derive(Debug, Clone)]
19pub struct SubsequentEventGeneratorConfig {
20    /// Maximum number of events per period-end (actual count is 0..=max)
21    pub max_events_per_period: u32,
22    /// Window in days after period-end during which events are discovered (min, max)
23    pub discovery_window_days: (i64, i64),
24    /// Probability that an event is adjusting (vs non-adjusting)
25    pub adjusting_probability: f64,
26    /// Range for financial impact (min, max) in reporting currency units
27    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/// Input context for coherent subsequent event generation.
42///
43/// Provides real financial metrics and risk profile data so that generated
44/// events scale proportionally to the entity and reflect its risk landscape.
45#[derive(Debug, Clone)]
46pub struct SubsequentEventInput {
47    /// Total revenue for the period.
48    pub total_revenue: Decimal,
49    /// Total assets at period-end.
50    pub total_assets: Decimal,
51    /// Pre-tax income for the period (may be negative for loss-making entities).
52    pub pretax_income: Decimal,
53    /// Account areas assessed as high or moderate risk by the CRA.
54    pub high_risk_areas: Vec<String>,
55    /// Whether the going-concern assessment identified material uncertainty.
56    pub going_concern_doubt: bool,
57}
58
59/// Generator for ISA 560 / IAS 10 subsequent events.
60pub struct SubsequentEventGenerator {
61    rng: ChaCha8Rng,
62    config: SubsequentEventGeneratorConfig,
63}
64
65impl SubsequentEventGenerator {
66    /// Create a new generator with the given seed.
67    pub fn new(seed: u64) -> Self {
68        Self {
69            rng: seeded_rng(seed, 0x560),
70            config: SubsequentEventGeneratorConfig::default(),
71        }
72    }
73
74    /// Create a new generator with custom configuration.
75    pub fn with_config(seed: u64, config: SubsequentEventGeneratorConfig) -> Self {
76        Self {
77            rng: seeded_rng(seed, 0x560),
78            config,
79        }
80    }
81
82    /// Generate subsequent events for a single entity.
83    ///
84    /// # Arguments
85    /// * `entity_code` — Entity code for which events are generated
86    /// * `period_end_date` — Balance sheet date; events occur after this date
87    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            // Event date: 1 day after period-end up to the window end
106            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            // Discovery date: event date up to window end
110            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            // Adjusting events always have a financial impact; non-adjusting sometimes do.
135            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    /// Generate subsequent events for multiple entities.
158    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    /// Generate subsequent events with real financial context.
170    ///
171    /// Unlike [`generate_for_entity`], this method:
172    /// - Scales financial impact as 0.5–5% of the larger of `total_revenue` and
173    ///   `total_assets`, producing amounts proportional to entity size.
174    /// - Biases event type selection toward risk areas present in the CRA
175    ///   (e.g. more `AssetImpairment` when inventory/fixed-asset risk is high).
176    /// - Increases event count and adjusting probability when going-concern
177    ///   doubt exists.
178    /// - Favours `LitigationSettlement` / `RestructuringAnnouncement` when the
179    ///   entity is loss-making.
180    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        // --- Event count ---
192        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        // --- Adjusting probability ---
198        let adjusting_prob = if input.going_concern_doubt {
199            0.60
200        } else {
201            self.config.adjusting_probability
202        };
203
204        // --- Financial impact range (0.5–5% of larger of revenue / assets) ---
205        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); // floor to avoid degenerate tiny impacts
212        let impact_lo = base_f64 * 0.005;
213        let impact_hi = base_f64 * 0.05;
214
215        // --- Discovery window ---
216        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    /// Select event type with weights influenced by high-risk areas and loss
287    /// status.  Each event type starts with a base weight of 1.0; matches on
288    /// risk area or loss-making bump the weight upward.
289    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        // Boost asset impairment when inventory or fixed-asset risk is present.
313        if has_risk(&["inventory", "fixed asset", "ppe", "property"]) {
314            weights[2].1 += 3.0;
315        }
316        // Boost customer bankruptcy when receivable risk is present.
317        if has_risk(&["receivable", "trade receivable", "revenue"]) {
318            weights[1].1 += 3.0;
319        }
320        // Favour litigation / restructuring for loss-making entities.
321        if is_loss_making {
322            weights[0].1 += 2.0; // LitigationSettlement
323            weights[3].1 += 2.0; // RestructuringAnnouncement
324        }
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        // Run several times to get events
435        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        // Generate many events to get a stable ratio
457        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            // Allow wide tolerance: 25%–60%
470            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        // Generate many to get at least one with impact
493        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        // Impacts should be scaled to 0.5–5% of $350M (the larger base)
504        // i.e. $1.75M–$17.5M
505        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}