1use std::collections::HashMap;
9
10use chrono::{Datelike, Timelike};
11
12use datasynth_banking::models::{BankAccount, BankTransaction, BankingCustomer, CounterpartyPool};
13use datasynth_core::models::banking::{
14 AmlTypology, CashIntensity, Direction, RiskTier, TransactionChannel, TurnoverBand,
15};
16
17use crate::models::{EdgeType, Graph, GraphEdge, GraphNode, GraphType, NodeId, NodeType};
18
19#[derive(Debug, Clone)]
21pub struct BankingGraphConfig {
22 pub include_customers: bool,
24 pub include_accounts: bool,
26 pub include_counterparties: bool,
28 pub include_beneficial_ownership: bool,
30 pub create_transaction_edges: bool,
32 pub min_transaction_amount: f64,
34 pub aggregate_parallel_edges: bool,
36 pub include_temporal_features: bool,
38 pub include_risk_features: bool,
40}
41
42impl Default for BankingGraphConfig {
43 fn default() -> Self {
44 Self {
45 include_customers: true,
46 include_accounts: true,
47 include_counterparties: true,
48 include_beneficial_ownership: true,
49 create_transaction_edges: true,
50 min_transaction_amount: 0.0,
51 aggregate_parallel_edges: false,
52 include_temporal_features: true,
53 include_risk_features: true,
54 }
55 }
56}
57
58pub struct BankingGraphBuilder {
60 config: BankingGraphConfig,
61 graph: Graph,
62 customer_nodes: HashMap<String, NodeId>,
64 account_nodes: HashMap<String, NodeId>,
66 counterparty_nodes: HashMap<String, NodeId>,
68 edge_aggregation: HashMap<(NodeId, NodeId), AggregatedBankingEdge>,
70}
71
72impl BankingGraphBuilder {
73 pub fn new(config: BankingGraphConfig) -> Self {
75 Self {
76 config,
77 graph: Graph::new("banking_network", GraphType::Custom("banking".to_string())),
78 customer_nodes: HashMap::new(),
79 account_nodes: HashMap::new(),
80 counterparty_nodes: HashMap::new(),
81 edge_aggregation: HashMap::new(),
82 }
83 }
84
85 pub fn add_customers(&mut self, customers: &[BankingCustomer]) {
87 if !self.config.include_customers {
88 return;
89 }
90
91 for customer in customers {
92 self.add_customer(customer);
93 }
94 }
95
96 fn add_customer(&mut self, customer: &BankingCustomer) -> NodeId {
98 let key = customer.customer_id.to_string();
99
100 if let Some(&id) = self.customer_nodes.get(&key) {
101 return id;
102 }
103
104 let mut node = GraphNode::new(
105 0,
106 NodeType::Customer,
107 key.clone(),
108 customer.name.display_name().to_string(),
109 );
110
111 node.categorical_features.insert(
113 "customer_type".to_string(),
114 format!("{:?}", customer.customer_type),
115 );
116 node.categorical_features.insert(
117 "residence_country".to_string(),
118 customer.residence_country.clone(),
119 );
120 node.categorical_features
121 .insert("risk_tier".to_string(), format!("{:?}", customer.risk_tier));
122
123 if self.config.include_risk_features {
125 let risk_score = match customer.risk_tier {
127 RiskTier::Low => 0.0,
128 RiskTier::Medium => 0.33,
129 RiskTier::High => 0.67,
130 RiskTier::VeryHigh | RiskTier::Prohibited => 1.0,
131 };
132 node.features.push(risk_score);
133
134 node.features.push(if customer.is_pep { 1.0 } else { 0.0 });
136
137 node.features.push(customer.account_ids.len() as f64);
139
140 let kyc = &customer.kyc_profile;
142
143 let turnover_band = match kyc.expected_monthly_turnover {
145 TurnoverBand::VeryLow => 1.0,
146 TurnoverBand::Low => 2.0,
147 TurnoverBand::Medium => 3.0,
148 TurnoverBand::High => 4.0,
149 TurnoverBand::VeryHigh => 5.0,
150 TurnoverBand::UltraHigh => 6.0,
151 };
152 node.features.push(turnover_band);
153
154 let cash_intensity: f64 = match kyc.cash_intensity {
156 CashIntensity::VeryLow => 0.0,
157 CashIntensity::Low => 0.25,
158 CashIntensity::Moderate => 0.5,
159 CashIntensity::High => 0.75,
160 CashIntensity::VeryHigh => 1.0,
161 };
162 node.features.push(cash_intensity);
163 }
164
165 if customer.is_mule {
167 node = node.as_anomaly("money_mule");
168 node.labels.push("mule".to_string());
169 }
170
171 let id = self.graph.add_node(node);
172 self.customer_nodes.insert(key, id);
173 id
174 }
175
176 pub fn add_accounts(&mut self, accounts: &[BankAccount], customers: &[BankingCustomer]) {
178 if !self.config.include_accounts {
179 return;
180 }
181
182 let customer_map: HashMap<_, _> = customers.iter().map(|c| (c.customer_id, c)).collect();
184
185 for account in accounts {
186 let account_id = self.add_account(account);
187
188 if let Some(customer) = customer_map.get(&account.primary_owner_id) {
190 let customer_id = self.add_customer(customer);
191
192 let edge = GraphEdge::new(0, customer_id, account_id, EdgeType::Ownership)
193 .with_weight(1.0)
194 .with_property(
195 "relationship",
196 crate::models::EdgeProperty::String("account_owner".to_string()),
197 );
198
199 self.graph.add_edge(edge);
200 }
201 }
202 }
203
204 fn add_account(&mut self, account: &BankAccount) -> NodeId {
206 let key = account.account_id.to_string();
207
208 if let Some(&id) = self.account_nodes.get(&key) {
209 return id;
210 }
211
212 let mut node = GraphNode::new(
213 0,
214 NodeType::Account,
215 key.clone(),
216 format!("{:?} - {}", account.account_type, account.account_number),
217 );
218
219 node.categorical_features.insert(
221 "account_type".to_string(),
222 format!("{:?}", account.account_type),
223 );
224 node.categorical_features
225 .insert("currency".to_string(), account.currency.clone());
226 node.categorical_features
227 .insert("status".to_string(), format!("{:?}", account.status));
228
229 if self.config.include_risk_features {
231 let balance: f64 = account.current_balance.try_into().unwrap_or(0.0);
233 node.features.push((balance.abs() + 1.0).ln());
234
235 let limit: f64 = account.overdraft_limit.try_into().unwrap_or(0.0);
237 node.features.push((limit + 1.0).ln());
238
239 node.features.push(if account.features.debit_card {
241 1.0
242 } else {
243 0.0
244 });
245
246 node.features
248 .push(if account.features.international_transfers {
249 1.0
250 } else {
251 0.0
252 });
253 }
254
255 let id = self.graph.add_node(node);
256 self.account_nodes.insert(key, id);
257 id
258 }
259
260 pub fn add_counterparties(&mut self, pool: &CounterpartyPool) {
262 if !self.config.include_counterparties {
263 return;
264 }
265
266 for merchant in &pool.merchants {
267 self.add_counterparty_node(
268 &merchant.name,
269 "merchant",
270 Some(&format!("{:?}", merchant.mcc)),
271 );
272 }
273
274 for employer in &pool.employers {
275 let industry = employer.industry_code.as_deref().unwrap_or("Unknown");
276 self.add_counterparty_node(&employer.name, "employer", Some(industry));
277 }
278
279 for utility in &pool.utilities {
280 self.add_counterparty_node(
281 &utility.name,
282 "utility",
283 Some(&format!("{:?}", utility.utility_type)),
284 );
285 }
286 }
287
288 fn add_counterparty_node(
290 &mut self,
291 name: &str,
292 cp_type: &str,
293 category: Option<&str>,
294 ) -> NodeId {
295 let key = format!("{}_{}", cp_type, name);
296
297 if let Some(&id) = self.counterparty_nodes.get(&key) {
298 return id;
299 }
300
301 let mut node = GraphNode::new(
302 0,
303 NodeType::Custom("Counterparty".to_string()),
304 key.clone(),
305 name.to_string(),
306 );
307
308 node.categorical_features
309 .insert("counterparty_type".to_string(), cp_type.to_string());
310
311 if let Some(cat) = category {
312 node.categorical_features
313 .insert("category".to_string(), cat.to_string());
314 }
315
316 let id = self.graph.add_node(node);
317 self.counterparty_nodes.insert(key, id);
318 id
319 }
320
321 pub fn add_transactions(&mut self, transactions: &[BankTransaction]) {
323 if !self.config.create_transaction_edges {
324 return;
325 }
326
327 for txn in transactions {
328 self.add_transaction(txn);
329 }
330 }
331
332 fn add_transaction(&mut self, txn: &BankTransaction) {
334 let amount: f64 = txn.amount.try_into().unwrap_or(0.0);
335 if amount < self.config.min_transaction_amount {
336 return;
337 }
338
339 let account_key = txn.account_id.to_string();
341 let account_node = *self.account_nodes.get(&account_key).unwrap_or(&0);
342 if account_node == 0 {
343 return; }
345
346 let cp_key = format!("counterparty_{}", txn.counterparty.name);
348 let counterparty_node = if let Some(&id) = self.counterparty_nodes.get(&cp_key) {
349 id
350 } else {
351 self.add_counterparty_node(
352 &txn.counterparty.name,
353 &format!("{:?}", txn.counterparty.counterparty_type),
354 None,
355 )
356 };
357
358 let (source, target) = match txn.direction {
360 Direction::Inbound => (counterparty_node, account_node),
361 Direction::Outbound => (account_node, counterparty_node),
362 };
363
364 if self.config.aggregate_parallel_edges {
365 self.aggregate_transaction_edge(source, target, txn);
366 } else {
367 let edge = self.create_transaction_edge(source, target, txn);
368 self.graph.add_edge(edge);
369 }
370 }
371
372 fn create_transaction_edge(
374 &self,
375 source: NodeId,
376 target: NodeId,
377 txn: &BankTransaction,
378 ) -> GraphEdge {
379 let amount: f64 = txn.amount.try_into().unwrap_or(0.0);
380
381 let mut edge = GraphEdge::new(0, source, target, EdgeType::Transaction)
382 .with_weight(amount)
383 .with_timestamp(txn.timestamp_initiated.date_naive());
384
385 edge.properties.insert(
387 "transaction_id".to_string(),
388 crate::models::EdgeProperty::String(txn.transaction_id.to_string()),
389 );
390 edge.properties.insert(
391 "channel".to_string(),
392 crate::models::EdgeProperty::String(format!("{:?}", txn.channel)),
393 );
394 edge.properties.insert(
395 "category".to_string(),
396 crate::models::EdgeProperty::String(format!("{:?}", txn.category)),
397 );
398
399 edge.features.push((amount + 1.0).ln());
402
403 edge.features.push(match txn.direction {
405 Direction::Inbound => 1.0,
406 Direction::Outbound => 0.0,
407 });
408
409 let channel_code = match txn.channel {
411 TransactionChannel::CardPresent => 0.0,
412 TransactionChannel::CardNotPresent => 1.0,
413 TransactionChannel::Ach => 2.0,
414 TransactionChannel::Wire => 3.0,
415 TransactionChannel::Cash => 4.0,
416 TransactionChannel::Atm => 5.0,
417 TransactionChannel::Branch => 6.0,
418 TransactionChannel::Mobile => 7.0,
419 TransactionChannel::Online => 8.0,
420 TransactionChannel::Swift => 9.0,
421 TransactionChannel::InternalTransfer => 10.0,
422 TransactionChannel::Check => 11.0,
423 TransactionChannel::RealTimePayment => 12.0,
424 TransactionChannel::PeerToPeer => 13.0,
425 };
426 edge.features.push(channel_code / 13.0); if self.config.include_temporal_features {
430 let weekday = txn.timestamp_initiated.weekday().num_days_from_monday() as f64;
431 edge.features.push(weekday / 6.0);
432
433 let hour = txn.timestamp_initiated.hour() as f64;
434 edge.features.push(hour / 23.0);
435
436 let day = txn.timestamp_initiated.day() as f64;
437 edge.features.push(day / 31.0);
438
439 let month = txn.timestamp_initiated.month() as f64;
440 edge.features.push(month / 12.0);
441
442 edge.features.push(if weekday >= 5.0 { 1.0 } else { 0.0 });
444
445 let is_off_hours = !(7.0..=22.0).contains(&hour);
447 edge.features.push(if is_off_hours { 1.0 } else { 0.0 });
448 }
449
450 if self.config.include_risk_features {
452 edge.features.push(if txn.is_cash() { 1.0 } else { 0.0 });
454
455 edge.features
457 .push(if txn.is_cross_border() { 1.0 } else { 0.0 });
458
459 edge.features
461 .push(txn.calculate_risk_score() as f64 / 100.0);
462 }
463
464 if txn.is_suspicious {
466 edge = edge.as_anomaly(&format!(
467 "{:?}",
468 txn.suspicion_reason.unwrap_or(AmlTypology::Structuring)
469 ));
470
471 if let Some(typology) = txn.suspicion_reason {
472 edge.labels.push(format!("{:?}", typology));
473 }
474
475 if let Some(stage) = txn.laundering_stage {
476 edge.labels.push(format!("{:?}", stage));
477 }
478
479 if txn.is_spoofed {
480 edge.labels.push("spoofed".to_string());
481 }
482 }
483
484 edge
485 }
486
487 fn aggregate_transaction_edge(
489 &mut self,
490 source: NodeId,
491 target: NodeId,
492 txn: &BankTransaction,
493 ) {
494 let key = (source, target);
495 let amount: f64 = txn.amount.try_into().unwrap_or(0.0);
496 let date = txn.timestamp_initiated.date_naive();
497
498 let agg = self
499 .edge_aggregation
500 .entry(key)
501 .or_insert(AggregatedBankingEdge {
502 source,
503 target,
504 total_amount: 0.0,
505 count: 0,
506 suspicious_count: 0,
507 first_date: date,
508 last_date: date,
509 channels: HashMap::new(),
510 });
511
512 agg.total_amount += amount;
513 agg.count += 1;
514
515 if txn.is_suspicious {
516 agg.suspicious_count += 1;
517 }
518
519 if date < agg.first_date {
520 agg.first_date = date;
521 }
522 if date > agg.last_date {
523 agg.last_date = date;
524 }
525
526 let channel = format!("{:?}", txn.channel);
527 *agg.channels.entry(channel).or_insert(0) += 1;
528 }
529
530 pub fn build(mut self) -> Graph {
532 if self.config.aggregate_parallel_edges {
534 for ((source, target), agg) in self.edge_aggregation {
535 let mut edge = GraphEdge::new(0, source, target, EdgeType::Transaction)
536 .with_weight(agg.total_amount)
537 .with_timestamp(agg.last_date);
538
539 edge.features.push((agg.total_amount + 1.0).ln());
541 edge.features.push(agg.count as f64);
542 edge.features.push(agg.suspicious_count as f64);
543 edge.features
544 .push(agg.suspicious_count as f64 / agg.count.max(1) as f64);
545
546 let duration = (agg.last_date - agg.first_date).num_days() as f64;
547 edge.features.push(duration);
548
549 edge.features
551 .push(agg.total_amount / agg.count.max(1) as f64);
552
553 edge.features.push(agg.count as f64 / duration.max(1.0));
555
556 edge.features.push(agg.channels.len() as f64);
558
559 if agg.suspicious_count > 0 {
561 edge = edge.as_anomaly("suspicious_link");
562 }
563
564 self.graph.add_edge(edge);
565 }
566 }
567
568 self.graph.compute_statistics();
569 self.graph
570 }
571}
572
573#[allow(dead_code)]
575struct AggregatedBankingEdge {
576 source: NodeId,
577 target: NodeId,
578 total_amount: f64,
579 count: usize,
580 suspicious_count: usize,
581 first_date: chrono::NaiveDate,
582 last_date: chrono::NaiveDate,
583 channels: HashMap<String, usize>,
584}
585
586#[cfg(test)]
587#[allow(clippy::unwrap_used)]
588mod tests {
589 use super::*;
590 use chrono::NaiveDate;
591 use datasynth_banking::models::CounterpartyRef;
592 use datasynth_core::models::banking::{
593 BankAccountType, TransactionCategory, TransactionChannel,
594 };
595 use rust_decimal::Decimal;
596 use uuid::Uuid;
597
598 fn create_test_customer() -> BankingCustomer {
599 BankingCustomer::new_retail(
600 Uuid::new_v4(),
601 "John",
602 "Doe",
603 "US",
604 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
605 )
606 }
607
608 fn create_test_account(customer: &BankingCustomer) -> BankAccount {
609 BankAccount::new(
610 Uuid::new_v4(),
611 "****1234".to_string(),
612 BankAccountType::Checking,
613 customer.customer_id,
614 "USD",
615 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
616 )
617 }
618
619 fn create_test_transaction(account: &BankAccount) -> BankTransaction {
620 BankTransaction::new(
621 Uuid::new_v4(),
622 account.account_id,
623 Decimal::from(1000),
624 "USD",
625 Direction::Outbound,
626 TransactionChannel::CardPresent,
627 TransactionCategory::Shopping,
628 CounterpartyRef::merchant(Uuid::new_v4(), "Test Store"),
629 "Test purchase",
630 chrono::Utc::now(),
631 )
632 }
633
634 #[test]
635 fn test_build_banking_graph() {
636 let customer = create_test_customer();
637 let account = create_test_account(&customer);
638 let txn = create_test_transaction(&account);
639
640 let mut builder = BankingGraphBuilder::new(BankingGraphConfig::default());
641 builder.add_customers(std::slice::from_ref(&customer));
642 builder.add_accounts(
643 std::slice::from_ref(&account),
644 std::slice::from_ref(&customer),
645 );
646 builder.add_transactions(std::slice::from_ref(&txn));
647
648 let graph = builder.build();
649
650 assert!(graph.node_count() >= 2);
652 assert!(graph.edge_count() >= 1);
654 }
655
656 #[test]
657 fn test_suspicious_transaction_labels() {
658 let customer = create_test_customer();
659 let account = create_test_account(&customer);
660 let mut txn = create_test_transaction(&account);
661
662 txn = txn.mark_suspicious(AmlTypology::Structuring, "CASE-001");
664
665 let mut builder = BankingGraphBuilder::new(BankingGraphConfig::default());
666 builder.add_customers(std::slice::from_ref(&customer));
667 builder.add_accounts(
668 std::slice::from_ref(&account),
669 std::slice::from_ref(&customer),
670 );
671 builder.add_transactions(std::slice::from_ref(&txn));
672
673 let graph = builder.build();
674
675 let suspicious_edges = graph.anomalous_edges();
677 assert!(!suspicious_edges.is_empty());
678 }
679}