use std::collections::HashMap;
use crate::date::Date;
use crate::decimal::Decimal;
use crate::indexer::Index;
use crate::parser::located::Located;
use crate::parser::posting::{Amount, Posting};
use crate::parser::transaction::{State, Transaction};
pub struct RevaluationAccounts<'a> {
pub gain: &'a str,
pub loss: &'a str,
}
const LATEST: &str = "9999-12-31";
pub fn revaluate(
txs: &mut Vec<Located<Transaction>>,
target: &str,
db: &Index,
accounts: &RevaluationAccounts,
precision: usize,
) {
if txs.is_empty() {
return;
}
let mut groups: HashMap<(String, String), (Decimal, Decimal, bool)> = HashMap::new();
let reval_date = Date::today();
for lt in txs.iter() {
let date = lt.value.date.to_string();
for lp in <.value.postings {
let Some(a) = &lp.value.amount else { continue };
let key = (lp.value.account.clone(), a.commodity.clone());
let e = groups.entry(key).or_insert((Decimal::zero(), Decimal::zero(), false));
e.0 = e.0 + a.value;
match crate::rebalancer::target_value(&lp.value, target, db, &date) {
Some(v) => e.1 = e.1 + v,
None => e.2 = true,
}
}
}
let file = txs[0].file.clone();
let line = txs[0].line;
let mut keys: Vec<&(String, String)> = groups.keys().collect();
keys.sort();
let mut out: Vec<Located<Transaction>> = Vec::new();
for key in keys {
let (account, commodity) = key;
if commodity == target {
continue;
}
let (native_bal, historical, rate_missing) = &groups[key];
if *rate_missing || native_bal.is_zero() {
continue;
}
let Some(rate) = db.find(commodity, target, LATEST) else {
continue;
};
let current = native_bal.mul_rounded(rate);
let diff = current - *historical;
if diff.is_display_zero(precision) {
continue;
}
let reval = if diff.is_negative() { accounts.loss } else { accounts.gain };
out.push(build_tx(&file, line, reval_date, target, account, commodity, reval, diff, precision));
}
txs.extend(out);
txs.sort_by(|a, b| a.value.date.cmp(&b.value.date));
}
#[allow(clippy::too_many_arguments)]
fn build_tx(
file: &std::sync::Arc<str>,
line: usize,
date: Date,
target: &str,
account: &str,
commodity: &str,
reval_account: &str,
diff: Decimal,
precision: usize,
) -> Located<Transaction> {
let asset = Posting {
account: account.to_string(),
amount: Some(Amount {
commodity: target.to_string(),
value: diff,
decimals: precision,
}),
costs: None,
lot_cost: None,
lot_date: None,
balance_assertion: None,
is_virtual: false,
balanced: true,
comments: Vec::new(),
};
let counter = Posting {
account: reval_account.to_string(),
amount: Some(Amount {
commodity: target.to_string(),
value: Decimal::zero() - diff,
decimals: precision,
}),
costs: None,
lot_cost: None,
lot_date: None,
balance_assertion: None,
is_virtual: false,
balanced: true,
comments: Vec::new(),
};
Located {
file: file.clone(),
line,
value: Transaction {
date,
state: State::Cleared,
code: None,
description: format!("unrealized fx revaluation {commodity}"),
postings: vec![
Located { file: file.clone(), line, value: asset },
Located { file: file.clone(), line, value: counter },
],
comments: Vec::new(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::resolver;
fn setup(src: &str) -> (Vec<Located<Transaction>>, Index, usize) {
let entries = parser::parse(src).unwrap();
let resolved = resolver::resolve(entries).unwrap();
let prices = crate::indexer::index(resolved.prices);
let txs = crate::booker::book(resolved.transactions).unwrap();
(txs, prices, 2)
}
fn accounts() -> RevaluationAccounts<'static> {
RevaluationAccounts { gain: "income:reval", loss: "expenses:reval" }
}
fn balance(txs: &[Located<Transaction>], account: &str, commodity: &str) -> Decimal {
let mut sum = Decimal::zero();
for lt in txs {
for lp in <.value.postings {
if lp.value.account == account {
if let Some(a) = &lp.value.amount {
if a.commodity == commodity {
sum = sum + a.value;
}
}
}
}
}
sum
}
#[test]
fn open_position_marked_to_latest_rate() {
let src = "\
P 2024-01-01 USD EUR 0.80\n\
P 2024-06-01 USD EUR 0.95\n\
2024-01-01 * buy\n\
\tassets:usd 1000 USD\n\
\tassets:bank -800 EUR\n\
2024-06-01 * spend\n\
\texpenses:x 855 EUR\n\
\tassets:usd -900 USD\n";
let (mut txs, db, prec) = setup(src);
revaluate(&mut txs, "EUR", &db, &accounts(), prec);
assert_eq!(balance(&txs, "assets:usd", "EUR"), Decimal::parse("150").unwrap());
assert_eq!(balance(&txs, "income:reval", "EUR"), Decimal::parse("-150").unwrap());
assert_eq!(balance(&txs, "assets:usd", "USD"), Decimal::parse("100").unwrap());
}
#[test]
fn closed_position_is_left_alone() {
let src = "\
P 2024-01-01 USD EUR 0.80\n\
P 2024-06-01 USD EUR 0.95\n\
2024-01-01 * in\n\
\tassets:usd 1000 USD\n\
\tassets:bank -800 EUR\n\
2024-06-01 * out\n\
\texpenses:x 950 EUR\n\
\tassets:usd -1000 USD\n";
let (mut txs, db, prec) = setup(src);
let before = txs.len();
revaluate(&mut txs, "EUR", &db, &accounts(), prec);
assert_eq!(txs.len(), before, "closed position must not be revalued");
}
#[test]
fn target_commodity_not_revalued() {
let src = "\
2024-01-01 * x\n\
\tassets:bank 100 EUR\n\
\tequity:open -100 EUR\n";
let (mut txs, db, prec) = setup(src);
let before = txs.len();
revaluate(&mut txs, "EUR", &db, &accounts(), prec);
assert_eq!(txs.len(), before);
}
}