Skip to main content

datasynth_generators/fx/
fx_rate_service.rs

1//! FX rate generation service using Ornstein-Uhlenbeck process.
2//!
3//! Generates realistic exchange rates with mean-reversion and
4//! occasional fat-tail moves.
5
6use chrono::{Datelike, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
9use rand_chacha::ChaCha8Rng;
10use rand_distr::{Distribution, Normal};
11use rust_decimal::prelude::ToPrimitive;
12use rust_decimal::Decimal;
13use rust_decimal_macros::dec;
14use std::collections::HashMap;
15
16use datasynth_core::models::{base_rates_usd, FxRate, FxRateTable, RateType};
17
18/// Configuration for FX rate generation.
19#[derive(Debug, Clone)]
20pub struct FxRateServiceConfig {
21    /// Base currency for all rates.
22    pub base_currency: String,
23    /// Mean reversion speed (theta) - higher = faster reversion.
24    pub mean_reversion_speed: f64,
25    /// Long-term mean level (typically 0 for log returns).
26    pub long_term_mean: f64,
27    /// Daily volatility (sigma) - typical FX volatility is 0.5-1% daily.
28    pub daily_volatility: f64,
29    /// Probability of fat-tail event (2x volatility).
30    pub fat_tail_probability: f64,
31    /// Fat-tail multiplier.
32    pub fat_tail_multiplier: f64,
33    /// Currencies to generate rates for.
34    pub currencies: Vec<String>,
35    /// Whether to generate weekend rates (or skip weekends).
36    pub include_weekends: bool,
37}
38
39impl Default for FxRateServiceConfig {
40    fn default() -> Self {
41        Self {
42            base_currency: "USD".to_string(),
43            mean_reversion_speed: 0.05,
44            long_term_mean: 0.0,
45            daily_volatility: 0.006, // ~0.6% daily volatility
46            fat_tail_probability: 0.05,
47            fat_tail_multiplier: 2.5,
48            currencies: vec![
49                "EUR".to_string(),
50                "GBP".to_string(),
51                "JPY".to_string(),
52                "CHF".to_string(),
53                "CAD".to_string(),
54                "AUD".to_string(),
55                "CNY".to_string(),
56            ],
57            include_weekends: false,
58        }
59    }
60}
61
62/// Service for generating FX rates.
63pub struct FxRateService {
64    config: FxRateServiceConfig,
65    rng: ChaCha8Rng,
66    /// Current rates for each currency (log of rate for O-U process).
67    current_log_rates: HashMap<String, f64>,
68    /// Base rates (initial starting points).
69    base_rates: HashMap<String, Decimal>,
70}
71
72impl FxRateService {
73    /// Creates a new FX rate service.
74    pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
75        let base_rates = base_rates_usd();
76        let mut current_log_rates = HashMap::new();
77
78        // Initialize log rates from base rates
79        for currency in &config.currencies {
80            if let Some(rate) = base_rates.get(currency) {
81                let rate_f64: f64 = rate.to_f64().unwrap_or(1.0);
82                current_log_rates.insert(currency.clone(), rate_f64.ln());
83            }
84        }
85
86        Self {
87            config,
88            rng,
89            current_log_rates,
90            base_rates,
91        }
92    }
93
94    /// Creates a new FX rate service from a seed, constructing the RNG internally.
95    pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
96        Self::new(config, seeded_rng(seed, 0))
97    }
98
99    /// Generates daily FX rates for a date range.
100    pub fn generate_daily_rates(
101        &mut self,
102        start_date: NaiveDate,
103        end_date: NaiveDate,
104    ) -> FxRateTable {
105        let mut table = FxRateTable::new(&self.config.base_currency);
106        let mut current_date = start_date;
107
108        while current_date <= end_date {
109            // Skip weekends if configured
110            if !self.config.include_weekends {
111                let weekday = current_date.weekday();
112                if weekday == chrono::Weekday::Sat || weekday == chrono::Weekday::Sun {
113                    current_date = current_date.succ_opt().unwrap_or(current_date);
114                    continue;
115                }
116            }
117
118            // Generate rates for each currency
119            for currency in self.config.currencies.clone() {
120                if currency == self.config.base_currency {
121                    continue;
122                }
123
124                let rate = self.generate_next_rate(&currency, current_date);
125                table.add_rate(rate);
126            }
127
128            current_date = current_date.succ_opt().unwrap_or(current_date);
129        }
130
131        table
132    }
133
134    /// Generates period rates (closing and average) for a fiscal period.
135    pub fn generate_period_rates(
136        &mut self,
137        year: i32,
138        month: u32,
139        daily_table: &FxRateTable,
140    ) -> Vec<FxRate> {
141        let mut rates = Vec::new();
142
143        let period_start =
144            NaiveDate::from_ymd_opt(year, month, 1).expect("valid year/month for period start");
145        let period_end = if month == 12 {
146            NaiveDate::from_ymd_opt(year + 1, 1, 1)
147                .expect("valid next year start")
148                .pred_opt()
149                .expect("valid predecessor date")
150        } else {
151            NaiveDate::from_ymd_opt(year, month + 1, 1)
152                .expect("valid next month start")
153                .pred_opt()
154                .expect("valid predecessor date")
155        };
156
157        for currency in &self.config.currencies {
158            if *currency == self.config.base_currency {
159                continue;
160            }
161
162            // Closing rate = last spot rate of the period
163            if let Some(closing) =
164                daily_table.get_spot_rate(currency, &self.config.base_currency, period_end)
165            {
166                rates.push(FxRate::new(
167                    currency,
168                    &self.config.base_currency,
169                    RateType::Closing,
170                    period_end,
171                    closing.rate,
172                    "GENERATED",
173                ));
174            }
175
176            // Average rate = simple average of all spot rates in the period
177            let spot_rates: Vec<&FxRate> = daily_table
178                .get_all_rates(currency, &self.config.base_currency)
179                .into_iter()
180                .filter(|r| {
181                    r.rate_type == RateType::Spot
182                        && r.effective_date >= period_start
183                        && r.effective_date <= period_end
184                })
185                .collect();
186
187            if !spot_rates.is_empty() {
188                let sum: Decimal = spot_rates.iter().map(|r| r.rate).sum();
189                let avg = sum / Decimal::from(spot_rates.len());
190
191                rates.push(FxRate::new(
192                    currency,
193                    &self.config.base_currency,
194                    RateType::Average,
195                    period_end,
196                    avg.round_dp(6),
197                    "GENERATED",
198                ));
199            }
200        }
201
202        rates
203    }
204
205    /// Generates the next rate using Ornstein-Uhlenbeck process.
206    ///
207    /// The O-U process: dX = θ(μ - X)dt + σdW
208    /// Where:
209    /// - θ = mean reversion speed
210    /// - μ = long-term mean
211    /// - σ = volatility
212    /// - dW = Wiener process increment
213    fn generate_next_rate(&mut self, currency: &str, date: NaiveDate) -> FxRate {
214        let current_log = *self.current_log_rates.get(currency).unwrap_or(&0.0);
215
216        // Get base rate for mean reversion target
217        let base_rate: f64 = self
218            .base_rates
219            .get(currency)
220            .map(|d| (*d).try_into().unwrap_or(1.0))
221            .unwrap_or(1.0);
222        let base_log = base_rate.ln();
223
224        // Ornstein-Uhlenbeck step
225        let theta = self.config.mean_reversion_speed;
226        let mu = base_log + self.config.long_term_mean;
227
228        // Check for fat-tail event
229        let volatility = if self.rng.random::<f64>() < self.config.fat_tail_probability {
230            self.config.daily_volatility * self.config.fat_tail_multiplier
231        } else {
232            self.config.daily_volatility
233        };
234
235        // Generate normal random shock
236        let normal = Normal::new(0.0, 1.0).expect("valid standard normal parameters");
237        let dw: f64 = normal.sample(&mut self.rng);
238
239        // O-U update: X(t+1) = X(t) + θ(μ - X(t)) + σ * dW
240        let drift = theta * (mu - current_log);
241        let diffusion = volatility * dw;
242        let new_log = current_log + drift + diffusion;
243
244        // Update state
245        self.current_log_rates.insert(currency.to_string(), new_log);
246
247        // Convert log rate back to actual rate
248        let new_rate = new_log.exp();
249        let rate_decimal = Decimal::try_from(new_rate).unwrap_or(dec!(1)).round_dp(6);
250
251        FxRate::new(
252            currency,
253            &self.config.base_currency,
254            RateType::Spot,
255            date,
256            rate_decimal,
257            "O-U PROCESS",
258        )
259    }
260
261    /// Resets the rate service to initial base rates.
262    pub fn reset(&mut self) {
263        self.current_log_rates.clear();
264        for currency in &self.config.currencies {
265            if let Some(rate) = self.base_rates.get(currency) {
266                let rate_f64: f64 = (*rate).try_into().unwrap_or(1.0);
267                self.current_log_rates
268                    .insert(currency.clone(), rate_f64.ln());
269            }
270        }
271    }
272
273    /// Gets the current rate for a currency.
274    pub fn current_rate(&self, currency: &str) -> Option<Decimal> {
275        self.current_log_rates.get(currency).map(|log_rate| {
276            let rate = log_rate.exp();
277            Decimal::try_from(rate).unwrap_or(dec!(1)).round_dp(6)
278        })
279    }
280}
281
282/// Generates a complete set of FX rates for a simulation period.
283pub struct FxRateGenerator {
284    service: FxRateService,
285}
286
287impl FxRateGenerator {
288    /// Creates a new FX rate generator.
289    pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
290        Self {
291            service: FxRateService::new(config, rng),
292        }
293    }
294
295    /// Creates a new FX rate generator from a seed, constructing the RNG internally.
296    pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
297        Self::new(config, seeded_rng(seed, 0))
298    }
299
300    /// Generates all rates (daily, closing, average) for a date range.
301    pub fn generate_all_rates(
302        &mut self,
303        start_date: NaiveDate,
304        end_date: NaiveDate,
305    ) -> GeneratedFxRates {
306        // Generate daily spot rates
307        let daily_rates = self.service.generate_daily_rates(start_date, end_date);
308
309        // Generate period rates for each month
310        let mut period_rates = Vec::new();
311        let mut current_year = start_date.year();
312        let mut current_month = start_date.month();
313
314        while (current_year < end_date.year())
315            || (current_year == end_date.year() && current_month <= end_date.month())
316        {
317            let rates =
318                self.service
319                    .generate_period_rates(current_year, current_month, &daily_rates);
320            period_rates.extend(rates);
321
322            // Move to next month
323            if current_month == 12 {
324                current_month = 1;
325                current_year += 1;
326            } else {
327                current_month += 1;
328            }
329        }
330
331        GeneratedFxRates {
332            daily_rates,
333            period_rates,
334            start_date,
335            end_date,
336        }
337    }
338
339    /// Gets a reference to the underlying service.
340    pub fn service(&self) -> &FxRateService {
341        &self.service
342    }
343
344    /// Gets a mutable reference to the underlying service.
345    pub fn service_mut(&mut self) -> &mut FxRateService {
346        &mut self.service
347    }
348}
349
350/// Container for all generated FX rates.
351#[derive(Debug, Clone)]
352pub struct GeneratedFxRates {
353    /// Daily spot rates.
354    pub daily_rates: FxRateTable,
355    /// Period closing and average rates.
356    pub period_rates: Vec<FxRate>,
357    /// Start date of generation.
358    pub start_date: NaiveDate,
359    /// End date of generation.
360    pub end_date: NaiveDate,
361}
362
363impl GeneratedFxRates {
364    /// Combines all rates into a single rate table.
365    pub fn combined_rate_table(&self) -> FxRateTable {
366        let mut table = self.daily_rates.clone();
367        for rate in &self.period_rates {
368            table.add_rate(rate.clone());
369        }
370        table
371    }
372
373    /// Gets closing rates for a specific period end date.
374    pub fn closing_rates_for_date(&self, date: NaiveDate) -> Vec<&FxRate> {
375        self.period_rates
376            .iter()
377            .filter(|r| r.rate_type == RateType::Closing && r.effective_date == date)
378            .collect()
379    }
380
381    /// Gets average rates for a specific period end date.
382    pub fn average_rates_for_date(&self, date: NaiveDate) -> Vec<&FxRate> {
383        self.period_rates
384            .iter()
385            .filter(|r| r.rate_type == RateType::Average && r.effective_date == date)
386            .collect()
387    }
388}
389
390#[cfg(test)]
391#[allow(clippy::unwrap_used)]
392mod tests {
393    use super::*;
394    use rand::SeedableRng;
395
396    #[test]
397    fn test_fx_rate_generation() {
398        let rng = ChaCha8Rng::seed_from_u64(12345);
399        let config = FxRateServiceConfig {
400            currencies: vec!["EUR".to_string(), "GBP".to_string()],
401            ..Default::default()
402        };
403
404        let mut service = FxRateService::new(config, rng);
405
406        let rates = service.generate_daily_rates(
407            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
408            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
409        );
410
411        // Should have rates for each business day
412        assert!(!rates.is_empty());
413    }
414
415    #[test]
416    fn test_rate_mean_reversion() {
417        let rng = ChaCha8Rng::seed_from_u64(12345);
418        let config = FxRateServiceConfig {
419            currencies: vec!["EUR".to_string()],
420            mean_reversion_speed: 0.1, // Strong mean reversion
421            daily_volatility: 0.001,   // Low volatility
422            ..Default::default()
423        };
424
425        let mut service = FxRateService::new(config.clone(), rng);
426
427        // Generate 100 days of rates
428        let rates = service.generate_daily_rates(
429            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
430            NaiveDate::from_ymd_opt(2024, 4, 10).unwrap(),
431        );
432
433        // With strong mean reversion and low volatility, rates should stay near base
434        let base_eur = base_rates_usd().get("EUR").cloned().unwrap_or(dec!(1.10));
435        let all_eur_rates: Vec<Decimal> = rates
436            .get_all_rates("EUR", "USD")
437            .iter()
438            .map(|r| r.rate)
439            .collect();
440
441        assert!(!all_eur_rates.is_empty());
442
443        // Check that rates stay within reasonable bounds of base rate (±10%)
444        for rate in &all_eur_rates {
445            let deviation = (*rate - base_eur).abs() / base_eur;
446            assert!(
447                deviation < dec!(0.15),
448                "Rate {} deviated too much from base {}",
449                rate,
450                base_eur
451            );
452        }
453    }
454
455    #[test]
456    fn test_period_rates() {
457        let rng = ChaCha8Rng::seed_from_u64(12345);
458        let config = FxRateServiceConfig {
459            currencies: vec!["EUR".to_string()],
460            ..Default::default()
461        };
462
463        let mut generator = FxRateGenerator::new(config, rng);
464
465        let generated = generator.generate_all_rates(
466            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
467            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
468        );
469
470        // Should have closing and average rates for each month
471        let jan_closing =
472            generated.closing_rates_for_date(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
473        assert!(!jan_closing.is_empty());
474
475        let jan_average =
476            generated.average_rates_for_date(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
477        assert!(!jan_average.is_empty());
478    }
479}