Skip to main content

datasynth_eval/coherence/
intercompany.rs

1//! Intercompany matching evaluation.
2//!
3//! Validates that intercompany transactions are properly matched
4//! between company pairs.
5
6use crate::error::EvalResult;
7use rust_decimal::prelude::ToPrimitive;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11/// Results of intercompany matching evaluation.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ICMatchingEvaluation {
14    /// Total company pairs with IC transactions.
15    pub total_pairs: usize,
16    /// Number of pairs fully matched.
17    pub matched_pairs: usize,
18    /// Match rate (0.0-1.0).
19    pub match_rate: f64,
20    /// Total intercompany receivables.
21    pub total_receivables: Decimal,
22    /// Total intercompany payables.
23    pub total_payables: Decimal,
24    /// Total unmatched amount.
25    pub total_unmatched: Decimal,
26    /// Net position (receivables - payables).
27    pub net_position: Decimal,
28    /// Number of discrepancies (outside tolerance).
29    pub discrepancy_count: usize,
30    /// Number of unmatched items within tolerance.
31    pub within_tolerance_count: usize,
32    /// Number of unmatched items outside tolerance.
33    pub outside_tolerance_count: usize,
34    /// Netting efficiency if applicable.
35    pub netting_efficiency: Option<f64>,
36}
37
38/// Input for IC matching evaluation.
39#[derive(Debug, Clone)]
40pub struct ICMatchingData {
41    /// Total company pairs.
42    pub total_pairs: usize,
43    /// Matched company pairs.
44    pub matched_pairs: usize,
45    /// Total receivables amount.
46    pub total_receivables: Decimal,
47    /// Total payables amount.
48    pub total_payables: Decimal,
49    /// Unmatched items details.
50    pub unmatched_items: Vec<UnmatchedICItem>,
51    /// Gross IC volume (for netting calculation).
52    pub gross_volume: Option<Decimal>,
53    /// Net settlement amount (for netting calculation).
54    pub net_settlement: Option<Decimal>,
55}
56
57/// An unmatched IC item.
58#[derive(Debug, Clone)]
59pub struct UnmatchedICItem {
60    /// Company code.
61    pub company: String,
62    /// Counterparty company code.
63    pub counterparty: String,
64    /// Amount.
65    pub amount: Decimal,
66    /// Whether this is a receivable (true) or payable (false).
67    pub is_receivable: bool,
68}
69
70/// Evaluator for intercompany matching.
71pub struct ICMatchingEvaluator {
72    /// Tolerance for classifying unmatched items as within/outside tolerance.
73    tolerance: Decimal,
74}
75
76impl ICMatchingEvaluator {
77    /// Create a new evaluator with the specified tolerance.
78    pub fn new(tolerance: Decimal) -> Self {
79        Self { tolerance }
80    }
81
82    /// Evaluate IC matching results.
83    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        // Classify unmatched items by tolerance
94        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        // Only outside-tolerance items count as true discrepancies
101        let discrepancy_count = outside_tolerance_count;
102
103        // Calculate netting efficiency if data available
104        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)) // 0.01 tolerance
130    }
131}
132
133#[cfg(test)]
134#[allow(clippy::unwrap_used)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_fully_matched_ic() {
140        let data = ICMatchingData {
141            total_pairs: 5,
142            matched_pairs: 5,
143            total_receivables: Decimal::new(100000, 2),
144            total_payables: Decimal::new(100000, 2),
145            unmatched_items: vec![],
146            gross_volume: Some(Decimal::new(200000, 2)),
147            net_settlement: Some(Decimal::new(20000, 2)),
148        };
149
150        let evaluator = ICMatchingEvaluator::default();
151        let result = evaluator.evaluate(&data).unwrap();
152
153        assert_eq!(result.match_rate, 1.0);
154        assert_eq!(result.total_unmatched, Decimal::ZERO);
155        assert_eq!(result.net_position, Decimal::ZERO);
156        assert!(result.netting_efficiency.unwrap() > 0.8);
157    }
158
159    #[test]
160    fn test_partial_match() {
161        let data = ICMatchingData {
162            total_pairs: 10,
163            matched_pairs: 8,
164            total_receivables: Decimal::new(100000, 2),
165            total_payables: Decimal::new(95000, 2),
166            unmatched_items: vec![UnmatchedICItem {
167                company: "1000".to_string(),
168                counterparty: "2000".to_string(),
169                amount: Decimal::new(5000, 2),
170                is_receivable: true,
171            }],
172            gross_volume: None,
173            net_settlement: None,
174        };
175
176        let evaluator = ICMatchingEvaluator::default();
177        let result = evaluator.evaluate(&data).unwrap();
178
179        assert_eq!(result.match_rate, 0.8);
180        assert_eq!(result.discrepancy_count, 1);
181        assert_eq!(result.net_position, Decimal::new(5000, 2));
182    }
183
184    #[test]
185    fn test_no_ic_transactions() {
186        let data = ICMatchingData {
187            total_pairs: 0,
188            matched_pairs: 0,
189            total_receivables: Decimal::ZERO,
190            total_payables: Decimal::ZERO,
191            unmatched_items: vec![],
192            gross_volume: None,
193            net_settlement: None,
194        };
195
196        let evaluator = ICMatchingEvaluator::default();
197        let result = evaluator.evaluate(&data).unwrap();
198
199        assert_eq!(result.match_rate, 1.0); // No IC = 100% matched
200    }
201}