datasynth_banking/typologies/
spoofing.rs1use chrono::Timelike;
4use datasynth_core::models::banking::TransactionCategory;
5use rand::prelude::*;
6use rand_chacha::ChaCha8Rng;
7use rust_decimal::Decimal;
8
9use crate::config::SpoofingConfig;
10use crate::models::{BankTransaction, BankingCustomer};
11
12pub struct SpoofingEngine {
21 config: SpoofingConfig,
22 rng: ChaCha8Rng,
23}
24
25impl SpoofingEngine {
26 pub fn new(config: SpoofingConfig, seed: u64) -> Self {
28 Self {
29 config,
30 rng: ChaCha8Rng::seed_from_u64(seed.wrapping_add(6400)),
31 }
32 }
33
34 pub fn apply(&mut self, txn: &mut BankTransaction, customer: &BankingCustomer) {
36 if !self.config.enabled || txn.spoofing_intensity.is_none() {
37 return;
38 }
39
40 let intensity = txn.spoofing_intensity.unwrap_or(0.0);
41
42 if self.config.spoof_timing && self.rng.gen::<f64>() < intensity {
44 self.spoof_timing(txn, customer);
45 }
46
47 if self.config.spoof_amounts && self.rng.gen::<f64>() < intensity {
49 self.spoof_amount(txn, customer);
50 }
51
52 if self.config.spoof_merchants && self.rng.gen::<f64>() < intensity {
54 self.spoof_merchant(txn, customer);
55 }
56
57 if self.config.add_delays && self.rng.gen::<f64>() < intensity {
59 self.add_timing_jitter(txn);
60 }
61 }
62
63 fn spoof_timing(&mut self, txn: &mut BankTransaction, _customer: &BankingCustomer) {
65 let current_hour = txn.timestamp_initiated.hour();
67
68 if !(7..=22).contains(¤t_hour) {
70 let new_hour = self.rng.gen_range(9..18);
71 let new_minute = self.rng.gen_range(0..60);
72 let new_second = self.rng.gen_range(0..60);
73
74 if let Some(new_time) = txn
75 .timestamp_initiated
76 .date_naive()
77 .and_hms_opt(new_hour, new_minute, new_second)
78 {
79 txn.timestamp_initiated =
80 chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
81 new_time,
82 chrono::Utc,
83 );
84 }
85 }
86 }
87
88 fn spoof_amount(&mut self, txn: &mut BankTransaction, _customer: &BankingCustomer) {
90 let amount_f64: f64 = txn.amount.try_into().unwrap_or(0.0);
93
94 let cents = self.rng.gen_range(1..99) as f64 / 100.0;
96 let new_amount = amount_f64 + cents;
97
98 let new_amount = self.avoid_thresholds(new_amount);
100
101 txn.amount = Decimal::from_f64_retain(new_amount).unwrap_or(txn.amount);
102 }
103
104 fn avoid_thresholds(&mut self, amount: f64) -> f64 {
106 let thresholds = [10_000.0, 5_000.0, 3_000.0, 1_000.0];
107
108 for threshold in thresholds {
109 let lower_bound = threshold * 0.95;
110 let upper_bound = threshold * 1.05;
111
112 if amount > lower_bound && amount < upper_bound {
113 if self.rng.gen::<bool>() {
115 return threshold * 0.85 + self.rng.gen_range(0.0..100.0);
116 } else {
117 return threshold * 1.15 + self.rng.gen_range(0.0..100.0);
118 }
119 }
120 }
121
122 amount
123 }
124
125 fn spoof_merchant(&mut self, txn: &mut BankTransaction, customer: &BankingCustomer) {
127 let persona_categories = self.get_persona_categories(customer);
129
130 if !persona_categories.is_empty() {
131 let idx = self.rng.gen_range(0..persona_categories.len());
132 txn.category = persona_categories[idx];
133 }
134 }
135
136 fn get_persona_categories(&self, customer: &BankingCustomer) -> Vec<TransactionCategory> {
138 use crate::models::PersonaVariant;
139 use datasynth_core::models::banking::RetailPersona;
140
141 match &customer.persona {
142 Some(PersonaVariant::Retail(persona)) => match persona {
143 RetailPersona::Student => vec![
144 TransactionCategory::Shopping,
145 TransactionCategory::Dining,
146 TransactionCategory::Entertainment,
147 TransactionCategory::Subscription,
148 ],
149 RetailPersona::EarlyCareer => vec![
150 TransactionCategory::Shopping,
151 TransactionCategory::Dining,
152 TransactionCategory::Subscription,
153 TransactionCategory::Transportation,
154 ],
155 RetailPersona::MidCareer => vec![
156 TransactionCategory::Groceries,
157 TransactionCategory::Shopping,
158 TransactionCategory::Utilities,
159 TransactionCategory::Insurance,
160 ],
161 RetailPersona::Retiree => vec![
162 TransactionCategory::Healthcare,
163 TransactionCategory::Groceries,
164 TransactionCategory::Utilities,
165 ],
166 RetailPersona::HighNetWorth => vec![
167 TransactionCategory::Investment,
168 TransactionCategory::Entertainment,
169 TransactionCategory::Shopping,
170 ],
171 RetailPersona::GigWorker => vec![
172 TransactionCategory::Shopping,
173 TransactionCategory::Transportation,
174 TransactionCategory::Dining,
175 ],
176 _ => vec![
177 TransactionCategory::Shopping,
178 TransactionCategory::Groceries,
179 ],
180 },
181 Some(PersonaVariant::Business(_)) => vec![
182 TransactionCategory::TransferOut,
183 TransactionCategory::Utilities,
184 TransactionCategory::Other,
185 ],
186 Some(PersonaVariant::Trust(_)) => vec![
187 TransactionCategory::Investment,
188 TransactionCategory::Other,
189 TransactionCategory::Charity,
190 ],
191 None => vec![TransactionCategory::Shopping],
192 }
193 }
194
195 fn add_timing_jitter(&mut self, txn: &mut BankTransaction) {
197 let jitter_minutes = self.rng.gen_range(-30..30);
199 txn.timestamp_initiated += chrono::Duration::minutes(jitter_minutes as i64);
200 }
201
202 pub fn calculate_effectiveness(
204 &self,
205 txn: &BankTransaction,
206 customer: &BankingCustomer,
207 ) -> f64 {
208 let mut score = 0.0;
209 let mut factors = 0;
210
211 let hour = txn.timestamp_initiated.hour();
213 if (9..=17).contains(&hour) {
214 score += 1.0;
215 }
216 factors += 1;
217
218 let amount: f64 = txn.amount.try_into().unwrap_or(0.0);
220 let has_cents = (amount * 100.0) % 100.0 != 0.0;
221 if has_cents {
222 score += 0.5;
223 }
224 if !(9_000.0..=11_000.0).contains(&amount) {
226 score += 0.5;
227 }
228 factors += 1;
229
230 let expected = self.get_persona_categories(customer);
232 if expected.contains(&txn.category) {
233 score += 1.0;
234 }
235 factors += 1;
236
237 score / factors as f64
238 }
239}
240
241#[derive(Debug, Clone, Default)]
243pub struct SpoofingStats {
244 pub transactions_spoofed: usize,
246 pub timing_adjustments: usize,
248 pub amount_adjustments: usize,
250 pub merchant_changes: usize,
252 pub avg_effectiveness: f64,
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use chrono::NaiveDate;
260 use uuid::Uuid;
261
262 #[test]
263 fn test_spoofing_engine() {
264 let config = SpoofingConfig {
265 enabled: true,
266 intensity: 0.5,
267 spoof_timing: true,
268 spoof_amounts: true,
269 spoof_merchants: true,
270 spoof_geography: false,
271 add_delays: true,
272 };
273
274 let mut engine = SpoofingEngine::new(config, 12345);
275
276 let customer = BankingCustomer::new_retail(
277 Uuid::new_v4(),
278 "Test",
279 "User",
280 "US",
281 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
282 );
283
284 let account_id = Uuid::new_v4();
285 let mut txn = BankTransaction::new(
286 Uuid::new_v4(),
287 account_id,
288 Decimal::from(9999),
289 "USD",
290 datasynth_core::models::banking::Direction::Outbound,
291 datasynth_core::models::banking::TransactionChannel::Wire,
292 TransactionCategory::TransferOut,
293 crate::models::CounterpartyRef::person("Test"),
294 "Test transaction",
295 chrono::Utc::now(),
296 );
297
298 txn.spoofing_intensity = Some(0.8);
299
300 engine.apply(&mut txn, &customer);
301
302 let amount: f64 = txn.amount.try_into().unwrap();
305 assert!(amount != 9999.0 || amount != 9999.0); }
307
308 #[test]
309 fn test_threshold_avoidance() {
310 let config = SpoofingConfig::default();
311 let mut engine = SpoofingEngine::new(config, 12345);
312
313 let amount = 9_950.0;
315 let adjusted = engine.avoid_thresholds(amount);
316
317 assert!(!(9_500.0..=10_500.0).contains(&adjusted));
319 }
320}