Skip to main content

datasynth_core/models/
treasury.rs

1//! Treasury and cash management data models.
2//!
3//! This module provides comprehensive treasury models including:
4//! - Daily cash positions per entity/account/currency
5//! - Forward-looking cash forecasts with probability-weighted items
6//! - Cash pooling structures (physical, notional, zero-balancing)
7//! - Hedging instruments (FX forwards, IR swaps) under ASC 815 / IFRS 9
8//! - Hedge relationship designations with effectiveness testing
9//! - Debt instruments with amortization schedules and covenant monitoring
10//! - Bank guarantees and letters of credit
11//! - Intercompany netting runs with multilateral settlement
12
13use std::collections::HashMap;
14
15use chrono::{NaiveDate, NaiveTime};
16use rust_decimal::prelude::ToPrimitive;
17use rust_decimal::Decimal;
18use serde::{Deserialize, Serialize};
19
20use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
21
22// ---------------------------------------------------------------------------
23// Enums
24// ---------------------------------------------------------------------------
25
26/// Category of a cash flow item in a treasury forecast.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum TreasuryCashFlowCategory {
30    /// Accounts receivable collection
31    #[default]
32    ArCollection,
33    /// Accounts payable payment
34    ApPayment,
35    /// Payroll disbursement
36    PayrollDisbursement,
37    /// Tax payment to authority
38    TaxPayment,
39    /// Debt principal and interest service
40    DebtService,
41    /// Capital expenditure
42    CapitalExpenditure,
43    /// Intercompany settlement
44    IntercompanySettlement,
45    /// Project milestone payment
46    ProjectMilestone,
47    /// Other / unclassified cash flow
48    Other,
49}
50
51/// Type of cash pool structure.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum PoolType {
55    /// Physical sweeping of balances to a header account
56    #[default]
57    PhysicalPooling,
58    /// Balances remain in sub-accounts; interest calculated on notional aggregate
59    NotionalPooling,
60    /// Sub-accounts are swept to zero daily
61    ZeroBalancing,
62}
63
64/// Type of hedging instrument.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
66#[serde(rename_all = "snake_case")]
67pub enum HedgeInstrumentType {
68    /// Foreign exchange forward contract
69    #[default]
70    FxForward,
71    /// Foreign exchange option
72    FxOption,
73    /// Interest rate swap
74    InterestRateSwap,
75    /// Commodity forward contract
76    CommodityForward,
77    /// Cross-currency interest rate swap
78    CrossCurrencySwap,
79}
80
81/// Lifecycle status of a hedging instrument.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
83#[serde(rename_all = "snake_case")]
84pub enum InstrumentStatus {
85    /// Instrument is live and outstanding
86    #[default]
87    Active,
88    /// Instrument has reached maturity date
89    Matured,
90    /// Instrument was terminated early
91    Terminated,
92    /// Instrument was novated to a new counterparty
93    Novated,
94}
95
96/// Type of hedged item under ASC 815 / IFRS 9.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
98#[serde(rename_all = "snake_case")]
99pub enum HedgedItemType {
100    /// Highly probable future transaction
101    #[default]
102    ForecastedTransaction,
103    /// Binding contractual commitment
104    FirmCommitment,
105    /// Asset or liability already on balance sheet
106    RecognizedAsset,
107    /// Net investment in a foreign operation
108    NetInvestment,
109}
110
111/// Hedge accounting classification under ASC 815 / IFRS 9.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum HedgeType {
115    /// Fair value hedge — hedges the fair value of an asset/liability
116    #[default]
117    FairValueHedge,
118    /// Cash flow hedge — hedges variability of future cash flows
119    CashFlowHedge,
120    /// Net investment hedge — hedges FX risk in foreign subsidiaries
121    NetInvestmentHedge,
122}
123
124/// Method used to test hedge effectiveness.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
126#[serde(rename_all = "snake_case")]
127pub enum EffectivenessMethod {
128    /// Dollar-offset method (ratio of cumulative changes)
129    #[default]
130    DollarOffset,
131    /// Statistical regression analysis
132    Regression,
133    /// Critical terms match (qualitative)
134    CriticalTerms,
135}
136
137/// Type of debt instrument.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
139#[serde(rename_all = "snake_case")]
140pub enum DebtType {
141    /// Amortizing term loan
142    #[default]
143    TermLoan,
144    /// Revolving credit facility
145    RevolvingCredit,
146    /// Bond issuance
147    Bond,
148    /// Commercial paper (short-term)
149    CommercialPaper,
150    /// Bridge loan (interim financing)
151    BridgeLoan,
152}
153
154/// Interest rate type on a debt instrument.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
156#[serde(rename_all = "snake_case")]
157pub enum InterestRateType {
158    /// Fixed interest rate for the life of the instrument
159    #[default]
160    Fixed,
161    /// Floating rate (index + spread)
162    Variable,
163}
164
165/// Type of financial covenant on a debt instrument.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
167#[serde(rename_all = "snake_case")]
168pub enum CovenantType {
169    /// Total debt / total equity
170    #[default]
171    DebtToEquity,
172    /// EBIT / interest expense
173    InterestCoverage,
174    /// Current assets / current liabilities
175    CurrentRatio,
176    /// Minimum net worth requirement
177    NetWorth,
178    /// Total debt / EBITDA
179    DebtToEbitda,
180    /// (EBITDA - CapEx) / fixed charges
181    FixedChargeCoverage,
182}
183
184/// Measurement frequency for covenant testing.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
186#[serde(rename_all = "snake_case")]
187pub enum Frequency {
188    /// Monthly measurement
189    Monthly,
190    /// Quarterly measurement
191    #[default]
192    Quarterly,
193    /// Annual measurement
194    Annual,
195}
196
197/// Type of bank guarantee or letter of credit.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
199#[serde(rename_all = "snake_case")]
200pub enum GuaranteeType {
201    /// Commercial letter of credit (trade finance)
202    #[default]
203    CommercialLc,
204    /// Standby letter of credit (financial guarantee)
205    StandbyLc,
206    /// Bank guarantee
207    BankGuarantee,
208    /// Performance bond
209    PerformanceBond,
210}
211
212/// Lifecycle status of a bank guarantee.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
214#[serde(rename_all = "snake_case")]
215pub enum GuaranteeStatus {
216    /// Guarantee is active
217    #[default]
218    Active,
219    /// Guarantee has been drawn upon
220    Drawn,
221    /// Guarantee has expired
222    Expired,
223    /// Guarantee was cancelled
224    Cancelled,
225}
226
227/// Netting cycle frequency.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
229#[serde(rename_all = "snake_case")]
230pub enum NettingCycle {
231    /// Daily netting
232    Daily,
233    /// Weekly netting
234    Weekly,
235    /// Monthly netting
236    #[default]
237    Monthly,
238}
239
240/// Settlement direction for a netting position.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
242#[serde(rename_all = "snake_case")]
243pub enum PayOrReceive {
244    /// Entity must pay the net amount
245    #[default]
246    Pay,
247    /// Entity will receive the net amount
248    Receive,
249    /// Entity's position is zero
250    Flat,
251}
252
253// ---------------------------------------------------------------------------
254// Structs
255// ---------------------------------------------------------------------------
256
257/// Daily cash position per entity / bank account / currency.
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct CashPosition {
260    /// Unique position identifier
261    pub id: String,
262    /// Legal entity
263    pub entity_id: String,
264    /// Bank account holding the cash
265    pub bank_account_id: String,
266    /// Position currency
267    pub currency: String,
268    /// Position date
269    pub date: NaiveDate,
270    /// Balance at start of day
271    #[serde(with = "crate::serde_decimal")]
272    pub opening_balance: Decimal,
273    /// Total inflows during the day
274    #[serde(with = "crate::serde_decimal")]
275    pub inflows: Decimal,
276    /// Total outflows during the day
277    #[serde(with = "crate::serde_decimal")]
278    pub outflows: Decimal,
279    /// Balance at end of day (opening + inflows - outflows)
280    #[serde(with = "crate::serde_decimal")]
281    pub closing_balance: Decimal,
282    /// Available balance (after holds, pending transactions)
283    #[serde(with = "crate::serde_decimal")]
284    pub available_balance: Decimal,
285    /// Value-date balance (settlement-adjusted)
286    #[serde(with = "crate::serde_decimal")]
287    pub value_date_balance: Decimal,
288}
289
290impl CashPosition {
291    /// Creates a new cash position.
292    #[allow(clippy::too_many_arguments)]
293    pub fn new(
294        id: impl Into<String>,
295        entity_id: impl Into<String>,
296        bank_account_id: impl Into<String>,
297        currency: impl Into<String>,
298        date: NaiveDate,
299        opening_balance: Decimal,
300        inflows: Decimal,
301        outflows: Decimal,
302    ) -> Self {
303        let closing = (opening_balance + inflows - outflows).round_dp(2);
304        Self {
305            id: id.into(),
306            entity_id: entity_id.into(),
307            bank_account_id: bank_account_id.into(),
308            currency: currency.into(),
309            date,
310            opening_balance,
311            inflows,
312            outflows,
313            closing_balance: closing,
314            available_balance: closing,
315            value_date_balance: closing,
316        }
317    }
318
319    /// Overrides the available balance.
320    pub fn with_available_balance(mut self, balance: Decimal) -> Self {
321        self.available_balance = balance;
322        self
323    }
324
325    /// Overrides the value-date balance.
326    pub fn with_value_date_balance(mut self, balance: Decimal) -> Self {
327        self.value_date_balance = balance;
328        self
329    }
330
331    /// Computes closing balance from opening + inflows - outflows.
332    pub fn computed_closing_balance(&self) -> Decimal {
333        (self.opening_balance + self.inflows - self.outflows).round_dp(2)
334    }
335}
336
337/// A single item in a cash forecast.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct CashForecastItem {
340    /// Unique item identifier
341    pub id: String,
342    /// Expected date of the cash flow
343    pub date: NaiveDate,
344    /// Category of the forecast item
345    pub category: TreasuryCashFlowCategory,
346    /// Expected amount (positive = inflow, negative = outflow)
347    #[serde(with = "crate::serde_decimal")]
348    pub amount: Decimal,
349    /// Probability of occurrence (0.0 to 1.0)
350    #[serde(with = "crate::serde_decimal")]
351    pub probability: Decimal,
352    /// Source document type (e.g., "SalesOrder", "PurchaseOrder")
353    pub source_document_type: Option<String>,
354    /// Source document identifier
355    pub source_document_id: Option<String>,
356}
357
358/// Forward-looking cash forecast for an entity.
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct CashForecast {
361    /// Unique forecast identifier
362    pub id: String,
363    /// Legal entity
364    pub entity_id: String,
365    /// Forecast currency
366    pub currency: String,
367    /// Date the forecast was prepared
368    pub forecast_date: NaiveDate,
369    /// Number of days the forecast covers
370    pub horizon_days: u32,
371    /// Individual forecast line items
372    pub items: Vec<CashForecastItem>,
373    /// Net position (sum of probability-weighted amounts)
374    #[serde(with = "crate::serde_decimal")]
375    pub net_position: Decimal,
376    /// Confidence level for the forecast (0.0 to 1.0)
377    #[serde(with = "crate::serde_decimal")]
378    pub confidence_level: Decimal,
379}
380
381impl CashForecast {
382    /// Creates a new cash forecast.
383    pub fn new(
384        id: impl Into<String>,
385        entity_id: impl Into<String>,
386        currency: impl Into<String>,
387        forecast_date: NaiveDate,
388        horizon_days: u32,
389        items: Vec<CashForecastItem>,
390        confidence_level: Decimal,
391    ) -> Self {
392        let net_position = items
393            .iter()
394            .map(|item| (item.amount * item.probability).round_dp(2))
395            .sum::<Decimal>()
396            .round_dp(2);
397        Self {
398            id: id.into(),
399            entity_id: entity_id.into(),
400            currency: currency.into(),
401            forecast_date,
402            horizon_days,
403            items,
404            net_position,
405            confidence_level,
406        }
407    }
408
409    /// Recomputes the net position from the probability-weighted items.
410    pub fn computed_net_position(&self) -> Decimal {
411        self.items
412            .iter()
413            .map(|item| (item.amount * item.probability).round_dp(2))
414            .sum::<Decimal>()
415            .round_dp(2)
416    }
417}
418
419/// Cash pool grouping entity bank accounts.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct CashPool {
422    /// Unique pool identifier
423    pub id: String,
424    /// Descriptive name
425    pub name: String,
426    /// Type of pooling structure
427    pub pool_type: PoolType,
428    /// Master / header account receiving sweeps
429    pub header_account_id: String,
430    /// Participant sub-account identifiers
431    pub participant_accounts: Vec<String>,
432    /// Time of day when sweeps occur
433    pub sweep_time: NaiveTime,
434    /// Interest rate benefit from pooling (bps or decimal fraction)
435    #[serde(with = "crate::serde_decimal")]
436    pub interest_rate_benefit: Decimal,
437}
438
439impl CashPool {
440    /// Creates a new cash pool.
441    pub fn new(
442        id: impl Into<String>,
443        name: impl Into<String>,
444        pool_type: PoolType,
445        header_account_id: impl Into<String>,
446        sweep_time: NaiveTime,
447    ) -> Self {
448        Self {
449            id: id.into(),
450            name: name.into(),
451            pool_type,
452            header_account_id: header_account_id.into(),
453            participant_accounts: Vec::new(),
454            sweep_time,
455            interest_rate_benefit: Decimal::ZERO,
456        }
457    }
458
459    /// Adds a participant account.
460    pub fn with_participant(mut self, account_id: impl Into<String>) -> Self {
461        self.participant_accounts.push(account_id.into());
462        self
463    }
464
465    /// Sets the interest rate benefit.
466    pub fn with_interest_rate_benefit(mut self, benefit: Decimal) -> Self {
467        self.interest_rate_benefit = benefit;
468        self
469    }
470
471    /// Returns the total number of accounts in the pool (header + participants).
472    pub fn total_accounts(&self) -> usize {
473        1 + self.participant_accounts.len()
474    }
475}
476
477/// A single sweep transaction within a cash pool.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct CashPoolSweep {
480    /// Unique sweep identifier
481    pub id: String,
482    /// Pool this sweep belongs to
483    pub pool_id: String,
484    /// Date of the sweep
485    pub date: NaiveDate,
486    /// Source account (balance swept from)
487    pub from_account_id: String,
488    /// Destination account (balance swept to)
489    pub to_account_id: String,
490    /// Amount swept
491    #[serde(with = "crate::serde_decimal")]
492    pub amount: Decimal,
493    /// Currency of the sweep
494    pub currency: String,
495}
496
497/// A hedging instrument (derivative contract).
498#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct HedgingInstrument {
500    /// Unique instrument identifier
501    pub id: String,
502    /// Type of derivative
503    pub instrument_type: HedgeInstrumentType,
504    /// Notional / face amount
505    #[serde(with = "crate::serde_decimal")]
506    pub notional_amount: Decimal,
507    /// Primary currency
508    pub currency: String,
509    /// Currency pair for FX instruments (e.g., "EUR/USD")
510    pub currency_pair: Option<String>,
511    /// Fixed rate (for swaps, forwards)
512    #[serde(default, with = "crate::serde_decimal::option")]
513    pub fixed_rate: Option<Decimal>,
514    /// Floating rate index name (e.g., "SOFR", "EURIBOR")
515    pub floating_index: Option<String>,
516    /// Strike rate for options
517    #[serde(default, with = "crate::serde_decimal::option")]
518    pub strike_rate: Option<Decimal>,
519    /// Trade date
520    pub trade_date: NaiveDate,
521    /// Maturity / expiry date
522    pub maturity_date: NaiveDate,
523    /// Counterparty name
524    pub counterparty: String,
525    /// Current fair value (mark-to-market)
526    #[serde(with = "crate::serde_decimal")]
527    pub fair_value: Decimal,
528    /// Current lifecycle status
529    pub status: InstrumentStatus,
530}
531
532impl HedgingInstrument {
533    /// Creates a new hedging instrument.
534    #[allow(clippy::too_many_arguments)]
535    pub fn new(
536        id: impl Into<String>,
537        instrument_type: HedgeInstrumentType,
538        notional_amount: Decimal,
539        currency: impl Into<String>,
540        trade_date: NaiveDate,
541        maturity_date: NaiveDate,
542        counterparty: impl Into<String>,
543    ) -> Self {
544        Self {
545            id: id.into(),
546            instrument_type,
547            notional_amount,
548            currency: currency.into(),
549            currency_pair: None,
550            fixed_rate: None,
551            floating_index: None,
552            strike_rate: None,
553            trade_date,
554            maturity_date,
555            counterparty: counterparty.into(),
556            fair_value: Decimal::ZERO,
557            status: InstrumentStatus::Active,
558        }
559    }
560
561    /// Sets the currency pair.
562    pub fn with_currency_pair(mut self, pair: impl Into<String>) -> Self {
563        self.currency_pair = Some(pair.into());
564        self
565    }
566
567    /// Sets the fixed rate.
568    pub fn with_fixed_rate(mut self, rate: Decimal) -> Self {
569        self.fixed_rate = Some(rate);
570        self
571    }
572
573    /// Sets the floating rate index.
574    pub fn with_floating_index(mut self, index: impl Into<String>) -> Self {
575        self.floating_index = Some(index.into());
576        self
577    }
578
579    /// Sets the strike rate.
580    pub fn with_strike_rate(mut self, rate: Decimal) -> Self {
581        self.strike_rate = Some(rate);
582        self
583    }
584
585    /// Sets the fair value.
586    pub fn with_fair_value(mut self, value: Decimal) -> Self {
587        self.fair_value = value;
588        self
589    }
590
591    /// Sets the status.
592    pub fn with_status(mut self, status: InstrumentStatus) -> Self {
593        self.status = status;
594        self
595    }
596
597    /// Returns `true` if the instrument is still outstanding.
598    pub fn is_active(&self) -> bool {
599        self.status == InstrumentStatus::Active
600    }
601
602    /// Returns the remaining tenor in days from the given date.
603    /// Returns 0 if the instrument has already matured.
604    pub fn remaining_tenor_days(&self, as_of: NaiveDate) -> i64 {
605        (self.maturity_date - as_of).num_days().max(0)
606    }
607}
608
609/// ASC 815 / IFRS 9 hedge relationship designation.
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct HedgeRelationship {
612    /// Unique relationship identifier
613    pub id: String,
614    /// Type of hedged item
615    pub hedged_item_type: HedgedItemType,
616    /// Description of what is being hedged
617    pub hedged_item_description: String,
618    /// Hedging instrument linked to this relationship
619    pub hedging_instrument_id: String,
620    /// Hedge accounting classification
621    pub hedge_type: HedgeType,
622    /// Date the hedge was designated
623    pub designation_date: NaiveDate,
624    /// Method used for effectiveness testing
625    pub effectiveness_test_method: EffectivenessMethod,
626    /// Effectiveness ratio (hedging instrument change / hedged item change)
627    #[serde(with = "crate::serde_decimal")]
628    pub effectiveness_ratio: Decimal,
629    /// Whether the hedge qualifies as effective (ratio within 80-125%)
630    pub is_effective: bool,
631    /// Ineffectiveness amount recognized in P&L
632    #[serde(with = "crate::serde_decimal")]
633    pub ineffectiveness_amount: Decimal,
634}
635
636impl HedgeRelationship {
637    /// Creates a new hedge relationship.
638    #[allow(clippy::too_many_arguments)]
639    pub fn new(
640        id: impl Into<String>,
641        hedged_item_type: HedgedItemType,
642        hedged_item_description: impl Into<String>,
643        hedging_instrument_id: impl Into<String>,
644        hedge_type: HedgeType,
645        designation_date: NaiveDate,
646        effectiveness_test_method: EffectivenessMethod,
647        effectiveness_ratio: Decimal,
648    ) -> Self {
649        let is_effective = Self::check_effectiveness(effectiveness_ratio);
650        Self {
651            id: id.into(),
652            hedged_item_type,
653            hedged_item_description: hedged_item_description.into(),
654            hedging_instrument_id: hedging_instrument_id.into(),
655            hedge_type,
656            designation_date,
657            effectiveness_test_method,
658            effectiveness_ratio,
659            is_effective,
660            ineffectiveness_amount: Decimal::ZERO,
661        }
662    }
663
664    /// Sets the ineffectiveness amount.
665    pub fn with_ineffectiveness_amount(mut self, amount: Decimal) -> Self {
666        self.ineffectiveness_amount = amount;
667        self
668    }
669
670    /// Checks whether the effectiveness ratio is within the 80-125% corridor.
671    ///
672    /// Under ASC 815 / IAS 39, a hedge is considered highly effective if the
673    /// ratio of changes in the hedging instrument to changes in the hedged item
674    /// falls within 0.80 to 1.25.
675    pub fn check_effectiveness(ratio: Decimal) -> bool {
676        let lower = Decimal::new(80, 2); // 0.80
677        let upper = Decimal::new(125, 2); // 1.25
678        ratio >= lower && ratio <= upper
679    }
680
681    /// Recomputes the `is_effective` flag from the current ratio.
682    pub fn update_effectiveness(&mut self) {
683        self.is_effective = Self::check_effectiveness(self.effectiveness_ratio);
684    }
685}
686
687/// A single payment in a debt amortization schedule.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct AmortizationPayment {
690    /// Payment date
691    pub date: NaiveDate,
692    /// Principal portion of the payment
693    #[serde(with = "crate::serde_decimal")]
694    pub principal_payment: Decimal,
695    /// Interest portion of the payment
696    #[serde(with = "crate::serde_decimal")]
697    pub interest_payment: Decimal,
698    /// Outstanding balance after this payment
699    #[serde(with = "crate::serde_decimal")]
700    pub balance_after: Decimal,
701}
702
703impl AmortizationPayment {
704    /// Total payment (principal + interest).
705    pub fn total_payment(&self) -> Decimal {
706        (self.principal_payment + self.interest_payment).round_dp(2)
707    }
708}
709
710/// A financial covenant attached to a debt instrument.
711#[derive(Debug, Clone, Serialize, Deserialize)]
712pub struct DebtCovenant {
713    /// Unique covenant identifier
714    pub id: String,
715    /// Type of financial ratio being tested
716    pub covenant_type: CovenantType,
717    /// Covenant threshold value
718    #[serde(with = "crate::serde_decimal")]
719    pub threshold: Decimal,
720    /// How often the covenant is tested
721    pub measurement_frequency: Frequency,
722    /// Most recent actual measured value
723    #[serde(with = "crate::serde_decimal")]
724    pub actual_value: Decimal,
725    /// Date the measurement was taken
726    pub measurement_date: NaiveDate,
727    /// Whether the entity is in compliance
728    pub is_compliant: bool,
729    /// Distance from the covenant threshold (positive = headroom, negative = breach)
730    #[serde(with = "crate::serde_decimal")]
731    pub headroom: Decimal,
732    /// Whether a waiver was obtained for a breach
733    pub waiver_obtained: bool,
734
735    // -- Standalone fields for graph export (DS-003) --
736    /// Back-reference to the parent debt instrument ID
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub facility_id: Option<String>,
739    /// Entity / company code
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub entity_code: Option<String>,
742    /// Debt facility name
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub facility_name: Option<String>,
745    /// Outstanding principal at measurement date
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub outstanding_principal: Option<Decimal>,
748    /// Currency of the facility
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub currency: Option<String>,
751    /// Fiscal period (e.g. "2024-06")
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub period: Option<String>,
754}
755
756impl DebtCovenant {
757    /// Creates a new debt covenant.
758    #[allow(clippy::too_many_arguments)]
759    pub fn new(
760        id: impl Into<String>,
761        covenant_type: CovenantType,
762        threshold: Decimal,
763        measurement_frequency: Frequency,
764        actual_value: Decimal,
765        measurement_date: NaiveDate,
766    ) -> Self {
767        let (is_compliant, headroom) =
768            Self::evaluate_compliance(covenant_type, threshold, actual_value);
769        Self {
770            id: id.into(),
771            covenant_type,
772            threshold,
773            measurement_frequency,
774            actual_value,
775            measurement_date,
776            is_compliant,
777            headroom,
778            waiver_obtained: false,
779            facility_id: None,
780            entity_code: None,
781            facility_name: None,
782            outstanding_principal: None,
783            currency: None,
784            period: None,
785        }
786    }
787
788    /// Sets the waiver flag.
789    pub fn with_waiver(mut self, waiver: bool) -> Self {
790        self.waiver_obtained = waiver;
791        self
792    }
793
794    /// Set the parent debt facility details for standalone graph export.
795    pub fn with_facility(
796        mut self,
797        facility_id: impl Into<String>,
798        entity_code: impl Into<String>,
799        facility_name: impl Into<String>,
800        outstanding_principal: Decimal,
801        currency: impl Into<String>,
802        period: impl Into<String>,
803    ) -> Self {
804        self.facility_id = Some(facility_id.into());
805        self.entity_code = Some(entity_code.into());
806        self.facility_name = Some(facility_name.into());
807        self.outstanding_principal = Some(outstanding_principal);
808        self.currency = Some(currency.into());
809        self.period = Some(period.into());
810        self
811    }
812
813    /// Evaluates compliance and computes headroom.
814    ///
815    /// For "maximum" covenants (DebtToEquity, DebtToEbitda): actual must be ≤ threshold.
816    /// For "minimum" covenants (InterestCoverage, CurrentRatio, NetWorth, FixedChargeCoverage):
817    /// actual must be ≥ threshold.
818    fn evaluate_compliance(
819        covenant_type: CovenantType,
820        threshold: Decimal,
821        actual_value: Decimal,
822    ) -> (bool, Decimal) {
823        match covenant_type {
824            // Maximum covenants: actual <= threshold means compliant
825            CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
826                let headroom = (threshold - actual_value).round_dp(4);
827                (actual_value <= threshold, headroom)
828            }
829            // Minimum covenants: actual >= threshold means compliant
830            CovenantType::InterestCoverage
831            | CovenantType::CurrentRatio
832            | CovenantType::NetWorth
833            | CovenantType::FixedChargeCoverage => {
834                let headroom = (actual_value - threshold).round_dp(4);
835                (actual_value >= threshold, headroom)
836            }
837        }
838    }
839
840    /// Recomputes compliance and headroom from current values.
841    pub fn update_compliance(&mut self) {
842        let (compliant, headroom) =
843            Self::evaluate_compliance(self.covenant_type, self.threshold, self.actual_value);
844        self.is_compliant = compliant;
845        self.headroom = headroom;
846    }
847}
848
849/// A debt instrument (loan, bond, credit facility).
850#[derive(Debug, Clone, Serialize, Deserialize)]
851pub struct DebtInstrument {
852    /// Unique instrument identifier
853    pub id: String,
854    /// Legal entity borrower
855    pub entity_id: String,
856    /// Type of debt instrument
857    pub instrument_type: DebtType,
858    /// Lender / creditor name
859    pub lender: String,
860    /// Original principal amount
861    #[serde(with = "crate::serde_decimal")]
862    pub principal: Decimal,
863    /// Denomination currency
864    pub currency: String,
865    /// Interest rate (annual, as decimal fraction)
866    #[serde(with = "crate::serde_decimal")]
867    pub interest_rate: Decimal,
868    /// Fixed or variable rate
869    pub rate_type: InterestRateType,
870    /// Date the instrument was originated
871    pub origination_date: NaiveDate,
872    /// Contractual maturity date
873    pub maturity_date: NaiveDate,
874    /// Amortization schedule (empty for bullet / revolving)
875    pub amortization_schedule: Vec<AmortizationPayment>,
876    /// Associated financial covenants
877    pub covenants: Vec<DebtCovenant>,
878    /// Current drawn amount (for revolving facilities)
879    #[serde(with = "crate::serde_decimal")]
880    pub drawn_amount: Decimal,
881    /// Committed facility limit (for revolving facilities)
882    #[serde(with = "crate::serde_decimal")]
883    pub facility_limit: Decimal,
884}
885
886impl DebtInstrument {
887    /// Creates a new debt instrument.
888    #[allow(clippy::too_many_arguments)]
889    pub fn new(
890        id: impl Into<String>,
891        entity_id: impl Into<String>,
892        instrument_type: DebtType,
893        lender: impl Into<String>,
894        principal: Decimal,
895        currency: impl Into<String>,
896        interest_rate: Decimal,
897        rate_type: InterestRateType,
898        origination_date: NaiveDate,
899        maturity_date: NaiveDate,
900    ) -> Self {
901        Self {
902            id: id.into(),
903            entity_id: entity_id.into(),
904            instrument_type,
905            lender: lender.into(),
906            principal,
907            currency: currency.into(),
908            interest_rate,
909            rate_type,
910            origination_date,
911            maturity_date,
912            amortization_schedule: Vec::new(),
913            covenants: Vec::new(),
914            drawn_amount: principal,
915            facility_limit: principal,
916        }
917    }
918
919    /// Sets the amortization schedule.
920    pub fn with_amortization_schedule(mut self, schedule: Vec<AmortizationPayment>) -> Self {
921        self.amortization_schedule = schedule;
922        self
923    }
924
925    /// Adds a covenant.
926    pub fn with_covenant(mut self, covenant: DebtCovenant) -> Self {
927        self.covenants.push(covenant);
928        self
929    }
930
931    /// Sets the drawn amount (for revolving facilities).
932    pub fn with_drawn_amount(mut self, amount: Decimal) -> Self {
933        self.drawn_amount = amount;
934        self
935    }
936
937    /// Sets the facility limit (for revolving facilities).
938    pub fn with_facility_limit(mut self, limit: Decimal) -> Self {
939        self.facility_limit = limit;
940        self
941    }
942
943    /// Returns the total principal payments across the amortization schedule.
944    pub fn total_principal_payments(&self) -> Decimal {
945        self.amortization_schedule
946            .iter()
947            .map(|p| p.principal_payment)
948            .sum::<Decimal>()
949            .round_dp(2)
950    }
951
952    /// Returns the total interest payments across the amortization schedule.
953    pub fn total_interest_payments(&self) -> Decimal {
954        self.amortization_schedule
955            .iter()
956            .map(|p| p.interest_payment)
957            .sum::<Decimal>()
958            .round_dp(2)
959    }
960
961    /// Returns available capacity on a revolving credit facility.
962    pub fn available_capacity(&self) -> Decimal {
963        (self.facility_limit - self.drawn_amount).round_dp(2)
964    }
965
966    /// Returns `true` if all covenants are compliant.
967    pub fn all_covenants_compliant(&self) -> bool {
968        self.covenants
969            .iter()
970            .all(|c| c.is_compliant || c.waiver_obtained)
971    }
972}
973
974/// A bank guarantee or letter of credit.
975#[derive(Debug, Clone, Serialize, Deserialize)]
976pub struct BankGuarantee {
977    /// Unique guarantee identifier
978    pub id: String,
979    /// Legal entity that obtained the guarantee
980    pub entity_id: String,
981    /// Type of guarantee
982    pub guarantee_type: GuaranteeType,
983    /// Face amount of the guarantee
984    #[serde(with = "crate::serde_decimal")]
985    pub amount: Decimal,
986    /// Denomination currency
987    pub currency: String,
988    /// Party in whose favour the guarantee is issued
989    pub beneficiary: String,
990    /// Bank that issued the guarantee
991    pub issuing_bank: String,
992    /// Issue date
993    pub issue_date: NaiveDate,
994    /// Expiry date
995    pub expiry_date: NaiveDate,
996    /// Current lifecycle status
997    pub status: GuaranteeStatus,
998    /// Linked procurement contract (if applicable)
999    pub linked_contract_id: Option<String>,
1000    /// Linked project (if applicable)
1001    pub linked_project_id: Option<String>,
1002}
1003
1004impl BankGuarantee {
1005    /// Creates a new bank guarantee.
1006    #[allow(clippy::too_many_arguments)]
1007    pub fn new(
1008        id: impl Into<String>,
1009        entity_id: impl Into<String>,
1010        guarantee_type: GuaranteeType,
1011        amount: Decimal,
1012        currency: impl Into<String>,
1013        beneficiary: impl Into<String>,
1014        issuing_bank: impl Into<String>,
1015        issue_date: NaiveDate,
1016        expiry_date: NaiveDate,
1017    ) -> Self {
1018        Self {
1019            id: id.into(),
1020            entity_id: entity_id.into(),
1021            guarantee_type,
1022            amount,
1023            currency: currency.into(),
1024            beneficiary: beneficiary.into(),
1025            issuing_bank: issuing_bank.into(),
1026            issue_date,
1027            expiry_date,
1028            status: GuaranteeStatus::Active,
1029            linked_contract_id: None,
1030            linked_project_id: None,
1031        }
1032    }
1033
1034    /// Sets the status.
1035    pub fn with_status(mut self, status: GuaranteeStatus) -> Self {
1036        self.status = status;
1037        self
1038    }
1039
1040    /// Links to a procurement contract.
1041    pub fn with_linked_contract(mut self, contract_id: impl Into<String>) -> Self {
1042        self.linked_contract_id = Some(contract_id.into());
1043        self
1044    }
1045
1046    /// Links to a project.
1047    pub fn with_linked_project(mut self, project_id: impl Into<String>) -> Self {
1048        self.linked_project_id = Some(project_id.into());
1049        self
1050    }
1051
1052    /// Returns `true` if the guarantee is active on the given date.
1053    pub fn is_active_on(&self, date: NaiveDate) -> bool {
1054        self.status == GuaranteeStatus::Active
1055            && date >= self.issue_date
1056            && date <= self.expiry_date
1057    }
1058
1059    /// Returns the remaining validity in days from the given date.
1060    pub fn remaining_days(&self, as_of: NaiveDate) -> i64 {
1061        (self.expiry_date - as_of).num_days().max(0)
1062    }
1063}
1064
1065/// A netting position for a single entity within a netting run.
1066#[derive(Debug, Clone, Serialize, Deserialize)]
1067pub struct NettingPosition {
1068    /// Entity identifier
1069    pub entity_id: String,
1070    /// Gross amount receivable from other entities
1071    #[serde(with = "crate::serde_decimal")]
1072    pub gross_receivable: Decimal,
1073    /// Gross amount payable to other entities
1074    #[serde(with = "crate::serde_decimal")]
1075    pub gross_payable: Decimal,
1076    /// Net position (receivable - payable)
1077    #[serde(with = "crate::serde_decimal")]
1078    pub net_position: Decimal,
1079    /// Whether this entity pays or receives
1080    pub settlement_direction: PayOrReceive,
1081}
1082
1083/// An intercompany netting run.
1084#[derive(Debug, Clone, Serialize, Deserialize)]
1085pub struct NettingRun {
1086    /// Unique netting run identifier
1087    pub id: String,
1088    /// Settlement date
1089    pub netting_date: NaiveDate,
1090    /// Netting cycle frequency
1091    pub cycle: NettingCycle,
1092    /// List of participating entity IDs
1093    pub participating_entities: Vec<String>,
1094    /// Total gross receivables across all entities
1095    #[serde(with = "crate::serde_decimal")]
1096    pub gross_receivables: Decimal,
1097    /// Total gross payables across all entities
1098    #[serde(with = "crate::serde_decimal")]
1099    pub gross_payables: Decimal,
1100    /// Net settlement amount (sum of absolute net positions / 2)
1101    #[serde(with = "crate::serde_decimal")]
1102    pub net_settlement: Decimal,
1103    /// Settlement currency
1104    pub settlement_currency: String,
1105    /// Per-entity positions
1106    pub positions: Vec<NettingPosition>,
1107}
1108
1109impl NettingRun {
1110    /// Creates a new netting run.
1111    #[allow(clippy::too_many_arguments)]
1112    pub fn new(
1113        id: impl Into<String>,
1114        netting_date: NaiveDate,
1115        cycle: NettingCycle,
1116        settlement_currency: impl Into<String>,
1117        positions: Vec<NettingPosition>,
1118    ) -> Self {
1119        let participating_entities: Vec<String> =
1120            positions.iter().map(|p| p.entity_id.clone()).collect();
1121        let gross_receivables = positions
1122            .iter()
1123            .map(|p| p.gross_receivable)
1124            .sum::<Decimal>()
1125            .round_dp(2);
1126        let gross_payables = positions
1127            .iter()
1128            .map(|p| p.gross_payable)
1129            .sum::<Decimal>()
1130            .round_dp(2);
1131        let net_settlement = positions
1132            .iter()
1133            .map(|p| p.net_position.abs())
1134            .sum::<Decimal>()
1135            .round_dp(2)
1136            / Decimal::TWO;
1137        Self {
1138            id: id.into(),
1139            netting_date,
1140            cycle,
1141            participating_entities,
1142            gross_receivables,
1143            gross_payables,
1144            net_settlement: net_settlement.round_dp(2),
1145            settlement_currency: settlement_currency.into(),
1146            positions,
1147        }
1148    }
1149
1150    /// Payment savings from netting: gross flows eliminated.
1151    ///
1152    /// `savings = max(gross_receivables, gross_payables) - net_settlement`
1153    pub fn savings(&self) -> Decimal {
1154        let gross_max = self.gross_receivables.max(self.gross_payables);
1155        (gross_max - self.net_settlement).round_dp(2)
1156    }
1157
1158    /// Savings as a percentage of gross flows.
1159    pub fn savings_pct(&self) -> Decimal {
1160        let gross_max = self.gross_receivables.max(self.gross_payables);
1161        if gross_max.is_zero() {
1162            return Decimal::ZERO;
1163        }
1164        (self.savings() / gross_max * Decimal::ONE_HUNDRED).round_dp(2)
1165    }
1166}
1167
1168// ---------------------------------------------------------------------------
1169// ToNodeProperties implementations
1170// ---------------------------------------------------------------------------
1171
1172impl ToNodeProperties for CashPosition {
1173    fn node_type_name(&self) -> &'static str {
1174        "cash_position"
1175    }
1176    fn node_type_code(&self) -> u16 {
1177        420
1178    }
1179    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1180        let mut p = HashMap::new();
1181        p.insert(
1182            "entityCode".into(),
1183            GraphPropertyValue::String(self.entity_id.clone()),
1184        );
1185        p.insert(
1186            "bankId".into(),
1187            GraphPropertyValue::String(self.bank_account_id.clone()),
1188        );
1189        p.insert(
1190            "currency".into(),
1191            GraphPropertyValue::String(self.currency.clone()),
1192        );
1193        p.insert("asOfDate".into(), GraphPropertyValue::Date(self.date));
1194        p.insert(
1195            "openingBalance".into(),
1196            GraphPropertyValue::Decimal(self.opening_balance),
1197        );
1198        p.insert("inflows".into(), GraphPropertyValue::Decimal(self.inflows));
1199        p.insert(
1200            "outflows".into(),
1201            GraphPropertyValue::Decimal(self.outflows),
1202        );
1203        p.insert(
1204            "balance".into(),
1205            GraphPropertyValue::Decimal(self.closing_balance),
1206        );
1207        p.insert(
1208            "availableBalance".into(),
1209            GraphPropertyValue::Decimal(self.available_balance),
1210        );
1211        p.insert(
1212            "valueDateBalance".into(),
1213            GraphPropertyValue::Decimal(self.value_date_balance),
1214        );
1215        p
1216    }
1217}
1218
1219impl ToNodeProperties for CashForecast {
1220    fn node_type_name(&self) -> &'static str {
1221        "cash_forecast"
1222    }
1223    fn node_type_code(&self) -> u16 {
1224        421
1225    }
1226    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1227        let mut p = HashMap::new();
1228        p.insert(
1229            "entityCode".into(),
1230            GraphPropertyValue::String(self.entity_id.clone()),
1231        );
1232        p.insert(
1233            "currency".into(),
1234            GraphPropertyValue::String(self.currency.clone()),
1235        );
1236        p.insert(
1237            "forecastDate".into(),
1238            GraphPropertyValue::Date(self.forecast_date),
1239        );
1240        p.insert(
1241            "horizonDays".into(),
1242            GraphPropertyValue::Int(self.horizon_days as i64),
1243        );
1244        p.insert(
1245            "itemCount".into(),
1246            GraphPropertyValue::Int(self.items.len() as i64),
1247        );
1248        p.insert(
1249            "netPosition".into(),
1250            GraphPropertyValue::Decimal(self.net_position),
1251        );
1252        p.insert(
1253            "certaintyLevel".into(),
1254            GraphPropertyValue::Float(self.confidence_level.to_f64().unwrap_or(0.0)),
1255        );
1256        p
1257    }
1258}
1259
1260impl ToNodeProperties for CashPool {
1261    fn node_type_name(&self) -> &'static str {
1262        "cash_pool"
1263    }
1264    fn node_type_code(&self) -> u16 {
1265        422
1266    }
1267    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1268        let mut p = HashMap::new();
1269        p.insert("name".into(), GraphPropertyValue::String(self.name.clone()));
1270        p.insert(
1271            "poolType".into(),
1272            GraphPropertyValue::String(format!("{:?}", self.pool_type)),
1273        );
1274        p.insert(
1275            "headerAccount".into(),
1276            GraphPropertyValue::String(self.header_account_id.clone()),
1277        );
1278        p.insert(
1279            "participantCount".into(),
1280            GraphPropertyValue::Int(self.participant_accounts.len() as i64),
1281        );
1282        p.insert(
1283            "interestBenefit".into(),
1284            GraphPropertyValue::Decimal(self.interest_rate_benefit),
1285        );
1286        p
1287    }
1288}
1289
1290impl ToNodeProperties for CashPoolSweep {
1291    fn node_type_name(&self) -> &'static str {
1292        "cash_pool_sweep"
1293    }
1294    fn node_type_code(&self) -> u16 {
1295        423
1296    }
1297    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1298        let mut p = HashMap::new();
1299        p.insert(
1300            "poolId".into(),
1301            GraphPropertyValue::String(self.pool_id.clone()),
1302        );
1303        p.insert("date".into(), GraphPropertyValue::Date(self.date));
1304        p.insert(
1305            "fromAccount".into(),
1306            GraphPropertyValue::String(self.from_account_id.clone()),
1307        );
1308        p.insert(
1309            "toAccount".into(),
1310            GraphPropertyValue::String(self.to_account_id.clone()),
1311        );
1312        p.insert("amount".into(), GraphPropertyValue::Decimal(self.amount));
1313        p.insert(
1314            "currency".into(),
1315            GraphPropertyValue::String(self.currency.clone()),
1316        );
1317        p
1318    }
1319}
1320
1321impl ToNodeProperties for HedgingInstrument {
1322    fn node_type_name(&self) -> &'static str {
1323        "hedging_instrument"
1324    }
1325    fn node_type_code(&self) -> u16 {
1326        424
1327    }
1328    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1329        let mut p = HashMap::new();
1330        p.insert(
1331            "hedgeType".into(),
1332            GraphPropertyValue::String(format!("{:?}", self.instrument_type)),
1333        );
1334        p.insert(
1335            "notionalAmount".into(),
1336            GraphPropertyValue::Decimal(self.notional_amount),
1337        );
1338        p.insert(
1339            "currency".into(),
1340            GraphPropertyValue::String(self.currency.clone()),
1341        );
1342        if let Some(ref cp) = self.currency_pair {
1343            p.insert(
1344                "currencyPair".into(),
1345                GraphPropertyValue::String(cp.clone()),
1346            );
1347        }
1348        p.insert(
1349            "tradeDate".into(),
1350            GraphPropertyValue::Date(self.trade_date),
1351        );
1352        p.insert(
1353            "maturityDate".into(),
1354            GraphPropertyValue::Date(self.maturity_date),
1355        );
1356        p.insert(
1357            "counterparty".into(),
1358            GraphPropertyValue::String(self.counterparty.clone()),
1359        );
1360        p.insert(
1361            "fairValue".into(),
1362            GraphPropertyValue::Decimal(self.fair_value),
1363        );
1364        p.insert(
1365            "status".into(),
1366            GraphPropertyValue::String(format!("{:?}", self.status)),
1367        );
1368        p
1369    }
1370}
1371
1372impl ToNodeProperties for HedgeRelationship {
1373    fn node_type_name(&self) -> &'static str {
1374        "hedge_relationship"
1375    }
1376    fn node_type_code(&self) -> u16 {
1377        425
1378    }
1379    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1380        let mut p = HashMap::new();
1381        p.insert(
1382            "hedgedItemType".into(),
1383            GraphPropertyValue::String(format!("{:?}", self.hedged_item_type)),
1384        );
1385        p.insert(
1386            "hedgedItemDescription".into(),
1387            GraphPropertyValue::String(self.hedged_item_description.clone()),
1388        );
1389        p.insert(
1390            "instrumentId".into(),
1391            GraphPropertyValue::String(self.hedging_instrument_id.clone()),
1392        );
1393        p.insert(
1394            "hedgeType".into(),
1395            GraphPropertyValue::String(format!("{:?}", self.hedge_type)),
1396        );
1397        p.insert(
1398            "designationDate".into(),
1399            GraphPropertyValue::Date(self.designation_date),
1400        );
1401        p.insert(
1402            "effectivenessMethod".into(),
1403            GraphPropertyValue::String(format!("{:?}", self.effectiveness_test_method)),
1404        );
1405        p.insert(
1406            "effectivenessRatio".into(),
1407            GraphPropertyValue::Float(self.effectiveness_ratio.to_f64().unwrap_or(0.0)),
1408        );
1409        p.insert(
1410            "isEffective".into(),
1411            GraphPropertyValue::Bool(self.is_effective),
1412        );
1413        p.insert(
1414            "ineffectivenessAmount".into(),
1415            GraphPropertyValue::Decimal(self.ineffectiveness_amount),
1416        );
1417        p
1418    }
1419}
1420
1421impl ToNodeProperties for DebtInstrument {
1422    fn node_type_name(&self) -> &'static str {
1423        "debt_instrument"
1424    }
1425    fn node_type_code(&self) -> u16 {
1426        426
1427    }
1428    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1429        let mut p = HashMap::new();
1430        p.insert(
1431            "entityCode".into(),
1432            GraphPropertyValue::String(self.entity_id.clone()),
1433        );
1434        p.insert(
1435            "instrumentType".into(),
1436            GraphPropertyValue::String(format!("{:?}", self.instrument_type)),
1437        );
1438        p.insert(
1439            "lender".into(),
1440            GraphPropertyValue::String(self.lender.clone()),
1441        );
1442        p.insert(
1443            "principal".into(),
1444            GraphPropertyValue::Decimal(self.principal),
1445        );
1446        p.insert(
1447            "currency".into(),
1448            GraphPropertyValue::String(self.currency.clone()),
1449        );
1450        p.insert(
1451            "interestRate".into(),
1452            GraphPropertyValue::Decimal(self.interest_rate),
1453        );
1454        p.insert(
1455            "rateType".into(),
1456            GraphPropertyValue::String(format!("{:?}", self.rate_type)),
1457        );
1458        p.insert(
1459            "originationDate".into(),
1460            GraphPropertyValue::Date(self.origination_date),
1461        );
1462        p.insert(
1463            "maturityDate".into(),
1464            GraphPropertyValue::Date(self.maturity_date),
1465        );
1466        p.insert(
1467            "drawnAmount".into(),
1468            GraphPropertyValue::Decimal(self.drawn_amount),
1469        );
1470        p.insert(
1471            "facilityLimit".into(),
1472            GraphPropertyValue::Decimal(self.facility_limit),
1473        );
1474        p.insert(
1475            "covenantCount".into(),
1476            GraphPropertyValue::Int(self.covenants.len() as i64),
1477        );
1478        p
1479    }
1480}
1481
1482impl ToNodeProperties for DebtCovenant {
1483    fn node_type_name(&self) -> &'static str {
1484        "debt_covenant"
1485    }
1486    fn node_type_code(&self) -> u16 {
1487        427
1488    }
1489    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1490        let mut p = HashMap::new();
1491        p.insert(
1492            "covenantType".into(),
1493            GraphPropertyValue::String(format!("{:?}", self.covenant_type)),
1494        );
1495        p.insert(
1496            "threshold".into(),
1497            GraphPropertyValue::Decimal(self.threshold),
1498        );
1499        p.insert(
1500            "frequency".into(),
1501            GraphPropertyValue::String(format!("{:?}", self.measurement_frequency)),
1502        );
1503        p.insert(
1504            "actualValue".into(),
1505            GraphPropertyValue::Decimal(self.actual_value),
1506        );
1507        p.insert(
1508            "testDate".into(),
1509            GraphPropertyValue::Date(self.measurement_date),
1510        );
1511        p.insert(
1512            "complianceStatus".into(),
1513            GraphPropertyValue::Bool(self.is_compliant),
1514        );
1515        p.insert(
1516            "headroom".into(),
1517            GraphPropertyValue::Decimal(self.headroom),
1518        );
1519        p.insert(
1520            "waiverObtained".into(),
1521            GraphPropertyValue::Bool(self.waiver_obtained),
1522        );
1523        if let Some(ref fid) = self.facility_id {
1524            p.insert("facilityId".into(), GraphPropertyValue::String(fid.clone()));
1525        }
1526        if let Some(ref ec) = self.entity_code {
1527            p.insert("entityCode".into(), GraphPropertyValue::String(ec.clone()));
1528        }
1529        if let Some(ref fn_) = self.facility_name {
1530            p.insert(
1531                "facilityName".into(),
1532                GraphPropertyValue::String(fn_.clone()),
1533            );
1534        }
1535        if let Some(op) = self.outstanding_principal {
1536            p.insert(
1537                "outstandingPrincipal".into(),
1538                GraphPropertyValue::Decimal(op),
1539            );
1540        }
1541        if let Some(ref cur) = self.currency {
1542            p.insert("currency".into(), GraphPropertyValue::String(cur.clone()));
1543        }
1544        if let Some(ref per) = self.period {
1545            p.insert("period".into(), GraphPropertyValue::String(per.clone()));
1546        }
1547        p
1548    }
1549}
1550
1551// ---------------------------------------------------------------------------
1552// Tests
1553// ---------------------------------------------------------------------------
1554
1555#[cfg(test)]
1556mod tests {
1557    use super::*;
1558    use rust_decimal_macros::dec;
1559
1560    #[test]
1561    fn test_cash_position_closing_balance() {
1562        let pos = CashPosition::new(
1563            "CP-001",
1564            "C001",
1565            "BA-001",
1566            "USD",
1567            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1568            dec!(10000),
1569            dec!(5000),
1570            dec!(2000),
1571        );
1572        // closing = 10000 + 5000 - 2000 = 13000
1573        assert_eq!(pos.closing_balance, dec!(13000));
1574        assert_eq!(pos.computed_closing_balance(), dec!(13000));
1575        assert_eq!(pos.available_balance, dec!(13000)); // defaults to closing
1576    }
1577
1578    #[test]
1579    fn test_cash_position_with_overrides() {
1580        let pos = CashPosition::new(
1581            "CP-002",
1582            "C001",
1583            "BA-001",
1584            "USD",
1585            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1586            dec!(10000),
1587            dec!(5000),
1588            dec!(2000),
1589        )
1590        .with_available_balance(dec!(12000))
1591        .with_value_date_balance(dec!(12500));
1592
1593        assert_eq!(pos.closing_balance, dec!(13000));
1594        assert_eq!(pos.available_balance, dec!(12000));
1595        assert_eq!(pos.value_date_balance, dec!(12500));
1596    }
1597
1598    #[test]
1599    fn test_cash_forecast_net_position() {
1600        let items = vec![
1601            CashForecastItem {
1602                id: "CFI-001".to_string(),
1603                date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
1604                category: TreasuryCashFlowCategory::ArCollection,
1605                amount: dec!(50000),
1606                probability: dec!(0.90),
1607                source_document_type: Some("SalesOrder".to_string()),
1608                source_document_id: Some("SO-001".to_string()),
1609            },
1610            CashForecastItem {
1611                id: "CFI-002".to_string(),
1612                date: NaiveDate::from_ymd_opt(2025, 2, 5).unwrap(),
1613                category: TreasuryCashFlowCategory::ApPayment,
1614                amount: dec!(-30000),
1615                probability: dec!(1.00),
1616                source_document_type: Some("PurchaseOrder".to_string()),
1617                source_document_id: Some("PO-001".to_string()),
1618            },
1619            CashForecastItem {
1620                id: "CFI-003".to_string(),
1621                date: NaiveDate::from_ymd_opt(2025, 2, 15).unwrap(),
1622                category: TreasuryCashFlowCategory::TaxPayment,
1623                amount: dec!(-10000),
1624                probability: dec!(1.00),
1625                source_document_type: None,
1626                source_document_id: None,
1627            },
1628        ];
1629        let forecast = CashForecast::new(
1630            "CF-001",
1631            "C001",
1632            "USD",
1633            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
1634            30,
1635            items,
1636            dec!(0.90),
1637        );
1638
1639        // net = (50000 * 0.90) + (-30000 * 1.00) + (-10000 * 1.00)
1640        //     = 45000 - 30000 - 10000 = 5000
1641        assert_eq!(forecast.net_position, dec!(5000));
1642        assert_eq!(forecast.computed_net_position(), dec!(5000));
1643        assert_eq!(forecast.items.len(), 3);
1644    }
1645
1646    #[test]
1647    fn test_cash_pool_total_accounts() {
1648        let pool = CashPool::new(
1649            "POOL-001",
1650            "EUR Cash Pool",
1651            PoolType::ZeroBalancing,
1652            "BA-HEADER",
1653            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1654        )
1655        .with_participant("BA-001")
1656        .with_participant("BA-002")
1657        .with_participant("BA-003")
1658        .with_interest_rate_benefit(dec!(0.0025));
1659
1660        assert_eq!(pool.total_accounts(), 4); // header + 3 participants
1661        assert_eq!(pool.interest_rate_benefit, dec!(0.0025));
1662        assert_eq!(pool.pool_type, PoolType::ZeroBalancing);
1663    }
1664
1665    #[test]
1666    fn test_hedging_instrument_lifecycle() {
1667        let instr = HedgingInstrument::new(
1668            "HI-001",
1669            HedgeInstrumentType::FxForward,
1670            dec!(1000000),
1671            "EUR",
1672            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1673            NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1674            "Deutsche Bank",
1675        )
1676        .with_currency_pair("EUR/USD")
1677        .with_fixed_rate(dec!(1.0850))
1678        .with_fair_value(dec!(15000));
1679
1680        assert!(instr.is_active());
1681        assert_eq!(
1682            instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
1683            107 // 2025-03-15 to 2025-06-30
1684        );
1685        assert_eq!(instr.currency_pair, Some("EUR/USD".to_string()));
1686        assert_eq!(instr.fixed_rate, Some(dec!(1.0850)));
1687
1688        // Terminate
1689        let terminated = instr.with_status(InstrumentStatus::Terminated);
1690        assert!(!terminated.is_active());
1691    }
1692
1693    #[test]
1694    fn test_hedging_instrument_remaining_tenor_past_maturity() {
1695        let instr = HedgingInstrument::new(
1696            "HI-002",
1697            HedgeInstrumentType::InterestRateSwap,
1698            dec!(5000000),
1699            "USD",
1700            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1701            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1702            "JPMorgan",
1703        );
1704
1705        // Past maturity → 0 days
1706        assert_eq!(
1707            instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()),
1708            0
1709        );
1710    }
1711
1712    #[test]
1713    fn test_hedge_relationship_effectiveness() {
1714        // Effective: ratio = 0.95 (within 80-125%)
1715        let effective = HedgeRelationship::new(
1716            "HR-001",
1717            HedgedItemType::ForecastedTransaction,
1718            "Forecasted EUR revenue Q2 2025",
1719            "HI-001",
1720            HedgeType::CashFlowHedge,
1721            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1722            EffectivenessMethod::Regression,
1723            dec!(0.95),
1724        );
1725        assert!(effective.is_effective);
1726        assert!(HedgeRelationship::check_effectiveness(dec!(0.80))); // boundary
1727        assert!(HedgeRelationship::check_effectiveness(dec!(1.25))); // boundary
1728
1729        // Ineffective: ratio = 0.75 (below 80%)
1730        let ineffective = HedgeRelationship::new(
1731            "HR-002",
1732            HedgedItemType::FirmCommitment,
1733            "Committed USD purchase",
1734            "HI-002",
1735            HedgeType::FairValueHedge,
1736            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1737            EffectivenessMethod::DollarOffset,
1738            dec!(0.75),
1739        )
1740        .with_ineffectiveness_amount(dec!(25000));
1741        assert!(!ineffective.is_effective);
1742        assert_eq!(ineffective.ineffectiveness_amount, dec!(25000));
1743
1744        // Boundaries
1745        assert!(!HedgeRelationship::check_effectiveness(dec!(0.79)));
1746        assert!(!HedgeRelationship::check_effectiveness(dec!(1.26)));
1747    }
1748
1749    #[test]
1750    fn test_debt_covenant_compliance() {
1751        // Maximum covenant (DebtToEbitda): actual 2.8 <= threshold 3.5 → compliant
1752        let compliant = DebtCovenant::new(
1753            "COV-001",
1754            CovenantType::DebtToEbitda,
1755            dec!(3.5),
1756            Frequency::Quarterly,
1757            dec!(2.8),
1758            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1759        );
1760        assert!(compliant.is_compliant);
1761        assert_eq!(compliant.headroom, dec!(0.7)); // 3.5 - 2.8
1762
1763        // Maximum covenant breached: actual 4.0 > threshold 3.5
1764        let breached = DebtCovenant::new(
1765            "COV-002",
1766            CovenantType::DebtToEbitda,
1767            dec!(3.5),
1768            Frequency::Quarterly,
1769            dec!(4.0),
1770            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1771        );
1772        assert!(!breached.is_compliant);
1773        assert_eq!(breached.headroom, dec!(-0.5)); // negative = breach
1774
1775        // Minimum covenant (InterestCoverage): actual 4.5 >= threshold 3.0 → compliant
1776        let min_compliant = DebtCovenant::new(
1777            "COV-003",
1778            CovenantType::InterestCoverage,
1779            dec!(3.0),
1780            Frequency::Quarterly,
1781            dec!(4.5),
1782            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1783        );
1784        assert!(min_compliant.is_compliant);
1785        assert_eq!(min_compliant.headroom, dec!(1.5)); // 4.5 - 3.0
1786
1787        // Minimum covenant breached: actual 2.5 < threshold 3.0
1788        let min_breached = DebtCovenant::new(
1789            "COV-004",
1790            CovenantType::InterestCoverage,
1791            dec!(3.0),
1792            Frequency::Quarterly,
1793            dec!(2.5),
1794            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1795        );
1796        assert!(!min_breached.is_compliant);
1797        assert_eq!(min_breached.headroom, dec!(-0.5));
1798
1799        // With waiver
1800        let waived = DebtCovenant::new(
1801            "COV-005",
1802            CovenantType::DebtToEquity,
1803            dec!(2.0),
1804            Frequency::Annual,
1805            dec!(2.5),
1806            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1807        )
1808        .with_waiver(true);
1809        assert!(!waived.is_compliant); // technically breached
1810        assert!(waived.waiver_obtained); // but waiver obtained
1811    }
1812
1813    #[test]
1814    fn test_debt_instrument_amortization() {
1815        let schedule = vec![
1816            AmortizationPayment {
1817                date: NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1818                principal_payment: dec!(250000),
1819                interest_payment: dec!(68750),
1820                balance_after: dec!(4750000),
1821            },
1822            AmortizationPayment {
1823                date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1824                principal_payment: dec!(250000),
1825                interest_payment: dec!(65312.50),
1826                balance_after: dec!(4500000),
1827            },
1828            AmortizationPayment {
1829                date: NaiveDate::from_ymd_opt(2025, 9, 30).unwrap(),
1830                principal_payment: dec!(250000),
1831                interest_payment: dec!(61875),
1832                balance_after: dec!(4250000),
1833            },
1834            AmortizationPayment {
1835                date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1836                principal_payment: dec!(250000),
1837                interest_payment: dec!(58437.50),
1838                balance_after: dec!(4000000),
1839            },
1840        ];
1841
1842        let debt = DebtInstrument::new(
1843            "DEBT-001",
1844            "C001",
1845            DebtType::TermLoan,
1846            "First National Bank",
1847            dec!(5000000),
1848            "USD",
1849            dec!(0.055),
1850            InterestRateType::Fixed,
1851            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1852            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1853        )
1854        .with_amortization_schedule(schedule);
1855
1856        assert_eq!(debt.total_principal_payments(), dec!(1000000));
1857        assert_eq!(debt.total_interest_payments(), dec!(254375));
1858        assert_eq!(debt.amortization_schedule[0].total_payment(), dec!(318750));
1859    }
1860
1861    #[test]
1862    fn test_debt_instrument_revolving_credit() {
1863        let revolver = DebtInstrument::new(
1864            "DEBT-002",
1865            "C001",
1866            DebtType::RevolvingCredit,
1867            "Wells Fargo",
1868            dec!(0),
1869            "USD",
1870            dec!(0.045),
1871            InterestRateType::Variable,
1872            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1873            NaiveDate::from_ymd_opt(2028, 1, 1).unwrap(),
1874        )
1875        .with_drawn_amount(dec!(800000))
1876        .with_facility_limit(dec!(2000000));
1877
1878        assert_eq!(revolver.available_capacity(), dec!(1200000));
1879    }
1880
1881    #[test]
1882    fn test_debt_instrument_all_covenants_compliant() {
1883        let debt = DebtInstrument::new(
1884            "DEBT-003",
1885            "C001",
1886            DebtType::TermLoan,
1887            "Citibank",
1888            dec!(3000000),
1889            "USD",
1890            dec!(0.05),
1891            InterestRateType::Fixed,
1892            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1893            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1894        )
1895        .with_covenant(DebtCovenant::new(
1896            "COV-A",
1897            CovenantType::DebtToEbitda,
1898            dec!(3.5),
1899            Frequency::Quarterly,
1900            dec!(2.5),
1901            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1902        ))
1903        .with_covenant(DebtCovenant::new(
1904            "COV-B",
1905            CovenantType::InterestCoverage,
1906            dec!(3.0),
1907            Frequency::Quarterly,
1908            dec!(5.0),
1909            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1910        ));
1911
1912        assert!(debt.all_covenants_compliant());
1913
1914        // Add a breached covenant with waiver
1915        let debt_waived = debt.with_covenant(
1916            DebtCovenant::new(
1917                "COV-C",
1918                CovenantType::CurrentRatio,
1919                dec!(1.5),
1920                Frequency::Quarterly,
1921                dec!(1.2), // breached
1922                NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1923            )
1924            .with_waiver(true),
1925        );
1926        assert!(debt_waived.all_covenants_compliant()); // waiver counts
1927    }
1928
1929    #[test]
1930    fn test_bank_guarantee_active_check() {
1931        let guarantee = BankGuarantee::new(
1932            "BG-001",
1933            "C001",
1934            GuaranteeType::PerformanceBond,
1935            dec!(500000),
1936            "USD",
1937            "Construction Corp",
1938            "HSBC",
1939            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1940            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1941        )
1942        .with_linked_project("PROJ-001");
1943
1944        // Active within range
1945        assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1946        // Before issue
1947        assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()));
1948        // After expiry
1949        assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1950        // On expiry (inclusive)
1951        assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1952
1953        // Remaining days
1954        assert_eq!(
1955            guarantee.remaining_days(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()),
1956            199
1957        );
1958        assert_eq!(
1959            guarantee.remaining_days(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()),
1960            0 // past expiry
1961        );
1962
1963        // Drawn status
1964        let drawn = BankGuarantee::new(
1965            "BG-002",
1966            "C001",
1967            GuaranteeType::StandbyLc,
1968            dec!(200000),
1969            "EUR",
1970            "Supplier GmbH",
1971            "Deutsche Bank",
1972            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1973            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1974        )
1975        .with_status(GuaranteeStatus::Drawn);
1976        assert!(!drawn.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1977    }
1978
1979    #[test]
1980    fn test_netting_run_savings() {
1981        let positions = vec![
1982            NettingPosition {
1983                entity_id: "C001".to_string(),
1984                gross_receivable: dec!(100000),
1985                gross_payable: dec!(60000),
1986                net_position: dec!(40000),
1987                settlement_direction: PayOrReceive::Receive,
1988            },
1989            NettingPosition {
1990                entity_id: "C002".to_string(),
1991                gross_receivable: dec!(80000),
1992                gross_payable: dec!(90000),
1993                net_position: dec!(-10000),
1994                settlement_direction: PayOrReceive::Pay,
1995            },
1996            NettingPosition {
1997                entity_id: "C003".to_string(),
1998                gross_receivable: dec!(50000),
1999                gross_payable: dec!(80000),
2000                net_position: dec!(-30000),
2001                settlement_direction: PayOrReceive::Pay,
2002            },
2003        ];
2004
2005        let run = NettingRun::new(
2006            "NR-001",
2007            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
2008            NettingCycle::Monthly,
2009            "USD",
2010            positions,
2011        );
2012
2013        assert_eq!(run.gross_receivables, dec!(230000));
2014        assert_eq!(run.gross_payables, dec!(230000));
2015        // net_settlement = sum(|net_position|) / 2 = (40000 + 10000 + 30000) / 2 = 40000
2016        assert_eq!(run.net_settlement, dec!(40000));
2017        // savings = max(230000, 230000) - 40000 = 190000
2018        assert_eq!(run.savings(), dec!(190000));
2019        assert_eq!(run.participating_entities.len(), 3);
2020    }
2021
2022    #[test]
2023    fn test_netting_run_savings_pct() {
2024        let positions = vec![
2025            NettingPosition {
2026                entity_id: "C001".to_string(),
2027                gross_receivable: dec!(100000),
2028                gross_payable: dec!(0),
2029                net_position: dec!(100000),
2030                settlement_direction: PayOrReceive::Receive,
2031            },
2032            NettingPosition {
2033                entity_id: "C002".to_string(),
2034                gross_receivable: dec!(0),
2035                gross_payable: dec!(100000),
2036                net_position: dec!(-100000),
2037                settlement_direction: PayOrReceive::Pay,
2038            },
2039        ];
2040
2041        let run = NettingRun::new(
2042            "NR-002",
2043            NaiveDate::from_ymd_opt(2025, 2, 28).unwrap(),
2044            NettingCycle::Monthly,
2045            "EUR",
2046            positions,
2047        );
2048
2049        // No savings when perfectly bilateral
2050        assert_eq!(run.net_settlement, dec!(100000));
2051        assert_eq!(run.savings(), dec!(0));
2052        assert_eq!(run.savings_pct(), dec!(0));
2053    }
2054
2055    #[test]
2056    fn test_cash_pool_sweep() {
2057        let sweep = CashPoolSweep {
2058            id: "SWP-001".to_string(),
2059            pool_id: "POOL-001".to_string(),
2060            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2061            from_account_id: "BA-001".to_string(),
2062            to_account_id: "BA-HEADER".to_string(),
2063            amount: dec!(50000),
2064            currency: "EUR".to_string(),
2065        };
2066
2067        assert_eq!(sweep.amount, dec!(50000));
2068        assert_eq!(sweep.pool_id, "POOL-001");
2069    }
2070
2071    #[test]
2072    fn test_serde_roundtrip_cash_position() {
2073        let pos = CashPosition::new(
2074            "CP-SERDE",
2075            "C001",
2076            "BA-001",
2077            "USD",
2078            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2079            dec!(10000.50),
2080            dec!(5000.25),
2081            dec!(2000.75),
2082        );
2083
2084        let json = serde_json::to_string_pretty(&pos).unwrap();
2085        let deserialized: CashPosition = serde_json::from_str(&json).unwrap();
2086
2087        assert_eq!(deserialized.opening_balance, pos.opening_balance);
2088        assert_eq!(deserialized.closing_balance, pos.closing_balance);
2089        assert_eq!(deserialized.date, pos.date);
2090    }
2091
2092    #[test]
2093    fn test_serde_roundtrip_hedging_instrument() {
2094        let instr = HedgingInstrument::new(
2095            "HI-SERDE",
2096            HedgeInstrumentType::InterestRateSwap,
2097            dec!(5000000),
2098            "USD",
2099            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2100            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
2101            "JPMorgan",
2102        )
2103        .with_fixed_rate(dec!(0.0425))
2104        .with_floating_index("SOFR")
2105        .with_fair_value(dec!(-35000));
2106
2107        let json = serde_json::to_string_pretty(&instr).unwrap();
2108        let deserialized: HedgingInstrument = serde_json::from_str(&json).unwrap();
2109
2110        assert_eq!(deserialized.fixed_rate, Some(dec!(0.0425)));
2111        assert_eq!(deserialized.floating_index, Some("SOFR".to_string()));
2112        assert_eq!(deserialized.strike_rate, None);
2113        assert_eq!(deserialized.fair_value, dec!(-35000));
2114    }
2115}