1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
17pub struct AmlTransactionData {
18 pub transaction_id: String,
20 pub typology: String,
22 pub case_id: String,
24 pub amount: f64,
26 pub is_flagged: bool,
28}
29
30#[derive(Debug, Clone)]
32pub struct TypologyData {
33 pub name: String,
35 pub scenario_count: usize,
37 pub case_ids_consistent: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AmlDetectabilityThresholds {
44 pub min_typology_coverage: f64,
46 pub min_scenario_coherence: f64,
48 pub structuring_threshold: f64,
50}
51
52impl Default for AmlDetectabilityThresholds {
53 fn default() -> Self {
54 Self {
55 min_typology_coverage: 0.80,
56 min_scenario_coherence: 0.90,
57 structuring_threshold: 10_000.0,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TypologyDetectability {
65 pub name: String,
67 pub transaction_count: usize,
69 pub case_count: usize,
71 pub flag_rate: f64,
73 pub pattern_detected: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct AmlDetectabilityAnalysis {
80 pub typology_coverage: f64,
82 pub scenario_coherence: f64,
84 pub per_typology: Vec<TypologyDetectability>,
86 pub total_transactions: usize,
88 pub passes: bool,
90 pub issues: Vec<String>,
92}
93
94const EXPECTED_TYPOLOGIES: &[(&str, &[&str])] = &[
110 (
111 "structuring",
112 &["structuring", "smurfing", "cuckoo_smurfing"],
113 ),
114 (
115 "funnel",
116 &[
117 "funnel",
118 "funnel_account",
119 "concentration_account",
120 "pouch_activity",
121 ],
122 ),
123 ("layering", &["layering", "rapid_movement", "shell_company"]),
124 (
125 "mule",
126 &[
127 "mule",
128 "money_mule",
129 "authorized_push_payment",
130 "synthetic_identity",
131 ],
132 ),
133 (
134 "round_tripping",
135 &[
136 "round_tripping",
137 "trade_based_ml",
138 "real_estate_integration",
139 ],
140 ),
141 (
142 "fraud",
143 &[
144 "fraud",
145 "first_party_fraud",
146 "account_takeover",
147 "romance_scam",
148 "sanctions_evasion",
149 ],
150 ),
151 (
152 "spoofing",
153 &["spoofing", "casino_integration", "crypto_integration"],
154 ),
155];
156
157pub struct AmlDetectabilityAnalyzer {
159 thresholds: AmlDetectabilityThresholds,
160}
161
162impl AmlDetectabilityAnalyzer {
163 pub fn new() -> Self {
165 Self {
166 thresholds: AmlDetectabilityThresholds::default(),
167 }
168 }
169
170 pub fn with_thresholds(thresholds: AmlDetectabilityThresholds) -> Self {
172 Self { thresholds }
173 }
174
175 pub fn analyze(
177 &self,
178 transactions: &[AmlTransactionData],
179 typologies: &[TypologyData],
180 ) -> EvalResult<AmlDetectabilityAnalysis> {
181 let mut issues = Vec::new();
182
183 let present_typologies: std::collections::HashSet<&str> =
189 typologies.iter().map(|t| t.name.as_str()).collect();
190 let covered = EXPECTED_TYPOLOGIES
191 .iter()
192 .filter(|(_, aliases)| aliases.iter().any(|a| present_typologies.contains(a)))
193 .count();
194 let typology_coverage = covered as f64 / EXPECTED_TYPOLOGIES.len() as f64;
195
196 let coherent = typologies.iter().filter(|t| t.case_ids_consistent).count();
198 let scenario_coherence = if typologies.is_empty() {
199 1.0
200 } else {
201 coherent as f64 / typologies.len() as f64
202 };
203
204 let mut by_typology: HashMap<String, Vec<&AmlTransactionData>> = HashMap::new();
206 for txn in transactions {
207 by_typology
208 .entry(txn.typology.clone())
209 .or_default()
210 .push(txn);
211 }
212
213 let mut per_typology = Vec::new();
214 for (name, txns) in &by_typology {
215 let case_ids: std::collections::HashSet<&str> =
216 txns.iter().map(|t| t.case_id.as_str()).collect();
217 let flagged = txns.iter().filter(|t| t.is_flagged).count();
218 let flag_rate = if txns.is_empty() {
219 0.0
220 } else {
221 flagged as f64 / txns.len() as f64
222 };
223
224 let pattern_detected = match name.as_str() {
226 "structuring" => {
227 let below = txns
229 .iter()
230 .filter(|t| t.amount < self.thresholds.structuring_threshold)
231 .count();
232 below as f64 / txns.len().max(1) as f64 > 0.5
233 }
234 "layering" => {
235 !case_ids.is_empty() && txns.len() > case_ids.len()
237 }
238 _ => {
239 let suspicious_count = txns.iter().filter(|t| t.is_flagged).count();
243 let suspicious_ratio = suspicious_count as f64 / txns.len().max(1) as f64;
244 !txns.is_empty() && suspicious_ratio > 0.0
245 }
246 };
247
248 per_typology.push(TypologyDetectability {
249 name: name.clone(),
250 transaction_count: txns.len(),
251 case_count: case_ids.len(),
252 flag_rate,
253 pattern_detected,
254 });
255 }
256
257 if typology_coverage < self.thresholds.min_typology_coverage {
259 issues.push(format!(
260 "Typology coverage {:.3} < {:.3}",
261 typology_coverage, self.thresholds.min_typology_coverage
262 ));
263 }
264 if scenario_coherence < self.thresholds.min_scenario_coherence {
265 issues.push(format!(
266 "Scenario coherence {:.3} < {:.3}",
267 scenario_coherence, self.thresholds.min_scenario_coherence
268 ));
269 }
270
271 let passes = issues.is_empty();
272
273 Ok(AmlDetectabilityAnalysis {
274 typology_coverage,
275 scenario_coherence,
276 per_typology,
277 total_transactions: transactions.len(),
278 passes,
279 issues,
280 })
281 }
282}
283
284impl Default for AmlDetectabilityAnalyzer {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290#[cfg(test)]
291#[allow(clippy::unwrap_used)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn test_good_aml_data() {
297 let analyzer = AmlDetectabilityAnalyzer::new();
298 let typologies: Vec<TypologyData> = EXPECTED_TYPOLOGIES
301 .iter()
302 .map(|(canonical, _aliases)| TypologyData {
303 name: canonical.to_string(),
304 scenario_count: 5,
305 case_ids_consistent: true,
306 })
307 .collect();
308 let transactions = vec![
309 AmlTransactionData {
310 transaction_id: "T001".to_string(),
311 typology: "structuring".to_string(),
312 case_id: "C001".to_string(),
313 amount: 9_500.0,
314 is_flagged: true,
315 },
316 AmlTransactionData {
317 transaction_id: "T002".to_string(),
318 typology: "structuring".to_string(),
319 case_id: "C001".to_string(),
320 amount: 9_800.0,
321 is_flagged: true,
322 },
323 ];
324
325 let result = analyzer.analyze(&transactions, &typologies).unwrap();
326 assert!(result.passes);
327 assert_eq!(result.typology_coverage, 1.0);
328 }
329
330 #[test]
331 fn test_missing_typologies() {
332 let analyzer = AmlDetectabilityAnalyzer::new();
333 let typologies = vec![TypologyData {
334 name: "structuring".to_string(),
335 scenario_count: 5,
336 case_ids_consistent: true,
337 }];
338
339 let result = analyzer.analyze(&[], &typologies).unwrap();
340 assert!(!result.passes); }
342
343 #[test]
344 fn test_empty() {
345 let analyzer = AmlDetectabilityAnalyzer::new();
346 let result = analyzer.analyze(&[], &[]).unwrap();
347 assert!(!result.passes); }
349}