Skip to main content

datasynth_core/models/
fx.rs

1//! Foreign exchange (FX) models.
2//!
3//! This module provides comprehensive FX rate management including:
4//! - Exchange rate types (spot, closing, average, budget)
5//! - Currency pair definitions
6//! - Rate tables with temporal validity
7//! - Currency translation methods for consolidation
8
9use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12use std::collections::HashMap;
13
14/// Currency pair representing source and target currencies.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct CurrencyPair {
17    /// Source (from) currency code (ISO 4217).
18    pub from_currency: String,
19    /// Target (to) currency code (ISO 4217).
20    pub to_currency: String,
21}
22
23impl CurrencyPair {
24    /// Creates a new currency pair.
25    #[allow(clippy::too_many_arguments)]
26    pub fn new(from: &str, to: &str) -> Self {
27        Self {
28            from_currency: from.to_uppercase(),
29            to_currency: to.to_uppercase(),
30        }
31    }
32
33    /// Returns the inverse currency pair.
34    pub fn inverse(&self) -> Self {
35        Self {
36            from_currency: self.to_currency.clone(),
37            to_currency: self.from_currency.clone(),
38        }
39    }
40
41    /// Returns the pair as a string (e.g., "EUR/USD").
42    pub fn as_string(&self) -> String {
43        format!("{}/{}", self.from_currency, self.to_currency)
44    }
45
46    /// Returns true if this is a same-currency pair.
47    pub fn is_same_currency(&self) -> bool {
48        self.from_currency == self.to_currency
49    }
50}
51
52impl std::fmt::Display for CurrencyPair {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}/{}", self.from_currency, self.to_currency)
55    }
56}
57
58/// Type of exchange rate.
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
60pub enum RateType {
61    /// Spot rate - rate at transaction date.
62    Spot,
63    /// Closing rate - rate at period end (for balance sheet translation).
64    Closing,
65    /// Average rate - period average rate (for P&L translation).
66    Average,
67    /// Budget rate - rate used for budgeting/planning.
68    Budget,
69    /// Historical rate - rate at original transaction date (for equity items).
70    Historical,
71    /// Negotiated rate - contractually agreed rate.
72    Negotiated,
73    /// Custom rate type.
74    Custom(String),
75}
76
77impl std::fmt::Display for RateType {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            RateType::Spot => write!(f, "SPOT"),
81            RateType::Closing => write!(f, "CLOSING"),
82            RateType::Average => write!(f, "AVERAGE"),
83            RateType::Budget => write!(f, "BUDGET"),
84            RateType::Historical => write!(f, "HISTORICAL"),
85            RateType::Negotiated => write!(f, "NEGOTIATED"),
86            RateType::Custom(s) => write!(f, "{}", s),
87        }
88    }
89}
90
91/// An exchange rate for a currency pair on a specific date.
92#[derive(Debug, Clone)]
93pub struct FxRate {
94    /// Currency pair.
95    pub pair: CurrencyPair,
96    /// Rate type.
97    pub rate_type: RateType,
98    /// Effective date.
99    pub effective_date: NaiveDate,
100    /// Exchange rate (units of to_currency per 1 unit of from_currency).
101    pub rate: Decimal,
102    /// Inverse rate (for convenience).
103    pub inverse_rate: Decimal,
104    /// Rate source (e.g., "ECB", "FED", "INTERNAL").
105    pub source: String,
106    /// Validity end date (if rate has limited validity).
107    pub valid_until: Option<NaiveDate>,
108}
109
110impl FxRate {
111    /// Creates a new FX rate.
112    pub fn new(
113        from_currency: &str,
114        to_currency: &str,
115        rate_type: RateType,
116        effective_date: NaiveDate,
117        rate: Decimal,
118        source: &str,
119    ) -> Self {
120        let inverse = if rate > Decimal::ZERO {
121            (Decimal::ONE / rate).round_dp(6)
122        } else {
123            Decimal::ZERO
124        };
125
126        Self {
127            pair: CurrencyPair::new(from_currency, to_currency),
128            rate_type,
129            effective_date,
130            rate,
131            inverse_rate: inverse,
132            source: source.to_string(),
133            valid_until: None,
134        }
135    }
136
137    /// Creates a rate with validity period.
138    pub fn with_validity(mut self, valid_until: NaiveDate) -> Self {
139        self.valid_until = Some(valid_until);
140        self
141    }
142
143    /// Converts an amount from source to target currency.
144    pub fn convert(&self, amount: Decimal) -> Decimal {
145        (amount * self.rate).round_dp(2)
146    }
147
148    /// Converts an amount from target to source currency (inverse).
149    pub fn convert_inverse(&self, amount: Decimal) -> Decimal {
150        (amount * self.inverse_rate).round_dp(2)
151    }
152
153    /// Returns true if the rate is valid on the given date.
154    pub fn is_valid_on(&self, date: NaiveDate) -> bool {
155        if date < self.effective_date {
156            return false;
157        }
158        if let Some(valid_until) = self.valid_until {
159            date <= valid_until
160        } else {
161            true
162        }
163    }
164}
165
166/// Collection of FX rates with lookup functionality.
167#[derive(Debug, Clone, Default)]
168pub struct FxRateTable {
169    /// Rates indexed by (currency_pair, rate_type, date).
170    rates: HashMap<(String, String), Vec<FxRate>>,
171    /// Base currency for the rate table.
172    pub base_currency: String,
173}
174
175impl FxRateTable {
176    /// Creates a new FX rate table with the specified base currency.
177    pub fn new(base_currency: &str) -> Self {
178        Self {
179            rates: HashMap::new(),
180            base_currency: base_currency.to_uppercase(),
181        }
182    }
183
184    /// Adds a rate to the table.
185    pub fn add_rate(&mut self, rate: FxRate) {
186        let key = (
187            format!("{}_{}", rate.pair.from_currency, rate.pair.to_currency),
188            rate.rate_type.to_string(),
189        );
190        self.rates.entry(key).or_default().push(rate);
191    }
192
193    /// Gets a rate for the currency pair, type, and date.
194    pub fn get_rate(
195        &self,
196        from_currency: &str,
197        to_currency: &str,
198        rate_type: &RateType,
199        date: NaiveDate,
200    ) -> Option<&FxRate> {
201        // Same currency - no rate needed
202        if from_currency.to_uppercase() == to_currency.to_uppercase() {
203            return None;
204        }
205
206        let key = (
207            format!(
208                "{}_{}",
209                from_currency.to_uppercase(),
210                to_currency.to_uppercase()
211            ),
212            rate_type.to_string(),
213        );
214
215        self.rates.get(&key).and_then(|rates| {
216            rates
217                .iter()
218                .filter(|r| r.is_valid_on(date))
219                .max_by_key(|r| r.effective_date)
220        })
221    }
222
223    /// Gets the closing rate for a currency pair on a date.
224    pub fn get_closing_rate(
225        &self,
226        from_currency: &str,
227        to_currency: &str,
228        date: NaiveDate,
229    ) -> Option<&FxRate> {
230        self.get_rate(from_currency, to_currency, &RateType::Closing, date)
231    }
232
233    /// Gets the average rate for a currency pair on a date.
234    pub fn get_average_rate(
235        &self,
236        from_currency: &str,
237        to_currency: &str,
238        date: NaiveDate,
239    ) -> Option<&FxRate> {
240        self.get_rate(from_currency, to_currency, &RateType::Average, date)
241    }
242
243    /// Gets the spot rate for a currency pair on a date.
244    pub fn get_spot_rate(
245        &self,
246        from_currency: &str,
247        to_currency: &str,
248        date: NaiveDate,
249    ) -> Option<&FxRate> {
250        self.get_rate(from_currency, to_currency, &RateType::Spot, date)
251    }
252
253    /// Converts an amount using the appropriate rate.
254    pub fn convert(
255        &self,
256        amount: Decimal,
257        from_currency: &str,
258        to_currency: &str,
259        rate_type: &RateType,
260        date: NaiveDate,
261    ) -> Option<Decimal> {
262        if from_currency.to_uppercase() == to_currency.to_uppercase() {
263            return Some(amount);
264        }
265
266        // Try direct rate
267        if let Some(rate) = self.get_rate(from_currency, to_currency, rate_type, date) {
268            return Some(rate.convert(amount));
269        }
270
271        // Try inverse rate
272        if let Some(rate) = self.get_rate(to_currency, from_currency, rate_type, date) {
273            return Some(rate.convert_inverse(amount));
274        }
275
276        // Try triangulation through base currency
277        if from_currency.to_uppercase() != self.base_currency
278            && to_currency.to_uppercase() != self.base_currency
279        {
280            let to_base = self.get_rate(from_currency, &self.base_currency, rate_type, date);
281            let from_base = self.get_rate(&self.base_currency, to_currency, rate_type, date);
282
283            if let (Some(r1), Some(r2)) = (to_base, from_base) {
284                let base_amount = r1.convert(amount);
285                return Some(r2.convert(base_amount));
286            }
287        }
288
289        None
290    }
291
292    /// Returns all rates for a currency pair.
293    pub fn get_all_rates(&self, from_currency: &str, to_currency: &str) -> Vec<&FxRate> {
294        self.rates
295            .iter()
296            .filter(|((pair, _), _)| {
297                *pair
298                    == format!(
299                        "{}_{}",
300                        from_currency.to_uppercase(),
301                        to_currency.to_uppercase()
302                    )
303            })
304            .flat_map(|(_, rates)| rates.iter())
305            .collect()
306    }
307
308    /// Returns the number of rates in the table.
309    pub fn len(&self) -> usize {
310        self.rates.values().map(|v| v.len()).sum()
311    }
312
313    /// Returns true if the table is empty.
314    pub fn is_empty(&self) -> bool {
315        self.rates.is_empty()
316    }
317}
318
319/// Currency translation method for financial statement consolidation.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum TranslationMethod {
322    /// Current rate method - all items at closing rate (except equity at historical).
323    CurrentRate,
324    /// Temporal method - monetary items at closing, non-monetary at historical.
325    Temporal,
326    /// Monetary/Non-monetary method.
327    MonetaryNonMonetary,
328}
329
330/// Account classification for currency translation.
331#[derive(Debug, Clone, PartialEq, Eq)]
332pub enum TranslationAccountType {
333    /// Asset - balance sheet debit.
334    Asset,
335    /// Liability - balance sheet credit.
336    Liability,
337    /// Equity - owner's equity.
338    Equity,
339    /// Revenue - income statement credit.
340    Revenue,
341    /// Expense - income statement debit.
342    Expense,
343    /// Retained Earnings - special equity treatment.
344    RetainedEarnings,
345    /// Common Stock - historical rate.
346    CommonStock,
347    /// APIC - historical rate.
348    AdditionalPaidInCapital,
349}
350
351/// Result of translating an amount.
352#[derive(Debug, Clone)]
353pub struct TranslatedAmount {
354    /// Original amount in local currency.
355    pub local_amount: Decimal,
356    /// Local currency code.
357    pub local_currency: String,
358    /// Translated amount in group currency.
359    pub group_amount: Decimal,
360    /// Group currency code.
361    pub group_currency: String,
362    /// Rate used for translation.
363    pub rate_used: Decimal,
364    /// Rate type used.
365    pub rate_type: RateType,
366    /// Translation date.
367    pub translation_date: NaiveDate,
368}
369
370/// Currency Translation Adjustment (CTA) entry.
371#[derive(Debug, Clone)]
372pub struct CTAEntry {
373    /// Entry ID.
374    pub entry_id: String,
375    /// Company code (subsidiary).
376    pub company_code: String,
377    /// Local currency.
378    pub local_currency: String,
379    /// Group currency.
380    pub group_currency: String,
381    /// Fiscal year.
382    pub fiscal_year: i32,
383    /// Fiscal period.
384    pub fiscal_period: u8,
385    /// Period end date.
386    pub period_end_date: NaiveDate,
387    /// CTA amount (positive = gain, negative = loss).
388    pub cta_amount: Decimal,
389    /// Opening rate used.
390    pub opening_rate: Decimal,
391    /// Closing rate used.
392    pub closing_rate: Decimal,
393    /// Average rate used.
394    pub average_rate: Decimal,
395    /// Net assets at opening (local currency).
396    pub opening_net_assets_local: Decimal,
397    /// Net assets at closing (local currency).
398    pub closing_net_assets_local: Decimal,
399    /// Net income for period (local currency).
400    pub net_income_local: Decimal,
401    /// Breakdown by component.
402    pub components: Vec<CTAComponent>,
403}
404
405/// Component of CTA calculation.
406#[derive(Debug, Clone)]
407pub struct CTAComponent {
408    /// Component description.
409    pub description: String,
410    /// Local currency amount.
411    pub local_amount: Decimal,
412    /// Rate applied.
413    pub rate: Decimal,
414    /// Group currency amount.
415    pub group_amount: Decimal,
416}
417
418impl CTAEntry {
419    /// Creates a new CTA entry.
420    pub fn new(
421        entry_id: String,
422        company_code: String,
423        local_currency: String,
424        group_currency: String,
425        fiscal_year: i32,
426        fiscal_period: u8,
427        period_end_date: NaiveDate,
428    ) -> Self {
429        Self {
430            entry_id,
431            company_code,
432            local_currency,
433            group_currency,
434            fiscal_year,
435            fiscal_period,
436            period_end_date,
437            cta_amount: Decimal::ZERO,
438            opening_rate: Decimal::ONE,
439            closing_rate: Decimal::ONE,
440            average_rate: Decimal::ONE,
441            opening_net_assets_local: Decimal::ZERO,
442            closing_net_assets_local: Decimal::ZERO,
443            net_income_local: Decimal::ZERO,
444            components: Vec::new(),
445        }
446    }
447
448    /// Calculates CTA using the current rate method.
449    ///
450    /// CTA = Net Assets(closing) × Closing Rate
451    ///     - Net Assets(opening) × Opening Rate
452    ///     - Net Income × Average Rate
453    pub fn calculate_current_rate_method(&mut self) {
454        let closing_translated = self.closing_net_assets_local * self.closing_rate;
455        let opening_translated = self.opening_net_assets_local * self.opening_rate;
456        let income_translated = self.net_income_local * self.average_rate;
457
458        self.cta_amount = closing_translated - opening_translated - income_translated;
459
460        self.components = vec![
461            CTAComponent {
462                description: "Closing net assets at closing rate".to_string(),
463                local_amount: self.closing_net_assets_local,
464                rate: self.closing_rate,
465                group_amount: closing_translated,
466            },
467            CTAComponent {
468                description: "Opening net assets at opening rate".to_string(),
469                local_amount: self.opening_net_assets_local,
470                rate: self.opening_rate,
471                group_amount: opening_translated,
472            },
473            CTAComponent {
474                description: "Net income at average rate".to_string(),
475                local_amount: self.net_income_local,
476                rate: self.average_rate,
477                group_amount: income_translated,
478            },
479        ];
480    }
481}
482
483/// Realized FX gain/loss from settling a transaction in foreign currency.
484#[derive(Debug, Clone)]
485pub struct RealizedFxGainLoss {
486    /// Document reference.
487    pub document_number: String,
488    /// Company code.
489    pub company_code: String,
490    /// Transaction date (original).
491    pub transaction_date: NaiveDate,
492    /// Settlement date.
493    pub settlement_date: NaiveDate,
494    /// Transaction currency.
495    pub transaction_currency: String,
496    /// Local currency.
497    pub local_currency: String,
498    /// Original amount (transaction currency).
499    pub original_amount: Decimal,
500    /// Original local amount (at transaction date rate).
501    pub original_local_amount: Decimal,
502    /// Settlement local amount (at settlement date rate).
503    pub settlement_local_amount: Decimal,
504    /// Realized gain/loss (positive = gain).
505    pub gain_loss: Decimal,
506    /// Transaction date rate.
507    pub transaction_rate: Decimal,
508    /// Settlement date rate.
509    pub settlement_rate: Decimal,
510}
511
512impl RealizedFxGainLoss {
513    /// Creates a new realized FX gain/loss entry.
514    #[allow(clippy::too_many_arguments)]
515    pub fn new(
516        document_number: String,
517        company_code: String,
518        transaction_date: NaiveDate,
519        settlement_date: NaiveDate,
520        transaction_currency: String,
521        local_currency: String,
522        original_amount: Decimal,
523        transaction_rate: Decimal,
524        settlement_rate: Decimal,
525    ) -> Self {
526        let original_local = (original_amount * transaction_rate).round_dp(2);
527        let settlement_local = (original_amount * settlement_rate).round_dp(2);
528        let gain_loss = settlement_local - original_local;
529
530        Self {
531            document_number,
532            company_code,
533            transaction_date,
534            settlement_date,
535            transaction_currency,
536            local_currency,
537            original_amount,
538            original_local_amount: original_local,
539            settlement_local_amount: settlement_local,
540            gain_loss,
541            transaction_rate,
542            settlement_rate,
543        }
544    }
545
546    /// Returns true if this is a gain.
547    pub fn is_gain(&self) -> bool {
548        self.gain_loss > Decimal::ZERO
549    }
550}
551
552/// Unrealized FX gain/loss from revaluing open items.
553#[derive(Debug, Clone)]
554pub struct UnrealizedFxGainLoss {
555    /// Revaluation run ID.
556    pub revaluation_id: String,
557    /// Company code.
558    pub company_code: String,
559    /// Revaluation date.
560    pub revaluation_date: NaiveDate,
561    /// Account code.
562    pub account_code: String,
563    /// Document reference.
564    pub document_number: String,
565    /// Transaction currency.
566    pub transaction_currency: String,
567    /// Local currency.
568    pub local_currency: String,
569    /// Open amount (transaction currency).
570    pub open_amount: Decimal,
571    /// Book value (local currency, at original rate).
572    pub book_value_local: Decimal,
573    /// Revalued amount (local currency, at revaluation rate).
574    pub revalued_local: Decimal,
575    /// Unrealized gain/loss.
576    pub gain_loss: Decimal,
577    /// Original rate.
578    pub original_rate: Decimal,
579    /// Revaluation rate.
580    pub revaluation_rate: Decimal,
581}
582
583/// Common currency codes.
584pub mod currencies {
585    pub const USD: &str = "USD";
586    pub const EUR: &str = "EUR";
587    pub const GBP: &str = "GBP";
588    pub const JPY: &str = "JPY";
589    pub const CHF: &str = "CHF";
590    pub const CAD: &str = "CAD";
591    pub const AUD: &str = "AUD";
592    pub const CNY: &str = "CNY";
593    pub const INR: &str = "INR";
594    pub const BRL: &str = "BRL";
595    pub const MXN: &str = "MXN";
596    pub const KRW: &str = "KRW";
597    pub const SGD: &str = "SGD";
598    pub const HKD: &str = "HKD";
599    pub const SEK: &str = "SEK";
600    pub const NOK: &str = "NOK";
601    pub const DKK: &str = "DKK";
602    pub const PLN: &str = "PLN";
603    pub const ZAR: &str = "ZAR";
604    pub const THB: &str = "THB";
605}
606
607/// Base rates against USD for common currencies (approximate).
608pub fn base_rates_usd() -> HashMap<String, Decimal> {
609    let mut rates = HashMap::new();
610    rates.insert("EUR".to_string(), dec!(1.10)); // EUR/USD
611    rates.insert("GBP".to_string(), dec!(1.27)); // GBP/USD
612    rates.insert("JPY".to_string(), dec!(0.0067)); // JPY/USD (1/150)
613    rates.insert("CHF".to_string(), dec!(1.13)); // CHF/USD
614    rates.insert("CAD".to_string(), dec!(0.74)); // CAD/USD
615    rates.insert("AUD".to_string(), dec!(0.65)); // AUD/USD
616    rates.insert("CNY".to_string(), dec!(0.14)); // CNY/USD
617    rates.insert("INR".to_string(), dec!(0.012)); // INR/USD
618    rates.insert("BRL".to_string(), dec!(0.20)); // BRL/USD
619    rates.insert("MXN".to_string(), dec!(0.058)); // MXN/USD
620    rates.insert("KRW".to_string(), dec!(0.00075)); // KRW/USD
621    rates.insert("SGD".to_string(), dec!(0.75)); // SGD/USD
622    rates.insert("HKD".to_string(), dec!(0.128)); // HKD/USD
623    rates.insert("SEK".to_string(), dec!(0.095)); // SEK/USD
624    rates.insert("NOK".to_string(), dec!(0.093)); // NOK/USD
625    rates.insert("DKK".to_string(), dec!(0.147)); // DKK/USD
626    rates.insert("PLN".to_string(), dec!(0.25)); // PLN/USD
627    rates.insert("ZAR".to_string(), dec!(0.053)); // ZAR/USD
628    rates.insert("THB".to_string(), dec!(0.028)); // THB/USD
629    rates
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn test_currency_pair() {
638        let pair = CurrencyPair::new("EUR", "USD");
639        assert_eq!(pair.from_currency, "EUR");
640        assert_eq!(pair.to_currency, "USD");
641        assert_eq!(pair.as_string(), "EUR/USD");
642
643        let inverse = pair.inverse();
644        assert_eq!(inverse.from_currency, "USD");
645        assert_eq!(inverse.to_currency, "EUR");
646    }
647
648    #[test]
649    fn test_fx_rate_conversion() {
650        let rate = FxRate::new(
651            "EUR",
652            "USD",
653            RateType::Spot,
654            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
655            dec!(1.10),
656            "ECB",
657        );
658
659        let converted = rate.convert(dec!(100));
660        assert_eq!(converted, dec!(110.00));
661
662        let inverse = rate.convert_inverse(dec!(110));
663        assert_eq!(inverse, dec!(100.00));
664    }
665
666    #[test]
667    fn test_fx_rate_table() {
668        let mut table = FxRateTable::new("USD");
669
670        table.add_rate(FxRate::new(
671            "EUR",
672            "USD",
673            RateType::Spot,
674            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
675            dec!(1.10),
676            "ECB",
677        ));
678
679        let converted = table.convert(
680            dec!(100),
681            "EUR",
682            "USD",
683            &RateType::Spot,
684            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
685        );
686
687        assert_eq!(converted, Some(dec!(110.00)));
688    }
689
690    #[test]
691    fn test_cta_calculation() {
692        let mut cta = CTAEntry::new(
693            "CTA-001".to_string(),
694            "1200".to_string(),
695            "EUR".to_string(),
696            "USD".to_string(),
697            2024,
698            12,
699            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
700        );
701
702        cta.opening_net_assets_local = dec!(1000000);
703        cta.closing_net_assets_local = dec!(1100000);
704        cta.net_income_local = dec!(100000);
705        cta.opening_rate = dec!(1.08);
706        cta.closing_rate = dec!(1.12);
707        cta.average_rate = dec!(1.10);
708
709        cta.calculate_current_rate_method();
710
711        // Closing: 1,100,000 × 1.12 = 1,232,000
712        // Opening: 1,000,000 × 1.08 = 1,080,000
713        // Income:    100,000 × 1.10 =   110,000
714        // CTA: 1,232,000 - 1,080,000 - 110,000 = 42,000
715        assert_eq!(cta.cta_amount, dec!(42000));
716    }
717}