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