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)]
293mod tests {
294 use super::*;
295 use rust_decimal_macros::dec;
296
297 fn open_date() -> NaiveDate {
298 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
299 }
300 fn mid_date() -> NaiveDate {
301 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
302 }
303 fn close_date() -> NaiveDate {
304 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()
305 }
306
307 fn ars_index() -> GeneralPriceIndex {
308 let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
309 idx.observe(open_date(), dec!(100));
310 idx.observe(mid_date(), dec!(160));
311 idx.observe(close_date(), dec!(220));
312 idx
313 }
314
315 #[test]
316 fn status_requires_restatement_only_when_hyperinflationary() {
317 assert!(!HyperinflationStatus::NotHyperinflationary.requires_restatement());
318 assert!(HyperinflationStatus::Hyperinflationary.requires_restatement());
319 }
320
321 #[test]
322 fn index_lookup_returns_most_recent_at_or_before_date() {
323 let idx = ars_index();
324 // Exact match.
325 assert_eq!(idx.lookup(open_date()), Some(dec!(100)));
326 assert_eq!(idx.lookup(close_date()), Some(dec!(220)));
327 // Between observations: returns the prior observation.
328 let between = NaiveDate::from_ymd_opt(2024, 9, 15).unwrap();
329 assert_eq!(idx.lookup(between), Some(dec!(160)));
330 // Before the first observation: None.
331 let earlier = NaiveDate::from_ymd_opt(2023, 12, 31).unwrap();
332 assert_eq!(idx.lookup(earlier), None);
333 }
334
335 #[test]
336 fn index_lookup_handles_unsorted_observations() {
337 // Insert in reverse order; lookup must still pick the
338 // chronologically most recent ≤ target.
339 let mut idx = GeneralPriceIndex::new("ARS", "INDEC IPC General");
340 idx.observe(close_date(), dec!(220));
341 idx.observe(open_date(), dec!(100));
342 idx.observe(mid_date(), dec!(160));
343 assert_eq!(idx.lookup(mid_date()), Some(dec!(160)));
344 }
345
346 #[test]
347 fn indexation_factor_is_ratio_of_indices() {
348 let idx = ars_index();
349 // open → close: 220 / 100 = 2.2.
350 let factor = idx.indexation_factor(open_date(), close_date()).unwrap();
351 assert_eq!(factor, dec!(2.2));
352 // mid → close: 220 / 160 = 1.375.
353 let factor = idx.indexation_factor(mid_date(), close_date()).unwrap();
354 assert_eq!(factor, dec!(1.375));
355 }
356
357 #[test]
358 fn indexation_factor_returns_none_on_missing_data() {
359 let idx = ars_index();
360 let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
361 // Pre-index date returns None for the from-side.
362 assert_eq!(idx.indexation_factor(pre_index, close_date()), None);
363 }
364
365 #[test]
366 fn restate_applies_factor_to_historical_amount() {
367 let idx = ars_index();
368 let r = IndexedRestatement::restate(
369 "1500", // PP&E
370 open_date(),
371 close_date(),
372 dec!(1_000_000),
373 &idx,
374 )
375 .unwrap();
376 assert_eq!(r.indexation_factor, dec!(2.2));
377 assert_eq!(r.restated_amount, dec!(2_200_000.00));
378 assert_eq!(r.adjustment(), dec!(1_200_000.00));
379 assert_eq!(r.currency, "ARS");
380 }
381
382 #[test]
383 fn restate_returns_none_when_factor_unavailable() {
384 let idx = ars_index();
385 let pre_index = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
386 assert!(IndexedRestatement::restate(
387 "1500",
388 pre_index,
389 close_date(),
390 dec!(1_000_000),
391 &idx
392 )
393 .is_none());
394 }
395
396 #[test]
397 fn net_monetary_loss_for_a_net_holder_of_cash() {
398 // Entity is a net holder of monetary assets. Opening net =
399 // 100k, closing net = 180k after a year of 120% inflation
400 // (factor 2.2). Restated opening = 100k × 2.2 = 220k.
401 // Loss = closing 180k − restated 220k = −40k (loss in P&L).
402 let result = NetMonetaryPositionGainLoss::compute(
403 close_date(),
404 dec!(100_000),
405 dec!(180_000),
406 dec!(2.2),
407 "ARS",
408 );
409 assert_eq!(result.gain_or_loss, dec!(-40_000.00));
410 }
411
412 #[test]
413 fn net_monetary_gain_for_a_net_debtor() {
414 // Entity is a net debtor (negative net monetary position).
415 // Opening = −500k, closing = −540k, factor 2.2. Restated
416 // opening = −500k × 2.2 = −1.1M. Gain = −540k − (−1.1M) =
417 // +560k (purchasing power gain on debt).
418 let result = NetMonetaryPositionGainLoss::compute(
419 close_date(),
420 dec!(-500_000),
421 dec!(-540_000),
422 dec!(2.2),
423 "ARS",
424 );
425 assert_eq!(result.gain_or_loss, dec!(560_000.00));
426 }
427
428 #[test]
429 fn round_trips_serialise_for_audit_evidence() {
430 let idx = ars_index();
431 let json = serde_json::to_string(&idx).unwrap();
432 let back: GeneralPriceIndex = serde_json::from_str(&json).unwrap();
433 assert_eq!(back, idx);
434
435 let r =
436 IndexedRestatement::restate("1500", open_date(), close_date(), dec!(1_000_000), &idx)
437 .unwrap();
438 let json = serde_json::to_string(&r).unwrap();
439 let back: IndexedRestatement = serde_json::from_str(&json).unwrap();
440 assert_eq!(back, r);
441
442 let gl = NetMonetaryPositionGainLoss::compute(
443 close_date(),
444 dec!(100_000),
445 dec!(180_000),
446 dec!(2.2),
447 "ARS",
448 );
449 let json = serde_json::to_string(&gl).unwrap();
450 let back: NetMonetaryPositionGainLoss = serde_json::from_str(&json).unwrap();
451 assert_eq!(back, gl);
452 }
453}