1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
12pub struct AmlTransactionData {
13 pub transaction_id: String,
15 pub typology: String,
17 pub case_id: String,
19 pub amount: f64,
21 pub is_flagged: bool,
23}
24
25#[derive(Debug, Clone)]
27pub struct TypologyData {
28 pub name: String,
30 pub scenario_count: usize,
32 pub case_ids_consistent: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct AmlDetectabilityThresholds {
39 pub min_typology_coverage: f64,
41 pub min_scenario_coherence: f64,
43 pub structuring_threshold: f64,
45}
46
47impl Default for AmlDetectabilityThresholds {
48 fn default() -> Self {
49 Self {
50 min_typology_coverage: 0.80,
51 min_scenario_coherence: 0.90,
52 structuring_threshold: 10_000.0,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TypologyDetectability {
60 pub name: String,
62 pub transaction_count: usize,
64 pub case_count: usize,
66 pub flag_rate: f64,
68 pub pattern_detected: bool,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AmlDetectabilityAnalysis {
75 pub typology_coverage: f64,
77 pub scenario_coherence: f64,
79 pub per_typology: Vec<TypologyDetectability>,
81 pub total_transactions: usize,
83 pub passes: bool,
85 pub issues: Vec<String>,
87}
88
89const EXPECTED_TYPOLOGIES: &[&str] = &[
91 "structuring",
92 "layering",
93 "mule_network",
94 "round_tripping",
95 "fraud",
96 "spoofing",
97];
98
99pub struct AmlDetectabilityAnalyzer {
101 thresholds: AmlDetectabilityThresholds,
102}
103
104impl AmlDetectabilityAnalyzer {
105 pub fn new() -> Self {
107 Self {
108 thresholds: AmlDetectabilityThresholds::default(),
109 }
110 }
111
112 pub fn with_thresholds(thresholds: AmlDetectabilityThresholds) -> Self {
114 Self { thresholds }
115 }
116
117 pub fn analyze(
119 &self,
120 transactions: &[AmlTransactionData],
121 typologies: &[TypologyData],
122 ) -> EvalResult<AmlDetectabilityAnalysis> {
123 let mut issues = Vec::new();
124
125 let present_typologies: std::collections::HashSet<&str> =
127 typologies.iter().map(|t| t.name.as_str()).collect();
128 let covered = EXPECTED_TYPOLOGIES
129 .iter()
130 .filter(|&&t| present_typologies.contains(t))
131 .count();
132 let typology_coverage = covered as f64 / EXPECTED_TYPOLOGIES.len() as f64;
133
134 let coherent = typologies.iter().filter(|t| t.case_ids_consistent).count();
136 let scenario_coherence = if typologies.is_empty() {
137 1.0
138 } else {
139 coherent as f64 / typologies.len() as f64
140 };
141
142 let mut by_typology: HashMap<String, Vec<&AmlTransactionData>> = HashMap::new();
144 for txn in transactions {
145 by_typology
146 .entry(txn.typology.clone())
147 .or_default()
148 .push(txn);
149 }
150
151 let mut per_typology = Vec::new();
152 for (name, txns) in &by_typology {
153 let case_ids: std::collections::HashSet<&str> =
154 txns.iter().map(|t| t.case_id.as_str()).collect();
155 let flagged = txns.iter().filter(|t| t.is_flagged).count();
156 let flag_rate = if txns.is_empty() {
157 0.0
158 } else {
159 flagged as f64 / txns.len() as f64
160 };
161
162 let pattern_detected = match name.as_str() {
164 "structuring" => {
165 let below = txns
167 .iter()
168 .filter(|t| t.amount < self.thresholds.structuring_threshold)
169 .count();
170 below as f64 / txns.len().max(1) as f64 > 0.5
171 }
172 "layering" => {
173 !case_ids.is_empty() && txns.len() > case_ids.len()
175 }
176 _ => {
177 !txns.is_empty()
179 }
180 };
181
182 per_typology.push(TypologyDetectability {
183 name: name.clone(),
184 transaction_count: txns.len(),
185 case_count: case_ids.len(),
186 flag_rate,
187 pattern_detected,
188 });
189 }
190
191 if typology_coverage < self.thresholds.min_typology_coverage {
193 issues.push(format!(
194 "Typology coverage {:.3} < {:.3}",
195 typology_coverage, self.thresholds.min_typology_coverage
196 ));
197 }
198 if scenario_coherence < self.thresholds.min_scenario_coherence {
199 issues.push(format!(
200 "Scenario coherence {:.3} < {:.3}",
201 scenario_coherence, self.thresholds.min_scenario_coherence
202 ));
203 }
204
205 let passes = issues.is_empty();
206
207 Ok(AmlDetectabilityAnalysis {
208 typology_coverage,
209 scenario_coherence,
210 per_typology,
211 total_transactions: transactions.len(),
212 passes,
213 issues,
214 })
215 }
216}
217
218impl Default for AmlDetectabilityAnalyzer {
219 fn default() -> Self {
220 Self::new()
221 }
222}
223
224#[cfg(test)]
225#[allow(clippy::unwrap_used)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_good_aml_data() {
231 let analyzer = AmlDetectabilityAnalyzer::new();
232 let typologies: Vec<TypologyData> = EXPECTED_TYPOLOGIES
233 .iter()
234 .map(|name| TypologyData {
235 name: name.to_string(),
236 scenario_count: 5,
237 case_ids_consistent: true,
238 })
239 .collect();
240 let transactions = vec![
241 AmlTransactionData {
242 transaction_id: "T001".to_string(),
243 typology: "structuring".to_string(),
244 case_id: "C001".to_string(),
245 amount: 9_500.0,
246 is_flagged: true,
247 },
248 AmlTransactionData {
249 transaction_id: "T002".to_string(),
250 typology: "structuring".to_string(),
251 case_id: "C001".to_string(),
252 amount: 9_800.0,
253 is_flagged: true,
254 },
255 ];
256
257 let result = analyzer.analyze(&transactions, &typologies).unwrap();
258 assert!(result.passes);
259 assert_eq!(result.typology_coverage, 1.0);
260 }
261
262 #[test]
263 fn test_missing_typologies() {
264 let analyzer = AmlDetectabilityAnalyzer::new();
265 let typologies = vec![TypologyData {
266 name: "structuring".to_string(),
267 scenario_count: 5,
268 case_ids_consistent: true,
269 }];
270
271 let result = analyzer.analyze(&[], &typologies).unwrap();
272 assert!(!result.passes); }
274
275 #[test]
276 fn test_empty() {
277 let analyzer = AmlDetectabilityAnalyzer::new();
278 let result = analyzer.analyze(&[], &[]).unwrap();
279 assert!(!result.passes); }
281}