1use std::fs::File;
8use std::io::{BufWriter, Write};
9use std::path::{Path, PathBuf};
10
11use datasynth_core::error::SynthResult;
12use datasynth_core::models::{
13 BankGuarantee, CashForecast, CashPoolSweep, CashPosition, DebtInstrument, HedgeRelationship,
14 HedgingInstrument, NettingRun,
15};
16
17#[derive(Debug, Clone)]
23pub struct TreasuryAnomalyLabelRow {
24 pub id: String,
25 pub anomaly_type: String,
26 pub severity: String,
27 pub document_type: String,
28 pub document_id: String,
29 pub description: String,
30 pub original_value: String,
31 pub anomalous_value: String,
32}
33
34#[derive(Debug, Default)]
40pub struct TreasuryExportSummary {
41 pub cash_positions_count: usize,
42 pub cash_forecasts_count: usize,
43 pub cash_forecast_items_count: usize,
44 pub cash_pool_sweeps_count: usize,
45 pub hedging_instruments_count: usize,
46 pub hedge_relationships_count: usize,
47 pub debt_instruments_count: usize,
48 pub debt_covenants_count: usize,
49 pub amortization_schedules_count: usize,
50 pub bank_guarantees_count: usize,
51 pub netting_runs_count: usize,
52 pub netting_positions_count: usize,
53 pub anomaly_labels_count: usize,
54}
55
56impl TreasuryExportSummary {
57 pub fn total(&self) -> usize {
59 self.cash_positions_count
60 + self.cash_forecasts_count
61 + self.cash_forecast_items_count
62 + self.cash_pool_sweeps_count
63 + self.hedging_instruments_count
64 + self.hedge_relationships_count
65 + self.debt_instruments_count
66 + self.debt_covenants_count
67 + self.amortization_schedules_count
68 + self.bank_guarantees_count
69 + self.netting_runs_count
70 + self.netting_positions_count
71 + self.anomaly_labels_count
72 }
73}
74
75pub struct TreasuryExporter {
81 output_dir: PathBuf,
82}
83
84impl TreasuryExporter {
85 pub fn new(output_dir: impl AsRef<Path>) -> Self {
87 Self {
88 output_dir: output_dir.as_ref().to_path_buf(),
89 }
90 }
91
92 pub fn export_cash_positions(&self, data: &[CashPosition]) -> SynthResult<usize> {
94 let path = self.output_dir.join("cash_positions.csv");
95 let file = File::create(&path)?;
96 let mut w = BufWriter::with_capacity(256 * 1024, file);
97
98 writeln!(
99 w,
100 "id,entity_id,bank_account_id,currency,date,opening_balance,inflows,outflows,closing_balance,available_balance,value_date_balance"
101 )?;
102
103 for p in data {
104 writeln!(
105 w,
106 "{},{},{},{},{},{},{},{},{},{},{}",
107 esc(&p.id),
108 esc(&p.entity_id),
109 esc(&p.bank_account_id),
110 esc(&p.currency),
111 p.date,
112 p.opening_balance,
113 p.inflows,
114 p.outflows,
115 p.closing_balance,
116 p.available_balance,
117 p.value_date_balance,
118 )?;
119 }
120
121 w.flush()?;
122 Ok(data.len())
123 }
124
125 pub fn export_cash_forecasts(&self, data: &[CashForecast]) -> SynthResult<usize> {
127 let path = self.output_dir.join("cash_forecasts.csv");
128 let file = File::create(&path)?;
129 let mut w = BufWriter::with_capacity(256 * 1024, file);
130
131 writeln!(
132 w,
133 "id,entity_id,currency,forecast_date,horizon_days,net_position,confidence_level,item_count"
134 )?;
135
136 for f in data {
137 writeln!(
138 w,
139 "{},{},{},{},{},{},{},{}",
140 esc(&f.id),
141 esc(&f.entity_id),
142 esc(&f.currency),
143 f.forecast_date,
144 f.horizon_days,
145 f.net_position,
146 f.confidence_level,
147 f.items.len(),
148 )?;
149 }
150
151 w.flush()?;
152 Ok(data.len())
153 }
154
155 pub fn export_cash_forecast_items(&self, data: &[CashForecast]) -> SynthResult<usize> {
157 let path = self.output_dir.join("cash_forecast_items.csv");
158 let file = File::create(&path)?;
159 let mut w = BufWriter::with_capacity(256 * 1024, file);
160
161 writeln!(
162 w,
163 "id,forecast_id,date,category,amount,probability,source_document_type,source_document_id"
164 )?;
165
166 let mut count = 0;
167 for forecast in data {
168 for item in &forecast.items {
169 writeln!(
170 w,
171 "{},{},{},{:?},{},{},{},{}",
172 esc(&item.id),
173 esc(&forecast.id),
174 item.date,
175 item.category,
176 item.amount,
177 item.probability,
178 item.source_document_type.as_deref().unwrap_or(""),
179 item.source_document_id.as_deref().unwrap_or(""),
180 )?;
181 count += 1;
182 }
183 }
184
185 w.flush()?;
186 Ok(count)
187 }
188
189 pub fn export_cash_pool_sweeps(&self, data: &[CashPoolSweep]) -> SynthResult<usize> {
191 let path = self.output_dir.join("cash_pool_sweeps.csv");
192 let file = File::create(&path)?;
193 let mut w = BufWriter::with_capacity(256 * 1024, file);
194
195 writeln!(
196 w,
197 "id,pool_id,date,from_account_id,to_account_id,amount,currency"
198 )?;
199
200 for s in data {
201 writeln!(
202 w,
203 "{},{},{},{},{},{},{}",
204 esc(&s.id),
205 esc(&s.pool_id),
206 s.date,
207 esc(&s.from_account_id),
208 esc(&s.to_account_id),
209 s.amount,
210 esc(&s.currency),
211 )?;
212 }
213
214 w.flush()?;
215 Ok(data.len())
216 }
217
218 pub fn export_hedging_instruments(&self, data: &[HedgingInstrument]) -> SynthResult<usize> {
220 let path = self.output_dir.join("hedging_instruments.csv");
221 let file = File::create(&path)?;
222 let mut w = BufWriter::with_capacity(256 * 1024, file);
223
224 writeln!(
225 w,
226 "id,instrument_type,notional_amount,currency,currency_pair,fixed_rate,floating_index,strike_rate,trade_date,maturity_date,counterparty,fair_value,status"
227 )?;
228
229 for h in data {
230 writeln!(
231 w,
232 "{},{:?},{},{},{},{},{},{},{},{},{},{},{:?}",
233 esc(&h.id),
234 h.instrument_type,
235 h.notional_amount,
236 esc(&h.currency),
237 h.currency_pair.as_deref().unwrap_or(""),
238 h.fixed_rate.map(|r| r.to_string()).unwrap_or_default(),
239 h.floating_index.as_deref().unwrap_or(""),
240 h.strike_rate.map(|r| r.to_string()).unwrap_or_default(),
241 h.trade_date,
242 h.maturity_date,
243 esc(&h.counterparty),
244 h.fair_value,
245 h.status,
246 )?;
247 }
248
249 w.flush()?;
250 Ok(data.len())
251 }
252
253 pub fn export_hedge_relationships(&self, data: &[HedgeRelationship]) -> SynthResult<usize> {
255 let path = self.output_dir.join("hedge_relationships.csv");
256 let file = File::create(&path)?;
257 let mut w = BufWriter::with_capacity(256 * 1024, file);
258
259 writeln!(
260 w,
261 "id,hedged_item_type,hedged_item_description,hedging_instrument_id,hedge_type,designation_date,effectiveness_test_method,effectiveness_ratio,is_effective,ineffectiveness_amount"
262 )?;
263
264 for r in data {
265 writeln!(
266 w,
267 "{},{:?},{},{},{:?},{},{:?},{},{},{}",
268 esc(&r.id),
269 r.hedged_item_type,
270 esc(&r.hedged_item_description),
271 esc(&r.hedging_instrument_id),
272 r.hedge_type,
273 r.designation_date,
274 r.effectiveness_test_method,
275 r.effectiveness_ratio,
276 r.is_effective,
277 r.ineffectiveness_amount,
278 )?;
279 }
280
281 w.flush()?;
282 Ok(data.len())
283 }
284
285 pub fn export_debt_instruments(&self, data: &[DebtInstrument]) -> SynthResult<usize> {
287 let path = self.output_dir.join("debt_instruments.csv");
288 let file = File::create(&path)?;
289 let mut w = BufWriter::with_capacity(256 * 1024, file);
290
291 writeln!(
292 w,
293 "id,entity_id,instrument_type,lender,principal,currency,interest_rate,rate_type,origination_date,maturity_date,drawn_amount,facility_limit"
294 )?;
295
296 for d in data {
297 writeln!(
298 w,
299 "{},{},{:?},{},{},{},{},{:?},{},{},{},{}",
300 esc(&d.id),
301 esc(&d.entity_id),
302 d.instrument_type,
303 esc(&d.lender),
304 d.principal,
305 esc(&d.currency),
306 d.interest_rate,
307 d.rate_type,
308 d.origination_date,
309 d.maturity_date,
310 d.drawn_amount,
311 d.facility_limit,
312 )?;
313 }
314
315 w.flush()?;
316 Ok(data.len())
317 }
318
319 pub fn export_debt_covenants(&self, instruments: &[DebtInstrument]) -> SynthResult<usize> {
321 let path = self.output_dir.join("debt_covenants.csv");
322 let file = File::create(&path)?;
323 let mut w = BufWriter::with_capacity(256 * 1024, file);
324
325 writeln!(
326 w,
327 "id,debt_instrument_id,covenant_type,threshold,measurement_frequency,actual_value,measurement_date,is_compliant,headroom,waiver_obtained"
328 )?;
329
330 let mut count = 0;
331 for instrument in instruments {
332 for c in &instrument.covenants {
333 writeln!(
334 w,
335 "{},{},{:?},{},{:?},{},{},{},{},{}",
336 esc(&c.id),
337 esc(&instrument.id),
338 c.covenant_type,
339 c.threshold,
340 c.measurement_frequency,
341 c.actual_value,
342 c.measurement_date,
343 c.is_compliant,
344 c.headroom,
345 c.waiver_obtained,
346 )?;
347 count += 1;
348 }
349 }
350
351 w.flush()?;
352 Ok(count)
353 }
354
355 pub fn export_amortization_schedules(
357 &self,
358 instruments: &[DebtInstrument],
359 ) -> SynthResult<usize> {
360 let path = self.output_dir.join("amortization_schedules.csv");
361 let file = File::create(&path)?;
362 let mut w = BufWriter::with_capacity(256 * 1024, file);
363
364 writeln!(
365 w,
366 "debt_instrument_id,date,principal_payment,interest_payment,total_payment,balance_after"
367 )?;
368
369 let mut count = 0;
370 for instrument in instruments {
371 for p in &instrument.amortization_schedule {
372 writeln!(
373 w,
374 "{},{},{},{},{},{}",
375 esc(&instrument.id),
376 p.date,
377 p.principal_payment,
378 p.interest_payment,
379 p.total_payment(),
380 p.balance_after,
381 )?;
382 count += 1;
383 }
384 }
385
386 w.flush()?;
387 Ok(count)
388 }
389
390 pub fn export_bank_guarantees(&self, data: &[BankGuarantee]) -> SynthResult<usize> {
392 let path = self.output_dir.join("bank_guarantees.csv");
393 let file = File::create(&path)?;
394 let mut w = BufWriter::with_capacity(256 * 1024, file);
395
396 writeln!(
397 w,
398 "id,entity_id,guarantee_type,amount,currency,beneficiary,issuing_bank,issue_date,expiry_date,status,linked_contract_id,linked_project_id"
399 )?;
400
401 for g in data {
402 writeln!(
403 w,
404 "{},{},{:?},{},{},{},{},{},{},{:?},{},{}",
405 esc(&g.id),
406 esc(&g.entity_id),
407 g.guarantee_type,
408 g.amount,
409 esc(&g.currency),
410 esc(&g.beneficiary),
411 esc(&g.issuing_bank),
412 g.issue_date,
413 g.expiry_date,
414 g.status,
415 g.linked_contract_id.as_deref().unwrap_or(""),
416 g.linked_project_id.as_deref().unwrap_or(""),
417 )?;
418 }
419
420 w.flush()?;
421 Ok(data.len())
422 }
423
424 pub fn export_netting_runs(&self, data: &[NettingRun]) -> SynthResult<usize> {
426 let path = self.output_dir.join("netting_runs.csv");
427 let file = File::create(&path)?;
428 let mut w = BufWriter::with_capacity(256 * 1024, file);
429
430 writeln!(
431 w,
432 "id,netting_date,cycle,gross_receivables,gross_payables,net_settlement,settlement_currency,savings,savings_pct,participant_count"
433 )?;
434
435 for n in data {
436 writeln!(
437 w,
438 "{},{},{:?},{},{},{},{},{},{},{}",
439 esc(&n.id),
440 n.netting_date,
441 n.cycle,
442 n.gross_receivables,
443 n.gross_payables,
444 n.net_settlement,
445 esc(&n.settlement_currency),
446 n.savings(),
447 n.savings_pct(),
448 n.participating_entities.len(),
449 )?;
450 }
451
452 w.flush()?;
453 Ok(data.len())
454 }
455
456 pub fn export_netting_positions(&self, data: &[NettingRun]) -> SynthResult<usize> {
458 let path = self.output_dir.join("netting_positions.csv");
459 let file = File::create(&path)?;
460 let mut w = BufWriter::with_capacity(256 * 1024, file);
461
462 writeln!(
463 w,
464 "netting_run_id,entity_id,gross_receivable,gross_payable,net_position,settlement_direction"
465 )?;
466
467 let mut count = 0;
468 for run in data {
469 for pos in &run.positions {
470 writeln!(
471 w,
472 "{},{},{},{},{},{:?}",
473 esc(&run.id),
474 esc(&pos.entity_id),
475 pos.gross_receivable,
476 pos.gross_payable,
477 pos.net_position,
478 pos.settlement_direction,
479 )?;
480 count += 1;
481 }
482 }
483
484 w.flush()?;
485 Ok(count)
486 }
487
488 pub fn export_anomaly_labels(&self, data: &[TreasuryAnomalyLabelRow]) -> SynthResult<usize> {
490 let path = self.output_dir.join("treasury_anomaly_labels.csv");
491 let file = File::create(&path)?;
492 let mut w = BufWriter::with_capacity(256 * 1024, file);
493
494 writeln!(
495 w,
496 "id,anomaly_type,severity,document_type,document_id,description,original_value,anomalous_value"
497 )?;
498
499 for a in data {
500 writeln!(
501 w,
502 "{},{},{},{},{},{},{},{}",
503 esc(&a.id),
504 esc(&a.anomaly_type),
505 esc(&a.severity),
506 esc(&a.document_type),
507 esc(&a.document_id),
508 esc(&a.description),
509 esc(&a.original_value),
510 esc(&a.anomalous_value),
511 )?;
512 }
513
514 w.flush()?;
515 Ok(data.len())
516 }
517}
518
519fn esc(s: &str) -> String {
521 if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') {
522 format!("\"{}\"", s.replace('"', "\"\""))
523 } else {
524 s.to_string()
525 }
526}
527
528#[cfg(test)]
533#[allow(clippy::unwrap_used)]
534mod tests {
535 use super::*;
536 use chrono::NaiveDate;
537 use rust_decimal_macros::dec;
538 use tempfile::TempDir;
539
540 use datasynth_core::models::{
541 AmortizationPayment, CovenantType, DebtCovenant, DebtType, EffectivenessMethod, Frequency,
542 HedgeInstrumentType, HedgeType, HedgedItemType, InterestRateType, NettingCycle,
543 NettingPosition, PayOrReceive, TreasuryCashFlowCategory,
544 };
545
546 fn d(s: &str) -> NaiveDate {
547 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
548 }
549
550 #[test]
551 fn test_export_cash_positions() {
552 let temp = TempDir::new().unwrap();
553 let exporter = TreasuryExporter::new(temp.path());
554
555 let positions = vec![
556 CashPosition::new(
557 "CP-001",
558 "C001",
559 "BA-001",
560 "USD",
561 d("2025-01-15"),
562 dec!(100000),
563 dec!(5000),
564 dec!(2000),
565 ),
566 CashPosition::new(
567 "CP-002",
568 "C001",
569 "BA-001",
570 "USD",
571 d("2025-01-16"),
572 dec!(103000),
573 dec!(0),
574 dec!(1000),
575 ),
576 ];
577
578 let count = exporter.export_cash_positions(&positions).unwrap();
579 assert_eq!(count, 2);
580
581 let content = std::fs::read_to_string(temp.path().join("cash_positions.csv")).unwrap();
582 assert_eq!(content.lines().count(), 3); assert!(content.contains("CP-001"));
584 assert!(content.contains("CP-002"));
585 assert!(content.contains("opening_balance"));
586 }
587
588 #[test]
589 fn test_export_cash_forecasts_and_items() {
590 let temp = TempDir::new().unwrap();
591 let exporter = TreasuryExporter::new(temp.path());
592
593 let items = vec![datasynth_core::models::CashForecastItem {
594 id: "CFI-001".to_string(),
595 date: d("2025-02-15"),
596 category: TreasuryCashFlowCategory::ArCollection,
597 amount: dec!(50000),
598 probability: dec!(0.90),
599 source_document_type: Some("SalesOrder".to_string()),
600 source_document_id: Some("SO-001".to_string()),
601 }];
602 let forecasts = vec![CashForecast::new(
603 "CF-001",
604 "C001",
605 "USD",
606 d("2025-01-31"),
607 90,
608 items,
609 dec!(0.90),
610 )];
611
612 let fc_count = exporter.export_cash_forecasts(&forecasts).unwrap();
613 let fi_count = exporter.export_cash_forecast_items(&forecasts).unwrap();
614
615 assert_eq!(fc_count, 1);
616 assert_eq!(fi_count, 1);
617
618 let fc_content = std::fs::read_to_string(temp.path().join("cash_forecasts.csv")).unwrap();
619 assert!(fc_content.contains("CF-001"));
620
621 let fi_content =
622 std::fs::read_to_string(temp.path().join("cash_forecast_items.csv")).unwrap();
623 assert!(fi_content.contains("CFI-001"));
624 assert!(fi_content.contains("CF-001")); }
626
627 #[test]
628 fn test_export_hedging_and_relationships() {
629 let temp = TempDir::new().unwrap();
630 let exporter = TreasuryExporter::new(temp.path());
631
632 let instruments = vec![HedgingInstrument::new(
633 "HI-001",
634 HedgeInstrumentType::FxForward,
635 dec!(1000000),
636 "EUR",
637 d("2025-01-01"),
638 d("2025-06-30"),
639 "Deutsche Bank",
640 )
641 .with_currency_pair("EUR/USD")
642 .with_fixed_rate(dec!(1.0850))];
643
644 let relationships = vec![HedgeRelationship::new(
645 "HR-001",
646 HedgedItemType::ForecastedTransaction,
647 "EUR receivables Q2",
648 "HI-001",
649 HedgeType::CashFlowHedge,
650 d("2025-01-01"),
651 EffectivenessMethod::Regression,
652 dec!(0.95),
653 )];
654
655 let hi_count = exporter.export_hedging_instruments(&instruments).unwrap();
656 let hr_count = exporter.export_hedge_relationships(&relationships).unwrap();
657
658 assert_eq!(hi_count, 1);
659 assert_eq!(hr_count, 1);
660 }
661
662 #[test]
663 fn test_export_debt_with_covenants_and_amortization() {
664 let temp = TempDir::new().unwrap();
665 let exporter = TreasuryExporter::new(temp.path());
666
667 let instruments = vec![DebtInstrument::new(
668 "DEBT-001",
669 "C001",
670 DebtType::TermLoan,
671 "First Bank",
672 dec!(1000000),
673 "USD",
674 dec!(0.05),
675 InterestRateType::Fixed,
676 d("2025-01-01"),
677 d("2026-01-01"),
678 )
679 .with_amortization_schedule(vec![AmortizationPayment {
680 date: d("2025-06-30"),
681 principal_payment: dec!(500000),
682 interest_payment: dec!(25000),
683 balance_after: dec!(500000),
684 }])
685 .with_covenant(DebtCovenant::new(
686 "COV-001",
687 CovenantType::DebtToEbitda,
688 dec!(3.5),
689 Frequency::Quarterly,
690 dec!(2.5),
691 d("2025-03-31"),
692 ))];
693
694 let di_count = exporter.export_debt_instruments(&instruments).unwrap();
695 let dc_count = exporter.export_debt_covenants(&instruments).unwrap();
696 let as_count = exporter
697 .export_amortization_schedules(&instruments)
698 .unwrap();
699
700 assert_eq!(di_count, 1);
701 assert_eq!(dc_count, 1);
702 assert_eq!(as_count, 1);
703
704 let dc_csv = std::fs::read_to_string(temp.path().join("debt_covenants.csv")).unwrap();
705 assert!(dc_csv.contains("DEBT-001")); assert!(dc_csv.contains("COV-001"));
707 }
708
709 #[test]
710 fn test_export_netting() {
711 let temp = TempDir::new().unwrap();
712 let exporter = TreasuryExporter::new(temp.path());
713
714 let runs = vec![NettingRun::new(
715 "NR-001",
716 d("2025-01-31"),
717 NettingCycle::Monthly,
718 "USD",
719 vec![
720 NettingPosition {
721 entity_id: "C001".to_string(),
722 gross_receivable: dec!(100000),
723 gross_payable: dec!(60000),
724 net_position: dec!(40000),
725 settlement_direction: PayOrReceive::Receive,
726 },
727 NettingPosition {
728 entity_id: "C002".to_string(),
729 gross_receivable: dec!(60000),
730 gross_payable: dec!(100000),
731 net_position: dec!(-40000),
732 settlement_direction: PayOrReceive::Pay,
733 },
734 ],
735 )];
736
737 let nr_count = exporter.export_netting_runs(&runs).unwrap();
738 let np_count = exporter.export_netting_positions(&runs).unwrap();
739
740 assert_eq!(nr_count, 1);
741 assert_eq!(np_count, 2);
742 }
743
744 #[test]
745 fn test_export_anomaly_labels() {
746 let temp = TempDir::new().unwrap();
747 let exporter = TreasuryExporter::new(temp.path());
748
749 let labels = vec![TreasuryAnomalyLabelRow {
750 id: "TANOM-001".to_string(),
751 anomaly_type: "hedge_ineffectiveness".to_string(),
752 severity: "high".to_string(),
753 document_type: "hedge_relationship".to_string(),
754 document_id: "HR-001".to_string(),
755 description: "Ratio 0.72 outside corridor".to_string(),
756 original_value: "0.95".to_string(),
757 anomalous_value: "0.72".to_string(),
758 }];
759
760 let count = exporter.export_anomaly_labels(&labels).unwrap();
761 assert_eq!(count, 1);
762
763 let content =
764 std::fs::read_to_string(temp.path().join("treasury_anomaly_labels.csv")).unwrap();
765 assert!(content.contains("TANOM-001"));
766 assert!(content.contains("hedge_ineffectiveness"));
767 }
768
769 #[test]
770 fn test_export_summary_total() {
771 let summary = TreasuryExportSummary {
772 cash_positions_count: 30,
773 cash_forecasts_count: 1,
774 cash_forecast_items_count: 15,
775 cash_pool_sweeps_count: 20,
776 hedging_instruments_count: 5,
777 hedge_relationships_count: 5,
778 debt_instruments_count: 2,
779 debt_covenants_count: 4,
780 amortization_schedules_count: 40,
781 bank_guarantees_count: 3,
782 netting_runs_count: 1,
783 netting_positions_count: 4,
784 anomaly_labels_count: 3,
785 };
786 assert_eq!(summary.total(), 133);
787 }
788}