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.gen_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)]
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), // zero = no sweep
243            },
244        ];
245
246        let sweeps = gen.generate_sweeps(&pool, d("2025-01-15"), "EUR", &balances);
247
248        assert_eq!(sweeps.len(), 2); // BA-003 is zero, no sweep
249
250        // BA-001 positive → swept to header
251        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        // BA-002 negative → funded from header
259        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), // excess = 40000 (50000 - 10000 min)
287            },
288            AccountBalance {
289                account_id: "BA-002".to_string(),
290                balance: dec!(5000), // below minimum, no sweep
291            },
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)); // 50000 - 10000 minimum
297    }
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        // Single account should return None
348        let pool = gen.create_pool("Bad Pool", "USD", &["BA-001".to_string()]);
349        assert!(pool.is_none());
350
351        // Empty should return None
352        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), // header balance should not be swept
371            },
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        // Only BA-001 should be swept, not header
381        assert_eq!(sweeps[0].from_account_id, "BA-001");
382    }
383}