datasynth_generators/treasury/
cash_pool_generator.rs1use chrono::NaiveDate;
8use datasynth_core::utils::seeded_rng;
9use rand::prelude::*;
10use rand_chacha::ChaCha8Rng;
11use rust_decimal::Decimal;
12use rust_decimal_macros::dec;
13
14use datasynth_config::schema::CashPoolingConfig;
15use datasynth_core::models::{CashPool, CashPoolSweep, PoolType};
16
17use chrono::NaiveTime;
18
19#[derive(Debug, Clone)]
25pub struct AccountBalance {
26 pub account_id: String,
28 pub balance: Decimal,
30}
31
32pub struct CashPoolGenerator {
38 rng: ChaCha8Rng,
39 config: CashPoolingConfig,
40 pool_counter: u64,
41 sweep_counter: u64,
42}
43
44impl CashPoolGenerator {
45 pub fn new(config: CashPoolingConfig, seed: u64) -> Self {
47 Self {
48 rng: seeded_rng(seed, 0),
49 config,
50 pool_counter: 0,
51 sweep_counter: 0,
52 }
53 }
54
55 pub fn create_pool(
59 &mut self,
60 name: &str,
61 _currency: &str,
62 account_ids: &[String],
63 ) -> Option<CashPool> {
64 if account_ids.len() < 2 {
65 return None; }
67
68 self.pool_counter += 1;
69 let pool_type = self.parse_pool_type();
70 let sweep_time = self.parse_sweep_time();
71 let interest_benefit = dec!(0.0025)
72 + Decimal::try_from(self.rng.random_range(0.0f64..0.003)).unwrap_or(Decimal::ZERO);
73
74 let mut pool = CashPool::new(
75 format!("POOL-{:06}", self.pool_counter),
76 name,
77 pool_type,
78 &account_ids[0],
79 sweep_time,
80 )
81 .with_interest_rate_benefit(interest_benefit.round_dp(4));
82
83 for account_id in &account_ids[1..] {
84 pool = pool.with_participant(account_id);
85 }
86
87 Some(pool)
88 }
89
90 pub fn generate_sweeps(
96 &mut self,
97 pool: &CashPool,
98 date: NaiveDate,
99 currency: &str,
100 participant_balances: &[AccountBalance],
101 ) -> Vec<CashPoolSweep> {
102 match pool.pool_type {
103 PoolType::ZeroBalancing => {
104 self.generate_zero_balance_sweeps(pool, date, currency, participant_balances)
105 }
106 PoolType::PhysicalPooling => {
107 self.generate_physical_sweeps(pool, date, currency, participant_balances)
108 }
109 PoolType::NotionalPooling => {
110 Vec::new()
112 }
113 }
114 }
115
116 fn generate_zero_balance_sweeps(
118 &mut self,
119 pool: &CashPool,
120 date: NaiveDate,
121 currency: &str,
122 balances: &[AccountBalance],
123 ) -> Vec<CashPoolSweep> {
124 let mut sweeps = Vec::new();
125
126 for bal in balances {
127 if bal.account_id == pool.header_account_id || bal.balance.is_zero() {
128 continue;
129 }
130
131 self.sweep_counter += 1;
132 let (from, to, amount) = if bal.balance > Decimal::ZERO {
133 (&bal.account_id, &pool.header_account_id, bal.balance)
135 } else {
136 (&pool.header_account_id, &bal.account_id, bal.balance.abs())
138 };
139
140 sweeps.push(CashPoolSweep {
141 id: format!("SWP-{:06}", self.sweep_counter),
142 pool_id: pool.id.clone(),
143 date,
144 from_account_id: from.clone(),
145 to_account_id: to.clone(),
146 amount,
147 currency: currency.to_string(),
148 });
149 }
150
151 sweeps
152 }
153
154 fn generate_physical_sweeps(
156 &mut self,
157 pool: &CashPool,
158 date: NaiveDate,
159 currency: &str,
160 balances: &[AccountBalance],
161 ) -> Vec<CashPoolSweep> {
162 let min_balance = dec!(10000); let mut sweeps = Vec::new();
164
165 for bal in balances {
166 if bal.account_id == pool.header_account_id {
167 continue;
168 }
169
170 let excess = bal.balance - min_balance;
171 if excess > Decimal::ZERO {
172 self.sweep_counter += 1;
173 sweeps.push(CashPoolSweep {
174 id: format!("SWP-{:06}", self.sweep_counter),
175 pool_id: pool.id.clone(),
176 date,
177 from_account_id: bal.account_id.clone(),
178 to_account_id: pool.header_account_id.clone(),
179 amount: excess,
180 currency: currency.to_string(),
181 });
182 }
183 }
184
185 sweeps
186 }
187
188 fn parse_pool_type(&self) -> PoolType {
189 match self.config.pool_type.as_str() {
190 "physical_pooling" => PoolType::PhysicalPooling,
191 "notional_pooling" => PoolType::NotionalPooling,
192 _ => PoolType::ZeroBalancing,
193 }
194 }
195
196 fn parse_sweep_time(&self) -> NaiveTime {
197 NaiveTime::parse_from_str(&self.config.sweep_time, "%H:%M")
198 .unwrap_or_else(|_| NaiveTime::from_hms_opt(16, 0, 0).expect("valid constant time"))
199 }
200}
201
202#[cfg(test)]
207#[allow(clippy::unwrap_used)]
208mod tests {
209 use super::*;
210
211 fn d(s: &str) -> NaiveDate {
212 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
213 }
214
215 #[test]
216 fn test_zero_balancing_sweeps() {
217 let mut gen = CashPoolGenerator::new(CashPoolingConfig::default(), 42);
218 let pool = gen
219 .create_pool(
220 "EUR Pool",
221 "EUR",
222 &[
223 "BA-HEADER".to_string(),
224 "BA-001".to_string(),
225 "BA-002".to_string(),
226 "BA-003".to_string(),
227 ],
228 )
229 .unwrap();
230
231 let balances = vec![
232 AccountBalance {
233 account_id: "BA-001".to_string(),
234 balance: dec!(50000),
235 },
236 AccountBalance {
237 account_id: "BA-002".to_string(),
238 balance: dec!(-20000),
239 },
240 AccountBalance {
241 account_id: "BA-003".to_string(),
242 balance: dec!(0), },
244 ];
245
246 let sweeps = gen.generate_sweeps(&pool, d("2025-01-15"), "EUR", &balances);
247
248 assert_eq!(sweeps.len(), 2); let s1 = sweeps
252 .iter()
253 .find(|s| s.from_account_id == "BA-001")
254 .unwrap();
255 assert_eq!(s1.to_account_id, "BA-HEADER");
256 assert_eq!(s1.amount, dec!(50000));
257
258 let s2 = sweeps.iter().find(|s| s.to_account_id == "BA-002").unwrap();
260 assert_eq!(s2.from_account_id, "BA-HEADER");
261 assert_eq!(s2.amount, dec!(20000));
262 }
263
264 #[test]
265 fn test_physical_pooling_sweeps() {
266 let config = CashPoolingConfig {
267 pool_type: "physical_pooling".to_string(),
268 ..CashPoolingConfig::default()
269 };
270 let mut gen = CashPoolGenerator::new(config, 42);
271 let pool = gen
272 .create_pool(
273 "USD Pool",
274 "USD",
275 &[
276 "BA-HEADER".to_string(),
277 "BA-001".to_string(),
278 "BA-002".to_string(),
279 ],
280 )
281 .unwrap();
282
283 let balances = vec![
284 AccountBalance {
285 account_id: "BA-001".to_string(),
286 balance: dec!(50000), },
288 AccountBalance {
289 account_id: "BA-002".to_string(),
290 balance: dec!(5000), },
292 ];
293
294 let sweeps = gen.generate_sweeps(&pool, d("2025-01-15"), "USD", &balances);
295 assert_eq!(sweeps.len(), 1);
296 assert_eq!(sweeps[0].amount, dec!(40000)); }
298
299 #[test]
300 fn test_notional_pooling_no_sweeps() {
301 let config = CashPoolingConfig {
302 pool_type: "notional_pooling".to_string(),
303 ..CashPoolingConfig::default()
304 };
305 let mut gen = CashPoolGenerator::new(config, 42);
306 let pool = gen
307 .create_pool(
308 "EUR Pool",
309 "EUR",
310 &["BA-HEADER".to_string(), "BA-001".to_string()],
311 )
312 .unwrap();
313
314 let balances = vec![AccountBalance {
315 account_id: "BA-001".to_string(),
316 balance: dec!(100000),
317 }];
318
319 let sweeps = gen.generate_sweeps(&pool, d("2025-01-15"), "EUR", &balances);
320 assert!(sweeps.is_empty());
321 }
322
323 #[test]
324 fn test_pool_creation() {
325 let mut gen = CashPoolGenerator::new(CashPoolingConfig::default(), 42);
326 let pool = gen
327 .create_pool(
328 "Test Pool",
329 "USD",
330 &[
331 "BA-HDR".to_string(),
332 "BA-001".to_string(),
333 "BA-002".to_string(),
334 ],
335 )
336 .unwrap();
337
338 assert_eq!(pool.header_account_id, "BA-HDR");
339 assert_eq!(pool.participant_accounts.len(), 2);
340 assert_eq!(pool.total_accounts(), 3);
341 assert_eq!(pool.pool_type, PoolType::ZeroBalancing);
342 }
343
344 #[test]
345 fn test_pool_requires_minimum_accounts() {
346 let mut gen = CashPoolGenerator::new(CashPoolingConfig::default(), 42);
347 let pool = gen.create_pool("Bad Pool", "USD", &["BA-001".to_string()]);
349 assert!(pool.is_none());
350
351 let pool = gen.create_pool("Empty Pool", "USD", &[]);
353 assert!(pool.is_none());
354 }
355
356 #[test]
357 fn test_header_account_excluded_from_sweeps() {
358 let mut gen = CashPoolGenerator::new(CashPoolingConfig::default(), 42);
359 let pool = gen
360 .create_pool(
361 "Pool",
362 "USD",
363 &["BA-HEADER".to_string(), "BA-001".to_string()],
364 )
365 .unwrap();
366
367 let balances = vec![
368 AccountBalance {
369 account_id: "BA-HEADER".to_string(),
370 balance: dec!(500000), },
372 AccountBalance {
373 account_id: "BA-001".to_string(),
374 balance: dec!(30000),
375 },
376 ];
377
378 let sweeps = gen.generate_sweeps(&pool, d("2025-01-15"), "USD", &balances);
379 assert_eq!(sweeps.len(), 1);
380 assert_eq!(sweeps[0].from_account_id, "BA-001");
382 }
383}