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)]
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}