use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct AmlTransactionData {
pub transaction_id: String,
pub typology: String,
pub case_id: String,
pub amount: f64,
pub is_flagged: bool,
}
#[derive(Debug, Clone)]
pub struct TypologyData {
pub name: String,
pub scenario_count: usize,
pub case_ids_consistent: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmlDetectabilityThresholds {
pub min_typology_coverage: f64,
pub min_scenario_coherence: f64,
pub structuring_threshold: f64,
}
impl Default for AmlDetectabilityThresholds {
fn default() -> Self {
Self {
min_typology_coverage: 0.80,
min_scenario_coherence: 0.90,
structuring_threshold: 10_000.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypologyDetectability {
pub name: String,
pub transaction_count: usize,
pub case_count: usize,
pub flag_rate: f64,
pub pattern_detected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmlDetectabilityAnalysis {
pub typology_coverage: f64,
pub scenario_coherence: f64,
pub per_typology: Vec<TypologyDetectability>,
pub total_transactions: usize,
pub passes: bool,
pub issues: Vec<String>,
}
const EXPECTED_TYPOLOGIES: &[&str] = &[
"structuring",
"layering",
"mule_network",
"round_tripping",
"fraud",
"spoofing",
];
pub struct AmlDetectabilityAnalyzer {
thresholds: AmlDetectabilityThresholds,
}
impl AmlDetectabilityAnalyzer {
pub fn new() -> Self {
Self {
thresholds: AmlDetectabilityThresholds::default(),
}
}
pub fn with_thresholds(thresholds: AmlDetectabilityThresholds) -> Self {
Self { thresholds }
}
pub fn analyze(
&self,
transactions: &[AmlTransactionData],
typologies: &[TypologyData],
) -> EvalResult<AmlDetectabilityAnalysis> {
let mut issues = Vec::new();
let present_typologies: std::collections::HashSet<&str> =
typologies.iter().map(|t| t.name.as_str()).collect();
let covered = EXPECTED_TYPOLOGIES
.iter()
.filter(|&&t| present_typologies.contains(t))
.count();
let typology_coverage = covered as f64 / EXPECTED_TYPOLOGIES.len() as f64;
let coherent = typologies.iter().filter(|t| t.case_ids_consistent).count();
let scenario_coherence = if typologies.is_empty() {
1.0
} else {
coherent as f64 / typologies.len() as f64
};
let mut by_typology: HashMap<String, Vec<&AmlTransactionData>> = HashMap::new();
for txn in transactions {
by_typology
.entry(txn.typology.clone())
.or_default()
.push(txn);
}
let mut per_typology = Vec::new();
for (name, txns) in &by_typology {
let case_ids: std::collections::HashSet<&str> =
txns.iter().map(|t| t.case_id.as_str()).collect();
let flagged = txns.iter().filter(|t| t.is_flagged).count();
let flag_rate = if txns.is_empty() {
0.0
} else {
flagged as f64 / txns.len() as f64
};
let pattern_detected = match name.as_str() {
"structuring" => {
let below = txns
.iter()
.filter(|t| t.amount < self.thresholds.structuring_threshold)
.count();
below as f64 / txns.len().max(1) as f64 > 0.5
}
"layering" => {
!case_ids.is_empty() && txns.len() > case_ids.len()
}
_ => {
let suspicious_count = txns.iter().filter(|t| t.is_flagged).count();
let suspicious_ratio = suspicious_count as f64 / txns.len().max(1) as f64;
!txns.is_empty() && suspicious_ratio > 0.0
}
};
per_typology.push(TypologyDetectability {
name: name.clone(),
transaction_count: txns.len(),
case_count: case_ids.len(),
flag_rate,
pattern_detected,
});
}
if typology_coverage < self.thresholds.min_typology_coverage {
issues.push(format!(
"Typology coverage {:.3} < {:.3}",
typology_coverage, self.thresholds.min_typology_coverage
));
}
if scenario_coherence < self.thresholds.min_scenario_coherence {
issues.push(format!(
"Scenario coherence {:.3} < {:.3}",
scenario_coherence, self.thresholds.min_scenario_coherence
));
}
let passes = issues.is_empty();
Ok(AmlDetectabilityAnalysis {
typology_coverage,
scenario_coherence,
per_typology,
total_transactions: transactions.len(),
passes,
issues,
})
}
}
impl Default for AmlDetectabilityAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_good_aml_data() {
let analyzer = AmlDetectabilityAnalyzer::new();
let typologies: Vec<TypologyData> = EXPECTED_TYPOLOGIES
.iter()
.map(|name| TypologyData {
name: name.to_string(),
scenario_count: 5,
case_ids_consistent: true,
})
.collect();
let transactions = vec![
AmlTransactionData {
transaction_id: "T001".to_string(),
typology: "structuring".to_string(),
case_id: "C001".to_string(),
amount: 9_500.0,
is_flagged: true,
},
AmlTransactionData {
transaction_id: "T002".to_string(),
typology: "structuring".to_string(),
case_id: "C001".to_string(),
amount: 9_800.0,
is_flagged: true,
},
];
let result = analyzer.analyze(&transactions, &typologies).unwrap();
assert!(result.passes);
assert_eq!(result.typology_coverage, 1.0);
}
#[test]
fn test_missing_typologies() {
let analyzer = AmlDetectabilityAnalyzer::new();
let typologies = vec![TypologyData {
name: "structuring".to_string(),
scenario_count: 5,
case_ids_consistent: true,
}];
let result = analyzer.analyze(&[], &typologies).unwrap();
assert!(!result.passes); }
#[test]
fn test_empty() {
let analyzer = AmlDetectabilityAnalyzer::new();
let result = analyzer.analyze(&[], &[]).unwrap();
assert!(!result.passes); }
}