1use chrono::NaiveDate;
14use datasynth_core::models::audit::going_concern::{
15 GoingConcernAssessment, GoingConcernIndicator, GoingConcernIndicatorType, GoingConcernSeverity,
16};
17use datasynth_core::utils::seeded_rng;
18use rand::Rng;
19use rand_chacha::ChaCha8Rng;
20use rust_decimal::Decimal;
21use rust_decimal_macros::dec;
22
23#[derive(Debug, Clone)]
29pub struct GoingConcernGeneratorConfig {
30 pub clean_probability: f64,
32 pub mild_probability: f64,
34 }
36
37impl Default for GoingConcernGeneratorConfig {
38 fn default() -> Self {
39 Self {
40 clean_probability: 0.90,
41 mild_probability: 0.08,
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
55pub struct GoingConcernInput {
56 pub entity_code: String,
58 pub net_income: Decimal,
60 pub working_capital: Decimal,
62 pub operating_cash_flow: Decimal,
64 pub total_debt: Decimal,
66 pub assessment_date: NaiveDate,
68}
69
70pub struct GoingConcernGenerator {
76 rng: ChaCha8Rng,
77 config: GoingConcernGeneratorConfig,
78}
79
80impl GoingConcernGenerator {
81 pub fn new(seed: u64) -> Self {
83 Self {
84 rng: seeded_rng(seed, 0x570), config: GoingConcernGeneratorConfig::default(),
86 }
87 }
88
89 pub fn with_config(seed: u64, config: GoingConcernGeneratorConfig) -> Self {
91 Self {
92 rng: seeded_rng(seed, 0x570),
93 config,
94 }
95 }
96
97 pub fn generate_for_entity(
105 &mut self,
106 entity_code: &str,
107 assessment_date: NaiveDate,
108 period: &str,
109 ) -> GoingConcernAssessment {
110 let roll: f64 = self.rng.random();
111 let indicator_count = if roll < self.config.clean_probability {
112 0
113 } else if roll < self.config.clean_probability + self.config.mild_probability {
114 self.rng.random_range(1u32..=2)
115 } else {
116 self.rng.random_range(3u32..=5)
117 };
118
119 let indicators = (0..indicator_count)
120 .map(|_| self.random_indicator(entity_code))
121 .collect::<Vec<_>>();
122
123 let management_plans = if indicators.is_empty() {
124 Vec::new()
125 } else {
126 self.management_plans(indicators.len())
127 };
128
129 GoingConcernAssessment {
130 entity_code: entity_code.to_string(),
131 assessment_date,
132 assessment_period: period.to_string(),
133 indicators,
134 management_plans,
135 auditor_conclusion: Default::default(), material_uncertainty_exists: false,
137 }
138 .conclude_from_indicators()
139 }
140
141 pub fn generate_for_entities(
143 &mut self,
144 entity_codes: &[String],
145 assessment_date: NaiveDate,
146 period: &str,
147 ) -> Vec<GoingConcernAssessment> {
148 entity_codes
149 .iter()
150 .map(|code| self.generate_for_entity(code, assessment_date, period))
151 .collect()
152 }
153
154 pub fn generate_for_entity_with_input(
170 &mut self,
171 input: &GoingConcernInput,
172 period: &str,
173 ) -> GoingConcernAssessment {
174 let entity_code = input.entity_code.as_str();
175 let mut indicators: Vec<GoingConcernIndicator> = Vec::new();
176
177 if input.net_income < Decimal::ZERO {
180 let loss = input.net_income.abs();
181 let threshold = loss * dec!(1.50);
182 indicators.push(GoingConcernIndicator {
183 indicator_type: GoingConcernIndicatorType::RecurringOperatingLosses,
184 severity: if loss > Decimal::from(1_000_000i64) {
185 GoingConcernSeverity::High
186 } else if loss > Decimal::from(100_000i64) {
187 GoingConcernSeverity::Medium
188 } else {
189 GoingConcernSeverity::Low
190 },
191 description: self.describe_indicator(
192 GoingConcernIndicatorType::RecurringOperatingLosses,
193 entity_code,
194 ),
195 quantitative_measure: Some(loss),
196 threshold: Some(threshold),
197 });
198 }
199
200 if input.working_capital < Decimal::ZERO {
201 let deficit = input.working_capital.abs();
202 indicators.push(GoingConcernIndicator {
203 indicator_type: GoingConcernIndicatorType::WorkingCapitalDeficiency,
204 severity: if deficit > Decimal::from(5_000_000i64) {
205 GoingConcernSeverity::High
206 } else if deficit > Decimal::from(500_000i64) {
207 GoingConcernSeverity::Medium
208 } else {
209 GoingConcernSeverity::Low
210 },
211 description: self.describe_indicator(
212 GoingConcernIndicatorType::WorkingCapitalDeficiency,
213 entity_code,
214 ),
215 quantitative_measure: Some(deficit),
216 threshold: Some(Decimal::ZERO),
217 });
218 }
219
220 if input.operating_cash_flow < Decimal::ZERO {
221 let outflow = input.operating_cash_flow.abs();
222 indicators.push(GoingConcernIndicator {
223 indicator_type: GoingConcernIndicatorType::NegativeOperatingCashFlow,
224 severity: if outflow > Decimal::from(2_000_000i64) {
225 GoingConcernSeverity::High
226 } else if outflow > Decimal::from(200_000i64) {
227 GoingConcernSeverity::Medium
228 } else {
229 GoingConcernSeverity::Low
230 },
231 description: self.describe_indicator(
232 GoingConcernIndicatorType::NegativeOperatingCashFlow,
233 entity_code,
234 ),
235 quantitative_measure: Some(outflow),
236 threshold: Some(Decimal::ZERO),
237 });
238 }
239
240 if indicators.len() < 3 {
244 let roll: f64 = self.rng.random();
245 if roll < 0.05 {
247 let extra = self.random_non_financial_indicator(entity_code);
248 indicators.push(extra);
249 }
250 }
251
252 let management_plans = if indicators.is_empty() {
253 Vec::new()
254 } else {
255 self.management_plans(indicators.len())
256 };
257
258 GoingConcernAssessment {
259 entity_code: entity_code.to_string(),
260 assessment_date: input.assessment_date,
261 assessment_period: period.to_string(),
262 indicators,
263 management_plans,
264 auditor_conclusion: Default::default(),
265 material_uncertainty_exists: false,
266 }
267 .conclude_from_indicators()
268 }
269
270 pub fn generate_for_entities_with_inputs(
274 &mut self,
275 entity_codes: &[String],
276 inputs: &[GoingConcernInput],
277 assessment_date: NaiveDate,
278 period: &str,
279 ) -> Vec<GoingConcernAssessment> {
280 entity_codes
281 .iter()
282 .map(|code| {
283 if let Some(input) = inputs.iter().find(|i| &i.entity_code == code) {
284 self.generate_for_entity_with_input(input, period)
285 } else {
286 self.generate_for_entity(code, assessment_date, period)
287 }
288 })
289 .collect()
290 }
291
292 fn random_non_financial_indicator(&mut self, entity_code: &str) -> GoingConcernIndicator {
298 let indicator_type = match self.rng.random_range(0u8..5) {
300 0 => GoingConcernIndicatorType::DebtCovenantBreach,
301 1 => GoingConcernIndicatorType::LossOfKeyCustomer,
302 2 => GoingConcernIndicatorType::RegulatoryAction,
303 3 => GoingConcernIndicatorType::LitigationExposure,
304 _ => GoingConcernIndicatorType::InabilityToObtainFinancing,
305 };
306 let severity = self.random_severity();
307 let description = self.describe_indicator(indicator_type, entity_code);
308 let (measure, threshold) = self.quantitative_measures(indicator_type);
309 GoingConcernIndicator {
310 indicator_type,
311 severity,
312 description,
313 quantitative_measure: Some(measure),
314 threshold: Some(threshold),
315 }
316 }
317
318 fn random_indicator(&mut self, entity_code: &str) -> GoingConcernIndicator {
319 let indicator_type = self.random_indicator_type();
320 let severity = self.random_severity();
321
322 let description = self.describe_indicator(indicator_type, entity_code);
323 let (measure, threshold) = self.quantitative_measures(indicator_type);
324
325 GoingConcernIndicator {
326 indicator_type,
327 severity,
328 description,
329 quantitative_measure: Some(measure),
330 threshold: Some(threshold),
331 }
332 }
333
334 fn random_indicator_type(&mut self) -> GoingConcernIndicatorType {
335 match self.rng.random_range(0u8..8) {
336 0 => GoingConcernIndicatorType::RecurringOperatingLosses,
337 1 => GoingConcernIndicatorType::NegativeOperatingCashFlow,
338 2 => GoingConcernIndicatorType::WorkingCapitalDeficiency,
339 3 => GoingConcernIndicatorType::DebtCovenantBreach,
340 4 => GoingConcernIndicatorType::LossOfKeyCustomer,
341 5 => GoingConcernIndicatorType::RegulatoryAction,
342 6 => GoingConcernIndicatorType::LitigationExposure,
343 _ => GoingConcernIndicatorType::InabilityToObtainFinancing,
344 }
345 }
346
347 fn random_severity(&mut self) -> GoingConcernSeverity {
348 match self.rng.random_range(0u8..3) {
349 0 => GoingConcernSeverity::Low,
350 1 => GoingConcernSeverity::Medium,
351 _ => GoingConcernSeverity::High,
352 }
353 }
354
355 fn describe_indicator(
356 &self,
357 indicator_type: GoingConcernIndicatorType,
358 entity_code: &str,
359 ) -> String {
360 match indicator_type {
361 GoingConcernIndicatorType::RecurringOperatingLosses => format!(
362 "{} has reported operating losses in each of the past three financial years, \
363 indicating structural challenges in its core business model.",
364 entity_code
365 ),
366 GoingConcernIndicatorType::NegativeOperatingCashFlow => format!(
367 "{} generated negative operating cash flows during the current period, \
368 requiring reliance on financing activities to fund operations.",
369 entity_code
370 ),
371 GoingConcernIndicatorType::WorkingCapitalDeficiency => format!(
372 "{} has a working capital deficiency, with current liabilities exceeding \
373 current assets, potentially impairing its ability to meet short-term obligations.",
374 entity_code
375 ),
376 GoingConcernIndicatorType::DebtCovenantBreach => format!(
377 "{} has breached one or more financial covenants in its debt agreements, \
378 which may result in lenders demanding immediate repayment.",
379 entity_code
380 ),
381 GoingConcernIndicatorType::LossOfKeyCustomer => format!(
382 "{} lost a major customer during the period, representing a material decline \
383 in projected revenue and profitability.",
384 entity_code
385 ),
386 GoingConcernIndicatorType::RegulatoryAction => format!(
387 "{} is subject to regulatory action or investigation that may threaten \
388 its licence to operate or result in material financial penalties.",
389 entity_code
390 ),
391 GoingConcernIndicatorType::LitigationExposure => format!(
392 "{} faces pending legal proceedings with a potential financial exposure \
393 that could be material relative to its net assets.",
394 entity_code
395 ),
396 GoingConcernIndicatorType::InabilityToObtainFinancing => format!(
397 "{} has been unable to secure new credit facilities or roll over existing \
398 financing arrangements, creating a liquidity risk.",
399 entity_code
400 ),
401 }
402 }
403
404 fn quantitative_measures(
406 &mut self,
407 indicator_type: GoingConcernIndicatorType,
408 ) -> (Decimal, Decimal) {
409 match indicator_type {
410 GoingConcernIndicatorType::RecurringOperatingLosses => {
411 let loss = Decimal::new(self.rng.random_range(100_000i64..=5_000_000), 0);
413 let threshold = loss * Decimal::new(150, 2); (loss, threshold)
415 }
416 GoingConcernIndicatorType::NegativeOperatingCashFlow => {
417 let outflow = Decimal::new(self.rng.random_range(50_000i64..=2_000_000), 0);
418 let threshold = Decimal::ZERO;
419 (outflow, threshold)
420 }
421 GoingConcernIndicatorType::WorkingCapitalDeficiency => {
422 let deficit = Decimal::new(self.rng.random_range(100_000i64..=10_000_000), 0);
423 let threshold = Decimal::ZERO;
424 (deficit, threshold)
425 }
426 GoingConcernIndicatorType::DebtCovenantBreach => {
427 let actual = Decimal::new(self.rng.random_range(350i64..=600), 2); let covenant = Decimal::new(300, 2); (actual, covenant)
431 }
432 GoingConcernIndicatorType::LossOfKeyCustomer => {
433 let pct = Decimal::new(self.rng.random_range(15i64..=40), 2); let threshold = Decimal::new(10, 2); (pct, threshold)
437 }
438 GoingConcernIndicatorType::RegulatoryAction
439 | GoingConcernIndicatorType::LitigationExposure
440 | GoingConcernIndicatorType::InabilityToObtainFinancing => {
441 let exposure = Decimal::new(self.rng.random_range(500_000i64..=20_000_000), 0);
442 let threshold = Decimal::new(self.rng.random_range(1_000_000i64..=5_000_000), 0);
443 (exposure, threshold)
444 }
445 }
446 }
447
448 fn management_plans(&mut self, indicator_count: usize) -> Vec<String> {
449 let all_plans = [
450 "Management has engaged external financial advisors to explore refinancing options \
451 and extend the maturity of existing credit facilities.",
452 "A detailed cash flow management plan has been approved by the board, including \
453 targeted working capital improvements and deferral of non-essential capital expenditure.",
454 "Management is actively pursuing new customer acquisition initiatives and has \
455 secured letters of intent from prospective strategic customers.",
456 "The board has committed to a capital injection of additional equity through \
457 a rights issue to be completed within 90 days of the balance sheet date.",
458 "Management is in advanced negotiations with existing lenders to obtain covenant \
459 waivers and to restructure the terms of outstanding debt facilities.",
460 "A formal cost reduction programme has been announced, targeting annualised \
461 savings sufficient to return the entity to operating profitability within 12 months.",
462 "The entity has received a legally binding letter of support from its parent \
463 company confirming financial support for a minimum of 12 months.",
464 ];
465
466 let n_plans = indicator_count.clamp(1, 3);
467 let start = self
468 .rng
469 .random_range(0..all_plans.len().saturating_sub(n_plans));
470 all_plans[start..start + n_plans]
471 .iter()
472 .map(|s| s.to_string())
473 .collect()
474 }
475}
476
477#[cfg(test)]
482#[allow(clippy::unwrap_used)]
483mod tests {
484 use super::*;
485 use datasynth_core::models::audit::going_concern::GoingConcernConclusion;
486
487 fn assessment_date() -> NaiveDate {
488 NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()
489 }
490
491 #[test]
492 fn test_generates_one_assessment_per_entity() {
493 let entities = vec!["C001".to_string(), "C002".to_string(), "C003".to_string()];
494 let mut gen = GoingConcernGenerator::new(42);
495 let assessments = gen.generate_for_entities(&entities, assessment_date(), "FY2024");
496 assert_eq!(assessments.len(), entities.len());
497 }
498
499 #[test]
500 fn test_approximately_90_percent_clean() {
501 let mut total = 0usize;
502 let mut clean = 0usize;
503 for seed in 0..200u64 {
504 let mut gen = GoingConcernGenerator::new(seed);
505 let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
506 total += 1;
507 if matches!(
508 a.auditor_conclusion,
509 GoingConcernConclusion::NoMaterialUncertainty
510 ) {
511 clean += 1;
512 }
513 }
514 let ratio = clean as f64 / total as f64;
515 assert!(
516 ratio >= 0.80 && ratio <= 0.98,
517 "Clean ratio = {:.2}, expected ~0.90",
518 ratio
519 );
520 }
521
522 #[test]
523 fn test_conclusion_matches_indicator_count() {
524 let mut gen = GoingConcernGenerator::new(42);
525 for seed in 0..100u64 {
526 let mut g = GoingConcernGenerator::new(seed);
527 let a = g.generate_for_entity("C001", assessment_date(), "FY2024");
528 let n = a.indicators.len();
529 match a.auditor_conclusion {
530 GoingConcernConclusion::NoMaterialUncertainty => {
531 assert_eq!(n, 0, "seed={}: clean but has {} indicators", seed, n);
532 }
533 GoingConcernConclusion::MaterialUncertaintyExists => {
534 assert!(
535 n >= 1 && n <= 2,
536 "seed={}: MaterialUncertainty but {} indicators",
537 seed,
538 n
539 );
540 }
541 GoingConcernConclusion::GoingConcernDoubt => {
542 assert!(n >= 3, "seed={}: Doubt but only {} indicators", seed, n);
543 }
544 }
545 }
546 drop(gen);
547 }
548
549 #[test]
550 fn test_indicators_have_severity() {
551 for seed in 0..50u64 {
552 let mut gen = GoingConcernGenerator::new(seed);
553 let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
554 for indicator in &a.indicators {
555 let json = serde_json::to_string(&indicator.severity).unwrap();
558 assert!(!json.is_empty());
559 }
560 }
561 }
562
563 #[test]
564 fn test_material_uncertainty_flag_consistent() {
565 for seed in 0..100u64 {
566 let mut gen = GoingConcernGenerator::new(seed);
567 let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
568 if a.indicators.is_empty() {
569 assert!(
570 !a.material_uncertainty_exists,
571 "seed={}: no indicators but material_uncertainty_exists=true",
572 seed
573 );
574 } else {
575 assert!(
576 a.material_uncertainty_exists,
577 "seed={}: has {} indicators but material_uncertainty_exists=false",
578 seed,
579 a.indicators.len()
580 );
581 }
582 }
583 }
584
585 #[test]
586 fn test_management_plans_when_indicators_present() {
587 for seed in 0..200u64 {
588 let mut gen = GoingConcernGenerator::new(seed);
589 let a = gen.generate_for_entity("C001", assessment_date(), "FY2024");
590 if !a.indicators.is_empty() {
591 assert!(
592 !a.management_plans.is_empty(),
593 "seed={}: indicators present but no management plans",
594 seed
595 );
596 } else {
597 assert!(
598 a.management_plans.is_empty(),
599 "seed={}: no indicators but management plans present",
600 seed
601 );
602 }
603 }
604 }
605}