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/// Generator for ISA 560 / IAS 10 subsequent events.
42pub struct SubsequentEventGenerator {
43    rng: ChaCha8Rng,
44    config: SubsequentEventGeneratorConfig,
45}
46
47impl SubsequentEventGenerator {
48    /// Create a new generator with the given seed.
49    pub fn new(seed: u64) -> Self {
50        Self {
51            rng: seeded_rng(seed, 0x560),
52            config: SubsequentEventGeneratorConfig::default(),
53        }
54    }
55
56    /// Create a new generator with custom configuration.
57    pub fn with_config(seed: u64, config: SubsequentEventGeneratorConfig) -> Self {
58        Self {
59            rng: seeded_rng(seed, 0x560),
60            config,
61        }
62    }
63
64    /// Generate subsequent events for a single entity.
65    ///
66    /// # Arguments
67    /// * `entity_code` — Entity code for which events are generated
68    /// * `period_end_date` — Balance sheet date; events occur after this date
69    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            // Event date: 1 day after period-end up to the window end
88            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            // Discovery date: event date up to window end
92            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            // Adjusting events always have a financial impact; non-adjusting sometimes do.
117            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    /// Generate subsequent events for multiple entities.
140    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        // Run several times to get events
261        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        // Generate many events to get a stable ratio
283        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            // Allow wide tolerance: 25%–60%
296            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}