1use rust_decimal::Decimal;
8use rustc_hash::FxHashMap;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::str::FromStr;
12
13use crate::intern::InternedStr;
14use crate::{Amount, CostSpec, Position};
15
16mod booking;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[cfg_attr(
21 feature = "rkyv",
22 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
23)]
24pub enum BookingMethod {
25 #[default]
28 Strict,
29 StrictWithSize,
32 Fifo,
34 Lifo,
36 Hifo,
38 Average,
40 None,
42}
43
44impl FromStr for BookingMethod {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_uppercase().as_str() {
49 "STRICT" => Ok(Self::Strict),
50 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
51 "FIFO" => Ok(Self::Fifo),
52 "LIFO" => Ok(Self::Lifo),
53 "HIFO" => Ok(Self::Hifo),
54 "AVERAGE" => Ok(Self::Average),
55 "NONE" => Ok(Self::None),
56 _ => Err(format!("unknown booking method: {s}")),
57 }
58 }
59}
60
61impl fmt::Display for BookingMethod {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::Strict => write!(f, "STRICT"),
65 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
66 Self::Fifo => write!(f, "FIFO"),
67 Self::Lifo => write!(f, "LIFO"),
68 Self::Hifo => write!(f, "HIFO"),
69 Self::Average => write!(f, "AVERAGE"),
70 Self::None => write!(f, "NONE"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ReductionScope {
87 AllPositions,
89 CostBearingOnly,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BookingResult {
96 pub matched: Vec<Position>,
98 pub cost_basis: Option<Amount>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum BookingError {
105 AmbiguousMatch {
107 num_matches: usize,
109 currency: InternedStr,
111 },
112 NoMatchingLot {
114 currency: InternedStr,
116 cost_spec: CostSpec,
118 },
119 InsufficientUnits {
121 currency: InternedStr,
123 requested: Decimal,
125 available: Decimal,
127 },
128 CurrencyMismatch {
130 expected: InternedStr,
132 got: InternedStr,
134 },
135}
136
137impl fmt::Display for BookingError {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self {
140 Self::AmbiguousMatch {
141 num_matches,
142 currency,
143 } => write!(
144 f,
145 "Ambiguous match: {num_matches} lots match for {currency}"
146 ),
147 Self::NoMatchingLot {
148 currency,
149 cost_spec,
150 } => {
151 write!(f, "No matching lot for {currency} with cost {cost_spec}")
152 }
153 Self::InsufficientUnits {
154 currency,
155 requested,
156 available,
157 } => write!(
158 f,
159 "Insufficient units of {currency}: requested {requested}, available {available}"
160 ),
161 Self::CurrencyMismatch { expected, got } => {
162 write!(f, "Currency mismatch: expected {expected}, got {got}")
163 }
164 }
165 }
166}
167
168impl std::error::Error for BookingError {}
169
170impl BookingError {
171 #[must_use]
184 pub const fn with_account(self, account: InternedStr) -> AccountedBookingError {
185 AccountedBookingError {
186 error: self,
187 account,
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct AccountedBookingError {
203 pub error: BookingError,
205 pub account: InternedStr,
207}
208
209impl fmt::Display for AccountedBookingError {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 match &self.error {
212 BookingError::InsufficientUnits {
213 requested,
214 available,
215 ..
216 } => write!(
217 f,
218 "Not enough units in {}: requested {}, available {}; not enough to reduce",
219 self.account, requested, available
220 ),
221 BookingError::NoMatchingLot { currency, .. } => {
222 write!(f, "No matching lot for {} in {}", currency, self.account)
223 }
224 BookingError::AmbiguousMatch {
225 num_matches,
226 currency,
227 } => write!(
228 f,
229 "Ambiguous lot match for {}: {} lots match in {}",
230 currency, num_matches, self.account
231 ),
232 BookingError::CurrencyMismatch { got, .. } => {
241 write!(f, "No matching lot for {} in {}", got, self.account)
242 }
243 }
244 }
245}
246
247impl std::error::Error for AccountedBookingError {}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272#[cfg_attr(
273 feature = "rkyv",
274 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
275)]
276pub struct Inventory {
277 positions: Vec<Position>,
278 #[serde(skip)]
282 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
283 simple_index: FxHashMap<InternedStr, usize>,
284 #[serde(skip)]
288 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
289 units_cache: FxHashMap<InternedStr, Decimal>,
290}
291
292impl PartialEq for Inventory {
293 fn eq(&self, other: &Self) -> bool {
294 self.positions == other.positions
296 }
297}
298
299impl Eq for Inventory {}
300
301impl Inventory {
302 #[must_use]
304 pub fn new() -> Self {
305 Self::default()
306 }
307
308 #[must_use]
310 pub fn positions(&self) -> &[Position] {
311 &self.positions
312 }
313
314 pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
316 &mut self.positions
317 }
318
319 #[must_use]
321 pub fn is_empty(&self) -> bool {
322 self.positions.is_empty()
323 || self
324 .positions
325 .iter()
326 .all(super::position::Position::is_empty)
327 }
328
329 #[must_use]
331 pub const fn len(&self) -> usize {
332 self.positions.len()
333 }
334
335 #[must_use]
340 pub fn units(&self, currency: &str) -> Decimal {
341 self.units_cache.get(currency).copied().unwrap_or_else(|| {
344 self.positions
346 .iter()
347 .filter(|p| p.units.currency == currency)
348 .map(|p| p.units.number)
349 .sum()
350 })
351 }
352
353 #[must_use]
355 pub fn currencies(&self) -> Vec<&str> {
356 let mut currencies: Vec<&str> = self
357 .positions
358 .iter()
359 .filter(|p| !p.is_empty())
360 .map(|p| p.units.currency.as_str())
361 .collect();
362 currencies.sort_unstable();
363 currencies.dedup();
364 currencies
365 }
366
367 #[must_use]
382 pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
383 self.positions.iter().any(|pos| {
384 pos.units.currency == units.currency
385 && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
386 && match scope {
387 ReductionScope::AllPositions => true,
388 ReductionScope::CostBearingOnly => pos.cost.is_some(),
389 }
390 })
391 }
392
393 #[must_use]
397 pub fn book_value(&self, units_currency: &str) -> FxHashMap<InternedStr, Decimal> {
398 let mut totals: FxHashMap<InternedStr, Decimal> = FxHashMap::default();
399
400 for pos in &self.positions {
401 if pos.units.currency == units_currency
402 && let Some(book) = pos.book_value()
403 {
404 *totals.entry(book.currency.clone()).or_default() += book.number;
405 }
406 }
407
408 totals
409 }
410
411 pub fn add(&mut self, position: Position) {
428 if position.is_empty() {
429 return;
430 }
431
432 *self
434 .units_cache
435 .entry(position.units.currency.clone())
436 .or_default() += position.units.number;
437
438 if position.cost.is_none() {
440 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
441 debug_assert!(self.positions[idx].cost.is_none());
443 self.positions[idx].units += &position.units;
444 return;
445 }
446 let idx = self.positions.len();
448 self.simple_index
449 .insert(position.units.currency.clone(), idx);
450 self.positions.push(position);
451 return;
452 }
453
454 self.positions.push(position);
458 }
459
460 pub fn reduce(
487 &mut self,
488 units: &Amount,
489 cost_spec: Option<&CostSpec>,
490 method: BookingMethod,
491 ) -> Result<BookingResult, BookingError> {
492 let spec = cost_spec.cloned().unwrap_or_default();
493
494 if spec.merge {
497 return self.reduce_merge(units);
498 }
499
500 match method {
501 BookingMethod::Strict => self.reduce_strict(units, &spec),
502 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
503 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
504 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
505 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
506 BookingMethod::Average => self.reduce_average(units),
507 BookingMethod::None => self.reduce_none(units),
508 }
509 }
510
511 pub fn compact(&mut self) {
513 self.positions.retain(|p| !p.is_empty());
514 self.rebuild_index();
515 }
516
517 fn rebuild_index(&mut self) {
520 self.simple_index.clear();
521 self.units_cache.clear();
522
523 for (idx, pos) in self.positions.iter().enumerate() {
524 *self
526 .units_cache
527 .entry(pos.units.currency.clone())
528 .or_default() += pos.units.number;
529
530 if pos.cost.is_none() {
532 debug_assert!(
533 !self.simple_index.contains_key(&pos.units.currency),
534 "Invariant violated: multiple simple positions for currency {}",
535 pos.units.currency
536 );
537 self.simple_index.insert(pos.units.currency.clone(), idx);
538 }
539 }
540 }
541
542 pub fn merge(&mut self, other: &Self) {
544 for pos in &other.positions {
545 self.add(pos.clone());
546 }
547 }
548
549 #[must_use]
554 pub fn at_cost(&self) -> Self {
555 let mut result = Self::new();
556
557 for pos in &self.positions {
558 if pos.is_empty() {
559 continue;
560 }
561
562 if let Some(cost) = &pos.cost {
563 let total = pos.units.number * cost.number;
565 result.add(Position::simple(Amount::new(total, &cost.currency)));
566 } else {
567 result.add(pos.clone());
569 }
570 }
571
572 result
573 }
574
575 #[must_use]
580 pub fn at_units(&self) -> Self {
581 let mut result = Self::new();
582
583 for pos in &self.positions {
584 if pos.is_empty() {
585 continue;
586 }
587
588 result.add(Position::simple(pos.units.clone()));
590 }
591
592 result
593 }
594}
595
596impl fmt::Display for Inventory {
597 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
598 if self.is_empty() {
599 return write!(f, "(empty)");
600 }
601
602 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
604 non_empty.sort_by(|a, b| {
605 let cmp = a.units.currency.cmp(&b.units.currency);
607 if cmp != std::cmp::Ordering::Equal {
608 return cmp;
609 }
610 match (&a.cost, &b.cost) {
612 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
613 (Some(_), None) => std::cmp::Ordering::Greater,
614 (None, Some(_)) => std::cmp::Ordering::Less,
615 (None, None) => std::cmp::Ordering::Equal,
616 }
617 });
618
619 for (i, pos) in non_empty.iter().enumerate() {
620 if i > 0 {
621 write!(f, ", ")?;
622 }
623 write!(f, "{pos}")?;
624 }
625 Ok(())
626 }
627}
628
629impl FromIterator<Position> for Inventory {
630 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
631 let mut inv = Self::new();
632 for pos in iter {
633 inv.add(pos);
634 }
635 inv
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::Cost;
643 use crate::NaiveDate;
644 use rust_decimal_macros::dec;
645
646 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
647 crate::naive_date(year, month, day).unwrap()
648 }
649
650 #[test]
651 fn test_empty_inventory() {
652 let inv = Inventory::new();
653 assert!(inv.is_empty());
654 assert_eq!(inv.len(), 0);
655 }
656
657 #[test]
658 fn test_add_simple() {
659 let mut inv = Inventory::new();
660 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
661
662 assert!(!inv.is_empty());
663 assert_eq!(inv.units("USD"), dec!(100));
664 }
665
666 #[test]
667 fn test_add_merge_simple() {
668 let mut inv = Inventory::new();
669 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
670 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
671
672 assert_eq!(inv.len(), 1);
674 assert_eq!(inv.units("USD"), dec!(150));
675 }
676
677 #[test]
678 fn test_add_with_cost_no_merge() {
679 let mut inv = Inventory::new();
680
681 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
682 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
683
684 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
685 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
686
687 assert_eq!(inv.len(), 2);
689 assert_eq!(inv.units("AAPL"), dec!(15));
690 }
691
692 #[test]
693 fn test_currencies() {
694 let mut inv = Inventory::new();
695 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
696 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
697 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
698
699 let currencies = inv.currencies();
700 assert_eq!(currencies.len(), 3);
701 assert!(currencies.contains(&"USD"));
702 assert!(currencies.contains(&"EUR"));
703 assert!(currencies.contains(&"AAPL"));
704 }
705
706 #[test]
707 fn test_reduce_strict_unique() {
708 let mut inv = Inventory::new();
709 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
710 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
711
712 let result = inv
713 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
714 .unwrap();
715
716 assert_eq!(inv.units("AAPL"), dec!(5));
717 assert!(result.cost_basis.is_some());
718 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
720
721 #[test]
722 fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
723 let mut inv = Inventory::new();
724
725 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
726 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
727
728 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
729 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
730
731 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
735
736 assert!(
737 matches!(result, Err(BookingError::AmbiguousMatch { .. })),
738 "expected AmbiguousMatch, got {result:?}"
739 );
740 assert_eq!(inv.units("AAPL"), dec!(15));
742 }
743
744 #[test]
745 fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
746 let mut inv = Inventory::new();
747
748 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
750
751 inv.add(Position::with_cost(
752 Amount::new(dec!(10), "AAPL"),
753 cost.clone(),
754 ));
755 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
756
757 let result = inv
758 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
759 .expect("identical lots should fall back to FIFO without error");
760
761 assert_eq!(inv.units("AAPL"), dec!(12));
762 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
763 }
764
765 #[test]
766 fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
767 let mut inv = Inventory::new();
768
769 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
773 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
774
775 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
776 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
777
778 let result = inv
779 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
780 .expect("same cost number, different dates should fall back to FIFO");
781
782 assert_eq!(inv.units("AAPL"), dec!(15));
783 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
785 }
786
787 #[test]
788 fn test_reduce_strict_multiple_match_total_match_exception() {
789 let mut inv = Inventory::new();
790
791 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
792 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
793
794 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
795 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
796
797 let result = inv
800 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
801 .expect("total-match exception should accept a full liquidation");
802
803 assert_eq!(inv.units("AAPL"), dec!(0));
804 assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
806 }
807
808 #[test]
809 fn test_reduce_strict_with_spec() {
810 let mut inv = Inventory::new();
811
812 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
813 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
814
815 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
816 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
817
818 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
820 let result = inv
821 .reduce(
822 &Amount::new(dec!(-3), "AAPL"),
823 Some(&spec),
824 BookingMethod::Strict,
825 )
826 .unwrap();
827
828 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
831
832 #[test]
833 fn test_reduce_fifo() {
834 let mut inv = Inventory::new();
835
836 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
837 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
838 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
839
840 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
841 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
842 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
843
844 let result = inv
846 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
847 .unwrap();
848
849 assert_eq!(inv.units("AAPL"), dec!(15));
850 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
852 }
853
854 #[test]
855 fn test_reduce_lifo() {
856 let mut inv = Inventory::new();
857
858 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
859 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
860 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
861
862 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
863 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
864 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
865
866 let result = inv
868 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
869 .unwrap();
870
871 assert_eq!(inv.units("AAPL"), dec!(15));
872 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
874 }
875
876 #[test]
877 fn test_reduce_insufficient() {
878 let mut inv = Inventory::new();
879 let cost = Cost::new(dec!(150.00), "USD");
880 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
881
882 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
883
884 assert!(matches!(
885 result,
886 Err(BookingError::InsufficientUnits { .. })
887 ));
888 }
889
890 #[test]
891 fn test_book_value() {
892 let mut inv = Inventory::new();
893
894 let cost1 = Cost::new(dec!(100.00), "USD");
895 let cost2 = Cost::new(dec!(150.00), "USD");
896
897 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
898 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
899
900 let book = inv.book_value("AAPL");
901 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
903
904 #[test]
905 fn test_display() {
906 let mut inv = Inventory::new();
907 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
908
909 let s = format!("{inv}");
910 assert!(s.contains("100 USD"));
911 }
912
913 #[test]
914 fn test_display_empty() {
915 let inv = Inventory::new();
916 assert_eq!(format!("{inv}"), "(empty)");
917 }
918
919 #[test]
920 fn test_from_iterator() {
921 let positions = vec![
922 Position::simple(Amount::new(dec!(100), "USD")),
923 Position::simple(Amount::new(dec!(50), "USD")),
924 ];
925
926 let inv: Inventory = positions.into_iter().collect();
927 assert_eq!(inv.units("USD"), dec!(150));
928 }
929
930 #[test]
931 fn test_add_costed_positions_kept_separate() {
932 let mut inv = Inventory::new();
935
936 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
937
938 inv.add(Position::with_cost(
940 Amount::new(dec!(10), "AAPL"),
941 cost.clone(),
942 ));
943 assert_eq!(inv.len(), 1);
944 assert_eq!(inv.units("AAPL"), dec!(10));
945
946 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
948 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
951
952 #[test]
953 fn test_add_costed_positions_net_units() {
954 let mut inv = Inventory::new();
956
957 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
958
959 inv.add(Position::with_cost(
961 Amount::new(dec!(10), "AAPL"),
962 cost.clone(),
963 ));
964
965 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
967 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
970
971 #[test]
972 fn test_add_no_cancel_different_cost() {
973 let mut inv = Inventory::new();
975
976 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
977 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
978
979 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
981
982 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
984
985 assert_eq!(inv.len(), 2);
987 assert_eq!(inv.units("AAPL"), dec!(5)); }
989
990 #[test]
991 fn test_add_no_cancel_same_sign() {
992 let mut inv = Inventory::new();
994
995 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
996
997 inv.add(Position::with_cost(
999 Amount::new(dec!(10), "AAPL"),
1000 cost.clone(),
1001 ));
1002
1003 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1005
1006 assert_eq!(inv.len(), 2);
1008 assert_eq!(inv.units("AAPL"), dec!(15));
1009 }
1010
1011 #[test]
1012 fn test_merge_keeps_lots_separate() {
1013 let mut inv1 = Inventory::new();
1015 let mut inv2 = Inventory::new();
1016
1017 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1018
1019 inv1.add(Position::with_cost(
1021 Amount::new(dec!(10), "AAPL"),
1022 cost.clone(),
1023 ));
1024
1025 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1027
1028 inv1.merge(&inv2);
1030 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
1033
1034 #[test]
1039 fn test_hifo_with_tie_breaking() {
1040 let mut inv = Inventory::new();
1042
1043 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1045 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1046 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1047
1048 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1049 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1050 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1051
1052 let result = inv
1054 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1055 .unwrap();
1056
1057 assert_eq!(inv.units("AAPL"), dec!(15));
1058 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1060 }
1061
1062 #[test]
1063 fn test_hifo_with_different_costs() {
1064 let mut inv = Inventory::new();
1066
1067 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1068 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1069 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1070
1071 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1072 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1073 inv.add(Position::with_cost(
1074 Amount::new(dec!(10), "AAPL"),
1075 cost_high,
1076 ));
1077
1078 let result = inv
1080 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1081 .unwrap();
1082
1083 assert_eq!(inv.units("AAPL"), dec!(15));
1084 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1086 }
1087
1088 #[test]
1089 fn test_average_booking_with_pre_existing_positions() {
1090 let mut inv = Inventory::new();
1091
1092 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1094 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1095
1096 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1097 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1098
1099 let result = inv
1102 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1103 .unwrap();
1104
1105 assert_eq!(inv.units("AAPL"), dec!(15));
1106 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1108 }
1109
1110 #[test]
1111 fn test_average_booking_reduces_all() {
1112 let mut inv = Inventory::new();
1113
1114 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1115 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1116
1117 let result = inv
1119 .reduce(
1120 &Amount::new(dec!(-10), "AAPL"),
1121 None,
1122 BookingMethod::Average,
1123 )
1124 .unwrap();
1125
1126 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1127 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1128 }
1129
1130 #[test]
1131 fn test_none_booking_augmentation() {
1132 let mut inv = Inventory::new();
1134 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1135
1136 let result = inv
1138 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1139 .unwrap();
1140
1141 assert_eq!(inv.units("USD"), dec!(150));
1142 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
1144 }
1145
1146 #[test]
1147 fn test_none_booking_reduction() {
1148 let mut inv = Inventory::new();
1150 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1151
1152 let result = inv
1153 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1154 .unwrap();
1155
1156 assert_eq!(inv.units("USD"), dec!(70));
1157 assert!(!result.matched.is_empty());
1158 }
1159
1160 #[test]
1161 fn test_none_booking_insufficient() {
1162 let mut inv = Inventory::new();
1163 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1164
1165 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1166
1167 assert!(matches!(
1168 result,
1169 Err(BookingError::InsufficientUnits { .. })
1170 ));
1171 }
1172
1173 #[test]
1174 fn test_booking_error_no_matching_lot() {
1175 let mut inv = Inventory::new();
1176
1177 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1179 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1180
1181 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1183 let result = inv.reduce(
1184 &Amount::new(dec!(-5), "AAPL"),
1185 Some(&wrong_spec),
1186 BookingMethod::Strict,
1187 );
1188
1189 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1190 }
1191
1192 #[test]
1193 fn test_booking_error_insufficient_units() {
1194 let mut inv = Inventory::new();
1195
1196 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1197 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1198
1199 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1201
1202 match result {
1203 Err(BookingError::InsufficientUnits {
1204 requested,
1205 available,
1206 ..
1207 }) => {
1208 assert_eq!(requested, dec!(20));
1209 assert_eq!(available, dec!(10));
1210 }
1211 _ => panic!("Expected InsufficientUnits error"),
1212 }
1213 }
1214
1215 #[test]
1216 fn test_strict_with_size_exact_match() {
1217 let mut inv = Inventory::new();
1218
1219 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1221 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1222
1223 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1224 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1225
1226 let result = inv
1228 .reduce(
1229 &Amount::new(dec!(-5), "AAPL"),
1230 None,
1231 BookingMethod::StrictWithSize,
1232 )
1233 .unwrap();
1234
1235 assert_eq!(inv.units("AAPL"), dec!(10));
1236 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1237 }
1238
1239 #[test]
1240 fn test_strict_with_size_total_match() {
1241 let mut inv = Inventory::new();
1242
1243 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1245 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1246
1247 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1248 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1249
1250 let result = inv
1252 .reduce(
1253 &Amount::new(dec!(-15), "AAPL"),
1254 None,
1255 BookingMethod::StrictWithSize,
1256 )
1257 .unwrap();
1258
1259 assert_eq!(inv.units("AAPL"), dec!(0));
1260 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1261 }
1262
1263 #[test]
1264 fn test_strict_with_size_ambiguous() {
1265 let mut inv = Inventory::new();
1266
1267 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1269 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1270
1271 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1272 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1273
1274 let result = inv.reduce(
1276 &Amount::new(dec!(-7), "AAPL"),
1277 None,
1278 BookingMethod::StrictWithSize,
1279 );
1280
1281 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1282 }
1283
1284 #[test]
1285 fn test_short_position() {
1286 let mut inv = Inventory::new();
1288
1289 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1291 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1292
1293 assert_eq!(inv.units("AAPL"), dec!(-10));
1294 assert!(!inv.is_empty());
1295 }
1296
1297 #[test]
1298 fn test_at_cost() {
1299 let mut inv = Inventory::new();
1300
1301 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1302 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1303
1304 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1305 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1306 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1307
1308 let at_cost = inv.at_cost();
1309
1310 assert_eq!(at_cost.units("USD"), dec!(1850));
1313 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1315
1316 #[test]
1317 fn test_at_units() {
1318 let mut inv = Inventory::new();
1319
1320 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1321 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1322
1323 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1324 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1325
1326 let at_units = inv.at_units();
1327
1328 assert_eq!(at_units.units("AAPL"), dec!(15));
1330 assert_eq!(at_units.len(), 1);
1332 }
1333
1334 #[test]
1335 fn test_add_empty_position() {
1336 let mut inv = Inventory::new();
1337 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1338
1339 assert!(inv.is_empty());
1340 assert_eq!(inv.len(), 0);
1341 }
1342
1343 #[test]
1344 fn test_compact() {
1345 let mut inv = Inventory::new();
1346
1347 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1348 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1349
1350 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1352 .unwrap();
1353
1354 inv.compact();
1356 assert!(inv.is_empty());
1357 assert_eq!(inv.len(), 0);
1358 }
1359
1360 #[test]
1361 fn test_booking_method_from_str() {
1362 assert_eq!(
1363 BookingMethod::from_str("STRICT").unwrap(),
1364 BookingMethod::Strict
1365 );
1366 assert_eq!(
1367 BookingMethod::from_str("fifo").unwrap(),
1368 BookingMethod::Fifo
1369 );
1370 assert_eq!(
1371 BookingMethod::from_str("LIFO").unwrap(),
1372 BookingMethod::Lifo
1373 );
1374 assert_eq!(
1375 BookingMethod::from_str("Hifo").unwrap(),
1376 BookingMethod::Hifo
1377 );
1378 assert_eq!(
1379 BookingMethod::from_str("AVERAGE").unwrap(),
1380 BookingMethod::Average
1381 );
1382 assert_eq!(
1383 BookingMethod::from_str("NONE").unwrap(),
1384 BookingMethod::None
1385 );
1386 assert_eq!(
1387 BookingMethod::from_str("strict_with_size").unwrap(),
1388 BookingMethod::StrictWithSize
1389 );
1390 assert!(BookingMethod::from_str("INVALID").is_err());
1391 }
1392
1393 #[test]
1394 fn test_booking_method_display() {
1395 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1396 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1397 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1398 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1399 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1400 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1401 assert_eq!(
1402 format!("{}", BookingMethod::StrictWithSize),
1403 "STRICT_WITH_SIZE"
1404 );
1405 }
1406
1407 #[test]
1408 fn test_booking_error_display() {
1409 let err = BookingError::AmbiguousMatch {
1410 num_matches: 3,
1411 currency: "AAPL".into(),
1412 };
1413 assert!(format!("{err}").contains("3 lots match"));
1414
1415 let err = BookingError::NoMatchingLot {
1416 currency: "AAPL".into(),
1417 cost_spec: CostSpec::empty(),
1418 };
1419 assert!(format!("{err}").contains("No matching lot"));
1420
1421 let err = BookingError::InsufficientUnits {
1422 currency: "AAPL".into(),
1423 requested: dec!(100),
1424 available: dec!(50),
1425 };
1426 assert!(format!("{err}").contains("requested 100"));
1427 assert!(format!("{err}").contains("available 50"));
1428
1429 let err = BookingError::CurrencyMismatch {
1430 expected: "USD".into(),
1431 got: "EUR".into(),
1432 };
1433 assert!(format!("{err}").contains("expected USD"));
1434 assert!(format!("{err}").contains("got EUR"));
1435 }
1436
1437 #[test]
1438 fn test_book_value_multiple_currencies() {
1439 let mut inv = Inventory::new();
1440
1441 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1443 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1444
1445 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1447 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1448
1449 let book = inv.book_value("AAPL");
1450 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1451 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1452 }
1453
1454 #[test]
1455 fn test_reduce_hifo_insufficient_units() {
1456 let mut inv = Inventory::new();
1457
1458 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1459 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1460
1461 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1462
1463 assert!(matches!(
1464 result,
1465 Err(BookingError::InsufficientUnits { .. })
1466 ));
1467 }
1468
1469 #[test]
1470 fn test_reduce_average_insufficient_units() {
1471 let mut inv = Inventory::new();
1472
1473 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1474 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1475
1476 let result = inv.reduce(
1477 &Amount::new(dec!(-20), "AAPL"),
1478 None,
1479 BookingMethod::Average,
1480 );
1481
1482 assert!(matches!(
1483 result,
1484 Err(BookingError::InsufficientUnits { .. })
1485 ));
1486 }
1487
1488 #[test]
1489 fn test_reduce_average_empty_inventory() {
1490 let mut inv = Inventory::new();
1491
1492 let result = inv.reduce(
1493 &Amount::new(dec!(-10), "AAPL"),
1494 None,
1495 BookingMethod::Average,
1496 );
1497
1498 assert!(matches!(
1499 result,
1500 Err(BookingError::InsufficientUnits { .. })
1501 ));
1502 }
1503
1504 #[test]
1505 fn test_reduce_merge_operator() {
1506 let mut inv = Inventory::new();
1508 inv.add(Position::with_cost(
1509 Amount::new(dec!(10), "AAPL"),
1510 Cost::new(dec!(150), "USD"),
1511 ));
1512 inv.add(Position::with_cost(
1513 Amount::new(dec!(10), "AAPL"),
1514 Cost::new(dec!(160), "USD"),
1515 ));
1516
1517 let merge_spec = CostSpec::empty().with_merge();
1518 let result = inv
1519 .reduce(
1520 &Amount::new(dec!(-5), "AAPL"),
1521 Some(&merge_spec),
1522 BookingMethod::Strict,
1523 )
1524 .expect("merge reduction should succeed");
1525
1526 assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1528
1529 assert_eq!(inv.positions.len(), 1);
1531 assert_eq!(inv.positions[0].units.number, dec!(15));
1532 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1533 assert_eq!(cost.number, dec!(155));
1534 }
1535
1536 #[test]
1537 fn test_reduce_merge_insufficient_units() {
1538 let mut inv = Inventory::new();
1539 inv.add(Position::with_cost(
1540 Amount::new(dec!(10), "AAPL"),
1541 Cost::new(dec!(150), "USD"),
1542 ));
1543
1544 let merge_spec = CostSpec::empty().with_merge();
1545 let result = inv.reduce(
1546 &Amount::new(dec!(-20), "AAPL"),
1547 Some(&merge_spec),
1548 BookingMethod::Strict,
1549 );
1550
1551 assert!(matches!(
1552 result,
1553 Err(BookingError::InsufficientUnits { .. })
1554 ));
1555 }
1556
1557 #[test]
1558 fn test_reduce_merge_sells_all() {
1559 let mut inv = Inventory::new();
1561 inv.add(Position::with_cost(
1562 Amount::new(dec!(10), "AAPL"),
1563 Cost::new(dec!(150), "USD"),
1564 ));
1565 inv.add(Position::with_cost(
1566 Amount::new(dec!(10), "AAPL"),
1567 Cost::new(dec!(160), "USD"),
1568 ));
1569
1570 let merge_spec = CostSpec::empty().with_merge();
1571 let result = inv
1572 .reduce(
1573 &Amount::new(dec!(-20), "AAPL"),
1574 Some(&merge_spec),
1575 BookingMethod::Strict,
1576 )
1577 .expect("merge reduction should succeed");
1578
1579 assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1581
1582 assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1584 }
1585
1586 #[test]
1587 fn test_reduce_merge_single_lot() {
1588 let mut inv = Inventory::new();
1590 inv.add(Position::with_cost(
1591 Amount::new(dec!(10), "AAPL"),
1592 Cost::new(dec!(150), "USD"),
1593 ));
1594
1595 let merge_spec = CostSpec::empty().with_merge();
1596 let result = inv
1597 .reduce(
1598 &Amount::new(dec!(-3), "AAPL"),
1599 Some(&merge_spec),
1600 BookingMethod::Strict,
1601 )
1602 .expect("single-lot merge should succeed");
1603
1604 assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1605 assert_eq!(inv.positions.len(), 1);
1606 assert_eq!(inv.positions[0].units.number, dec!(7));
1607 }
1608
1609 #[test]
1610 fn test_reduce_merge_three_lots() {
1611 let mut inv = Inventory::new();
1613 inv.add(Position::with_cost(
1614 Amount::new(dec!(10), "AAPL"),
1615 Cost::new(dec!(100), "USD"),
1616 ));
1617 inv.add(Position::with_cost(
1618 Amount::new(dec!(10), "AAPL"),
1619 Cost::new(dec!(150), "USD"),
1620 ));
1621 inv.add(Position::with_cost(
1622 Amount::new(dec!(10), "AAPL"),
1623 Cost::new(dec!(200), "USD"),
1624 ));
1625
1626 let merge_spec = CostSpec::empty().with_merge();
1628 let result = inv
1629 .reduce(
1630 &Amount::new(dec!(-6), "AAPL"),
1631 Some(&merge_spec),
1632 BookingMethod::Strict,
1633 )
1634 .expect("three-lot merge should succeed");
1635
1636 assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1637 assert_eq!(inv.positions.len(), 1);
1638 assert_eq!(inv.positions[0].units.number, dec!(24));
1639 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1640 assert_eq!(cost.number, dec!(150));
1641 }
1642
1643 #[test]
1644 fn test_reduce_merge_mixed_cost_currencies_errors() {
1645 let mut inv = Inventory::new();
1647 inv.add(Position::with_cost(
1648 Amount::new(dec!(10), "AAPL"),
1649 Cost::new(dec!(150), "USD"),
1650 ));
1651 inv.add(Position::with_cost(
1652 Amount::new(dec!(10), "AAPL"),
1653 Cost::new(dec!(130), "EUR"),
1654 ));
1655
1656 let merge_spec = CostSpec::empty().with_merge();
1657 let result = inv.reduce(
1658 &Amount::new(dec!(-5), "AAPL"),
1659 Some(&merge_spec),
1660 BookingMethod::Strict,
1661 );
1662
1663 assert!(
1664 matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1665 "expected CurrencyMismatch, got {result:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_reduce_merge_empty_inventory() {
1671 let mut inv = Inventory::new();
1672
1673 let merge_spec = CostSpec::empty().with_merge();
1674 let result = inv.reduce(
1675 &Amount::new(dec!(-5), "AAPL"),
1676 Some(&merge_spec),
1677 BookingMethod::Strict,
1678 );
1679
1680 assert!(matches!(
1681 result,
1682 Err(BookingError::InsufficientUnits { .. })
1683 ));
1684 }
1685
1686 #[test]
1687 fn test_inventory_display_sorted() {
1688 let mut inv = Inventory::new();
1689
1690 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1692 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1693 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1694
1695 let display = format!("{inv}");
1696
1697 let aapl_pos = display.find("AAPL").unwrap();
1699 let eur_pos = display.find("EUR").unwrap();
1700 let usd_pos = display.find("USD").unwrap();
1701
1702 assert!(aapl_pos < eur_pos);
1703 assert!(eur_pos < usd_pos);
1704 }
1705
1706 #[test]
1707 fn test_inventory_with_cost_display_sorted() {
1708 let mut inv = Inventory::new();
1709
1710 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1712 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1713
1714 inv.add(Position::with_cost(
1715 Amount::new(dec!(10), "AAPL"),
1716 cost_high,
1717 ));
1718 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1719
1720 let display = format!("{inv}");
1721
1722 assert!(display.contains("AAPL"));
1724 assert!(display.contains("100"));
1725 assert!(display.contains("200"));
1726 }
1727
1728 #[test]
1729 fn test_reduce_hifo_no_matching_lot() {
1730 let mut inv = Inventory::new();
1731
1732 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1734
1735 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1736
1737 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1738 }
1739
1740 #[test]
1741 fn test_fifo_respects_dates() {
1742 let mut inv = Inventory::new();
1744
1745 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1747 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1748
1749 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1750 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1751
1752 let result = inv
1754 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1755 .unwrap();
1756
1757 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1759 }
1760
1761 #[test]
1762 fn test_lifo_respects_dates() {
1763 let mut inv = Inventory::new();
1765
1766 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1768 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1769
1770 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1771 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1772
1773 let result = inv
1775 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1776 .unwrap();
1777
1778 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1780 }
1781
1782 #[test]
1795 fn test_strict_with_size_different_costs_exact_match() {
1796 let mut inv = Inventory::new();
1799
1800 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1801 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1802
1803 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1804 inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1805
1806 let result = inv
1808 .reduce(
1809 &Amount::new(dec!(-7), "AAPL"),
1810 None,
1811 BookingMethod::StrictWithSize,
1812 )
1813 .unwrap();
1814
1815 assert_eq!(inv.units("AAPL"), dec!(10));
1816 assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); }
1818
1819 #[test]
1820 fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1821 let mut inv = Inventory::new();
1824
1825 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1826 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1827
1828 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1829 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1830
1831 let result = inv
1833 .reduce(
1834 &Amount::new(dec!(-5), "AAPL"),
1835 None,
1836 BookingMethod::StrictWithSize,
1837 )
1838 .unwrap();
1839
1840 assert_eq!(inv.units("AAPL"), dec!(5));
1841 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1843 }
1844
1845 #[test]
1846 fn test_strict_with_size_with_cost_spec() {
1847 let mut inv = Inventory::new();
1849
1850 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1851 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1852
1853 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1854 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1855
1856 let spec = CostSpec::empty().with_number_per(dec!(200.00));
1858 let result = inv
1859 .reduce(
1860 &Amount::new(dec!(-5), "AAPL"),
1861 Some(&spec),
1862 BookingMethod::StrictWithSize,
1863 )
1864 .unwrap();
1865
1866 assert_eq!(inv.units("AAPL"), dec!(15));
1867 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
1869
1870 #[test]
1873 fn test_hifo_reduces_highest_cost_first() {
1874 let mut inv = Inventory::new();
1876
1877 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1878 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1879 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1880
1881 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1882 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1883 inv.add(Position::with_cost(
1884 Amount::new(dec!(10), "AAPL"),
1885 cost_high,
1886 ));
1887
1888 let result = inv
1890 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1891 .unwrap();
1892
1893 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
1895 }
1896
1897 #[test]
1898 fn test_hifo_spans_multiple_lots() {
1899 let mut inv = Inventory::new();
1902
1903 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1904 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1905
1906 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1907 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1908
1909 let result = inv
1911 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1912 .unwrap();
1913
1914 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1916 assert_eq!(inv.units("AAPL"), dec!(2));
1917 }
1918
1919 #[test]
1920 fn test_hifo_with_cost_spec_filter() {
1921 let mut inv = Inventory::new();
1923
1924 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1925 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1926
1927 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1928 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1929
1930 let spec = CostSpec::empty().with_currency("USD");
1932 let result = inv
1933 .reduce(
1934 &Amount::new(dec!(-5), "AAPL"),
1935 Some(&spec),
1936 BookingMethod::Hifo,
1937 )
1938 .unwrap();
1939
1940 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
1942
1943 #[test]
1944 fn test_hifo_short_position() {
1945 let mut inv = Inventory::new();
1947
1948 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1949 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1950
1951 inv.add(Position::with_cost(
1953 Amount::new(dec!(-10), "AAPL"),
1954 cost_low,
1955 ));
1956 inv.add(Position::with_cost(
1957 Amount::new(dec!(-10), "AAPL"),
1958 cost_high,
1959 ));
1960
1961 let result = inv
1964 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
1965 .unwrap();
1966
1967 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
1969 }
1970
1971 #[test]
1974 fn test_average_weighted_cost() {
1975 let mut inv = Inventory::new();
1977
1978 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1979 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1980
1981 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1982 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1983
1984 let result = inv
1986 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1987 .unwrap();
1988
1989 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1991 assert_eq!(inv.units("AAPL"), dec!(15));
1992 }
1993
1994 #[test]
1995 fn test_average_merges_into_single_position() {
1996 let mut inv = Inventory::new();
1998
1999 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2000 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2001
2002 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2003 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2004
2005 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2006 .unwrap();
2007
2008 let aapl_positions: Vec<_> = inv
2010 .positions
2011 .iter()
2012 .filter(|p| p.units.currency.as_ref() == "AAPL")
2013 .collect();
2014 assert_eq!(aapl_positions.len(), 1);
2015 assert_eq!(aapl_positions[0].units.number, dec!(15));
2016 }
2017
2018 #[test]
2019 fn test_average_uneven_lots() {
2020 let mut inv = Inventory::new();
2022
2023 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2024 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2025
2026 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2027 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2028
2029 let result = inv
2031 .reduce(
2032 &Amount::new(dec!(-10), "AAPL"),
2033 None,
2034 BookingMethod::Average,
2035 )
2036 .unwrap();
2037
2038 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
2040
2041 #[test]
2044 fn test_none_booking_with_cost_positions() {
2045 let mut inv = Inventory::new();
2047
2048 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2049 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2050
2051 let result = inv
2052 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2053 .unwrap();
2054
2055 assert_eq!(inv.units("AAPL"), dec!(5));
2056 assert!(result.cost_basis.is_some());
2058 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2059 }
2060
2061 #[test]
2062 fn test_none_booking_short_cover() {
2063 let mut inv = Inventory::new();
2065 inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2066
2067 let result = inv
2069 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2070 .unwrap();
2071
2072 assert_eq!(inv.units("USD"), dec!(-70));
2073 assert!(!result.matched.is_empty());
2074 }
2075
2076 #[test]
2077 fn test_none_booking_empty_inventory_augments() {
2078 let mut inv = Inventory::new();
2080
2081 let result = inv
2082 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2083 .unwrap();
2084
2085 assert_eq!(inv.units("USD"), dec!(50));
2086 assert!(result.matched.is_empty()); }
2088
2089 #[test]
2092 fn test_fifo_short_position_cover() {
2093 let mut inv = Inventory::new();
2095
2096 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2097 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2098
2099 inv.add(Position::with_cost(
2100 Amount::new(dec!(-10), "AAPL"),
2101 cost_old,
2102 ));
2103 inv.add(Position::with_cost(
2104 Amount::new(dec!(-10), "AAPL"),
2105 cost_new,
2106 ));
2107
2108 let result = inv
2110 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2111 .unwrap();
2112
2113 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2115 }
2116
2117 #[test]
2118 fn test_lifo_short_position_cover() {
2119 let mut inv = Inventory::new();
2121
2122 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2123 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2124
2125 inv.add(Position::with_cost(
2126 Amount::new(dec!(-10), "AAPL"),
2127 cost_old,
2128 ));
2129 inv.add(Position::with_cost(
2130 Amount::new(dec!(-10), "AAPL"),
2131 cost_new,
2132 ));
2133
2134 let result = inv
2136 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2137 .unwrap();
2138
2139 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2141 }
2142
2143 #[test]
2164 fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2165 let mut inv = Inventory::new();
2175
2176 let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2178 inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2179
2180 inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2182
2183 let buy_units = Amount::new(dec!(50), "HOOG");
2185
2186 assert!(
2190 !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2191 "augmentation with cost spec should NOT be treated as reduction \
2192 when only a simple (no-cost) position has opposite sign"
2193 );
2194
2195 assert!(
2198 inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2199 "without cost spec filter, the -25 HOOG simple position \
2200 should cause is_reduced_by to return true"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_accounted_error_display_insufficient_units() {
2206 let err = BookingError::InsufficientUnits {
2207 currency: "AAPL".into(),
2208 requested: dec!(15),
2209 available: dec!(10),
2210 }
2211 .with_account("Assets:Stock".into());
2212 let rendered = format!("{err}");
2213
2214 assert!(
2217 rendered.contains("not enough"),
2218 "must contain 'not enough' (pta-standards): {rendered}"
2219 );
2220 assert!(
2221 rendered.contains("Assets:Stock"),
2222 "must contain account name: {rendered}"
2223 );
2224 assert!(
2225 rendered.contains("15") && rendered.contains("10"),
2226 "must contain requested and available amounts: {rendered}"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_accounted_error_display_no_matching_lot() {
2232 let err = BookingError::NoMatchingLot {
2233 currency: "AAPL".into(),
2234 cost_spec: CostSpec::empty(),
2235 }
2236 .with_account("Assets:Stock".into());
2237 let rendered = format!("{err}");
2238
2239 assert!(
2240 rendered.contains("No matching lot"),
2241 "must contain 'No matching lot': {rendered}"
2242 );
2243 assert!(
2244 rendered.contains("AAPL"),
2245 "must contain currency: {rendered}"
2246 );
2247 assert!(
2248 rendered.contains("Assets:Stock"),
2249 "must contain account name: {rendered}"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_accounted_error_display_ambiguous_match() {
2255 let err = BookingError::AmbiguousMatch {
2256 num_matches: 3,
2257 currency: "AAPL".into(),
2258 }
2259 .with_account("Assets:Stock".into());
2260 let rendered = format!("{err}");
2261
2262 assert!(
2263 rendered.contains("Ambiguous"),
2264 "must contain 'Ambiguous': {rendered}"
2265 );
2266 assert!(
2267 rendered.contains("AAPL"),
2268 "must contain currency: {rendered}"
2269 );
2270 assert!(
2271 rendered.contains("Assets:Stock"),
2272 "must contain account name: {rendered}"
2273 );
2274 assert!(
2275 rendered.contains('3'),
2276 "must contain match count: {rendered}"
2277 );
2278 }
2279
2280 #[test]
2281 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2282 let err = BookingError::CurrencyMismatch {
2290 expected: "USD".into(),
2291 got: "EUR".into(),
2292 }
2293 .with_account("Assets:Cash".into());
2294 let rendered = format!("{err}");
2295
2296 assert!(
2297 rendered.contains("No matching lot"),
2298 "CurrencyMismatch must render as 'No matching lot' for E4001 \
2299 consistency: {rendered}"
2300 );
2301 assert!(
2302 rendered.contains("EUR"),
2303 "must contain the mismatched (got) currency: {rendered}"
2304 );
2305 assert!(
2306 rendered.contains("Assets:Cash"),
2307 "must contain account name: {rendered}"
2308 );
2309 }
2310}