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 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// ---------------------------------------------------------------------------
19// Input abstraction
20// ---------------------------------------------------------------------------
21
22/// End-of-day balance for a bank account, used as input for sweep generation.
23#[derive(Debug, Clone)]
24pub struct AccountBalance {
25    /// Bank account identifier
26    pub account_id: String,
27    /// End-of-day balance before sweep
28    pub balance: Decimal,
29}
30
31// ---------------------------------------------------------------------------
32// Generator
33// ---------------------------------------------------------------------------
34
35/// Generates cash pool structures and daily sweep transactions.
36pub struct CashPoolGenerator {
37    rng: ChaCha8Rng,
38    config: CashPoolingConfig,
39    pool_counter: u64,
40    sweep_counter: u64,
41}
42
43impl CashPoolGenerator {
44    /// Creates a new cash pool generator.
45    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    /// Creates a cash pool from a list of participant accounts.
55    ///
56    /// The first account is designated as the header account.
57    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; // Need at least header + 1 participant
65        }
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    /// Generates sweep transactions for a pool on a given date.
90    ///
91    /// For zero-balancing: each participant's balance is swept to/from the
92    /// header account, leaving the participant at zero.
93    /// For physical pooling: only positive balances above a threshold are swept.
94    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                // Notional pooling doesn't move physical cash — no sweeps generated
110                Vec::new()
111            }
112        }
113    }
114
115    /// Zero-balancing: sweep each participant's entire balance to/from header.
116    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                // Positive balance: sweep from participant to header
133                (&bal.account_id, &pool.header_account_id, bal.balance)
134            } else {
135                // Negative balance: fund from header to participant
136                (&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    /// Physical pooling: sweep balances above minimum to header.
154    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); // keep minimum in sub-accounts
162        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// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[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), // 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(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), // 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(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        // 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(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), // 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}