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, PartialEq, Eq)]
77pub struct BookingResult {
78 pub matched: Vec<Position>,
80 pub cost_basis: Option<Amount>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum BookingError {
87 AmbiguousMatch {
89 num_matches: usize,
91 currency: InternedStr,
93 },
94 NoMatchingLot {
96 currency: InternedStr,
98 cost_spec: CostSpec,
100 },
101 InsufficientUnits {
103 currency: InternedStr,
105 requested: Decimal,
107 available: Decimal,
109 },
110 CurrencyMismatch {
112 expected: InternedStr,
114 got: InternedStr,
116 },
117}
118
119impl fmt::Display for BookingError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::AmbiguousMatch {
123 num_matches,
124 currency,
125 } => write!(
126 f,
127 "Ambiguous match: {num_matches} lots match for {currency}"
128 ),
129 Self::NoMatchingLot {
130 currency,
131 cost_spec,
132 } => {
133 write!(f, "No matching lot for {currency} with cost {cost_spec}")
134 }
135 Self::InsufficientUnits {
136 currency,
137 requested,
138 available,
139 } => write!(
140 f,
141 "Insufficient units of {currency}: requested {requested}, available {available}"
142 ),
143 Self::CurrencyMismatch { expected, got } => {
144 write!(f, "Currency mismatch: expected {expected}, got {got}")
145 }
146 }
147 }
148}
149
150impl std::error::Error for BookingError {}
151
152impl BookingError {
153 #[must_use]
166 pub const fn with_account(self, account: InternedStr) -> AccountedBookingError {
167 AccountedBookingError {
168 error: self,
169 account,
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct AccountedBookingError {
185 pub error: BookingError,
187 pub account: InternedStr,
189}
190
191impl fmt::Display for AccountedBookingError {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 match &self.error {
194 BookingError::InsufficientUnits {
195 requested,
196 available,
197 ..
198 } => write!(
199 f,
200 "Not enough units in {}: requested {}, available {}; not enough to reduce",
201 self.account, requested, available
202 ),
203 BookingError::NoMatchingLot { currency, .. } => {
204 write!(f, "No matching lot for {} in {}", currency, self.account)
205 }
206 BookingError::AmbiguousMatch {
207 num_matches,
208 currency,
209 } => write!(
210 f,
211 "Ambiguous lot match for {}: {} lots match in {}",
212 currency, num_matches, self.account
213 ),
214 BookingError::CurrencyMismatch { got, .. } => {
223 write!(f, "No matching lot for {} in {}", got, self.account)
224 }
225 }
226 }
227}
228
229impl std::error::Error for AccountedBookingError {}
230
231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254#[cfg_attr(
255 feature = "rkyv",
256 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
257)]
258pub struct Inventory {
259 positions: Vec<Position>,
260 #[serde(skip)]
264 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
265 simple_index: FxHashMap<InternedStr, usize>,
266 #[serde(skip)]
270 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
271 units_cache: FxHashMap<InternedStr, Decimal>,
272}
273
274impl PartialEq for Inventory {
275 fn eq(&self, other: &Self) -> bool {
276 self.positions == other.positions
278 }
279}
280
281impl Eq for Inventory {}
282
283impl Inventory {
284 #[must_use]
286 pub fn new() -> Self {
287 Self::default()
288 }
289
290 #[must_use]
292 pub fn positions(&self) -> &[Position] {
293 &self.positions
294 }
295
296 pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
298 &mut self.positions
299 }
300
301 #[must_use]
303 pub fn is_empty(&self) -> bool {
304 self.positions.is_empty()
305 || self
306 .positions
307 .iter()
308 .all(super::position::Position::is_empty)
309 }
310
311 #[must_use]
313 pub const fn len(&self) -> usize {
314 self.positions.len()
315 }
316
317 #[must_use]
322 pub fn units(&self, currency: &str) -> Decimal {
323 self.units_cache.get(currency).copied().unwrap_or_else(|| {
326 self.positions
328 .iter()
329 .filter(|p| p.units.currency == currency)
330 .map(|p| p.units.number)
331 .sum()
332 })
333 }
334
335 #[must_use]
337 pub fn currencies(&self) -> Vec<&str> {
338 let mut currencies: Vec<&str> = self
339 .positions
340 .iter()
341 .filter(|p| !p.is_empty())
342 .map(|p| p.units.currency.as_str())
343 .collect();
344 currencies.sort_unstable();
345 currencies.dedup();
346 currencies
347 }
348
349 #[must_use]
357 pub fn is_reduced_by(&self, units: &Amount) -> bool {
358 self.positions.iter().any(|pos| {
359 pos.units.currency == units.currency
360 && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
361 })
362 }
363
364 #[must_use]
368 pub fn book_value(&self, units_currency: &str) -> FxHashMap<InternedStr, Decimal> {
369 let mut totals: FxHashMap<InternedStr, Decimal> = FxHashMap::default();
370
371 for pos in &self.positions {
372 if pos.units.currency == units_currency
373 && let Some(book) = pos.book_value()
374 {
375 *totals.entry(book.currency.clone()).or_default() += book.number;
376 }
377 }
378
379 totals
380 }
381
382 pub fn add(&mut self, position: Position) {
399 if position.is_empty() {
400 return;
401 }
402
403 *self
405 .units_cache
406 .entry(position.units.currency.clone())
407 .or_default() += position.units.number;
408
409 if position.cost.is_none() {
411 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
412 debug_assert!(self.positions[idx].cost.is_none());
414 self.positions[idx].units += &position.units;
415 return;
416 }
417 let idx = self.positions.len();
419 self.simple_index
420 .insert(position.units.currency.clone(), idx);
421 self.positions.push(position);
422 return;
423 }
424
425 self.positions.push(position);
429 }
430
431 pub fn reduce(
458 &mut self,
459 units: &Amount,
460 cost_spec: Option<&CostSpec>,
461 method: BookingMethod,
462 ) -> Result<BookingResult, BookingError> {
463 let spec = cost_spec.cloned().unwrap_or_default();
464
465 if spec.merge {
468 return self.reduce_merge(units);
469 }
470
471 match method {
472 BookingMethod::Strict => self.reduce_strict(units, &spec),
473 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
474 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
475 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
476 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
477 BookingMethod::Average => self.reduce_average(units),
478 BookingMethod::None => self.reduce_none(units),
479 }
480 }
481
482 pub fn compact(&mut self) {
484 self.positions.retain(|p| !p.is_empty());
485 self.rebuild_index();
486 }
487
488 fn rebuild_index(&mut self) {
491 self.simple_index.clear();
492 self.units_cache.clear();
493
494 for (idx, pos) in self.positions.iter().enumerate() {
495 *self
497 .units_cache
498 .entry(pos.units.currency.clone())
499 .or_default() += pos.units.number;
500
501 if pos.cost.is_none() {
503 debug_assert!(
504 !self.simple_index.contains_key(&pos.units.currency),
505 "Invariant violated: multiple simple positions for currency {}",
506 pos.units.currency
507 );
508 self.simple_index.insert(pos.units.currency.clone(), idx);
509 }
510 }
511 }
512
513 pub fn merge(&mut self, other: &Self) {
515 for pos in &other.positions {
516 self.add(pos.clone());
517 }
518 }
519
520 #[must_use]
525 pub fn at_cost(&self) -> Self {
526 let mut result = Self::new();
527
528 for pos in &self.positions {
529 if pos.is_empty() {
530 continue;
531 }
532
533 if let Some(cost) = &pos.cost {
534 let total = pos.units.number * cost.number;
536 result.add(Position::simple(Amount::new(total, &cost.currency)));
537 } else {
538 result.add(pos.clone());
540 }
541 }
542
543 result
544 }
545
546 #[must_use]
551 pub fn at_units(&self) -> Self {
552 let mut result = Self::new();
553
554 for pos in &self.positions {
555 if pos.is_empty() {
556 continue;
557 }
558
559 result.add(Position::simple(pos.units.clone()));
561 }
562
563 result
564 }
565}
566
567impl fmt::Display for Inventory {
568 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569 if self.is_empty() {
570 return write!(f, "(empty)");
571 }
572
573 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
575 non_empty.sort_by(|a, b| {
576 let cmp = a.units.currency.cmp(&b.units.currency);
578 if cmp != std::cmp::Ordering::Equal {
579 return cmp;
580 }
581 match (&a.cost, &b.cost) {
583 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
584 (Some(_), None) => std::cmp::Ordering::Greater,
585 (None, Some(_)) => std::cmp::Ordering::Less,
586 (None, None) => std::cmp::Ordering::Equal,
587 }
588 });
589
590 for (i, pos) in non_empty.iter().enumerate() {
591 if i > 0 {
592 write!(f, ", ")?;
593 }
594 write!(f, "{pos}")?;
595 }
596 Ok(())
597 }
598}
599
600impl FromIterator<Position> for Inventory {
601 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
602 let mut inv = Self::new();
603 for pos in iter {
604 inv.add(pos);
605 }
606 inv
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::Cost;
614 use crate::NaiveDate;
615 use rust_decimal_macros::dec;
616
617 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
618 crate::naive_date(year, month, day).unwrap()
619 }
620
621 #[test]
622 fn test_empty_inventory() {
623 let inv = Inventory::new();
624 assert!(inv.is_empty());
625 assert_eq!(inv.len(), 0);
626 }
627
628 #[test]
629 fn test_add_simple() {
630 let mut inv = Inventory::new();
631 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
632
633 assert!(!inv.is_empty());
634 assert_eq!(inv.units("USD"), dec!(100));
635 }
636
637 #[test]
638 fn test_add_merge_simple() {
639 let mut inv = Inventory::new();
640 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
641 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
642
643 assert_eq!(inv.len(), 1);
645 assert_eq!(inv.units("USD"), dec!(150));
646 }
647
648 #[test]
649 fn test_add_with_cost_no_merge() {
650 let mut inv = Inventory::new();
651
652 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
653 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
654
655 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
656 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
657
658 assert_eq!(inv.len(), 2);
660 assert_eq!(inv.units("AAPL"), dec!(15));
661 }
662
663 #[test]
664 fn test_currencies() {
665 let mut inv = Inventory::new();
666 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
667 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
668 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
669
670 let currencies = inv.currencies();
671 assert_eq!(currencies.len(), 3);
672 assert!(currencies.contains(&"USD"));
673 assert!(currencies.contains(&"EUR"));
674 assert!(currencies.contains(&"AAPL"));
675 }
676
677 #[test]
678 fn test_reduce_strict_unique() {
679 let mut inv = Inventory::new();
680 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
681 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
682
683 let result = inv
684 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
685 .unwrap();
686
687 assert_eq!(inv.units("AAPL"), dec!(5));
688 assert!(result.cost_basis.is_some());
689 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
691
692 #[test]
693 fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
694 let mut inv = Inventory::new();
695
696 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
697 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
698
699 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
700 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
701
702 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
706
707 assert!(
708 matches!(result, Err(BookingError::AmbiguousMatch { .. })),
709 "expected AmbiguousMatch, got {result:?}"
710 );
711 assert_eq!(inv.units("AAPL"), dec!(15));
713 }
714
715 #[test]
716 fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
717 let mut inv = Inventory::new();
718
719 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
721
722 inv.add(Position::with_cost(
723 Amount::new(dec!(10), "AAPL"),
724 cost.clone(),
725 ));
726 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
727
728 let result = inv
729 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
730 .expect("identical lots should fall back to FIFO without error");
731
732 assert_eq!(inv.units("AAPL"), dec!(12));
733 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
734 }
735
736 #[test]
737 fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
738 let mut inv = Inventory::new();
739
740 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
744 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
745
746 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
747 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
748
749 let result = inv
750 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
751 .expect("same cost number, different dates should fall back to FIFO");
752
753 assert_eq!(inv.units("AAPL"), dec!(15));
754 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
756 }
757
758 #[test]
759 fn test_reduce_strict_multiple_match_total_match_exception() {
760 let mut inv = Inventory::new();
761
762 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
763 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
764
765 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
766 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
767
768 let result = inv
771 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
772 .expect("total-match exception should accept a full liquidation");
773
774 assert_eq!(inv.units("AAPL"), dec!(0));
775 assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
777 }
778
779 #[test]
780 fn test_reduce_strict_with_spec() {
781 let mut inv = Inventory::new();
782
783 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
784 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
785
786 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
787 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
788
789 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
791 let result = inv
792 .reduce(
793 &Amount::new(dec!(-3), "AAPL"),
794 Some(&spec),
795 BookingMethod::Strict,
796 )
797 .unwrap();
798
799 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
802
803 #[test]
804 fn test_reduce_fifo() {
805 let mut inv = Inventory::new();
806
807 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
808 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
809 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
810
811 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
812 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
813 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
814
815 let result = inv
817 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
818 .unwrap();
819
820 assert_eq!(inv.units("AAPL"), dec!(15));
821 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
823 }
824
825 #[test]
826 fn test_reduce_lifo() {
827 let mut inv = Inventory::new();
828
829 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
830 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
831 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
832
833 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
834 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
835 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
836
837 let result = inv
839 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
840 .unwrap();
841
842 assert_eq!(inv.units("AAPL"), dec!(15));
843 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
845 }
846
847 #[test]
848 fn test_reduce_insufficient() {
849 let mut inv = Inventory::new();
850 let cost = Cost::new(dec!(150.00), "USD");
851 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
852
853 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
854
855 assert!(matches!(
856 result,
857 Err(BookingError::InsufficientUnits { .. })
858 ));
859 }
860
861 #[test]
862 fn test_book_value() {
863 let mut inv = Inventory::new();
864
865 let cost1 = Cost::new(dec!(100.00), "USD");
866 let cost2 = Cost::new(dec!(150.00), "USD");
867
868 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
869 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
870
871 let book = inv.book_value("AAPL");
872 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
874
875 #[test]
876 fn test_display() {
877 let mut inv = Inventory::new();
878 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
879
880 let s = format!("{inv}");
881 assert!(s.contains("100 USD"));
882 }
883
884 #[test]
885 fn test_display_empty() {
886 let inv = Inventory::new();
887 assert_eq!(format!("{inv}"), "(empty)");
888 }
889
890 #[test]
891 fn test_from_iterator() {
892 let positions = vec![
893 Position::simple(Amount::new(dec!(100), "USD")),
894 Position::simple(Amount::new(dec!(50), "USD")),
895 ];
896
897 let inv: Inventory = positions.into_iter().collect();
898 assert_eq!(inv.units("USD"), dec!(150));
899 }
900
901 #[test]
902 fn test_add_costed_positions_kept_separate() {
903 let mut inv = Inventory::new();
906
907 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
908
909 inv.add(Position::with_cost(
911 Amount::new(dec!(10), "AAPL"),
912 cost.clone(),
913 ));
914 assert_eq!(inv.len(), 1);
915 assert_eq!(inv.units("AAPL"), dec!(10));
916
917 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
919 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
922
923 #[test]
924 fn test_add_costed_positions_net_units() {
925 let mut inv = Inventory::new();
927
928 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
929
930 inv.add(Position::with_cost(
932 Amount::new(dec!(10), "AAPL"),
933 cost.clone(),
934 ));
935
936 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
938 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
941
942 #[test]
943 fn test_add_no_cancel_different_cost() {
944 let mut inv = Inventory::new();
946
947 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
948 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
949
950 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
952
953 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
955
956 assert_eq!(inv.len(), 2);
958 assert_eq!(inv.units("AAPL"), dec!(5)); }
960
961 #[test]
962 fn test_add_no_cancel_same_sign() {
963 let mut inv = Inventory::new();
965
966 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
967
968 inv.add(Position::with_cost(
970 Amount::new(dec!(10), "AAPL"),
971 cost.clone(),
972 ));
973
974 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
976
977 assert_eq!(inv.len(), 2);
979 assert_eq!(inv.units("AAPL"), dec!(15));
980 }
981
982 #[test]
983 fn test_merge_keeps_lots_separate() {
984 let mut inv1 = Inventory::new();
986 let mut inv2 = Inventory::new();
987
988 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
989
990 inv1.add(Position::with_cost(
992 Amount::new(dec!(10), "AAPL"),
993 cost.clone(),
994 ));
995
996 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
998
999 inv1.merge(&inv2);
1001 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
1004
1005 #[test]
1010 fn test_hifo_with_tie_breaking() {
1011 let mut inv = Inventory::new();
1013
1014 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1016 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1017 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1018
1019 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1020 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1021 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1022
1023 let result = inv
1025 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1026 .unwrap();
1027
1028 assert_eq!(inv.units("AAPL"), dec!(15));
1029 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1031 }
1032
1033 #[test]
1034 fn test_hifo_with_different_costs() {
1035 let mut inv = Inventory::new();
1037
1038 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1039 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1040 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1041
1042 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1043 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1044 inv.add(Position::with_cost(
1045 Amount::new(dec!(10), "AAPL"),
1046 cost_high,
1047 ));
1048
1049 let result = inv
1051 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1052 .unwrap();
1053
1054 assert_eq!(inv.units("AAPL"), dec!(15));
1055 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1057 }
1058
1059 #[test]
1060 fn test_average_booking_with_pre_existing_positions() {
1061 let mut inv = Inventory::new();
1062
1063 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1065 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1066
1067 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1068 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1069
1070 let result = inv
1073 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1074 .unwrap();
1075
1076 assert_eq!(inv.units("AAPL"), dec!(15));
1077 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1079 }
1080
1081 #[test]
1082 fn test_average_booking_reduces_all() {
1083 let mut inv = Inventory::new();
1084
1085 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1086 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1087
1088 let result = inv
1090 .reduce(
1091 &Amount::new(dec!(-10), "AAPL"),
1092 None,
1093 BookingMethod::Average,
1094 )
1095 .unwrap();
1096
1097 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1098 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1099 }
1100
1101 #[test]
1102 fn test_none_booking_augmentation() {
1103 let mut inv = Inventory::new();
1105 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1106
1107 let result = inv
1109 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1110 .unwrap();
1111
1112 assert_eq!(inv.units("USD"), dec!(150));
1113 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
1115 }
1116
1117 #[test]
1118 fn test_none_booking_reduction() {
1119 let mut inv = Inventory::new();
1121 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1122
1123 let result = inv
1124 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1125 .unwrap();
1126
1127 assert_eq!(inv.units("USD"), dec!(70));
1128 assert!(!result.matched.is_empty());
1129 }
1130
1131 #[test]
1132 fn test_none_booking_insufficient() {
1133 let mut inv = Inventory::new();
1134 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1135
1136 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1137
1138 assert!(matches!(
1139 result,
1140 Err(BookingError::InsufficientUnits { .. })
1141 ));
1142 }
1143
1144 #[test]
1145 fn test_booking_error_no_matching_lot() {
1146 let mut inv = Inventory::new();
1147
1148 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1150 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1151
1152 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1154 let result = inv.reduce(
1155 &Amount::new(dec!(-5), "AAPL"),
1156 Some(&wrong_spec),
1157 BookingMethod::Strict,
1158 );
1159
1160 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1161 }
1162
1163 #[test]
1164 fn test_booking_error_insufficient_units() {
1165 let mut inv = Inventory::new();
1166
1167 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1168 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1169
1170 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1172
1173 match result {
1174 Err(BookingError::InsufficientUnits {
1175 requested,
1176 available,
1177 ..
1178 }) => {
1179 assert_eq!(requested, dec!(20));
1180 assert_eq!(available, dec!(10));
1181 }
1182 _ => panic!("Expected InsufficientUnits error"),
1183 }
1184 }
1185
1186 #[test]
1187 fn test_strict_with_size_exact_match() {
1188 let mut inv = Inventory::new();
1189
1190 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1192 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1193
1194 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1195 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1196
1197 let result = inv
1199 .reduce(
1200 &Amount::new(dec!(-5), "AAPL"),
1201 None,
1202 BookingMethod::StrictWithSize,
1203 )
1204 .unwrap();
1205
1206 assert_eq!(inv.units("AAPL"), dec!(10));
1207 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1208 }
1209
1210 #[test]
1211 fn test_strict_with_size_total_match() {
1212 let mut inv = Inventory::new();
1213
1214 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1216 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1217
1218 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1219 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1220
1221 let result = inv
1223 .reduce(
1224 &Amount::new(dec!(-15), "AAPL"),
1225 None,
1226 BookingMethod::StrictWithSize,
1227 )
1228 .unwrap();
1229
1230 assert_eq!(inv.units("AAPL"), dec!(0));
1231 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1232 }
1233
1234 #[test]
1235 fn test_strict_with_size_ambiguous() {
1236 let mut inv = Inventory::new();
1237
1238 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1240 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1241
1242 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1243 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1244
1245 let result = inv.reduce(
1247 &Amount::new(dec!(-7), "AAPL"),
1248 None,
1249 BookingMethod::StrictWithSize,
1250 );
1251
1252 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1253 }
1254
1255 #[test]
1256 fn test_short_position() {
1257 let mut inv = Inventory::new();
1259
1260 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1262 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1263
1264 assert_eq!(inv.units("AAPL"), dec!(-10));
1265 assert!(!inv.is_empty());
1266 }
1267
1268 #[test]
1269 fn test_at_cost() {
1270 let mut inv = Inventory::new();
1271
1272 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1273 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1274
1275 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1276 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1277 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1278
1279 let at_cost = inv.at_cost();
1280
1281 assert_eq!(at_cost.units("USD"), dec!(1850));
1284 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1286
1287 #[test]
1288 fn test_at_units() {
1289 let mut inv = Inventory::new();
1290
1291 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1292 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1293
1294 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1295 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1296
1297 let at_units = inv.at_units();
1298
1299 assert_eq!(at_units.units("AAPL"), dec!(15));
1301 assert_eq!(at_units.len(), 1);
1303 }
1304
1305 #[test]
1306 fn test_add_empty_position() {
1307 let mut inv = Inventory::new();
1308 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1309
1310 assert!(inv.is_empty());
1311 assert_eq!(inv.len(), 0);
1312 }
1313
1314 #[test]
1315 fn test_compact() {
1316 let mut inv = Inventory::new();
1317
1318 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1319 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1320
1321 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1323 .unwrap();
1324
1325 inv.compact();
1327 assert!(inv.is_empty());
1328 assert_eq!(inv.len(), 0);
1329 }
1330
1331 #[test]
1332 fn test_booking_method_from_str() {
1333 assert_eq!(
1334 BookingMethod::from_str("STRICT").unwrap(),
1335 BookingMethod::Strict
1336 );
1337 assert_eq!(
1338 BookingMethod::from_str("fifo").unwrap(),
1339 BookingMethod::Fifo
1340 );
1341 assert_eq!(
1342 BookingMethod::from_str("LIFO").unwrap(),
1343 BookingMethod::Lifo
1344 );
1345 assert_eq!(
1346 BookingMethod::from_str("Hifo").unwrap(),
1347 BookingMethod::Hifo
1348 );
1349 assert_eq!(
1350 BookingMethod::from_str("AVERAGE").unwrap(),
1351 BookingMethod::Average
1352 );
1353 assert_eq!(
1354 BookingMethod::from_str("NONE").unwrap(),
1355 BookingMethod::None
1356 );
1357 assert_eq!(
1358 BookingMethod::from_str("strict_with_size").unwrap(),
1359 BookingMethod::StrictWithSize
1360 );
1361 assert!(BookingMethod::from_str("INVALID").is_err());
1362 }
1363
1364 #[test]
1365 fn test_booking_method_display() {
1366 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1367 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1368 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1369 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1370 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1371 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1372 assert_eq!(
1373 format!("{}", BookingMethod::StrictWithSize),
1374 "STRICT_WITH_SIZE"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_booking_error_display() {
1380 let err = BookingError::AmbiguousMatch {
1381 num_matches: 3,
1382 currency: "AAPL".into(),
1383 };
1384 assert!(format!("{err}").contains("3 lots match"));
1385
1386 let err = BookingError::NoMatchingLot {
1387 currency: "AAPL".into(),
1388 cost_spec: CostSpec::empty(),
1389 };
1390 assert!(format!("{err}").contains("No matching lot"));
1391
1392 let err = BookingError::InsufficientUnits {
1393 currency: "AAPL".into(),
1394 requested: dec!(100),
1395 available: dec!(50),
1396 };
1397 assert!(format!("{err}").contains("requested 100"));
1398 assert!(format!("{err}").contains("available 50"));
1399
1400 let err = BookingError::CurrencyMismatch {
1401 expected: "USD".into(),
1402 got: "EUR".into(),
1403 };
1404 assert!(format!("{err}").contains("expected USD"));
1405 assert!(format!("{err}").contains("got EUR"));
1406 }
1407
1408 #[test]
1409 fn test_book_value_multiple_currencies() {
1410 let mut inv = Inventory::new();
1411
1412 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1414 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1415
1416 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1418 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1419
1420 let book = inv.book_value("AAPL");
1421 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1422 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1423 }
1424
1425 #[test]
1426 fn test_reduce_hifo_insufficient_units() {
1427 let mut inv = Inventory::new();
1428
1429 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1430 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1431
1432 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1433
1434 assert!(matches!(
1435 result,
1436 Err(BookingError::InsufficientUnits { .. })
1437 ));
1438 }
1439
1440 #[test]
1441 fn test_reduce_average_insufficient_units() {
1442 let mut inv = Inventory::new();
1443
1444 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1445 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1446
1447 let result = inv.reduce(
1448 &Amount::new(dec!(-20), "AAPL"),
1449 None,
1450 BookingMethod::Average,
1451 );
1452
1453 assert!(matches!(
1454 result,
1455 Err(BookingError::InsufficientUnits { .. })
1456 ));
1457 }
1458
1459 #[test]
1460 fn test_reduce_average_empty_inventory() {
1461 let mut inv = Inventory::new();
1462
1463 let result = inv.reduce(
1464 &Amount::new(dec!(-10), "AAPL"),
1465 None,
1466 BookingMethod::Average,
1467 );
1468
1469 assert!(matches!(
1470 result,
1471 Err(BookingError::InsufficientUnits { .. })
1472 ));
1473 }
1474
1475 #[test]
1476 fn test_reduce_merge_operator() {
1477 let mut inv = Inventory::new();
1479 inv.add(Position::with_cost(
1480 Amount::new(dec!(10), "AAPL"),
1481 Cost::new(dec!(150), "USD"),
1482 ));
1483 inv.add(Position::with_cost(
1484 Amount::new(dec!(10), "AAPL"),
1485 Cost::new(dec!(160), "USD"),
1486 ));
1487
1488 let merge_spec = CostSpec::empty().with_merge();
1489 let result = inv
1490 .reduce(
1491 &Amount::new(dec!(-5), "AAPL"),
1492 Some(&merge_spec),
1493 BookingMethod::Strict,
1494 )
1495 .expect("merge reduction should succeed");
1496
1497 assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1499
1500 assert_eq!(inv.positions.len(), 1);
1502 assert_eq!(inv.positions[0].units.number, dec!(15));
1503 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1504 assert_eq!(cost.number, dec!(155));
1505 }
1506
1507 #[test]
1508 fn test_reduce_merge_insufficient_units() {
1509 let mut inv = Inventory::new();
1510 inv.add(Position::with_cost(
1511 Amount::new(dec!(10), "AAPL"),
1512 Cost::new(dec!(150), "USD"),
1513 ));
1514
1515 let merge_spec = CostSpec::empty().with_merge();
1516 let result = inv.reduce(
1517 &Amount::new(dec!(-20), "AAPL"),
1518 Some(&merge_spec),
1519 BookingMethod::Strict,
1520 );
1521
1522 assert!(matches!(
1523 result,
1524 Err(BookingError::InsufficientUnits { .. })
1525 ));
1526 }
1527
1528 #[test]
1529 fn test_reduce_merge_sells_all() {
1530 let mut inv = Inventory::new();
1532 inv.add(Position::with_cost(
1533 Amount::new(dec!(10), "AAPL"),
1534 Cost::new(dec!(150), "USD"),
1535 ));
1536 inv.add(Position::with_cost(
1537 Amount::new(dec!(10), "AAPL"),
1538 Cost::new(dec!(160), "USD"),
1539 ));
1540
1541 let merge_spec = CostSpec::empty().with_merge();
1542 let result = inv
1543 .reduce(
1544 &Amount::new(dec!(-20), "AAPL"),
1545 Some(&merge_spec),
1546 BookingMethod::Strict,
1547 )
1548 .expect("merge reduction should succeed");
1549
1550 assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1552
1553 assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1555 }
1556
1557 #[test]
1558 fn test_reduce_merge_single_lot() {
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
1566 let merge_spec = CostSpec::empty().with_merge();
1567 let result = inv
1568 .reduce(
1569 &Amount::new(dec!(-3), "AAPL"),
1570 Some(&merge_spec),
1571 BookingMethod::Strict,
1572 )
1573 .expect("single-lot merge should succeed");
1574
1575 assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1576 assert_eq!(inv.positions.len(), 1);
1577 assert_eq!(inv.positions[0].units.number, dec!(7));
1578 }
1579
1580 #[test]
1581 fn test_reduce_merge_three_lots() {
1582 let mut inv = Inventory::new();
1584 inv.add(Position::with_cost(
1585 Amount::new(dec!(10), "AAPL"),
1586 Cost::new(dec!(100), "USD"),
1587 ));
1588 inv.add(Position::with_cost(
1589 Amount::new(dec!(10), "AAPL"),
1590 Cost::new(dec!(150), "USD"),
1591 ));
1592 inv.add(Position::with_cost(
1593 Amount::new(dec!(10), "AAPL"),
1594 Cost::new(dec!(200), "USD"),
1595 ));
1596
1597 let merge_spec = CostSpec::empty().with_merge();
1599 let result = inv
1600 .reduce(
1601 &Amount::new(dec!(-6), "AAPL"),
1602 Some(&merge_spec),
1603 BookingMethod::Strict,
1604 )
1605 .expect("three-lot merge should succeed");
1606
1607 assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1608 assert_eq!(inv.positions.len(), 1);
1609 assert_eq!(inv.positions[0].units.number, dec!(24));
1610 let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1611 assert_eq!(cost.number, dec!(150));
1612 }
1613
1614 #[test]
1615 fn test_reduce_merge_mixed_cost_currencies_errors() {
1616 let mut inv = Inventory::new();
1618 inv.add(Position::with_cost(
1619 Amount::new(dec!(10), "AAPL"),
1620 Cost::new(dec!(150), "USD"),
1621 ));
1622 inv.add(Position::with_cost(
1623 Amount::new(dec!(10), "AAPL"),
1624 Cost::new(dec!(130), "EUR"),
1625 ));
1626
1627 let merge_spec = CostSpec::empty().with_merge();
1628 let result = inv.reduce(
1629 &Amount::new(dec!(-5), "AAPL"),
1630 Some(&merge_spec),
1631 BookingMethod::Strict,
1632 );
1633
1634 assert!(
1635 matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1636 "expected CurrencyMismatch, got {result:?}"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_reduce_merge_empty_inventory() {
1642 let mut inv = Inventory::new();
1643
1644 let merge_spec = CostSpec::empty().with_merge();
1645 let result = inv.reduce(
1646 &Amount::new(dec!(-5), "AAPL"),
1647 Some(&merge_spec),
1648 BookingMethod::Strict,
1649 );
1650
1651 assert!(matches!(
1652 result,
1653 Err(BookingError::InsufficientUnits { .. })
1654 ));
1655 }
1656
1657 #[test]
1658 fn test_inventory_display_sorted() {
1659 let mut inv = Inventory::new();
1660
1661 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1663 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1664 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1665
1666 let display = format!("{inv}");
1667
1668 let aapl_pos = display.find("AAPL").unwrap();
1670 let eur_pos = display.find("EUR").unwrap();
1671 let usd_pos = display.find("USD").unwrap();
1672
1673 assert!(aapl_pos < eur_pos);
1674 assert!(eur_pos < usd_pos);
1675 }
1676
1677 #[test]
1678 fn test_inventory_with_cost_display_sorted() {
1679 let mut inv = Inventory::new();
1680
1681 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1683 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1684
1685 inv.add(Position::with_cost(
1686 Amount::new(dec!(10), "AAPL"),
1687 cost_high,
1688 ));
1689 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1690
1691 let display = format!("{inv}");
1692
1693 assert!(display.contains("AAPL"));
1695 assert!(display.contains("100"));
1696 assert!(display.contains("200"));
1697 }
1698
1699 #[test]
1700 fn test_reduce_hifo_no_matching_lot() {
1701 let mut inv = Inventory::new();
1702
1703 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1705
1706 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1707
1708 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1709 }
1710
1711 #[test]
1712 fn test_fifo_respects_dates() {
1713 let mut inv = Inventory::new();
1715
1716 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1718 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1719
1720 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1721 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1722
1723 let result = inv
1725 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1726 .unwrap();
1727
1728 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1730 }
1731
1732 #[test]
1733 fn test_lifo_respects_dates() {
1734 let mut inv = Inventory::new();
1736
1737 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1739 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1740
1741 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1742 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1743
1744 let result = inv
1746 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1747 .unwrap();
1748
1749 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1751 }
1752
1753 #[test]
1766 fn test_strict_with_size_different_costs_exact_match() {
1767 let mut inv = Inventory::new();
1770
1771 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1772 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1773
1774 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1775 inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1776
1777 let result = inv
1779 .reduce(
1780 &Amount::new(dec!(-7), "AAPL"),
1781 None,
1782 BookingMethod::StrictWithSize,
1783 )
1784 .unwrap();
1785
1786 assert_eq!(inv.units("AAPL"), dec!(10));
1787 assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); }
1789
1790 #[test]
1791 fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1792 let mut inv = Inventory::new();
1795
1796 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1797 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1798
1799 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1800 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1801
1802 let result = inv
1804 .reduce(
1805 &Amount::new(dec!(-5), "AAPL"),
1806 None,
1807 BookingMethod::StrictWithSize,
1808 )
1809 .unwrap();
1810
1811 assert_eq!(inv.units("AAPL"), dec!(5));
1812 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1814 }
1815
1816 #[test]
1817 fn test_strict_with_size_with_cost_spec() {
1818 let mut inv = Inventory::new();
1820
1821 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1822 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1823
1824 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1825 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1826
1827 let spec = CostSpec::empty().with_number_per(dec!(200.00));
1829 let result = inv
1830 .reduce(
1831 &Amount::new(dec!(-5), "AAPL"),
1832 Some(&spec),
1833 BookingMethod::StrictWithSize,
1834 )
1835 .unwrap();
1836
1837 assert_eq!(inv.units("AAPL"), dec!(15));
1838 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); }
1840
1841 #[test]
1844 fn test_hifo_reduces_highest_cost_first() {
1845 let mut inv = Inventory::new();
1847
1848 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1849 let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1850 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1851
1852 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1853 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1854 inv.add(Position::with_cost(
1855 Amount::new(dec!(10), "AAPL"),
1856 cost_high,
1857 ));
1858
1859 let result = inv
1861 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1862 .unwrap();
1863
1864 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(25));
1866 }
1867
1868 #[test]
1869 fn test_hifo_spans_multiple_lots() {
1870 let mut inv = Inventory::new();
1873
1874 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1875 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1876
1877 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1878 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1879
1880 let result = inv
1882 .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1883 .unwrap();
1884
1885 assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1887 assert_eq!(inv.units("AAPL"), dec!(2));
1888 }
1889
1890 #[test]
1891 fn test_hifo_with_cost_spec_filter() {
1892 let mut inv = Inventory::new();
1894
1895 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1896 let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1897
1898 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1899 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1900
1901 let spec = CostSpec::empty().with_currency("USD");
1903 let result = inv
1904 .reduce(
1905 &Amount::new(dec!(-5), "AAPL"),
1906 Some(&spec),
1907 BookingMethod::Hifo,
1908 )
1909 .unwrap();
1910
1911 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); }
1913
1914 #[test]
1915 fn test_hifo_short_position() {
1916 let mut inv = Inventory::new();
1918
1919 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1920 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1921
1922 inv.add(Position::with_cost(
1924 Amount::new(dec!(-10), "AAPL"),
1925 cost_low,
1926 ));
1927 inv.add(Position::with_cost(
1928 Amount::new(dec!(-10), "AAPL"),
1929 cost_high,
1930 ));
1931
1932 let result = inv
1935 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
1936 .unwrap();
1937
1938 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
1940 }
1941
1942 #[test]
1945 fn test_average_weighted_cost() {
1946 let mut inv = Inventory::new();
1948
1949 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1950 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1951
1952 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1953 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1954
1955 let result = inv
1957 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1958 .unwrap();
1959
1960 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1962 assert_eq!(inv.units("AAPL"), dec!(15));
1963 }
1964
1965 #[test]
1966 fn test_average_merges_into_single_position() {
1967 let mut inv = Inventory::new();
1969
1970 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1971 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1972
1973 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1974 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1975
1976 inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1977 .unwrap();
1978
1979 let aapl_positions: Vec<_> = inv
1981 .positions
1982 .iter()
1983 .filter(|p| p.units.currency.as_ref() == "AAPL")
1984 .collect();
1985 assert_eq!(aapl_positions.len(), 1);
1986 assert_eq!(aapl_positions[0].units.number, dec!(15));
1987 }
1988
1989 #[test]
1990 fn test_average_uneven_lots() {
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), "USD").with_date(date(2024, 2, 1));
1996
1997 inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
1998 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1999
2000 let result = inv
2002 .reduce(
2003 &Amount::new(dec!(-10), "AAPL"),
2004 None,
2005 BookingMethod::Average,
2006 )
2007 .unwrap();
2008
2009 assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); }
2011
2012 #[test]
2015 fn test_none_booking_with_cost_positions() {
2016 let mut inv = Inventory::new();
2018
2019 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2020 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2021
2022 let result = inv
2023 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2024 .unwrap();
2025
2026 assert_eq!(inv.units("AAPL"), dec!(5));
2027 assert!(result.cost_basis.is_some());
2029 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2030 }
2031
2032 #[test]
2033 fn test_none_booking_short_cover() {
2034 let mut inv = Inventory::new();
2036 inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2037
2038 let result = inv
2040 .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2041 .unwrap();
2042
2043 assert_eq!(inv.units("USD"), dec!(-70));
2044 assert!(!result.matched.is_empty());
2045 }
2046
2047 #[test]
2048 fn test_none_booking_empty_inventory_augments() {
2049 let mut inv = Inventory::new();
2051
2052 let result = inv
2053 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2054 .unwrap();
2055
2056 assert_eq!(inv.units("USD"), dec!(50));
2057 assert!(result.matched.is_empty()); }
2059
2060 #[test]
2063 fn test_fifo_short_position_cover() {
2064 let mut inv = Inventory::new();
2066
2067 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2068 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2069
2070 inv.add(Position::with_cost(
2071 Amount::new(dec!(-10), "AAPL"),
2072 cost_old,
2073 ));
2074 inv.add(Position::with_cost(
2075 Amount::new(dec!(-10), "AAPL"),
2076 cost_new,
2077 ));
2078
2079 let result = inv
2081 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2082 .unwrap();
2083
2084 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2086 }
2087
2088 #[test]
2089 fn test_lifo_short_position_cover() {
2090 let mut inv = Inventory::new();
2092
2093 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2094 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2095
2096 inv.add(Position::with_cost(
2097 Amount::new(dec!(-10), "AAPL"),
2098 cost_old,
2099 ));
2100 inv.add(Position::with_cost(
2101 Amount::new(dec!(-10), "AAPL"),
2102 cost_new,
2103 ));
2104
2105 let result = inv
2107 .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2108 .unwrap();
2109
2110 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); assert_eq!(inv.units("AAPL"), dec!(-15));
2112 }
2113
2114 #[test]
2126 fn test_accounted_error_display_insufficient_units() {
2127 let err = BookingError::InsufficientUnits {
2128 currency: "AAPL".into(),
2129 requested: dec!(15),
2130 available: dec!(10),
2131 }
2132 .with_account("Assets:Stock".into());
2133 let rendered = format!("{err}");
2134
2135 assert!(
2138 rendered.contains("not enough"),
2139 "must contain 'not enough' (pta-standards): {rendered}"
2140 );
2141 assert!(
2142 rendered.contains("Assets:Stock"),
2143 "must contain account name: {rendered}"
2144 );
2145 assert!(
2146 rendered.contains("15") && rendered.contains("10"),
2147 "must contain requested and available amounts: {rendered}"
2148 );
2149 }
2150
2151 #[test]
2152 fn test_accounted_error_display_no_matching_lot() {
2153 let err = BookingError::NoMatchingLot {
2154 currency: "AAPL".into(),
2155 cost_spec: CostSpec::empty(),
2156 }
2157 .with_account("Assets:Stock".into());
2158 let rendered = format!("{err}");
2159
2160 assert!(
2161 rendered.contains("No matching lot"),
2162 "must contain 'No matching lot': {rendered}"
2163 );
2164 assert!(
2165 rendered.contains("AAPL"),
2166 "must contain currency: {rendered}"
2167 );
2168 assert!(
2169 rendered.contains("Assets:Stock"),
2170 "must contain account name: {rendered}"
2171 );
2172 }
2173
2174 #[test]
2175 fn test_accounted_error_display_ambiguous_match() {
2176 let err = BookingError::AmbiguousMatch {
2177 num_matches: 3,
2178 currency: "AAPL".into(),
2179 }
2180 .with_account("Assets:Stock".into());
2181 let rendered = format!("{err}");
2182
2183 assert!(
2184 rendered.contains("Ambiguous"),
2185 "must contain 'Ambiguous': {rendered}"
2186 );
2187 assert!(
2188 rendered.contains("AAPL"),
2189 "must contain currency: {rendered}"
2190 );
2191 assert!(
2192 rendered.contains("Assets:Stock"),
2193 "must contain account name: {rendered}"
2194 );
2195 assert!(
2196 rendered.contains('3'),
2197 "must contain match count: {rendered}"
2198 );
2199 }
2200
2201 #[test]
2202 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2203 let err = BookingError::CurrencyMismatch {
2211 expected: "USD".into(),
2212 got: "EUR".into(),
2213 }
2214 .with_account("Assets:Cash".into());
2215 let rendered = format!("{err}");
2216
2217 assert!(
2218 rendered.contains("No matching lot"),
2219 "CurrencyMismatch must render as 'No matching lot' for E4001 \
2220 consistency: {rendered}"
2221 );
2222 assert!(
2223 rendered.contains("EUR"),
2224 "must contain the mismatched (got) currency: {rendered}"
2225 );
2226 assert!(
2227 rendered.contains("Assets:Cash"),
2228 "must contain account name: {rendered}"
2229 );
2230 }
2231}