datasynth_generators/treasury/
netting_run_generator.rs1use std::collections::HashMap;
7
8use chrono::NaiveDate;
9use rust_decimal::Decimal;
10
11use datasynth_config::schema::NettingSchemaConfig;
12use datasynth_core::models::{NettingCycle, NettingPosition, NettingRun, PayOrReceive};
13
14pub struct NettingRunGenerator {
20 config: NettingSchemaConfig,
21 counter: u64,
22}
23
24impl NettingRunGenerator {
25 pub fn new(config: NettingSchemaConfig, _seed: u64) -> Self {
27 Self { config, counter: 0 }
28 }
29
30 pub fn generate(
37 &mut self,
38 entity_ids: &[String],
39 currency: &str,
40 start_date: NaiveDate,
41 period_months: u32,
42 ic_amounts: &[(String, String, Decimal)],
43 ) -> Vec<NettingRun> {
44 if entity_ids.len() < 2 || ic_amounts.is_empty() {
45 return Vec::new();
46 }
47
48 let cycle = self.parse_cycle();
49 let mut runs = Vec::new();
50
51 let period_count = match cycle {
53 NettingCycle::Daily => period_months * 30, NettingCycle::Weekly => period_months * 4,
55 NettingCycle::Monthly => period_months,
56 };
57
58 let amounts_per_period = if period_count > 0 {
60 ic_amounts.len() / period_count as usize
61 } else {
62 ic_amounts.len()
63 }
64 .max(1);
65
66 let mut amount_idx = 0;
67
68 for period in 0..period_count {
69 if amount_idx >= ic_amounts.len() {
70 break;
71 }
72
73 let netting_date = match cycle {
75 NettingCycle::Daily => start_date + chrono::Duration::days(period as i64),
76 NettingCycle::Weekly => start_date + chrono::Duration::weeks(period as i64),
77 NettingCycle::Monthly => {
78 add_months_end(start_date, period)
80 }
81 };
82
83 let end_idx = (amount_idx + amounts_per_period).min(ic_amounts.len());
85 let period_amounts = &ic_amounts[amount_idx..end_idx];
86 amount_idx = end_idx;
87
88 if period_amounts.is_empty() {
89 continue;
90 }
91
92 let mut receivables: HashMap<&str, Decimal> = HashMap::new();
94 let mut payables: HashMap<&str, Decimal> = HashMap::new();
95
96 for (seller, buyer, amount) in period_amounts {
97 *receivables.entry(seller.as_str()).or_insert(Decimal::ZERO) += amount;
99 *payables.entry(buyer.as_str()).or_insert(Decimal::ZERO) += amount;
100 }
101
102 let mut all_entities: Vec<&str> =
104 receivables.keys().chain(payables.keys()).copied().collect();
105 all_entities.sort();
106 all_entities.dedup();
107
108 let positions: Vec<NettingPosition> = all_entities
109 .into_iter()
110 .map(|eid| {
111 let gross_receivable = receivables
112 .get(eid)
113 .copied()
114 .unwrap_or(Decimal::ZERO)
115 .round_dp(2);
116 let gross_payable = payables
117 .get(eid)
118 .copied()
119 .unwrap_or(Decimal::ZERO)
120 .round_dp(2);
121 let net_position = (gross_receivable - gross_payable).round_dp(2);
122 let settlement_direction = if net_position > Decimal::ZERO {
123 PayOrReceive::Receive
124 } else if net_position < Decimal::ZERO {
125 PayOrReceive::Pay
126 } else {
127 PayOrReceive::Flat
128 };
129 NettingPosition {
130 entity_id: eid.to_string(),
131 gross_receivable,
132 gross_payable,
133 net_position,
134 settlement_direction,
135 }
136 })
137 .collect();
138
139 if positions.is_empty() {
140 continue;
141 }
142
143 self.counter += 1;
144 let id = format!("NR-{:06}", self.counter);
145
146 let run = NettingRun::new(id, netting_date, cycle, currency, positions);
147 runs.push(run);
148 }
149
150 runs
151 }
152
153 fn parse_cycle(&self) -> NettingCycle {
154 match self.config.cycle.as_str() {
155 "daily" => NettingCycle::Daily,
156 "weekly" => NettingCycle::Weekly,
157 _ => NettingCycle::Monthly,
158 }
159 }
160}
161
162fn add_months_end(date: NaiveDate, months: u32) -> NaiveDate {
164 use chrono::Datelike;
165 let total_months = date.month0() + months;
166 let year = date.year() + (total_months / 12) as i32;
167 let month = (total_months % 12) + 1;
168 let last_day = days_in_month(year, month);
169 NaiveDate::from_ymd_opt(year, month, last_day).unwrap_or(date)
170}
171
172fn days_in_month(year: i32, month: u32) -> u32 {
173 match month {
174 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
175 4 | 6 | 9 | 11 => 30,
176 2 => {
177 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
178 29
179 } else {
180 28
181 }
182 }
183 _ => 30,
184 }
185}
186
187#[cfg(test)]
192#[allow(clippy::unwrap_used)]
193mod tests {
194 use super::*;
195 use rust_decimal_macros::dec;
196
197 fn d(s: &str) -> NaiveDate {
198 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
199 }
200
201 #[test]
202 fn test_basic_generation() {
203 let config = NettingSchemaConfig {
204 enabled: true,
205 cycle: "monthly".to_string(),
206 };
207 let mut gen = NettingRunGenerator::new(config, 42);
208
209 let entity_ids = vec!["C001".to_string(), "C002".to_string(), "C003".to_string()];
210 let ic_amounts = vec![
211 ("C001".to_string(), "C002".to_string(), dec!(100000)),
212 ("C002".to_string(), "C003".to_string(), dec!(50000)),
213 ("C003".to_string(), "C001".to_string(), dec!(30000)),
214 ];
215
216 let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &ic_amounts);
217
218 assert!(!runs.is_empty());
219 for run in &runs {
220 assert!(run.id.starts_with("NR-"));
221 assert_eq!(run.settlement_currency, "USD");
222 assert_eq!(run.cycle, NettingCycle::Monthly);
223 assert!(!run.positions.is_empty());
224 assert_eq!(run.gross_receivables, run.gross_payables);
226 }
227 }
228
229 #[test]
230 fn test_deterministic() {
231 let config = NettingSchemaConfig {
232 enabled: true,
233 cycle: "monthly".to_string(),
234 };
235 let entity_ids = vec!["C001".to_string(), "C002".to_string()];
236 let ic_amounts = vec![
237 ("C001".to_string(), "C002".to_string(), dec!(100000)),
238 ("C002".to_string(), "C001".to_string(), dec!(60000)),
239 ];
240
241 let mut gen1 = NettingRunGenerator::new(config.clone(), 42);
242 let r1 = gen1.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
243
244 let mut gen2 = NettingRunGenerator::new(config, 42);
245 let r2 = gen2.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
246
247 assert_eq!(r1.len(), r2.len());
248 for (a, b) in r1.iter().zip(r2.iter()) {
249 assert_eq!(a.id, b.id);
250 assert_eq!(a.gross_receivables, b.gross_receivables);
251 assert_eq!(a.net_settlement, b.net_settlement);
252 }
253 }
254
255 #[test]
256 fn test_empty_input() {
257 let config = NettingSchemaConfig {
258 enabled: true,
259 cycle: "monthly".to_string(),
260 };
261 let mut gen = NettingRunGenerator::new(config, 42);
262
263 let entity_ids = vec!["C001".to_string(), "C002".to_string()];
265 let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &[]);
266 assert!(runs.is_empty());
267
268 let single = vec!["C001".to_string()];
270 let ic_amounts = vec![("C001".to_string(), "C001".to_string(), dec!(100000))];
271 let runs = gen.generate(&single, "USD", d("2025-01-01"), 3, &ic_amounts);
272 assert!(runs.is_empty());
273 }
274
275 #[test]
276 fn test_net_positions_balance() {
277 let config = NettingSchemaConfig {
278 enabled: true,
279 cycle: "monthly".to_string(),
280 };
281 let mut gen = NettingRunGenerator::new(config, 42);
282
283 let entity_ids = vec!["C001".to_string(), "C002".to_string()];
284 let ic_amounts = vec![
285 ("C001".to_string(), "C002".to_string(), dec!(100000)),
286 ("C002".to_string(), "C001".to_string(), dec!(40000)),
287 ];
288
289 let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 1, &ic_amounts);
290 assert_eq!(runs.len(), 1);
291
292 let run = &runs[0];
293 let net_sum: Decimal = run.positions.iter().map(|p| p.net_position).sum();
295 assert_eq!(net_sum, Decimal::ZERO);
296
297 assert!(run.savings() >= Decimal::ZERO);
299 }
300}