1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum TreasuryAnomalyType {
25 CashForecastMiss,
27 CovenantBreachRisk,
29 HedgeIneffectiveness,
31 UnusualCashMovement,
33 LiquidityCrisis,
35 CounterpartyConcentration,
37}
38
39#[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#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TreasuryAnomalyLabel {
56 pub id: String,
58 pub anomaly_type: TreasuryAnomalyType,
60 pub severity: TreasuryAnomalySeverity,
62 pub document_type: String,
65 pub document_id: String,
67 pub description: String,
69 pub original_value: Option<String>,
71 pub anomalous_value: Option<String>,
73}
74
75pub struct TreasuryAnomalyInjector {
81 rng: ChaCha8Rng,
82 anomaly_rate: f64,
83 counter: u64,
84}
85
86impl TreasuryAnomalyInjector {
87 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 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 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 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 fn inject_unusual_cash_movement(&mut self, pos: &mut CashPosition) -> TreasuryAnomalyLabel {
169 let original_outflows = pos.outflows;
170 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 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 let new_ratio = if self.rng.random_bool(0.5) {
234 Decimal::try_from(self.rng.random_range(0.50f64..0.79f64)).unwrap_or(dec!(0.65))
236 } else {
237 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 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#[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); 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), )];
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 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), 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 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}