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;
15
16use datasynth_core::models::{base_rates_usd, FxRate, FxRateTable, RateType};
17
18#[derive(Debug, Clone)]
20pub struct FxRateServiceConfig {
21 pub base_currency: String,
23 pub mean_reversion_speed: f64,
25 pub long_term_mean: f64,
27 pub daily_volatility: f64,
29 pub fat_tail_probability: f64,
31 pub fat_tail_multiplier: f64,
33 pub currencies: Vec<String>,
35 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, 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
62pub struct FxRateService {
64 config: FxRateServiceConfig,
65 rng: ChaCha8Rng,
66 current_log_rates: HashMap<String, f64>,
68 base_rates: HashMap<String, Decimal>,
70}
71
72impl FxRateService {
73 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 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 pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
96 Self::new(config, seeded_rng(seed, 0))
97 }
98
99 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 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 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(¤cy, 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 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 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 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 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 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 let theta = self.config.mean_reversion_speed;
226 let mu = base_log + self.config.long_term_mean;
227
228 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 let normal = Normal::new(0.0, 1.0).expect("valid standard normal parameters");
237 let dw: f64 = normal.sample(&mut self.rng);
238
239 let drift = theta * (mu - current_log);
241 let diffusion = volatility * dw;
242 let new_log = current_log + drift + diffusion;
243
244 self.current_log_rates.insert(currency.to_string(), new_log);
246
247 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 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 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
282pub struct FxRateGenerator {
284 service: FxRateService,
285}
286
287impl FxRateGenerator {
288 pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
290 Self {
291 service: FxRateService::new(config, rng),
292 }
293 }
294
295 pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
297 Self::new(config, seeded_rng(seed, 0))
298 }
299
300 pub fn generate_all_rates(
302 &mut self,
303 start_date: NaiveDate,
304 end_date: NaiveDate,
305 ) -> GeneratedFxRates {
306 let daily_rates = self.service.generate_daily_rates(start_date, end_date);
308
309 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 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 pub fn service(&self) -> &FxRateService {
341 &self.service
342 }
343
344 pub fn service_mut(&mut self) -> &mut FxRateService {
346 &mut self.service
347 }
348}
349
350#[derive(Debug, Clone)]
352pub struct GeneratedFxRates {
353 pub daily_rates: FxRateTable,
355 pub period_rates: Vec<FxRate>,
357 pub start_date: NaiveDate,
359 pub end_date: NaiveDate,
361}
362
363impl GeneratedFxRates {
364 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 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 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 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, daily_volatility: 0.001, ..Default::default()
423 };
424
425 let mut service = FxRateService::new(config.clone(), rng);
426
427 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 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 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 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}