1use im::Vector;
8use rust_decimal::Decimal;
9use rustc_hash::FxHashMap;
10use serde::{Deserialize, Serialize};
11use smallvec::SmallVec;
12use std::fmt;
13use std::str::FromStr;
14
15use crate::intern::InternedStr;
16use crate::{Amount, CostSpec, Position};
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: InternedStr,
140 },
141 NoMatchingLot {
143 currency: InternedStr,
145 cost_spec: CostSpec,
147 },
148 InsufficientUnits {
150 currency: InternedStr,
152 requested: Decimal,
154 available: Decimal,
156 },
157 CurrencyMismatch {
159 expected: InternedStr,
161 got: InternedStr,
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: InternedStr) -> 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: InternedStr,
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<InternedStr, usize>,
330 #[serde(skip)]
334 units_cache: FxHashMap<InternedStr, 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]
465 pub fn book_value(&self, units_currency: &str) -> FxHashMap<InternedStr, Decimal> {
466 let mut totals: FxHashMap<InternedStr, Decimal> = FxHashMap::default();
467
468 for pos in &self.positions {
469 if pos.units.currency == units_currency
470 && let Some(book) = pos.book_value()
471 {
472 *totals.entry(book.currency.clone()).or_default() += book.number;
473 }
474 }
475
476 totals
477 }
478
479 pub fn add(&mut self, position: Position) {
496 if position.is_empty() {
497 return;
498 }
499
500 *self
502 .units_cache
503 .entry(position.units.currency.clone())
504 .or_default() += position.units.number;
505
506 if position.cost.is_none() {
508 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
509 debug_assert!(self.positions[idx].cost.is_none());
511 self.positions[idx].units += &position.units;
512 return;
513 }
514 let idx = self.positions.len();
516 self.simple_index
517 .insert(position.units.currency.clone(), idx);
518 self.positions.push_back(position);
519 return;
520 }
521
522 self.positions.push_back(position);
526 }
527
528 pub fn reduce(
555 &mut self,
556 units: &Amount,
557 cost_spec: Option<&CostSpec>,
558 method: BookingMethod,
559 ) -> Result<BookingResult, BookingError> {
560 let spec = cost_spec.cloned().unwrap_or_default();
561
562 if spec.merge {
565 return self.reduce_merge(units);
566 }
567
568 match method {
569 BookingMethod::Strict => self.reduce_strict(units, &spec),
570 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
571 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
572 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
573 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
574 BookingMethod::Average => self.reduce_average(units),
575 BookingMethod::None => self.reduce_none(units),
576 }
577 }
578
579 pub fn compact(&mut self) {
581 self.positions.retain(|p| !p.is_empty());
582 self.rebuild_index();
583 }
584
585 fn rebuild_index(&mut self) {
588 self.simple_index.clear();
589 self.units_cache.clear();
590
591 for (idx, pos) in self.positions.iter().enumerate() {
592 *self
594 .units_cache
595 .entry(pos.units.currency.clone())
596 .or_default() += pos.units.number;
597
598 if pos.cost.is_none() {
600 debug_assert!(
601 !self.simple_index.contains_key(&pos.units.currency),
602 "Invariant violated: multiple simple positions for currency {}",
603 pos.units.currency
604 );
605 self.simple_index.insert(pos.units.currency.clone(), idx);
606 }
607 }
608 }
609
610 pub fn merge(&mut self, other: &Self) {
612 for pos in &other.positions {
613 self.add(pos.clone());
614 }
615 }
616
617 #[must_use]
622 pub fn at_cost(&self) -> Self {
623 let mut result = Self::new();
624
625 for pos in &self.positions {
626 if pos.is_empty() {
627 continue;
628 }
629
630 if let Some(cost) = &pos.cost {
631 let total = pos.units.number * cost.number;
633 result.add(Position::simple(Amount::new(total, &cost.currency)));
634 } else {
635 result.add(pos.clone());
637 }
638 }
639
640 result
641 }
642
643 #[must_use]
648 pub fn at_units(&self) -> Self {
649 let mut result = Self::new();
650
651 for pos in &self.positions {
652 if pos.is_empty() {
653 continue;
654 }
655
656 result.add(Position::simple(pos.units.clone()));
658 }
659
660 result
661 }
662}
663
664impl fmt::Display for Inventory {
665 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666 if self.is_empty() {
667 return write!(f, "(empty)");
668 }
669
670 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
672 non_empty.sort_by(|a, b| {
673 let cmp = a.units.currency.cmp(&b.units.currency);
675 if cmp != std::cmp::Ordering::Equal {
676 return cmp;
677 }
678 match (&a.cost, &b.cost) {
680 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
681 (Some(_), None) => std::cmp::Ordering::Greater,
682 (None, Some(_)) => std::cmp::Ordering::Less,
683 (None, None) => std::cmp::Ordering::Equal,
684 }
685 });
686
687 for (i, pos) in non_empty.iter().enumerate() {
688 if i > 0 {
689 write!(f, ", ")?;
690 }
691 write!(f, "{pos}")?;
692 }
693 Ok(())
694 }
695}
696
697impl FromIterator<Position> for Inventory {
698 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
699 let mut inv = Self::new();
700 for pos in iter {
701 inv.add(pos);
702 }
703 inv
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use crate::Cost;
711 use crate::NaiveDate;
712 use rust_decimal_macros::dec;
713
714 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
715 crate::naive_date(year, month, day).unwrap()
716 }
717
718 #[test]
719 fn test_empty_inventory() {
720 let inv = Inventory::new();
721 assert!(inv.is_empty());
722 assert_eq!(inv.len(), 0);
723 }
724
725 #[test]
726 fn test_add_simple() {
727 let mut inv = Inventory::new();
728 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
729
730 assert!(!inv.is_empty());
731 assert_eq!(inv.units("USD"), dec!(100));
732 }
733
734 #[test]
735 fn test_add_merge_simple() {
736 let mut inv = Inventory::new();
737 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
738 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
739
740 assert_eq!(inv.len(), 1);
742 assert_eq!(inv.units("USD"), dec!(150));
743 }
744
745 #[test]
746 fn test_add_with_cost_no_merge() {
747 let mut inv = Inventory::new();
748
749 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
750 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
751
752 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
753 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
754
755 assert_eq!(inv.len(), 2);
757 assert_eq!(inv.units("AAPL"), dec!(15));
758 }
759
760 #[test]
761 fn test_currencies() {
762 let mut inv = Inventory::new();
763 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
764 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
765 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
766
767 let currencies = inv.currencies();
768 assert_eq!(currencies.len(), 3);
769 assert!(currencies.contains(&"USD"));
770 assert!(currencies.contains(&"EUR"));
771 assert!(currencies.contains(&"AAPL"));
772 }
773
774 #[test]
775 fn test_reduce_strict_unique() {
776 let mut inv = Inventory::new();
777 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
778 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
779
780 let result = inv
781 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
782 .unwrap();
783
784 assert_eq!(inv.units("AAPL"), dec!(5));
785 assert!(result.cost_basis.is_some());
786 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
788
789 #[test]
790 fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
791 let mut inv = Inventory::new();
792
793 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
794 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
795
796 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
797 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
798
799 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
803
804 assert!(
805 matches!(result, Err(BookingError::AmbiguousMatch { .. })),
806 "expected AmbiguousMatch, got {result:?}"
807 );
808 assert_eq!(inv.units("AAPL"), dec!(15));
810 }
811
812 #[test]
813 fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
814 let mut inv = Inventory::new();
815
816 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
818
819 inv.add(Position::with_cost(
820 Amount::new(dec!(10), "AAPL"),
821 cost.clone(),
822 ));
823 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
824
825 let result = inv
826 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
827 .expect("identical lots should fall back to FIFO without error");
828
829 assert_eq!(inv.units("AAPL"), dec!(12));
830 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
831 }
832
833 #[test]
834 fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
835 let mut inv = Inventory::new();
836
837 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
841 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
842
843 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
844 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
845
846 let result = inv
847 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
848 .expect("same cost number, different dates should fall back to FIFO");
849
850 assert_eq!(inv.units("AAPL"), dec!(15));
851 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
853 }
854
855 #[test]
856 fn test_reduce_strict_multiple_match_total_match_exception() {
857 let mut inv = Inventory::new();
858
859 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
860 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
861
862 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
863 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
864
865 let result = inv
868 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
869 .expect("total-match exception should accept a full liquidation");
870
871 assert_eq!(inv.units("AAPL"), dec!(0));
872 assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
874 }
875
876 #[test]
877 fn test_reduce_strict_with_spec() {
878 let mut inv = Inventory::new();
879
880 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
881 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
882
883 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
884 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
885
886 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
888 let result = inv
889 .reduce(
890 &Amount::new(dec!(-3), "AAPL"),
891 Some(&spec),
892 BookingMethod::Strict,
893 )
894 .unwrap();
895
896 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
899
900 #[test]
901 fn test_reduce_fifo() {
902 let mut inv = Inventory::new();
903
904 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
905 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
906 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
907
908 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
909 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
910 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
911
912 let result = inv
914 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
915 .unwrap();
916
917 assert_eq!(inv.units("AAPL"), dec!(15));
918 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
920 }
921
922 #[test]
923 fn test_reduce_lifo() {
924 let mut inv = Inventory::new();
925
926 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
927 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
928 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
929
930 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
931 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
932 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
933
934 let result = inv
936 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
937 .unwrap();
938
939 assert_eq!(inv.units("AAPL"), dec!(15));
940 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
942 }
943
944 #[test]
945 fn test_reduce_insufficient() {
946 let mut inv = Inventory::new();
947 let cost = Cost::new(dec!(150.00), "USD");
948 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
949
950 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
951
952 assert!(matches!(
953 result,
954 Err(BookingError::InsufficientUnits { .. })
955 ));
956 }
957
958 #[test]
959 fn test_book_value() {
960 let mut inv = Inventory::new();
961
962 let cost1 = Cost::new(dec!(100.00), "USD");
963 let cost2 = Cost::new(dec!(150.00), "USD");
964
965 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
966 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
967
968 let book = inv.book_value("AAPL");
969 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
971
972 #[test]
973 fn test_display() {
974 let mut inv = Inventory::new();
975 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
976
977 let s = format!("{inv}");
978 assert!(s.contains("100 USD"));
979 }
980
981 #[test]
982 fn test_display_empty() {
983 let inv = Inventory::new();
984 assert_eq!(format!("{inv}"), "(empty)");
985 }
986
987 #[test]
988 fn test_from_iterator() {
989 let positions = vec![
990 Position::simple(Amount::new(dec!(100), "USD")),
991 Position::simple(Amount::new(dec!(50), "USD")),
992 ];
993
994 let inv: Inventory = positions.into_iter().collect();
995 assert_eq!(inv.units("USD"), dec!(150));
996 }
997
998 #[test]
999 fn test_add_costed_positions_kept_separate() {
1000 let mut inv = Inventory::new();
1003
1004 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1005
1006 inv.add(Position::with_cost(
1008 Amount::new(dec!(10), "AAPL"),
1009 cost.clone(),
1010 ));
1011 assert_eq!(inv.len(), 1);
1012 assert_eq!(inv.units("AAPL"), dec!(10));
1013
1014 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1016 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
1019
1020 #[test]
1021 fn test_add_costed_positions_net_units() {
1022 let mut inv = Inventory::new();
1024
1025 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1026
1027 inv.add(Position::with_cost(
1029 Amount::new(dec!(10), "AAPL"),
1030 cost.clone(),
1031 ));
1032
1033 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
1035 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
1038
1039 #[test]
1040 fn test_add_no_cancel_different_cost() {
1041 let mut inv = Inventory::new();
1043
1044 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1045 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1046
1047 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1049
1050 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
1052
1053 assert_eq!(inv.len(), 2);
1055 assert_eq!(inv.units("AAPL"), dec!(5)); }
1057
1058 #[test]
1059 fn test_add_no_cancel_same_sign() {
1060 let mut inv = Inventory::new();
1062
1063 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1064
1065 inv.add(Position::with_cost(
1067 Amount::new(dec!(10), "AAPL"),
1068 cost.clone(),
1069 ));
1070
1071 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1073
1074 assert_eq!(inv.len(), 2);
1076 assert_eq!(inv.units("AAPL"), dec!(15));
1077 }
1078
1079 #[test]
1080 fn test_merge_keeps_lots_separate() {
1081 let mut inv1 = Inventory::new();
1083 let mut inv2 = Inventory::new();
1084
1085 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1086
1087 inv1.add(Position::with_cost(
1089 Amount::new(dec!(10), "AAPL"),
1090 cost.clone(),
1091 ));
1092
1093 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1095
1096 inv1.merge(&inv2);
1098 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
1101
1102 #[test]
1107 fn test_hifo_with_tie_breaking() {
1108 let mut inv = Inventory::new();
1110
1111 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1113 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1114 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1115
1116 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1117 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1118 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1119
1120 let result = inv
1122 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1123 .unwrap();
1124
1125 assert_eq!(inv.units("AAPL"), dec!(15));
1126 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1128 }
1129
1130 #[test]
1131 fn test_hifo_with_different_costs() {
1132 let mut inv = Inventory::new();
1134
1135 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1136 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1137 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1138
1139 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1140 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1141 inv.add(Position::with_cost(
1142 Amount::new(dec!(10), "AAPL"),
1143 cost_high,
1144 ));
1145
1146 let result = inv
1148 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1149 .unwrap();
1150
1151 assert_eq!(inv.units("AAPL"), dec!(15));
1152 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1154 }
1155
1156 #[test]
1157 fn test_average_booking_with_pre_existing_positions() {
1158 let mut inv = Inventory::new();
1159
1160 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1162 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1163
1164 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1165 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1166
1167 let result = inv
1170 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1171 .unwrap();
1172
1173 assert_eq!(inv.units("AAPL"), dec!(15));
1174 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1176 }
1177
1178 #[test]
1179 fn test_average_booking_reduces_all() {
1180 let mut inv = Inventory::new();
1181
1182 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1183 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1184
1185 let result = inv
1187 .reduce(
1188 &Amount::new(dec!(-10), "AAPL"),
1189 None,
1190 BookingMethod::Average,
1191 )
1192 .unwrap();
1193
1194 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1195 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1196 }
1197
1198 #[test]
1199 fn test_none_booking_augmentation() {
1200 let mut inv = Inventory::new();
1202 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1203
1204 let result = inv
1206 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1207 .unwrap();
1208
1209 assert_eq!(inv.units("USD"), dec!(150));
1210 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
1212 }
1213
1214 #[test]
1215 fn test_none_booking_reduction() {
1216 let mut inv = Inventory::new();
1218 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1219
1220 let result = inv
1221 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1222 .unwrap();
1223
1224 assert_eq!(inv.units("USD"), dec!(70));
1225 assert!(!result.matched.is_empty());
1226 }
1227
1228 #[test]
1229 fn test_none_booking_insufficient() {
1230 let mut inv = Inventory::new();
1231 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1232
1233 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1234
1235 assert!(matches!(
1236 result,
1237 Err(BookingError::InsufficientUnits { .. })
1238 ));
1239 }
1240
1241 #[test]
1242 fn test_booking_error_no_matching_lot() {
1243 let mut inv = Inventory::new();
1244
1245 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1247 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1248
1249 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1251 let result = inv.reduce(
1252 &Amount::new(dec!(-5), "AAPL"),
1253 Some(&wrong_spec),
1254 BookingMethod::Strict,
1255 );
1256
1257 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1258 }
1259
1260 #[test]
1261 fn test_booking_error_insufficient_units() {
1262 let mut inv = Inventory::new();
1263
1264 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1265 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1266
1267 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1269
1270 match result {
1271 Err(BookingError::InsufficientUnits {
1272 requested,
1273 available,
1274 ..
1275 }) => {
1276 assert_eq!(requested, dec!(20));
1277 assert_eq!(available, dec!(10));
1278 }
1279 _ => panic!("Expected InsufficientUnits error"),
1280 }
1281 }
1282
1283 #[test]
1284 fn test_strict_with_size_exact_match() {
1285 let mut inv = Inventory::new();
1286
1287 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1289 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1290
1291 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1292 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1293
1294 let result = inv
1296 .reduce(
1297 &Amount::new(dec!(-5), "AAPL"),
1298 None,
1299 BookingMethod::StrictWithSize,
1300 )
1301 .unwrap();
1302
1303 assert_eq!(inv.units("AAPL"), dec!(10));
1304 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1305 }
1306
1307 #[test]
1308 fn test_strict_with_size_total_match() {
1309 let mut inv = Inventory::new();
1310
1311 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1313 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1314
1315 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1316 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1317
1318 let result = inv
1320 .reduce(
1321 &Amount::new(dec!(-15), "AAPL"),
1322 None,
1323 BookingMethod::StrictWithSize,
1324 )
1325 .unwrap();
1326
1327 assert_eq!(inv.units("AAPL"), dec!(0));
1328 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1329 }
1330
1331 #[test]
1332 fn test_strict_with_size_ambiguous() {
1333 let mut inv = Inventory::new();
1334
1335 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1337 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1338
1339 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1340 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1341
1342 let result = inv.reduce(
1344 &Amount::new(dec!(-7), "AAPL"),
1345 None,
1346 BookingMethod::StrictWithSize,
1347 );
1348
1349 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1350 }
1351
1352 #[test]
1353 fn test_short_position() {
1354 let mut inv = Inventory::new();
1356
1357 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1359 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1360
1361 assert_eq!(inv.units("AAPL"), dec!(-10));
1362 assert!(!inv.is_empty());
1363 }
1364
1365 #[test]
1366 fn test_at_cost() {
1367 let mut inv = Inventory::new();
1368
1369 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1370 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1371
1372 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1373 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1374 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1375
1376 let at_cost = inv.at_cost();
1377
1378 assert_eq!(at_cost.units("USD"), dec!(1850));
1381 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1383
1384 #[test]
1385 fn test_at_units() {
1386 let mut inv = Inventory::new();
1387
1388 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1389 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1390
1391 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1392 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1393
1394 let at_units = inv.at_units();
1395
1396 assert_eq!(at_units.units("AAPL"), dec!(15));
1398 assert_eq!(at_units.len(), 1);
1400 }
1401
1402 #[test]
1403 fn test_add_empty_position() {
1404 let mut inv = Inventory::new();
1405 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1406
1407 assert!(inv.is_empty());
1408 assert_eq!(inv.len(), 0);
1409 }
1410
1411 #[test]
1412 fn test_compact() {
1413 let mut inv = Inventory::new();
1414
1415 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1416 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1417
1418 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1420 .unwrap();
1421
1422 inv.compact();
1424 assert!(inv.is_empty());
1425 assert_eq!(inv.len(), 0);
1426 }
1427
1428 #[test]
1429 fn test_booking_method_from_str() {
1430 assert_eq!(
1431 BookingMethod::from_str("STRICT").unwrap(),
1432 BookingMethod::Strict
1433 );
1434 assert_eq!(
1435 BookingMethod::from_str("fifo").unwrap(),
1436 BookingMethod::Fifo
1437 );
1438 assert_eq!(
1439 BookingMethod::from_str("LIFO").unwrap(),
1440 BookingMethod::Lifo
1441 );
1442 assert_eq!(
1443 BookingMethod::from_str("Hifo").unwrap(),
1444 BookingMethod::Hifo
1445 );
1446 assert_eq!(
1447 BookingMethod::from_str("AVERAGE").unwrap(),
1448 BookingMethod::Average
1449 );
1450 assert_eq!(
1451 BookingMethod::from_str("NONE").unwrap(),
1452 BookingMethod::None
1453 );
1454 assert_eq!(
1455 BookingMethod::from_str("strict_with_size").unwrap(),
1456 BookingMethod::StrictWithSize
1457 );
1458 assert!(BookingMethod::from_str("INVALID").is_err());
1459 }
1460
1461 #[test]
1462 fn test_booking_method_display() {
1463 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1464 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1465 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1466 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1467 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1468 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1469 assert_eq!(
1470 format!("{}", BookingMethod::StrictWithSize),
1471 "STRICT_WITH_SIZE"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_booking_error_display() {
1477 let err = BookingError::AmbiguousMatch {
1478 num_matches: 3,
1479 currency: "AAPL".into(),
1480 };
1481 assert!(format!("{err}").contains("3 lots match"));
1482
1483 let err = BookingError::NoMatchingLot {
1484 currency: "AAPL".into(),
1485 cost_spec: CostSpec::empty(),
1486 };
1487 assert!(format!("{err}").contains("No matching lot"));
1488
1489 let err = BookingError::InsufficientUnits {
1490 currency: "AAPL".into(),
1491 requested: dec!(100),
1492 available: dec!(50),
1493 };
1494 assert!(format!("{err}").contains("requested 100"));
1495 assert!(format!("{err}").contains("available 50"));
1496
1497 let err = BookingError::CurrencyMismatch {
1498 expected: "USD".into(),
1499 got: "EUR".into(),
1500 };
1501 assert!(format!("{err}").contains("expected USD"));
1502 assert!(format!("{err}").contains("got EUR"));
1503 }
1504
1505 #[test]
1506 fn test_book_value_multiple_currencies() {
1507 let mut inv = Inventory::new();
1508
1509 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1511 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1512
1513 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1515 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1516
1517 let book = inv.book_value("AAPL");
1518 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1519 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1520 }
1521
1522 #[test]
1523 fn test_reduce_hifo_insufficient_units() {
1524 let mut inv = Inventory::new();
1525
1526 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1527 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1528
1529 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1530
1531 assert!(matches!(
1532 result,
1533 Err(BookingError::InsufficientUnits { .. })
1534 ));
1535 }
1536
1537 #[test]
1538 fn test_reduce_average_insufficient_units() {
1539 let mut inv = Inventory::new();
1540
1541 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1542 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1543
1544 let result = inv.reduce(
1545 &Amount::new(dec!(-20), "AAPL"),
1546 None,
1547 BookingMethod::Average,
1548 );
1549
1550 assert!(matches!(
1551 result,
1552 Err(BookingError::InsufficientUnits { .. })
1553 ));
1554 }
1555
1556 #[test]
1557 fn test_reduce_average_empty_inventory() {
1558 let mut inv = Inventory::new();
1559
1560 let result = inv.reduce(
1561 &Amount::new(dec!(-10), "AAPL"),
1562 None,
1563 BookingMethod::Average,
1564 );
1565
1566 assert!(matches!(
1567 result,
1568 Err(BookingError::InsufficientUnits { .. })
1569 ));
1570 }
1571
1572 #[test]
1573 fn test_reduce_merge_operator() {
1574 let mut inv = Inventory::new();
1576 inv.add(Position::with_cost(
1577 Amount::new(dec!(10), "AAPL"),
1578 Cost::new(dec!(150), "USD"),
1579 ));
1580 inv.add(Position::with_cost(
1581 Amount::new(dec!(10), "AAPL"),
1582 Cost::new(dec!(160), "USD"),
1583 ));
1584
1585 let merge_spec = CostSpec::empty().with_merge();
1586 let result = inv
1587 .reduce(
1588 &Amount::new(dec!(-5), "AAPL"),
1589 Some(&merge_spec),
1590 BookingMethod::Strict,
1591 )
1592 .expect("merge reduction should succeed");
1593
1594 assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1596
1597 assert_eq!(inv.positions.len(), 1);
1599 assert_eq!(inv.positions[0].units.number, dec!(15));
1600 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1601 assert_eq!(cost.number, dec!(155));
1602 }
1603
1604 #[test]
1605 fn test_reduce_merge_insufficient_units() {
1606 let mut inv = Inventory::new();
1607 inv.add(Position::with_cost(
1608 Amount::new(dec!(10), "AAPL"),
1609 Cost::new(dec!(150), "USD"),
1610 ));
1611
1612 let merge_spec = CostSpec::empty().with_merge();
1613 let result = inv.reduce(
1614 &Amount::new(dec!(-20), "AAPL"),
1615 Some(&merge_spec),
1616 BookingMethod::Strict,
1617 );
1618
1619 assert!(matches!(
1620 result,
1621 Err(BookingError::InsufficientUnits { .. })
1622 ));
1623 }
1624
1625 #[test]
1626 fn test_reduce_merge_sells_all() {
1627 let mut inv = Inventory::new();
1629 inv.add(Position::with_cost(
1630 Amount::new(dec!(10), "AAPL"),
1631 Cost::new(dec!(150), "USD"),
1632 ));
1633 inv.add(Position::with_cost(
1634 Amount::new(dec!(10), "AAPL"),
1635 Cost::new(dec!(160), "USD"),
1636 ));
1637
1638 let merge_spec = CostSpec::empty().with_merge();
1639 let result = inv
1640 .reduce(
1641 &Amount::new(dec!(-20), "AAPL"),
1642 Some(&merge_spec),
1643 BookingMethod::Strict,
1644 )
1645 .expect("merge reduction should succeed");
1646
1647 assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1649
1650 assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1652 }
1653
1654 #[test]
1655 fn test_reduce_merge_single_lot() {
1656 let mut inv = Inventory::new();
1658 inv.add(Position::with_cost(
1659 Amount::new(dec!(10), "AAPL"),
1660 Cost::new(dec!(150), "USD"),
1661 ));
1662
1663 let merge_spec = CostSpec::empty().with_merge();
1664 let result = inv
1665 .reduce(
1666 &Amount::new(dec!(-3), "AAPL"),
1667 Some(&merge_spec),
1668 BookingMethod::Strict,
1669 )
1670 .expect("single-lot merge should succeed");
1671
1672 assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1673 assert_eq!(inv.positions.len(), 1);
1674 assert_eq!(inv.positions[0].units.number, dec!(7));
1675 }
1676
1677 #[test]
1678 fn test_reduce_merge_three_lots() {
1679 let mut inv = Inventory::new();
1681 inv.add(Position::with_cost(
1682 Amount::new(dec!(10), "AAPL"),
1683 Cost::new(dec!(100), "USD"),
1684 ));
1685 inv.add(Position::with_cost(
1686 Amount::new(dec!(10), "AAPL"),
1687 Cost::new(dec!(150), "USD"),
1688 ));
1689 inv.add(Position::with_cost(
1690 Amount::new(dec!(10), "AAPL"),
1691 Cost::new(dec!(200), "USD"),
1692 ));
1693
1694 let merge_spec = CostSpec::empty().with_merge();
1696 let result = inv
1697 .reduce(
1698 &Amount::new(dec!(-6), "AAPL"),
1699 Some(&merge_spec),
1700 BookingMethod::Strict,
1701 )
1702 .expect("three-lot merge should succeed");
1703
1704 assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1705 assert_eq!(inv.positions.len(), 1);
1706 assert_eq!(inv.positions[0].units.number, dec!(24));
1707 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1708 assert_eq!(cost.number, dec!(150));
1709 }
1710
1711 #[test]
1712 fn test_reduce_merge_mixed_cost_currencies_errors() {
1713 let mut inv = Inventory::new();
1715 inv.add(Position::with_cost(
1716 Amount::new(dec!(10), "AAPL"),
1717 Cost::new(dec!(150), "USD"),
1718 ));
1719 inv.add(Position::with_cost(
1720 Amount::new(dec!(10), "AAPL"),
1721 Cost::new(dec!(130), "EUR"),
1722 ));
1723
1724 let merge_spec = CostSpec::empty().with_merge();
1725 let result = inv.reduce(
1726 &Amount::new(dec!(-5), "AAPL"),
1727 Some(&merge_spec),
1728 BookingMethod::Strict,
1729 );
1730
1731 assert!(
1732 matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1733 "expected CurrencyMismatch, got {result:?}"
1734 );
1735 }
1736
1737 #[test]
1738 fn test_reduce_merge_empty_inventory() {
1739 let mut inv = Inventory::new();
1740
1741 let merge_spec = CostSpec::empty().with_merge();
1742 let result = inv.reduce(
1743 &Amount::new(dec!(-5), "AAPL"),
1744 Some(&merge_spec),
1745 BookingMethod::Strict,
1746 );
1747
1748 assert!(matches!(
1749 result,
1750 Err(BookingError::InsufficientUnits { .. })
1751 ));
1752 }
1753
1754 #[test]
1755 fn test_inventory_display_sorted() {
1756 let mut inv = Inventory::new();
1757
1758 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1760 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1761 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1762
1763 let display = format!("{inv}");
1764
1765 let aapl_pos = display.find("AAPL").unwrap();
1767 let eur_pos = display.find("EUR").unwrap();
1768 let usd_pos = display.find("USD").unwrap();
1769
1770 assert!(aapl_pos < eur_pos);
1771 assert!(eur_pos < usd_pos);
1772 }
1773
1774 #[test]
1775 fn test_inventory_with_cost_display_sorted() {
1776 let mut inv = Inventory::new();
1777
1778 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1780 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1781
1782 inv.add(Position::with_cost(
1783 Amount::new(dec!(10), "AAPL"),
1784 cost_high,
1785 ));
1786 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1787
1788 let display = format!("{inv}");
1789
1790 assert!(display.contains("AAPL"));
1792 assert!(display.contains("100"));
1793 assert!(display.contains("200"));
1794 }
1795
1796 #[test]
1797 fn test_reduce_hifo_no_matching_lot() {
1798 let mut inv = Inventory::new();
1799
1800 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1802
1803 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1804
1805 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1806 }
1807
1808 #[test]
1809 fn test_fifo_respects_dates() {
1810 let mut inv = Inventory::new();
1812
1813 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1815 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1816
1817 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1818 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1819
1820 let result = inv
1822 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1823 .unwrap();
1824
1825 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1827 }
1828
1829 #[test]
1830 fn test_lifo_respects_dates() {
1831 let mut inv = Inventory::new();
1833
1834 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1836 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1837
1838 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1839 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1840
1841 let result = inv
1843 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1844 .unwrap();
1845
1846 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1848 }
1849
1850 #[test]
1863 fn test_strict_with_size_different_costs_exact_match() {
1864 let mut inv = Inventory::new();
1867
1868 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1869 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1870
1871 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1872 inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1873
1874 let result = inv
1876 .reduce(
1877 &Amount::new(dec!(-7), "AAPL"),
1878 None,
1879 BookingMethod::StrictWithSize,
1880 )
1881 .unwrap();
1882
1883 assert_eq!(inv.units("AAPL"), dec!(10));
1884 assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); }
1886
1887 #[test]
1888 fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1889 let mut inv = Inventory::new();
1892
1893 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1894 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1895
1896 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1897 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1898
1899 let result = inv
1901 .reduce(
1902 &Amount::new(dec!(-5), "AAPL"),
1903 None,
1904 BookingMethod::StrictWithSize,
1905 )
1906 .unwrap();
1907
1908 assert_eq!(inv.units("AAPL"), dec!(5));
1909 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1911 }
1912
1913 #[test]
1914 fn test_strict_with_size_with_cost_spec() {
1915 let mut inv = Inventory::new();
1917
1918 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1919 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1920
1921 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1922 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1923
1924 let spec = CostSpec::empty().with_number_per(dec!(200.00));
1926 let result = inv
1927 .reduce(
1928 &Amount::new(dec!(-5), "AAPL"),
1929 Some(&spec),
1930 BookingMethod::StrictWithSize,
1931 )
1932 .unwrap();
1933
1934 assert_eq!(inv.units("AAPL"), dec!(15));
1935 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
1937
1938 #[test]
1941 fn test_hifo_reduces_highest_cost_first() {
1942 let mut inv = Inventory::new();
1944
1945 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1946 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1947 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1948
1949 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1950 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1951 inv.add(Position::with_cost(
1952 Amount::new(dec!(10), "AAPL"),
1953 cost_high,
1954 ));
1955
1956 let result = inv
1958 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1959 .unwrap();
1960
1961 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
1963 }
1964
1965 #[test]
1966 fn test_hifo_spans_multiple_lots() {
1967 let mut inv = Inventory::new();
1970
1971 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1972 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1973
1974 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1975 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1976
1977 let result = inv
1979 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1980 .unwrap();
1981
1982 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1984 assert_eq!(inv.units("AAPL"), dec!(2));
1985 }
1986
1987 #[test]
1988 fn test_hifo_with_cost_spec_filter() {
1989 let mut inv = Inventory::new();
1991
1992 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1993 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1994
1995 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1996 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1997
1998 let spec = CostSpec::empty().with_currency("USD");
2000 let result = inv
2001 .reduce(
2002 &Amount::new(dec!(-5), "AAPL"),
2003 Some(&spec),
2004 BookingMethod::Hifo,
2005 )
2006 .unwrap();
2007
2008 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
2010
2011 #[test]
2012 fn test_hifo_short_position() {
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_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2018
2019 inv.add(Position::with_cost(
2021 Amount::new(dec!(-10), "AAPL"),
2022 cost_low,
2023 ));
2024 inv.add(Position::with_cost(
2025 Amount::new(dec!(-10), "AAPL"),
2026 cost_high,
2027 ));
2028
2029 let result = inv
2032 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2033 .unwrap();
2034
2035 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2037 }
2038
2039 #[test]
2042 fn test_average_weighted_cost() {
2043 let mut inv = Inventory::new();
2045
2046 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2047 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2048
2049 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2050 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2051
2052 let result = inv
2054 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2055 .unwrap();
2056
2057 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2059 assert_eq!(inv.units("AAPL"), dec!(15));
2060 }
2061
2062 #[test]
2063 fn test_average_merges_into_single_position() {
2064 let mut inv = Inventory::new();
2066
2067 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2068 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2069
2070 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2071 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2072
2073 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2074 .unwrap();
2075
2076 let aapl_positions: Vec<_> = inv
2078 .positions
2079 .iter()
2080 .filter(|p| p.units.currency.as_ref() == "AAPL")
2081 .collect();
2082 assert_eq!(aapl_positions.len(), 1);
2083 assert_eq!(aapl_positions[0].units.number, dec!(15));
2084 }
2085
2086 #[test]
2087 fn test_average_uneven_lots() {
2088 let mut inv = Inventory::new();
2090
2091 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2092 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2093
2094 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2095 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2096
2097 let result = inv
2099 .reduce(
2100 &Amount::new(dec!(-10), "AAPL"),
2101 None,
2102 BookingMethod::Average,
2103 )
2104 .unwrap();
2105
2106 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
2108
2109 #[test]
2112 fn test_none_booking_with_cost_positions() {
2113 let mut inv = Inventory::new();
2115
2116 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2117 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2118
2119 let result = inv
2120 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2121 .unwrap();
2122
2123 assert_eq!(inv.units("AAPL"), dec!(5));
2124 assert!(result.cost_basis.is_some());
2126 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2127 }
2128
2129 #[test]
2130 fn test_none_booking_short_cover() {
2131 let mut inv = Inventory::new();
2133 inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2134
2135 let result = inv
2137 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2138 .unwrap();
2139
2140 assert_eq!(inv.units("USD"), dec!(-70));
2141 assert!(!result.matched.is_empty());
2142 }
2143
2144 #[test]
2145 fn test_none_booking_empty_inventory_augments() {
2146 let mut inv = Inventory::new();
2148
2149 let result = inv
2150 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2151 .unwrap();
2152
2153 assert_eq!(inv.units("USD"), dec!(50));
2154 assert!(result.matched.is_empty()); }
2156
2157 #[test]
2160 fn test_fifo_short_position_cover() {
2161 let mut inv = Inventory::new();
2163
2164 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2165 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2166
2167 inv.add(Position::with_cost(
2168 Amount::new(dec!(-10), "AAPL"),
2169 cost_old,
2170 ));
2171 inv.add(Position::with_cost(
2172 Amount::new(dec!(-10), "AAPL"),
2173 cost_new,
2174 ));
2175
2176 let result = inv
2178 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2179 .unwrap();
2180
2181 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2183 }
2184
2185 #[test]
2186 fn test_lifo_short_position_cover() {
2187 let mut inv = Inventory::new();
2189
2190 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2191 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2192
2193 inv.add(Position::with_cost(
2194 Amount::new(dec!(-10), "AAPL"),
2195 cost_old,
2196 ));
2197 inv.add(Position::with_cost(
2198 Amount::new(dec!(-10), "AAPL"),
2199 cost_new,
2200 ));
2201
2202 let result = inv
2204 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2205 .unwrap();
2206
2207 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2209 }
2210
2211 #[test]
2232 fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2233 let mut inv = Inventory::new();
2243
2244 let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2246 inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2247
2248 inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2250
2251 let buy_units = Amount::new(dec!(50), "HOOG");
2253
2254 assert!(
2258 !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2259 "augmentation with cost spec should NOT be treated as reduction \
2260 when only a simple (no-cost) position has opposite sign"
2261 );
2262
2263 assert!(
2266 inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2267 "without cost spec filter, the -25 HOOG simple position \
2268 should cause is_reduced_by to return true"
2269 );
2270 }
2271
2272 #[test]
2273 fn test_accounted_error_display_insufficient_units() {
2274 let err = BookingError::InsufficientUnits {
2275 currency: "AAPL".into(),
2276 requested: dec!(15),
2277 available: dec!(10),
2278 }
2279 .with_account("Assets:Stock".into());
2280 let rendered = format!("{err}");
2281
2282 assert!(
2285 rendered.contains("not enough"),
2286 "must contain 'not enough' (pta-standards): {rendered}"
2287 );
2288 assert!(
2289 rendered.contains("Assets:Stock"),
2290 "must contain account name: {rendered}"
2291 );
2292 assert!(
2293 rendered.contains("15") && rendered.contains("10"),
2294 "must contain requested and available amounts: {rendered}"
2295 );
2296 }
2297
2298 #[test]
2299 fn test_accounted_error_display_no_matching_lot() {
2300 let err = BookingError::NoMatchingLot {
2301 currency: "AAPL".into(),
2302 cost_spec: CostSpec::empty(),
2303 }
2304 .with_account("Assets:Stock".into());
2305 let rendered = format!("{err}");
2306
2307 assert!(
2308 rendered.contains("No matching lot"),
2309 "must contain 'No matching lot': {rendered}"
2310 );
2311 assert!(
2312 rendered.contains("AAPL"),
2313 "must contain currency: {rendered}"
2314 );
2315 assert!(
2316 rendered.contains("Assets:Stock"),
2317 "must contain account name: {rendered}"
2318 );
2319 }
2320
2321 #[test]
2322 fn test_accounted_error_display_ambiguous_match() {
2323 let err = BookingError::AmbiguousMatch {
2324 num_matches: 3,
2325 currency: "AAPL".into(),
2326 }
2327 .with_account("Assets:Stock".into());
2328 let rendered = format!("{err}");
2329
2330 assert!(
2331 rendered.contains("Ambiguous"),
2332 "must contain 'Ambiguous': {rendered}"
2333 );
2334 assert!(
2335 rendered.contains("AAPL"),
2336 "must contain currency: {rendered}"
2337 );
2338 assert!(
2339 rendered.contains("Assets:Stock"),
2340 "must contain account name: {rendered}"
2341 );
2342 assert!(
2343 rendered.contains('3'),
2344 "must contain match count: {rendered}"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2350 let err = BookingError::CurrencyMismatch {
2358 expected: "USD".into(),
2359 got: "EUR".into(),
2360 }
2361 .with_account("Assets:Cash".into());
2362 let rendered = format!("{err}");
2363
2364 assert!(
2365 rendered.contains("No matching lot"),
2366 "CurrencyMismatch must render as 'No matching lot' for E4001 \
2367 consistency: {rendered}"
2368 );
2369 assert!(
2370 rendered.contains("EUR"),
2371 "must contain the mismatched (got) currency: {rendered}"
2372 );
2373 assert!(
2374 rendered.contains("Assets:Cash"),
2375 "must contain account name: {rendered}"
2376 );
2377 }
2378}