use std::collections::{HashMap, HashSet};
use crate::decimal::Decimal;
use crate::indexer::Index;
use crate::parser::located::Located;
use crate::parser::posting::{Amount, Posting};
use crate::parser::transaction::Transaction;
pub fn realize(
txs: &mut [Located<Transaction>],
target: &str,
db: &Index,
precisions: &HashMap<String, usize>,
fx_gain: &str,
fx_loss: &str,
) {
let precision = precisions.get(target).copied().unwrap_or(2);
for lt in txs.iter_mut() {
augment(lt, target, db, precision, fx_gain, fx_loss);
}
}
fn augment(
lt: &mut Located<Transaction>,
target: &str,
db: &Index,
precision: usize,
fx_gain: &str,
fx_loss: &str,
) {
let contributes = |p: &Posting| !p.is_virtual || p.balanced;
let mut commodities: HashSet<&str> = HashSet::new();
for lp in <.value.postings {
if !contributes(&lp.value) {
continue;
}
if let Some(a) = &lp.value.amount {
commodities.insert(a.commodity.as_str());
}
}
if commodities.len() < 2 {
return;
}
let date = lt.value.date.to_string();
let mut total = Decimal::zero();
for lp in <.value.postings {
if !contributes(&lp.value) {
continue;
}
let Some(a) = &lp.value.amount else { continue };
let Some(rate) = db.find(&a.commodity, target, &date) else {
return;
};
total = total + a.value.mul_rounded(rate);
}
if total.is_display_zero(precision) {
return;
}
let (account, value) = if total.is_negative() {
(fx_loss, -total)
} else {
(fx_gain, -total)
};
lt.value.postings.push(Located {
file: lt.file.clone(),
line: lt.line,
value: Posting {
account: account.to_string(),
amount: Some(Amount {
commodity: target.to_string(),
value,
decimals: precision,
}),
costs: None,
lot_cost: None,
balance_assertion: None,
is_virtual: true,
balanced: false,
comments: Vec::new(),
},
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::resolver;
fn build(src: &str) -> (Vec<Located<Transaction>>, Index) {
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)
}
#[test]
fn gain_when_implied_rate_above_market() {
let src = "\
P 2024-06-15 USD EUR 0.9\n\
2024-06-15 * x\n\
\tassets:usd -100 USD\n\
\tassets:eur 92 EUR\n";
let (mut txs, db) = build(src);
realize(&mut txs, "EUR", &db, &HashMap::new(), "in:gain", "ex:loss");
let posted = &txs[0].value.postings;
assert_eq!(posted.len(), 3);
let injected = &posted[2].value;
assert_eq!(injected.account, "in:gain");
let amt = injected.amount.as_ref().unwrap();
assert_eq!(amt.commodity, "EUR");
assert_eq!(amt.value, Decimal::from(-2));
}
#[test]
fn loss_when_implied_rate_below_market() {
let src = "\
P 2024-06-15 USD EUR 0.9\n\
2024-06-15 * x\n\
\tassets:usd -100 USD\n\
\tassets:eur 88 EUR\n";
let (mut txs, db) = build(src);
realize(&mut txs, "EUR", &db, &HashMap::new(), "in:gain", "ex:loss");
let injected = &txs[0].value.postings[2].value;
assert_eq!(injected.account, "ex:loss");
assert_eq!(injected.amount.as_ref().unwrap().value, Decimal::from(2));
}
#[test]
fn single_commodity_skipped() {
let src = "\
2024-06-15 * x\n\
\texpenses:food -5 EUR\n\
\tassets:cash 5 EUR\n";
let (mut txs, db) = build(src);
realize(&mut txs, "EUR", &db, &HashMap::new(), "in:gain", "ex:loss");
assert_eq!(txs[0].value.postings.len(), 2);
}
#[test]
fn missing_rate_skipped() {
let src = "\
2024-06-15 * x\n\
\tassets:usd -100 USD\n\
\tassets:eur 92 EUR\n";
let (mut txs, db) = build(src);
realize(&mut txs, "EUR", &db, &HashMap::new(), "in:gain", "ex:loss");
assert_eq!(txs[0].value.postings.len(), 2);
}
#[test]
fn delta_below_precision_skipped() {
let src = "\
P 2024-06-15 USD EUR 0.91999\n\
2024-06-15 * x\n\
\tassets:usd -100 USD\n\
\tassets:eur 92 EUR\n";
let (mut txs, db) = build(src);
let mut precs = HashMap::new();
precs.insert("EUR".to_string(), 2);
realize(&mut txs, "EUR", &db, &precs, "in:gain", "ex:loss");
assert_eq!(txs[0].value.postings.len(), 2);
}
}