use rust_decimal::Decimal;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use crate::intern::InternedStr;
use crate::{Amount, CostSpec, Position};
mod booking;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub enum BookingMethod {
#[default]
Strict,
StrictWithSize,
Fifo,
Lifo,
Hifo,
Average,
None,
}
impl FromStr for BookingMethod {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"STRICT" => Ok(Self::Strict),
"STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
"FIFO" => Ok(Self::Fifo),
"LIFO" => Ok(Self::Lifo),
"HIFO" => Ok(Self::Hifo),
"AVERAGE" => Ok(Self::Average),
"NONE" => Ok(Self::None),
_ => Err(format!("unknown booking method: {s}")),
}
}
}
impl fmt::Display for BookingMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Strict => write!(f, "STRICT"),
Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
Self::Fifo => write!(f, "FIFO"),
Self::Lifo => write!(f, "LIFO"),
Self::Hifo => write!(f, "HIFO"),
Self::Average => write!(f, "AVERAGE"),
Self::None => write!(f, "NONE"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BookingResult {
pub matched: Vec<Position>,
pub cost_basis: Option<Amount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BookingError {
AmbiguousMatch {
num_matches: usize,
currency: InternedStr,
},
NoMatchingLot {
currency: InternedStr,
cost_spec: CostSpec,
},
InsufficientUnits {
currency: InternedStr,
requested: Decimal,
available: Decimal,
},
CurrencyMismatch {
expected: InternedStr,
got: InternedStr,
},
}
impl fmt::Display for BookingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AmbiguousMatch {
num_matches,
currency,
} => write!(
f,
"Ambiguous match: {num_matches} lots match for {currency}"
),
Self::NoMatchingLot {
currency,
cost_spec,
} => {
write!(f, "No matching lot for {currency} with cost {cost_spec}")
}
Self::InsufficientUnits {
currency,
requested,
available,
} => write!(
f,
"Insufficient units of {currency}: requested {requested}, available {available}"
),
Self::CurrencyMismatch { expected, got } => {
write!(f, "Currency mismatch: expected {expected}, got {got}")
}
}
}
}
impl std::error::Error for BookingError {}
impl BookingError {
#[must_use]
pub const fn with_account(self, account: InternedStr) -> AccountedBookingError {
AccountedBookingError {
error: self,
account,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountedBookingError {
pub error: BookingError,
pub account: InternedStr,
}
impl fmt::Display for AccountedBookingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.error {
BookingError::InsufficientUnits {
requested,
available,
..
} => write!(
f,
"Not enough units in {}: requested {}, available {}; not enough to reduce",
self.account, requested, available
),
BookingError::NoMatchingLot { currency, .. } => {
write!(f, "No matching lot for {} in {}", currency, self.account)
}
BookingError::AmbiguousMatch {
num_matches,
currency,
} => write!(
f,
"Ambiguous lot match for {}: {} lots match in {}",
currency, num_matches, self.account
),
BookingError::CurrencyMismatch { got, .. } => {
write!(f, "No matching lot for {} in {}", got, self.account)
}
}
}
}
impl std::error::Error for AccountedBookingError {}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Inventory {
positions: Vec<Position>,
#[serde(skip)]
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
simple_index: FxHashMap<InternedStr, usize>,
#[serde(skip)]
#[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
units_cache: FxHashMap<InternedStr, Decimal>,
}
impl PartialEq for Inventory {
fn eq(&self, other: &Self) -> bool {
self.positions == other.positions
}
}
impl Eq for Inventory {}
impl Inventory {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn positions(&self) -> &[Position] {
&self.positions
}
pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
&mut self.positions
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.positions.is_empty()
|| self
.positions
.iter()
.all(super::position::Position::is_empty)
}
#[must_use]
pub const fn len(&self) -> usize {
self.positions.len()
}
#[must_use]
pub fn units(&self, currency: &str) -> Decimal {
self.units_cache.get(currency).copied().unwrap_or_else(|| {
self.positions
.iter()
.filter(|p| p.units.currency == currency)
.map(|p| p.units.number)
.sum()
})
}
#[must_use]
pub fn currencies(&self) -> Vec<&str> {
let mut currencies: Vec<&str> = self
.positions
.iter()
.filter(|p| !p.is_empty())
.map(|p| p.units.currency.as_str())
.collect();
currencies.sort_unstable();
currencies.dedup();
currencies
}
#[must_use]
pub fn is_reduced_by(&self, units: &Amount) -> bool {
self.positions.iter().any(|pos| {
pos.units.currency == units.currency
&& pos.units.number.is_sign_positive() != units.number.is_sign_positive()
})
}
#[must_use]
pub fn book_value(&self, units_currency: &str) -> FxHashMap<InternedStr, Decimal> {
let mut totals: FxHashMap<InternedStr, Decimal> = FxHashMap::default();
for pos in &self.positions {
if pos.units.currency == units_currency
&& let Some(book) = pos.book_value()
{
*totals.entry(book.currency.clone()).or_default() += book.number;
}
}
totals
}
pub fn add(&mut self, position: Position) {
if position.is_empty() {
return;
}
*self
.units_cache
.entry(position.units.currency.clone())
.or_default() += position.units.number;
if position.cost.is_none() {
if let Some(&idx) = self.simple_index.get(&position.units.currency) {
debug_assert!(self.positions[idx].cost.is_none());
self.positions[idx].units += &position.units;
return;
}
let idx = self.positions.len();
self.simple_index
.insert(position.units.currency.clone(), idx);
self.positions.push(position);
return;
}
self.positions.push(position);
}
pub fn reduce(
&mut self,
units: &Amount,
cost_spec: Option<&CostSpec>,
method: BookingMethod,
) -> Result<BookingResult, BookingError> {
let spec = cost_spec.cloned().unwrap_or_default();
if spec.merge {
return self.reduce_merge(units);
}
match method {
BookingMethod::Strict => self.reduce_strict(units, &spec),
BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
BookingMethod::Fifo => self.reduce_fifo(units, &spec),
BookingMethod::Lifo => self.reduce_lifo(units, &spec),
BookingMethod::Hifo => self.reduce_hifo(units, &spec),
BookingMethod::Average => self.reduce_average(units),
BookingMethod::None => self.reduce_none(units),
}
}
pub fn compact(&mut self) {
self.positions.retain(|p| !p.is_empty());
self.rebuild_index();
}
fn rebuild_index(&mut self) {
self.simple_index.clear();
self.units_cache.clear();
for (idx, pos) in self.positions.iter().enumerate() {
*self
.units_cache
.entry(pos.units.currency.clone())
.or_default() += pos.units.number;
if pos.cost.is_none() {
debug_assert!(
!self.simple_index.contains_key(&pos.units.currency),
"Invariant violated: multiple simple positions for currency {}",
pos.units.currency
);
self.simple_index.insert(pos.units.currency.clone(), idx);
}
}
}
pub fn merge(&mut self, other: &Self) {
for pos in &other.positions {
self.add(pos.clone());
}
}
#[must_use]
pub fn at_cost(&self) -> Self {
let mut result = Self::new();
for pos in &self.positions {
if pos.is_empty() {
continue;
}
if let Some(cost) = &pos.cost {
let total = pos.units.number * cost.number;
result.add(Position::simple(Amount::new(total, &cost.currency)));
} else {
result.add(pos.clone());
}
}
result
}
#[must_use]
pub fn at_units(&self) -> Self {
let mut result = Self::new();
for pos in &self.positions {
if pos.is_empty() {
continue;
}
result.add(Position::simple(pos.units.clone()));
}
result
}
}
impl fmt::Display for Inventory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_empty() {
return write!(f, "(empty)");
}
let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
non_empty.sort_by(|a, b| {
let cmp = a.units.currency.cmp(&b.units.currency);
if cmp != std::cmp::Ordering::Equal {
return cmp;
}
match (&a.cost, &b.cost) {
(Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
}
});
for (i, pos) in non_empty.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{pos}")?;
}
Ok(())
}
}
impl FromIterator<Position> for Inventory {
fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
let mut inv = Self::new();
for pos in iter {
inv.add(pos);
}
inv
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Cost;
use crate::NaiveDate;
use rust_decimal_macros::dec;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
crate::naive_date(year, month, day).unwrap()
}
#[test]
fn test_empty_inventory() {
let inv = Inventory::new();
assert!(inv.is_empty());
assert_eq!(inv.len(), 0);
}
#[test]
fn test_add_simple() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
assert!(!inv.is_empty());
assert_eq!(inv.units("USD"), dec!(100));
}
#[test]
fn test_add_merge_simple() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
inv.add(Position::simple(Amount::new(dec!(50), "USD")));
assert_eq!(inv.len(), 1);
assert_eq!(inv.units("USD"), dec!(150));
}
#[test]
fn test_add_with_cost_no_merge() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
assert_eq!(inv.len(), 2);
assert_eq!(inv.units("AAPL"), dec!(15));
}
#[test]
fn test_currencies() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
let currencies = inv.currencies();
assert_eq!(currencies.len(), 3);
assert!(currencies.contains(&"USD"));
assert!(currencies.contains(&"EUR"));
assert!(currencies.contains(&"AAPL"));
}
#[test]
fn test_reduce_strict_unique() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(5));
assert!(result.cost_basis.is_some());
assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
#[test]
fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
assert!(
matches!(result, Err(BookingError::AmbiguousMatch { .. })),
"expected AmbiguousMatch, got {result:?}"
);
assert_eq!(inv.units("AAPL"), dec!(15));
}
#[test]
fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
let result = inv
.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
.expect("identical lots should fall back to FIFO without error");
assert_eq!(inv.units("AAPL"), dec!(12));
assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
}
#[test]
fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
.expect("same cost number, different dates should fall back to FIFO");
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
}
#[test]
fn test_reduce_strict_multiple_match_total_match_exception() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let result = inv
.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
.expect("total-match exception should accept a full liquidation");
assert_eq!(inv.units("AAPL"), dec!(0));
assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
}
#[test]
fn test_reduce_strict_with_spec() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let spec = CostSpec::empty().with_date(date(2024, 1, 1));
let result = inv
.reduce(
&Amount::new(dec!(-3), "AAPL"),
Some(&spec),
BookingMethod::Strict,
)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
#[test]
fn test_reduce_fifo() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
let result = inv
.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
}
#[test]
fn test_reduce_lifo() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
let result = inv
.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
}
#[test]
fn test_reduce_insufficient() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_book_value() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD");
let cost2 = Cost::new(dec!(150.00), "USD");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let book = inv.book_value("AAPL");
assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
#[test]
fn test_display() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
let s = format!("{inv}");
assert!(s.contains("100 USD"));
}
#[test]
fn test_display_empty() {
let inv = Inventory::new();
assert_eq!(format!("{inv}"), "(empty)");
}
#[test]
fn test_from_iterator() {
let positions = vec![
Position::simple(Amount::new(dec!(100), "USD")),
Position::simple(Amount::new(dec!(50), "USD")),
];
let inv: Inventory = positions.into_iter().collect();
assert_eq!(inv.units("USD"), dec!(150));
}
#[test]
fn test_add_costed_positions_kept_separate() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
));
assert_eq!(inv.len(), 1);
assert_eq!(inv.units("AAPL"), dec!(10));
inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
#[test]
fn test_add_costed_positions_net_units() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
));
inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
#[test]
fn test_add_no_cancel_different_cost() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
assert_eq!(inv.len(), 2);
assert_eq!(inv.units("AAPL"), dec!(5)); }
#[test]
fn test_add_no_cancel_same_sign() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
assert_eq!(inv.len(), 2);
assert_eq!(inv.units("AAPL"), dec!(15));
}
#[test]
fn test_merge_keeps_lots_separate() {
let mut inv1 = Inventory::new();
let mut inv2 = Inventory::new();
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
inv1.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
));
inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
inv1.merge(&inv2);
assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
#[test]
fn test_hifo_with_tie_breaking() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
let result = inv
.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
}
#[test]
fn test_hifo_with_different_costs() {
let mut inv = Inventory::new();
let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost_high,
));
let result = inv
.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
}
#[test]
fn test_average_booking_with_pre_existing_positions() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
}
#[test]
fn test_average_booking_reduces_all() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv
.reduce(
&Amount::new(dec!(-10), "AAPL"),
None,
BookingMethod::Average,
)
.unwrap();
assert!(inv.is_empty() || inv.units("AAPL").is_zero());
assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
}
#[test]
fn test_none_booking_augmentation() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
let result = inv
.reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
.unwrap();
assert_eq!(inv.units("USD"), dec!(150));
assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
}
#[test]
fn test_none_booking_reduction() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
let result = inv
.reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
.unwrap();
assert_eq!(inv.units("USD"), dec!(70));
assert!(!result.matched.is_empty());
}
#[test]
fn test_none_booking_insufficient() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_booking_error_no_matching_lot() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
let result = inv.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&wrong_spec),
BookingMethod::Strict,
);
assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
}
#[test]
fn test_booking_error_insufficient_units() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
match result {
Err(BookingError::InsufficientUnits {
requested,
available,
..
}) => {
assert_eq!(requested, dec!(20));
assert_eq!(available, dec!(10));
}
_ => panic!("Expected InsufficientUnits error"),
}
}
#[test]
fn test_strict_with_size_exact_match() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let result = inv
.reduce(
&Amount::new(dec!(-5), "AAPL"),
None,
BookingMethod::StrictWithSize,
)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(10));
assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
}
#[test]
fn test_strict_with_size_total_match() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let result = inv
.reduce(
&Amount::new(dec!(-15), "AAPL"),
None,
BookingMethod::StrictWithSize,
)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(0));
assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
}
#[test]
fn test_strict_with_size_ambiguous() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let result = inv.reduce(
&Amount::new(dec!(-7), "AAPL"),
None,
BookingMethod::StrictWithSize,
);
assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
}
#[test]
fn test_short_position() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
assert_eq!(inv.units("AAPL"), dec!(-10));
assert!(!inv.is_empty());
}
#[test]
fn test_at_cost() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
let at_cost = inv.at_cost();
assert_eq!(at_cost.units("USD"), dec!(1850));
assert_eq!(at_cost.units("AAPL"), dec!(0)); }
#[test]
fn test_at_units() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let at_units = inv.at_units();
assert_eq!(at_units.units("AAPL"), dec!(15));
assert_eq!(at_units.len(), 1);
}
#[test]
fn test_add_empty_position() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(0), "USD")));
assert!(inv.is_empty());
assert_eq!(inv.len(), 0);
}
#[test]
fn test_compact() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
.unwrap();
inv.compact();
assert!(inv.is_empty());
assert_eq!(inv.len(), 0);
}
#[test]
fn test_booking_method_from_str() {
assert_eq!(
BookingMethod::from_str("STRICT").unwrap(),
BookingMethod::Strict
);
assert_eq!(
BookingMethod::from_str("fifo").unwrap(),
BookingMethod::Fifo
);
assert_eq!(
BookingMethod::from_str("LIFO").unwrap(),
BookingMethod::Lifo
);
assert_eq!(
BookingMethod::from_str("Hifo").unwrap(),
BookingMethod::Hifo
);
assert_eq!(
BookingMethod::from_str("AVERAGE").unwrap(),
BookingMethod::Average
);
assert_eq!(
BookingMethod::from_str("NONE").unwrap(),
BookingMethod::None
);
assert_eq!(
BookingMethod::from_str("strict_with_size").unwrap(),
BookingMethod::StrictWithSize
);
assert!(BookingMethod::from_str("INVALID").is_err());
}
#[test]
fn test_booking_method_display() {
assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
assert_eq!(format!("{}", BookingMethod::None), "NONE");
assert_eq!(
format!("{}", BookingMethod::StrictWithSize),
"STRICT_WITH_SIZE"
);
}
#[test]
fn test_booking_error_display() {
let err = BookingError::AmbiguousMatch {
num_matches: 3,
currency: "AAPL".into(),
};
assert!(format!("{err}").contains("3 lots match"));
let err = BookingError::NoMatchingLot {
currency: "AAPL".into(),
cost_spec: CostSpec::empty(),
};
assert!(format!("{err}").contains("No matching lot"));
let err = BookingError::InsufficientUnits {
currency: "AAPL".into(),
requested: dec!(100),
available: dec!(50),
};
assert!(format!("{err}").contains("requested 100"));
assert!(format!("{err}").contains("available 50"));
let err = BookingError::CurrencyMismatch {
expected: "USD".into(),
got: "EUR".into(),
};
assert!(format!("{err}").contains("expected USD"));
assert!(format!("{err}").contains("got EUR"));
}
#[test]
fn test_book_value_multiple_currencies() {
let mut inv = Inventory::new();
let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
let book = inv.book_value("AAPL");
assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
}
#[test]
fn test_reduce_hifo_insufficient_units() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_reduce_average_insufficient_units() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv.reduce(
&Amount::new(dec!(-20), "AAPL"),
None,
BookingMethod::Average,
);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_reduce_average_empty_inventory() {
let mut inv = Inventory::new();
let result = inv.reduce(
&Amount::new(dec!(-10), "AAPL"),
None,
BookingMethod::Average,
);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_reduce_merge_operator() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(160), "USD"),
));
let merge_spec = CostSpec::empty().with_merge();
let result = inv
.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
)
.expect("merge reduction should succeed");
assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
assert_eq!(inv.positions.len(), 1);
assert_eq!(inv.positions[0].units.number, dec!(15));
let cost = inv.positions[0].cost.as_ref().expect("should have cost");
assert_eq!(cost.number, dec!(155));
}
#[test]
fn test_reduce_merge_insufficient_units() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
));
let merge_spec = CostSpec::empty().with_merge();
let result = inv.reduce(
&Amount::new(dec!(-20), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_reduce_merge_sells_all() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(160), "USD"),
));
let merge_spec = CostSpec::empty().with_merge();
let result = inv
.reduce(
&Amount::new(dec!(-20), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
)
.expect("merge reduction should succeed");
assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
}
#[test]
fn test_reduce_merge_single_lot() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
));
let merge_spec = CostSpec::empty().with_merge();
let result = inv
.reduce(
&Amount::new(dec!(-3), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
)
.expect("single-lot merge should succeed");
assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
assert_eq!(inv.positions.len(), 1);
assert_eq!(inv.positions[0].units.number, dec!(7));
}
#[test]
fn test_reduce_merge_three_lots() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(100), "USD"),
));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(200), "USD"),
));
let merge_spec = CostSpec::empty().with_merge();
let result = inv
.reduce(
&Amount::new(dec!(-6), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
)
.expect("three-lot merge should succeed");
assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
assert_eq!(inv.positions.len(), 1);
assert_eq!(inv.positions[0].units.number, dec!(24));
let cost = inv.positions[0].cost.as_ref().expect("should have cost");
assert_eq!(cost.number, dec!(150));
}
#[test]
fn test_reduce_merge_mixed_cost_currencies_errors() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(130), "EUR"),
));
let merge_spec = CostSpec::empty().with_merge();
let result = inv.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
);
assert!(
matches!(result, Err(BookingError::CurrencyMismatch { .. })),
"expected CurrencyMismatch, got {result:?}"
);
}
#[test]
fn test_reduce_merge_empty_inventory() {
let mut inv = Inventory::new();
let merge_spec = CostSpec::empty().with_merge();
let result = inv.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&merge_spec),
BookingMethod::Strict,
);
assert!(matches!(
result,
Err(BookingError::InsufficientUnits { .. })
));
}
#[test]
fn test_inventory_display_sorted() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
let display = format!("{inv}");
let aapl_pos = display.find("AAPL").unwrap();
let eur_pos = display.find("EUR").unwrap();
let usd_pos = display.find("USD").unwrap();
assert!(aapl_pos < eur_pos);
assert!(eur_pos < usd_pos);
}
#[test]
fn test_inventory_with_cost_display_sorted() {
let mut inv = Inventory::new();
let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost_high,
));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
let display = format!("{inv}");
assert!(display.contains("AAPL"));
assert!(display.contains("100"));
assert!(display.contains("200"));
}
#[test]
fn test_reduce_hifo_no_matching_lot() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")));
let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
}
#[test]
fn test_fifo_respects_dates() {
let mut inv = Inventory::new();
let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
}
#[test]
fn test_lifo_respects_dates() {
let mut inv = Inventory::new();
let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
}
#[test]
fn test_strict_with_size_different_costs_exact_match() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
let result = inv
.reduce(
&Amount::new(dec!(-7), "AAPL"),
None,
BookingMethod::StrictWithSize,
)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(10));
assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); }
#[test]
fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
let result = inv
.reduce(
&Amount::new(dec!(-5), "AAPL"),
None,
BookingMethod::StrictWithSize,
)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(5));
assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
}
#[test]
fn test_strict_with_size_with_cost_spec() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let spec = CostSpec::empty().with_number_per(dec!(200.00));
let result = inv
.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&spec),
BookingMethod::StrictWithSize,
)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(15));
assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
#[test]
fn test_hifo_reduces_highest_cost_first() {
let mut inv = Inventory::new();
let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost_high,
));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
}
#[test]
fn test_hifo_spans_multiple_lots() {
let mut inv = Inventory::new();
let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
let result = inv
.reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
assert_eq!(inv.units("AAPL"), dec!(2));
}
#[test]
fn test_hifo_with_cost_spec_filter() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let spec = CostSpec::empty().with_currency("USD");
let result = inv
.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&spec),
BookingMethod::Hifo,
)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
#[test]
fn test_hifo_short_position() {
let mut inv = Inventory::new();
let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_low,
));
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_high,
));
let result = inv
.reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
}
#[test]
fn test_average_weighted_cost() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
assert_eq!(inv.units("AAPL"), dec!(15));
}
#[test]
fn test_average_merges_into_single_position() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
.unwrap();
let aapl_positions: Vec<_> = inv
.positions
.iter()
.filter(|p| p.units.currency.as_ref() == "AAPL")
.collect();
assert_eq!(aapl_positions.len(), 1);
assert_eq!(aapl_positions[0].units.number, dec!(15));
}
#[test]
fn test_average_uneven_lots() {
let mut inv = Inventory::new();
let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
let result = inv
.reduce(
&Amount::new(dec!(-10), "AAPL"),
None,
BookingMethod::Average,
)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
#[test]
fn test_none_booking_with_cost_positions() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
let result = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
.unwrap();
assert_eq!(inv.units("AAPL"), dec!(5));
assert!(result.cost_basis.is_some());
assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
}
#[test]
fn test_none_booking_short_cover() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
let result = inv
.reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
.unwrap();
assert_eq!(inv.units("USD"), dec!(-70));
assert!(!result.matched.is_empty());
}
#[test]
fn test_none_booking_empty_inventory_augments() {
let mut inv = Inventory::new();
let result = inv
.reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
.unwrap();
assert_eq!(inv.units("USD"), dec!(50));
assert!(result.matched.is_empty()); }
#[test]
fn test_fifo_short_position_cover() {
let mut inv = Inventory::new();
let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_old,
));
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_new,
));
let result = inv
.reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
}
#[test]
fn test_lifo_short_position_cover() {
let mut inv = Inventory::new();
let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_old,
));
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_new,
));
let result = inv
.reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
.unwrap();
assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
}
#[test]
fn test_accounted_error_display_insufficient_units() {
let err = BookingError::InsufficientUnits {
currency: "AAPL".into(),
requested: dec!(15),
available: dec!(10),
}
.with_account("Assets:Stock".into());
let rendered = format!("{err}");
assert!(
rendered.contains("not enough"),
"must contain 'not enough' (pta-standards): {rendered}"
);
assert!(
rendered.contains("Assets:Stock"),
"must contain account name: {rendered}"
);
assert!(
rendered.contains("15") && rendered.contains("10"),
"must contain requested and available amounts: {rendered}"
);
}
#[test]
fn test_accounted_error_display_no_matching_lot() {
let err = BookingError::NoMatchingLot {
currency: "AAPL".into(),
cost_spec: CostSpec::empty(),
}
.with_account("Assets:Stock".into());
let rendered = format!("{err}");
assert!(
rendered.contains("No matching lot"),
"must contain 'No matching lot': {rendered}"
);
assert!(
rendered.contains("AAPL"),
"must contain currency: {rendered}"
);
assert!(
rendered.contains("Assets:Stock"),
"must contain account name: {rendered}"
);
}
#[test]
fn test_accounted_error_display_ambiguous_match() {
let err = BookingError::AmbiguousMatch {
num_matches: 3,
currency: "AAPL".into(),
}
.with_account("Assets:Stock".into());
let rendered = format!("{err}");
assert!(
rendered.contains("Ambiguous"),
"must contain 'Ambiguous': {rendered}"
);
assert!(
rendered.contains("AAPL"),
"must contain currency: {rendered}"
);
assert!(
rendered.contains("Assets:Stock"),
"must contain account name: {rendered}"
);
assert!(
rendered.contains('3'),
"must contain match count: {rendered}"
);
}
#[test]
fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
let err = BookingError::CurrencyMismatch {
expected: "USD".into(),
got: "EUR".into(),
}
.with_account("Assets:Cash".into());
let rendered = format!("{err}");
assert!(
rendered.contains("No matching lot"),
"CurrencyMismatch must render as 'No matching lot' for E4001 \
consistency: {rendered}"
);
assert!(
rendered.contains("EUR"),
"must contain the mismatched (got) currency: {rendered}"
);
assert!(
rendered.contains("Assets:Cash"),
"must contain account name: {rendered}"
);
}
}