use imbl::Vector;
use rust_decimal::Decimal;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::fmt;
use std::str::FromStr;
use crate::{Account, Amount, CostSpec, Currency, Position, is_subaccount_or_equal};
pub(crate) type MatchedLots = SmallVec<[Position; 1]>;
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, Copy, PartialEq, Eq, Hash)]
pub enum ReductionScope {
AllPositions,
CostBearingOnly,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BookingResult {
pub matched: MatchedLots,
pub cost_basis: Option<Amount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BookingError {
AmbiguousMatch {
num_matches: usize,
currency: crate::Currency,
},
NoMatchingLot {
currency: crate::Currency,
cost_spec: CostSpec,
},
InsufficientUnits {
currency: crate::Currency,
requested: Decimal,
available: Decimal,
},
CurrencyMismatch {
expected: crate::Currency,
got: crate::Currency,
},
MergeMismatch {
currency: crate::Currency,
expected: crate::Amount,
got: crate::Amount,
},
Overflow(OverflowError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverflowError {
pub currency: crate::Currency,
}
impl fmt::Display for OverflowError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} amount exceeds the representable range (±7.9e28); \
split the transaction, or denominate it in larger units \
(thousands, millions) so the number is smaller",
self.currency
)
}
}
impl std::error::Error for OverflowError {}
impl From<OverflowError> for BookingError {
fn from(e: OverflowError) -> Self {
Self::Overflow(e)
}
}
impl fmt::Display for BookingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MergeMismatch {
currency,
expected,
got,
} => write!(
f,
"{{*}} merge of {currency} would produce a pool cost of {got}, \
but booking recorded {expected}: this posting is being applied \
against different inventory than it was booked against"
),
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}")
}
Self::Overflow(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for BookingError {}
impl BookingError {
#[must_use]
pub const fn with_account(self, account: crate::Account) -> AccountedBookingError {
AccountedBookingError {
error: self,
account,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountedBookingError {
pub error: BookingError,
pub account: crate::Account,
}
impl fmt::Display for AccountedBookingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.error {
BookingError::Overflow(e) => write!(f, "{}: {e}", self.account),
BookingError::MergeMismatch { .. } => write!(f, "{}: {}", self.account, 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)]
enum PositionStore {
Owned(Slots),
Shared(Vector<Position>),
}
enum PositionStoreIter<'a> {
Owned(std::iter::Flatten<std::slice::Iter<'a, Option<Position>>>),
Shared(imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>),
}
impl<'a> Iterator for PositionStoreIter<'a> {
type Item = &'a Position;
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Owned(i) => i.next(),
Self::Shared(i) => i.next(),
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
match self {
Self::Owned(i) => i.size_hint(),
Self::Shared(i) => i.size_hint(),
}
}
}
#[derive(Debug, Clone, Default)]
struct Slots {
entries: Vec<Option<Position>>,
live: usize,
undo: Option<Vec<(usize, Option<Position>)>>,
undo_seen: rustc_hash::FxHashSet<usize>,
}
impl Slots {
fn from_live(positions: Vec<Position>) -> Self {
let live = positions.len();
Self {
entries: positions.into_iter().map(Some).collect(),
live,
undo: None,
undo_seen: rustc_hash::FxHashSet::default(),
}
}
fn record(&mut self, i: usize) {
if self.undo.is_none() {
return;
}
if !self.undo_seen.insert(i) {
return;
}
let prior = self.entries.get(i).cloned().flatten();
if let Some(log) = self.undo.as_mut() {
log.push((i, prior));
}
}
fn set_dead(&mut self, i: usize) {
self.record(i);
debug_assert!(
i < self.entries.len(),
"set_dead called with out-of-range slot {i} (of {})",
self.entries.len(),
);
if let Some(entry) = self.entries.get_mut(i)
&& entry.take().is_some()
{
self.live -= 1;
}
}
}
enum SlotIter<'a> {
Owned(std::iter::Enumerate<std::slice::Iter<'a, Option<Position>>>),
Shared(
std::iter::Enumerate<imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>>,
),
}
impl<'a> SlotIter<'a> {
fn new(store: &'a PositionStore) -> Self {
match store {
PositionStore::Owned(v) => Self::Owned(v.entries.iter().enumerate()),
PositionStore::Shared(v) => Self::Shared(v.iter().enumerate()),
}
}
}
impl<'a> Iterator for SlotIter<'a> {
type Item = (usize, &'a Position);
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Owned(i) => {
for (slot, entry) in i.by_ref() {
if let Some(position) = entry {
return Some((slot, position));
}
}
None
}
Self::Shared(i) => i.next(),
}
}
}
impl Default for PositionStore {
fn default() -> Self {
Self::Owned(Slots::default())
}
}
impl PositionStore {
fn iter_slots(&self) -> impl Iterator<Item = (usize, &Position)> {
SlotIter::new(self)
}
fn iter(&self) -> PositionStoreIter<'_> {
match self {
Self::Owned(v) => PositionStoreIter::Owned(v.entries.iter().flatten()),
Self::Shared(v) => PositionStoreIter::Shared(v.iter()),
}
}
fn len(&self) -> usize {
match self {
Self::Owned(v) => v.live,
Self::Shared(v) => v.len(),
}
}
fn slot_count(&self) -> usize {
match self {
Self::Owned(v) => v.entries.len(),
Self::Shared(v) => v.len(),
}
}
const fn dead(&self) -> usize {
match self {
Self::Owned(v) => v.entries.len() - v.live,
Self::Shared(_) => 0,
}
}
fn is_empty(&self) -> bool {
self.len() == 0
}
fn get(&self, i: usize) -> Option<&Position> {
match self {
Self::Owned(v) => v.entries.get(i).and_then(Option::as_ref),
Self::Shared(v) => v.get(i),
}
}
fn push(&mut self, p: Position) {
self.push_slot(p);
}
fn push_slot(&mut self, p: Position) -> usize {
let slot = self.slot_count();
match self {
Self::Owned(v) => {
if let Some(log) = v.undo.as_mut() {
log.push((slot, None));
v.undo_seen.insert(slot);
}
v.entries.push(Some(p));
v.live += 1;
}
Self::Shared(v) => v.push_back(p),
}
slot
}
fn remove(&mut self, i: usize) {
match self {
Self::Owned(v) => v.set_dead(i),
Self::Shared(v) => {
v.remove(i);
}
}
}
fn retain(&mut self, mut f: impl FnMut(&Position) -> bool) {
match self {
Self::Owned(v) => {
for i in 0..v.entries.len() {
if v.entries[i].as_ref().is_some_and(|p| !f(p)) {
v.set_dead(i);
}
}
}
Self::Shared(v) => v.retain(f),
}
}
fn begin_undo(&mut self) {
if let Self::Owned(v) = self {
v.undo = Some(Vec::new());
v.undo_seen.clear();
}
}
fn commit_undo(&mut self) {
if let Self::Owned(v) = self {
v.undo = None;
v.undo_seen.clear();
}
}
fn rollback_undo(&mut self) {
let Self::Owned(v) = self else {
return;
};
let Some(log) = v.undo.take() else {
return;
};
v.undo_seen.clear();
for (slot, prior) in log.into_iter().rev() {
if let Some(position) = prior {
if v.entries[slot].is_none() {
v.live += 1;
}
v.entries[slot] = Some(position);
} else {
if v.entries[slot].is_some() {
v.live -= 1;
}
v.entries[slot] = None;
if slot + 1 == v.entries.len() {
v.entries.pop();
}
}
}
}
fn compact_slots(&mut self) {
if let Self::Owned(v) = self {
v.entries.retain(Option::is_some);
debug_assert_eq!(
v.entries.len(),
v.live,
"compaction must leave only live slots"
);
}
}
fn retain_slots(&mut self, mut f: impl FnMut(usize, &Position) -> bool) {
match self {
Self::Owned(v) => {
for i in 0..v.entries.len() {
if v.entries[i].as_ref().is_some_and(|p| !f(i, p)) {
v.set_dead(i);
}
}
}
Self::Shared(v) => {
let mut slot = 0;
v.retain(|position| {
let keep = f(slot, position);
slot += 1;
keep
});
}
}
}
fn make_owned(&mut self) {
if let Self::Shared(v) = self {
let slots: Vec<Option<Position>> = v.iter().cloned().map(Some).collect();
let live = slots.len();
*self = Self::Owned(Slots {
entries: slots,
live,
undo: None,
undo_seen: rustc_hash::FxHashSet::default(),
});
}
}
}
impl std::ops::Index<usize> for PositionStore {
type Output = Position;
fn index(&self, i: usize) -> &Position {
match self {
Self::Owned(v) => v.entries[i]
.as_ref()
.expect("slot index names a live position, not a tombstone"),
Self::Shared(v) => &v[i],
}
}
}
impl std::ops::IndexMut<usize> for PositionStore {
fn index_mut(&mut self, i: usize) -> &mut Position {
match self {
Self::Owned(v) => {
v.record(i);
v.entries[i]
.as_mut()
.expect("slot index names a live position, not a tombstone")
}
Self::Shared(v) => &mut v[i],
}
}
}
impl FromIterator<Position> for PositionStore {
fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
let slots: Vec<Option<Position>> = iter.into_iter().map(Some).collect();
let live = slots.len();
Self::Owned(Slots {
entries: slots,
live,
undo: None,
undo_seen: rustc_hash::FxHashSet::default(),
})
}
}
impl Serialize for PositionStore {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.iter())
}
}
impl<'de> Deserialize<'de> for PositionStore {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self::Owned(Slots::from_live(Vec::<Position>::deserialize(
deserializer,
)?)))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(try_from = "InventoryWire")]
pub struct Inventory {
positions: PositionStore,
#[serde(skip)]
units_cache: FxHashMap<crate::Currency, CurrencyStats>,
#[serde(skip)]
cost_index: FxHashMap<CostKey, smallvec::SmallVec<[usize; 2]>>,
#[serde(skip)]
ordered_index: Option<Box<OrderedIndex>>,
#[serde(skip)]
undo_open: bool,
#[cfg(debug_assertions)]
#[serde(skip)]
undo_witness: Option<Box<Self>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum LotOrder {
Date,
CostDescending,
}
#[derive(Debug, Clone)]
pub(super) struct OrderedIndex {
order: LotOrder,
by_currency: FxHashMap<crate::Currency, Vec<usize>>,
}
type CostKey = (crate::Currency, Decimal, crate::Currency);
fn cost_key(position: &Position) -> Option<CostKey> {
position.cost.as_ref().map(|cost| {
(
position.units.currency.clone(),
cost.number,
cost.currency.clone(),
)
})
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct CurrencyStats {
total: Decimal,
counts: SignCounts,
simple_slot: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct SignCounts {
cost_positive: u32,
cost_negative: u32,
simple_positive: u32,
simple_negative: u32,
}
impl SignCounts {
const fn opposite(self, units_is_positive: bool, scope: ReductionScope) -> u32 {
let (cost, simple) = if units_is_positive {
(self.cost_negative, self.simple_negative)
} else {
(self.cost_positive, self.simple_positive)
};
match scope {
ReductionScope::AllPositions => cost.saturating_add(simple),
ReductionScope::CostBearingOnly => cost,
}
}
fn bump(&mut self, has_cost: bool, is_positive: bool, delta: i32) {
debug_assert!(
delta == 1 || delta == -1,
"counts move one lot at a time; {delta} means a caller lost track",
);
let slot = match (has_cost, is_positive) {
(true, true) => &mut self.cost_positive,
(true, false) => &mut self.cost_negative,
(false, true) => &mut self.simple_positive,
(false, false) => &mut self.simple_negative,
};
*slot = slot.saturating_add_signed(delta);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CacheSource {
Internal,
Untrusted,
}
#[derive(Deserialize)]
struct InventoryWire {
positions: Vector<Position>,
}
impl TryFrom<InventoryWire> for Inventory {
type Error = OverflowError;
fn try_from(wire: InventoryWire) -> Result<Self, Self::Error> {
let mut inv = Self {
positions: PositionStore::Owned(Slots::from_live(wire.positions.into_iter().collect())),
units_cache: FxHashMap::default(),
cost_index: FxHashMap::default(),
ordered_index: None,
undo_open: false,
#[cfg(debug_assertions)]
undo_witness: None,
};
inv.try_rebuild_index_from(CacheSource::Untrusted)?;
Ok(inv)
}
}
impl PartialEq for Inventory {
fn eq(&self, other: &Self) -> bool {
self.positions.iter().eq(other.positions.iter())
}
}
impl Eq for Inventory {}
impl Inventory {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn positions(&self) -> impl Iterator<Item = &Position> + '_ {
self.positions.iter()
}
#[must_use]
pub fn position_list(&self) -> Vec<&Position> {
self.positions.iter().collect()
}
pub fn compact_if_sparse(&mut self) {
debug_assert!(
!self.undo_open,
"compact_if_sparse would renumber slots the open undo log refers to",
);
if self.positions.dead() > self.positions.len() {
self.positions.compact_slots();
self.rebuild_index();
}
}
pub fn begin_undo(&mut self) {
debug_assert!(
!self.undo_open,
"begin_undo called twice without commit or rollback",
);
debug_assert!(
matches!(self.positions, PositionStore::Owned(_)),
"begin_undo on a shared inventory would record nothing and roll \
back nothing",
);
self.undo_open = true;
#[cfg(debug_assertions)]
{
self.undo_witness = Some(Box::new(self.clone_for_witness()));
}
self.positions.begin_undo();
}
#[must_use]
pub const fn undo_is_open(&self) -> bool {
self.undo_open
}
pub fn commit_undo(&mut self) {
self.undo_open = false;
#[cfg(debug_assertions)]
{
self.undo_witness = None;
}
self.positions.commit_undo();
}
pub fn rollback_undo(&mut self) {
self.undo_open = false;
self.positions.rollback_undo();
self.rebuild_index();
#[cfg(debug_assertions)]
{
if let Some(witness) = self.undo_witness.take() {
let restored: Vec<&Position> = self.positions.iter().collect();
let expected: Vec<&Position> = witness.positions.iter().collect();
assert_eq!(
restored, expected,
"rollback did not restore the inventory: some mutation path \
did not go through the backing's primitives, so the undo \
log missed it",
);
}
}
}
#[cfg(debug_assertions)]
fn clone_for_witness(&self) -> Self {
let mut copy = self.clone();
copy.undo_witness = None;
copy.undo_open = false;
copy
}
pub fn modify_positions(&mut self, f: impl FnOnce(&mut Vec<Position>)) {
let mut dense: Vec<Position> = self.positions.iter().cloned().collect();
f(&mut dense);
self.positions = PositionStore::Owned(Slots::from_live(dense));
self.rebuild_index();
}
#[must_use]
pub fn new_shared() -> Self {
Self {
positions: PositionStore::Shared(Vector::new()),
..Self::default()
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.positions.is_empty()
|| self
.positions
.iter()
.all(super::position::Position::is_empty)
}
#[must_use]
pub fn len(&self) -> usize {
self.positions.len()
}
#[must_use]
pub fn units(&self, currency: &str) -> Decimal {
self.units_cache.get(currency).map_or_else(
|| {
self.positions
.iter()
.filter(|p| p.units.currency == currency)
.map(|p| p.units.number)
.sum()
},
|stats| stats.total,
)
}
#[must_use]
pub fn add_headroom_for(&self, currency: &str, needed: Decimal) -> bool {
let needed = needed.abs();
if self.units_cache.is_empty() && !self.positions.is_empty() {
return false;
}
let fits = |v: Decimal| v.abs().checked_add(needed).is_some();
let Some(stats) = self.units_cache.get(currency) else {
return true;
};
if !fits(stats.total) {
return false;
}
stats
.simple_slot
.and_then(|idx| self.positions.get(idx))
.is_none_or(|lot| fits(lot.units.number))
}
#[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, scope: ReductionScope) -> bool {
if self.units_cache.is_empty() && !self.positions.is_empty() {
return self.is_reduced_by_scan(units, scope);
}
let answer = self.units_cache.get(&units.currency).is_some_and(|stats| {
stats
.counts
.opposite(units.number.is_sign_positive(), scope)
> 0
});
debug_assert_eq!(
answer,
self.is_reduced_by_scan(units, scope),
"the cached sign counts disagree with a scan of positions — some \
mutation path changed a lot without maintaining them",
);
answer
}
fn is_reduced_by_scan(&self, units: &Amount, scope: ReductionScope) -> bool {
self.positions.iter().any(|pos| {
pos.units.currency == units.currency
&& pos.units.number.is_sign_positive() != units.number.is_sign_positive()
&& match scope {
ReductionScope::AllPositions => true,
ReductionScope::CostBearingOnly => pos.cost.is_some(),
}
})
}
#[must_use]
pub fn is_booking_reduction(
&self,
units: &Amount,
cost: Option<&CostSpec>,
method: BookingMethod,
) -> bool {
method != BookingMethod::None
&& cost.is_some()
&& self.is_reduced_by(units, ReductionScope::CostBearingOnly)
}
pub fn book_value(
&self,
units_currency: &str,
) -> Result<FxHashMap<crate::Currency, Decimal>, OverflowError> {
let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
for pos in self.positions.iter() {
if pos.units.currency == units_currency {
let Some(cost) = pos.cost.as_ref() else {
continue;
};
let overflow = || OverflowError {
currency: cost.currency.clone(),
};
let book = cost.total_cost(pos.units.number).ok_or_else(overflow)?;
let slot = totals.entry(book.currency.clone()).or_default();
*slot = slot.checked_add(book.number).ok_or_else(overflow)?;
}
}
Ok(totals)
}
pub fn add(&mut self, position: Position) -> Result<(), OverflowError> {
if position.is_empty() {
return Ok(());
}
let overflow = || OverflowError {
currency: position.units.currency.clone(),
};
let cached = self
.units_cache
.get(&position.units.currency)
.map(|s| s.total)
.unwrap_or_default();
let new_cached = crate::decimal::checked_add_python_scale(cached, position.units.number)
.ok_or_else(overflow)?;
let merge_idx = position
.cost
.is_none()
.then(|| {
self.units_cache
.get(&position.units.currency)
.and_then(|s| s.simple_slot)
})
.flatten();
let merged_units = merge_idx
.map(|idx| {
crate::decimal::checked_add_python_scale(
self.positions[idx].units.number,
position.units.number,
)
.ok_or_else(overflow)
})
.transpose()?;
let vacated = merge_idx.map(|idx| {
let lot = &self.positions[idx];
(lot.cost.is_some(), lot.units.number.is_sign_positive())
});
let occupied = (
position.cost.is_some(),
merged_units
.unwrap_or(position.units.number)
.is_sign_positive(),
);
if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
stats.total = new_cached;
if let Some((had_cost, was_positive)) = vacated {
stats.counts.bump(had_cost, was_positive, -1);
}
stats.counts.bump(occupied.0, occupied.1, 1);
} else {
debug_assert!(
vacated.is_none(),
"merging into a lot whose currency has no cached entry",
);
let mut counts = SignCounts::default();
counts.bump(occupied.0, occupied.1, 1);
self.units_cache.insert(
position.units.currency.clone(),
CurrencyStats {
total: new_cached,
counts,
simple_slot: None,
},
);
}
if position.cost.is_none() {
if let Some(idx) = merge_idx {
debug_assert!(self.positions[idx].cost.is_none());
self.positions[idx].units.number =
merged_units.expect("merged_units is Some whenever merge_idx is");
return Ok(());
}
let currency = position.units.currency.clone();
let idx = self.positions.push_slot(position);
self.units_cache.entry(currency).or_default().simple_slot = Some(idx);
return Ok(());
}
let key = cost_key(&position);
let ordering = position.units.currency.clone();
let slot = self.positions.push_slot(position);
if let Some(key) = key {
self.cost_index.entry(key).or_default().push(slot);
}
self.ordered_index_insert(&ordering, slot);
Ok(())
}
pub(super) fn cost_index_remove(&mut self, idx: usize) {
let Some(position) = self.positions.get(idx) else {
return;
};
let ordered = self
.ordered_index
.is_some()
.then(|| position.units.currency.clone());
if let Some(key) = cost_key(position)
&& let Some(slots) = self.cost_index.get_mut(&key)
{
slots.retain(|slot| *slot != idx);
if slots.is_empty() {
self.cost_index.remove(&key);
}
}
let Some(currency) = ordered else {
return;
};
let Some(index) = self.ordered_index.as_mut() else {
return;
};
if let Some(slots) = index.by_currency.get_mut(¤cy) {
let at = slots.iter().position(|&existing| existing == idx);
if let Some(at) = at {
slots.remove(at);
}
if slots.is_empty() {
index.by_currency.remove(¤cy);
}
}
}
fn ordered_index_insert(&mut self, currency: &crate::Currency, slot: usize) {
if !matches!(self.positions, PositionStore::Owned(_)) {
return;
}
let Some(mut index) = self.ordered_index.take() else {
return;
};
let order = index.order;
let key = (self.order_key(order, slot), slot);
let entry = index.by_currency.entry(currency.clone()).or_default();
let at =
entry.partition_point(|&existing| (self.order_key(order, existing), existing) < key);
entry.insert(at, slot);
self.ordered_index = Some(index);
}
fn order_key(
&self,
order: LotOrder,
slot: usize,
) -> (Option<Decimal>, Option<crate::NaiveDate>) {
let cost = self.positions.get(slot).and_then(|p| p.cost.as_ref());
match order {
LotOrder::Date => (None, cost.and_then(|c| c.date)),
LotOrder::CostDescending => (Some(-cost.map_or(Decimal::ZERO, |c| c.number)), None),
}
}
pub(super) fn build_ordered_index(&mut self, order: LotOrder) {
if !matches!(self.positions, PositionStore::Owned(_)) {
return;
}
let mut by_currency: FxHashMap<crate::Currency, Vec<usize>> = FxHashMap::default();
for (idx, pos) in self.positions.iter_slots() {
by_currency
.entry(pos.units.currency.clone())
.or_default()
.push(idx);
}
for slots in by_currency.values_mut() {
slots.sort_by_key(|&idx| self.order_key(order, idx));
}
self.ordered_index = Some(Box::new(OrderedIndex { order, by_currency }));
}
fn ordered_candidates(&self, currency: &crate::Currency, order: LotOrder) -> Option<&[usize]> {
let index = self.ordered_index.as_ref()?;
if index.order != order {
return None;
}
Some(
index
.by_currency
.get(currency)
.map_or(&[][..], Vec::as_slice),
)
}
fn cost_candidates(&self, units: &Amount, spec: &CostSpec) -> Option<Vec<usize>> {
if self.cost_index.is_empty() {
return None;
}
let number = spec.number.and_then(|n| n.per_unit())?;
let currency = spec.currency.clone()?;
let mut slots = self
.cost_index
.get(&(units.currency.clone(), number, currency))
.cloned()
.unwrap_or_default()
.to_vec();
slots.sort_unstable();
Some(slots)
}
pub(super) fn sign_index_bump(&mut self, idx: usize, delta: i32) {
debug_assert!(
idx < self.positions.slot_count(),
"sign_index_bump called with out-of-range index {idx}",
);
let Some(position) = self.positions.get(idx) else {
return;
};
let has_cost = position.cost.is_some();
let is_positive = position.units.number.is_sign_positive();
if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
stats.counts.bump(has_cost, is_positive, delta);
}
}
pub fn reduce(
&mut self,
units: &Amount,
cost_spec: Option<&CostSpec>,
method: BookingMethod,
) -> Result<BookingResult, BookingError> {
let spec = cost_spec.cloned().unwrap_or_default();
self.positions.make_owned();
if !self.undo_open {
self.compact_if_sparse();
}
let wanted_order = match method {
BookingMethod::Fifo | BookingMethod::Lifo => Some(LotOrder::Date),
BookingMethod::Hifo => Some(LotOrder::CostDescending),
_ => None,
};
if let Some(order) = wanted_order
&& self.ordered_index.as_ref().is_none_or(|i| i.order != order)
{
self.build_ordered_index(order);
}
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) {
let rebuilt = self.try_rebuild_index_from(CacheSource::Internal);
debug_assert!(
rebuilt.is_ok(),
"internal positions summed past the Decimal range; `add` should \
have rejected them",
);
}
fn try_rebuild_index_from(&mut self, source: CacheSource) -> Result<(), OverflowError> {
self.units_cache.clear();
self.cost_index.clear();
let ordered_was_built = self.ordered_index.as_ref().map(|i| i.order);
self.ordered_index = None;
let index_costs = matches!(self.positions, PositionStore::Owned(_));
for (idx, pos) in self.positions.iter_slots() {
if index_costs {
if let Some(key) = cost_key(pos) {
self.cost_index.entry(key).or_default().push(idx);
}
if let Some(order) = ordered_was_built {
self.ordered_index
.get_or_insert_with(|| {
Box::new(OrderedIndex {
order,
by_currency: FxHashMap::default(),
})
})
.by_currency
.entry(pos.units.currency.clone())
.or_default()
.push(idx);
}
}
let slot = self
.units_cache
.entry(pos.units.currency.clone())
.or_default();
slot.counts
.bump(pos.cost.is_some(), pos.units.number.is_sign_positive(), 1);
slot.total = crate::decimal::checked_add_python_scale(slot.total, pos.units.number)
.ok_or_else(|| OverflowError {
currency: pos.units.currency.clone(),
})?;
if pos.cost.is_none() {
debug_assert!(
source == CacheSource::Untrusted
|| self
.units_cache
.get(&pos.units.currency)
.is_none_or(|s| s.simple_slot.is_none()),
"Invariant violated: multiple simple positions for currency {}",
pos.units.currency
);
self.units_cache
.entry(pos.units.currency.clone())
.or_default()
.simple_slot = Some(idx);
}
}
if let Some(order) = ordered_was_built {
let keys: Vec<(crate::Currency, Vec<usize>)> = self
.ordered_index
.as_ref()
.map(|i| {
i.by_currency
.iter()
.map(|(c, slots)| (c.clone(), slots.clone()))
.collect()
})
.unwrap_or_default();
for (currency, mut slots) in keys {
slots.sort_by_key(|&idx| self.order_key(order, idx));
if let Some(index) = self.ordered_index.as_mut() {
index.by_currency.insert(currency, slots);
}
}
}
Ok(())
}
pub fn merge(&mut self, other: &Self) -> Result<(), OverflowError> {
for pos in other.positions.iter() {
self.add(pos.clone())?;
}
Ok(())
}
pub fn at_cost(&self) -> Result<Self, OverflowError> {
let mut result = Self::new();
for pos in self.positions.iter() {
if pos.is_empty() {
continue;
}
if let Some(cost) = &pos.cost {
let total =
pos.units
.number
.checked_mul(cost.number)
.ok_or_else(|| OverflowError {
currency: cost.currency.clone(),
})?;
result.add(Position::simple(Amount::new(total, &cost.currency)))?;
} else {
result.add(pos.clone())?;
}
}
Ok(result)
}
pub fn at_units(&self) -> Result<Self, OverflowError> {
let mut result = Self::new();
for pos in self.positions.iter() {
if pos.is_empty() {
continue;
}
result.add(Position::simple(pos.units.clone()))?;
}
Ok(result)
}
}
pub fn sum_account_and_subaccounts<'a, I>(
inventories: I,
account: &str,
currency: &Currency,
) -> Option<Decimal>
where
I: IntoIterator<Item = (&'a Account, &'a Inventory)>,
{
inventories
.into_iter()
.filter(|(inv_account, _)| is_subaccount_or_equal(inv_account.as_str(), account))
.try_fold(Decimal::ZERO, |acc, (_, inv)| {
acc.checked_add(inv.units(currency))
})
}
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 Inventory {
pub fn try_from_positions<I>(iter: I) -> Result<Self, OverflowError>
where
I: IntoIterator<Item = Position>,
{
let mut inv = Self::new();
for pos in iter {
inv.add(pos)?;
}
Ok(inv)
}
}
#[cfg(test)]
mod tests {
#[test]
fn new_shared_is_shared_and_a_round_trip_is_owned() {
let mut shared = Inventory::new_shared();
assert!(
matches!(shared.positions, PositionStore::Shared(_)),
"new_shared must use the structurally-shared backing",
);
shared
.add(Position::simple(Amount::new(dec!(5), "USD")))
.expect("fits");
assert!(
matches!(shared.positions, PositionStore::Shared(_)),
"add must keep the shared backing; converting here would restore \
the O(rows x lots) blow-up #1086 is about",
);
let mut reduced = Inventory::new_shared();
reduced
.add(Position::simple(Amount::new(dec!(5), "USD")))
.expect("fits");
let _ = reduced.reduce(&Amount::new(dec!(-2), "USD"), None, BookingMethod::None);
assert!(
matches!(reduced.positions, PositionStore::Owned(_)),
"reduce must switch to the contiguous backing",
);
assert!(matches!(
Inventory::new().positions,
PositionStore::Owned(_)
));
let json = serde_json::to_string(&shared).expect("serializes");
let back: Inventory = serde_json::from_str(&json).expect("deserializes");
assert!(
matches!(back.positions, PositionStore::Owned(_)),
"a round-trip lands in the contiguous backing",
);
assert_eq!(back.units("USD"), dec!(5), "and preserves the positions");
}
#[test]
fn a_deserialized_inventory_refuses_to_claim_headroom() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
.expect("one MAX position fits");
assert!(!inv.add_headroom_for("USD", Decimal::ONE));
let round_tripped: Inventory =
serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
.expect("deserialize");
assert!(
!round_tripped.positions.is_empty(),
"the positions survive the round-trip"
);
assert!(
!round_tripped.units_cache.is_empty(),
"and so do the caches now — deserialization rebuilds them"
);
assert!(
!round_tripped.add_headroom_for("USD", Decimal::ONE),
"the inventory still holds Decimal::MAX"
);
}
#[test]
fn a_payload_violating_the_lot_invariant_does_not_panic() {
let json = r#"{"positions":[
{"units":{"number":"100","currency":"USD"},"cost":null},
{"units":{"number":"5","currency":"USD"},"cost":null}]}"#;
let inv: Inventory = serde_json::from_str(json).expect("malformed input still loads");
assert_eq!(inv.units("USD"), dec!(105), "the total sums every lot");
assert_eq!(
inv.positions().count(),
2,
"the lots are preserved as given"
);
}
#[test]
fn a_payload_that_overflows_the_total_is_an_error_not_a_panic() {
let max = Decimal::MAX.to_string();
let json = format!(
r#"{{"positions":[
{{"units":{{"number":"{max}","currency":"USD"}},
"cost":{{"number":"1","currency":"EUR","date":null,"label":null}}}},
{{"units":{{"number":"{max}","currency":"USD"}},"cost":null}}]}}"#
);
let err = serde_json::from_str::<Inventory>(&json)
.expect_err("a total past the Decimal range cannot be represented");
assert!(
err.to_string().contains("exceeds the representable range"),
"expected the USD overflow error, got: {err}",
);
}
#[test]
fn a_payload_without_positions_is_rejected() {
let err = serde_json::from_str::<Inventory>("{}")
.expect_err("an inventory without positions is malformed");
assert!(
err.to_string().contains("missing field"),
"expected a missing-field error, got: {err}",
);
}
#[test]
fn adding_to_a_deserialized_inventory_keeps_the_running_total() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")))
.expect("fits");
let mut round_tripped: Inventory =
serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
.expect("deserialize");
assert_eq!(
round_tripped.units("USD"),
dec!(100),
"the round-trip preserves the total"
);
round_tripped
.add(Position::simple(Amount::new(dec!(5), "USD")))
.expect("fits");
assert_eq!(
round_tripped.units("USD"),
dec!(105),
"add must extend the existing total, not replace it"
);
assert_eq!(
round_tripped.positions().count(),
1,
"a cost-less add merges into the existing lot rather than appending"
);
}
#[test]
fn add_headroom_for_reads_needed_as_a_magnitude() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
.expect("one MAX position fits");
assert!(
!inv.add_headroom_for("USD", Decimal::ONE),
"at the ceiling, there is no room for one more unit"
);
assert!(
!inv.add_headroom_for("USD", -Decimal::ONE),
"and a negatively-signed magnitude must not manufacture room"
);
assert_eq!(
inv.add_headroom_for("USD", Decimal::ONE),
inv.add_headroom_for("USD", -Decimal::ONE),
"the sign of `needed` cannot change the answer"
);
}
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")))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(50), "USD")))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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(),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
.expect("fixture fits in Decimal");
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_same_cost_different_dates_is_ambiguous() {
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
let err = inv
.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
.expect_err("a partial sale cannot choose between two dated lots");
assert!(
matches!(err, BookingError::AmbiguousMatch { num_matches: 2, .. }),
"expected AmbiguousMatch over the two dated lots, got {err:?}"
);
assert_eq!(inv.units("AAPL"), dec!(20));
}
#[test]
fn test_reduce_strict_selling_every_matched_lot_is_not_ambiguous() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15)),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15)),
))
.expect("fixture fits in Decimal");
let result = inv
.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Strict)
.expect("selling the whole matched set names no lot to choose");
assert_eq!(inv.units("AAPL"), dec!(0));
assert_eq!(result.cost_basis.unwrap().number, dec!(3000.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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
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::try_from_positions(positions).expect("fixture fits in Decimal");
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(),
))
.expect("fixture fits in Decimal");
assert_eq!(inv.len(), 1);
assert_eq!(inv.units("AAPL"), dec!(10));
inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
.expect("fixture fits in Decimal");
assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
#[test]
fn a_round_trip_reports_the_same_scale_as_incremental_adds() {
let mut inv = Inventory::new();
let lots = [
(
dec!(2.00),
Cost::new(dec!(10.00), "USD").with_date(date(2024, 1, 1)),
),
(
dec!(-2.00),
Cost::new(dec!(11.00), "USD").with_date(date(2024, 1, 2)),
),
(
dec!(1),
Cost::new(dec!(12.00), "USD").with_date(date(2024, 1, 3)),
),
];
for (units, cost) in lots {
inv.add(Position::with_cost(Amount::new(units, "SH"), cost))
.expect("fixture fits in Decimal");
}
let built = inv.units("SH").to_string();
assert_eq!(built, "1.00", "the incrementally-built total");
let json = serde_json::to_string(&inv).expect("serializes");
let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
assert_eq!(
round_tripped.units("SH").to_string(),
built,
"a serde round-trip must not change the reported scale",
);
}
#[test]
fn coalescing_is_independent_of_the_order_amounts_arrive_in() {
let zero_crossing_first = [dec!(-2.00), dec!(2.00), dec!(-1)];
let zero_crossing_last = [dec!(-1), dec!(-2.00), dec!(2.00)];
let build = |amounts: &[Decimal]| {
let mut inv = Inventory::new();
for n in amounts {
inv.add(Position::simple(Amount::new(*n, "USD")))
.expect("fixture fits in Decimal");
}
inv
};
let a = build(&zero_crossing_first);
let b = build(&zero_crossing_last);
assert_eq!(
a.positions()
.next()
.expect("one position")
.units
.number
.to_string(),
"-1.00",
"a total that passed through zero must keep the widest scale",
);
assert_eq!(
b.positions()
.next()
.expect("one position")
.units
.number
.to_string(),
"-1.00",
);
assert_eq!(a.units("USD").to_string(), "-1.00");
assert_eq!(b.units("USD").to_string(), "-1.00");
}
#[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(),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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(),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
.expect("fixture fits in Decimal");
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(),
))
.expect("fixture fits in Decimal");
inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
.expect("fixture fits in Decimal");
inv1.merge(&inv2).expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost_high,
))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
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_shorts_past_zero() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(100), "USD")))
.expect("fixture fits in Decimal");
let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
assert!(result.is_ok(), "NONE must allow shorting: {result:?}");
assert_eq!(inv.units("USD"), dec!(-50));
}
#[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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(100), "USD")))
.expect("fixture fits in Decimal");
let at_cost = inv.at_cost().expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
let at_units = inv.at_units().expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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"),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(160), "USD"),
))
.expect("fixture fits in Decimal");
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);
let merged = inv.positions().next().expect("one merged lot");
assert_eq!(merged.units.number, dec!(15));
let cost = merged.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"),
))
.expect("fixture fits in Decimal");
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"),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(160), "USD"),
))
.expect("fixture fits in Decimal");
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"),
))
.expect("fixture fits in Decimal");
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);
let merged = inv.positions().next().expect("one merged lot");
assert_eq!(merged.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"),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(200), "USD"),
))
.expect("fixture fits in Decimal");
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);
let merged = inv.positions().next().expect("one merged lot");
assert_eq!(merged.units.number, dec!(24));
let cost = merged.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"),
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(130), "EUR"),
))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
.expect("fixture fits in Decimal");
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,
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
value: 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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost_high,
))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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,
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_high,
))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
.expect("fixture fits in Decimal");
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))
.expect("fixture fits in Decimal");
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")))
.expect("fixture fits in Decimal");
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,
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_new,
))
.expect("fixture fits in Decimal");
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,
))
.expect("fixture fits in Decimal");
inv.add(Position::with_cost(
Amount::new(dec!(-10), "AAPL"),
cost_new,
))
.expect("fixture fits in Decimal");
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_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
let mut inv = Inventory::new();
let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost))
.expect("fixture fits in Decimal");
inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")))
.expect("fixture fits in Decimal");
let buy_units = Amount::new(dec!(50), "HOOG");
assert!(
!inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
"augmentation with cost spec should NOT be treated as reduction \
when only a simple (no-cost) position has opposite sign"
);
assert!(
inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
"without cost spec filter, the -25 HOOG simple position \
should cause is_reduced_by to return true"
);
}
#[test]
fn is_booking_reduction_gates_on_method_cost_and_sign() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
))
.expect("fixture fits in Decimal");
let sell = Amount::new(dec!(-5), "AAPL"); let buy = Amount::new(dec!(5), "AAPL"); let spec = CostSpec::empty();
assert!(inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::Strict));
assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
}
#[test]
fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
let mut bank = Inventory::new();
bank.add(Position::simple(Amount::new(dec!(10), "USD")))
.expect("fixture fits in Decimal");
let mut checking = Inventory::new(); checking
.add(Position::simple(Amount::new(dec!(40), "USD")))
.expect("fixture fits in Decimal");
let mut alias = Inventory::new(); alias
.add(Position::simple(Amount::new(dec!(99), "USD")))
.expect("fixture fits in Decimal");
let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
map.insert(Account::from("Assets:Bank"), bank);
map.insert(Account::from("Assets:Bank:Checking"), checking);
map.insert(Account::from("Assets:BankAlias"), alias);
let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"))
.expect("fixture fits in Decimal");
assert_eq!(
total,
dec!(50),
"parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
);
}
#[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}"
);
}
#[test]
fn the_sign_index_tracks_every_mutation_path() {
let usd = Amount::new(dec!(1), "USD");
let aapl = Amount::new(dec!(1), "AAPL");
let check = |inv: &Inventory, label: &str| {
let counts_of = |inv: &Inventory| {
inv.units_cache
.iter()
.filter(|(_, stats)| stats.counts != SignCounts::default())
.map(|(currency, stats)| (currency.as_str().to_string(), stats.counts))
.collect::<std::collections::BTreeMap<_, _>>()
};
let mut rebuilt = inv.clone();
rebuilt.rebuild_index();
assert_eq!(
counts_of(inv),
counts_of(&rebuilt),
"the incrementally maintained sign counts diverged from a \
fresh rebuild after {label}",
);
for units in [&usd, &aapl] {
for signed in [
units.clone(),
Amount::new(-units.number, units.currency.clone()),
] {
for scope in [
ReductionScope::AllPositions,
ReductionScope::CostBearingOnly,
] {
assert_eq!(
inv.is_reduced_by(&signed, scope),
inv.is_reduced_by_scan(&signed, scope),
"the sign counts disagree with a scan after {label} \
for {signed:?} / {scope:?}",
);
}
}
}
};
let mut inv = Inventory::new();
check(&inv, "empty");
inv.add(Position::simple(Amount::new(dec!(3), "USD")))
.expect("fits");
check(&inv, "one simple lot");
inv.add(Position::simple(Amount::new(dec!(-8), "USD")))
.expect("fits");
check(&inv, "simple lot flipped negative by merge");
inv.add(Position::simple(Amount::new(dec!(8), "USD")))
.expect("fits");
check(&inv, "simple lot flipped back positive");
let cost = Cost::new(dec!(100), "USD");
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
))
.expect("fits");
check(&inv, "one cost-bearing lot");
inv.reduce(
&Amount::new(dec!(-4), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Fifo,
)
.expect("partial reduction");
check(&inv, "partially reduced lot");
inv.reduce(
&Amount::new(dec!(-6), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Fifo,
)
.expect("full reduction");
check(&inv, "fully drained lot");
let mut strict = Inventory::new();
strict
.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
cost.clone(),
))
.expect("fits");
check(&strict, "strict: one lot");
strict
.reduce(
&Amount::new(dec!(-4), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Strict,
)
.expect("partial strict reduction");
check(&strict, "strict: partially reduced");
strict
.reduce(
&Amount::new(dec!(-6), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Strict,
)
.expect("draining strict reduction");
check(&strict, "strict: lot drained and removed");
assert!(
strict.positions.is_empty(),
"the fixture must actually remove the lot, or the removal path is \
untested",
);
let mut short = Inventory::new();
short
.add(Position::with_cost(
Amount::new(dec!(-5), "AAPL"),
Cost::new(dec!(100), "USD"),
))
.expect("fits");
check(&short, "short: one negative lot");
short
.reduce(
&Amount::new(dec!(5), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Strict,
)
.expect("covering the short");
check(&short, "short: covered to zero and removed");
assert!(
short.positions.is_empty(),
"the short must actually close, or the bucket flip is untested",
);
let mut incremental_inv = Inventory::new();
incremental_inv
.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
.expect("fits");
incremental_inv
.reduce(
&Amount::new(dec!(-4), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Strict,
)
.expect("partial strict reduction");
let incremental = incremental_inv.units_cache.clone();
assert!(!incremental.is_empty(), "fixture holds a lot");
incremental_inv.rebuild_index();
assert_eq!(
incremental, incremental_inv.units_cache,
"the incrementally maintained index must equal a fresh rebuild",
);
}
#[test]
fn an_unbuilt_cache_falls_back_to_the_scan_rather_than_answering_no() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(100), "USD"),
))
.expect("fits");
inv.units_cache.clear();
assert!(
inv.units_cache.is_empty(),
"the fixture must reach `is_reduced_by` with an unbuilt cache, or \
it is testing the fast path instead",
);
assert!(
inv.is_reduced_by(
&Amount::new(dec!(-4), "AAPL"),
ReductionScope::CostBearingOnly
),
"a sale against a held lot must be seen as a reduction even with \
no cache built",
);
assert!(
!inv.is_reduced_by(
&Amount::new(dec!(4), "AAPL"),
ReductionScope::CostBearingOnly
),
"a purchase in the same direction is still an augmentation",
);
inv.rebuild_index();
assert!(!inv.units_cache.is_empty(), "rebuild populates the cache");
assert!(inv.is_reduced_by(
&Amount::new(dec!(-4), "AAPL"),
ReductionScope::CostBearingOnly
));
assert!(!inv.is_reduced_by(
&Amount::new(dec!(4), "AAPL"),
ReductionScope::CostBearingOnly
));
}
#[test]
fn removing_a_lot_repairs_the_index_of_a_later_cost_less_lot() {
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(100), "USD"),
))
.expect("fits");
inv.add(Position::simple(Amount::new(dec!(50), "USD")))
.expect("fits");
assert_eq!(
inv.units_cache
.get(&crate::Currency::new("USD"))
.and_then(|s| s.simple_slot),
Some(1),
"fixture must put the cost-less lot second, or the shift is untested",
);
inv.reduce(
&Amount::new(dec!(-10), "AAPL"),
Some(&CostSpec::default()),
BookingMethod::Strict,
)
.expect("drains the lot");
assert_eq!(
inv.units_cache
.get(&crate::Currency::new("USD"))
.and_then(|s| s.simple_slot),
Some(1),
"the cost-less lot did not move, so its stored slot must not change",
);
inv.add(Position::simple(Amount::new(dec!(25), "USD")))
.expect("fits");
assert_eq!(
inv.positions().count(),
1,
"a stale simple_index appends a duplicate cost-less lot instead of \
merging",
);
assert_eq!(inv.units("USD"), dec!(75));
}
#[test]
fn removing_a_cost_less_lot_drops_its_index_entry() {
let mut inv = Inventory::new();
inv.add(Position::simple(Amount::new(dec!(50), "USD")))
.expect("fits");
assert_eq!(
inv.units_cache
.get(&crate::Currency::new("USD"))
.and_then(|s| s.simple_slot),
Some(0)
);
inv.reduce(
&Amount::new(dec!(-50), "USD"),
Some(&CostSpec::default()),
BookingMethod::Strict,
)
.expect("drains the cost-less lot");
assert!(inv.positions().next().is_none(), "the lot is gone");
assert_eq!(
inv.units_cache
.get(&crate::Currency::new("USD"))
.and_then(|s| s.simple_slot),
None,
"a stale entry points at a removed lot; the next cost-less add \
indexes past the end",
);
inv.add(Position::simple(Amount::new(dec!(20), "USD")))
.expect("fits");
assert_eq!(inv.units("USD"), dec!(20));
}
#[test]
fn iter_slots_yields_indices_that_address_their_own_position() {
let mut inv = Inventory::new();
for units in [dec!(10), dec!(20), dec!(30)] {
inv.add(Position::with_cost(
Amount::new(units, "AAPL"),
Cost::new(units * dec!(10), "USD"),
))
.expect("fits");
}
for _ in 0..2 {
inv.add(Position::with_cost(
Amount::new(dec!(7), "AAPL"),
Cost::new(dec!(70), "USD"),
))
.expect("fits");
}
inv.add(Position::simple(Amount::new(dec!(99), "USD")))
.expect("fits");
let mut seen = 0;
for (slot, position) in inv.positions.iter_slots() {
assert!(
std::ptr::eq(std::ptr::from_ref(&inv.positions[slot]), position),
"slot {slot} addresses a different position than the one it \
was yielded with",
);
seen += 1;
}
assert_eq!(
seen,
inv.positions().count(),
"iter_slots must visit every live position",
);
assert_eq!(seen, 6, "fixture must hold six lots, two of them equal");
}
#[test]
fn a_removal_does_not_renumber_the_lots_after_it() {
let mut inv = Inventory::new();
for units in [dec!(10), dec!(20), dec!(30)] {
inv.add(Position::with_cost(
Amount::new(units, "AAPL"),
Cost::new(units * dec!(10), "USD"),
))
.expect("fits");
}
let before: Vec<usize> = inv.positions.iter_slots().map(|(slot, _)| slot).collect();
assert_eq!(before, vec![0, 1, 2], "fixture must fill three slots");
inv.reduce(
&Amount::new(dec!(-20), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(200) })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("drains the middle lot");
let after: Vec<(usize, Decimal)> = inv
.positions
.iter_slots()
.map(|(slot, p)| (slot, p.units.number))
.collect();
assert_eq!(
after,
vec![(0, dec!(10)), (2, dec!(30))],
"the surviving lots must keep the slots they had; slot 1 is now a \
tombstone and slot 2 must NOT have become slot 1",
);
assert_eq!(inv.positions.len(), 2, "two live lots");
assert_eq!(inv.positions.slot_count(), 3, "three slots, one dead");
}
#[test]
fn tombstones_are_compacted_rather_than_accumulating() {
let mut inv = Inventory::new();
for i in 0..50u32 {
let cost = Decimal::from(100 + i);
inv.add(Position::with_cost(
Amount::new(dec!(1), "AAPL"),
Cost::new(cost, "USD"),
))
.expect("fits");
inv.reduce(
&Amount::new(dec!(-1), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: cost })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("drains it again");
}
assert_eq!(inv.positions.len(), 0, "every lot was closed");
assert!(
inv.positions.slot_count() <= 4,
"50 open-and-close cycles left {} slots; compaction is not running, \
and every later scan pays for all of them",
inv.positions.slot_count(),
);
}
#[test]
fn draining_a_lot_removes_it_from_the_cost_index() {
let spec = || {
CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(100) })
.with_currency("USD")
};
let mut inv = Inventory::new();
inv.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(100), "USD"),
))
.expect("fits");
assert_eq!(inv.cost_index.len(), 1, "the lot is indexed");
inv.reduce(
&Amount::new(dec!(-10), "AAPL"),
Some(&spec()),
BookingMethod::Strict,
)
.expect("drains the lot");
assert!(
inv.cost_index.is_empty(),
"the drained lot is still indexed: {:?}",
inv.cost_index,
);
inv.add(Position::with_cost(
Amount::new(dec!(5), "AAPL"),
Cost::new(dec!(100), "USD"),
))
.expect("fits");
let result = inv
.reduce(
&Amount::new(dec!(-5), "AAPL"),
Some(&spec()),
BookingMethod::Strict,
)
.expect("re-buying at the same cost and selling must work");
assert_eq!(result.matched.len(), 1);
assert_eq!(inv.positions.len(), 0);
}
#[test]
fn modify_positions_hands_over_a_dense_vector_and_rebuilds_the_caches() {
let mut inv = Inventory::new();
for units in [dec!(10), dec!(20)] {
inv.add(Position::with_cost(
Amount::new(units, "AAPL"),
Cost::new(units * dec!(10), "USD"),
))
.expect("fits");
}
inv.reduce(
&Amount::new(dec!(-10), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(100) })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("drains the first lot");
assert_eq!(inv.positions.slot_count(), 2, "one live lot, one tombstone");
inv.modify_positions(|positions| {
assert_eq!(
positions.len(),
1,
"the closure must see only live lots; tombstones are ours, not \
the caller's",
);
positions.push(Position::simple(Amount::new(dec!(5), "USD")));
});
assert_eq!(inv.units("USD"), dec!(5), "units_cache rebuilt");
assert_eq!(inv.units("AAPL"), dec!(20));
assert_eq!(inv.positions.len(), 2);
inv.add(Position::simple(Amount::new(dec!(2), "USD")))
.expect("fits");
assert_eq!(inv.positions.len(), 2, "merged rather than appended");
assert_eq!(inv.units("USD"), dec!(7));
inv.reduce(
&Amount::new(dec!(-20), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(200) })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("the surviving lot is still reachable through the cost index");
assert_eq!(inv.units("AAPL"), dec!(0));
}
#[test]
fn a_shared_snapshot_carries_no_cost_index() {
let mut shared = Inventory::new_shared();
for units in [dec!(10), dec!(20), dec!(30)] {
shared
.add(Position::with_cost(
Amount::new(units, "AAPL"),
Cost::new(units * dec!(10), "USD"),
))
.expect("fits");
}
shared.rebuild_index();
assert!(
shared.cost_index.is_empty(),
"a shared snapshot built an index of {} entries; every per-row \
clone now pays for it",
shared.cost_index.len(),
);
let mut owned = Inventory::new();
for units in [dec!(10), dec!(20), dec!(30)] {
owned
.add(Position::with_cost(
Amount::new(units, "AAPL"),
Cost::new(units * dec!(10), "USD"),
))
.expect("fits");
}
assert_eq!(
owned.cost_index.len(),
3,
"the owned backing must still index its lots, or the fast path is \
dead everywhere",
);
let result = shared
.reduce(
&Amount::new(dec!(-20), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(200) })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("a snapshot with no cost index must fall back to scanning");
assert_eq!(result.matched.len(), 1);
}
#[test]
fn a_drained_lot_does_not_reach_the_wire() {
let mut inv = Inventory::new();
for units in [dec!(10), dec!(20)] {
inv.add(Position::with_cost(
Amount::new(units, "AAPL"),
Cost::new(units * dec!(10), "USD"),
))
.expect("fits");
}
inv.reduce(
&Amount::new(dec!(-10), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(100) })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("drains the first lot");
assert_eq!(
inv.positions.slot_count(),
2,
"the fixture must actually hold a tombstone, or this proves nothing",
);
assert_eq!(inv.positions.len(), 1, "one live lot");
let json = serde_json::to_string(&inv).expect("serializes");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
let wire_positions = parsed
.get("positions")
.and_then(serde_json::Value::as_array)
.expect("positions is an array");
assert_eq!(
wire_positions.len(),
1,
"the wire must carry only the live lot, not the tombstone: {json}",
);
assert!(
!wire_positions.iter().any(serde_json::Value::is_null),
"a tombstone leaked onto the wire as a null element: {json}",
);
let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
assert_eq!(
round_tripped.positions.slot_count(),
1,
"the round trip must come back dense, not carrying the hole",
);
assert_eq!(round_tripped.positions.len(), 1);
assert_eq!(round_tripped.units("AAPL"), dec!(20));
assert_eq!(
round_tripped
.positions()
.next()
.expect("one lot")
.units
.number,
dec!(20),
);
let mut round_tripped = round_tripped;
round_tripped
.reduce(
&Amount::new(dec!(-20), "AAPL"),
Some(
&CostSpec::empty()
.with_number(crate::CostNumber::PerUnit { value: dec!(200) })
.with_currency("USD"),
),
BookingMethod::Strict,
)
.expect("the deserialized lot is reachable");
assert_eq!(round_tripped.units("AAPL"), dec!(0));
}
}