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::{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: 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]
465 pub fn book_value(&self, units_currency: &str) -> FxHashMap<crate::Currency, Decimal> {
466 let mut totals: FxHashMap<crate::Currency, 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(crate::CostNumber::PerUnit {
1926 value: dec!(200.00),
1927 });
1928 let result = inv
1929 .reduce(
1930 &Amount::new(dec!(-5), "AAPL"),
1931 Some(&spec),
1932 BookingMethod::StrictWithSize,
1933 )
1934 .unwrap();
1935
1936 assert_eq!(inv.units("AAPL"), dec!(15));
1937 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
1939
1940 #[test]
1943 fn test_hifo_reduces_highest_cost_first() {
1944 let mut inv = Inventory::new();
1946
1947 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1948 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1949 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1950
1951 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1952 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1953 inv.add(Position::with_cost(
1954 Amount::new(dec!(10), "AAPL"),
1955 cost_high,
1956 ));
1957
1958 let result = inv
1960 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1961 .unwrap();
1962
1963 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
1965 }
1966
1967 #[test]
1968 fn test_hifo_spans_multiple_lots() {
1969 let mut inv = Inventory::new();
1972
1973 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1974 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1975
1976 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1977 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1978
1979 let result = inv
1981 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1982 .unwrap();
1983
1984 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1986 assert_eq!(inv.units("AAPL"), dec!(2));
1987 }
1988
1989 #[test]
1990 fn test_hifo_with_cost_spec_filter() {
1991 let mut inv = Inventory::new();
1993
1994 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1995 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1996
1997 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1998 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1999
2000 let spec = CostSpec::empty().with_currency("USD");
2002 let result = inv
2003 .reduce(
2004 &Amount::new(dec!(-5), "AAPL"),
2005 Some(&spec),
2006 BookingMethod::Hifo,
2007 )
2008 .unwrap();
2009
2010 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
2012
2013 #[test]
2014 fn test_hifo_short_position() {
2015 let mut inv = Inventory::new();
2017
2018 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2019 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2020
2021 inv.add(Position::with_cost(
2023 Amount::new(dec!(-10), "AAPL"),
2024 cost_low,
2025 ));
2026 inv.add(Position::with_cost(
2027 Amount::new(dec!(-10), "AAPL"),
2028 cost_high,
2029 ));
2030
2031 let result = inv
2034 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2035 .unwrap();
2036
2037 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2039 }
2040
2041 #[test]
2044 fn test_average_weighted_cost() {
2045 let mut inv = Inventory::new();
2047
2048 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2049 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2050
2051 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2052 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2053
2054 let result = inv
2056 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2057 .unwrap();
2058
2059 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2061 assert_eq!(inv.units("AAPL"), dec!(15));
2062 }
2063
2064 #[test]
2065 fn test_average_merges_into_single_position() {
2066 let mut inv = Inventory::new();
2068
2069 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2070 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2071
2072 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2073 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2074
2075 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2076 .unwrap();
2077
2078 let aapl_positions: Vec<_> = inv
2080 .positions
2081 .iter()
2082 .filter(|p| p.units.currency.as_ref() == "AAPL")
2083 .collect();
2084 assert_eq!(aapl_positions.len(), 1);
2085 assert_eq!(aapl_positions[0].units.number, dec!(15));
2086 }
2087
2088 #[test]
2089 fn test_average_uneven_lots() {
2090 let mut inv = Inventory::new();
2092
2093 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2094 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2095
2096 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2097 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2098
2099 let result = inv
2101 .reduce(
2102 &Amount::new(dec!(-10), "AAPL"),
2103 None,
2104 BookingMethod::Average,
2105 )
2106 .unwrap();
2107
2108 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
2110
2111 #[test]
2114 fn test_none_booking_with_cost_positions() {
2115 let mut inv = Inventory::new();
2117
2118 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2119 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2120
2121 let result = inv
2122 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2123 .unwrap();
2124
2125 assert_eq!(inv.units("AAPL"), dec!(5));
2126 assert!(result.cost_basis.is_some());
2128 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2129 }
2130
2131 #[test]
2132 fn test_none_booking_short_cover() {
2133 let mut inv = Inventory::new();
2135 inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2136
2137 let result = inv
2139 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2140 .unwrap();
2141
2142 assert_eq!(inv.units("USD"), dec!(-70));
2143 assert!(!result.matched.is_empty());
2144 }
2145
2146 #[test]
2147 fn test_none_booking_empty_inventory_augments() {
2148 let mut inv = Inventory::new();
2150
2151 let result = inv
2152 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2153 .unwrap();
2154
2155 assert_eq!(inv.units("USD"), dec!(50));
2156 assert!(result.matched.is_empty()); }
2158
2159 #[test]
2162 fn test_fifo_short_position_cover() {
2163 let mut inv = Inventory::new();
2165
2166 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2167 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2168
2169 inv.add(Position::with_cost(
2170 Amount::new(dec!(-10), "AAPL"),
2171 cost_old,
2172 ));
2173 inv.add(Position::with_cost(
2174 Amount::new(dec!(-10), "AAPL"),
2175 cost_new,
2176 ));
2177
2178 let result = inv
2180 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2181 .unwrap();
2182
2183 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2185 }
2186
2187 #[test]
2188 fn test_lifo_short_position_cover() {
2189 let mut inv = Inventory::new();
2191
2192 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2193 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2194
2195 inv.add(Position::with_cost(
2196 Amount::new(dec!(-10), "AAPL"),
2197 cost_old,
2198 ));
2199 inv.add(Position::with_cost(
2200 Amount::new(dec!(-10), "AAPL"),
2201 cost_new,
2202 ));
2203
2204 let result = inv
2206 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2207 .unwrap();
2208
2209 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2211 }
2212
2213 #[test]
2234 fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2235 let mut inv = Inventory::new();
2245
2246 let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2248 inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2249
2250 inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2252
2253 let buy_units = Amount::new(dec!(50), "HOOG");
2255
2256 assert!(
2260 !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2261 "augmentation with cost spec should NOT be treated as reduction \
2262 when only a simple (no-cost) position has opposite sign"
2263 );
2264
2265 assert!(
2268 inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2269 "without cost spec filter, the -25 HOOG simple position \
2270 should cause is_reduced_by to return true"
2271 );
2272 }
2273
2274 #[test]
2275 fn test_accounted_error_display_insufficient_units() {
2276 let err = BookingError::InsufficientUnits {
2277 currency: "AAPL".into(),
2278 requested: dec!(15),
2279 available: dec!(10),
2280 }
2281 .with_account("Assets:Stock".into());
2282 let rendered = format!("{err}");
2283
2284 assert!(
2287 rendered.contains("not enough"),
2288 "must contain 'not enough' (pta-standards): {rendered}"
2289 );
2290 assert!(
2291 rendered.contains("Assets:Stock"),
2292 "must contain account name: {rendered}"
2293 );
2294 assert!(
2295 rendered.contains("15") && rendered.contains("10"),
2296 "must contain requested and available amounts: {rendered}"
2297 );
2298 }
2299
2300 #[test]
2301 fn test_accounted_error_display_no_matching_lot() {
2302 let err = BookingError::NoMatchingLot {
2303 currency: "AAPL".into(),
2304 cost_spec: CostSpec::empty(),
2305 }
2306 .with_account("Assets:Stock".into());
2307 let rendered = format!("{err}");
2308
2309 assert!(
2310 rendered.contains("No matching lot"),
2311 "must contain 'No matching lot': {rendered}"
2312 );
2313 assert!(
2314 rendered.contains("AAPL"),
2315 "must contain currency: {rendered}"
2316 );
2317 assert!(
2318 rendered.contains("Assets:Stock"),
2319 "must contain account name: {rendered}"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_accounted_error_display_ambiguous_match() {
2325 let err = BookingError::AmbiguousMatch {
2326 num_matches: 3,
2327 currency: "AAPL".into(),
2328 }
2329 .with_account("Assets:Stock".into());
2330 let rendered = format!("{err}");
2331
2332 assert!(
2333 rendered.contains("Ambiguous"),
2334 "must contain 'Ambiguous': {rendered}"
2335 );
2336 assert!(
2337 rendered.contains("AAPL"),
2338 "must contain currency: {rendered}"
2339 );
2340 assert!(
2341 rendered.contains("Assets:Stock"),
2342 "must contain account name: {rendered}"
2343 );
2344 assert!(
2345 rendered.contains('3'),
2346 "must contain match count: {rendered}"
2347 );
2348 }
2349
2350 #[test]
2351 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2352 let err = BookingError::CurrencyMismatch {
2360 expected: "USD".into(),
2361 got: "EUR".into(),
2362 }
2363 .with_account("Assets:Cash".into());
2364 let rendered = format!("{err}");
2365
2366 assert!(
2367 rendered.contains("No matching lot"),
2368 "CurrencyMismatch must render as 'No matching lot' for E4001 \
2369 consistency: {rendered}"
2370 );
2371 assert!(
2372 rendered.contains("EUR"),
2373 "must contain the mismatched (got) currency: {rendered}"
2374 );
2375 assert!(
2376 rendered.contains("Assets:Cash"),
2377 "must contain account name: {rendered}"
2378 );
2379 }
2380}