1use imbl::Vector;
9use rust_decimal::Decimal;
10use rustc_hash::FxHashMap;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13use std::fmt;
14use std::str::FromStr;
15
16use crate::{Account, Amount, CostSpec, Currency, Position, is_subaccount_or_equal};
17
18pub(crate) type MatchedLots = SmallVec<[Position; 1]>;
32
33mod booking;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
37#[cfg_attr(
38 feature = "rkyv",
39 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
40)]
41pub enum BookingMethod {
42 #[default]
45 Strict,
46 StrictWithSize,
49 Fifo,
51 Lifo,
53 Hifo,
55 Average,
57 None,
59}
60
61impl FromStr for BookingMethod {
62 type Err = String;
63
64 fn from_str(s: &str) -> Result<Self, Self::Err> {
65 match s.to_uppercase().as_str() {
66 "STRICT" => Ok(Self::Strict),
67 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
68 "FIFO" => Ok(Self::Fifo),
69 "LIFO" => Ok(Self::Lifo),
70 "HIFO" => Ok(Self::Hifo),
71 "AVERAGE" => Ok(Self::Average),
72 "NONE" => Ok(Self::None),
73 _ => Err(format!("unknown booking method: {s}")),
74 }
75 }
76}
77
78impl fmt::Display for BookingMethod {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 Self::Strict => write!(f, "STRICT"),
82 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
83 Self::Fifo => write!(f, "FIFO"),
84 Self::Lifo => write!(f, "LIFO"),
85 Self::Hifo => write!(f, "HIFO"),
86 Self::Average => write!(f, "AVERAGE"),
87 Self::None => write!(f, "NONE"),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum ReductionScope {
104 AllPositions,
106 CostBearingOnly,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct BookingResult {
113 pub matched: MatchedLots,
127 pub cost_basis: Option<Amount>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum BookingError {
134 AmbiguousMatch {
136 num_matches: usize,
138 currency: crate::Currency,
140 },
141 NoMatchingLot {
143 currency: crate::Currency,
145 cost_spec: CostSpec,
147 },
148 InsufficientUnits {
150 currency: crate::Currency,
152 requested: Decimal,
154 available: Decimal,
156 },
157 CurrencyMismatch {
159 expected: crate::Currency,
161 got: crate::Currency,
163 },
164}
165
166impl fmt::Display for BookingError {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 match self {
169 Self::AmbiguousMatch {
170 num_matches,
171 currency,
172 } => write!(
173 f,
174 "Ambiguous match: {num_matches} lots match for {currency}"
175 ),
176 Self::NoMatchingLot {
177 currency,
178 cost_spec,
179 } => {
180 write!(f, "No matching lot for {currency} with cost {cost_spec}")
181 }
182 Self::InsufficientUnits {
183 currency,
184 requested,
185 available,
186 } => write!(
187 f,
188 "Insufficient units of {currency}: requested {requested}, available {available}"
189 ),
190 Self::CurrencyMismatch { expected, got } => {
191 write!(f, "Currency mismatch: expected {expected}, got {got}")
192 }
193 }
194 }
195}
196
197impl std::error::Error for BookingError {}
198
199impl BookingError {
200 #[must_use]
213 pub const fn with_account(self, account: crate::Account) -> AccountedBookingError {
214 AccountedBookingError {
215 error: self,
216 account,
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct AccountedBookingError {
232 pub error: BookingError,
234 pub account: crate::Account,
236}
237
238impl fmt::Display for AccountedBookingError {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 match &self.error {
241 BookingError::InsufficientUnits {
242 requested,
243 available,
244 ..
245 } => write!(
246 f,
247 "Not enough units in {}: requested {}, available {}; not enough to reduce",
248 self.account, requested, available
249 ),
250 BookingError::NoMatchingLot { currency, .. } => {
251 write!(f, "No matching lot for {} in {}", currency, self.account)
252 }
253 BookingError::AmbiguousMatch {
254 num_matches,
255 currency,
256 } => write!(
257 f,
258 "Ambiguous lot match for {}: {} lots match in {}",
259 currency, num_matches, self.account
260 ),
261 BookingError::CurrencyMismatch { got, .. } => {
270 write!(f, "No matching lot for {} in {}", got, self.account)
271 }
272 }
273 }
274}
275
276impl std::error::Error for AccountedBookingError {}
277
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct Inventory {
302 positions: Vector<Position>,
325 #[serde(skip)]
329 simple_index: FxHashMap<crate::Currency, usize>,
330 #[serde(skip)]
334 units_cache: FxHashMap<crate::Currency, Decimal>,
335}
336
337impl PartialEq for Inventory {
338 fn eq(&self, other: &Self) -> bool {
339 self.positions == other.positions
341 }
342}
343
344impl Eq for Inventory {}
345
346impl Inventory {
347 #[must_use]
349 pub fn new() -> Self {
350 Self::default()
351 }
352
353 pub fn positions(&self) -> impl Iterator<Item = &Position> + '_ {
362 self.positions.iter()
363 }
364
365 #[must_use]
372 pub fn position_list(&self) -> Vec<&Position> {
373 self.positions.iter().collect()
374 }
375
376 pub const fn positions_mut(&mut self) -> &mut Vector<Position> {
384 &mut self.positions
385 }
386
387 #[must_use]
389 pub fn is_empty(&self) -> bool {
390 self.positions.is_empty()
391 || self
392 .positions
393 .iter()
394 .all(super::position::Position::is_empty)
395 }
396
397 #[must_use]
399 pub fn len(&self) -> usize {
400 self.positions.len()
401 }
402
403 #[must_use]
408 pub fn units(&self, currency: &str) -> Decimal {
409 self.units_cache.get(currency).copied().unwrap_or_else(|| {
412 self.positions
414 .iter()
415 .filter(|p| p.units.currency == currency)
416 .map(|p| p.units.number)
417 .sum()
418 })
419 }
420
421 #[must_use]
423 pub fn currencies(&self) -> Vec<&str> {
424 let mut currencies: Vec<&str> = self
425 .positions
426 .iter()
427 .filter(|p| !p.is_empty())
428 .map(|p| p.units.currency.as_str())
429 .collect();
430 currencies.sort_unstable();
431 currencies.dedup();
432 currencies
433 }
434
435 #[must_use]
450 pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
451 self.positions.iter().any(|pos| {
452 pos.units.currency == units.currency
453 && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
454 && match scope {
455 ReductionScope::AllPositions => true,
456 ReductionScope::CostBearingOnly => pos.cost.is_some(),
457 }
458 })
459 }
460
461 #[must_use]
475 pub fn is_booking_reduction(
476 &self,
477 units: &Amount,
478 cost: Option<&CostSpec>,
479 method: BookingMethod,
480 ) -> bool {
481 method != BookingMethod::None
482 && cost.is_some()
483 && self.is_reduced_by(units, ReductionScope::CostBearingOnly)
484 }
485
486 #[must_use]
490 pub fn book_value(&self, units_currency: &str) -> FxHashMap<crate::Currency, Decimal> {
491 let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
492
493 for pos in &self.positions {
494 if pos.units.currency == units_currency
495 && let Some(book) = pos.book_value()
496 {
497 *totals.entry(book.currency.clone()).or_default() += book.number;
498 }
499 }
500
501 totals
502 }
503
504 pub fn add(&mut self, position: Position) {
521 if position.is_empty() {
522 return;
523 }
524
525 *self
527 .units_cache
528 .entry(position.units.currency.clone())
529 .or_default() += position.units.number;
530
531 if position.cost.is_none() {
533 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
534 debug_assert!(self.positions[idx].cost.is_none());
536 self.positions[idx].units += &position.units;
537 return;
538 }
539 let idx = self.positions.len();
541 self.simple_index
542 .insert(position.units.currency.clone(), idx);
543 self.positions.push_back(position);
544 return;
545 }
546
547 self.positions.push_back(position);
551 }
552
553 pub fn reduce(
580 &mut self,
581 units: &Amount,
582 cost_spec: Option<&CostSpec>,
583 method: BookingMethod,
584 ) -> Result<BookingResult, BookingError> {
585 let spec = cost_spec.cloned().unwrap_or_default();
586
587 self.positions = self.positions.iter().cloned().collect();
599
600 if spec.merge {
603 return self.reduce_merge(units);
604 }
605
606 match method {
607 BookingMethod::Strict => self.reduce_strict(units, &spec),
608 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
609 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
610 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
611 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
612 BookingMethod::Average => self.reduce_average(units),
613 BookingMethod::None => self.reduce_none(units),
614 }
615 }
616
617 pub fn compact(&mut self) {
619 self.positions.retain(|p| !p.is_empty());
620 self.rebuild_index();
621 }
622
623 fn rebuild_index(&mut self) {
626 self.simple_index.clear();
627 self.units_cache.clear();
628
629 for (idx, pos) in self.positions.iter().enumerate() {
630 *self
632 .units_cache
633 .entry(pos.units.currency.clone())
634 .or_default() += pos.units.number;
635
636 if pos.cost.is_none() {
638 debug_assert!(
639 !self.simple_index.contains_key(&pos.units.currency),
640 "Invariant violated: multiple simple positions for currency {}",
641 pos.units.currency
642 );
643 self.simple_index.insert(pos.units.currency.clone(), idx);
644 }
645 }
646 }
647
648 pub fn merge(&mut self, other: &Self) {
650 for pos in &other.positions {
651 self.add(pos.clone());
652 }
653 }
654
655 #[must_use]
660 pub fn at_cost(&self) -> Self {
661 let mut result = Self::new();
662
663 for pos in &self.positions {
664 if pos.is_empty() {
665 continue;
666 }
667
668 if let Some(cost) = &pos.cost {
669 let total = pos.units.number * cost.number;
671 result.add(Position::simple(Amount::new(total, &cost.currency)));
672 } else {
673 result.add(pos.clone());
675 }
676 }
677
678 result
679 }
680
681 #[must_use]
686 pub fn at_units(&self) -> Self {
687 let mut result = Self::new();
688
689 for pos in &self.positions {
690 if pos.is_empty() {
691 continue;
692 }
693
694 result.add(Position::simple(pos.units.clone()));
696 }
697
698 result
699 }
700}
701
702pub fn sum_account_and_subaccounts<'a, I>(
718 inventories: I,
719 account: &str,
720 currency: &Currency,
721) -> Decimal
722where
723 I: IntoIterator<Item = (&'a Account, &'a Inventory)>,
724{
725 inventories
726 .into_iter()
727 .filter(|(inv_account, _)| is_subaccount_or_equal(inv_account.as_str(), account))
728 .map(|(_, inv)| inv.units(currency))
729 .sum()
730}
731
732impl fmt::Display for Inventory {
733 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
734 if self.is_empty() {
735 return write!(f, "(empty)");
736 }
737
738 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
740 non_empty.sort_by(|a, b| {
741 let cmp = a.units.currency.cmp(&b.units.currency);
743 if cmp != std::cmp::Ordering::Equal {
744 return cmp;
745 }
746 match (&a.cost, &b.cost) {
748 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
749 (Some(_), None) => std::cmp::Ordering::Greater,
750 (None, Some(_)) => std::cmp::Ordering::Less,
751 (None, None) => std::cmp::Ordering::Equal,
752 }
753 });
754
755 for (i, pos) in non_empty.iter().enumerate() {
756 if i > 0 {
757 write!(f, ", ")?;
758 }
759 write!(f, "{pos}")?;
760 }
761 Ok(())
762 }
763}
764
765impl FromIterator<Position> for Inventory {
766 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
767 let mut inv = Self::new();
768 for pos in iter {
769 inv.add(pos);
770 }
771 inv
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use crate::Cost;
779 use crate::NaiveDate;
780 use rust_decimal_macros::dec;
781
782 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
783 crate::naive_date(year, month, day).unwrap()
784 }
785
786 #[test]
787 fn test_empty_inventory() {
788 let inv = Inventory::new();
789 assert!(inv.is_empty());
790 assert_eq!(inv.len(), 0);
791 }
792
793 #[test]
794 fn test_add_simple() {
795 let mut inv = Inventory::new();
796 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
797
798 assert!(!inv.is_empty());
799 assert_eq!(inv.units("USD"), dec!(100));
800 }
801
802 #[test]
803 fn test_add_merge_simple() {
804 let mut inv = Inventory::new();
805 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
806 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
807
808 assert_eq!(inv.len(), 1);
810 assert_eq!(inv.units("USD"), dec!(150));
811 }
812
813 #[test]
814 fn test_add_with_cost_no_merge() {
815 let mut inv = Inventory::new();
816
817 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
818 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
819
820 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
821 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
822
823 assert_eq!(inv.len(), 2);
825 assert_eq!(inv.units("AAPL"), dec!(15));
826 }
827
828 #[test]
829 fn test_currencies() {
830 let mut inv = Inventory::new();
831 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
832 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
833 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
834
835 let currencies = inv.currencies();
836 assert_eq!(currencies.len(), 3);
837 assert!(currencies.contains(&"USD"));
838 assert!(currencies.contains(&"EUR"));
839 assert!(currencies.contains(&"AAPL"));
840 }
841
842 #[test]
843 fn test_reduce_strict_unique() {
844 let mut inv = Inventory::new();
845 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
846 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
847
848 let result = inv
849 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
850 .unwrap();
851
852 assert_eq!(inv.units("AAPL"), dec!(5));
853 assert!(result.cost_basis.is_some());
854 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
856
857 #[test]
858 fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
859 let mut inv = Inventory::new();
860
861 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
862 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
863
864 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
865 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
866
867 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
871
872 assert!(
873 matches!(result, Err(BookingError::AmbiguousMatch { .. })),
874 "expected AmbiguousMatch, got {result:?}"
875 );
876 assert_eq!(inv.units("AAPL"), dec!(15));
878 }
879
880 #[test]
881 fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
882 let mut inv = Inventory::new();
883
884 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
886
887 inv.add(Position::with_cost(
888 Amount::new(dec!(10), "AAPL"),
889 cost.clone(),
890 ));
891 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
892
893 let result = inv
894 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
895 .expect("identical lots should fall back to FIFO without error");
896
897 assert_eq!(inv.units("AAPL"), dec!(12));
898 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
899 }
900
901 #[test]
902 fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
903 let mut inv = Inventory::new();
904
905 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
909 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
910
911 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
912 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
913
914 let result = inv
915 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
916 .expect("same cost number, different dates should fall back to FIFO");
917
918 assert_eq!(inv.units("AAPL"), dec!(15));
919 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
921 }
922
923 #[test]
924 fn test_reduce_strict_multiple_match_total_match_exception() {
925 let mut inv = Inventory::new();
926
927 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
928 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
929
930 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
931 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
932
933 let result = inv
936 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
937 .expect("total-match exception should accept a full liquidation");
938
939 assert_eq!(inv.units("AAPL"), dec!(0));
940 assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
942 }
943
944 #[test]
945 fn test_reduce_strict_with_spec() {
946 let mut inv = Inventory::new();
947
948 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
949 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
950
951 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
952 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
953
954 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
956 let result = inv
957 .reduce(
958 &Amount::new(dec!(-3), "AAPL"),
959 Some(&spec),
960 BookingMethod::Strict,
961 )
962 .unwrap();
963
964 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
967
968 #[test]
969 fn test_reduce_fifo() {
970 let mut inv = Inventory::new();
971
972 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
973 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
974 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
975
976 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
977 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
978 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
979
980 let result = inv
982 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
983 .unwrap();
984
985 assert_eq!(inv.units("AAPL"), dec!(15));
986 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
988 }
989
990 #[test]
991 fn test_reduce_lifo() {
992 let mut inv = Inventory::new();
993
994 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
995 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
996 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
997
998 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
999 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1000 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1001
1002 let result = inv
1004 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1005 .unwrap();
1006
1007 assert_eq!(inv.units("AAPL"), dec!(15));
1008 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1010 }
1011
1012 #[test]
1013 fn test_reduce_insufficient() {
1014 let mut inv = Inventory::new();
1015 let cost = Cost::new(dec!(150.00), "USD");
1016 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1017
1018 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1019
1020 assert!(matches!(
1021 result,
1022 Err(BookingError::InsufficientUnits { .. })
1023 ));
1024 }
1025
1026 #[test]
1027 fn test_book_value() {
1028 let mut inv = Inventory::new();
1029
1030 let cost1 = Cost::new(dec!(100.00), "USD");
1031 let cost2 = Cost::new(dec!(150.00), "USD");
1032
1033 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1034 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1035
1036 let book = inv.book_value("AAPL");
1037 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
1039
1040 #[test]
1041 fn test_display() {
1042 let mut inv = Inventory::new();
1043 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1044
1045 let s = format!("{inv}");
1046 assert!(s.contains("100 USD"));
1047 }
1048
1049 #[test]
1050 fn test_display_empty() {
1051 let inv = Inventory::new();
1052 assert_eq!(format!("{inv}"), "(empty)");
1053 }
1054
1055 #[test]
1056 fn test_from_iterator() {
1057 let positions = vec![
1058 Position::simple(Amount::new(dec!(100), "USD")),
1059 Position::simple(Amount::new(dec!(50), "USD")),
1060 ];
1061
1062 let inv: Inventory = positions.into_iter().collect();
1063 assert_eq!(inv.units("USD"), dec!(150));
1064 }
1065
1066 #[test]
1067 fn test_add_costed_positions_kept_separate() {
1068 let mut inv = Inventory::new();
1071
1072 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1073
1074 inv.add(Position::with_cost(
1076 Amount::new(dec!(10), "AAPL"),
1077 cost.clone(),
1078 ));
1079 assert_eq!(inv.len(), 1);
1080 assert_eq!(inv.units("AAPL"), dec!(10));
1081
1082 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1084 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
1087
1088 #[test]
1089 fn test_add_costed_positions_net_units() {
1090 let mut inv = Inventory::new();
1092
1093 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1094
1095 inv.add(Position::with_cost(
1097 Amount::new(dec!(10), "AAPL"),
1098 cost.clone(),
1099 ));
1100
1101 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
1103 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
1106
1107 #[test]
1108 fn test_add_no_cancel_different_cost() {
1109 let mut inv = Inventory::new();
1111
1112 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1113 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1114
1115 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1117
1118 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
1120
1121 assert_eq!(inv.len(), 2);
1123 assert_eq!(inv.units("AAPL"), dec!(5)); }
1125
1126 #[test]
1127 fn test_add_no_cancel_same_sign() {
1128 let mut inv = Inventory::new();
1130
1131 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1132
1133 inv.add(Position::with_cost(
1135 Amount::new(dec!(10), "AAPL"),
1136 cost.clone(),
1137 ));
1138
1139 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1141
1142 assert_eq!(inv.len(), 2);
1144 assert_eq!(inv.units("AAPL"), dec!(15));
1145 }
1146
1147 #[test]
1148 fn test_merge_keeps_lots_separate() {
1149 let mut inv1 = Inventory::new();
1151 let mut inv2 = Inventory::new();
1152
1153 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1154
1155 inv1.add(Position::with_cost(
1157 Amount::new(dec!(10), "AAPL"),
1158 cost.clone(),
1159 ));
1160
1161 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1163
1164 inv1.merge(&inv2);
1166 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
1169
1170 #[test]
1175 fn test_hifo_with_tie_breaking() {
1176 let mut inv = Inventory::new();
1178
1179 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1181 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1182 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1183
1184 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1185 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1186 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1187
1188 let result = inv
1190 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1191 .unwrap();
1192
1193 assert_eq!(inv.units("AAPL"), dec!(15));
1194 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1196 }
1197
1198 #[test]
1199 fn test_hifo_with_different_costs() {
1200 let mut inv = Inventory::new();
1202
1203 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1204 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1205 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1206
1207 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1208 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1209 inv.add(Position::with_cost(
1210 Amount::new(dec!(10), "AAPL"),
1211 cost_high,
1212 ));
1213
1214 let result = inv
1216 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1217 .unwrap();
1218
1219 assert_eq!(inv.units("AAPL"), dec!(15));
1220 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1222 }
1223
1224 #[test]
1225 fn test_average_booking_with_pre_existing_positions() {
1226 let mut inv = Inventory::new();
1227
1228 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1230 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1231
1232 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1233 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1234
1235 let result = inv
1238 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1239 .unwrap();
1240
1241 assert_eq!(inv.units("AAPL"), dec!(15));
1242 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1244 }
1245
1246 #[test]
1247 fn test_average_booking_reduces_all() {
1248 let mut inv = Inventory::new();
1249
1250 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1251 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1252
1253 let result = inv
1255 .reduce(
1256 &Amount::new(dec!(-10), "AAPL"),
1257 None,
1258 BookingMethod::Average,
1259 )
1260 .unwrap();
1261
1262 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1263 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1264 }
1265
1266 #[test]
1267 fn test_none_booking_augmentation() {
1268 let mut inv = Inventory::new();
1270 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1271
1272 let result = inv
1274 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1275 .unwrap();
1276
1277 assert_eq!(inv.units("USD"), dec!(150));
1278 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
1280 }
1281
1282 #[test]
1283 fn test_none_booking_reduction() {
1284 let mut inv = Inventory::new();
1286 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1287
1288 let result = inv
1289 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1290 .unwrap();
1291
1292 assert_eq!(inv.units("USD"), dec!(70));
1293 assert!(!result.matched.is_empty());
1294 }
1295
1296 #[test]
1297 fn test_none_booking_insufficient() {
1298 let mut inv = Inventory::new();
1299 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1300
1301 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1302
1303 assert!(matches!(
1304 result,
1305 Err(BookingError::InsufficientUnits { .. })
1306 ));
1307 }
1308
1309 #[test]
1310 fn test_booking_error_no_matching_lot() {
1311 let mut inv = Inventory::new();
1312
1313 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1315 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1316
1317 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1319 let result = inv.reduce(
1320 &Amount::new(dec!(-5), "AAPL"),
1321 Some(&wrong_spec),
1322 BookingMethod::Strict,
1323 );
1324
1325 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1326 }
1327
1328 #[test]
1329 fn test_booking_error_insufficient_units() {
1330 let mut inv = Inventory::new();
1331
1332 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1333 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1334
1335 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1337
1338 match result {
1339 Err(BookingError::InsufficientUnits {
1340 requested,
1341 available,
1342 ..
1343 }) => {
1344 assert_eq!(requested, dec!(20));
1345 assert_eq!(available, dec!(10));
1346 }
1347 _ => panic!("Expected InsufficientUnits error"),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_strict_with_size_exact_match() {
1353 let mut inv = Inventory::new();
1354
1355 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1357 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1358
1359 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1360 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1361
1362 let result = inv
1364 .reduce(
1365 &Amount::new(dec!(-5), "AAPL"),
1366 None,
1367 BookingMethod::StrictWithSize,
1368 )
1369 .unwrap();
1370
1371 assert_eq!(inv.units("AAPL"), dec!(10));
1372 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1373 }
1374
1375 #[test]
1376 fn test_strict_with_size_total_match() {
1377 let mut inv = Inventory::new();
1378
1379 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1381 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1382
1383 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1384 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1385
1386 let result = inv
1388 .reduce(
1389 &Amount::new(dec!(-15), "AAPL"),
1390 None,
1391 BookingMethod::StrictWithSize,
1392 )
1393 .unwrap();
1394
1395 assert_eq!(inv.units("AAPL"), dec!(0));
1396 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1397 }
1398
1399 #[test]
1400 fn test_strict_with_size_ambiguous() {
1401 let mut inv = Inventory::new();
1402
1403 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1405 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1406
1407 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1408 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1409
1410 let result = inv.reduce(
1412 &Amount::new(dec!(-7), "AAPL"),
1413 None,
1414 BookingMethod::StrictWithSize,
1415 );
1416
1417 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1418 }
1419
1420 #[test]
1421 fn test_short_position() {
1422 let mut inv = Inventory::new();
1424
1425 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1427 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1428
1429 assert_eq!(inv.units("AAPL"), dec!(-10));
1430 assert!(!inv.is_empty());
1431 }
1432
1433 #[test]
1434 fn test_at_cost() {
1435 let mut inv = Inventory::new();
1436
1437 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1438 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1439
1440 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1441 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1442 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1443
1444 let at_cost = inv.at_cost();
1445
1446 assert_eq!(at_cost.units("USD"), dec!(1850));
1449 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1451
1452 #[test]
1453 fn test_at_units() {
1454 let mut inv = Inventory::new();
1455
1456 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1457 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1458
1459 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1460 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1461
1462 let at_units = inv.at_units();
1463
1464 assert_eq!(at_units.units("AAPL"), dec!(15));
1466 assert_eq!(at_units.len(), 1);
1468 }
1469
1470 #[test]
1471 fn test_add_empty_position() {
1472 let mut inv = Inventory::new();
1473 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1474
1475 assert!(inv.is_empty());
1476 assert_eq!(inv.len(), 0);
1477 }
1478
1479 #[test]
1480 fn test_compact() {
1481 let mut inv = Inventory::new();
1482
1483 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1484 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1485
1486 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1488 .unwrap();
1489
1490 inv.compact();
1492 assert!(inv.is_empty());
1493 assert_eq!(inv.len(), 0);
1494 }
1495
1496 #[test]
1497 fn test_booking_method_from_str() {
1498 assert_eq!(
1499 BookingMethod::from_str("STRICT").unwrap(),
1500 BookingMethod::Strict
1501 );
1502 assert_eq!(
1503 BookingMethod::from_str("fifo").unwrap(),
1504 BookingMethod::Fifo
1505 );
1506 assert_eq!(
1507 BookingMethod::from_str("LIFO").unwrap(),
1508 BookingMethod::Lifo
1509 );
1510 assert_eq!(
1511 BookingMethod::from_str("Hifo").unwrap(),
1512 BookingMethod::Hifo
1513 );
1514 assert_eq!(
1515 BookingMethod::from_str("AVERAGE").unwrap(),
1516 BookingMethod::Average
1517 );
1518 assert_eq!(
1519 BookingMethod::from_str("NONE").unwrap(),
1520 BookingMethod::None
1521 );
1522 assert_eq!(
1523 BookingMethod::from_str("strict_with_size").unwrap(),
1524 BookingMethod::StrictWithSize
1525 );
1526 assert!(BookingMethod::from_str("INVALID").is_err());
1527 }
1528
1529 #[test]
1530 fn test_booking_method_display() {
1531 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1532 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1533 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1534 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1535 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1536 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1537 assert_eq!(
1538 format!("{}", BookingMethod::StrictWithSize),
1539 "STRICT_WITH_SIZE"
1540 );
1541 }
1542
1543 #[test]
1544 fn test_booking_error_display() {
1545 let err = BookingError::AmbiguousMatch {
1546 num_matches: 3,
1547 currency: "AAPL".into(),
1548 };
1549 assert!(format!("{err}").contains("3 lots match"));
1550
1551 let err = BookingError::NoMatchingLot {
1552 currency: "AAPL".into(),
1553 cost_spec: CostSpec::empty(),
1554 };
1555 assert!(format!("{err}").contains("No matching lot"));
1556
1557 let err = BookingError::InsufficientUnits {
1558 currency: "AAPL".into(),
1559 requested: dec!(100),
1560 available: dec!(50),
1561 };
1562 assert!(format!("{err}").contains("requested 100"));
1563 assert!(format!("{err}").contains("available 50"));
1564
1565 let err = BookingError::CurrencyMismatch {
1566 expected: "USD".into(),
1567 got: "EUR".into(),
1568 };
1569 assert!(format!("{err}").contains("expected USD"));
1570 assert!(format!("{err}").contains("got EUR"));
1571 }
1572
1573 #[test]
1574 fn test_book_value_multiple_currencies() {
1575 let mut inv = Inventory::new();
1576
1577 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1579 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1580
1581 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1583 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1584
1585 let book = inv.book_value("AAPL");
1586 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1587 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1588 }
1589
1590 #[test]
1591 fn test_reduce_hifo_insufficient_units() {
1592 let mut inv = Inventory::new();
1593
1594 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1595 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1596
1597 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1598
1599 assert!(matches!(
1600 result,
1601 Err(BookingError::InsufficientUnits { .. })
1602 ));
1603 }
1604
1605 #[test]
1606 fn test_reduce_average_insufficient_units() {
1607 let mut inv = Inventory::new();
1608
1609 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1610 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1611
1612 let result = inv.reduce(
1613 &Amount::new(dec!(-20), "AAPL"),
1614 None,
1615 BookingMethod::Average,
1616 );
1617
1618 assert!(matches!(
1619 result,
1620 Err(BookingError::InsufficientUnits { .. })
1621 ));
1622 }
1623
1624 #[test]
1625 fn test_reduce_average_empty_inventory() {
1626 let mut inv = Inventory::new();
1627
1628 let result = inv.reduce(
1629 &Amount::new(dec!(-10), "AAPL"),
1630 None,
1631 BookingMethod::Average,
1632 );
1633
1634 assert!(matches!(
1635 result,
1636 Err(BookingError::InsufficientUnits { .. })
1637 ));
1638 }
1639
1640 #[test]
1641 fn test_reduce_merge_operator() {
1642 let mut inv = Inventory::new();
1644 inv.add(Position::with_cost(
1645 Amount::new(dec!(10), "AAPL"),
1646 Cost::new(dec!(150), "USD"),
1647 ));
1648 inv.add(Position::with_cost(
1649 Amount::new(dec!(10), "AAPL"),
1650 Cost::new(dec!(160), "USD"),
1651 ));
1652
1653 let merge_spec = CostSpec::empty().with_merge();
1654 let result = inv
1655 .reduce(
1656 &Amount::new(dec!(-5), "AAPL"),
1657 Some(&merge_spec),
1658 BookingMethod::Strict,
1659 )
1660 .expect("merge reduction should succeed");
1661
1662 assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1664
1665 assert_eq!(inv.positions.len(), 1);
1667 assert_eq!(inv.positions[0].units.number, dec!(15));
1668 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1669 assert_eq!(cost.number, dec!(155));
1670 }
1671
1672 #[test]
1673 fn test_reduce_merge_insufficient_units() {
1674 let mut inv = Inventory::new();
1675 inv.add(Position::with_cost(
1676 Amount::new(dec!(10), "AAPL"),
1677 Cost::new(dec!(150), "USD"),
1678 ));
1679
1680 let merge_spec = CostSpec::empty().with_merge();
1681 let result = inv.reduce(
1682 &Amount::new(dec!(-20), "AAPL"),
1683 Some(&merge_spec),
1684 BookingMethod::Strict,
1685 );
1686
1687 assert!(matches!(
1688 result,
1689 Err(BookingError::InsufficientUnits { .. })
1690 ));
1691 }
1692
1693 #[test]
1694 fn test_reduce_merge_sells_all() {
1695 let mut inv = Inventory::new();
1697 inv.add(Position::with_cost(
1698 Amount::new(dec!(10), "AAPL"),
1699 Cost::new(dec!(150), "USD"),
1700 ));
1701 inv.add(Position::with_cost(
1702 Amount::new(dec!(10), "AAPL"),
1703 Cost::new(dec!(160), "USD"),
1704 ));
1705
1706 let merge_spec = CostSpec::empty().with_merge();
1707 let result = inv
1708 .reduce(
1709 &Amount::new(dec!(-20), "AAPL"),
1710 Some(&merge_spec),
1711 BookingMethod::Strict,
1712 )
1713 .expect("merge reduction should succeed");
1714
1715 assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1717
1718 assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1720 }
1721
1722 #[test]
1723 fn test_reduce_merge_single_lot() {
1724 let mut inv = Inventory::new();
1726 inv.add(Position::with_cost(
1727 Amount::new(dec!(10), "AAPL"),
1728 Cost::new(dec!(150), "USD"),
1729 ));
1730
1731 let merge_spec = CostSpec::empty().with_merge();
1732 let result = inv
1733 .reduce(
1734 &Amount::new(dec!(-3), "AAPL"),
1735 Some(&merge_spec),
1736 BookingMethod::Strict,
1737 )
1738 .expect("single-lot merge should succeed");
1739
1740 assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1741 assert_eq!(inv.positions.len(), 1);
1742 assert_eq!(inv.positions[0].units.number, dec!(7));
1743 }
1744
1745 #[test]
1746 fn test_reduce_merge_three_lots() {
1747 let mut inv = Inventory::new();
1749 inv.add(Position::with_cost(
1750 Amount::new(dec!(10), "AAPL"),
1751 Cost::new(dec!(100), "USD"),
1752 ));
1753 inv.add(Position::with_cost(
1754 Amount::new(dec!(10), "AAPL"),
1755 Cost::new(dec!(150), "USD"),
1756 ));
1757 inv.add(Position::with_cost(
1758 Amount::new(dec!(10), "AAPL"),
1759 Cost::new(dec!(200), "USD"),
1760 ));
1761
1762 let merge_spec = CostSpec::empty().with_merge();
1764 let result = inv
1765 .reduce(
1766 &Amount::new(dec!(-6), "AAPL"),
1767 Some(&merge_spec),
1768 BookingMethod::Strict,
1769 )
1770 .expect("three-lot merge should succeed");
1771
1772 assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1773 assert_eq!(inv.positions.len(), 1);
1774 assert_eq!(inv.positions[0].units.number, dec!(24));
1775 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1776 assert_eq!(cost.number, dec!(150));
1777 }
1778
1779 #[test]
1780 fn test_reduce_merge_mixed_cost_currencies_errors() {
1781 let mut inv = Inventory::new();
1783 inv.add(Position::with_cost(
1784 Amount::new(dec!(10), "AAPL"),
1785 Cost::new(dec!(150), "USD"),
1786 ));
1787 inv.add(Position::with_cost(
1788 Amount::new(dec!(10), "AAPL"),
1789 Cost::new(dec!(130), "EUR"),
1790 ));
1791
1792 let merge_spec = CostSpec::empty().with_merge();
1793 let result = inv.reduce(
1794 &Amount::new(dec!(-5), "AAPL"),
1795 Some(&merge_spec),
1796 BookingMethod::Strict,
1797 );
1798
1799 assert!(
1800 matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1801 "expected CurrencyMismatch, got {result:?}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_reduce_merge_empty_inventory() {
1807 let mut inv = Inventory::new();
1808
1809 let merge_spec = CostSpec::empty().with_merge();
1810 let result = inv.reduce(
1811 &Amount::new(dec!(-5), "AAPL"),
1812 Some(&merge_spec),
1813 BookingMethod::Strict,
1814 );
1815
1816 assert!(matches!(
1817 result,
1818 Err(BookingError::InsufficientUnits { .. })
1819 ));
1820 }
1821
1822 #[test]
1823 fn test_inventory_display_sorted() {
1824 let mut inv = Inventory::new();
1825
1826 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1828 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1829 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1830
1831 let display = format!("{inv}");
1832
1833 let aapl_pos = display.find("AAPL").unwrap();
1835 let eur_pos = display.find("EUR").unwrap();
1836 let usd_pos = display.find("USD").unwrap();
1837
1838 assert!(aapl_pos < eur_pos);
1839 assert!(eur_pos < usd_pos);
1840 }
1841
1842 #[test]
1843 fn test_inventory_with_cost_display_sorted() {
1844 let mut inv = Inventory::new();
1845
1846 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1848 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1849
1850 inv.add(Position::with_cost(
1851 Amount::new(dec!(10), "AAPL"),
1852 cost_high,
1853 ));
1854 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1855
1856 let display = format!("{inv}");
1857
1858 assert!(display.contains("AAPL"));
1860 assert!(display.contains("100"));
1861 assert!(display.contains("200"));
1862 }
1863
1864 #[test]
1865 fn test_reduce_hifo_no_matching_lot() {
1866 let mut inv = Inventory::new();
1867
1868 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1870
1871 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1872
1873 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1874 }
1875
1876 #[test]
1877 fn test_fifo_respects_dates() {
1878 let mut inv = Inventory::new();
1880
1881 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1883 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1884
1885 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1886 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1887
1888 let result = inv
1890 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1891 .unwrap();
1892
1893 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1895 }
1896
1897 #[test]
1898 fn test_lifo_respects_dates() {
1899 let mut inv = Inventory::new();
1901
1902 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1904 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1905
1906 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1907 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1908
1909 let result = inv
1911 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1912 .unwrap();
1913
1914 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1916 }
1917
1918 #[test]
1931 fn test_strict_with_size_different_costs_exact_match() {
1932 let mut inv = Inventory::new();
1935
1936 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1937 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1938
1939 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1940 inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1941
1942 let result = inv
1944 .reduce(
1945 &Amount::new(dec!(-7), "AAPL"),
1946 None,
1947 BookingMethod::StrictWithSize,
1948 )
1949 .unwrap();
1950
1951 assert_eq!(inv.units("AAPL"), dec!(10));
1952 assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); }
1954
1955 #[test]
1956 fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1957 let mut inv = Inventory::new();
1960
1961 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1962 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1963
1964 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1965 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1966
1967 let result = inv
1969 .reduce(
1970 &Amount::new(dec!(-5), "AAPL"),
1971 None,
1972 BookingMethod::StrictWithSize,
1973 )
1974 .unwrap();
1975
1976 assert_eq!(inv.units("AAPL"), dec!(5));
1977 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1979 }
1980
1981 #[test]
1982 fn test_strict_with_size_with_cost_spec() {
1983 let mut inv = Inventory::new();
1985
1986 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1987 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1988
1989 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1990 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1991
1992 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1994 value: dec!(200.00),
1995 });
1996 let result = inv
1997 .reduce(
1998 &Amount::new(dec!(-5), "AAPL"),
1999 Some(&spec),
2000 BookingMethod::StrictWithSize,
2001 )
2002 .unwrap();
2003
2004 assert_eq!(inv.units("AAPL"), dec!(15));
2005 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
2007
2008 #[test]
2011 fn test_hifo_reduces_highest_cost_first() {
2012 let mut inv = Inventory::new();
2014
2015 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2016 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2017 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2018
2019 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
2020 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
2021 inv.add(Position::with_cost(
2022 Amount::new(dec!(10), "AAPL"),
2023 cost_high,
2024 ));
2025
2026 let result = inv
2028 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
2029 .unwrap();
2030
2031 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
2033 }
2034
2035 #[test]
2036 fn test_hifo_spans_multiple_lots() {
2037 let mut inv = Inventory::new();
2040
2041 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2042 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2043
2044 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
2045 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
2046
2047 let result = inv
2049 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
2050 .unwrap();
2051
2052 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
2054 assert_eq!(inv.units("AAPL"), dec!(2));
2055 }
2056
2057 #[test]
2058 fn test_hifo_with_cost_spec_filter() {
2059 let mut inv = Inventory::new();
2061
2062 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2063 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
2064
2065 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2066 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2067
2068 let spec = CostSpec::empty().with_currency("USD");
2070 let result = inv
2071 .reduce(
2072 &Amount::new(dec!(-5), "AAPL"),
2073 Some(&spec),
2074 BookingMethod::Hifo,
2075 )
2076 .unwrap();
2077
2078 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
2080
2081 #[test]
2082 fn test_hifo_short_position() {
2083 let mut inv = Inventory::new();
2085
2086 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2087 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2088
2089 inv.add(Position::with_cost(
2091 Amount::new(dec!(-10), "AAPL"),
2092 cost_low,
2093 ));
2094 inv.add(Position::with_cost(
2095 Amount::new(dec!(-10), "AAPL"),
2096 cost_high,
2097 ));
2098
2099 let result = inv
2102 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2103 .unwrap();
2104
2105 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2107 }
2108
2109 #[test]
2112 fn test_average_weighted_cost() {
2113 let mut inv = Inventory::new();
2115
2116 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2117 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2118
2119 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2120 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2121
2122 let result = inv
2124 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2125 .unwrap();
2126
2127 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2129 assert_eq!(inv.units("AAPL"), dec!(15));
2130 }
2131
2132 #[test]
2133 fn test_average_merges_into_single_position() {
2134 let mut inv = Inventory::new();
2136
2137 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2138 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2139
2140 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2141 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2142
2143 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2144 .unwrap();
2145
2146 let aapl_positions: Vec<_> = inv
2148 .positions
2149 .iter()
2150 .filter(|p| p.units.currency.as_ref() == "AAPL")
2151 .collect();
2152 assert_eq!(aapl_positions.len(), 1);
2153 assert_eq!(aapl_positions[0].units.number, dec!(15));
2154 }
2155
2156 #[test]
2157 fn test_average_uneven_lots() {
2158 let mut inv = Inventory::new();
2160
2161 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2162 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2163
2164 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2165 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2166
2167 let result = inv
2169 .reduce(
2170 &Amount::new(dec!(-10), "AAPL"),
2171 None,
2172 BookingMethod::Average,
2173 )
2174 .unwrap();
2175
2176 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
2178
2179 #[test]
2182 fn test_none_booking_with_cost_positions() {
2183 let mut inv = Inventory::new();
2185
2186 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2187 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2188
2189 let result = inv
2190 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2191 .unwrap();
2192
2193 assert_eq!(inv.units("AAPL"), dec!(5));
2194 assert!(result.cost_basis.is_some());
2196 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2197 }
2198
2199 #[test]
2200 fn test_none_booking_short_cover() {
2201 let mut inv = Inventory::new();
2203 inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2204
2205 let result = inv
2207 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2208 .unwrap();
2209
2210 assert_eq!(inv.units("USD"), dec!(-70));
2211 assert!(!result.matched.is_empty());
2212 }
2213
2214 #[test]
2215 fn test_none_booking_empty_inventory_augments() {
2216 let mut inv = Inventory::new();
2218
2219 let result = inv
2220 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2221 .unwrap();
2222
2223 assert_eq!(inv.units("USD"), dec!(50));
2224 assert!(result.matched.is_empty()); }
2226
2227 #[test]
2230 fn test_fifo_short_position_cover() {
2231 let mut inv = Inventory::new();
2233
2234 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2235 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2236
2237 inv.add(Position::with_cost(
2238 Amount::new(dec!(-10), "AAPL"),
2239 cost_old,
2240 ));
2241 inv.add(Position::with_cost(
2242 Amount::new(dec!(-10), "AAPL"),
2243 cost_new,
2244 ));
2245
2246 let result = inv
2248 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2249 .unwrap();
2250
2251 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2253 }
2254
2255 #[test]
2256 fn test_lifo_short_position_cover() {
2257 let mut inv = Inventory::new();
2259
2260 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2261 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2262
2263 inv.add(Position::with_cost(
2264 Amount::new(dec!(-10), "AAPL"),
2265 cost_old,
2266 ));
2267 inv.add(Position::with_cost(
2268 Amount::new(dec!(-10), "AAPL"),
2269 cost_new,
2270 ));
2271
2272 let result = inv
2274 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2275 .unwrap();
2276
2277 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2279 }
2280
2281 #[test]
2302 fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2303 let mut inv = Inventory::new();
2313
2314 let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2316 inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2317
2318 inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2320
2321 let buy_units = Amount::new(dec!(50), "HOOG");
2323
2324 assert!(
2328 !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2329 "augmentation with cost spec should NOT be treated as reduction \
2330 when only a simple (no-cost) position has opposite sign"
2331 );
2332
2333 assert!(
2336 inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2337 "without cost spec filter, the -25 HOOG simple position \
2338 should cause is_reduced_by to return true"
2339 );
2340 }
2341
2342 #[test]
2343 fn is_booking_reduction_gates_on_method_cost_and_sign() {
2344 let mut inv = Inventory::new();
2346 inv.add(Position::with_cost(
2347 Amount::new(dec!(10), "AAPL"),
2348 Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
2349 ));
2350
2351 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));
2358 assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
2360 assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
2362 assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
2364 }
2365
2366 #[test]
2367 fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
2368 let mut bank = Inventory::new();
2369 bank.add(Position::simple(Amount::new(dec!(10), "USD")));
2370 let mut checking = Inventory::new(); checking.add(Position::simple(Amount::new(dec!(40), "USD")));
2372 let mut alias = Inventory::new(); alias.add(Position::simple(Amount::new(dec!(99), "USD")));
2374
2375 let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
2376 map.insert(Account::from("Assets:Bank"), bank);
2377 map.insert(Account::from("Assets:Bank:Checking"), checking);
2378 map.insert(Account::from("Assets:BankAlias"), alias);
2379
2380 let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"));
2381 assert_eq!(
2382 total,
2383 dec!(50),
2384 "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
2385 );
2386 }
2387
2388 #[test]
2389 fn test_accounted_error_display_insufficient_units() {
2390 let err = BookingError::InsufficientUnits {
2391 currency: "AAPL".into(),
2392 requested: dec!(15),
2393 available: dec!(10),
2394 }
2395 .with_account("Assets:Stock".into());
2396 let rendered = format!("{err}");
2397
2398 assert!(
2401 rendered.contains("not enough"),
2402 "must contain 'not enough' (pta-standards): {rendered}"
2403 );
2404 assert!(
2405 rendered.contains("Assets:Stock"),
2406 "must contain account name: {rendered}"
2407 );
2408 assert!(
2409 rendered.contains("15") && rendered.contains("10"),
2410 "must contain requested and available amounts: {rendered}"
2411 );
2412 }
2413
2414 #[test]
2415 fn test_accounted_error_display_no_matching_lot() {
2416 let err = BookingError::NoMatchingLot {
2417 currency: "AAPL".into(),
2418 cost_spec: CostSpec::empty(),
2419 }
2420 .with_account("Assets:Stock".into());
2421 let rendered = format!("{err}");
2422
2423 assert!(
2424 rendered.contains("No matching lot"),
2425 "must contain 'No matching lot': {rendered}"
2426 );
2427 assert!(
2428 rendered.contains("AAPL"),
2429 "must contain currency: {rendered}"
2430 );
2431 assert!(
2432 rendered.contains("Assets:Stock"),
2433 "must contain account name: {rendered}"
2434 );
2435 }
2436
2437 #[test]
2438 fn test_accounted_error_display_ambiguous_match() {
2439 let err = BookingError::AmbiguousMatch {
2440 num_matches: 3,
2441 currency: "AAPL".into(),
2442 }
2443 .with_account("Assets:Stock".into());
2444 let rendered = format!("{err}");
2445
2446 assert!(
2447 rendered.contains("Ambiguous"),
2448 "must contain 'Ambiguous': {rendered}"
2449 );
2450 assert!(
2451 rendered.contains("AAPL"),
2452 "must contain currency: {rendered}"
2453 );
2454 assert!(
2455 rendered.contains("Assets:Stock"),
2456 "must contain account name: {rendered}"
2457 );
2458 assert!(
2459 rendered.contains('3'),
2460 "must contain match count: {rendered}"
2461 );
2462 }
2463
2464 #[test]
2465 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2466 let err = BookingError::CurrencyMismatch {
2474 expected: "USD".into(),
2475 got: "EUR".into(),
2476 }
2477 .with_account("Assets:Cash".into());
2478 let rendered = format!("{err}");
2479
2480 assert!(
2481 rendered.contains("No matching lot"),
2482 "CurrencyMismatch must render as 'No matching lot' for E4001 \
2483 consistency: {rendered}"
2484 );
2485 assert!(
2486 rendered.contains("EUR"),
2487 "must contain the mismatched (got) currency: {rendered}"
2488 );
2489 assert!(
2490 rendered.contains("Assets:Cash"),
2491 "must contain account name: {rendered}"
2492 );
2493 }
2494}