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