datasynth_core/models/hyperinflation.rs
1//! Hyperinflationary-economy accounting under IAS 29 / ASC 830.
2//!
3//! When an entity's functional currency is the currency of a
4//! hyperinflationary economy, IAS 29 requires the entity to restate
5//! its non-monetary items using a general price index so the
6//! financial statements are stated in terms of the **measuring unit
7//! current at the end of the reporting period**. v5.0 / v5.1 did not
8//! handle this — every entity went through the standard IAS 21
9//! translation path which produces nonsense results once cumulative
10//! inflation crosses ~100 %.
11//!
12//! v5.2 ships the typed model + arithmetic helpers; the integration
13//! with the IAS 21 translation pipeline (`translate_entity_tb` /
14//! `cta_rollforward`) is a follow-up.
15//!
16//! # Standards reference
17//!
18//! - **IAS 29 § 3** — characteristics of a hyperinflationary economy
19//! (cumulative 3-year inflation approaching or exceeding 100 %; the
20//! general population prefers a stable foreign currency to keep its
21//! wealth; etc.). IAS 29 does not establish an absolute rate at
22//! which hyperinflation is deemed to arise — it's a matter of
23//! judgement.
24//! - **IAS 29 § 8** — the financial statements **shall be stated in
25//! terms of the measuring unit current at the end of the reporting
26//! period**. Comparative figures are also restated.
27//! - **IAS 29 § 12** — non-monetary items carried at historical cost
28//! are restated by applying the change in the general price index
29//! between the date of acquisition (or revaluation) and the
30//! reporting date.
31//! - **IAS 29 § 13** — non-monetary items at current value (e.g.
32//! inventories at NRV) are NOT restated (already at current
33//! measuring unit).
34//! - **IAS 29 § 27** — the **net gain or loss on the net monetary
35//! position** is included in profit or loss for the period. It
36//! represents the loss of purchasing power on monetary items
37//! (cash, receivables, payables).
38//! - **IAS 21 § 39 / IAS 29 § 33** — when restated financial
39//! statements of a hyperinflationary subsidiary are translated
40//! into the group's (non-hyperinflationary) presentation
41//! currency, the **closing rate** is used for ALL items (not the
42//! spot/average split that IAS 21 normally prescribes).
43//!
44//! # Scope
45//!
46//! v5.2 ships:
47//!
48//! - [`HyperinflationStatus`] entity-level flag
49//! - [`GeneralPriceIndex`] CPI series + index lookup
50//! - [`IndexedRestatement`] line-item restatement record
51//! - [`NetMonetaryPositionGainLoss`] helper for the IAS 29 § 27
52//! purchasing-power gain/loss calc
53//!
54//! The wiring to actually drive these through `translate_entity_tb`
55//! is a follow-up tracked in the README.
56
57use chrono::NaiveDate;
58use rust_decimal::Decimal;
59use serde::{Deserialize, Serialize};
60
61/// Hyperinflation status of an entity's functional currency.
62/// Captured per-entity per-period because a country can transition
63/// in or out of hyperinflation across reporting cycles (e.g.
64/// Argentina entered hyperinflationary status in 2018 per IAS 29
65/// criteria; Türkiye did so in 2022).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum HyperinflationStatus {
69 /// The functional currency is **not** hyperinflationary.
70 /// Standard IAS 21 translation applies.
71 #[default]
72 NotHyperinflationary,
73 /// The functional currency **is** hyperinflationary; IAS 29
74 /// restatement applies before IAS 21 translation per IAS 21 § 43.
75 /// The closing rate is used for all items per IAS 21 § 42(b).
76 Hyperinflationary,
77}
78
79impl HyperinflationStatus {
80 /// Returns `true` when this status requires IAS 29 restatement
81 /// before IAS 21 translation.
82 pub fn requires_restatement(&self) -> bool {
83 matches!(self, Self::Hyperinflationary)
84 }
85}
86
87/// Time series of general-price-index (CPI) observations for a
88/// hyperinflationary economy. The index is monotonically
89/// non-decreasing in normal use; the lookup helpers tolerate
90/// out-of-order dates by sorting on access.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct GeneralPriceIndex {
93 /// ISO 4217 currency code the index applies to (e.g. "ARS",
94 /// "TRY"). Joins to entity functional currency for lookup.
95 pub currency: String,
96
97 /// Source / methodology label (e.g. "INDEC IPC General",
98 /// "TÜİK CPI"). Carried for audit-trail purposes.
99 pub source: String,
100
101 /// Observed (date, index level) pairs. Index level convention:
102 /// any positive [`Decimal`] — the helpers compute relative
103 /// indexation factors (`new / old`), so absolute scale is free.
104 pub observations: Vec<(NaiveDate, Decimal)>,
105}
106
107impl GeneralPriceIndex {
108 /// Construct an empty index for `currency` from a labelled
109 /// source.
110 pub fn new(currency: impl Into<String>, source: impl Into<String>) -> Self {
111 Self {
112 currency: currency.into(),
113 source: source.into(),
114 observations: Vec::new(),
115 }
116 }
117
118 /// Append an observation. Caller is responsible for ordering;
119 /// [`Self::lookup`] sorts on access.
120 pub fn observe(&mut self, date: NaiveDate, level: Decimal) -> &mut Self {
121 self.observations.push((date, level));
122 self
123 }
124
125 /// Look up the index level for a date. Returns the level on the
126 /// **most recent observation at or before** `date`, mirroring
127 /// the conservative IAS 29 convention of using the latest
128 /// available CPI for each measurement date. Returns `None` when
129 /// no observation exists at or before the date.
130 pub fn lookup(&self, date: NaiveDate) -> Option<Decimal> {
131 let mut sorted: Vec<&(NaiveDate, Decimal)> = self.observations.iter().collect();
132 sorted.sort_by_key(|(d, _)| *d);
133 sorted
134 .iter()
135 .rev()
136 .find(|(d, _)| *d <= date)
137 .map(|(_, level)| *level)
138 }
139
140 /// Compute the IAS 29 § 12 indexation factor for restating a
141 /// historical-cost amount from `from_date` to `to_date`:
142 ///
143 /// `factor = index(to_date) / index(from_date)`
144 ///
145 /// Returns `None` when either lookup misses, or when the
146 /// `from_date` index is zero (would otherwise divide-by-zero).
147 pub fn indexation_factor(&self, from_date: NaiveDate, to_date: NaiveDate) -> Option<Decimal> {
148 let from = self.lookup(from_date)?;
149 let to = self.lookup(to_date)?;
150 if from.is_zero() {
151 None
152 } else {
153 Some(to / from)
154 }
155 }
156}
157
158/// One line-item restatement under IAS 29 § 12.
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160pub struct IndexedRestatement {
161 /// Account code being restated.
162 pub account_code: String,
163
164 /// The historical date on which the underlying item was
165 /// recognised (acquisition date for non-monetary items).
166 pub historical_date: NaiveDate,
167
168 /// Reporting period end the restatement is being made for.
169 pub reporting_date: NaiveDate,
170
171 /// Pre-restatement (historical-cost) carrying amount, in the
172 /// functional currency.
173 #[serde(with = "crate::serde_decimal")]
174 pub historical_amount: Decimal,
175
176 /// Indexation factor applied =
177 /// `index(reporting_date) / index(historical_date)`.
178 #[serde(with = "crate::serde_decimal")]
179 pub indexation_factor: Decimal,
180
181 /// Post-restatement amount =
182 /// `historical_amount * indexation_factor`.
183 #[serde(with = "crate::serde_decimal")]
184 pub restated_amount: Decimal,
185
186 /// Functional currency code.
187 pub currency: String,
188}
189
190impl IndexedRestatement {
191 /// Restate `historical_amount` from `historical_date` to
192 /// `reporting_date` using the supplied [`GeneralPriceIndex`].
193 /// Returns `None` when the index can't yield a factor for either
194 /// date. Pure projection — no I/O.
195 pub fn restate(
196 account_code: impl Into<String>,
197 historical_date: NaiveDate,
198 reporting_date: NaiveDate,
199 historical_amount: Decimal,
200 index: &GeneralPriceIndex,
201 ) -> Option<Self> {
202 let factor = index.indexation_factor(historical_date, reporting_date)?;
203 Some(Self {
204 account_code: account_code.into(),
205 historical_date,
206 reporting_date,
207 historical_amount,
208 indexation_factor: factor,
209 restated_amount: (historical_amount * factor).round_dp(2),
210 currency: index.currency.clone(),
211 })
212 }
213
214 /// Restatement adjustment amount =
215 /// `restated_amount − historical_amount`. Positive when the
216 /// asset's measured value increased (general inflation outpaces
217 /// historical book value); negative when the index has fallen
218 /// (rare — typically only happens with a base-period reset).
219 pub fn adjustment(&self) -> Decimal {
220 self.restated_amount - self.historical_amount
221 }
222}
223
224/// IAS 29 § 27 net-monetary-position gain or loss for a period.
225///
226/// Monetary items (cash, receivables, payables) lose purchasing
227/// power as the general price level rises. The gain or loss is
228/// recognised in P&L for the period.
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
230pub struct NetMonetaryPositionGainLoss {
231 /// Reporting period end.
232 pub reporting_date: NaiveDate,
233
234 /// Opening net monetary position (monetary assets less
235 /// monetary liabilities) at the start of the period, in the
236 /// functional currency.
237 #[serde(with = "crate::serde_decimal")]
238 pub opening_net_monetary_position: Decimal,
239
240 /// Closing net monetary position at `reporting_date`.
241 #[serde(with = "crate::serde_decimal")]
242 pub closing_net_monetary_position: Decimal,
243
244 /// Indexation factor for the period =
245 /// `index(reporting_date) / index(opening_date)`.
246 #[serde(with = "crate::serde_decimal")]
247 pub period_indexation_factor: Decimal,
248
249 /// Computed gain or loss on the net monetary position. Sign
250 /// convention: a **loss** (negative number) when the entity is a
251 /// net holder of monetary assets in a rising-price environment
252 /// (the typical case in hyperinflation). A **gain** (positive)
253 /// arises when the entity is a net debtor — its monetary
254 /// liabilities lose purchasing power.
255 #[serde(with = "crate::serde_decimal")]
256 pub gain_or_loss: Decimal,
257
258 /// Functional currency code.
259 pub currency: String,
260}
261
262impl NetMonetaryPositionGainLoss {
263 /// Compute the IAS 29 § 27 gain/loss using the simplified
264 /// **opening-balance restatement** approach:
265 ///
266 /// `gain_or_loss = closing_net_monetary − (opening_net_monetary × factor)`
267 ///
268 /// A more rigorous calculation would index every monetary
269 /// transaction during the period — that's out of scope for v5.2's
270 /// model layer; the wiring layer can refine the input
271 /// aggregation later.
272 pub fn compute(
273 reporting_date: NaiveDate,
274 opening_net_monetary_position: Decimal,
275 closing_net_monetary_position: Decimal,
276 period_indexation_factor: Decimal,
277 currency: impl Into<String>,
278 ) -> Self {
279 let restated_opening = opening_net_monetary_position * period_indexation_factor;
280 let gain_or_loss = (closing_net_monetary_position - restated_opening).round_dp(2);
281 Self {
282 reporting_date,
283 opening_net_monetary_position: opening_net_monetary_position.round_dp(2),
284 closing_net_monetary_position: closing_net_monetary_position.round_dp(2),
285 period_indexation_factor,
286 gain_or_loss,
287 currency: currency.into(),
288 }
289 }
290}
291
292#[cfg(test)]
293#[allow(clippy::unwrap_used)]
294mod tests {
295 use super::*;
296 use rust_decimal_macros::dec;
297
298 fn open_date() -> NaiveDate {
299 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
300 }
301 fn mid_date() -> NaiveDate {
302 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
303 }
304 fn close_date() -> NaiveDate {
305 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
306 }
307
308 fn ars_index() -> GeneralPriceIndex {
309 let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
310 idx.observe(open_date(), dec!(100));
311 idx.observe(mid_date(), dec!(160));
312 idx.observe(close_date(), dec!(220));
313 idx
314 }
315
316 #[test]
317 fn status_requires_restatement_only_when_hyperinflationary() {
318 assert!(!HyperinflationStatus::NotHyperinflationary.requires_restatement());
319 assert!(HyperinflationStatus::Hyperinflationary.requires_restatement());
320 }
321
322 #[test]
323 fn index_lookup_returns_most_recent_at_or_before_date() {
324 let idx = ars_index();
325 // Exact match.
326 assert_eq!(idx.lookup(open_date()), Some(dec!(100)));
327 assert_eq!(idx.lookup(close_date()), Some(dec!(220)));
328 // Between observations: returns the prior observation.
329 let between = NaiveDate::from_ymd_opt(2024, 9, 15).unwrap();
330 assert_eq!(idx.lookup(between), Some(dec!(160)));
331 // Before the first observation: None.
332 let earlier = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap();
333 assert_eq!(idx.lookup(earlier), None);
334 }
335
336 #[test]
337 fn index_lookup_handles_unsorted_observations() {
338 // Insert in reverse order; lookup must still pick the
339 // chronologically most recent ≤ target.
340 let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
341 idx.observe(close_date(), dec!(220));
342 idx.observe(open_date(), dec!(100));
343 idx.observe(mid_date(), dec!(160));
344 assert_eq!(idx.lookup(mid_date()), Some(dec!(160)));
345 }
346
347 #[test]
348 fn indexation_factor_is_ratio_of_indices() {
349 let idx = ars_index();
350 // open → close: 220 / 100 = 2.2.
351 let factor = idx.indexation_factor(open_date(), close_date()).unwrap();
352 assert_eq!(factor, dec!(2.2));
353 // mid → close: 220 / 160 = 1.375.
354 let factor = idx.indexation_factor(mid_date(), close_date()).unwrap();
355 assert_eq!(factor, dec!(1.375));
356 }
357
358 #[test]
359 fn indexation_factor_returns_none_on_missing_data() {
360 let idx = ars_index();
361 let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
362 // Pre-index date returns None for the from-side.
363 assert_eq!(idx.indexation_factor(pre_index, close_date()), None);
364 }
365
366 #[test]
367 fn restate_applies_factor_to_historical_amount() {
368 let idx = ars_index();
369 let r = IndexedRestatement::restate(
370 "1500", // PP&E
371 open_date(),
372 close_date(),
373 dec!(1_000_000),
374 &idx,
375 )
376 .unwrap();
377 assert_eq!(r.indexation_factor, dec!(2.2));
378 assert_eq!(r.restated_amount, dec!(2_200_000.00));
379 assert_eq!(r.adjustment(), dec!(1_200_000.00));
380 assert_eq!(r.currency, "ARS");
381 }
382
383 #[test]
384 fn restate_returns_none_when_factor_unavailable() {
385 let idx = ars_index();
386 let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
387 assert!(IndexedRestatement::restate(
388 "1500",
389 pre_index,
390 close_date(),
391 dec!(1_000_000),
392 &idx
393 )
394 .is_none());
395 }
396
397 #[test]
398 fn net_monetary_loss_for_a_net_holder_of_cash() {
399 // Entity is a net holder of monetary assets. Opening net =
400 // 100k, closing net = 180k after a year of 120% inflation
401 // (factor 2.2). Restated opening = 100k × 2.2 = 220k.
402 // Loss = closing 180k − restated 220k = −40k (loss in P&L).
403 let result = NetMonetaryPositionGainLoss::compute(
404 close_date(),
405 dec!(100_000),
406 dec!(180_000),
407 dec!(2.2),
408 "ARS",
409 );
410 assert_eq!(result.gain_or_loss, dec!(-40_000.00));
411 }
412
413 #[test]
414 fn net_monetary_gain_for_a_net_debtor() {
415 // Entity is a net debtor (negative net monetary position).
416 // Opening = −500k, closing = −540k, factor 2.2. Restated
417 // opening = −500k × 2.2 = −1.1M. Gain = −540k − (−1.1M) =
418 // +560k (purchasing power gain on debt).
419 let result = NetMonetaryPositionGainLoss::compute(
420 close_date(),
421 dec!(-500_000),
422 dec!(-540_000),
423 dec!(2.2),
424 "ARS",
425 );
426 assert_eq!(result.gain_or_loss, dec!(560_000.00));
427 }
428
429 #[test]
430 fn round_trips_serialise_for_audit_evidence() {
431 let idx = ars_index();
432 let json = serde_json::to_string(&idx).unwrap();
433 let back: GeneralPriceIndex = serde_json::from_str(&json).unwrap();
434 assert_eq!(back, idx);
435
436 let r =
437 IndexedRestatement::restate("1500", open_date(), close_date(), dec!(1_000_000), &idx)
438 .unwrap();
439 let json = serde_json::to_string(&r).unwrap();
440 let back: IndexedRestatement = serde_json::from_str(&json).unwrap();
441 assert_eq!(back, r);
442
443 let gl = NetMonetaryPositionGainLoss::compute(
444 close_date(),
445 dec!(100_000),
446 dec!(180_000),
447 dec!(2.2),
448 "ARS",
449 );
450 let json = serde_json::to_string(&gl).unwrap();
451 let back: NetMonetaryPositionGainLoss = serde_json::from_str(&json).unwrap();
452 assert_eq!(back, gl);
453 }
454}