1use 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#[derive(Debug, Clone)]
21pub struct FxRateServiceConfig {
22 pub base_currency: String,
24 pub mean_reversion_speed: f64,
26 pub long_term_mean: f64,
28 pub daily_volatility: f64,
30 pub fat_tail_probability: f64,
32 pub fat_tail_multiplier: f64,
34 pub currencies: Vec<String>,
36 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, 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
63pub struct FxRateService {
65 config: FxRateServiceConfig,
66 rng: ChaCha8Rng,
67 current_log_rates: HashMap<String, f64>,
69 base_rates: HashMap<String, Decimal>,
71}
72
73impl FxRateService {
74 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 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 pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
97 Self::new(config, seeded_rng(seed, 0))
98 }
99
100 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 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 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(¤cy, 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 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 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 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 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 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 let theta = self.config.mean_reversion_speed;
233 let mu = base_log + self.config.long_term_mean;
234
235 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 let normal = Normal::new(0.0, 1.0).expect("valid standard normal parameters");
244 let dw: f64 = normal.sample(&mut self.rng);
245
246 let drift = theta * (mu - current_log);
248 let diffusion = volatility * dw;
249 let new_log = current_log + drift + diffusion;
250
251 self.current_log_rates.insert(currency.to_string(), new_log);
253
254 let new_rate = new_log.exp();
256 let rate_decimal = Decimal::try_from(new_rate).unwrap_or(dec!(1)).round_dp(6);
257
258 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 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 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
297pub struct FxRateGenerator {
299 service: FxRateService,
300}
301
302impl FxRateGenerator {
303 pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
305 Self {
306 service: FxRateService::new(config, rng),
307 }
308 }
309
310 pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
312 Self::new(config, seeded_rng(seed, 0))
313 }
314
315 pub fn generate_all_rates(
317 &mut self,
318 start_date: NaiveDate,
319 end_date: NaiveDate,
320 ) -> GeneratedFxRates {
321 let daily_rates = self.service.generate_daily_rates(start_date, end_date);
323
324 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 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 pub fn service(&self) -> &FxRateService {
356 &self.service
357 }
358
359 pub fn service_mut(&mut self) -> &mut FxRateService {
361 &mut self.service
362 }
363}
364
365#[derive(Debug, Clone)]
367pub struct GeneratedFxRates {
368 pub daily_rates: FxRateTable,
370 pub period_rates: Vec<FxRate>,
372 pub start_date: NaiveDate,
374 pub end_date: NaiveDate,
376}
377
378impl GeneratedFxRates {
379 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 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 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 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, daily_volatility: 0.001, ..Default::default()
438 };
439
440 let mut service = FxRateService::new(config.clone(), rng);
441
442 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 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 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 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}