1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum TreasuryAnomalyType {
24 CashForecastMiss,
26 CovenantBreachRisk,
28 HedgeIneffectiveness,
30 UnusualCashMovement,
32 LiquidityCrisis,
34 CounterpartyConcentration,
36}
37
38#[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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TreasuryAnomalyLabel {
55 pub id: String,
57 pub anomaly_type: TreasuryAnomalyType,
59 pub severity: TreasuryAnomalySeverity,
61 pub document_type: String,
64 pub document_id: String,
66 pub description: String,
68 pub original_value: Option<String>,
70 pub anomalous_value: Option<String>,
72}
73
74pub struct TreasuryAnomalyInjector {
80 rng: ChaCha8Rng,
81 anomaly_rate: f64,
82 counter: u64,
83}
84
85impl TreasuryAnomalyInjector {
86 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 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 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 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 fn inject_unusual_cash_movement(&mut self, pos: &mut CashPosition) -> TreasuryAnomalyLabel {
168 let original_outflows = pos.outflows;
169 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 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 let new_ratio = if self.rng.gen_bool(0.5) {
233 Decimal::try_from(self.rng.gen_range(0.50f64..0.79f64)).unwrap_or(dec!(0.65))
235 } else {
236 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 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#[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); 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), )];
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 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), 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 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}