Skip to main content

datasynth_generators/treasury/
cash_pool_generator.rs

1//! Cash Pool Sweep Generator.
2//!
3//! Groups entity bank accounts into pools and generates daily sweep
4//! transactions. Supports zero-balancing (all participant balances → 0,
5//! header gets net), physical pooling, and notional pooling.
6
7use 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// ---------------------------------------------------------------------------
20// Input abstraction
21// ---------------------------------------------------------------------------
22
23/// End-of-day balance for a bank account, used as input for sweep generation.
24#[derive(Debug, Clone)]
25pub struct AccountBalance {
26    /// Bank account identifier
27    pub account_id: String,
28    /// End-of-day balance before sweep
29    pub balance: Decimal,
30}
31
32// ---------------------------------------------------------------------------
33// Generator
34// ---------------------------------------------------------------------------
35
36/// Generates cash pool structures and daily sweep transactions.
37pub struct CashPoolGenerator {
38    rng: ChaCha8Rng,
39    config: CashPoolingConfig,
40    pool_counter: u64,
41    sweep_counter: u64,
42}
43
44impl CashPoolGenerator {
45    /// Creates a new cash pool generator.
46    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    /// Creates a cash pool from a list of participant accounts.
56    ///
57    /// The first account is designated as the header account.
58    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; // Need at least header + 1 participant
66        }
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    /// Generates sweep transactions for a pool on a given date.
91    ///
92    /// For zero-balancing: each participant's balance is swept to/from the
93    /// header account, leaving the participant at zero.
94    /// For physical pooling: only positive balances above a threshold are swept.
95    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                // Notional pooling doesn't move physical cash — no sweeps generated
111                Vec::new()
112            }
113        }
114    }
115
116    /// Zero-balancing: sweep each participant's entire balance to/from header.
117    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                // Positive balance: sweep from participant to header
134                (&bal.account_id, &pool.header_account_id, bal.balance)
135            } else {
136                // Negative balance: fund from header to participant
137                (&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    /// Physical pooling: sweep balances above minimum to header.
155    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); // keep minimum in sub-accounts
163        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// ---------------------------------------------------------------------------
203// Tests
204// ---------------------------------------------------------------------------
205
206#[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), // zero = no sweep
242            },
243        ];
244
245        let sweeps = gen.generate_sweeps(&pool, d("2025-01-15"), "EUR", &balances);
246
247        assert_eq!(sweeps.len(), 2); // BA-003 is zero, no sweep
248
249        // BA-001 positive → swept to header
250        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        // BA-002 negative → funded from header
258        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), // excess = 40000 (50000 - 10000 min)
286            },
287            AccountBalance {
288                account_id: "BA-002".to_string(),
289                balance: dec!(5000), // below minimum, no sweep
290            },
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)); // 50000 - 10000 minimum
296    }
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        // Single account should return None
347        let pool = gen.create_pool("Bad Pool", "USD", &["BA-001".to_string()]);
348        assert!(pool.is_none());
349
350        // Empty should return None
351        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), // header balance should not be swept
370            },
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        // Only BA-001 should be swept, not header
380        assert_eq!(sweeps[0].from_account_id, "BA-001");
381    }
382}