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 = "rust_decimal::serde::str")]
272    pub opening_balance: Decimal,
273    /// Total inflows during the day
274    #[serde(with = "rust_decimal::serde::str")]
275    pub inflows: Decimal,
276    /// Total outflows during the day
277    #[serde(with = "rust_decimal::serde::str")]
278    pub outflows: Decimal,
279    /// Balance at end of day (opening + inflows - outflows)
280    #[serde(with = "rust_decimal::serde::str")]
281    pub closing_balance: Decimal,
282    /// Available balance (after holds, pending transactions)
283    #[serde(with = "rust_decimal::serde::str")]
284    pub available_balance: Decimal,
285    /// Value-date balance (settlement-adjusted)
286    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
348    pub amount: Decimal,
349    /// Probability of occurrence (0.0 to 1.0)
350    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
375    pub net_position: Decimal,
376    /// Confidence level for the forecast (0.0 to 1.0)
377    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str_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 = "rust_decimal::serde::str_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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
694    pub principal_payment: Decimal,
695    /// Interest portion of the payment
696    #[serde(with = "rust_decimal::serde::str")]
697    pub interest_payment: Decimal,
698    /// Outstanding balance after this payment
699    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
862    pub principal: Decimal,
863    /// Denomination currency
864    pub currency: String,
865    /// Interest rate (annual, as decimal fraction)
866    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
880    pub drawn_amount: Decimal,
881    /// Committed facility limit (for revolving facilities)
882    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
1072    pub gross_receivable: Decimal,
1073    /// Gross amount payable to other entities
1074    #[serde(with = "rust_decimal::serde::str")]
1075    pub gross_payable: Decimal,
1076    /// Net position (receivable - payable)
1077    #[serde(with = "rust_decimal::serde::str")]
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 = "rust_decimal::serde::str")]
1096    pub gross_receivables: Decimal,
1097    /// Total gross payables across all entities
1098    #[serde(with = "rust_decimal::serde::str")]
1099    pub gross_payables: Decimal,
1100    /// Net settlement amount (sum of absolute net positions / 2)
1101    #[serde(with = "rust_decimal::serde::str")]
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)]
1556#[allow(clippy::unwrap_used)]
1557mod tests {
1558    use super::*;
1559    use rust_decimal_macros::dec;
1560
1561    #[test]
1562    fn test_cash_position_closing_balance() {
1563        let pos = CashPosition::new(
1564            "CP-001",
1565            "C001",
1566            "BA-001",
1567            "USD",
1568            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1569            dec!(10000),
1570            dec!(5000),
1571            dec!(2000),
1572        );
1573        // closing = 10000 + 5000 - 2000 = 13000
1574        assert_eq!(pos.closing_balance, dec!(13000));
1575        assert_eq!(pos.computed_closing_balance(), dec!(13000));
1576        assert_eq!(pos.available_balance, dec!(13000)); // defaults to closing
1577    }
1578
1579    #[test]
1580    fn test_cash_position_with_overrides() {
1581        let pos = CashPosition::new(
1582            "CP-002",
1583            "C001",
1584            "BA-001",
1585            "USD",
1586            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
1587            dec!(10000),
1588            dec!(5000),
1589            dec!(2000),
1590        )
1591        .with_available_balance(dec!(12000))
1592        .with_value_date_balance(dec!(12500));
1593
1594        assert_eq!(pos.closing_balance, dec!(13000));
1595        assert_eq!(pos.available_balance, dec!(12000));
1596        assert_eq!(pos.value_date_balance, dec!(12500));
1597    }
1598
1599    #[test]
1600    fn test_cash_forecast_net_position() {
1601        let items = vec![
1602            CashForecastItem {
1603                id: "CFI-001".to_string(),
1604                date: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
1605                category: TreasuryCashFlowCategory::ArCollection,
1606                amount: dec!(50000),
1607                probability: dec!(0.90),
1608                source_document_type: Some("SalesOrder".to_string()),
1609                source_document_id: Some("SO-001".to_string()),
1610            },
1611            CashForecastItem {
1612                id: "CFI-002".to_string(),
1613                date: NaiveDate::from_ymd_opt(2025, 2, 5).unwrap(),
1614                category: TreasuryCashFlowCategory::ApPayment,
1615                amount: dec!(-30000),
1616                probability: dec!(1.00),
1617                source_document_type: Some("PurchaseOrder".to_string()),
1618                source_document_id: Some("PO-001".to_string()),
1619            },
1620            CashForecastItem {
1621                id: "CFI-003".to_string(),
1622                date: NaiveDate::from_ymd_opt(2025, 2, 15).unwrap(),
1623                category: TreasuryCashFlowCategory::TaxPayment,
1624                amount: dec!(-10000),
1625                probability: dec!(1.00),
1626                source_document_type: None,
1627                source_document_id: None,
1628            },
1629        ];
1630        let forecast = CashForecast::new(
1631            "CF-001",
1632            "C001",
1633            "USD",
1634            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
1635            30,
1636            items,
1637            dec!(0.90),
1638        );
1639
1640        // net = (50000 * 0.90) + (-30000 * 1.00) + (-10000 * 1.00)
1641        //     = 45000 - 30000 - 10000 = 5000
1642        assert_eq!(forecast.net_position, dec!(5000));
1643        assert_eq!(forecast.computed_net_position(), dec!(5000));
1644        assert_eq!(forecast.items.len(), 3);
1645    }
1646
1647    #[test]
1648    fn test_cash_pool_total_accounts() {
1649        let pool = CashPool::new(
1650            "POOL-001",
1651            "EUR Cash Pool",
1652            PoolType::ZeroBalancing,
1653            "BA-HEADER",
1654            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1655        )
1656        .with_participant("BA-001")
1657        .with_participant("BA-002")
1658        .with_participant("BA-003")
1659        .with_interest_rate_benefit(dec!(0.0025));
1660
1661        assert_eq!(pool.total_accounts(), 4); // header + 3 participants
1662        assert_eq!(pool.interest_rate_benefit, dec!(0.0025));
1663        assert_eq!(pool.pool_type, PoolType::ZeroBalancing);
1664    }
1665
1666    #[test]
1667    fn test_hedging_instrument_lifecycle() {
1668        let instr = HedgingInstrument::new(
1669            "HI-001",
1670            HedgeInstrumentType::FxForward,
1671            dec!(1000000),
1672            "EUR",
1673            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1674            NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1675            "Deutsche Bank",
1676        )
1677        .with_currency_pair("EUR/USD")
1678        .with_fixed_rate(dec!(1.0850))
1679        .with_fair_value(dec!(15000));
1680
1681        assert!(instr.is_active());
1682        assert_eq!(
1683            instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
1684            107 // 2025-03-15 to 2025-06-30
1685        );
1686        assert_eq!(instr.currency_pair, Some("EUR/USD".to_string()));
1687        assert_eq!(instr.fixed_rate, Some(dec!(1.0850)));
1688
1689        // Terminate
1690        let terminated = instr.with_status(InstrumentStatus::Terminated);
1691        assert!(!terminated.is_active());
1692    }
1693
1694    #[test]
1695    fn test_hedging_instrument_remaining_tenor_past_maturity() {
1696        let instr = HedgingInstrument::new(
1697            "HI-002",
1698            HedgeInstrumentType::InterestRateSwap,
1699            dec!(5000000),
1700            "USD",
1701            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1702            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1703            "JPMorgan",
1704        );
1705
1706        // Past maturity → 0 days
1707        assert_eq!(
1708            instr.remaining_tenor_days(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap()),
1709            0
1710        );
1711    }
1712
1713    #[test]
1714    fn test_hedge_relationship_effectiveness() {
1715        // Effective: ratio = 0.95 (within 80-125%)
1716        let effective = HedgeRelationship::new(
1717            "HR-001",
1718            HedgedItemType::ForecastedTransaction,
1719            "Forecasted EUR revenue Q2 2025",
1720            "HI-001",
1721            HedgeType::CashFlowHedge,
1722            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1723            EffectivenessMethod::Regression,
1724            dec!(0.95),
1725        );
1726        assert!(effective.is_effective);
1727        assert!(HedgeRelationship::check_effectiveness(dec!(0.80))); // boundary
1728        assert!(HedgeRelationship::check_effectiveness(dec!(1.25))); // boundary
1729
1730        // Ineffective: ratio = 0.75 (below 80%)
1731        let ineffective = HedgeRelationship::new(
1732            "HR-002",
1733            HedgedItemType::FirmCommitment,
1734            "Committed USD purchase",
1735            "HI-002",
1736            HedgeType::FairValueHedge,
1737            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1738            EffectivenessMethod::DollarOffset,
1739            dec!(0.75),
1740        )
1741        .with_ineffectiveness_amount(dec!(25000));
1742        assert!(!ineffective.is_effective);
1743        assert_eq!(ineffective.ineffectiveness_amount, dec!(25000));
1744
1745        // Boundaries
1746        assert!(!HedgeRelationship::check_effectiveness(dec!(0.79)));
1747        assert!(!HedgeRelationship::check_effectiveness(dec!(1.26)));
1748    }
1749
1750    #[test]
1751    fn test_debt_covenant_compliance() {
1752        // Maximum covenant (DebtToEbitda): actual 2.8 <= threshold 3.5 → compliant
1753        let compliant = DebtCovenant::new(
1754            "COV-001",
1755            CovenantType::DebtToEbitda,
1756            dec!(3.5),
1757            Frequency::Quarterly,
1758            dec!(2.8),
1759            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1760        );
1761        assert!(compliant.is_compliant);
1762        assert_eq!(compliant.headroom, dec!(0.7)); // 3.5 - 2.8
1763
1764        // Maximum covenant breached: actual 4.0 > threshold 3.5
1765        let breached = DebtCovenant::new(
1766            "COV-002",
1767            CovenantType::DebtToEbitda,
1768            dec!(3.5),
1769            Frequency::Quarterly,
1770            dec!(4.0),
1771            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1772        );
1773        assert!(!breached.is_compliant);
1774        assert_eq!(breached.headroom, dec!(-0.5)); // negative = breach
1775
1776        // Minimum covenant (InterestCoverage): actual 4.5 >= threshold 3.0 → compliant
1777        let min_compliant = DebtCovenant::new(
1778            "COV-003",
1779            CovenantType::InterestCoverage,
1780            dec!(3.0),
1781            Frequency::Quarterly,
1782            dec!(4.5),
1783            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1784        );
1785        assert!(min_compliant.is_compliant);
1786        assert_eq!(min_compliant.headroom, dec!(1.5)); // 4.5 - 3.0
1787
1788        // Minimum covenant breached: actual 2.5 < threshold 3.0
1789        let min_breached = DebtCovenant::new(
1790            "COV-004",
1791            CovenantType::InterestCoverage,
1792            dec!(3.0),
1793            Frequency::Quarterly,
1794            dec!(2.5),
1795            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1796        );
1797        assert!(!min_breached.is_compliant);
1798        assert_eq!(min_breached.headroom, dec!(-0.5));
1799
1800        // With waiver
1801        let waived = DebtCovenant::new(
1802            "COV-005",
1803            CovenantType::DebtToEquity,
1804            dec!(2.0),
1805            Frequency::Annual,
1806            dec!(2.5),
1807            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1808        )
1809        .with_waiver(true);
1810        assert!(!waived.is_compliant); // technically breached
1811        assert!(waived.waiver_obtained); // but waiver obtained
1812    }
1813
1814    #[test]
1815    fn test_debt_instrument_amortization() {
1816        let schedule = vec![
1817            AmortizationPayment {
1818                date: NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1819                principal_payment: dec!(250000),
1820                interest_payment: dec!(68750),
1821                balance_after: dec!(4750000),
1822            },
1823            AmortizationPayment {
1824                date: NaiveDate::from_ymd_opt(2025, 6, 30).unwrap(),
1825                principal_payment: dec!(250000),
1826                interest_payment: dec!(65312.50),
1827                balance_after: dec!(4500000),
1828            },
1829            AmortizationPayment {
1830                date: NaiveDate::from_ymd_opt(2025, 9, 30).unwrap(),
1831                principal_payment: dec!(250000),
1832                interest_payment: dec!(61875),
1833                balance_after: dec!(4250000),
1834            },
1835            AmortizationPayment {
1836                date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1837                principal_payment: dec!(250000),
1838                interest_payment: dec!(58437.50),
1839                balance_after: dec!(4000000),
1840            },
1841        ];
1842
1843        let debt = DebtInstrument::new(
1844            "DEBT-001",
1845            "C001",
1846            DebtType::TermLoan,
1847            "First National Bank",
1848            dec!(5000000),
1849            "USD",
1850            dec!(0.055),
1851            InterestRateType::Fixed,
1852            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1853            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1854        )
1855        .with_amortization_schedule(schedule);
1856
1857        assert_eq!(debt.total_principal_payments(), dec!(1000000));
1858        assert_eq!(debt.total_interest_payments(), dec!(254375));
1859        assert_eq!(debt.amortization_schedule[0].total_payment(), dec!(318750));
1860    }
1861
1862    #[test]
1863    fn test_debt_instrument_revolving_credit() {
1864        let revolver = DebtInstrument::new(
1865            "DEBT-002",
1866            "C001",
1867            DebtType::RevolvingCredit,
1868            "Wells Fargo",
1869            dec!(0),
1870            "USD",
1871            dec!(0.045),
1872            InterestRateType::Variable,
1873            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1874            NaiveDate::from_ymd_opt(2028, 1, 1).unwrap(),
1875        )
1876        .with_drawn_amount(dec!(800000))
1877        .with_facility_limit(dec!(2000000));
1878
1879        assert_eq!(revolver.available_capacity(), dec!(1200000));
1880    }
1881
1882    #[test]
1883    fn test_debt_instrument_all_covenants_compliant() {
1884        let debt = DebtInstrument::new(
1885            "DEBT-003",
1886            "C001",
1887            DebtType::TermLoan,
1888            "Citibank",
1889            dec!(3000000),
1890            "USD",
1891            dec!(0.05),
1892            InterestRateType::Fixed,
1893            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1894            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
1895        )
1896        .with_covenant(DebtCovenant::new(
1897            "COV-A",
1898            CovenantType::DebtToEbitda,
1899            dec!(3.5),
1900            Frequency::Quarterly,
1901            dec!(2.5),
1902            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1903        ))
1904        .with_covenant(DebtCovenant::new(
1905            "COV-B",
1906            CovenantType::InterestCoverage,
1907            dec!(3.0),
1908            Frequency::Quarterly,
1909            dec!(5.0),
1910            NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1911        ));
1912
1913        assert!(debt.all_covenants_compliant());
1914
1915        // Add a breached covenant with waiver
1916        let debt_waived = debt.with_covenant(
1917            DebtCovenant::new(
1918                "COV-C",
1919                CovenantType::CurrentRatio,
1920                dec!(1.5),
1921                Frequency::Quarterly,
1922                dec!(1.2), // breached
1923                NaiveDate::from_ymd_opt(2025, 3, 31).unwrap(),
1924            )
1925            .with_waiver(true),
1926        );
1927        assert!(debt_waived.all_covenants_compliant()); // waiver counts
1928    }
1929
1930    #[test]
1931    fn test_bank_guarantee_active_check() {
1932        let guarantee = BankGuarantee::new(
1933            "BG-001",
1934            "C001",
1935            GuaranteeType::PerformanceBond,
1936            dec!(500000),
1937            "USD",
1938            "Construction Corp",
1939            "HSBC",
1940            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1941            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1942        )
1943        .with_linked_project("PROJ-001");
1944
1945        // Active within range
1946        assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1947        // Before issue
1948        assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()));
1949        // After expiry
1950        assert!(!guarantee.is_active_on(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1951        // On expiry (inclusive)
1952        assert!(guarantee.is_active_on(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1953
1954        // Remaining days
1955        assert_eq!(
1956            guarantee.remaining_days(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()),
1957            199
1958        );
1959        assert_eq!(
1960            guarantee.remaining_days(NaiveDate::from_ymd_opt(2026, 6, 1).unwrap()),
1961            0 // past expiry
1962        );
1963
1964        // Drawn status
1965        let drawn = BankGuarantee::new(
1966            "BG-002",
1967            "C001",
1968            GuaranteeType::StandbyLc,
1969            dec!(200000),
1970            "EUR",
1971            "Supplier GmbH",
1972            "Deutsche Bank",
1973            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
1974            NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
1975        )
1976        .with_status(GuaranteeStatus::Drawn);
1977        assert!(!drawn.is_active_on(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()));
1978    }
1979
1980    #[test]
1981    fn test_netting_run_savings() {
1982        let positions = vec![
1983            NettingPosition {
1984                entity_id: "C001".to_string(),
1985                gross_receivable: dec!(100000),
1986                gross_payable: dec!(60000),
1987                net_position: dec!(40000),
1988                settlement_direction: PayOrReceive::Receive,
1989            },
1990            NettingPosition {
1991                entity_id: "C002".to_string(),
1992                gross_receivable: dec!(80000),
1993                gross_payable: dec!(90000),
1994                net_position: dec!(-10000),
1995                settlement_direction: PayOrReceive::Pay,
1996            },
1997            NettingPosition {
1998                entity_id: "C003".to_string(),
1999                gross_receivable: dec!(50000),
2000                gross_payable: dec!(80000),
2001                net_position: dec!(-30000),
2002                settlement_direction: PayOrReceive::Pay,
2003            },
2004        ];
2005
2006        let run = NettingRun::new(
2007            "NR-001",
2008            NaiveDate::from_ymd_opt(2025, 1, 31).unwrap(),
2009            NettingCycle::Monthly,
2010            "USD",
2011            positions,
2012        );
2013
2014        assert_eq!(run.gross_receivables, dec!(230000));
2015        assert_eq!(run.gross_payables, dec!(230000));
2016        // net_settlement = sum(|net_position|) / 2 = (40000 + 10000 + 30000) / 2 = 40000
2017        assert_eq!(run.net_settlement, dec!(40000));
2018        // savings = max(230000, 230000) - 40000 = 190000
2019        assert_eq!(run.savings(), dec!(190000));
2020        assert_eq!(run.participating_entities.len(), 3);
2021    }
2022
2023    #[test]
2024    fn test_netting_run_savings_pct() {
2025        let positions = vec![
2026            NettingPosition {
2027                entity_id: "C001".to_string(),
2028                gross_receivable: dec!(100000),
2029                gross_payable: dec!(0),
2030                net_position: dec!(100000),
2031                settlement_direction: PayOrReceive::Receive,
2032            },
2033            NettingPosition {
2034                entity_id: "C002".to_string(),
2035                gross_receivable: dec!(0),
2036                gross_payable: dec!(100000),
2037                net_position: dec!(-100000),
2038                settlement_direction: PayOrReceive::Pay,
2039            },
2040        ];
2041
2042        let run = NettingRun::new(
2043            "NR-002",
2044            NaiveDate::from_ymd_opt(2025, 2, 28).unwrap(),
2045            NettingCycle::Monthly,
2046            "EUR",
2047            positions,
2048        );
2049
2050        // No savings when perfectly bilateral
2051        assert_eq!(run.net_settlement, dec!(100000));
2052        assert_eq!(run.savings(), dec!(0));
2053        assert_eq!(run.savings_pct(), dec!(0));
2054    }
2055
2056    #[test]
2057    fn test_cash_pool_sweep() {
2058        let sweep = CashPoolSweep {
2059            id: "SWP-001".to_string(),
2060            pool_id: "POOL-001".to_string(),
2061            date: NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2062            from_account_id: "BA-001".to_string(),
2063            to_account_id: "BA-HEADER".to_string(),
2064            amount: dec!(50000),
2065            currency: "EUR".to_string(),
2066        };
2067
2068        assert_eq!(sweep.amount, dec!(50000));
2069        assert_eq!(sweep.pool_id, "POOL-001");
2070    }
2071
2072    #[test]
2073    fn test_serde_roundtrip_cash_position() {
2074        let pos = CashPosition::new(
2075            "CP-SERDE",
2076            "C001",
2077            "BA-001",
2078            "USD",
2079            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
2080            dec!(10000.50),
2081            dec!(5000.25),
2082            dec!(2000.75),
2083        );
2084
2085        let json = serde_json::to_string_pretty(&pos).unwrap();
2086        let deserialized: CashPosition = serde_json::from_str(&json).unwrap();
2087
2088        assert_eq!(deserialized.opening_balance, pos.opening_balance);
2089        assert_eq!(deserialized.closing_balance, pos.closing_balance);
2090        assert_eq!(deserialized.date, pos.date);
2091    }
2092
2093    #[test]
2094    fn test_serde_roundtrip_hedging_instrument() {
2095        let instr = HedgingInstrument::new(
2096            "HI-SERDE",
2097            HedgeInstrumentType::InterestRateSwap,
2098            dec!(5000000),
2099            "USD",
2100            NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
2101            NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(),
2102            "JPMorgan",
2103        )
2104        .with_fixed_rate(dec!(0.0425))
2105        .with_floating_index("SOFR")
2106        .with_fair_value(dec!(-35000));
2107
2108        let json = serde_json::to_string_pretty(&instr).unwrap();
2109        let deserialized: HedgingInstrument = serde_json::from_str(&json).unwrap();
2110
2111        assert_eq!(deserialized.fixed_rate, Some(dec!(0.0425)));
2112        assert_eq!(deserialized.floating_index, Some("SOFR".to_string()));
2113        assert_eq!(deserialized.strike_rate, None);
2114        assert_eq!(deserialized.fair_value, dec!(-35000));
2115    }
2116}