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)]
192mod tests {
193 use super::*;
194 use rust_decimal_macros::dec;
195
196 fn d(s: &str) -> NaiveDate {
197 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
198 }
199
200 #[test]
201 fn test_basic_generation() {
202 let config = NettingSchemaConfig {
203 enabled: true,
204 cycle: "monthly".to_string(),
205 };
206 let mut gen = NettingRunGenerator::new(config, 42);
207
208 let entity_ids = vec!["C001".to_string(), "C002".to_string(), "C003".to_string()];
209 let ic_amounts = vec![
210 ("C001".to_string(), "C002".to_string(), dec!(100000)),
211 ("C002".to_string(), "C003".to_string(), dec!(50000)),
212 ("C003".to_string(), "C001".to_string(), dec!(30000)),
213 ];
214
215 let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &ic_amounts);
216
217 assert!(!runs.is_empty());
218 for run in &runs {
219 assert!(run.id.starts_with("NR-"));
220 assert_eq!(run.settlement_currency, "USD");
221 assert_eq!(run.cycle, NettingCycle::Monthly);
222 assert!(!run.positions.is_empty());
223 assert_eq!(run.gross_receivables, run.gross_payables);
225 }
226 }
227
228 #[test]
229 fn test_deterministic() {
230 let config = NettingSchemaConfig {
231 enabled: true,
232 cycle: "monthly".to_string(),
233 };
234 let entity_ids = vec!["C001".to_string(), "C002".to_string()];
235 let ic_amounts = vec![
236 ("C001".to_string(), "C002".to_string(), dec!(100000)),
237 ("C002".to_string(), "C001".to_string(), dec!(60000)),
238 ];
239
240 let mut gen1 = NettingRunGenerator::new(config.clone(), 42);
241 let r1 = gen1.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
242
243 let mut gen2 = NettingRunGenerator::new(config, 42);
244 let r2 = gen2.generate(&entity_ids, "USD", d("2025-01-01"), 2, &ic_amounts);
245
246 assert_eq!(r1.len(), r2.len());
247 for (a, b) in r1.iter().zip(r2.iter()) {
248 assert_eq!(a.id, b.id);
249 assert_eq!(a.gross_receivables, b.gross_receivables);
250 assert_eq!(a.net_settlement, b.net_settlement);
251 }
252 }
253
254 #[test]
255 fn test_empty_input() {
256 let config = NettingSchemaConfig {
257 enabled: true,
258 cycle: "monthly".to_string(),
259 };
260 let mut gen = NettingRunGenerator::new(config, 42);
261
262 let entity_ids = vec!["C001".to_string(), "C002".to_string()];
264 let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 3, &[]);
265 assert!(runs.is_empty());
266
267 let single = vec!["C001".to_string()];
269 let ic_amounts = vec![("C001".to_string(), "C001".to_string(), dec!(100000))];
270 let runs = gen.generate(&single, "USD", d("2025-01-01"), 3, &ic_amounts);
271 assert!(runs.is_empty());
272 }
273
274 #[test]
275 fn test_net_positions_balance() {
276 let config = NettingSchemaConfig {
277 enabled: true,
278 cycle: "monthly".to_string(),
279 };
280 let mut gen = NettingRunGenerator::new(config, 42);
281
282 let entity_ids = vec!["C001".to_string(), "C002".to_string()];
283 let ic_amounts = vec![
284 ("C001".to_string(), "C002".to_string(), dec!(100000)),
285 ("C002".to_string(), "C001".to_string(), dec!(40000)),
286 ];
287
288 let runs = gen.generate(&entity_ids, "USD", d("2025-01-01"), 1, &ic_amounts);
289 assert_eq!(runs.len(), 1);
290
291 let run = &runs[0];
292 let net_sum: Decimal = run.positions.iter().map(|p| p.net_position).sum();
294 assert_eq!(net_sum, Decimal::ZERO);
295
296 assert!(run.savings() >= Decimal::ZERO);
298 }
299}