use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::aggregate::translation::classify::TranslationAccountType;
use datasynth_core::models::hyperinflation::{GeneralPriceIndex, HyperinflationStatus};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexedRestatement {
#[serde(with = "datasynth_core::serde_decimal")]
pub opening_index: Decimal,
#[serde(with = "datasynth_core::serde_decimal")]
pub closing_index: Decimal,
#[serde(with = "datasynth_core::serde_decimal")]
pub average_index: Decimal,
}
impl IndexedRestatement {
pub fn new(
opening_index: Decimal,
closing_index: Decimal,
average_index: Decimal,
) -> Result<Self, String> {
if opening_index <= Decimal::ZERO {
return Err(format!(
"IndexedRestatement::new: opening_index must be > 0, got {opening_index}"
));
}
if closing_index <= Decimal::ZERO {
return Err(format!(
"IndexedRestatement::new: closing_index must be > 0, got {closing_index}"
));
}
if average_index <= Decimal::ZERO {
return Err(format!(
"IndexedRestatement::new: average_index must be > 0, got {average_index}"
));
}
Ok(Self {
opening_index,
closing_index,
average_index,
})
}
pub fn non_monetary_factor(&self) -> Decimal {
self.closing_index / self.opening_index
}
pub fn pl_factor(&self) -> Decimal {
self.closing_index / self.average_index
}
pub fn factor_for(&self, account_type: TranslationAccountType) -> Decimal {
match account_type {
TranslationAccountType::BsMonetary => Decimal::ONE,
TranslationAccountType::BsNonMonetary | TranslationAccountType::Equity => {
self.non_monetary_factor()
}
TranslationAccountType::PlRevenue
| TranslationAccountType::PlExpense
| TranslationAccountType::PlOci => self.pl_factor(),
}
}
pub fn from_cpi_series(
series: &GeneralPriceIndex,
period_start: NaiveDate,
period_end: NaiveDate,
) -> Option<Self> {
let opening_index = series.lookup(period_start)?;
let closing_index = series.lookup(period_end)?;
let in_period: Vec<Decimal> = series
.observations
.iter()
.filter(|(d, _)| *d >= period_start && *d <= period_end)
.map(|(_, level)| *level)
.collect();
let average_index = if in_period.is_empty() {
(opening_index + closing_index) / Decimal::from(2u32)
} else {
let sum: Decimal = in_period.iter().copied().sum();
sum / Decimal::from(in_period.len() as u64)
};
Self::new(opening_index, closing_index, average_index).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestatementPath {
Standard,
ClosingRate,
Indexed(IndexedRestatement),
}
pub fn select_restatement_path(
hyperinflation: HyperinflationStatus,
functional_currency: &str,
cpi_series_by_currency: Option<&std::collections::BTreeMap<String, GeneralPriceIndex>>,
period_start: NaiveDate,
period_end: NaiveDate,
) -> RestatementPath {
if !hyperinflation.requires_restatement() {
return RestatementPath::Standard;
}
let Some(map) = cpi_series_by_currency else {
return RestatementPath::ClosingRate;
};
let Some(series) = map.get(functional_currency) else {
return RestatementPath::ClosingRate;
};
match IndexedRestatement::from_cpi_series(series, period_start, period_end) {
Some(ir) => RestatementPath::Indexed(ir),
None => RestatementPath::ClosingRate,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn new_rejects_non_positive_indices() {
assert!(IndexedRestatement::new(dec!(0), dec!(110), dec!(105)).is_err());
assert!(IndexedRestatement::new(dec!(100), dec!(-1), dec!(105)).is_err());
assert!(IndexedRestatement::new(dec!(100), dec!(110), dec!(0)).is_err());
}
#[test]
fn factors_compute_from_indices() {
let ir = IndexedRestatement::new(dec!(100), dec!(200), dec!(150)).unwrap();
assert_eq!(ir.non_monetary_factor(), dec!(2));
assert_eq!(ir.pl_factor(), dec!(200) / dec!(150));
}
#[test]
fn factor_for_dispatches_by_account_type() {
let ir = IndexedRestatement::new(dec!(100), dec!(200), dec!(150)).unwrap();
assert_eq!(ir.factor_for(TranslationAccountType::BsMonetary), dec!(1));
assert_eq!(
ir.factor_for(TranslationAccountType::BsNonMonetary),
dec!(2)
);
assert_eq!(ir.factor_for(TranslationAccountType::Equity), dec!(2));
assert_eq!(
ir.factor_for(TranslationAccountType::PlRevenue),
dec!(200) / dec!(150)
);
assert_eq!(
ir.factor_for(TranslationAccountType::PlExpense),
dec!(200) / dec!(150)
);
assert_eq!(
ir.factor_for(TranslationAccountType::PlOci),
dec!(200) / dec!(150)
);
}
#[test]
fn unit_factors_when_all_indices_equal() {
let ir = IndexedRestatement::new(dec!(100), dec!(100), dec!(100)).unwrap();
for ty in [
TranslationAccountType::BsMonetary,
TranslationAccountType::BsNonMonetary,
TranslationAccountType::Equity,
TranslationAccountType::PlRevenue,
TranslationAccountType::PlExpense,
TranslationAccountType::PlOci,
] {
assert_eq!(ir.factor_for(ty), dec!(1), "{ty:?}");
}
}
#[test]
fn json_round_trip() {
let ir = IndexedRestatement::new(dec!(123.45), dec!(678.90), dec!(401.17)).unwrap();
let json = serde_json::to_string(&ir).unwrap();
let back: IndexedRestatement = serde_json::from_str(&json).unwrap();
assert_eq!(ir, back);
}
fn ars_index_q1() -> GeneralPriceIndex {
let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General (test)");
idx.observe(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), dec!(100));
idx.observe(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), dec!(120));
idx.observe(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(), dec!(150));
idx.observe(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(), dec!(180));
idx
}
#[test]
fn from_cpi_series_uses_endpoints_and_in_period_average() {
let idx = ars_index_q1();
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let ir = IndexedRestatement::from_cpi_series(&idx, start, end).unwrap();
assert_eq!(ir.opening_index, dec!(100));
assert_eq!(ir.closing_index, dec!(180));
assert_eq!(ir.average_index, dec!(137.5));
}
#[test]
fn from_cpi_series_falls_back_to_midpoint_when_no_in_period_obs() {
let mut idx = GeneralPriceIndex::new("TRY", "test");
idx.observe(NaiveDate::from_ymd_opt(2023, 12, 1).unwrap(), dec!(100));
idx.observe(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(), dec!(200));
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
let ir = IndexedRestatement::from_cpi_series(&idx, start, end).unwrap();
assert_eq!(ir.opening_index, dec!(100));
assert_eq!(ir.closing_index, dec!(100));
assert_eq!(ir.average_index, dec!(100));
}
#[test]
fn from_cpi_series_returns_none_when_no_obs_at_or_before_start() {
let mut idx = GeneralPriceIndex::new("TRY", "test");
idx.observe(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(), dec!(200));
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
assert!(IndexedRestatement::from_cpi_series(&idx, start, end).is_none());
}
#[test]
fn select_restatement_path_non_hyperinflationary_is_standard() {
let path = select_restatement_path(
HyperinflationStatus::NotHyperinflationary,
"USD",
None,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
);
assert_eq!(path, RestatementPath::Standard);
}
#[test]
fn select_restatement_path_hyperinflationary_no_map_is_closing_rate() {
let path = select_restatement_path(
HyperinflationStatus::Hyperinflationary,
"ARS",
None,
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
);
assert_eq!(path, RestatementPath::ClosingRate);
}
#[test]
fn select_restatement_path_hyperinflationary_no_match_is_closing_rate() {
let mut map: std::collections::BTreeMap<String, GeneralPriceIndex> =
std::collections::BTreeMap::new();
map.insert("TRY".to_string(), ars_index_q1());
let path = select_restatement_path(
HyperinflationStatus::Hyperinflationary,
"ARS",
Some(&map),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
);
assert_eq!(path, RestatementPath::ClosingRate);
}
#[test]
fn select_restatement_path_hyperinflationary_match_is_indexed() {
let mut map: std::collections::BTreeMap<String, GeneralPriceIndex> =
std::collections::BTreeMap::new();
map.insert("ARS".to_string(), ars_index_q1());
let path = select_restatement_path(
HyperinflationStatus::Hyperinflationary,
"ARS",
Some(&map),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
);
match path {
RestatementPath::Indexed(ir) => {
assert_eq!(ir.opening_index, dec!(100));
assert_eq!(ir.closing_index, dec!(180));
}
other => panic!("expected Indexed; got {other:?}"),
}
}
#[test]
fn select_restatement_path_hyperinflationary_match_but_lookup_miss_is_closing_rate() {
let mut idx = GeneralPriceIndex::new("ARS", "test");
idx.observe(NaiveDate::from_ymd_opt(2024, 6, 1).unwrap(), dec!(200));
let mut map: std::collections::BTreeMap<String, GeneralPriceIndex> =
std::collections::BTreeMap::new();
map.insert("ARS".to_string(), idx);
let path = select_restatement_path(
HyperinflationStatus::Hyperinflationary,
"ARS",
Some(&map),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
);
assert_eq!(path, RestatementPath::ClosingRate);
}
#[test]
fn cpi_series_json_round_trip() {
let idx = ars_index_q1();
let series = vec![idx];
let json = serde_json::to_string(&series).unwrap();
let back: Vec<GeneralPriceIndex> = serde_json::from_str(&json).unwrap();
assert_eq!(series, back);
assert_eq!(back[0].currency, "ARS");
assert_eq!(back[0].observations.len(), 4);
}
}