1use 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#[derive(Debug, Clone)]
19pub struct FxRateServiceConfig {
20 pub base_currency: String,
22 pub mean_reversion_speed: f64,
24 pub long_term_mean: f64,
26 pub daily_volatility: f64,
28 pub fat_tail_probability: f64,
30 pub fat_tail_multiplier: f64,
32 pub currencies: Vec<String>,
34 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, 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
61pub struct FxRateService {
63 config: FxRateServiceConfig,
64 rng: ChaCha8Rng,
65 current_log_rates: HashMap<String, f64>,
67 base_rates: HashMap<String, Decimal>,
69}
70
71impl FxRateService {
72 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 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 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 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 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(¤cy, 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 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 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 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 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 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 let theta = self.config.mean_reversion_speed;
220 let mu = base_log + self.config.long_term_mean;
221
222 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 let normal = Normal::new(0.0, 1.0).expect("valid standard normal parameters");
231 let dw: f64 = normal.sample(&mut self.rng);
232
233 let drift = theta * (mu - current_log);
235 let diffusion = volatility * dw;
236 let new_log = current_log + drift + diffusion;
237
238 self.current_log_rates.insert(currency.to_string(), new_log);
240
241 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 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 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
276pub struct FxRateGenerator {
278 service: FxRateService,
279}
280
281impl FxRateGenerator {
282 pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
284 Self {
285 service: FxRateService::new(config, rng),
286 }
287 }
288
289 pub fn generate_all_rates(
291 &mut self,
292 start_date: NaiveDate,
293 end_date: NaiveDate,
294 ) -> GeneratedFxRates {
295 let daily_rates = self.service.generate_daily_rates(start_date, end_date);
297
298 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 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 pub fn service(&self) -> &FxRateService {
330 &self.service
331 }
332
333 pub fn service_mut(&mut self) -> &mut FxRateService {
335 &mut self.service
336 }
337}
338
339#[derive(Debug, Clone)]
341pub struct GeneratedFxRates {
342 pub daily_rates: FxRateTable,
344 pub period_rates: Vec<FxRate>,
346 pub start_date: NaiveDate,
348 pub end_date: NaiveDate,
350}
351
352impl GeneratedFxRates {
353 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 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 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 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, daily_volatility: 0.001, ..Default::default()
412 };
413
414 let mut service = FxRateService::new(config.clone(), rng);
415
416 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 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 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 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}