use crate::config::{FxConfig, FxPolicyConfig, FxRateSource};
use crate::errors::{GroupError, GroupResult};
use crate::manifest::expansion::ExpandedEntity;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FxRateMaster {
pub base_currency: String,
pub policy: FxPolicyConfig,
pub rates: BTreeMap<String, BTreeMap<NaiveDate, Decimal>>,
pub closing_by_pair: BTreeMap<String, Decimal>,
pub average_by_pair: BTreeMap<String, Decimal>,
}
pub fn build_fx_master(
cfg: &FxConfig,
presentation_currency: &str,
period_start: NaiveDate,
period_end: NaiveDate,
expanded_entities: &[ExpandedEntity],
) -> GroupResult<FxRateMaster> {
if cfg.rate_source == FxRateSource::HistoricalSeries {
return Err(GroupError::Config(
"rate_source: historical_series not supported in v5.0 — use inline or user_supplied"
.to_string(),
));
}
if cfg.base_currency != presentation_currency {
return Err(GroupError::Config(format!(
"fx.base_currency {} does not match group presentation_currency {}",
cfg.base_currency, presentation_currency
)));
}
let mut needed_pairs: BTreeSet<String> = BTreeSet::new();
for entity in expanded_entities {
if entity.functional_currency != presentation_currency {
let pair = format!("{}/{}", entity.functional_currency, presentation_currency);
needed_pairs.insert(pair);
}
}
if needed_pairs.is_empty() {
return Ok(FxRateMaster {
base_currency: presentation_currency.to_string(),
policy: cfg.policy.clone(),
rates: BTreeMap::new(),
closing_by_pair: BTreeMap::new(),
average_by_pair: BTreeMap::new(),
});
}
let mut resolved_rates: BTreeMap<String, BTreeMap<NaiveDate, Decimal>> = BTreeMap::new();
for pair in &needed_pairs {
let (func, pres) = split_pair(pair)?;
let inverted_key = format!("{pres}/{func}");
let period_filtered: BTreeMap<NaiveDate, Decimal>;
if let Some(table) = cfg.rates.get(pair) {
period_filtered = table
.range(period_start..=period_end)
.map(|(d, r)| (*d, *r))
.collect();
} else if let Some(table) = cfg.rates.get(&inverted_key) {
period_filtered = table
.range(period_start..=period_end)
.filter_map(|(d, r)| {
if r.is_zero() {
None } else {
Some((*d, Decimal::ONE / r))
}
})
.collect();
} else {
return Err(GroupError::Config(format!(
"fx.rates missing pair {pair} for period [{period_start}, {period_end}]"
)));
}
if period_filtered.is_empty() {
return Err(GroupError::Config(format!(
"fx.rates missing pair {pair} for period [{period_start}, {period_end}]"
)));
}
resolved_rates.insert(pair.clone(), period_filtered);
}
let mut closing_by_pair: BTreeMap<String, Decimal> = BTreeMap::new();
for (pair, table) in &resolved_rates {
let closing =
table.values().last().copied().ok_or_else(|| {
GroupError::Config(format!("no closing rate found for pair {pair}"))
})?;
closing_by_pair.insert(pair.clone(), closing);
}
let mut average_by_pair: BTreeMap<String, Decimal> = BTreeMap::new();
for (pair, table) in &resolved_rates {
let count = table.len() as u32;
let sum: Decimal = table.values().copied().sum();
let average = sum / Decimal::from(count);
average_by_pair.insert(pair.clone(), average);
}
Ok(FxRateMaster {
base_currency: presentation_currency.to_string(),
policy: cfg.policy.clone(),
rates: resolved_rates,
closing_by_pair,
average_by_pair,
})
}
fn split_pair(pair: &str) -> GroupResult<(&str, &str)> {
let mut parts = pair.splitn(2, '/');
let func = parts.next().ok_or_else(|| {
GroupError::Config(format!("malformed pair key '{pair}' — expected FUNC/PRES"))
})?;
let pres = parts.next().ok_or_else(|| {
GroupError::Config(format!("malformed pair key '{pair}' — expected FUNC/PRES"))
})?;
Ok((func, pres))
}