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 = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
138        let period_end = if month == 12 {
139            NaiveDate::from_ymd_opt(year + 1, 1, 1)
140                .unwrap()
141                .pred_opt()
142                .unwrap()
143        } else {
144            NaiveDate::from_ymd_opt(year, month + 1, 1)
145                .unwrap()
146                .pred_opt()
147                .unwrap()
148        };
149
150        for currency in &self.config.currencies {
151            if *currency == self.config.base_currency {
152                continue;
153            }
154
155            // Closing rate = last spot rate of the period
156            if let Some(closing) =
157                daily_table.get_spot_rate(currency, &self.config.base_currency, period_end)
158            {
159                rates.push(FxRate::new(
160                    currency,
161                    &self.config.base_currency,
162                    RateType::Closing,
163                    period_end,
164                    closing.rate,
165                    "GENERATED",
166                ));
167            }
168
169            // Average rate = simple average of all spot rates in the period
170            let spot_rates: Vec<&FxRate> = daily_table
171                .get_all_rates(currency, &self.config.base_currency)
172                .into_iter()
173                .filter(|r| {
174                    r.rate_type == RateType::Spot
175                        && r.effective_date >= period_start
176                        && r.effective_date <= period_end
177                })
178                .collect();
179
180            if !spot_rates.is_empty() {
181                let sum: Decimal = spot_rates.iter().map(|r| r.rate).sum();
182                let avg = sum / Decimal::from(spot_rates.len());
183
184                rates.push(FxRate::new(
185                    currency,
186                    &self.config.base_currency,
187                    RateType::Average,
188                    period_end,
189                    avg.round_dp(6),
190                    "GENERATED",
191                ));
192            }
193        }
194
195        rates
196    }
197
198    /// Generates the next rate using Ornstein-Uhlenbeck process.
199    ///
200    /// The O-U process: dX = θ(μ - X)dt + σdW
201    /// Where:
202    /// - θ = mean reversion speed
203    /// - μ = long-term mean
204    /// - σ = volatility
205    /// - dW = Wiener process increment
206    fn generate_next_rate(&mut self, currency: &str, date: NaiveDate) -> FxRate {
207        let current_log = *self.current_log_rates.get(currency).unwrap_or(&0.0);
208
209        // Get base rate for mean reversion target
210        let base_rate: f64 = self
211            .base_rates
212            .get(currency)
213            .map(|d| (*d).try_into().unwrap_or(1.0))
214            .unwrap_or(1.0);
215        let base_log = base_rate.ln();
216
217        // Ornstein-Uhlenbeck step
218        let theta = self.config.mean_reversion_speed;
219        let mu = base_log + self.config.long_term_mean;
220
221        // Check for fat-tail event
222        let volatility = if self.rng.gen::<f64>() < self.config.fat_tail_probability {
223            self.config.daily_volatility * self.config.fat_tail_multiplier
224        } else {
225            self.config.daily_volatility
226        };
227
228        // Generate normal random shock
229        let normal = Normal::new(0.0, 1.0).unwrap();
230        let dw: f64 = normal.sample(&mut self.rng);
231
232        // O-U update: X(t+1) = X(t) + θ(μ - X(t)) + σ * dW
233        let drift = theta * (mu - current_log);
234        let diffusion = volatility * dw;
235        let new_log = current_log + drift + diffusion;
236
237        // Update state
238        self.current_log_rates.insert(currency.to_string(), new_log);
239
240        // Convert log rate back to actual rate
241        let new_rate = new_log.exp();
242        let rate_decimal = Decimal::try_from(new_rate).unwrap_or(dec!(1)).round_dp(6);
243
244        FxRate::new(
245            currency,
246            &self.config.base_currency,
247            RateType::Spot,
248            date,
249            rate_decimal,
250            "O-U PROCESS",
251        )
252    }
253
254    /// Resets the rate service to initial base rates.
255    pub fn reset(&mut self) {
256        self.current_log_rates.clear();
257        for currency in &self.config.currencies {
258            if let Some(rate) = self.base_rates.get(currency) {
259                let rate_f64: f64 = (*rate).try_into().unwrap_or(1.0);
260                self.current_log_rates
261                    .insert(currency.clone(), rate_f64.ln());
262            }
263        }
264    }
265
266    /// Gets the current rate for a currency.
267    pub fn current_rate(&self, currency: &str) -> Option<Decimal> {
268        self.current_log_rates.get(currency).map(|log_rate| {
269            let rate = log_rate.exp();
270            Decimal::try_from(rate).unwrap_or(dec!(1)).round_dp(6)
271        })
272    }
273}
274
275/// Generates a complete set of FX rates for a simulation period.
276pub struct FxRateGenerator {
277    service: FxRateService,
278}
279
280impl FxRateGenerator {
281    /// Creates a new FX rate generator.
282    pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
283        Self {
284            service: FxRateService::new(config, rng),
285        }
286    }
287
288    /// Generates all rates (daily, closing, average) for a date range.
289    pub fn generate_all_rates(
290        &mut self,
291        start_date: NaiveDate,
292        end_date: NaiveDate,
293    ) -> GeneratedFxRates {
294        // Generate daily spot rates
295        let daily_rates = self.service.generate_daily_rates(start_date, end_date);
296
297        // Generate period rates for each month
298        let mut period_rates = Vec::new();
299        let mut current_year = start_date.year();
300        let mut current_month = start_date.month();
301
302        while (current_year < end_date.year())
303            || (current_year == end_date.year() && current_month <= end_date.month())
304        {
305            let rates =
306                self.service
307                    .generate_period_rates(current_year, current_month, &daily_rates);
308            period_rates.extend(rates);
309
310            // Move to next month
311            if current_month == 12 {
312                current_month = 1;
313                current_year += 1;
314            } else {
315                current_month += 1;
316            }
317        }
318
319        GeneratedFxRates {
320            daily_rates,
321            period_rates,
322            start_date,
323            end_date,
324        }
325    }
326
327    /// Gets a reference to the underlying service.
328    pub fn service(&self) -> &FxRateService {
329        &self.service
330    }
331
332    /// Gets a mutable reference to the underlying service.
333    pub fn service_mut(&mut self) -> &mut FxRateService {
334        &mut self.service
335    }
336}
337
338/// Container for all generated FX rates.
339#[derive(Debug, Clone)]
340pub struct GeneratedFxRates {
341    /// Daily spot rates.
342    pub daily_rates: FxRateTable,
343    /// Period closing and average rates.
344    pub period_rates: Vec<FxRate>,
345    /// Start date of generation.
346    pub start_date: NaiveDate,
347    /// End date of generation.
348    pub end_date: NaiveDate,
349}
350
351impl GeneratedFxRates {
352    /// Combines all rates into a single rate table.
353    pub fn combined_rate_table(&self) -> FxRateTable {
354        let mut table = self.daily_rates.clone();
355        for rate in &self.period_rates {
356            table.add_rate(rate.clone());
357        }
358        table
359    }
360
361    /// Gets closing rates for a specific period end date.
362    pub fn closing_rates_for_date(&self, date: NaiveDate) -> Vec<&FxRate> {
363        self.period_rates
364            .iter()
365            .filter(|r| r.rate_type == RateType::Closing && r.effective_date == date)
366            .collect()
367    }
368
369    /// Gets average rates for a specific period end date.
370    pub fn average_rates_for_date(&self, date: NaiveDate) -> Vec<&FxRate> {
371        self.period_rates
372            .iter()
373            .filter(|r| r.rate_type == RateType::Average && r.effective_date == date)
374            .collect()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use rand::SeedableRng;
382
383    #[test]
384    fn test_fx_rate_generation() {
385        let rng = ChaCha8Rng::seed_from_u64(12345);
386        let config = FxRateServiceConfig {
387            currencies: vec!["EUR".to_string(), "GBP".to_string()],
388            ..Default::default()
389        };
390
391        let mut service = FxRateService::new(config, rng);
392
393        let rates = service.generate_daily_rates(
394            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
395            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
396        );
397
398        // Should have rates for each business day
399        assert!(!rates.is_empty());
400    }
401
402    #[test]
403    fn test_rate_mean_reversion() {
404        let rng = ChaCha8Rng::seed_from_u64(12345);
405        let config = FxRateServiceConfig {
406            currencies: vec!["EUR".to_string()],
407            mean_reversion_speed: 0.1, // Strong mean reversion
408            daily_volatility: 0.001,   // Low volatility
409            ..Default::default()
410        };
411
412        let mut service = FxRateService::new(config.clone(), rng);
413
414        // Generate 100 days of rates
415        let rates = service.generate_daily_rates(
416            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
417            NaiveDate::from_ymd_opt(2024, 4, 10).unwrap(),
418        );
419
420        // With strong mean reversion and low volatility, rates should stay near base
421        let base_eur = base_rates_usd().get("EUR").cloned().unwrap_or(dec!(1.10));
422        let all_eur_rates: Vec<Decimal> = rates
423            .get_all_rates("EUR", "USD")
424            .iter()
425            .map(|r| r.rate)
426            .collect();
427
428        assert!(!all_eur_rates.is_empty());
429
430        // Check that rates stay within reasonable bounds of base rate (±10%)
431        for rate in &all_eur_rates {
432            let deviation = (*rate - base_eur).abs() / base_eur;
433            assert!(
434                deviation < dec!(0.15),
435                "Rate {} deviated too much from base {}",
436                rate,
437                base_eur
438            );
439        }
440    }
441
442    #[test]
443    fn test_period_rates() {
444        let rng = ChaCha8Rng::seed_from_u64(12345);
445        let config = FxRateServiceConfig {
446            currencies: vec!["EUR".to_string()],
447            ..Default::default()
448        };
449
450        let mut generator = FxRateGenerator::new(config, rng);
451
452        let generated = generator.generate_all_rates(
453            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
454            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
455        );
456
457        // Should have closing and average rates for each month
458        let jan_closing =
459            generated.closing_rates_for_date(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
460        assert!(!jan_closing.is_empty());
461
462        let jan_average =
463            generated.average_rates_for_date(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
464        assert!(!jan_average.is_empty());
465    }
466}