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 = 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 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 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 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 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 let theta = self.config.mean_reversion_speed;
219 let mu = base_log + self.config.long_term_mean;
220
221 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 let normal = Normal::new(0.0, 1.0).unwrap();
230 let dw: f64 = normal.sample(&mut self.rng);
231
232 let drift = theta * (mu - current_log);
234 let diffusion = volatility * dw;
235 let new_log = current_log + drift + diffusion;
236
237 self.current_log_rates.insert(currency.to_string(), new_log);
239
240 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 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 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
275pub struct FxRateGenerator {
277 service: FxRateService,
278}
279
280impl FxRateGenerator {
281 pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
283 Self {
284 service: FxRateService::new(config, rng),
285 }
286 }
287
288 pub fn generate_all_rates(
290 &mut self,
291 start_date: NaiveDate,
292 end_date: NaiveDate,
293 ) -> GeneratedFxRates {
294 let daily_rates = self.service.generate_daily_rates(start_date, end_date);
296
297 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 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 pub fn service(&self) -> &FxRateService {
329 &self.service
330 }
331
332 pub fn service_mut(&mut self) -> &mut FxRateService {
334 &mut self.service
335 }
336}
337
338#[derive(Debug, Clone)]
340pub struct GeneratedFxRates {
341 pub daily_rates: FxRateTable,
343 pub period_rates: Vec<FxRate>,
345 pub start_date: NaiveDate,
347 pub end_date: NaiveDate,
349}
350
351impl GeneratedFxRates {
352 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 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 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 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, daily_volatility: 0.001, ..Default::default()
410 };
411
412 let mut service = FxRateService::new(config.clone(), rng);
413
414 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 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 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 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}