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)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_fully_matched_ic() {
250 let data = ICMatchingData {
251 total_pairs: 5,
252 matched_pairs: 5,
253 total_receivables: Decimal::new(100000, 2),
254 total_payables: Decimal::new(100000, 2),
255 unmatched_items: vec![],
256 gross_volume: Some(Decimal::new(200000, 2)),
257 net_settlement: Some(Decimal::new(20000, 2)),
258 };
259
260 let evaluator = ICMatchingEvaluator::default();
261 let result = evaluator.evaluate(&data).unwrap();
262
263 assert_eq!(result.match_rate, 1.0);
264 assert_eq!(result.total_unmatched, Decimal::ZERO);
265 assert_eq!(result.net_position, Decimal::ZERO);
266 assert!(result.netting_efficiency.unwrap() > 0.8);
267 }
268
269 #[test]
270 fn test_partial_match() {
271 let data = ICMatchingData {
272 total_pairs: 10,
273 matched_pairs: 8,
274 total_receivables: Decimal::new(100000, 2),
275 total_payables: Decimal::new(95000, 2),
276 unmatched_items: vec![UnmatchedICItem {
277 company: "1000".to_string(),
278 counterparty: "2000".to_string(),
279 amount: Decimal::new(5000, 2),
280 is_receivable: true,
281 }],
282 gross_volume: None,
283 net_settlement: None,
284 };
285
286 let evaluator = ICMatchingEvaluator::default();
287 let result = evaluator.evaluate(&data).unwrap();
288
289 assert_eq!(result.match_rate, 0.8);
290 assert_eq!(result.discrepancy_count, 1);
291 assert_eq!(result.net_position, Decimal::new(5000, 2));
292 }
293
294 #[test]
295 fn test_no_ic_transactions() {
296 let data = ICMatchingData {
297 total_pairs: 0,
298 matched_pairs: 0,
299 total_receivables: Decimal::ZERO,
300 total_payables: Decimal::ZERO,
301 unmatched_items: vec![],
302 gross_volume: None,
303 net_settlement: None,
304 };
305
306 let evaluator = ICMatchingEvaluator::default();
307 let result = evaluator.evaluate(&data).unwrap();
308
309 assert_eq!(result.match_rate, 1.0); }
311
312 #[test]
313 fn test_ic_net_zero_balanced() {
314 let data = ICNetZeroData {
315 elimination_entries: vec![
316 ICEliminationLineData {
317 entry_id: "ELIM-001".to_string(),
318 elimination_type: "ICBalances".to_string(),
319 total_debits: Decimal::new(500000, 2),
320 total_credits: Decimal::new(500000, 2),
321 },
322 ICEliminationLineData {
323 entry_id: "ELIM-002".to_string(),
324 elimination_type: "ICRevenueExpense".to_string(),
325 total_debits: Decimal::new(250000, 2),
326 total_credits: Decimal::new(250000, 2),
327 },
328 ],
329 post_elimination_ic_receivables: Decimal::ZERO,
330 post_elimination_ic_payables: Decimal::ZERO,
331 };
332
333 let evaluator = ICNetZeroEvaluator::default();
334 let result = evaluator.evaluate(&data).unwrap();
335
336 assert!(result.all_entries_balanced);
337 assert!(result.net_zero_achieved);
338 assert_eq!(result.unbalanced_entries, 0);
339 assert_eq!(result.residual_ic_balance, Decimal::ZERO);
340 }
341
342 #[test]
343 fn test_ic_net_zero_unbalanced_entry() {
344 let data = ICNetZeroData {
345 elimination_entries: vec![ICEliminationLineData {
346 entry_id: "ELIM-BAD".to_string(),
347 elimination_type: "ICBalances".to_string(),
348 total_debits: Decimal::new(500000, 2),
349 total_credits: Decimal::new(495000, 2), }],
351 post_elimination_ic_receivables: Decimal::new(5000, 2),
352 post_elimination_ic_payables: Decimal::ZERO,
353 };
354
355 let evaluator = ICNetZeroEvaluator::default();
356 let result = evaluator.evaluate(&data).unwrap();
357
358 assert!(!result.all_entries_balanced);
359 assert!(!result.net_zero_achieved);
360 assert_eq!(result.unbalanced_entries, 1);
361 }
362
363 #[test]
364 fn test_ic_net_zero_no_eliminations() {
365 let data = ICNetZeroData {
366 elimination_entries: vec![],
367 post_elimination_ic_receivables: Decimal::ZERO,
368 post_elimination_ic_payables: Decimal::ZERO,
369 };
370
371 let evaluator = ICNetZeroEvaluator::default();
372 let result = evaluator.evaluate(&data).unwrap();
373
374 assert!(result.all_entries_balanced);
375 assert!(result.net_zero_achieved);
376 }
377}