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)]
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(CashPoolingConfig::default(), 42);
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(config, 42);
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(config, 42);
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(CashPoolingConfig::default(), 42);
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(CashPoolingConfig::default(), 42);
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(CashPoolingConfig::default(), 42);
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}