use std::collections::HashMap;
use crate::decimal::Decimal;
use crate::indexer::Index;
use crate::parser::located::Located;
use crate::parser::posting::{Costs, Posting};
use crate::parser::transaction::Transaction;
pub fn rebalance(transactions: &mut [Located<Transaction>], target: &str, db: &Index) {
for lt in transactions {
let lookup_date: String = lt.value.date.to_string();
for lp in &mut lt.value.postings {
convert(&mut lp.value, target, db, &lookup_date);
}
}
}
pub fn round_for_print(
transactions: &mut [Located<Transaction>],
target: &str,
precisions: &HashMap<String, usize>,
) {
let prec = precisions.get(target).copied().unwrap_or(2);
let unit = display_unit(prec);
for lt in transactions {
for lp in &mut lt.value.postings {
if let Some(a) = lp.value.amount.as_mut()
&& a.commodity == target {
a.value = a.value.round(prec);
a.decimals = prec;
}
}
settle_round_off(&mut lt.value, target, unit);
}
}
fn display_unit(precision: usize) -> Decimal {
let ten = Decimal::from(10);
let mut unit = Decimal::from(1);
for _ in 0..precision {
unit = unit.div_rounded(ten);
}
unit
}
fn settle_round_off(tx: &mut Transaction, target: &str, unit: Decimal) {
let mut sum = Decimal::zero();
let mut count: i64 = 0;
let mut largest: Option<usize> = None;
let mut largest_abs = Decimal::zero();
for (i, lp) in tx.postings.iter().enumerate() {
if lp.value.is_virtual && !lp.value.balanced {
continue;
}
let Some(a) = &lp.value.amount else { continue };
if a.commodity != target {
return;
}
sum += a.value;
count += 1;
if a.value.abs() > largest_abs {
largest_abs = a.value.abs();
largest = Some(i);
}
}
if sum.is_zero() {
return;
}
let max_residual = unit.mul_rounded(Decimal::from(count));
if sum.abs() > max_residual {
return;
}
if let Some(i) = largest {
let amount = tx.postings[i].value.amount.as_mut().unwrap();
amount.value -= sum;
}
}
pub fn target_value(p: &Posting, target: &str, db: &Index, date: &str) -> Option<Decimal> {
let amount = p.amount.as_ref()?;
let (value, from) = if let Some(lot) = &p.lot_cost {
(lot.weight(amount.value), lot.amount.commodity.as_str())
} else if let Some(costs) = &p.costs {
match costs {
Costs::PerUnit(c) => (amount.value.mul_rounded(c.value), c.commodity.as_str()),
Costs::Total(c) if amount.value.is_negative() => (-c.value, c.commodity.as_str()),
Costs::Total(c) => (c.value, c.commodity.as_str()),
}
} else {
(amount.value, amount.commodity.as_str())
};
if from == target {
return Some(value);
}
db.find(from, target, date).map(|rate| value.mul_rounded(rate))
}
fn convert(p: &mut Posting, target: &str, db: &Index, date: &str) {
let Some(converted) = target_value(p, target, db, date) else {
return;
};
let amount = p.amount.as_mut().unwrap();
amount.value = converted;
amount.commodity = target.to_string();
p.lot_cost = None;
p.costs = None;
p.lot_date = None;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::posting::Posting;
use crate::{booker, indexer, parser, resolver};
fn setup(src: &str) -> (Vec<Located<Transaction>>, Index) {
let entries = parser::parse(src).unwrap();
let resolved = resolver::resolve(entries).unwrap();
let txs = booker::book(resolved.transactions).unwrap();
let db = indexer::index(resolved.prices);
(txs, db)
}
fn leg<'a>(txs: &'a [Located<Transaction>], prefix: &str) -> &'a Posting {
txs.iter()
.flat_map(|t| t.value.postings.iter())
.map(|lp| &lp.value)
.find(|p| p.account.starts_with(prefix))
.expect("posting not found")
}
fn dec(s: &str) -> Decimal {
Decimal::parse(s).unwrap()
}
#[test]
fn target_value_plain_amount_converts_at_txdate_rate() {
let (txs, db) = setup(
"P 2024-06-01 USD EUR 0.9\n\
2024-06-01 * x\n\
\tassets:usd 100 USD\n\
\tequity:open -100 USD\n",
);
let v = target_value(leg(&txs, "assets:usd"), "EUR", &db, "2024-06-01");
assert_eq!(v, Some(dec("90"))); }
#[test]
fn target_value_lot_cost_weights_by_cost_not_market() {
let (txs, db) = setup(
"P 2024-06-01 ASSET EUR 200\n\
2024-06-01 * sell\n\
\tassets:broker -10 ASSET {95 EUR}\n\
\tassets:cash 950 EUR\n",
);
let v = target_value(leg(&txs, "assets:broker"), "EUR", &db, "2024-06-01");
assert_eq!(v, Some(dec("-950")));
}
#[test]
fn target_value_per_unit_price_weight() {
let (txs, db) = setup(
"2024-06-01 * sell\n\
\tassets:broker -10 ASSET @ 95 EUR\n\
\tassets:cash 950 EUR\n",
);
let v = target_value(leg(&txs, "assets:broker"), "EUR", &db, "2024-06-01");
assert_eq!(v, Some(dec("-950")));
}
#[test]
fn target_value_total_cost_carries_amount_sign() {
let (txs, db) = setup(
"2024-06-01 * sell\n\
\tassets:broker -10 ASSET @@ 950 EUR\n\
\tassets:cash 950 EUR\n",
);
let v = target_value(leg(&txs, "assets:broker"), "EUR", &db, "2024-06-01");
assert_eq!(v, Some(dec("-950")));
}
#[test]
fn target_value_same_commodity_is_identity() {
let (txs, db) = setup(
"2024-06-01 * x\n\
\tassets:eur 100 EUR\n\
\tequity:open -100 EUR\n",
);
let v = target_value(leg(&txs, "assets:eur"), "EUR", &db, "2024-06-01");
assert_eq!(v, Some(dec("100"))); }
#[test]
fn target_value_missing_rate_is_none() {
let (txs, db) = setup(
"2024-06-01 * x\n\
\tassets:usd 100 USD\n\
\tequity:open -100 USD\n",
);
assert_eq!(target_value(leg(&txs, "assets:usd"), "EUR", &db, "2024-06-01"), None);
}
fn eur_leg<'a>(tx: &'a Transaction, prefix: &str) -> &'a Posting {
tx.postings
.iter()
.map(|lp| &lp.value)
.find(|p| p.account.starts_with(prefix))
.unwrap()
}
#[test]
fn round_for_print_rounds_to_display_precision() {
let (mut txs, db) = setup(
"P 2024-06-01 USD EUR 0.93331\n\
2024-06-01 * x\n\
\tassets:usd 100 USD\n\
\tequity:open -100 USD\n",
);
rebalance(&mut txs, "EUR", &db);
let prec = HashMap::from([("EUR".to_string(), 2usize)]);
round_for_print(&mut txs, "EUR", &prec);
let v = eur_leg(&txs[0].value, "assets:usd").amount.as_ref().unwrap();
assert_eq!(v.value, dec("93.33"));
}
#[test]
fn round_for_print_absorbs_residual_into_largest_leg() {
let (mut txs, db) = setup(
"P 2024-06-01 USD EUR 0.33335\n\
2024-06-01 * split\n\
\texpenses:a 10 USD\n\
\texpenses:b 10 USD\n\
\tassets:cash -20 USD\n",
);
rebalance(&mut txs, "EUR", &db);
let prec = HashMap::from([("EUR".to_string(), 2usize)]);
round_for_print(&mut txs, "EUR", &prec);
let sum: Decimal = txs[0]
.value
.postings
.iter()
.filter_map(|lp| lp.value.amount.as_ref())
.fold(Decimal::zero(), |acc, a| acc + a.value);
assert!(sum.is_zero(), "rounded legs must still sum to zero, got {:?}", sum);
}
#[test]
fn round_for_print_leaves_unconverted_leg_alone() {
let (mut txs, db) = setup(
"P 2024-06-01 USD EUR 0.9\n\
2024-06-01 * mixed\n\
\tassets:usd 100 USD\n\
\tassets:gbp -100 GBP\n",
);
rebalance(&mut txs, "EUR", &db);
let prec = HashMap::from([("EUR".to_string(), 2usize)]);
round_for_print(&mut txs, "EUR", &prec);
let usd = eur_leg(&txs[0].value, "assets:usd").amount.as_ref().unwrap();
assert_eq!(usd.value, dec("90"));
let gbp = eur_leg(&txs[0].value, "assets:gbp").amount.as_ref().unwrap();
assert_eq!(gbp.commodity, "GBP");
}
}