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