1use rust_decimal::prelude::Signed;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use std::str::FromStr;
13
14use crate::intern::InternedStr;
15use crate::{Amount, CostSpec, Position};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
19#[cfg_attr(
20 feature = "rkyv",
21 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
22)]
23pub enum BookingMethod {
24 #[default]
27 Strict,
28 StrictWithSize,
31 Fifo,
33 Lifo,
35 Hifo,
37 Average,
39 None,
41}
42
43impl FromStr for BookingMethod {
44 type Err = String;
45
46 fn from_str(s: &str) -> Result<Self, Self::Err> {
47 match s.to_uppercase().as_str() {
48 "STRICT" => Ok(Self::Strict),
49 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
50 "FIFO" => Ok(Self::Fifo),
51 "LIFO" => Ok(Self::Lifo),
52 "HIFO" => Ok(Self::Hifo),
53 "AVERAGE" => Ok(Self::Average),
54 "NONE" => Ok(Self::None),
55 _ => Err(format!("unknown booking method: {s}")),
56 }
57 }
58}
59
60impl fmt::Display for BookingMethod {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::Strict => write!(f, "STRICT"),
64 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
65 Self::Fifo => write!(f, "FIFO"),
66 Self::Lifo => write!(f, "LIFO"),
67 Self::Hifo => write!(f, "HIFO"),
68 Self::Average => write!(f, "AVERAGE"),
69 Self::None => write!(f, "NONE"),
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct BookingResult {
77 pub matched: Vec<Position>,
79 pub cost_basis: Option<Amount>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum BookingError {
86 AmbiguousMatch {
88 num_matches: usize,
90 currency: InternedStr,
92 },
93 NoMatchingLot {
95 currency: InternedStr,
97 cost_spec: CostSpec,
99 },
100 InsufficientUnits {
102 currency: InternedStr,
104 requested: Decimal,
106 available: Decimal,
108 },
109 CurrencyMismatch {
111 expected: InternedStr,
113 got: InternedStr,
115 },
116}
117
118impl fmt::Display for BookingError {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 Self::AmbiguousMatch {
122 num_matches,
123 currency,
124 } => write!(
125 f,
126 "Ambiguous match: {num_matches} lots match for {currency}"
127 ),
128 Self::NoMatchingLot {
129 currency,
130 cost_spec,
131 } => {
132 write!(f, "No matching lot for {currency} with cost {cost_spec}")
133 }
134 Self::InsufficientUnits {
135 currency,
136 requested,
137 available,
138 } => write!(
139 f,
140 "Insufficient units of {currency}: requested {requested}, available {available}"
141 ),
142 Self::CurrencyMismatch { expected, got } => {
143 write!(f, "Currency mismatch: expected {expected}, got {got}")
144 }
145 }
146 }
147}
148
149impl std::error::Error for BookingError {}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
174#[cfg_attr(
175 feature = "rkyv",
176 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
177)]
178pub struct Inventory {
179 positions: Vec<Position>,
180 #[serde(skip)]
184 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
185 simple_index: HashMap<InternedStr, usize>,
186}
187
188impl PartialEq for Inventory {
189 fn eq(&self, other: &Self) -> bool {
190 self.positions == other.positions
192 }
193}
194
195impl Eq for Inventory {}
196
197impl Inventory {
198 #[must_use]
200 pub fn new() -> Self {
201 Self::default()
202 }
203
204 #[must_use]
206 pub fn positions(&self) -> &[Position] {
207 &self.positions
208 }
209
210 pub fn positions_mut(&mut self) -> &mut Vec<Position> {
212 &mut self.positions
213 }
214
215 #[must_use]
217 pub fn is_empty(&self) -> bool {
218 self.positions.is_empty()
219 || self
220 .positions
221 .iter()
222 .all(super::position::Position::is_empty)
223 }
224
225 #[must_use]
227 pub fn len(&self) -> usize {
228 self.positions.len()
229 }
230
231 #[must_use]
235 pub fn units(&self, currency: &str) -> Decimal {
236 self.positions
237 .iter()
238 .filter(|p| p.units.currency == currency)
239 .map(|p| p.units.number)
240 .sum()
241 }
242
243 #[must_use]
245 pub fn currencies(&self) -> Vec<&str> {
246 let mut currencies: Vec<&str> = self
247 .positions
248 .iter()
249 .filter(|p| !p.is_empty())
250 .map(|p| p.units.currency.as_str())
251 .collect();
252 currencies.sort_unstable();
253 currencies.dedup();
254 currencies
255 }
256
257 #[must_use]
261 pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
262 let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
263
264 for pos in &self.positions {
265 if pos.units.currency == units_currency {
266 if let Some(book) = pos.book_value() {
267 *totals.entry(book.currency.clone()).or_default() += book.number;
268 }
269 }
270 }
271
272 totals
273 }
274
275 pub fn add(&mut self, position: Position) {
281 if position.is_empty() {
282 return;
283 }
284
285 if position.cost.is_none() {
287 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
288 debug_assert!(self.positions[idx].cost.is_none());
290 self.positions[idx].units += &position.units;
291 return;
292 }
293 let idx = self.positions.len();
295 self.simple_index
296 .insert(position.units.currency.clone(), idx);
297 }
298
299 self.positions.push(position);
301 }
302
303 pub fn reduce(
316 &mut self,
317 units: &Amount,
318 cost_spec: Option<&CostSpec>,
319 method: BookingMethod,
320 ) -> Result<BookingResult, BookingError> {
321 let spec = cost_spec.cloned().unwrap_or_default();
322
323 match method {
324 BookingMethod::Strict => self.reduce_strict(units, &spec),
325 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
326 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
327 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
328 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
329 BookingMethod::Average => self.reduce_average(units),
330 BookingMethod::None => self.reduce_none(units),
331 }
332 }
333
334 fn reduce_strict(
337 &mut self,
338 units: &Amount,
339 spec: &CostSpec,
340 ) -> Result<BookingResult, BookingError> {
341 let matching_indices: Vec<usize> = self
342 .positions
343 .iter()
344 .enumerate()
345 .filter(|(_, p)| {
346 p.units.currency == units.currency
347 && !p.is_empty()
348 && p.can_reduce(units)
349 && p.matches_cost_spec(spec)
350 })
351 .map(|(i, _)| i)
352 .collect();
353
354 match matching_indices.len() {
355 0 => Err(BookingError::NoMatchingLot {
356 currency: units.currency.clone(),
357 cost_spec: spec.clone(),
358 }),
359 1 => {
360 let idx = matching_indices[0];
361 self.reduce_from_lot(idx, units)
362 }
363 n => {
364 let total_units: Decimal = matching_indices
366 .iter()
367 .map(|&i| self.positions[i].units.number.abs())
368 .sum();
369 if total_units == units.number.abs() {
370 self.reduce_ordered(units, spec, false)
372 } else {
373 Err(BookingError::AmbiguousMatch {
374 num_matches: n,
375 currency: units.currency.clone(),
376 })
377 }
378 }
379 }
380 }
381
382 fn reduce_strict_with_size(
384 &mut self,
385 units: &Amount,
386 spec: &CostSpec,
387 ) -> Result<BookingResult, BookingError> {
388 let matching_indices: Vec<usize> = self
389 .positions
390 .iter()
391 .enumerate()
392 .filter(|(_, p)| {
393 p.units.currency == units.currency
394 && !p.is_empty()
395 && p.can_reduce(units)
396 && p.matches_cost_spec(spec)
397 })
398 .map(|(i, _)| i)
399 .collect();
400
401 match matching_indices.len() {
402 0 => Err(BookingError::NoMatchingLot {
403 currency: units.currency.clone(),
404 cost_spec: spec.clone(),
405 }),
406 1 => {
407 let idx = matching_indices[0];
408 self.reduce_from_lot(idx, units)
409 }
410 n => {
411 let exact_matches: Vec<usize> = matching_indices
413 .iter()
414 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
415 .copied()
416 .collect();
417
418 if exact_matches.is_empty() {
419 let total_units: Decimal = matching_indices
421 .iter()
422 .map(|&i| self.positions[i].units.number.abs())
423 .sum();
424 if total_units == units.number.abs() {
425 self.reduce_ordered(units, spec, false)
426 } else {
427 Err(BookingError::AmbiguousMatch {
428 num_matches: n,
429 currency: units.currency.clone(),
430 })
431 }
432 } else {
433 let idx = exact_matches[0];
435 self.reduce_from_lot(idx, units)
436 }
437 }
438 }
439 }
440
441 fn reduce_fifo(
443 &mut self,
444 units: &Amount,
445 spec: &CostSpec,
446 ) -> Result<BookingResult, BookingError> {
447 self.reduce_ordered(units, spec, false)
448 }
449
450 fn reduce_lifo(
452 &mut self,
453 units: &Amount,
454 spec: &CostSpec,
455 ) -> Result<BookingResult, BookingError> {
456 self.reduce_ordered(units, spec, true)
457 }
458
459 fn reduce_hifo(
461 &mut self,
462 units: &Amount,
463 spec: &CostSpec,
464 ) -> Result<BookingResult, BookingError> {
465 let mut remaining = units.number.abs();
466 let mut matched = Vec::new();
467 let mut cost_basis = Decimal::ZERO;
468 let mut cost_currency = None;
469
470 let mut matching: Vec<(usize, Decimal)> = self
472 .positions
473 .iter()
474 .enumerate()
475 .filter(|(_, p)| {
476 p.units.currency == units.currency
477 && !p.is_empty()
478 && p.units.number.signum() != units.number.signum()
479 && p.matches_cost_spec(spec)
480 })
481 .map(|(i, p)| {
482 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
483 (i, cost)
484 })
485 .collect();
486
487 if matching.is_empty() {
488 return Err(BookingError::NoMatchingLot {
489 currency: units.currency.clone(),
490 cost_spec: spec.clone(),
491 });
492 }
493
494 matching.sort_by(|a, b| b.1.cmp(&a.1));
496
497 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
498
499 for idx in indices {
500 if remaining.is_zero() {
501 break;
502 }
503
504 let pos = &self.positions[idx];
505 let available = pos.units.number.abs();
506 let take = remaining.min(available);
507
508 if let Some(cost) = &pos.cost {
510 cost_basis += take * cost.number;
511 cost_currency = Some(cost.currency.clone());
512 }
513
514 let (taken, _) = pos.split(take * pos.units.number.signum());
516 matched.push(taken);
517
518 let reduction = if units.number.is_sign_negative() {
520 -take
521 } else {
522 take
523 };
524
525 let new_pos = Position {
526 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
527 cost: pos.cost.clone(),
528 };
529 self.positions[idx] = new_pos;
530
531 remaining -= take;
532 }
533
534 if !remaining.is_zero() {
535 let available = units.number.abs() - remaining;
536 return Err(BookingError::InsufficientUnits {
537 currency: units.currency.clone(),
538 requested: units.number.abs(),
539 available,
540 });
541 }
542
543 self.positions.retain(|p| !p.is_empty());
545 self.rebuild_index();
546
547 Ok(BookingResult {
548 matched,
549 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
550 })
551 }
552
553 fn reduce_ordered(
555 &mut self,
556 units: &Amount,
557 spec: &CostSpec,
558 reverse: bool,
559 ) -> Result<BookingResult, BookingError> {
560 let mut remaining = units.number.abs();
561 let mut matched = Vec::new();
562 let mut cost_basis = Decimal::ZERO;
563 let mut cost_currency = None;
564
565 let mut indices: Vec<usize> = self
567 .positions
568 .iter()
569 .enumerate()
570 .filter(|(_, p)| {
571 p.units.currency == units.currency
572 && !p.is_empty()
573 && p.units.number.signum() != units.number.signum()
574 && p.matches_cost_spec(spec)
575 })
576 .map(|(i, _)| i)
577 .collect();
578
579 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
582
583 if reverse {
584 indices.reverse();
585 }
586
587 if indices.is_empty() {
588 return Err(BookingError::NoMatchingLot {
589 currency: units.currency.clone(),
590 cost_spec: spec.clone(),
591 });
592 }
593
594 for idx in indices {
595 if remaining.is_zero() {
596 break;
597 }
598
599 let pos = &self.positions[idx];
600 let available = pos.units.number.abs();
601 let take = remaining.min(available);
602
603 if let Some(cost) = &pos.cost {
605 cost_basis += take * cost.number;
606 cost_currency = Some(cost.currency.clone());
607 }
608
609 let (taken, _) = pos.split(take * pos.units.number.signum());
611 matched.push(taken);
612
613 let reduction = if units.number.is_sign_negative() {
615 -take
616 } else {
617 take
618 };
619
620 let new_pos = Position {
621 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
622 cost: pos.cost.clone(),
623 };
624 self.positions[idx] = new_pos;
625
626 remaining -= take;
627 }
628
629 if !remaining.is_zero() {
630 let available = units.number.abs() - remaining;
631 return Err(BookingError::InsufficientUnits {
632 currency: units.currency.clone(),
633 requested: units.number.abs(),
634 available,
635 });
636 }
637
638 self.positions.retain(|p| !p.is_empty());
640 self.rebuild_index();
641
642 Ok(BookingResult {
643 matched,
644 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
645 })
646 }
647
648 fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
650 let total_units: Decimal = self
652 .positions
653 .iter()
654 .filter(|p| p.units.currency == units.currency && !p.is_empty())
655 .map(|p| p.units.number)
656 .sum();
657
658 if total_units.is_zero() {
659 return Err(BookingError::InsufficientUnits {
660 currency: units.currency.clone(),
661 requested: units.number.abs(),
662 available: Decimal::ZERO,
663 });
664 }
665
666 let reduction = units.number.abs();
668 if reduction > total_units.abs() {
669 return Err(BookingError::InsufficientUnits {
670 currency: units.currency.clone(),
671 requested: reduction,
672 available: total_units.abs(),
673 });
674 }
675
676 let book_values = self.book_value(&units.currency);
678 let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
679 let per_unit_cost = total / total_units;
680 Some(Amount::new(reduction * per_unit_cost, curr.clone()))
681 } else {
682 None
683 };
684
685 let new_units = total_units + units.number;
687
688 let matched: Vec<Position> = self
690 .positions
691 .iter()
692 .filter(|p| p.units.currency == units.currency && !p.is_empty())
693 .cloned()
694 .collect();
695
696 self.positions
697 .retain(|p| p.units.currency != units.currency);
698
699 if !new_units.is_zero() {
701 self.positions.push(Position::simple(Amount::new(
702 new_units,
703 units.currency.clone(),
704 )));
705 }
706
707 self.rebuild_index();
709
710 Ok(BookingResult {
711 matched,
712 cost_basis,
713 })
714 }
715
716 fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
718 let total_units = self.units(&units.currency);
720
721 if total_units.signum() == units.number.signum() || total_units.is_zero() {
723 self.add(Position::simple(units.clone()));
725 return Ok(BookingResult {
726 matched: vec![],
727 cost_basis: None,
728 });
729 }
730
731 let available = total_units.abs();
732 let requested = units.number.abs();
733
734 if requested > available {
735 return Err(BookingError::InsufficientUnits {
736 currency: units.currency.clone(),
737 requested,
738 available,
739 });
740 }
741
742 self.reduce_ordered(units, &CostSpec::default(), false)
744 }
745
746 fn reduce_from_lot(
748 &mut self,
749 idx: usize,
750 units: &Amount,
751 ) -> Result<BookingResult, BookingError> {
752 let pos = &self.positions[idx];
753 let available = pos.units.number.abs();
754 let requested = units.number.abs();
755
756 if requested > available {
757 return Err(BookingError::InsufficientUnits {
758 currency: units.currency.clone(),
759 requested,
760 available,
761 });
762 }
763
764 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
766
767 let (matched, _) = pos.split(requested * pos.units.number.signum());
769
770 let new_units = pos.units.number + units.number;
772 let new_pos = Position {
773 units: Amount::new(new_units, pos.units.currency.clone()),
774 cost: pos.cost.clone(),
775 };
776 self.positions[idx] = new_pos;
777
778 if self.positions[idx].is_empty() {
780 self.positions.remove(idx);
781 }
782
783 Ok(BookingResult {
784 matched: vec![matched],
785 cost_basis,
786 })
787 }
788
789 pub fn compact(&mut self) {
791 self.positions.retain(|p| !p.is_empty());
792 self.rebuild_index();
793 }
794
795 fn rebuild_index(&mut self) {
798 self.simple_index.clear();
799 for (idx, pos) in self.positions.iter().enumerate() {
800 if pos.cost.is_none() {
801 debug_assert!(
802 !self.simple_index.contains_key(&pos.units.currency),
803 "Invariant violated: multiple simple positions for currency {}",
804 pos.units.currency
805 );
806 self.simple_index.insert(pos.units.currency.clone(), idx);
807 }
808 }
809 }
810
811 pub fn merge(&mut self, other: &Self) {
813 for pos in &other.positions {
814 self.add(pos.clone());
815 }
816 }
817
818 #[must_use]
823 pub fn at_cost(&self) -> Self {
824 let mut result = Self::new();
825
826 for pos in &self.positions {
827 if pos.is_empty() {
828 continue;
829 }
830
831 if let Some(cost) = &pos.cost {
832 let total = pos.units.number * cost.number;
834 result.add(Position::simple(Amount::new(total, &cost.currency)));
835 } else {
836 result.add(pos.clone());
838 }
839 }
840
841 result
842 }
843
844 #[must_use]
849 pub fn at_units(&self) -> Self {
850 let mut result = Self::new();
851
852 for pos in &self.positions {
853 if pos.is_empty() {
854 continue;
855 }
856
857 result.add(Position::simple(pos.units.clone()));
859 }
860
861 result
862 }
863}
864
865impl fmt::Display for Inventory {
866 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
867 if self.is_empty() {
868 return write!(f, "(empty)");
869 }
870
871 let non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
872 for (i, pos) in non_empty.iter().enumerate() {
873 if i > 0 {
874 write!(f, ", ")?;
875 }
876 write!(f, "{pos}")?;
877 }
878 Ok(())
879 }
880}
881
882impl FromIterator<Position> for Inventory {
883 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
884 let mut inv = Self::new();
885 for pos in iter {
886 inv.add(pos);
887 }
888 inv
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895 use crate::Cost;
896 use chrono::NaiveDate;
897 use rust_decimal_macros::dec;
898
899 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
900 NaiveDate::from_ymd_opt(year, month, day).unwrap()
901 }
902
903 #[test]
904 fn test_empty_inventory() {
905 let inv = Inventory::new();
906 assert!(inv.is_empty());
907 assert_eq!(inv.len(), 0);
908 }
909
910 #[test]
911 fn test_add_simple() {
912 let mut inv = Inventory::new();
913 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
914
915 assert!(!inv.is_empty());
916 assert_eq!(inv.units("USD"), dec!(100));
917 }
918
919 #[test]
920 fn test_add_merge_simple() {
921 let mut inv = Inventory::new();
922 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
923 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
924
925 assert_eq!(inv.len(), 1);
927 assert_eq!(inv.units("USD"), dec!(150));
928 }
929
930 #[test]
931 fn test_add_with_cost_no_merge() {
932 let mut inv = Inventory::new();
933
934 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
935 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
936
937 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
938 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
939
940 assert_eq!(inv.len(), 2);
942 assert_eq!(inv.units("AAPL"), dec!(15));
943 }
944
945 #[test]
946 fn test_currencies() {
947 let mut inv = Inventory::new();
948 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
949 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
950 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
951
952 let currencies = inv.currencies();
953 assert_eq!(currencies.len(), 3);
954 assert!(currencies.contains(&"USD"));
955 assert!(currencies.contains(&"EUR"));
956 assert!(currencies.contains(&"AAPL"));
957 }
958
959 #[test]
960 fn test_reduce_strict_unique() {
961 let mut inv = Inventory::new();
962 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
963 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
964
965 let result = inv
966 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
967 .unwrap();
968
969 assert_eq!(inv.units("AAPL"), dec!(5));
970 assert!(result.cost_basis.is_some());
971 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
973
974 #[test]
975 fn test_reduce_strict_ambiguous() {
976 let mut inv = Inventory::new();
977
978 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
979 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
980
981 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
982 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
983
984 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
986
987 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
988 }
989
990 #[test]
991 fn test_reduce_strict_with_spec() {
992 let mut inv = Inventory::new();
993
994 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
995 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
996
997 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
998 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
999
1000 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
1002 let result = inv
1003 .reduce(
1004 &Amount::new(dec!(-3), "AAPL"),
1005 Some(&spec),
1006 BookingMethod::Strict,
1007 )
1008 .unwrap();
1009
1010 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
1013
1014 #[test]
1015 fn test_reduce_fifo() {
1016 let mut inv = Inventory::new();
1017
1018 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1019 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1020 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1021
1022 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1023 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1024 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1025
1026 let result = inv
1028 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
1029 .unwrap();
1030
1031 assert_eq!(inv.units("AAPL"), dec!(15));
1032 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
1034 }
1035
1036 #[test]
1037 fn test_reduce_lifo() {
1038 let mut inv = Inventory::new();
1039
1040 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1041 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1042 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1043
1044 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1045 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1046 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1047
1048 let result = inv
1050 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1051 .unwrap();
1052
1053 assert_eq!(inv.units("AAPL"), dec!(15));
1054 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1056 }
1057
1058 #[test]
1059 fn test_reduce_insufficient() {
1060 let mut inv = Inventory::new();
1061 let cost = Cost::new(dec!(150.00), "USD");
1062 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1063
1064 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1065
1066 assert!(matches!(
1067 result,
1068 Err(BookingError::InsufficientUnits { .. })
1069 ));
1070 }
1071
1072 #[test]
1073 fn test_book_value() {
1074 let mut inv = Inventory::new();
1075
1076 let cost1 = Cost::new(dec!(100.00), "USD");
1077 let cost2 = Cost::new(dec!(150.00), "USD");
1078
1079 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1080 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1081
1082 let book = inv.book_value("AAPL");
1083 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
1085
1086 #[test]
1087 fn test_display() {
1088 let mut inv = Inventory::new();
1089 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1090
1091 let s = format!("{inv}");
1092 assert!(s.contains("100 USD"));
1093 }
1094
1095 #[test]
1096 fn test_display_empty() {
1097 let inv = Inventory::new();
1098 assert_eq!(format!("{inv}"), "(empty)");
1099 }
1100
1101 #[test]
1102 fn test_from_iterator() {
1103 let positions = vec![
1104 Position::simple(Amount::new(dec!(100), "USD")),
1105 Position::simple(Amount::new(dec!(50), "USD")),
1106 ];
1107
1108 let inv: Inventory = positions.into_iter().collect();
1109 assert_eq!(inv.units("USD"), dec!(150));
1110 }
1111}