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)]
533mod tests {
534 use super::*;
535 use chrono::NaiveDate;
536 use rust_decimal_macros::dec;
537 use tempfile::TempDir;
538
539 use datasynth_core::models::{
540 AmortizationPayment, CovenantType, DebtCovenant, DebtType, EffectivenessMethod, Frequency,
541 HedgeInstrumentType, HedgeType, HedgedItemType, InterestRateType, NettingCycle,
542 NettingPosition, PayOrReceive, TreasuryCashFlowCategory,
543 };
544
545 fn d(s: &str) -> NaiveDate {
546 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
547 }
548
549 #[test]
550 fn test_export_cash_positions() {
551 let temp = TempDir::new().unwrap();
552 let exporter = TreasuryExporter::new(temp.path());
553
554 let positions = vec![
555 CashPosition::new(
556 "CP-001",
557 "C001",
558 "BA-001",
559 "USD",
560 d("2025-01-15"),
561 dec!(100000),
562 dec!(5000),
563 dec!(2000),
564 ),
565 CashPosition::new(
566 "CP-002",
567 "C001",
568 "BA-001",
569 "USD",
570 d("2025-01-16"),
571 dec!(103000),
572 dec!(0),
573 dec!(1000),
574 ),
575 ];
576
577 let count = exporter.export_cash_positions(&positions).unwrap();
578 assert_eq!(count, 2);
579
580 let content = std::fs::read_to_string(temp.path().join("cash_positions.csv")).unwrap();
581 assert_eq!(content.lines().count(), 3); assert!(content.contains("CP-001"));
583 assert!(content.contains("CP-002"));
584 assert!(content.contains("opening_balance"));
585 }
586
587 #[test]
588 fn test_export_cash_forecasts_and_items() {
589 let temp = TempDir::new().unwrap();
590 let exporter = TreasuryExporter::new(temp.path());
591
592 let items = vec![datasynth_core::models::CashForecastItem {
593 id: "CFI-001".to_string(),
594 date: d("2025-02-15"),
595 category: TreasuryCashFlowCategory::ArCollection,
596 amount: dec!(50000),
597 probability: dec!(0.90),
598 source_document_type: Some("SalesOrder".to_string()),
599 source_document_id: Some("SO-001".to_string()),
600 }];
601 let forecasts = vec![CashForecast::new(
602 "CF-001",
603 "C001",
604 "USD",
605 d("2025-01-31"),
606 90,
607 items,
608 dec!(0.90),
609 )];
610
611 let fc_count = exporter.export_cash_forecasts(&forecasts).unwrap();
612 let fi_count = exporter.export_cash_forecast_items(&forecasts).unwrap();
613
614 assert_eq!(fc_count, 1);
615 assert_eq!(fi_count, 1);
616
617 let fc_content = std::fs::read_to_string(temp.path().join("cash_forecasts.csv")).unwrap();
618 assert!(fc_content.contains("CF-001"));
619
620 let fi_content =
621 std::fs::read_to_string(temp.path().join("cash_forecast_items.csv")).unwrap();
622 assert!(fi_content.contains("CFI-001"));
623 assert!(fi_content.contains("CF-001")); }
625
626 #[test]
627 fn test_export_hedging_and_relationships() {
628 let temp = TempDir::new().unwrap();
629 let exporter = TreasuryExporter::new(temp.path());
630
631 let instruments = vec![HedgingInstrument::new(
632 "HI-001",
633 HedgeInstrumentType::FxForward,
634 dec!(1000000),
635 "EUR",
636 d("2025-01-01"),
637 d("2025-06-30"),
638 "Deutsche Bank",
639 )
640 .with_currency_pair("EUR/USD")
641 .with_fixed_rate(dec!(1.0850))];
642
643 let relationships = vec![HedgeRelationship::new(
644 "HR-001",
645 HedgedItemType::ForecastedTransaction,
646 "EUR receivables Q2",
647 "HI-001",
648 HedgeType::CashFlowHedge,
649 d("2025-01-01"),
650 EffectivenessMethod::Regression,
651 dec!(0.95),
652 )];
653
654 let hi_count = exporter.export_hedging_instruments(&instruments).unwrap();
655 let hr_count = exporter.export_hedge_relationships(&relationships).unwrap();
656
657 assert_eq!(hi_count, 1);
658 assert_eq!(hr_count, 1);
659 }
660
661 #[test]
662 fn test_export_debt_with_covenants_and_amortization() {
663 let temp = TempDir::new().unwrap();
664 let exporter = TreasuryExporter::new(temp.path());
665
666 let instruments = vec![DebtInstrument::new(
667 "DEBT-001",
668 "C001",
669 DebtType::TermLoan,
670 "First Bank",
671 dec!(1000000),
672 "USD",
673 dec!(0.05),
674 InterestRateType::Fixed,
675 d("2025-01-01"),
676 d("2026-01-01"),
677 )
678 .with_amortization_schedule(vec![AmortizationPayment {
679 date: d("2025-06-30"),
680 principal_payment: dec!(500000),
681 interest_payment: dec!(25000),
682 balance_after: dec!(500000),
683 }])
684 .with_covenant(DebtCovenant::new(
685 "COV-001",
686 CovenantType::DebtToEbitda,
687 dec!(3.5),
688 Frequency::Quarterly,
689 dec!(2.5),
690 d("2025-03-31"),
691 ))];
692
693 let di_count = exporter.export_debt_instruments(&instruments).unwrap();
694 let dc_count = exporter.export_debt_covenants(&instruments).unwrap();
695 let as_count = exporter
696 .export_amortization_schedules(&instruments)
697 .unwrap();
698
699 assert_eq!(di_count, 1);
700 assert_eq!(dc_count, 1);
701 assert_eq!(as_count, 1);
702
703 let dc_csv = std::fs::read_to_string(temp.path().join("debt_covenants.csv")).unwrap();
704 assert!(dc_csv.contains("DEBT-001")); assert!(dc_csv.contains("COV-001"));
706 }
707
708 #[test]
709 fn test_export_netting() {
710 let temp = TempDir::new().unwrap();
711 let exporter = TreasuryExporter::new(temp.path());
712
713 let runs = vec![NettingRun::new(
714 "NR-001",
715 d("2025-01-31"),
716 NettingCycle::Monthly,
717 "USD",
718 vec![
719 NettingPosition {
720 entity_id: "C001".to_string(),
721 gross_receivable: dec!(100000),
722 gross_payable: dec!(60000),
723 net_position: dec!(40000),
724 settlement_direction: PayOrReceive::Receive,
725 },
726 NettingPosition {
727 entity_id: "C002".to_string(),
728 gross_receivable: dec!(60000),
729 gross_payable: dec!(100000),
730 net_position: dec!(-40000),
731 settlement_direction: PayOrReceive::Pay,
732 },
733 ],
734 )];
735
736 let nr_count = exporter.export_netting_runs(&runs).unwrap();
737 let np_count = exporter.export_netting_positions(&runs).unwrap();
738
739 assert_eq!(nr_count, 1);
740 assert_eq!(np_count, 2);
741 }
742
743 #[test]
744 fn test_export_anomaly_labels() {
745 let temp = TempDir::new().unwrap();
746 let exporter = TreasuryExporter::new(temp.path());
747
748 let labels = vec![TreasuryAnomalyLabelRow {
749 id: "TANOM-001".to_string(),
750 anomaly_type: "hedge_ineffectiveness".to_string(),
751 severity: "high".to_string(),
752 document_type: "hedge_relationship".to_string(),
753 document_id: "HR-001".to_string(),
754 description: "Ratio 0.72 outside corridor".to_string(),
755 original_value: "0.95".to_string(),
756 anomalous_value: "0.72".to_string(),
757 }];
758
759 let count = exporter.export_anomaly_labels(&labels).unwrap();
760 assert_eq!(count, 1);
761
762 let content =
763 std::fs::read_to_string(temp.path().join("treasury_anomaly_labels.csv")).unwrap();
764 assert!(content.contains("TANOM-001"));
765 assert!(content.contains("hedge_ineffectiveness"));
766 }
767
768 #[test]
769 fn test_export_summary_total() {
770 let summary = TreasuryExportSummary {
771 cash_positions_count: 30,
772 cash_forecasts_count: 1,
773 cash_forecast_items_count: 15,
774 cash_pool_sweeps_count: 20,
775 hedging_instruments_count: 5,
776 hedge_relationships_count: 5,
777 debt_instruments_count: 2,
778 debt_covenants_count: 4,
779 amortization_schedules_count: 40,
780 bank_guarantees_count: 3,
781 netting_runs_count: 1,
782 netting_positions_count: 4,
783 anomaly_labels_count: 3,
784 };
785 assert_eq!(summary.total(), 133);
786 }
787}