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;
15
16/// Configuration for subsequent event generation.
17#[derive(Debug, Clone)]
18pub struct SubsequentEventGeneratorConfig {
19    /// Maximum number of events per period-end (actual count is 0..=max)
20    pub max_events_per_period: u32,
21    /// Window in days after period-end during which events are discovered (min, max)
22    pub discovery_window_days: (i64, i64),
23    /// Probability that an event is adjusting (vs non-adjusting)
24    pub adjusting_probability: f64,
25    /// Range for financial impact (min, max) in reporting currency units
26    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
40/// Generator for ISA 560 / IAS 10 subsequent events.
41pub struct SubsequentEventGenerator {
42    rng: ChaCha8Rng,
43    config: SubsequentEventGeneratorConfig,
44}
45
46impl SubsequentEventGenerator {
47    /// Create a new generator with the given seed.
48    pub fn new(seed: u64) -> Self {
49        Self {
50            rng: seeded_rng(seed, 0x560),
51            config: SubsequentEventGeneratorConfig::default(),
52        }
53    }
54
55    /// Create a new generator with custom configuration.
56    pub fn with_config(seed: u64, config: SubsequentEventGeneratorConfig) -> Self {
57        Self {
58            rng: seeded_rng(seed, 0x560),
59            config,
60        }
61    }
62
63    /// Generate subsequent events for a single entity.
64    ///
65    /// # Arguments
66    /// * `entity_code` — Entity code for which events are generated
67    /// * `period_end_date` — Balance sheet date; events occur after this date
68    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            // Event date: 1 day after period-end up to the window end
83            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            // Discovery date: event date up to window end
87            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            // Adjusting events always have a financial impact; non-adjusting sometimes do.
112            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    /// Generate subsequent events for multiple entities.
130    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        // Run several times to get events
251        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        // Generate many events to get a stable ratio
273        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            // Allow wide tolerance: 25%–60%
286            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}