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