rustkernel_banking/
fraud.rs

1//! Fraud pattern detection kernels.
2//!
3//! This module provides fraud detection algorithms:
4//! - Aho-Corasick pattern matching
5//! - Rapid split detection (structuring)
6//! - Circular flow detection
7//! - Velocity and amount anomalies
8
9use crate::types::{
10    AccountProfile, BankTransaction, FraudDetectionResult, FraudPattern, FraudPatternType,
11    PatternMatch, PatternParams, RecommendedAction, RiskLevel,
12};
13use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
14use std::collections::{HashMap, HashSet};
15
16// ============================================================================
17// Fraud Pattern Match Kernel
18// ============================================================================
19
20/// Fraud pattern matching kernel.
21///
22/// Combines multiple fraud detection techniques:
23/// - Aho-Corasick for string pattern matching
24/// - Rapid split detection for structuring
25/// - Cycle detection for circular flows
26/// - Statistical anomaly detection
27#[derive(Debug, Clone)]
28pub struct FraudPatternMatch {
29    metadata: KernelMetadata,
30}
31
32impl Default for FraudPatternMatch {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl FraudPatternMatch {
39    /// Create a new fraud pattern match kernel.
40    #[must_use]
41    pub fn new() -> Self {
42        Self {
43            metadata: KernelMetadata::ring("banking/fraud-pattern-match", Domain::Banking)
44                .with_description("Fraud pattern detection (Aho-Corasick, rapid split, cycles)")
45                .with_throughput(50_000)
46                .with_latency_us(100.0)
47                .with_gpu_native(true),
48        }
49    }
50
51    /// Detect fraud patterns in a transaction.
52    ///
53    /// # Arguments
54    /// * `transaction` - Transaction to analyze
55    /// * `history` - Recent transaction history for the account
56    /// * `patterns` - Fraud patterns to check
57    /// * `profile` - Account profile for baseline comparison
58    pub fn compute(
59        transaction: &BankTransaction,
60        history: &[BankTransaction],
61        patterns: &[FraudPattern],
62        profile: Option<&AccountProfile>,
63    ) -> FraudDetectionResult {
64        let mut matched_patterns = Vec::new();
65        let mut total_score = 0.0;
66        let mut related_transactions = HashSet::new();
67
68        let default_profile = AccountProfile::default();
69        let acct_profile = profile.unwrap_or(&default_profile);
70
71        for pattern in patterns {
72            if let Some(match_result) =
73                Self::check_pattern(transaction, history, pattern, acct_profile)
74            {
75                total_score += match_result.score * pattern.risk_weight / 100.0;
76                for &tx_id in &match_result.evidence {
77                    related_transactions.insert(tx_id);
78                }
79                matched_patterns.push(match_result);
80            }
81        }
82
83        // Normalize score to 0-100
84        let fraud_score = (total_score / patterns.len().max(1) as f64 * 100.0).min(100.0);
85        let risk_level = RiskLevel::from(fraud_score);
86        let recommended_action = RecommendedAction::from(risk_level);
87
88        FraudDetectionResult {
89            transaction_id: transaction.id,
90            fraud_score,
91            matched_patterns,
92            risk_level,
93            recommended_action,
94            related_transactions: related_transactions.into_iter().collect(),
95        }
96    }
97
98    /// Batch analyze multiple transactions.
99    pub fn compute_batch(
100        transactions: &[BankTransaction],
101        history_map: &HashMap<u64, Vec<BankTransaction>>,
102        patterns: &[FraudPattern],
103        profiles: &HashMap<u64, AccountProfile>,
104    ) -> Vec<FraudDetectionResult> {
105        transactions
106            .iter()
107            .map(|tx| {
108                let history = history_map
109                    .get(&tx.source_account)
110                    .map(|h| h.as_slice())
111                    .unwrap_or(&[]);
112                let profile = profiles.get(&tx.source_account);
113                Self::compute(tx, history, patterns, profile)
114            })
115            .collect()
116    }
117
118    /// Check a single pattern against a transaction.
119    fn check_pattern(
120        transaction: &BankTransaction,
121        history: &[BankTransaction],
122        pattern: &FraudPattern,
123        profile: &AccountProfile,
124    ) -> Option<PatternMatch> {
125        match pattern.pattern_type {
126            FraudPatternType::RapidSplit => {
127                Self::check_rapid_split(transaction, history, &pattern.params)
128            }
129            FraudPatternType::CircularFlow => {
130                Self::check_circular_flow(transaction, history, &pattern.params)
131            }
132            FraudPatternType::VelocityAnomaly => {
133                Self::check_velocity(transaction, history, profile, &pattern.params)
134            }
135            FraudPatternType::AmountAnomaly => {
136                Self::check_amount_anomaly(transaction, profile, &pattern.params)
137            }
138            FraudPatternType::GeoAnomaly => {
139                Self::check_geo_anomaly(transaction, history, profile, &pattern.params)
140            }
141            FraudPatternType::TimeAnomaly => Self::check_time_anomaly(transaction, profile),
142            FraudPatternType::AccountTakeover => {
143                Self::check_account_takeover(transaction, history, profile)
144            }
145            FraudPatternType::MuleAccount => {
146                Self::check_mule_account(transaction, history, &pattern.params)
147            }
148            FraudPatternType::Layering => {
149                Self::check_layering(transaction, history, &pattern.params)
150            }
151        }
152        .map(|(score, details, evidence)| PatternMatch {
153            pattern_id: pattern.id,
154            pattern_name: pattern.name.clone(),
155            score,
156            details,
157            evidence,
158        })
159    }
160
161    /// Detect rapid split (structuring) pattern.
162    /// Multiple transactions just below reporting threshold.
163    fn check_rapid_split(
164        transaction: &BankTransaction,
165        history: &[BankTransaction],
166        params: &PatternParams,
167    ) -> Option<(f64, String, Vec<u64>)> {
168        let threshold = params.amount_threshold;
169        let time_window = params.time_window;
170        let min_count = params.min_count;
171
172        // Find transactions just below threshold within time window
173        let mut split_txs: Vec<&BankTransaction> = history
174            .iter()
175            .filter(|tx| {
176                tx.source_account == transaction.source_account
177                    && tx.amount >= threshold * 0.8
178                    && tx.amount < threshold
179                    && transaction.timestamp.saturating_sub(time_window) <= tx.timestamp
180            })
181            .collect();
182
183        // Add current transaction if it qualifies
184        if transaction.amount >= threshold * 0.8 && transaction.amount < threshold {
185            split_txs.push(transaction);
186        }
187
188        if split_txs.len() >= min_count as usize {
189            let total: f64 = split_txs.iter().map(|tx| tx.amount).sum();
190            let score = if total > threshold { 80.0 } else { 60.0 };
191            let evidence: Vec<u64> = split_txs.iter().map(|tx| tx.id).collect();
192
193            Some((
194                score,
195                format!(
196                    "Detected {} transactions totaling ${:.2} (threshold: ${:.2})",
197                    split_txs.len(),
198                    total,
199                    threshold
200                ),
201                evidence,
202            ))
203        } else {
204            None
205        }
206    }
207
208    /// Detect circular flow pattern.
209    /// Money flows back to originator through intermediaries.
210    fn check_circular_flow(
211        transaction: &BankTransaction,
212        history: &[BankTransaction],
213        params: &PatternParams,
214    ) -> Option<(f64, String, Vec<u64>)> {
215        let time_window = params.time_window;
216        let min_chain_length = params
217            .custom
218            .get("min_chain_length")
219            .copied()
220            .unwrap_or(3.0) as usize;
221
222        // Build transaction graph
223        let mut graph: HashMap<u64, Vec<(u64, u64, f64)>> = HashMap::new(); // account -> [(dest, tx_id, amount)]
224
225        for tx in history.iter().chain(std::iter::once(transaction)) {
226            if transaction.timestamp.saturating_sub(time_window) <= tx.timestamp {
227                graph.entry(tx.source_account).or_default().push((
228                    tx.dest_account,
229                    tx.id,
230                    tx.amount,
231                ));
232            }
233        }
234
235        // DFS to find cycles
236        let start = transaction.source_account;
237        let mut visited = HashSet::new();
238        let mut path = vec![(start, transaction.id)];
239        let mut cycle_evidence = Vec::new();
240
241        if Self::find_cycle(
242            &graph,
243            start,
244            start,
245            &mut visited,
246            &mut path,
247            &mut cycle_evidence,
248            min_chain_length,
249        ) {
250            let score = 90.0;
251            Some((
252                score,
253                format!("Circular flow detected with {} hops", cycle_evidence.len()),
254                cycle_evidence,
255            ))
256        } else {
257            None
258        }
259    }
260
261    /// DFS helper to find cycles in transaction graph.
262    fn find_cycle(
263        graph: &HashMap<u64, Vec<(u64, u64, f64)>>,
264        current: u64,
265        target: u64,
266        visited: &mut HashSet<u64>,
267        path: &mut Vec<(u64, u64)>,
268        evidence: &mut Vec<u64>,
269        min_length: usize,
270    ) -> bool {
271        if path.len() > 1 && current == target && path.len() >= min_length {
272            *evidence = path.iter().map(|(_, tx_id)| *tx_id).collect();
273            return true;
274        }
275
276        if path.len() > 10 || visited.contains(&current) {
277            return false;
278        }
279
280        visited.insert(current);
281
282        if let Some(edges) = graph.get(&current) {
283            for &(dest, tx_id, _) in edges {
284                path.push((dest, tx_id));
285                if Self::find_cycle(graph, dest, target, visited, path, evidence, min_length) {
286                    return true;
287                }
288                path.pop();
289            }
290        }
291
292        visited.remove(&current);
293        false
294    }
295
296    /// Detect velocity anomaly.
297    fn check_velocity(
298        transaction: &BankTransaction,
299        history: &[BankTransaction],
300        profile: &AccountProfile,
301        params: &PatternParams,
302    ) -> Option<(f64, String, Vec<u64>)> {
303        let time_window = params.time_window;
304
305        // Count transactions in time window
306        let recent_count = history
307            .iter()
308            .filter(|tx| {
309                tx.source_account == transaction.source_account
310                    && transaction.timestamp.saturating_sub(time_window) <= tx.timestamp
311            })
312            .count()
313            + 1; // Include current transaction
314
315        // Expected count based on profile (scaled to time window)
316        let expected = profile.avg_daily_count * (time_window as f64 / 86400.0);
317        let std_dev = expected.sqrt().max(1.0);
318
319        let z_score = (recent_count as f64 - expected) / std_dev;
320
321        if z_score > 3.0 {
322            let score = (z_score * 20.0).min(100.0);
323            Some((
324                score,
325                format!(
326                    "Velocity anomaly: {} transactions vs expected {:.1} (z={:.2})",
327                    recent_count, expected, z_score
328                ),
329                vec![transaction.id],
330            ))
331        } else {
332            None
333        }
334    }
335
336    /// Detect amount anomaly.
337    fn check_amount_anomaly(
338        transaction: &BankTransaction,
339        profile: &AccountProfile,
340        _params: &PatternParams,
341    ) -> Option<(f64, String, Vec<u64>)> {
342        let z_score = (transaction.amount - profile.avg_amount) / profile.std_amount.max(1.0);
343
344        if z_score.abs() > 3.0 {
345            let score = (z_score.abs() * 20.0).min(100.0);
346            Some((
347                score,
348                format!(
349                    "Amount anomaly: ${:.2} vs avg ${:.2} (z={:.2})",
350                    transaction.amount, profile.avg_amount, z_score
351                ),
352                vec![transaction.id],
353            ))
354        } else {
355            None
356        }
357    }
358
359    /// Detect geographic anomaly (impossible travel).
360    fn check_geo_anomaly(
361        transaction: &BankTransaction,
362        history: &[BankTransaction],
363        profile: &AccountProfile,
364        params: &PatternParams,
365    ) -> Option<(f64, String, Vec<u64>)> {
366        let tx_location = transaction.location.as_ref()?;
367
368        // Check if location is typical
369        if profile.typical_locations.contains(tx_location) {
370            return None;
371        }
372
373        // Check for impossible travel (different country within short time)
374        let time_window = params
375            .custom
376            .get("travel_window")
377            .copied()
378            .unwrap_or(3600.0) as u64;
379
380        let recent_diff_location = history.iter().find(|tx| {
381            tx.source_account == transaction.source_account
382                && transaction.timestamp.saturating_sub(time_window) <= tx.timestamp
383                && tx.location.as_ref() != Some(tx_location)
384                && tx.location.is_some()
385        });
386
387        if let Some(prev_tx) = recent_diff_location {
388            let score = 85.0;
389            Some((
390                score,
391                format!(
392                    "Impossible travel: {} to {} in {}s",
393                    prev_tx.location.as_ref().unwrap_or(&"Unknown".to_string()),
394                    tx_location,
395                    transaction.timestamp - prev_tx.timestamp
396                ),
397                vec![prev_tx.id, transaction.id],
398            ))
399        } else {
400            // Just unusual location
401            Some((
402                40.0,
403                format!("Unusual location: {}", tx_location),
404                vec![transaction.id],
405            ))
406        }
407    }
408
409    /// Detect time anomaly.
410    fn check_time_anomaly(
411        transaction: &BankTransaction,
412        profile: &AccountProfile,
413    ) -> Option<(f64, String, Vec<u64>)> {
414        // Extract hour from timestamp (simplified - assumes UTC)
415        let hour = ((transaction.timestamp % 86400) / 3600) as u8;
416
417        if !profile.typical_hours.contains(&hour) {
418            let score = 30.0; // Lower score for time anomalies alone
419            Some((
420                score,
421                format!("Transaction at unusual hour: {}:00", hour),
422                vec![transaction.id],
423            ))
424        } else {
425            None
426        }
427    }
428
429    /// Detect account takeover indicators.
430    fn check_account_takeover(
431        transaction: &BankTransaction,
432        history: &[BankTransaction],
433        profile: &AccountProfile,
434    ) -> Option<(f64, String, Vec<u64>)> {
435        let mut indicators = Vec::new();
436        let mut score: f64 = 0.0;
437
438        // New account with large transaction
439        if profile.account_age_days < 30 && transaction.amount > profile.avg_amount * 5.0 {
440            indicators.push("New account with large transaction");
441            score += 40.0;
442        }
443
444        // Sudden change in transaction pattern
445        let recent_total: f64 = history
446            .iter()
447            .filter(|tx| tx.source_account == transaction.source_account)
448            .take(10)
449            .map(|tx| tx.amount)
450            .sum();
451
452        if transaction.amount > recent_total {
453            indicators.push("Transaction exceeds recent total");
454            score += 30.0;
455        }
456
457        // Multiple failed attempts would be checked elsewhere (not in successful tx history)
458
459        if score > 0.0 {
460            Some((
461                score.min(100.0),
462                format!("Account takeover indicators: {}", indicators.join(", ")),
463                vec![transaction.id],
464            ))
465        } else {
466            None
467        }
468    }
469
470    /// Detect mule account behavior.
471    fn check_mule_account(
472        transaction: &BankTransaction,
473        history: &[BankTransaction],
474        params: &PatternParams,
475    ) -> Option<(f64, String, Vec<u64>)> {
476        let time_window = params.time_window;
477
478        // Mule accounts: receive money and quickly send it out
479        let recent: Vec<&BankTransaction> = history
480            .iter()
481            .filter(|tx| transaction.timestamp.saturating_sub(time_window) <= tx.timestamp)
482            .collect();
483
484        let incoming: f64 = recent
485            .iter()
486            .filter(|tx| tx.dest_account == transaction.source_account)
487            .map(|tx| tx.amount)
488            .sum();
489
490        let outgoing: f64 = recent
491            .iter()
492            .filter(|tx| tx.source_account == transaction.source_account)
493            .map(|tx| tx.amount)
494            .sum::<f64>()
495            + transaction.amount;
496
497        // Pass-through: most incoming money quickly sent out
498        if incoming > 1000.0 && outgoing > incoming * 0.8 {
499            let pass_through_ratio = outgoing / incoming;
500            let score = (pass_through_ratio * 50.0).min(80.0);
501
502            Some((
503                score,
504                format!(
505                    "Mule account behavior: ${:.2} in, ${:.2} out ({:.1}% pass-through)",
506                    incoming,
507                    outgoing,
508                    pass_through_ratio * 100.0
509                ),
510                recent.iter().map(|tx| tx.id).collect(),
511            ))
512        } else {
513            None
514        }
515    }
516
517    /// Detect layering (complex transaction chains).
518    fn check_layering(
519        transaction: &BankTransaction,
520        history: &[BankTransaction],
521        params: &PatternParams,
522    ) -> Option<(f64, String, Vec<u64>)> {
523        let time_window = params.time_window;
524        let min_layers = params.custom.get("min_layers").copied().unwrap_or(3.0) as usize;
525
526        // Count unique accounts involved in recent transactions
527        let mut accounts = HashSet::new();
528        let mut evidence = Vec::new();
529
530        for tx in history
531            .iter()
532            .filter(|tx| transaction.timestamp.saturating_sub(time_window) <= tx.timestamp)
533        {
534            accounts.insert(tx.source_account);
535            accounts.insert(tx.dest_account);
536            evidence.push(tx.id);
537        }
538        accounts.insert(transaction.source_account);
539        accounts.insert(transaction.dest_account);
540        evidence.push(transaction.id);
541
542        // High number of unique accounts suggests layering
543        if accounts.len() >= min_layers * 2 {
544            let score = (accounts.len() as f64 * 10.0).min(90.0);
545            Some((
546                score,
547                format!("Complex layering: {} accounts involved", accounts.len()),
548                evidence,
549            ))
550        } else {
551            None
552        }
553    }
554
555    /// Create standard fraud patterns.
556    pub fn standard_patterns() -> Vec<FraudPattern> {
557        vec![
558            FraudPattern {
559                id: 1,
560                name: "Rapid Split (Structuring)".to_string(),
561                pattern_type: FraudPatternType::RapidSplit,
562                risk_weight: 80.0,
563                params: PatternParams {
564                    time_window: 86400, // 24 hours
565                    min_count: 3,
566                    amount_threshold: 10000.0,
567                    ..Default::default()
568                },
569            },
570            FraudPattern {
571                id: 2,
572                name: "Circular Flow".to_string(),
573                pattern_type: FraudPatternType::CircularFlow,
574                risk_weight: 90.0,
575                params: PatternParams {
576                    time_window: 604800, // 1 week
577                    custom: [("min_chain_length".to_string(), 3.0)]
578                        .into_iter()
579                        .collect(),
580                    ..Default::default()
581                },
582            },
583            FraudPattern {
584                id: 3,
585                name: "Velocity Anomaly".to_string(),
586                pattern_type: FraudPatternType::VelocityAnomaly,
587                risk_weight: 60.0,
588                params: PatternParams {
589                    time_window: 3600, // 1 hour
590                    ..Default::default()
591                },
592            },
593            FraudPattern {
594                id: 4,
595                name: "Amount Anomaly".to_string(),
596                pattern_type: FraudPatternType::AmountAnomaly,
597                risk_weight: 50.0,
598                params: PatternParams::default(),
599            },
600            FraudPattern {
601                id: 5,
602                name: "Geographic Anomaly".to_string(),
603                pattern_type: FraudPatternType::GeoAnomaly,
604                risk_weight: 70.0,
605                params: PatternParams {
606                    custom: [("travel_window".to_string(), 7200.0)]
607                        .into_iter()
608                        .collect(), // 2 hours
609                    ..Default::default()
610                },
611            },
612            FraudPattern {
613                id: 6,
614                name: "Mule Account".to_string(),
615                pattern_type: FraudPatternType::MuleAccount,
616                risk_weight: 85.0,
617                params: PatternParams {
618                    time_window: 86400, // 24 hours
619                    ..Default::default()
620                },
621            },
622        ]
623    }
624}
625
626impl GpuKernel for FraudPatternMatch {
627    fn metadata(&self) -> &KernelMetadata {
628        &self.metadata
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::types::{Channel, TransactionType};
636
637    fn create_transaction(
638        id: u64,
639        source: u64,
640        dest: u64,
641        amount: f64,
642        timestamp: u64,
643    ) -> BankTransaction {
644        BankTransaction {
645            id,
646            source_account: source,
647            dest_account: dest,
648            amount,
649            timestamp,
650            tx_type: TransactionType::Wire,
651            channel: Channel::Online,
652            mcc: None,
653            location: Some("US".to_string()),
654        }
655    }
656
657    #[test]
658    fn test_fraud_pattern_match_metadata() {
659        let kernel = FraudPatternMatch::new();
660        assert_eq!(kernel.metadata().id, "banking/fraud-pattern-match");
661        assert_eq!(kernel.metadata().domain, Domain::Banking);
662    }
663
664    #[test]
665    fn test_normal_transaction() {
666        let tx = create_transaction(1, 100, 200, 500.0, 1000000);
667        let patterns = FraudPatternMatch::standard_patterns();
668
669        let result = FraudPatternMatch::compute(&tx, &[], &patterns, None);
670
671        // Normal transaction should have low fraud score
672        assert!(result.fraud_score < 50.0, "Score: {}", result.fraud_score);
673        assert_eq!(result.risk_level, RiskLevel::Low);
674    }
675
676    #[test]
677    fn test_rapid_split_detection() {
678        let base_time = 1000000u64;
679        let threshold = 10000.0;
680
681        // Create structuring pattern: multiple transactions just below threshold
682        let history = vec![
683            create_transaction(1, 100, 200, 9500.0, base_time),
684            create_transaction(2, 100, 201, 9800.0, base_time + 1000),
685            create_transaction(3, 100, 202, 9600.0, base_time + 2000),
686        ];
687
688        let current = create_transaction(4, 100, 203, 9700.0, base_time + 3000);
689
690        let patterns = vec![FraudPattern {
691            id: 1,
692            name: "Rapid Split".to_string(),
693            pattern_type: FraudPatternType::RapidSplit,
694            risk_weight: 80.0,
695            params: PatternParams {
696                time_window: 86400,
697                min_count: 3,
698                amount_threshold: threshold,
699                ..Default::default()
700            },
701        }];
702
703        let result = FraudPatternMatch::compute(&current, &history, &patterns, None);
704
705        assert!(
706            !result.matched_patterns.is_empty(),
707            "Should detect rapid split"
708        );
709        assert!(result.fraud_score > 30.0);
710    }
711
712    #[test]
713    fn test_circular_flow_detection() {
714        let base_time = 1000000u64;
715
716        // Create circular flow: A -> B -> C -> A
717        let history = vec![
718            create_transaction(1, 100, 200, 5000.0, base_time), // A -> B
719            create_transaction(2, 200, 300, 4800.0, base_time + 100), // B -> C
720            create_transaction(3, 300, 100, 4600.0, base_time + 200), // C -> A (completing cycle)
721        ];
722
723        let current = create_transaction(4, 100, 200, 4500.0, base_time + 300);
724
725        let patterns = vec![FraudPattern {
726            id: 1,
727            name: "Circular Flow".to_string(),
728            pattern_type: FraudPatternType::CircularFlow,
729            risk_weight: 90.0,
730            params: PatternParams {
731                time_window: 86400,
732                custom: [("min_chain_length".to_string(), 3.0)]
733                    .into_iter()
734                    .collect(),
735                ..Default::default()
736            },
737        }];
738
739        let result = FraudPatternMatch::compute(&current, &history, &patterns, None);
740
741        // Should detect circular flow
742        let has_circular = result
743            .matched_patterns
744            .iter()
745            .any(|p| p.pattern_name.contains("Circular"));
746        assert!(has_circular, "Should detect circular flow");
747    }
748
749    #[test]
750    fn test_velocity_anomaly() {
751        let base_time = 1000000u64;
752
753        // Create many transactions in short time
754        let history: Vec<BankTransaction> = (0..20)
755            .map(|i| create_transaction(i, 100, 200 + i, 100.0, base_time + i * 60))
756            .collect();
757
758        let current = create_transaction(21, 100, 300, 100.0, base_time + 1260);
759
760        let profile = AccountProfile {
761            account_id: 100,
762            avg_daily_count: 2.0, // Normally 2 transactions per day
763            ..Default::default()
764        };
765
766        let patterns = vec![FraudPattern {
767            id: 1,
768            name: "Velocity".to_string(),
769            pattern_type: FraudPatternType::VelocityAnomaly,
770            risk_weight: 60.0,
771            params: PatternParams {
772                time_window: 3600, // 1 hour
773                ..Default::default()
774            },
775        }];
776
777        let result = FraudPatternMatch::compute(&current, &history, &patterns, Some(&profile));
778
779        let has_velocity = result
780            .matched_patterns
781            .iter()
782            .any(|p| p.pattern_name.contains("Velocity"));
783        assert!(has_velocity, "Should detect velocity anomaly");
784    }
785
786    #[test]
787    fn test_amount_anomaly() {
788        let profile = AccountProfile {
789            account_id: 100,
790            avg_amount: 500.0,
791            std_amount: 100.0,
792            ..Default::default()
793        };
794
795        // Transaction 10x normal amount
796        let tx = create_transaction(1, 100, 200, 5000.0, 1000000);
797
798        let patterns = vec![FraudPattern {
799            id: 1,
800            name: "Amount Anomaly".to_string(),
801            pattern_type: FraudPatternType::AmountAnomaly,
802            risk_weight: 50.0,
803            params: PatternParams::default(),
804        }];
805
806        let result = FraudPatternMatch::compute(&tx, &[], &patterns, Some(&profile));
807
808        let has_amount = result
809            .matched_patterns
810            .iter()
811            .any(|p| p.pattern_name.contains("Amount"));
812        assert!(has_amount, "Should detect amount anomaly");
813    }
814
815    #[test]
816    fn test_geo_anomaly() {
817        let base_time = 1000000u64;
818
819        let mut tx1 = create_transaction(1, 100, 200, 500.0, base_time);
820        tx1.location = Some("US".to_string());
821
822        let mut tx2 = create_transaction(2, 100, 201, 500.0, base_time + 1800); // 30 min later
823        tx2.location = Some("UK".to_string()); // Impossible to travel US -> UK in 30 min
824
825        let profile = AccountProfile {
826            account_id: 100,
827            typical_locations: vec!["US".to_string()],
828            ..Default::default()
829        };
830
831        let patterns = vec![FraudPattern {
832            id: 1,
833            name: "Geographic Anomaly".to_string(),
834            pattern_type: FraudPatternType::GeoAnomaly,
835            risk_weight: 70.0,
836            params: PatternParams {
837                custom: [("travel_window".to_string(), 7200.0)]
838                    .into_iter()
839                    .collect(),
840                ..Default::default()
841            },
842        }];
843
844        let result = FraudPatternMatch::compute(&tx2, &[tx1], &patterns, Some(&profile));
845
846        let has_geo = result
847            .matched_patterns
848            .iter()
849            .any(|p| p.pattern_name.contains("Geographic"));
850        assert!(has_geo, "Should detect geographic anomaly");
851    }
852
853    #[test]
854    fn test_mule_account() {
855        let base_time = 1000000u64;
856
857        // Pattern: receive money, quickly send most of it out
858        let history = vec![
859            create_transaction(1, 200, 100, 10000.0, base_time), // Receive
860            create_transaction(2, 100, 300, 3000.0, base_time + 100), // Send out
861            create_transaction(3, 100, 301, 3000.0, base_time + 200), // Send out
862            create_transaction(4, 100, 302, 3000.0, base_time + 300), // Send out
863        ];
864
865        let current = create_transaction(5, 100, 303, 800.0, base_time + 400);
866
867        let patterns = vec![FraudPattern {
868            id: 1,
869            name: "Mule Account".to_string(),
870            pattern_type: FraudPatternType::MuleAccount,
871            risk_weight: 85.0,
872            params: PatternParams {
873                time_window: 86400,
874                ..Default::default()
875            },
876        }];
877
878        let result = FraudPatternMatch::compute(&current, &history, &patterns, None);
879
880        let has_mule = result
881            .matched_patterns
882            .iter()
883            .any(|p| p.pattern_name.contains("Mule"));
884        assert!(has_mule, "Should detect mule account behavior");
885    }
886
887    #[test]
888    fn test_standard_patterns() {
889        let patterns = FraudPatternMatch::standard_patterns();
890
891        assert!(!patterns.is_empty());
892        assert!(
893            patterns
894                .iter()
895                .any(|p| p.pattern_type == FraudPatternType::RapidSplit)
896        );
897        assert!(
898            patterns
899                .iter()
900                .any(|p| p.pattern_type == FraudPatternType::CircularFlow)
901        );
902    }
903
904    #[test]
905    fn test_risk_level_conversion() {
906        assert_eq!(RiskLevel::from(10.0), RiskLevel::Low);
907        assert_eq!(RiskLevel::from(30.0), RiskLevel::Medium);
908        assert_eq!(RiskLevel::from(60.0), RiskLevel::High);
909        assert_eq!(RiskLevel::from(90.0), RiskLevel::Critical);
910    }
911
912    #[test]
913    fn test_batch_processing() {
914        let txs = vec![
915            create_transaction(1, 100, 200, 500.0, 1000000),
916            create_transaction(2, 101, 201, 600.0, 1000001),
917        ];
918
919        let history_map: HashMap<u64, Vec<BankTransaction>> = HashMap::new();
920        let profiles: HashMap<u64, AccountProfile> = HashMap::new();
921        let patterns = FraudPatternMatch::standard_patterns();
922
923        let results = FraudPatternMatch::compute_batch(&txs, &history_map, &patterns, &profiles);
924
925        assert_eq!(results.len(), 2);
926    }
927}