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_shorts_past_zero() {
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);
1305
1306 assert!(result.is_ok(), "NONE must allow shorting: {result:?}");
1307 assert_eq!(inv.units("USD"), dec!(-50));
1308 }
1309
1310 #[test]
1311 fn test_booking_error_no_matching_lot() {
1312 let mut inv = Inventory::new();
1313
1314 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1316 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1317
1318 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1320 let result = inv.reduce(
1321 &Amount::new(dec!(-5), "AAPL"),
1322 Some(&wrong_spec),
1323 BookingMethod::Strict,
1324 );
1325
1326 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1327 }
1328
1329 #[test]
1330 fn test_booking_error_insufficient_units() {
1331 let mut inv = Inventory::new();
1332
1333 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1334 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1335
1336 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1338
1339 match result {
1340 Err(BookingError::InsufficientUnits {
1341 requested,
1342 available,
1343 ..
1344 }) => {
1345 assert_eq!(requested, dec!(20));
1346 assert_eq!(available, dec!(10));
1347 }
1348 _ => panic!("Expected InsufficientUnits error"),
1349 }
1350 }
1351
1352 #[test]
1353 fn test_strict_with_size_exact_match() {
1354 let mut inv = Inventory::new();
1355
1356 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1358 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1359
1360 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1361 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1362
1363 let result = inv
1365 .reduce(
1366 &Amount::new(dec!(-5), "AAPL"),
1367 None,
1368 BookingMethod::StrictWithSize,
1369 )
1370 .unwrap();
1371
1372 assert_eq!(inv.units("AAPL"), dec!(10));
1373 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1374 }
1375
1376 #[test]
1377 fn test_strict_with_size_total_match() {
1378 let mut inv = Inventory::new();
1379
1380 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1382 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1383
1384 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1385 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1386
1387 let result = inv
1389 .reduce(
1390 &Amount::new(dec!(-15), "AAPL"),
1391 None,
1392 BookingMethod::StrictWithSize,
1393 )
1394 .unwrap();
1395
1396 assert_eq!(inv.units("AAPL"), dec!(0));
1397 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1398 }
1399
1400 #[test]
1401 fn test_strict_with_size_ambiguous() {
1402 let mut inv = Inventory::new();
1403
1404 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1406 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1407
1408 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1409 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1410
1411 let result = inv.reduce(
1413 &Amount::new(dec!(-7), "AAPL"),
1414 None,
1415 BookingMethod::StrictWithSize,
1416 );
1417
1418 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1419 }
1420
1421 #[test]
1422 fn test_short_position() {
1423 let mut inv = Inventory::new();
1425
1426 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1428 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1429
1430 assert_eq!(inv.units("AAPL"), dec!(-10));
1431 assert!(!inv.is_empty());
1432 }
1433
1434 #[test]
1435 fn test_at_cost() {
1436 let mut inv = Inventory::new();
1437
1438 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1439 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1440
1441 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1442 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1443 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1444
1445 let at_cost = inv.at_cost();
1446
1447 assert_eq!(at_cost.units("USD"), dec!(1850));
1450 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1452
1453 #[test]
1454 fn test_at_units() {
1455 let mut inv = Inventory::new();
1456
1457 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1458 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1459
1460 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1461 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1462
1463 let at_units = inv.at_units();
1464
1465 assert_eq!(at_units.units("AAPL"), dec!(15));
1467 assert_eq!(at_units.len(), 1);
1469 }
1470
1471 #[test]
1472 fn test_add_empty_position() {
1473 let mut inv = Inventory::new();
1474 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1475
1476 assert!(inv.is_empty());
1477 assert_eq!(inv.len(), 0);
1478 }
1479
1480 #[test]
1481 fn test_compact() {
1482 let mut inv = Inventory::new();
1483
1484 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1485 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1486
1487 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1489 .unwrap();
1490
1491 inv.compact();
1493 assert!(inv.is_empty());
1494 assert_eq!(inv.len(), 0);
1495 }
1496
1497 #[test]
1498 fn test_booking_method_from_str() {
1499 assert_eq!(
1500 BookingMethod::from_str("STRICT").unwrap(),
1501 BookingMethod::Strict
1502 );
1503 assert_eq!(
1504 BookingMethod::from_str("fifo").unwrap(),
1505 BookingMethod::Fifo
1506 );
1507 assert_eq!(
1508 BookingMethod::from_str("LIFO").unwrap(),
1509 BookingMethod::Lifo
1510 );
1511 assert_eq!(
1512 BookingMethod::from_str("Hifo").unwrap(),
1513 BookingMethod::Hifo
1514 );
1515 assert_eq!(
1516 BookingMethod::from_str("AVERAGE").unwrap(),
1517 BookingMethod::Average
1518 );
1519 assert_eq!(
1520 BookingMethod::from_str("NONE").unwrap(),
1521 BookingMethod::None
1522 );
1523 assert_eq!(
1524 BookingMethod::from_str("strict_with_size").unwrap(),
1525 BookingMethod::StrictWithSize
1526 );
1527 assert!(BookingMethod::from_str("INVALID").is_err());
1528 }
1529
1530 #[test]
1531 fn test_booking_method_display() {
1532 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1533 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1534 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1535 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1536 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1537 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1538 assert_eq!(
1539 format!("{}", BookingMethod::StrictWithSize),
1540 "STRICT_WITH_SIZE"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_booking_error_display() {
1546 let err = BookingError::AmbiguousMatch {
1547 num_matches: 3,
1548 currency: "AAPL".into(),
1549 };
1550 assert!(format!("{err}").contains("3 lots match"));
1551
1552 let err = BookingError::NoMatchingLot {
1553 currency: "AAPL".into(),
1554 cost_spec: CostSpec::empty(),
1555 };
1556 assert!(format!("{err}").contains("No matching lot"));
1557
1558 let err = BookingError::InsufficientUnits {
1559 currency: "AAPL".into(),
1560 requested: dec!(100),
1561 available: dec!(50),
1562 };
1563 assert!(format!("{err}").contains("requested 100"));
1564 assert!(format!("{err}").contains("available 50"));
1565
1566 let err = BookingError::CurrencyMismatch {
1567 expected: "USD".into(),
1568 got: "EUR".into(),
1569 };
1570 assert!(format!("{err}").contains("expected USD"));
1571 assert!(format!("{err}").contains("got EUR"));
1572 }
1573
1574 #[test]
1575 fn test_book_value_multiple_currencies() {
1576 let mut inv = Inventory::new();
1577
1578 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1580 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1581
1582 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1584 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1585
1586 let book = inv.book_value("AAPL");
1587 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1588 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1589 }
1590
1591 #[test]
1592 fn test_reduce_hifo_insufficient_units() {
1593 let mut inv = Inventory::new();
1594
1595 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1596 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1597
1598 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1599
1600 assert!(matches!(
1601 result,
1602 Err(BookingError::InsufficientUnits { .. })
1603 ));
1604 }
1605
1606 #[test]
1607 fn test_reduce_average_insufficient_units() {
1608 let mut inv = Inventory::new();
1609
1610 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1611 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1612
1613 let result = inv.reduce(
1614 &Amount::new(dec!(-20), "AAPL"),
1615 None,
1616 BookingMethod::Average,
1617 );
1618
1619 assert!(matches!(
1620 result,
1621 Err(BookingError::InsufficientUnits { .. })
1622 ));
1623 }
1624
1625 #[test]
1626 fn test_reduce_average_empty_inventory() {
1627 let mut inv = Inventory::new();
1628
1629 let result = inv.reduce(
1630 &Amount::new(dec!(-10), "AAPL"),
1631 None,
1632 BookingMethod::Average,
1633 );
1634
1635 assert!(matches!(
1636 result,
1637 Err(BookingError::InsufficientUnits { .. })
1638 ));
1639 }
1640
1641 #[test]
1642 fn test_reduce_merge_operator() {
1643 let mut inv = Inventory::new();
1645 inv.add(Position::with_cost(
1646 Amount::new(dec!(10), "AAPL"),
1647 Cost::new(dec!(150), "USD"),
1648 ));
1649 inv.add(Position::with_cost(
1650 Amount::new(dec!(10), "AAPL"),
1651 Cost::new(dec!(160), "USD"),
1652 ));
1653
1654 let merge_spec = CostSpec::empty().with_merge();
1655 let result = inv
1656 .reduce(
1657 &Amount::new(dec!(-5), "AAPL"),
1658 Some(&merge_spec),
1659 BookingMethod::Strict,
1660 )
1661 .expect("merge reduction should succeed");
1662
1663 assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1665
1666 assert_eq!(inv.positions.len(), 1);
1668 assert_eq!(inv.positions[0].units.number, dec!(15));
1669 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1670 assert_eq!(cost.number, dec!(155));
1671 }
1672
1673 #[test]
1674 fn test_reduce_merge_insufficient_units() {
1675 let mut inv = Inventory::new();
1676 inv.add(Position::with_cost(
1677 Amount::new(dec!(10), "AAPL"),
1678 Cost::new(dec!(150), "USD"),
1679 ));
1680
1681 let merge_spec = CostSpec::empty().with_merge();
1682 let result = inv.reduce(
1683 &Amount::new(dec!(-20), "AAPL"),
1684 Some(&merge_spec),
1685 BookingMethod::Strict,
1686 );
1687
1688 assert!(matches!(
1689 result,
1690 Err(BookingError::InsufficientUnits { .. })
1691 ));
1692 }
1693
1694 #[test]
1695 fn test_reduce_merge_sells_all() {
1696 let mut inv = Inventory::new();
1698 inv.add(Position::with_cost(
1699 Amount::new(dec!(10), "AAPL"),
1700 Cost::new(dec!(150), "USD"),
1701 ));
1702 inv.add(Position::with_cost(
1703 Amount::new(dec!(10), "AAPL"),
1704 Cost::new(dec!(160), "USD"),
1705 ));
1706
1707 let merge_spec = CostSpec::empty().with_merge();
1708 let result = inv
1709 .reduce(
1710 &Amount::new(dec!(-20), "AAPL"),
1711 Some(&merge_spec),
1712 BookingMethod::Strict,
1713 )
1714 .expect("merge reduction should succeed");
1715
1716 assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1718
1719 assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1721 }
1722
1723 #[test]
1724 fn test_reduce_merge_single_lot() {
1725 let mut inv = Inventory::new();
1727 inv.add(Position::with_cost(
1728 Amount::new(dec!(10), "AAPL"),
1729 Cost::new(dec!(150), "USD"),
1730 ));
1731
1732 let merge_spec = CostSpec::empty().with_merge();
1733 let result = inv
1734 .reduce(
1735 &Amount::new(dec!(-3), "AAPL"),
1736 Some(&merge_spec),
1737 BookingMethod::Strict,
1738 )
1739 .expect("single-lot merge should succeed");
1740
1741 assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1742 assert_eq!(inv.positions.len(), 1);
1743 assert_eq!(inv.positions[0].units.number, dec!(7));
1744 }
1745
1746 #[test]
1747 fn test_reduce_merge_three_lots() {
1748 let mut inv = Inventory::new();
1750 inv.add(Position::with_cost(
1751 Amount::new(dec!(10), "AAPL"),
1752 Cost::new(dec!(100), "USD"),
1753 ));
1754 inv.add(Position::with_cost(
1755 Amount::new(dec!(10), "AAPL"),
1756 Cost::new(dec!(150), "USD"),
1757 ));
1758 inv.add(Position::with_cost(
1759 Amount::new(dec!(10), "AAPL"),
1760 Cost::new(dec!(200), "USD"),
1761 ));
1762
1763 let merge_spec = CostSpec::empty().with_merge();
1765 let result = inv
1766 .reduce(
1767 &Amount::new(dec!(-6), "AAPL"),
1768 Some(&merge_spec),
1769 BookingMethod::Strict,
1770 )
1771 .expect("three-lot merge should succeed");
1772
1773 assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1774 assert_eq!(inv.positions.len(), 1);
1775 assert_eq!(inv.positions[0].units.number, dec!(24));
1776 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1777 assert_eq!(cost.number, dec!(150));
1778 }
1779
1780 #[test]
1781 fn test_reduce_merge_mixed_cost_currencies_errors() {
1782 let mut inv = Inventory::new();
1784 inv.add(Position::with_cost(
1785 Amount::new(dec!(10), "AAPL"),
1786 Cost::new(dec!(150), "USD"),
1787 ));
1788 inv.add(Position::with_cost(
1789 Amount::new(dec!(10), "AAPL"),
1790 Cost::new(dec!(130), "EUR"),
1791 ));
1792
1793 let merge_spec = CostSpec::empty().with_merge();
1794 let result = inv.reduce(
1795 &Amount::new(dec!(-5), "AAPL"),
1796 Some(&merge_spec),
1797 BookingMethod::Strict,
1798 );
1799
1800 assert!(
1801 matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1802 "expected CurrencyMismatch, got {result:?}"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_reduce_merge_empty_inventory() {
1808 let mut inv = Inventory::new();
1809
1810 let merge_spec = CostSpec::empty().with_merge();
1811 let result = inv.reduce(
1812 &Amount::new(dec!(-5), "AAPL"),
1813 Some(&merge_spec),
1814 BookingMethod::Strict,
1815 );
1816
1817 assert!(matches!(
1818 result,
1819 Err(BookingError::InsufficientUnits { .. })
1820 ));
1821 }
1822
1823 #[test]
1824 fn test_inventory_display_sorted() {
1825 let mut inv = Inventory::new();
1826
1827 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1829 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1830 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1831
1832 let display = format!("{inv}");
1833
1834 let aapl_pos = display.find("AAPL").unwrap();
1836 let eur_pos = display.find("EUR").unwrap();
1837 let usd_pos = display.find("USD").unwrap();
1838
1839 assert!(aapl_pos < eur_pos);
1840 assert!(eur_pos < usd_pos);
1841 }
1842
1843 #[test]
1844 fn test_inventory_with_cost_display_sorted() {
1845 let mut inv = Inventory::new();
1846
1847 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1849 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1850
1851 inv.add(Position::with_cost(
1852 Amount::new(dec!(10), "AAPL"),
1853 cost_high,
1854 ));
1855 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1856
1857 let display = format!("{inv}");
1858
1859 assert!(display.contains("AAPL"));
1861 assert!(display.contains("100"));
1862 assert!(display.contains("200"));
1863 }
1864
1865 #[test]
1866 fn test_reduce_hifo_no_matching_lot() {
1867 let mut inv = Inventory::new();
1868
1869 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1871
1872 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1873
1874 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1875 }
1876
1877 #[test]
1878 fn test_fifo_respects_dates() {
1879 let mut inv = Inventory::new();
1881
1882 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1884 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1885
1886 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1887 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1888
1889 let result = inv
1891 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1892 .unwrap();
1893
1894 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1896 }
1897
1898 #[test]
1899 fn test_lifo_respects_dates() {
1900 let mut inv = Inventory::new();
1902
1903 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1905 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1906
1907 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1908 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1909
1910 let result = inv
1912 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1913 .unwrap();
1914
1915 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1917 }
1918
1919 #[test]
1932 fn test_strict_with_size_different_costs_exact_match() {
1933 let mut inv = Inventory::new();
1936
1937 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1938 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1939
1940 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1941 inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1942
1943 let result = inv
1945 .reduce(
1946 &Amount::new(dec!(-7), "AAPL"),
1947 None,
1948 BookingMethod::StrictWithSize,
1949 )
1950 .unwrap();
1951
1952 assert_eq!(inv.units("AAPL"), dec!(10));
1953 assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); }
1955
1956 #[test]
1957 fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1958 let mut inv = Inventory::new();
1961
1962 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1963 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1964
1965 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1966 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1967
1968 let result = inv
1970 .reduce(
1971 &Amount::new(dec!(-5), "AAPL"),
1972 None,
1973 BookingMethod::StrictWithSize,
1974 )
1975 .unwrap();
1976
1977 assert_eq!(inv.units("AAPL"), dec!(5));
1978 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1980 }
1981
1982 #[test]
1983 fn test_strict_with_size_with_cost_spec() {
1984 let mut inv = Inventory::new();
1986
1987 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1988 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1989
1990 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1991 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1992
1993 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1995 value: dec!(200.00),
1996 });
1997 let result = inv
1998 .reduce(
1999 &Amount::new(dec!(-5), "AAPL"),
2000 Some(&spec),
2001 BookingMethod::StrictWithSize,
2002 )
2003 .unwrap();
2004
2005 assert_eq!(inv.units("AAPL"), dec!(15));
2006 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
2008
2009 #[test]
2012 fn test_hifo_reduces_highest_cost_first() {
2013 let mut inv = Inventory::new();
2015
2016 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2017 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2018 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2019
2020 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
2021 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
2022 inv.add(Position::with_cost(
2023 Amount::new(dec!(10), "AAPL"),
2024 cost_high,
2025 ));
2026
2027 let result = inv
2029 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
2030 .unwrap();
2031
2032 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
2034 }
2035
2036 #[test]
2037 fn test_hifo_spans_multiple_lots() {
2038 let mut inv = Inventory::new();
2041
2042 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2043 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2044
2045 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
2046 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
2047
2048 let result = inv
2050 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
2051 .unwrap();
2052
2053 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
2055 assert_eq!(inv.units("AAPL"), dec!(2));
2056 }
2057
2058 #[test]
2059 fn test_hifo_with_cost_spec_filter() {
2060 let mut inv = Inventory::new();
2062
2063 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2064 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
2065
2066 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2067 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2068
2069 let spec = CostSpec::empty().with_currency("USD");
2071 let result = inv
2072 .reduce(
2073 &Amount::new(dec!(-5), "AAPL"),
2074 Some(&spec),
2075 BookingMethod::Hifo,
2076 )
2077 .unwrap();
2078
2079 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
2081
2082 #[test]
2083 fn test_hifo_short_position() {
2084 let mut inv = Inventory::new();
2086
2087 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2088 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2089
2090 inv.add(Position::with_cost(
2092 Amount::new(dec!(-10), "AAPL"),
2093 cost_low,
2094 ));
2095 inv.add(Position::with_cost(
2096 Amount::new(dec!(-10), "AAPL"),
2097 cost_high,
2098 ));
2099
2100 let result = inv
2103 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2104 .unwrap();
2105
2106 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2108 }
2109
2110 #[test]
2113 fn test_average_weighted_cost() {
2114 let mut inv = Inventory::new();
2116
2117 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2118 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2119
2120 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2121 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2122
2123 let result = inv
2125 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2126 .unwrap();
2127
2128 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2130 assert_eq!(inv.units("AAPL"), dec!(15));
2131 }
2132
2133 #[test]
2134 fn test_average_merges_into_single_position() {
2135 let mut inv = Inventory::new();
2137
2138 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2139 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2140
2141 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2142 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2143
2144 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2145 .unwrap();
2146
2147 let aapl_positions: Vec<_> = inv
2149 .positions
2150 .iter()
2151 .filter(|p| p.units.currency.as_ref() == "AAPL")
2152 .collect();
2153 assert_eq!(aapl_positions.len(), 1);
2154 assert_eq!(aapl_positions[0].units.number, dec!(15));
2155 }
2156
2157 #[test]
2158 fn test_average_uneven_lots() {
2159 let mut inv = Inventory::new();
2161
2162 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2163 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2164
2165 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2166 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2167
2168 let result = inv
2170 .reduce(
2171 &Amount::new(dec!(-10), "AAPL"),
2172 None,
2173 BookingMethod::Average,
2174 )
2175 .unwrap();
2176
2177 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
2179
2180 #[test]
2183 fn test_none_booking_with_cost_positions() {
2184 let mut inv = Inventory::new();
2186
2187 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2188 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2189
2190 let result = inv
2191 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2192 .unwrap();
2193
2194 assert_eq!(inv.units("AAPL"), dec!(5));
2195 assert!(result.cost_basis.is_some());
2197 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2198 }
2199
2200 #[test]
2201 fn test_none_booking_short_cover() {
2202 let mut inv = Inventory::new();
2204 inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2205
2206 let result = inv
2208 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2209 .unwrap();
2210
2211 assert_eq!(inv.units("USD"), dec!(-70));
2212 assert!(!result.matched.is_empty());
2213 }
2214
2215 #[test]
2216 fn test_none_booking_empty_inventory_augments() {
2217 let mut inv = Inventory::new();
2219
2220 let result = inv
2221 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2222 .unwrap();
2223
2224 assert_eq!(inv.units("USD"), dec!(50));
2225 assert!(result.matched.is_empty()); }
2227
2228 #[test]
2231 fn test_fifo_short_position_cover() {
2232 let mut inv = Inventory::new();
2234
2235 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2236 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2237
2238 inv.add(Position::with_cost(
2239 Amount::new(dec!(-10), "AAPL"),
2240 cost_old,
2241 ));
2242 inv.add(Position::with_cost(
2243 Amount::new(dec!(-10), "AAPL"),
2244 cost_new,
2245 ));
2246
2247 let result = inv
2249 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2250 .unwrap();
2251
2252 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2254 }
2255
2256 #[test]
2257 fn test_lifo_short_position_cover() {
2258 let mut inv = Inventory::new();
2260
2261 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2262 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2263
2264 inv.add(Position::with_cost(
2265 Amount::new(dec!(-10), "AAPL"),
2266 cost_old,
2267 ));
2268 inv.add(Position::with_cost(
2269 Amount::new(dec!(-10), "AAPL"),
2270 cost_new,
2271 ));
2272
2273 let result = inv
2275 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2276 .unwrap();
2277
2278 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2280 }
2281
2282 #[test]
2303 fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2304 let mut inv = Inventory::new();
2314
2315 let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2317 inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2318
2319 inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2321
2322 let buy_units = Amount::new(dec!(50), "HOOG");
2324
2325 assert!(
2329 !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2330 "augmentation with cost spec should NOT be treated as reduction \
2331 when only a simple (no-cost) position has opposite sign"
2332 );
2333
2334 assert!(
2337 inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2338 "without cost spec filter, the -25 HOOG simple position \
2339 should cause is_reduced_by to return true"
2340 );
2341 }
2342
2343 #[test]
2344 fn is_booking_reduction_gates_on_method_cost_and_sign() {
2345 let mut inv = Inventory::new();
2347 inv.add(Position::with_cost(
2348 Amount::new(dec!(10), "AAPL"),
2349 Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
2350 ));
2351
2352 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));
2359 assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
2361 assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
2363 assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
2365 }
2366
2367 #[test]
2368 fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
2369 let mut bank = Inventory::new();
2370 bank.add(Position::simple(Amount::new(dec!(10), "USD")));
2371 let mut checking = Inventory::new(); checking.add(Position::simple(Amount::new(dec!(40), "USD")));
2373 let mut alias = Inventory::new(); alias.add(Position::simple(Amount::new(dec!(99), "USD")));
2375
2376 let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
2377 map.insert(Account::from("Assets:Bank"), bank);
2378 map.insert(Account::from("Assets:Bank:Checking"), checking);
2379 map.insert(Account::from("Assets:BankAlias"), alias);
2380
2381 let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"));
2382 assert_eq!(
2383 total,
2384 dec!(50),
2385 "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
2386 );
2387 }
2388
2389 #[test]
2390 fn test_accounted_error_display_insufficient_units() {
2391 let err = BookingError::InsufficientUnits {
2392 currency: "AAPL".into(),
2393 requested: dec!(15),
2394 available: dec!(10),
2395 }
2396 .with_account("Assets:Stock".into());
2397 let rendered = format!("{err}");
2398
2399 assert!(
2402 rendered.contains("not enough"),
2403 "must contain 'not enough' (pta-standards): {rendered}"
2404 );
2405 assert!(
2406 rendered.contains("Assets:Stock"),
2407 "must contain account name: {rendered}"
2408 );
2409 assert!(
2410 rendered.contains("15") && rendered.contains("10"),
2411 "must contain requested and available amounts: {rendered}"
2412 );
2413 }
2414
2415 #[test]
2416 fn test_accounted_error_display_no_matching_lot() {
2417 let err = BookingError::NoMatchingLot {
2418 currency: "AAPL".into(),
2419 cost_spec: CostSpec::empty(),
2420 }
2421 .with_account("Assets:Stock".into());
2422 let rendered = format!("{err}");
2423
2424 assert!(
2425 rendered.contains("No matching lot"),
2426 "must contain 'No matching lot': {rendered}"
2427 );
2428 assert!(
2429 rendered.contains("AAPL"),
2430 "must contain currency: {rendered}"
2431 );
2432 assert!(
2433 rendered.contains("Assets:Stock"),
2434 "must contain account name: {rendered}"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_accounted_error_display_ambiguous_match() {
2440 let err = BookingError::AmbiguousMatch {
2441 num_matches: 3,
2442 currency: "AAPL".into(),
2443 }
2444 .with_account("Assets:Stock".into());
2445 let rendered = format!("{err}");
2446
2447 assert!(
2448 rendered.contains("Ambiguous"),
2449 "must contain 'Ambiguous': {rendered}"
2450 );
2451 assert!(
2452 rendered.contains("AAPL"),
2453 "must contain currency: {rendered}"
2454 );
2455 assert!(
2456 rendered.contains("Assets:Stock"),
2457 "must contain account name: {rendered}"
2458 );
2459 assert!(
2460 rendered.contains('3'),
2461 "must contain match count: {rendered}"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2467 let err = BookingError::CurrencyMismatch {
2475 expected: "USD".into(),
2476 got: "EUR".into(),
2477 }
2478 .with_account("Assets:Cash".into());
2479 let rendered = format!("{err}");
2480
2481 assert!(
2482 rendered.contains("No matching lot"),
2483 "CurrencyMismatch must render as 'No matching lot' for E4001 \
2484 consistency: {rendered}"
2485 );
2486 assert!(
2487 rendered.contains("EUR"),
2488 "must contain the mismatched (got) currency: {rendered}"
2489 );
2490 assert!(
2491 rendered.contains("Assets:Cash"),
2492 "must contain account name: {rendered}"
2493 );
2494 }
2495}