datasynth_eval/coherence/
intercompany.rs1use crate::error::EvalResult;
7use rust_decimal::prelude::ToPrimitive;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ICMatchingEvaluation {
14 pub total_pairs: usize,
16 pub matched_pairs: usize,
18 pub match_rate: f64,
20 pub total_receivables: Decimal,
22 pub total_payables: Decimal,
24 pub total_unmatched: Decimal,
26 pub net_position: Decimal,
28 pub discrepancy_count: usize,
30 pub within_tolerance_count: usize,
32 pub outside_tolerance_count: usize,
34 pub netting_efficiency: Option<f64>,
36}
37
38#[derive(Debug, Clone)]
40pub struct ICMatchingData {
41 pub total_pairs: usize,
43 pub matched_pairs: usize,
45 pub total_receivables: Decimal,
47 pub total_payables: Decimal,
49 pub unmatched_items: Vec<UnmatchedICItem>,
51 pub gross_volume: Option<Decimal>,
53 pub net_settlement: Option<Decimal>,
55}
56
57#[derive(Debug, Clone)]
59pub struct UnmatchedICItem {
60 pub company: String,
62 pub counterparty: String,
64 pub amount: Decimal,
66 pub is_receivable: bool,
68}
69
70pub struct ICMatchingEvaluator {
72 tolerance: Decimal,
74}
75
76impl ICMatchingEvaluator {
77 pub fn new(tolerance: Decimal) -> Self {
79 Self { tolerance }
80 }
81
82 pub fn evaluate(&self, data: &ICMatchingData) -> EvalResult<ICMatchingEvaluation> {
84 let match_rate = if data.total_pairs > 0 {
85 data.matched_pairs as f64 / data.total_pairs as f64
86 } else {
87 1.0
88 };
89
90 let total_unmatched: Decimal = data.unmatched_items.iter().map(|i| i.amount.abs()).sum();
91 let net_position = data.total_receivables - data.total_payables;
92
93 let within_tolerance_count = data
95 .unmatched_items
96 .iter()
97 .filter(|item| item.amount.abs() <= self.tolerance)
98 .count();
99 let outside_tolerance_count = data.unmatched_items.len() - within_tolerance_count;
100 let discrepancy_count = outside_tolerance_count;
102
103 let netting_efficiency = match (data.gross_volume, data.net_settlement) {
105 (Some(gross), Some(net)) if gross > Decimal::ZERO => {
106 Some(1.0 - (net / gross).to_f64().unwrap_or(0.0))
107 }
108 _ => None,
109 };
110
111 Ok(ICMatchingEvaluation {
112 total_pairs: data.total_pairs,
113 matched_pairs: data.matched_pairs,
114 match_rate,
115 total_receivables: data.total_receivables,
116 total_payables: data.total_payables,
117 total_unmatched,
118 net_position,
119 discrepancy_count,
120 within_tolerance_count,
121 outside_tolerance_count,
122 netting_efficiency,
123 })
124 }
125}
126
127impl Default for ICMatchingEvaluator {
128 fn default() -> Self {
129 Self::new(Decimal::new(1, 2)) }
131}
132
133#[derive(Debug, Clone)]
139pub struct ICNetZeroData {
140 pub elimination_entries: Vec<ICEliminationLineData>,
142 pub post_elimination_ic_receivables: Decimal,
144 pub post_elimination_ic_payables: Decimal,
146}
147
148#[derive(Debug, Clone)]
150pub struct ICEliminationLineData {
151 pub entry_id: String,
153 pub elimination_type: String,
155 pub total_debits: Decimal,
157 pub total_credits: Decimal,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ICNetZeroEvaluation {
164 pub total_entries: usize,
166 pub unbalanced_entries: usize,
168 pub all_entries_balanced: bool,
170 pub aggregate_debits: Decimal,
172 pub aggregate_credits: Decimal,
174 pub aggregate_imbalance: Decimal,
176 pub residual_ic_balance: Decimal,
178 pub net_zero_achieved: bool,
180 pub failed_entries: Vec<String>,
182}
183
184pub struct ICNetZeroEvaluator {
190 tolerance: Decimal,
192}
193
194impl ICNetZeroEvaluator {
195 pub fn new(tolerance: Decimal) -> Self {
197 Self { tolerance }
198 }
199
200 pub fn evaluate(&self, data: &ICNetZeroData) -> EvalResult<ICNetZeroEvaluation> {
202 let mut failed_entries = Vec::new();
203 let mut aggregate_debits = Decimal::ZERO;
204 let mut aggregate_credits = Decimal::ZERO;
205
206 for entry in &data.elimination_entries {
207 aggregate_debits += entry.total_debits;
208 aggregate_credits += entry.total_credits;
209
210 let diff = (entry.total_debits - entry.total_credits).abs();
211 if diff > self.tolerance {
212 failed_entries.push(entry.entry_id.clone());
213 }
214 }
215
216 let aggregate_imbalance = (aggregate_debits - aggregate_credits).abs();
217 let all_entries_balanced = failed_entries.is_empty();
218
219 let residual_ic_balance =
220 (data.post_elimination_ic_receivables - data.post_elimination_ic_payables).abs();
221 let net_zero_achieved =
222 residual_ic_balance <= self.tolerance && aggregate_imbalance <= self.tolerance;
223
224 Ok(ICNetZeroEvaluation {
225 total_entries: data.elimination_entries.len(),
226 unbalanced_entries: failed_entries.len(),
227 all_entries_balanced,
228 aggregate_debits,
229 aggregate_credits,
230 aggregate_imbalance,
231 residual_ic_balance,
232 net_zero_achieved,
233 failed_entries,
234 })
235 }
236}
237
238impl Default for ICNetZeroEvaluator {
239 fn default() -> Self {
240 Self::new(Decimal::new(1, 2)) }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_fully_matched_ic() {
251 let data = ICMatchingData {
252 total_pairs: 5,
253 matched_pairs: 5,
254 total_receivables: Decimal::new(100000, 2),
255 total_payables: Decimal::new(100000, 2),
256 unmatched_items: vec![],
257 gross_volume: Some(Decimal::new(200000, 2)),
258 net_settlement: Some(Decimal::new(20000, 2)),
259 };
260
261 let evaluator = ICMatchingEvaluator::default();
262 let result = evaluator.evaluate(&data).unwrap();
263
264 assert_eq!(result.match_rate, 1.0);
265 assert_eq!(result.total_unmatched, Decimal::ZERO);
266 assert_eq!(result.net_position, Decimal::ZERO);
267 assert!(result.netting_efficiency.unwrap() > 0.8);
268 }
269
270 #[test]
271 fn test_partial_match() {
272 let data = ICMatchingData {
273 total_pairs: 10,
274 matched_pairs: 8,
275 total_receivables: Decimal::new(100000, 2),
276 total_payables: Decimal::new(95000, 2),
277 unmatched_items: vec![UnmatchedICItem {
278 company: "1000".to_string(),
279 counterparty: "2000".to_string(),
280 amount: Decimal::new(5000, 2),
281 is_receivable: true,
282 }],
283 gross_volume: None,
284 net_settlement: None,
285 };
286
287 let evaluator = ICMatchingEvaluator::default();
288 let result = evaluator.evaluate(&data).unwrap();
289
290 assert_eq!(result.match_rate, 0.8);
291 assert_eq!(result.discrepancy_count, 1);
292 assert_eq!(result.net_position, Decimal::new(5000, 2));
293 }
294
295 #[test]
296 fn test_no_ic_transactions() {
297 let data = ICMatchingData {
298 total_pairs: 0,
299 matched_pairs: 0,
300 total_receivables: Decimal::ZERO,
301 total_payables: Decimal::ZERO,
302 unmatched_items: vec![],
303 gross_volume: None,
304 net_settlement: None,
305 };
306
307 let evaluator = ICMatchingEvaluator::default();
308 let result = evaluator.evaluate(&data).unwrap();
309
310 assert_eq!(result.match_rate, 1.0); }
312
313 #[test]
314 fn test_ic_net_zero_balanced() {
315 let data = ICNetZeroData {
316 elimination_entries: vec![
317 ICEliminationLineData {
318 entry_id: "ELIM-001".to_string(),
319 elimination_type: "ICBalances".to_string(),
320 total_debits: Decimal::new(500000, 2),
321 total_credits: Decimal::new(500000, 2),
322 },
323 ICEliminationLineData {
324 entry_id: "ELIM-002".to_string(),
325 elimination_type: "ICRevenueExpense".to_string(),
326 total_debits: Decimal::new(250000, 2),
327 total_credits: Decimal::new(250000, 2),
328 },
329 ],
330 post_elimination_ic_receivables: Decimal::ZERO,
331 post_elimination_ic_payables: Decimal::ZERO,
332 };
333
334 let evaluator = ICNetZeroEvaluator::default();
335 let result = evaluator.evaluate(&data).unwrap();
336
337 assert!(result.all_entries_balanced);
338 assert!(result.net_zero_achieved);
339 assert_eq!(result.unbalanced_entries, 0);
340 assert_eq!(result.residual_ic_balance, Decimal::ZERO);
341 }
342
343 #[test]
344 fn test_ic_net_zero_unbalanced_entry() {
345 let data = ICNetZeroData {
346 elimination_entries: vec![ICEliminationLineData {
347 entry_id: "ELIM-BAD".to_string(),
348 elimination_type: "ICBalances".to_string(),
349 total_debits: Decimal::new(500000, 2),
350 total_credits: Decimal::new(495000, 2), }],
352 post_elimination_ic_receivables: Decimal::new(5000, 2),
353 post_elimination_ic_payables: Decimal::ZERO,
354 };
355
356 let evaluator = ICNetZeroEvaluator::default();
357 let result = evaluator.evaluate(&data).unwrap();
358
359 assert!(!result.all_entries_balanced);
360 assert!(!result.net_zero_achieved);
361 assert_eq!(result.unbalanced_entries, 1);
362 }
363
364 #[test]
365 fn test_ic_net_zero_no_eliminations() {
366 let data = ICNetZeroData {
367 elimination_entries: vec![],
368 post_elimination_ic_receivables: Decimal::ZERO,
369 post_elimination_ic_payables: Decimal::ZERO,
370 };
371
372 let evaluator = ICNetZeroEvaluator::default();
373 let result = evaluator.evaluate(&data).unwrap();
374
375 assert!(result.all_entries_balanced);
376 assert!(result.net_zero_achieved);
377 }
378}