use std::collections::{HashMap, VecDeque};
use crate::date::Date;
use crate::decimal::Decimal;
use crate::indexer::Index;
use crate::parser::located::Located;
use crate::parser::posting::{Amount, Costs, LotCost, Posting};
use crate::parser::transaction::Transaction;
struct Lot {
qty: Decimal,
cost_per_unit: Decimal,
cost_commodity: String,
market_at_buy: Option<Decimal>,
date: Date,
}
struct ClosedLot {
qty: Decimal,
cost_per_unit: Decimal,
cost_commodity: String,
date: Date,
}
struct Disposal {
tx_idx: usize,
posting_idx: usize,
commodity: String,
decimals: usize,
is_virtual: bool,
balanced: bool,
proceeds_per_unit: Decimal,
gain: Decimal,
gain_commodity: String,
market: Option<Decimal>,
acquisition: bool,
lots: Vec<ClosedLot>,
}
pub struct CapitalAccounts<'a> {
pub capital_gain: &'a str,
pub capital_loss: &'a str,
pub fx_gain: Option<&'a str>,
pub fx_loss: Option<&'a str>,
}
pub fn realize_capital(
txs: &mut Vec<Located<Transaction>>,
accounts: &CapitalAccounts,
target: Option<&str>,
db: &Index,
precisions: &HashMap<String, usize>,
) {
let mut lots: HashMap<(String, String), VecDeque<Lot>> = HashMap::new();
let mut disposals: Vec<Disposal> = Vec::new();
let mut pin_rate: Vec<(usize, usize, Decimal, String)> = Vec::new();
for (idx, lt) in txs.iter().enumerate() {
let mut sums: HashMap<String, Decimal> = HashMap::new();
for lp in <.value.postings {
if !contributes(&lp.value) {
continue;
}
if let Some(a) = &lp.value.amount {
*sums.entry(a.commodity.clone()).or_insert(Decimal::zero()) += a.value;
}
}
if sums.len() < 2 {
continue;
}
let date = lt.value.date.to_string();
for (p_idx, lp) in lt.value.postings.iter().enumerate() {
if !contributes(&lp.value) {
continue;
}
let Some(a) = &lp.value.amount else { continue };
if let Some(t) = target {
if a.commodity == t {
continue;
}
}
let Some((unit_value, value_commodity)) = posting_value(&lp.value, &sums)
else {
continue;
};
if target == Some(value_commodity.as_str())
&& lp.value.costs.is_none()
&& lp.value.lot_cost.is_none()
{
pin_rate.push((idx, p_idx, unit_value, value_commodity.clone()));
}
let key = (lp.value.account.clone(), a.commodity.clone());
let manual = lp.value.lot_cost.is_some();
let queue = lots.entry(key.clone()).or_default();
let (value_commodity, unit_value) = match queue.front() {
Some(front)
if front.cost_commodity != value_commodity
&& target == Some(value_commodity.as_str()) =>
{
match db.find(&front.cost_commodity, &value_commodity, &date) {
Some(rate) if !rate.is_zero() => {
(front.cost_commodity.clone(), unit_value.div_rounded(rate))
}
_ => (value_commodity, unit_value),
}
}
_ => (value_commodity, unit_value),
};
let market_here =
target.and_then(|t| market_rate(&a.commodity, &value_commodity, t, db, &date));
let mut remaining = a.value; let mut gain = Decimal::zero();
let mut market = Decimal::zero();
let mut market_ok = true;
let mut closed: Vec<ClosedLot> = Vec::new();
let mut mixed = false;
while !remaining.is_zero() {
let Some(front) = queue.front() else { break };
if front.qty.is_negative() == remaining.is_negative() {
break;
}
let short = front.qty.is_negative();
let take = remaining.abs().min(front.qty.abs());
let front = queue.front_mut().unwrap();
if front.cost_commodity == value_commodity {
let per = unit_value - front.cost_per_unit;
let per = if short { Decimal::zero() - per } else { per };
gain = gain + take.mul_rounded(per);
match (market_here, front.market_at_buy) {
(Some(ms), Some(mb)) => {
let m = ms - mb;
let m = if short { Decimal::zero() - m } else { m };
market = market + take.mul_rounded(m);
}
_ => market_ok = false,
}
closed.push(ClosedLot {
qty: take,
cost_per_unit: front.cost_per_unit,
cost_commodity: front.cost_commodity.clone(),
date: front.date,
});
} else {
mixed = true;
}
front.qty = if short { front.qty + take } else { front.qty - take };
remaining = if remaining.is_negative() {
remaining + take
} else {
remaining - take
};
if front.qty.is_zero() {
queue.pop_front();
}
}
if manual {
continue;
}
if !remaining.is_zero()
&& (!remaining.is_negative() || target == Some(value_commodity.as_str()))
{
queue.push_back(Lot {
qty: remaining,
cost_per_unit: unit_value,
cost_commodity: value_commodity.clone(),
market_at_buy: market_here,
date: lt.value.date,
});
}
if mixed || closed.is_empty() {
continue;
}
let prec = precisions.get(&value_commodity).copied().unwrap_or(2);
if gain.is_display_zero(prec) {
continue;
}
let market = target.map(|_| if market_ok { market } else { gain });
disposals.push(Disposal {
tx_idx: idx,
posting_idx: p_idx,
commodity: a.commodity.clone(),
decimals: a.decimals,
is_virtual: lp.value.is_virtual,
balanced: lp.value.balanced,
proceeds_per_unit: unit_value,
gain,
gain_commodity: value_commodity,
market,
acquisition: !a.value.is_negative(),
lots: closed,
});
}
}
for (tx_idx, p_idx, rate, commodity) in pin_rate {
let p = &mut txs[tx_idx].value.postings[p_idx].value;
if p.costs.is_none() && p.lot_cost.is_none() {
let prec = precisions.get(&commodity).copied().unwrap_or(2);
p.costs = Some(Costs::PerUnit(Amount {
commodity,
value: rate,
decimals: prec,
}));
}
}
let mut by_tx: HashMap<usize, Vec<Disposal>> = HashMap::new();
for d in disposals {
by_tx.entry(d.tx_idx).or_default().push(d);
}
for (tx_idx, disps) in by_tx {
rewrite_tx(&mut txs[tx_idx], &disps, accounts, precisions);
}
}
fn rewrite_tx(
lt: &mut Located<Transaction>,
disps: &[Disposal],
accounts: &CapitalAccounts,
precisions: &HashMap<String, usize>,
) {
let file = lt.file.clone();
let line = lt.line;
let mut rewritten: Vec<Located<Posting>> = Vec::new();
let mut capitals: Vec<Located<Posting>> = Vec::new();
for (p_idx, lp) in lt.value.postings.iter().enumerate() {
let Some(disp) = disps.iter().find(|d| d.posting_idx == p_idx) else {
rewritten.push(lp.clone());
continue;
};
let price_prec = precisions.get(&disp.gain_commodity).copied().unwrap_or(2);
let leg_costs = || {
lp.value.costs.clone().or_else(|| {
Some(Costs::PerUnit(Amount {
commodity: disp.gain_commodity.clone(),
value: disp.proceeds_per_unit,
decimals: price_prec,
}))
})
};
let signed = |qty: Decimal| {
if disp.acquisition {
qty
} else {
Decimal::zero() - qty
}
};
for (i, lot) in disp.lots.iter().enumerate() {
let cost_prec = precisions.get(&lot.cost_commodity).copied().unwrap_or(2);
rewritten.push(Located {
file: lp.file.clone(),
line: lp.line,
value: Posting {
account: lp.value.account.clone(),
amount: Some(Amount {
commodity: disp.commodity.clone(),
value: signed(lot.qty),
decimals: disp.decimals,
}),
costs: leg_costs(),
lot_cost: Some(LotCost {
amount: Amount {
commodity: lot.cost_commodity.clone(),
value: lot.cost_per_unit,
decimals: cost_prec,
},
total: false,
fixed: false,
}),
lot_date: Some(lot.date),
balance_assertion: None,
is_virtual: disp.is_virtual,
balanced: disp.balanced,
comments: if i == 0 {
lp.value.comments.clone()
} else {
Vec::new()
},
},
});
}
let covered = disp.lots.iter().fold(Decimal::zero(), |acc, l| acc + l.qty);
let total = lp
.value
.amount
.as_ref()
.map(|a| a.value.abs())
.unwrap_or_else(Decimal::zero);
let uncovered = total - covered;
if uncovered > Decimal::zero() {
rewritten.push(Located {
file: lp.file.clone(),
line: lp.line,
value: Posting {
account: lp.value.account.clone(),
amount: Some(Amount {
commodity: disp.commodity.clone(),
value: signed(uncovered),
decimals: disp.decimals,
}),
costs: leg_costs(),
lot_cost: None,
lot_date: None,
balance_assertion: None,
is_virtual: disp.is_virtual,
balanced: disp.balanced,
comments: Vec::new(),
},
});
}
let mut parts: Vec<(Decimal, &str, &str)> = Vec::new();
match disp.market {
Some(market) => {
parts.push((market, accounts.capital_gain, accounts.capital_loss));
let spread = disp.gain - market;
match (accounts.fx_gain, accounts.fx_loss) {
(Some(fg), Some(fl)) => parts.push((spread, fg, fl)),
_ => parts.push((spread, accounts.capital_gain, accounts.capital_loss)),
}
}
None => parts.push((disp.gain, accounts.capital_gain, accounts.capital_loss)),
}
for (value, gain_acct, loss_acct) in parts {
if value.is_display_zero(price_prec) {
continue;
}
let account = if value.is_negative() {
loss_acct
} else {
gain_acct
};
capitals.push(Located {
file: file.clone(),
line,
value: Posting {
account: account.to_string(),
amount: Some(Amount {
commodity: disp.gain_commodity.clone(),
value: -value,
decimals: price_prec,
}),
costs: None,
lot_cost: None,
lot_date: None,
balance_assertion: None,
is_virtual: false,
balanced: true,
comments: Vec::new(),
},
});
}
}
rewritten.extend(capitals);
lt.value.postings = rewritten;
}
fn contributes(p: &Posting) -> bool {
!p.is_virtual || p.balanced
}
fn posting_value(
p: &Posting,
sums: &HashMap<String, Decimal>,
) -> Option<(Decimal, String)> {
let a = p.amount.as_ref()?;
if a.value.is_zero() {
return None;
}
if let Some(costs) = &p.costs {
return Some(match costs {
Costs::PerUnit(c) => (c.value, c.commodity.clone()),
Costs::Total(c) => (c.value.div_rounded(a.value.abs()), c.commodity.clone()),
});
}
if sums.len() != 2 {
return None;
}
let other = sums.keys().find(|k| k.as_str() != a.commodity)?;
let this_sum = sums.get(&a.commodity)?.abs();
if this_sum.is_zero() {
return None;
}
let other_sum = sums.get(other)?.abs();
Some((other_sum.div_rounded(this_sum), other.clone()))
}
fn market_rate(
commodity: &str,
counter: &str,
target: &str,
db: &Index,
date: &str,
) -> Option<Decimal> {
let c = db.find(commodity, target, date)?;
let g = db.find(counter, target, date)?;
if g.is_zero() {
return None;
}
Some(c.div_rounded(g))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::resolver;
fn setup(src: &str) -> (Vec<Located<Transaction>>, Index, HashMap<String, 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();
let mut precisions: HashMap<String, usize> = HashMap::new();
for lt in &txs {
for lp in <.value.postings {
if let Some(a) = &lp.value.amount {
let e = precisions.entry(a.commodity.clone()).or_insert(0);
if a.decimals > *e {
*e = a.decimals;
}
}
}
}
(txs, prices, precisions)
}
fn caps() -> CapitalAccounts<'static> {
CapitalAccounts {
capital_gain: "income:capital",
capital_loss: "expenses:capital",
fx_gain: None,
fx_loss: None,
}
}
fn gain_on(txs: &[Located<Transaction>], account: &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 {
sum = sum + a.value;
}
}
}
}
sum
}
fn any_capital(txs: &[Located<Transaction>]) -> bool {
txs.iter().any(|lt| {
lt.value
.postings
.iter()
.any(|lp| lp.value.account.contains("capital"))
})
}
fn split_legs<'a>(
txs: &'a [Located<Transaction>],
commodity: &str,
) -> Vec<&'a Posting> {
txs.iter()
.flat_map(|lt| lt.value.postings.iter())
.map(|lp| &lp.value)
.filter(|p| {
p.lot_cost.is_some()
&& p.amount
.as_ref()
.map(|a| a.commodity == commodity)
.unwrap_or(false)
})
.collect()
}
#[test]
fn native_total_gain_without_x() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 30000 USD\n\
\tassets:cash -30000 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC @ 50000 USD\n\
\tassets:cash 50000 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-20000").unwrap());
}
#[test]
fn market_spread_split_with_x() {
let src = "\
P 2024-01-01 BTC USD 30000\n\
P 2024-06-01 BTC USD 48000\n\
2024-01-01 buy\n\
\tassets:btc 1 BTC\n\
\tassets:cash -29000 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC\n\
\tassets:cash 51000 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(
&mut txs,
&CapitalAccounts {
capital_gain: "income:capital",
capital_loss: "expenses:capital",
fx_gain: Some("income:fx"),
fx_loss: Some("expenses:fx"),
},
Some("USD"),
&db,
&prec,
);
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-18000").unwrap());
assert_eq!(gain_on(&txs, "income:fx"), Decimal::parse("-4000").unwrap());
}
#[test]
fn fifo_partial_sale() {
let src = "\
2024-01-01 buy a\n\
\tassets:btc 1 BTC @ 30000 USD\n\
\tassets:cash -30000 USD\n\
2024-02-01 buy b\n\
\tassets:btc 1 BTC @ 40000 USD\n\
\tassets:cash -40000 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC @ 50000 USD\n\
\tassets:cash 50000 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-20000").unwrap());
}
#[test]
fn loss_routes_to_loss_account() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 50000 USD\n\
\tassets:cash -50000 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC @ 30000 USD\n\
\tassets:cash 30000 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert_eq!(gain_on(&txs, "expenses:capital"), Decimal::parse("20000").unwrap());
assert_eq!(gain_on(&txs, "income:capital"), Decimal::zero());
}
#[test]
fn manual_lot_cost_disposal_is_left_alone() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 100 USD\n\
\tassets:cash -100 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC {100 USD} @ 150 USD\n\
\tassets:cash 150 USD\n\
\tincome:trade -50 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert!(!any_capital(&txs));
}
#[test]
fn no_gain_no_transaction() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC\n\
\tassets:cash -30000 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC\n\
\tassets:cash 30000 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert!(!any_capital(&txs));
}
#[test]
fn disposal_leg_gets_lot_annotation_and_gain() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 100 USD\n\
\tassets:cash -100 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC @ 150 USD\n\
\tassets:cash 150 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
let legs = split_legs(&txs, "BTC");
assert_eq!(legs.len(), 1);
assert!(legs[0].lot_cost.is_some());
assert!(legs[0].lot_date.is_some());
let cap = txs
.iter()
.flat_map(|lt| lt.value.postings.iter())
.find(|lp| lp.value.account == "income:capital")
.unwrap();
assert!(!cap.value.is_virtual);
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-50").unwrap());
}
#[test]
fn disposal_across_two_lots_splits_into_two_legs() {
let src = "\
2024-01-01 buy a\n\
\tassets:btc 4 BTC @ 100 USD\n\
\tassets:cash -400 USD\n\
2024-02-01 buy b\n\
\tassets:btc 6 BTC @ 110 USD\n\
\tassets:cash -660 USD\n\
2024-06-01 sell\n\
\tassets:btc -10 BTC @ 120 USD\n\
\tassets:cash 1200 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
let legs = split_legs(&txs, "BTC");
assert_eq!(legs.len(), 2);
assert!(legs.iter().all(|p| p.lot_date.is_some()));
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-140").unwrap());
}
#[test]
fn lot_annotation_carries_cost_date_and_price() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 100 USD\n\
\tassets:cash -100 USD\n\
2024-06-01 sell\n\
\tassets:btc -1 BTC @ 150 USD\n\
\tassets:cash 150 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
let legs = split_legs(&txs, "BTC");
assert_eq!(legs.len(), 1);
let leg = legs[0];
assert_eq!(
leg.lot_cost.as_ref().unwrap().amount.value,
Decimal::parse("100").unwrap()
);
assert_eq!(leg.lot_date.unwrap().to_string(), "2024-01-01");
match leg.costs.as_ref().unwrap() {
Costs::PerUnit(a) => {
assert_eq!(a.value, Decimal::parse("150").unwrap())
}
_ => panic!("expected per-unit @ cost"),
}
}
#[test]
fn oversell_preserves_uncovered_remainder() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 100 USD\n\
\tassets:cash -100 USD\n\
2024-06-01 sell\n\
\tassets:btc -2 BTC @ 200 USD\n\
\tassets:cash 400 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
let disposed = txs
.iter()
.flat_map(|lt| lt.value.postings.iter())
.filter(|lp| lp.value.account == "assets:btc")
.filter_map(|lp| lp.value.amount.as_ref())
.filter(|a| a.value < Decimal::zero())
.fold(Decimal::zero(), |acc, a| acc + a.value);
assert_eq!(disposed, Decimal::parse("-2").unwrap());
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-100").unwrap());
assert_eq!(split_legs(&txs, "BTC").len(), 1);
}
#[test]
fn open_position_never_realizes() {
let src = "\
2024-01-01 buy\n\
\tassets:btc 1 BTC\n\
\tassets:cash -30000 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert!(!any_capital(&txs));
}
#[test]
fn short_then_cover_realizes_gain_with_x() {
let src = "\
2024-01-01 spend\n\
\tassets:usd -100 USD\n\
\texpenses:dev 105 EUR\n\
2024-06-01 cover\n\
\tassets:bank -103 EUR\n\
\tassets:usd 100 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), Some("EUR"), &db, &prec);
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-2").unwrap());
}
#[test]
fn short_then_long_sequence_realizes_both() {
let src = "\
2024-01-01 spend1\n\
\tassets:usd -100 USD\n\
\texpenses:dev 105 EUR\n\
2024-06-01 buy1\n\
\tassets:bank -103 EUR\n\
\tassets:usd 100 USD\n\
2024-09-01 buy2\n\
\tassets:bank -106 EUR\n\
\tassets:usd 100 USD\n\
2024-12-01 spend2\n\
\tassets:usd -100 USD\n\
\texpenses:dev 108 EUR\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), Some("EUR"), &db, &prec);
assert_eq!(gain_on(&txs, "income:capital"), Decimal::parse("-4").unwrap());
}
#[test]
fn oversell_against_non_target_opens_no_short() {
let src = "\
P 2024-01-01 BTC EUR 90\n\
P 2024-06-01 BTC EUR 180\n\
2024-01-01 buy\n\
\tassets:btc 1 BTC @ 100 USD\n\
\tassets:cash -100 USD\n\
2024-06-01 sell\n\
\tassets:btc -2 BTC @ 200 USD\n\
\tassets:cash 400 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), Some("EUR"), &db, &prec);
assert_eq!(split_legs(&txs, "BTC").len(), 1);
}
#[test]
fn zero_quantity_leg_does_not_divide_by_zero() {
let src = "\
2024-06-01 degenerate\n\
\tassets:btc 0 BTC @@ 50 USD\n\
\tassets:cash -50 USD\n";
let (mut txs, db, prec) = setup(src);
realize_capital(&mut txs, &caps(), None, &db, &prec);
assert!(!any_capital(&txs));
}
}