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, PartialEq, Eq, 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}
181
182impl Inventory {
183 #[must_use]
185 pub fn new() -> Self {
186 Self::default()
187 }
188
189 #[must_use]
191 pub fn positions(&self) -> &[Position] {
192 &self.positions
193 }
194
195 pub fn positions_mut(&mut self) -> &mut Vec<Position> {
197 &mut self.positions
198 }
199
200 #[must_use]
202 pub fn is_empty(&self) -> bool {
203 self.positions.is_empty()
204 || self
205 .positions
206 .iter()
207 .all(super::position::Position::is_empty)
208 }
209
210 #[must_use]
212 pub fn len(&self) -> usize {
213 self.positions.len()
214 }
215
216 #[must_use]
220 pub fn units(&self, currency: &str) -> Decimal {
221 self.positions
222 .iter()
223 .filter(|p| p.units.currency == currency)
224 .map(|p| p.units.number)
225 .sum()
226 }
227
228 #[must_use]
230 pub fn currencies(&self) -> Vec<&str> {
231 let mut currencies: Vec<&str> = self
232 .positions
233 .iter()
234 .filter(|p| !p.is_empty())
235 .map(|p| p.units.currency.as_str())
236 .collect();
237 currencies.sort_unstable();
238 currencies.dedup();
239 currencies
240 }
241
242 #[must_use]
246 pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
247 let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
248
249 for pos in &self.positions {
250 if pos.units.currency == units_currency {
251 if let Some(book) = pos.book_value() {
252 *totals.entry(book.currency.clone()).or_default() += book.number;
253 }
254 }
255 }
256
257 totals
258 }
259
260 pub fn add(&mut self, position: Position) {
266 if position.is_empty() {
267 return;
268 }
269
270 if position.cost.is_none() {
272 for existing in &mut self.positions {
273 if existing.cost.is_none() && existing.units.currency == position.units.currency {
274 existing.units += &position.units;
275 return;
276 }
277 }
278 }
279
280 self.positions.push(position);
282 }
283
284 pub fn reduce(
297 &mut self,
298 units: &Amount,
299 cost_spec: Option<&CostSpec>,
300 method: BookingMethod,
301 ) -> Result<BookingResult, BookingError> {
302 let spec = cost_spec.cloned().unwrap_or_default();
303
304 match method {
305 BookingMethod::Strict => self.reduce_strict(units, &spec),
306 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
307 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
308 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
309 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
310 BookingMethod::Average => self.reduce_average(units),
311 BookingMethod::None => self.reduce_none(units),
312 }
313 }
314
315 fn reduce_strict(
318 &mut self,
319 units: &Amount,
320 spec: &CostSpec,
321 ) -> Result<BookingResult, BookingError> {
322 let matching_indices: Vec<usize> = self
323 .positions
324 .iter()
325 .enumerate()
326 .filter(|(_, p)| {
327 p.units.currency == units.currency
328 && !p.is_empty()
329 && p.can_reduce(units)
330 && p.matches_cost_spec(spec)
331 })
332 .map(|(i, _)| i)
333 .collect();
334
335 match matching_indices.len() {
336 0 => Err(BookingError::NoMatchingLot {
337 currency: units.currency.clone(),
338 cost_spec: spec.clone(),
339 }),
340 1 => {
341 let idx = matching_indices[0];
342 self.reduce_from_lot(idx, units)
343 }
344 n => {
345 let total_units: Decimal = matching_indices
347 .iter()
348 .map(|&i| self.positions[i].units.number.abs())
349 .sum();
350 if total_units == units.number.abs() {
351 self.reduce_ordered(units, spec, false)
353 } else {
354 Err(BookingError::AmbiguousMatch {
355 num_matches: n,
356 currency: units.currency.clone(),
357 })
358 }
359 }
360 }
361 }
362
363 fn reduce_strict_with_size(
365 &mut self,
366 units: &Amount,
367 spec: &CostSpec,
368 ) -> Result<BookingResult, BookingError> {
369 let matching_indices: Vec<usize> = self
370 .positions
371 .iter()
372 .enumerate()
373 .filter(|(_, p)| {
374 p.units.currency == units.currency
375 && !p.is_empty()
376 && p.can_reduce(units)
377 && p.matches_cost_spec(spec)
378 })
379 .map(|(i, _)| i)
380 .collect();
381
382 match matching_indices.len() {
383 0 => Err(BookingError::NoMatchingLot {
384 currency: units.currency.clone(),
385 cost_spec: spec.clone(),
386 }),
387 1 => {
388 let idx = matching_indices[0];
389 self.reduce_from_lot(idx, units)
390 }
391 n => {
392 let exact_matches: Vec<usize> = matching_indices
394 .iter()
395 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
396 .copied()
397 .collect();
398
399 if exact_matches.is_empty() {
400 let total_units: Decimal = matching_indices
402 .iter()
403 .map(|&i| self.positions[i].units.number.abs())
404 .sum();
405 if total_units == units.number.abs() {
406 self.reduce_ordered(units, spec, false)
407 } else {
408 Err(BookingError::AmbiguousMatch {
409 num_matches: n,
410 currency: units.currency.clone(),
411 })
412 }
413 } else {
414 let idx = exact_matches[0];
416 self.reduce_from_lot(idx, units)
417 }
418 }
419 }
420 }
421
422 fn reduce_fifo(
424 &mut self,
425 units: &Amount,
426 spec: &CostSpec,
427 ) -> Result<BookingResult, BookingError> {
428 self.reduce_ordered(units, spec, false)
429 }
430
431 fn reduce_lifo(
433 &mut self,
434 units: &Amount,
435 spec: &CostSpec,
436 ) -> Result<BookingResult, BookingError> {
437 self.reduce_ordered(units, spec, true)
438 }
439
440 fn reduce_hifo(
442 &mut self,
443 units: &Amount,
444 spec: &CostSpec,
445 ) -> Result<BookingResult, BookingError> {
446 let mut remaining = units.number.abs();
447 let mut matched = Vec::new();
448 let mut cost_basis = Decimal::ZERO;
449 let mut cost_currency = None;
450
451 let mut matching: Vec<(usize, Decimal)> = self
453 .positions
454 .iter()
455 .enumerate()
456 .filter(|(_, p)| {
457 p.units.currency == units.currency
458 && !p.is_empty()
459 && p.units.number.signum() != units.number.signum()
460 && p.matches_cost_spec(spec)
461 })
462 .map(|(i, p)| {
463 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
464 (i, cost)
465 })
466 .collect();
467
468 if matching.is_empty() {
469 return Err(BookingError::NoMatchingLot {
470 currency: units.currency.clone(),
471 cost_spec: spec.clone(),
472 });
473 }
474
475 matching.sort_by(|a, b| b.1.cmp(&a.1));
477
478 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
479
480 for idx in indices {
481 if remaining.is_zero() {
482 break;
483 }
484
485 let pos = &self.positions[idx];
486 let available = pos.units.number.abs();
487 let take = remaining.min(available);
488
489 if let Some(cost) = &pos.cost {
491 cost_basis += take * cost.number;
492 cost_currency = Some(cost.currency.clone());
493 }
494
495 let (taken, _) = pos.split(take * pos.units.number.signum());
497 matched.push(taken);
498
499 let reduction = if units.number.is_sign_negative() {
501 -take
502 } else {
503 take
504 };
505
506 let new_pos = Position {
507 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
508 cost: pos.cost.clone(),
509 };
510 self.positions[idx] = new_pos;
511
512 remaining -= take;
513 }
514
515 if !remaining.is_zero() {
516 let available = units.number.abs() - remaining;
517 return Err(BookingError::InsufficientUnits {
518 currency: units.currency.clone(),
519 requested: units.number.abs(),
520 available,
521 });
522 }
523
524 self.positions.retain(|p| !p.is_empty());
526
527 Ok(BookingResult {
528 matched,
529 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
530 })
531 }
532
533 fn reduce_ordered(
535 &mut self,
536 units: &Amount,
537 spec: &CostSpec,
538 reverse: bool,
539 ) -> Result<BookingResult, BookingError> {
540 let mut remaining = units.number.abs();
541 let mut matched = Vec::new();
542 let mut cost_basis = Decimal::ZERO;
543 let mut cost_currency = None;
544
545 let mut indices: Vec<usize> = self
547 .positions
548 .iter()
549 .enumerate()
550 .filter(|(_, p)| {
551 p.units.currency == units.currency
552 && !p.is_empty()
553 && p.units.number.signum() != units.number.signum()
554 && p.matches_cost_spec(spec)
555 })
556 .map(|(i, _)| i)
557 .collect();
558
559 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
562
563 if reverse {
564 indices.reverse();
565 }
566
567 if indices.is_empty() {
568 return Err(BookingError::NoMatchingLot {
569 currency: units.currency.clone(),
570 cost_spec: spec.clone(),
571 });
572 }
573
574 for idx in indices {
575 if remaining.is_zero() {
576 break;
577 }
578
579 let pos = &self.positions[idx];
580 let available = pos.units.number.abs();
581 let take = remaining.min(available);
582
583 if let Some(cost) = &pos.cost {
585 cost_basis += take * cost.number;
586 cost_currency = Some(cost.currency.clone());
587 }
588
589 let (taken, _) = pos.split(take * pos.units.number.signum());
591 matched.push(taken);
592
593 let reduction = if units.number.is_sign_negative() {
595 -take
596 } else {
597 take
598 };
599
600 let new_pos = Position {
601 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
602 cost: pos.cost.clone(),
603 };
604 self.positions[idx] = new_pos;
605
606 remaining -= take;
607 }
608
609 if !remaining.is_zero() {
610 let available = units.number.abs() - remaining;
611 return Err(BookingError::InsufficientUnits {
612 currency: units.currency.clone(),
613 requested: units.number.abs(),
614 available,
615 });
616 }
617
618 self.positions.retain(|p| !p.is_empty());
620
621 Ok(BookingResult {
622 matched,
623 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
624 })
625 }
626
627 fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
629 let total_units: Decimal = self
631 .positions
632 .iter()
633 .filter(|p| p.units.currency == units.currency && !p.is_empty())
634 .map(|p| p.units.number)
635 .sum();
636
637 if total_units.is_zero() {
638 return Err(BookingError::InsufficientUnits {
639 currency: units.currency.clone(),
640 requested: units.number.abs(),
641 available: Decimal::ZERO,
642 });
643 }
644
645 let reduction = units.number.abs();
647 if reduction > total_units.abs() {
648 return Err(BookingError::InsufficientUnits {
649 currency: units.currency.clone(),
650 requested: reduction,
651 available: total_units.abs(),
652 });
653 }
654
655 let book_values = self.book_value(&units.currency);
657 let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
658 let per_unit_cost = total / total_units;
659 Some(Amount::new(reduction * per_unit_cost, curr.clone()))
660 } else {
661 None
662 };
663
664 let new_units = total_units + units.number;
666
667 let matched: Vec<Position> = self
669 .positions
670 .iter()
671 .filter(|p| p.units.currency == units.currency && !p.is_empty())
672 .cloned()
673 .collect();
674
675 self.positions
676 .retain(|p| p.units.currency != units.currency);
677
678 if !new_units.is_zero() {
680 self.positions.push(Position::simple(Amount::new(
681 new_units,
682 units.currency.clone(),
683 )));
684 }
685
686 Ok(BookingResult {
687 matched,
688 cost_basis,
689 })
690 }
691
692 fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
694 let total_units = self.units(&units.currency);
696
697 if total_units.signum() == units.number.signum() || total_units.is_zero() {
699 self.add(Position::simple(units.clone()));
701 return Ok(BookingResult {
702 matched: vec![],
703 cost_basis: None,
704 });
705 }
706
707 let available = total_units.abs();
708 let requested = units.number.abs();
709
710 if requested > available {
711 return Err(BookingError::InsufficientUnits {
712 currency: units.currency.clone(),
713 requested,
714 available,
715 });
716 }
717
718 self.reduce_ordered(units, &CostSpec::default(), false)
720 }
721
722 fn reduce_from_lot(
724 &mut self,
725 idx: usize,
726 units: &Amount,
727 ) -> Result<BookingResult, BookingError> {
728 let pos = &self.positions[idx];
729 let available = pos.units.number.abs();
730 let requested = units.number.abs();
731
732 if requested > available {
733 return Err(BookingError::InsufficientUnits {
734 currency: units.currency.clone(),
735 requested,
736 available,
737 });
738 }
739
740 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
742
743 let (matched, _) = pos.split(requested * pos.units.number.signum());
745
746 let new_units = pos.units.number + units.number;
748 let new_pos = Position {
749 units: Amount::new(new_units, pos.units.currency.clone()),
750 cost: pos.cost.clone(),
751 };
752 self.positions[idx] = new_pos;
753
754 if self.positions[idx].is_empty() {
756 self.positions.remove(idx);
757 }
758
759 Ok(BookingResult {
760 matched: vec![matched],
761 cost_basis,
762 })
763 }
764
765 pub fn compact(&mut self) {
767 self.positions.retain(|p| !p.is_empty());
768 }
769
770 pub fn merge(&mut self, other: &Self) {
772 for pos in &other.positions {
773 self.add(pos.clone());
774 }
775 }
776
777 #[must_use]
782 pub fn at_cost(&self) -> Self {
783 let mut result = Self::new();
784
785 for pos in &self.positions {
786 if pos.is_empty() {
787 continue;
788 }
789
790 if let Some(cost) = &pos.cost {
791 let total = pos.units.number * cost.number;
793 result.add(Position::simple(Amount::new(total, &cost.currency)));
794 } else {
795 result.add(pos.clone());
797 }
798 }
799
800 result
801 }
802
803 #[must_use]
808 pub fn at_units(&self) -> Self {
809 let mut result = Self::new();
810
811 for pos in &self.positions {
812 if pos.is_empty() {
813 continue;
814 }
815
816 result.add(Position::simple(pos.units.clone()));
818 }
819
820 result
821 }
822}
823
824impl fmt::Display for Inventory {
825 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
826 if self.is_empty() {
827 return write!(f, "(empty)");
828 }
829
830 let non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
831 for (i, pos) in non_empty.iter().enumerate() {
832 if i > 0 {
833 write!(f, ", ")?;
834 }
835 write!(f, "{pos}")?;
836 }
837 Ok(())
838 }
839}
840
841impl FromIterator<Position> for Inventory {
842 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
843 let mut inv = Self::new();
844 for pos in iter {
845 inv.add(pos);
846 }
847 inv
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use crate::Cost;
855 use chrono::NaiveDate;
856 use rust_decimal_macros::dec;
857
858 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
859 NaiveDate::from_ymd_opt(year, month, day).unwrap()
860 }
861
862 #[test]
863 fn test_empty_inventory() {
864 let inv = Inventory::new();
865 assert!(inv.is_empty());
866 assert_eq!(inv.len(), 0);
867 }
868
869 #[test]
870 fn test_add_simple() {
871 let mut inv = Inventory::new();
872 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
873
874 assert!(!inv.is_empty());
875 assert_eq!(inv.units("USD"), dec!(100));
876 }
877
878 #[test]
879 fn test_add_merge_simple() {
880 let mut inv = Inventory::new();
881 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
882 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
883
884 assert_eq!(inv.len(), 1);
886 assert_eq!(inv.units("USD"), dec!(150));
887 }
888
889 #[test]
890 fn test_add_with_cost_no_merge() {
891 let mut inv = Inventory::new();
892
893 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
894 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
895
896 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
897 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
898
899 assert_eq!(inv.len(), 2);
901 assert_eq!(inv.units("AAPL"), dec!(15));
902 }
903
904 #[test]
905 fn test_currencies() {
906 let mut inv = Inventory::new();
907 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
908 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
909 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
910
911 let currencies = inv.currencies();
912 assert_eq!(currencies.len(), 3);
913 assert!(currencies.contains(&"USD"));
914 assert!(currencies.contains(&"EUR"));
915 assert!(currencies.contains(&"AAPL"));
916 }
917
918 #[test]
919 fn test_reduce_strict_unique() {
920 let mut inv = Inventory::new();
921 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
922 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
923
924 let result = inv
925 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
926 .unwrap();
927
928 assert_eq!(inv.units("AAPL"), dec!(5));
929 assert!(result.cost_basis.is_some());
930 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
932
933 #[test]
934 fn test_reduce_strict_ambiguous() {
935 let mut inv = Inventory::new();
936
937 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
938 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
939
940 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
941 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
942
943 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
945
946 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
947 }
948
949 #[test]
950 fn test_reduce_strict_with_spec() {
951 let mut inv = Inventory::new();
952
953 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
954 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
955
956 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
957 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
958
959 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
961 let result = inv
962 .reduce(
963 &Amount::new(dec!(-3), "AAPL"),
964 Some(&spec),
965 BookingMethod::Strict,
966 )
967 .unwrap();
968
969 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
972
973 #[test]
974 fn test_reduce_fifo() {
975 let mut inv = Inventory::new();
976
977 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
978 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
979 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
980
981 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
982 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
983 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
984
985 let result = inv
987 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
988 .unwrap();
989
990 assert_eq!(inv.units("AAPL"), dec!(15));
991 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
993 }
994
995 #[test]
996 fn test_reduce_lifo() {
997 let mut inv = Inventory::new();
998
999 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1000 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1001 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1002
1003 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1004 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1005 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1006
1007 let result = inv
1009 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1010 .unwrap();
1011
1012 assert_eq!(inv.units("AAPL"), dec!(15));
1013 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1015 }
1016
1017 #[test]
1018 fn test_reduce_insufficient() {
1019 let mut inv = Inventory::new();
1020 let cost = Cost::new(dec!(150.00), "USD");
1021 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1022
1023 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1024
1025 assert!(matches!(
1026 result,
1027 Err(BookingError::InsufficientUnits { .. })
1028 ));
1029 }
1030
1031 #[test]
1032 fn test_book_value() {
1033 let mut inv = Inventory::new();
1034
1035 let cost1 = Cost::new(dec!(100.00), "USD");
1036 let cost2 = Cost::new(dec!(150.00), "USD");
1037
1038 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1039 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1040
1041 let book = inv.book_value("AAPL");
1042 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
1044
1045 #[test]
1046 fn test_display() {
1047 let mut inv = Inventory::new();
1048 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1049
1050 let s = format!("{inv}");
1051 assert!(s.contains("100 USD"));
1052 }
1053
1054 #[test]
1055 fn test_display_empty() {
1056 let inv = Inventory::new();
1057 assert_eq!(format!("{inv}"), "(empty)");
1058 }
1059
1060 #[test]
1061 fn test_from_iterator() {
1062 let positions = vec![
1063 Position::simple(Amount::new(dec!(100), "USD")),
1064 Position::simple(Amount::new(dec!(50), "USD")),
1065 ];
1066
1067 let inv: Inventory = positions.into_iter().collect();
1068 assert_eq!(inv.units("USD"), dec!(150));
1069 }
1070}