use chrono::{Datelike, NaiveDate};
use datasynth_core::utils::seeded_rng;
use rand::RngExt;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::collections::HashMap;
use tracing::{debug, info};
use datasynth_core::models::{base_rates_usd, FxRate, FxRateTable, RateType};
#[derive(Debug, Clone)]
pub struct FxRateServiceConfig {
pub base_currency: String,
pub mean_reversion_speed: f64,
pub long_term_mean: f64,
pub daily_volatility: f64,
pub fat_tail_probability: f64,
pub fat_tail_multiplier: f64,
pub currencies: Vec<String>,
pub include_weekends: bool,
}
impl Default for FxRateServiceConfig {
fn default() -> Self {
Self {
base_currency: "USD".to_string(),
mean_reversion_speed: 0.05,
long_term_mean: 0.0,
daily_volatility: 0.006, fat_tail_probability: 0.05,
fat_tail_multiplier: 2.5,
currencies: vec![
"EUR".to_string(),
"GBP".to_string(),
"JPY".to_string(),
"CHF".to_string(),
"CAD".to_string(),
"AUD".to_string(),
"CNY".to_string(),
],
include_weekends: false,
}
}
}
pub struct FxRateService {
config: FxRateServiceConfig,
rng: ChaCha8Rng,
current_log_rates: HashMap<String, f64>,
base_rates: HashMap<String, Decimal>,
}
impl FxRateService {
pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
let base_rates = base_rates_usd();
let mut current_log_rates = HashMap::new();
for currency in &config.currencies {
if let Some(rate) = base_rates.get(currency) {
let rate_f64: f64 = rate.to_f64().unwrap_or(1.0);
current_log_rates.insert(currency.clone(), rate_f64.ln());
}
}
Self {
config,
rng,
current_log_rates,
base_rates,
}
}
pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
Self::new(config, seeded_rng(seed, 0))
}
pub fn generate_daily_rates(
&mut self,
start_date: NaiveDate,
end_date: NaiveDate,
) -> FxRateTable {
info!(
"Generating FX rates for {} currencies ({} to {})",
self.config.currencies.len(),
start_date,
end_date
);
let mut table = FxRateTable::new(&self.config.base_currency);
let mut current_date = start_date;
while current_date <= end_date {
if !self.config.include_weekends {
let weekday = current_date.weekday();
if weekday == chrono::Weekday::Sat || weekday == chrono::Weekday::Sun {
current_date = current_date.succ_opt().unwrap_or(current_date);
continue;
}
}
for currency in self.config.currencies.clone() {
if currency == self.config.base_currency {
continue;
}
let rate = self.generate_next_rate(¤cy, current_date);
table.add_rate(rate);
}
current_date = current_date.succ_opt().unwrap_or(current_date);
}
table
}
pub fn generate_period_rates(
&mut self,
year: i32,
month: u32,
daily_table: &FxRateTable,
) -> Vec<FxRate> {
let mut rates = Vec::new();
let period_start =
NaiveDate::from_ymd_opt(year, month, 1).expect("valid year/month for period start");
let period_end = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
.expect("valid next year start")
.pred_opt()
.expect("valid predecessor date")
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1)
.expect("valid next month start")
.pred_opt()
.expect("valid predecessor date")
};
for currency in &self.config.currencies {
if *currency == self.config.base_currency {
continue;
}
if let Some(closing) =
daily_table.get_spot_rate(currency, &self.config.base_currency, period_end)
{
rates.push(FxRate::new(
currency,
&self.config.base_currency,
RateType::Closing,
period_end,
closing.rate,
"GENERATED",
));
}
let spot_rates: Vec<&FxRate> = daily_table
.get_all_rates(currency, &self.config.base_currency)
.into_iter()
.filter(|r| {
r.rate_type == RateType::Spot
&& r.effective_date >= period_start
&& r.effective_date <= period_end
})
.collect();
if !spot_rates.is_empty() {
let sum: Decimal = spot_rates.iter().map(|r| r.rate).sum();
let avg = sum / Decimal::from(spot_rates.len());
rates.push(FxRate::new(
currency,
&self.config.base_currency,
RateType::Average,
period_end,
avg.round_dp(6),
"GENERATED",
));
}
}
rates
}
fn generate_next_rate(&mut self, currency: &str, date: NaiveDate) -> FxRate {
let current_log = *self.current_log_rates.get(currency).unwrap_or(&0.0);
let base_rate: f64 = self
.base_rates
.get(currency)
.map(|d| (*d).try_into().unwrap_or(1.0))
.unwrap_or(1.0);
let base_log = base_rate.ln();
let theta = self.config.mean_reversion_speed;
let mu = base_log + self.config.long_term_mean;
let volatility = if self.rng.random::<f64>() < self.config.fat_tail_probability {
self.config.daily_volatility * self.config.fat_tail_multiplier
} else {
self.config.daily_volatility
};
let normal = Normal::new(0.0, 1.0).expect("valid standard normal parameters");
let dw: f64 = normal.sample(&mut self.rng);
let drift = theta * (mu - current_log);
let diffusion = volatility * dw;
let new_log = current_log + drift + diffusion;
self.current_log_rates.insert(currency.to_string(), new_log);
let new_rate = new_log.exp();
let rate_decimal = Decimal::try_from(new_rate).unwrap_or(dec!(1)).round_dp(6);
if date.day() == 1 {
debug!(
"Rate {}/{} for {}: {}",
currency, self.config.base_currency, date, rate_decimal
);
}
FxRate::new(
currency,
&self.config.base_currency,
RateType::Spot,
date,
rate_decimal,
"O-U PROCESS",
)
}
pub fn reset(&mut self) {
self.current_log_rates.clear();
for currency in &self.config.currencies {
if let Some(rate) = self.base_rates.get(currency) {
let rate_f64: f64 = (*rate).try_into().unwrap_or(1.0);
self.current_log_rates
.insert(currency.clone(), rate_f64.ln());
}
}
}
pub fn current_rate(&self, currency: &str) -> Option<Decimal> {
self.current_log_rates.get(currency).map(|log_rate| {
let rate = log_rate.exp();
Decimal::try_from(rate).unwrap_or(dec!(1)).round_dp(6)
})
}
}
pub struct FxRateGenerator {
service: FxRateService,
}
impl FxRateGenerator {
pub fn new(config: FxRateServiceConfig, rng: ChaCha8Rng) -> Self {
Self {
service: FxRateService::new(config, rng),
}
}
pub fn with_seed(config: FxRateServiceConfig, seed: u64) -> Self {
Self::new(config, seeded_rng(seed, 0))
}
pub fn generate_all_rates(
&mut self,
start_date: NaiveDate,
end_date: NaiveDate,
) -> GeneratedFxRates {
let daily_rates = self.service.generate_daily_rates(start_date, end_date);
let mut period_rates = Vec::new();
let mut current_year = start_date.year();
let mut current_month = start_date.month();
while (current_year < end_date.year())
|| (current_year == end_date.year() && current_month <= end_date.month())
{
let rates =
self.service
.generate_period_rates(current_year, current_month, &daily_rates);
period_rates.extend(rates);
if current_month == 12 {
current_month = 1;
current_year += 1;
} else {
current_month += 1;
}
}
GeneratedFxRates {
daily_rates,
period_rates,
start_date,
end_date,
}
}
pub fn service(&self) -> &FxRateService {
&self.service
}
pub fn service_mut(&mut self) -> &mut FxRateService {
&mut self.service
}
}
#[derive(Debug, Clone)]
pub struct GeneratedFxRates {
pub daily_rates: FxRateTable,
pub period_rates: Vec<FxRate>,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
}
impl GeneratedFxRates {
pub fn combined_rate_table(&self) -> FxRateTable {
let mut table = self.daily_rates.clone();
for rate in &self.period_rates {
table.add_rate(rate.clone());
}
table
}
pub fn closing_rates_for_date(&self, date: NaiveDate) -> Vec<&FxRate> {
self.period_rates
.iter()
.filter(|r| r.rate_type == RateType::Closing && r.effective_date == date)
.collect()
}
pub fn average_rates_for_date(&self, date: NaiveDate) -> Vec<&FxRate> {
self.period_rates
.iter()
.filter(|r| r.rate_type == RateType::Average && r.effective_date == date)
.collect()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_fx_rate_generation() {
let rng = ChaCha8Rng::seed_from_u64(12345);
let config = FxRateServiceConfig {
currencies: vec!["EUR".to_string(), "GBP".to_string()],
..Default::default()
};
let mut service = FxRateService::new(config, rng);
let rates = service.generate_daily_rates(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
);
assert!(!rates.is_empty());
}
#[test]
fn test_rate_mean_reversion() {
let rng = ChaCha8Rng::seed_from_u64(12345);
let config = FxRateServiceConfig {
currencies: vec!["EUR".to_string()],
mean_reversion_speed: 0.1, daily_volatility: 0.001, ..Default::default()
};
let mut service = FxRateService::new(config.clone(), rng);
let rates = service.generate_daily_rates(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 4, 10).unwrap(),
);
let base_eur = base_rates_usd().get("EUR").cloned().unwrap_or(dec!(1.10));
let all_eur_rates: Vec<Decimal> = rates
.get_all_rates("EUR", "USD")
.iter()
.map(|r| r.rate)
.collect();
assert!(!all_eur_rates.is_empty());
for rate in &all_eur_rates {
let deviation = (*rate - base_eur).abs() / base_eur;
assert!(
deviation < dec!(0.15),
"Rate {} deviated too much from base {}",
rate,
base_eur
);
}
}
#[test]
fn test_period_rates() {
let rng = ChaCha8Rng::seed_from_u64(12345);
let config = FxRateServiceConfig {
currencies: vec!["EUR".to_string()],
..Default::default()
};
let mut generator = FxRateGenerator::new(config, rng);
let generated = generator.generate_all_rates(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
);
let jan_closing =
generated.closing_rates_for_date(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
assert!(!jan_closing.is_empty());
let jan_average =
generated.average_rates_for_date(NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
assert!(!jan_average.is_empty());
}
}