Skip to main content

datasynth_generators/treasury/
treasury_anomaly.rs

1//! Treasury Anomaly Injector.
2//!
3//! Injects labeled anomalies into treasury data (cash positions, forecasts,
4//! hedge relationships, debt covenants) for ML ground-truth generation.
5//! Each injected anomaly produces a [`TreasuryAnomalyLabel`] that records the
6//! anomaly type, severity, affected document, and original vs. anomalous values.
7
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13use serde::{Deserialize, Serialize};
14
15use datasynth_core::models::{CashPosition, DebtCovenant, HedgeRelationship};
16
17// ---------------------------------------------------------------------------
18// Enums
19// ---------------------------------------------------------------------------
20
21/// Types of treasury anomalies that can be injected.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum TreasuryAnomalyType {
25    /// Actual cash flow deviates significantly from forecast.
26    CashForecastMiss,
27    /// Covenant headroom trending toward zero (potential breach).
28    CovenantBreachRisk,
29    /// Hedge effectiveness ratio falls outside 80-125% corridor.
30    HedgeIneffectiveness,
31    /// Unusually large or unexpected cash movement.
32    UnusualCashMovement,
33    /// Available cash drops below minimum balance policy.
34    LiquidityCrisis,
35    /// Excessive hedging exposure to a single counterparty.
36    CounterpartyConcentration,
37}
38
39/// Severity of the anomaly.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum TreasuryAnomalySeverity {
43    Low,
44    Medium,
45    High,
46    Critical,
47}
48
49// ---------------------------------------------------------------------------
50// Label
51// ---------------------------------------------------------------------------
52
53/// A labeled treasury anomaly for ground truth.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TreasuryAnomalyLabel {
56    /// Unique anomaly label identifier.
57    pub id: String,
58    /// Type of the anomaly.
59    pub anomaly_type: TreasuryAnomalyType,
60    /// Severity of the anomaly.
61    pub severity: TreasuryAnomalySeverity,
62    /// Kind of document affected: `"cash_position"`, `"hedge_relationship"`,
63    /// `"debt_covenant"`, `"cash_forecast"`.
64    pub document_type: String,
65    /// ID of the affected record.
66    pub document_id: String,
67    /// Human-readable description of the anomaly.
68    pub description: String,
69    /// What the correct value should be (if applicable).
70    pub original_value: Option<String>,
71    /// What was injected (if applicable).
72    pub anomalous_value: Option<String>,
73}
74
75// ---------------------------------------------------------------------------
76// Injector
77// ---------------------------------------------------------------------------
78
79/// Injects treasury anomalies into generated data.
80pub struct TreasuryAnomalyInjector {
81    rng: ChaCha8Rng,
82    anomaly_rate: f64,
83    counter: u64,
84}
85
86impl TreasuryAnomalyInjector {
87    /// Creates a new treasury anomaly injector.
88    pub fn new(seed: u64, anomaly_rate: f64) -> Self {
89        Self {
90            rng: seeded_rng(seed, 0),
91            anomaly_rate: anomaly_rate.clamp(0.0, 1.0),
92            counter: 0,
93        }
94    }
95
96    /// Inject anomalies into cash positions. Modifies positions in-place and returns labels.
97    ///
98    /// Anomaly types:
99    /// - **UnusualCashMovement** (50%): Inject a large unexpected outflow.
100    /// - **LiquidityCrisis** (50%): Reduce available balance below minimum policy.
101    pub fn inject_into_cash_positions(
102        &mut self,
103        positions: &mut [CashPosition],
104        minimum_balance: Decimal,
105    ) -> Vec<TreasuryAnomalyLabel> {
106        let mut labels = Vec::new();
107
108        for pos in positions.iter_mut() {
109            if !self.should_inject() {
110                continue;
111            }
112
113            let roll: f64 = self.rng.random();
114            if roll < 0.50 {
115                labels.push(self.inject_unusual_cash_movement(pos));
116            } else {
117                labels.push(self.inject_liquidity_crisis(pos, minimum_balance));
118            }
119        }
120
121        labels
122    }
123
124    /// Inject anomalies into hedge relationships. Modifies relationships in-place
125    /// and returns labels.
126    ///
127    /// Anomaly type: **HedgeIneffectiveness** — push effectiveness ratio outside
128    /// the 80-125% corridor.
129    pub fn inject_into_hedge_relationships(
130        &mut self,
131        relationships: &mut [HedgeRelationship],
132    ) -> Vec<TreasuryAnomalyLabel> {
133        let mut labels = Vec::new();
134
135        for rel in relationships.iter_mut() {
136            if !self.should_inject() {
137                continue;
138            }
139            labels.push(self.inject_hedge_ineffectiveness(rel));
140        }
141
142        labels
143    }
144
145    /// Inject anomalies into debt covenants. Modifies covenants in-place and returns labels.
146    ///
147    /// Anomaly type: **CovenantBreachRisk** — push actual value toward or past threshold.
148    pub fn inject_into_debt_covenants(
149        &mut self,
150        covenants: &mut [DebtCovenant],
151    ) -> Vec<TreasuryAnomalyLabel> {
152        let mut labels = Vec::new();
153
154        for cov in covenants.iter_mut() {
155            if !self.should_inject() {
156                continue;
157            }
158            labels.push(self.inject_covenant_breach_risk(cov));
159        }
160
161        labels
162    }
163
164    // -----------------------------------------------------------------------
165    // Private injection methods
166    // -----------------------------------------------------------------------
167
168    fn inject_unusual_cash_movement(&mut self, pos: &mut CashPosition) -> TreasuryAnomalyLabel {
169        let original_outflows = pos.outflows;
170        // Inject a large unexpected outflow (50-200% of current closing balance)
171        let spike_pct =
172            Decimal::try_from(self.rng.random_range(0.50f64..2.00f64)).unwrap_or(dec!(1.0));
173        let spike = (pos.closing_balance.abs() * spike_pct).round_dp(2);
174        pos.outflows += spike;
175        let new_closing = (pos.opening_balance + pos.inflows - pos.outflows).round_dp(2);
176        pos.closing_balance = new_closing;
177        pos.available_balance = new_closing.max(Decimal::ZERO);
178
179        self.counter += 1;
180        TreasuryAnomalyLabel {
181            id: format!("TANOM-{:06}", self.counter),
182            anomaly_type: TreasuryAnomalyType::UnusualCashMovement,
183            severity: if spike > pos.opening_balance {
184                TreasuryAnomalySeverity::Critical
185            } else {
186                TreasuryAnomalySeverity::High
187            },
188            document_type: "cash_position".to_string(),
189            document_id: pos.id.clone(),
190            description: format!("Unusual cash outflow of {} on {}", spike, pos.date),
191            original_value: Some(original_outflows.to_string()),
192            anomalous_value: Some(pos.outflows.to_string()),
193        }
194    }
195
196    fn inject_liquidity_crisis(
197        &mut self,
198        pos: &mut CashPosition,
199        minimum_balance: Decimal,
200    ) -> TreasuryAnomalyLabel {
201        let original_available = pos.available_balance;
202        // Drop available balance below the minimum policy
203        let target_pct =
204            Decimal::try_from(self.rng.random_range(0.10f64..0.80f64)).unwrap_or(dec!(0.50));
205        pos.available_balance = (minimum_balance * target_pct).round_dp(2);
206
207        self.counter += 1;
208        TreasuryAnomalyLabel {
209            id: format!("TANOM-{:06}", self.counter),
210            anomaly_type: TreasuryAnomalyType::LiquidityCrisis,
211            severity: if pos.available_balance < minimum_balance * dec!(0.25) {
212                TreasuryAnomalySeverity::Critical
213            } else {
214                TreasuryAnomalySeverity::Medium
215            },
216            document_type: "cash_position".to_string(),
217            document_id: pos.id.clone(),
218            description: format!(
219                "Available balance {} below minimum policy {} on {}",
220                pos.available_balance, minimum_balance, pos.date
221            ),
222            original_value: Some(original_available.to_string()),
223            anomalous_value: Some(pos.available_balance.to_string()),
224        }
225    }
226
227    fn inject_hedge_ineffectiveness(
228        &mut self,
229        rel: &mut HedgeRelationship,
230    ) -> TreasuryAnomalyLabel {
231        let original_ratio = rel.effectiveness_ratio;
232        // Push ratio outside the 80-125% corridor
233        let new_ratio = if self.rng.random_bool(0.5) {
234            // Below 80%
235            Decimal::try_from(self.rng.random_range(0.50f64..0.79f64)).unwrap_or(dec!(0.65))
236        } else {
237            // Above 125%
238            Decimal::try_from(self.rng.random_range(1.26f64..1.60f64)).unwrap_or(dec!(1.40))
239        };
240        rel.effectiveness_ratio = new_ratio.round_dp(4);
241        rel.update_effectiveness();
242
243        self.counter += 1;
244        TreasuryAnomalyLabel {
245            id: format!("TANOM-{:06}", self.counter),
246            anomaly_type: TreasuryAnomalyType::HedgeIneffectiveness,
247            severity: TreasuryAnomalySeverity::High,
248            document_type: "hedge_relationship".to_string(),
249            document_id: rel.id.clone(),
250            description: format!(
251                "Hedge effectiveness ratio {} outside 80-125% corridor",
252                rel.effectiveness_ratio
253            ),
254            original_value: Some(original_ratio.to_string()),
255            anomalous_value: Some(rel.effectiveness_ratio.to_string()),
256        }
257    }
258
259    fn inject_covenant_breach_risk(&mut self, cov: &mut DebtCovenant) -> TreasuryAnomalyLabel {
260        let original_value = cov.actual_value;
261        // Push actual value past the threshold
262        let breach_factor =
263            Decimal::try_from(self.rng.random_range(1.05f64..1.25f64)).unwrap_or(dec!(1.10));
264        cov.actual_value = (cov.threshold * breach_factor).round_dp(2);
265        cov.update_compliance();
266
267        self.counter += 1;
268        TreasuryAnomalyLabel {
269            id: format!("TANOM-{:06}", self.counter),
270            anomaly_type: TreasuryAnomalyType::CovenantBreachRisk,
271            severity: if cov.headroom.abs() > dec!(1.0) {
272                TreasuryAnomalySeverity::Critical
273            } else {
274                TreasuryAnomalySeverity::High
275            },
276            document_type: "debt_covenant".to_string(),
277            document_id: cov.id.clone(),
278            description: format!(
279                "Covenant {:?} actual value {} vs threshold {} (headroom: {})",
280                cov.covenant_type, cov.actual_value, cov.threshold, cov.headroom
281            ),
282            original_value: Some(original_value.to_string()),
283            anomalous_value: Some(cov.actual_value.to_string()),
284        }
285    }
286
287    fn should_inject(&mut self) -> bool {
288        self.rng.random_bool(self.anomaly_rate)
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Tests
294// ---------------------------------------------------------------------------
295
296#[cfg(test)]
297#[allow(clippy::unwrap_used)]
298mod tests {
299    use super::*;
300    use chrono::NaiveDate;
301    use datasynth_core::models::{
302        CashPosition, CovenantType, DebtCovenant, EffectivenessMethod, Frequency,
303        HedgeRelationship, HedgeType, HedgedItemType,
304    };
305
306    fn d(s: &str) -> NaiveDate {
307        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
308    }
309
310    #[test]
311    fn test_inject_unusual_cash_movement() {
312        let mut injector = TreasuryAnomalyInjector::new(42, 1.0); // 100% rate for testing
313        let mut positions = vec![CashPosition::new(
314            "CP-001",
315            "C001",
316            "BA-001",
317            "USD",
318            d("2025-01-15"),
319            dec!(100000),
320            dec!(5000),
321            dec!(2000),
322        )];
323
324        let labels = injector.inject_into_cash_positions(&mut positions, dec!(50000));
325
326        assert_eq!(labels.len(), 1);
327        assert!(
328            labels[0].anomaly_type == TreasuryAnomalyType::UnusualCashMovement
329                || labels[0].anomaly_type == TreasuryAnomalyType::LiquidityCrisis
330        );
331        assert!(labels[0].original_value.is_some());
332        assert!(labels[0].anomalous_value.is_some());
333    }
334
335    #[test]
336    fn test_inject_hedge_ineffectiveness() {
337        let mut injector = TreasuryAnomalyInjector::new(42, 1.0);
338        let mut relationships = vec![HedgeRelationship::new(
339            "HR-001",
340            HedgedItemType::ForecastedTransaction,
341            "EUR receivables",
342            "HI-001",
343            HedgeType::CashFlowHedge,
344            d("2025-01-01"),
345            EffectivenessMethod::Regression,
346            dec!(0.95), // starts effective
347        )];
348
349        let labels = injector.inject_into_hedge_relationships(&mut relationships);
350
351        assert_eq!(labels.len(), 1);
352        assert_eq!(
353            labels[0].anomaly_type,
354            TreasuryAnomalyType::HedgeIneffectiveness
355        );
356        // Relationship should now be marked as ineffective
357        assert!(!relationships[0].is_effective);
358    }
359
360    #[test]
361    fn test_inject_covenant_breach() {
362        let mut injector = TreasuryAnomalyInjector::new(42, 1.0);
363        let mut covenants = vec![DebtCovenant::new(
364            "COV-001",
365            CovenantType::DebtToEbitda,
366            dec!(3.5),
367            Frequency::Quarterly,
368            dec!(2.5), // starts compliant
369            d("2025-03-31"),
370        )];
371
372        let labels = injector.inject_into_debt_covenants(&mut covenants);
373
374        assert_eq!(labels.len(), 1);
375        assert_eq!(
376            labels[0].anomaly_type,
377            TreasuryAnomalyType::CovenantBreachRisk
378        );
379        // For DebtToEbitda (max covenant), injected value should exceed threshold
380        // The breach_factor pushes actual_value = threshold * 1.05..1.25
381        // So it will be above threshold, making it non-compliant
382        assert!(!covenants[0].is_compliant);
383        assert!(covenants[0].headroom < Decimal::ZERO);
384    }
385
386    #[test]
387    fn test_no_injection_at_zero_rate() {
388        let mut injector = TreasuryAnomalyInjector::new(42, 0.0);
389        let mut positions = vec![CashPosition::new(
390            "CP-001",
391            "C001",
392            "BA-001",
393            "USD",
394            d("2025-01-15"),
395            dec!(100000),
396            dec!(5000),
397            dec!(2000),
398        )];
399
400        let labels = injector.inject_into_cash_positions(&mut positions, dec!(50000));
401        assert!(labels.is_empty());
402    }
403
404    #[test]
405    fn test_anomaly_label_serde_roundtrip() {
406        let label = TreasuryAnomalyLabel {
407            id: "TANOM-001".to_string(),
408            anomaly_type: TreasuryAnomalyType::CashForecastMiss,
409            severity: TreasuryAnomalySeverity::Medium,
410            document_type: "cash_forecast".to_string(),
411            document_id: "CF-001".to_string(),
412            description: "Forecast missed by 25%".to_string(),
413            original_value: Some("100000".to_string()),
414            anomalous_value: Some("75000".to_string()),
415        };
416
417        let json = serde_json::to_string(&label).unwrap();
418        let deserialized: TreasuryAnomalyLabel = serde_json::from_str(&json).unwrap();
419        assert_eq!(
420            deserialized.anomaly_type,
421            TreasuryAnomalyType::CashForecastMiss
422        );
423        assert_eq!(deserialized.document_id, "CF-001");
424    }
425}