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::{Amount, CostSpec, Position};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
18pub enum BookingMethod {
19 #[default]
22 Strict,
23 StrictWithSize,
26 Fifo,
28 Lifo,
30 Hifo,
32 Average,
34 None,
36}
37
38impl FromStr for BookingMethod {
39 type Err = String;
40
41 fn from_str(s: &str) -> Result<Self, Self::Err> {
42 match s.to_uppercase().as_str() {
43 "STRICT" => Ok(Self::Strict),
44 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
45 "FIFO" => Ok(Self::Fifo),
46 "LIFO" => Ok(Self::Lifo),
47 "HIFO" => Ok(Self::Hifo),
48 "AVERAGE" => Ok(Self::Average),
49 "NONE" => Ok(Self::None),
50 _ => Err(format!("unknown booking method: {s}")),
51 }
52 }
53}
54
55impl fmt::Display for BookingMethod {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 Self::Strict => write!(f, "STRICT"),
59 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
60 Self::Fifo => write!(f, "FIFO"),
61 Self::Lifo => write!(f, "LIFO"),
62 Self::Hifo => write!(f, "HIFO"),
63 Self::Average => write!(f, "AVERAGE"),
64 Self::None => write!(f, "NONE"),
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct BookingResult {
72 pub matched: Vec<Position>,
74 pub cost_basis: Option<Amount>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum BookingError {
81 AmbiguousMatch {
83 num_matches: usize,
85 currency: String,
87 },
88 NoMatchingLot {
90 currency: String,
92 cost_spec: CostSpec,
94 },
95 InsufficientUnits {
97 currency: String,
99 requested: Decimal,
101 available: Decimal,
103 },
104 CurrencyMismatch {
106 expected: String,
108 got: String,
110 },
111}
112
113impl fmt::Display for BookingError {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 match self {
116 Self::AmbiguousMatch {
117 num_matches,
118 currency,
119 } => write!(
120 f,
121 "Ambiguous match: {num_matches} lots match for {currency}"
122 ),
123 Self::NoMatchingLot {
124 currency,
125 cost_spec,
126 } => {
127 write!(f, "No matching lot for {currency} with cost {cost_spec}")
128 }
129 Self::InsufficientUnits {
130 currency,
131 requested,
132 available,
133 } => write!(
134 f,
135 "Insufficient units of {currency}: requested {requested}, available {available}"
136 ),
137 Self::CurrencyMismatch { expected, got } => {
138 write!(f, "Currency mismatch: expected {expected}, got {got}")
139 }
140 }
141 }
142}
143
144impl std::error::Error for BookingError {}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169pub struct Inventory {
170 positions: Vec<Position>,
171}
172
173impl Inventory {
174 #[must_use]
176 pub fn new() -> Self {
177 Self::default()
178 }
179
180 #[must_use]
182 pub fn positions(&self) -> &[Position] {
183 &self.positions
184 }
185
186 pub fn positions_mut(&mut self) -> &mut Vec<Position> {
188 &mut self.positions
189 }
190
191 #[must_use]
193 pub fn is_empty(&self) -> bool {
194 self.positions.is_empty()
195 || self
196 .positions
197 .iter()
198 .all(super::position::Position::is_empty)
199 }
200
201 #[must_use]
203 pub fn len(&self) -> usize {
204 self.positions.len()
205 }
206
207 #[must_use]
211 pub fn units(&self, currency: &str) -> Decimal {
212 self.positions
213 .iter()
214 .filter(|p| p.units.currency == currency)
215 .map(|p| p.units.number)
216 .sum()
217 }
218
219 #[must_use]
221 pub fn currencies(&self) -> Vec<&str> {
222 let mut currencies: Vec<&str> = self
223 .positions
224 .iter()
225 .filter(|p| !p.is_empty())
226 .map(|p| p.units.currency.as_str())
227 .collect();
228 currencies.sort_unstable();
229 currencies.dedup();
230 currencies
231 }
232
233 #[must_use]
237 pub fn book_value(&self, units_currency: &str) -> HashMap<String, Decimal> {
238 let mut totals: HashMap<String, Decimal> = HashMap::new();
239
240 for pos in &self.positions {
241 if pos.units.currency == units_currency {
242 if let Some(book) = pos.book_value() {
243 *totals.entry(book.currency).or_default() += book.number;
244 }
245 }
246 }
247
248 totals
249 }
250
251 pub fn add(&mut self, position: Position) {
257 if position.is_empty() {
258 return;
259 }
260
261 if position.cost.is_none() {
263 for existing in &mut self.positions {
264 if existing.cost.is_none() && existing.units.currency == position.units.currency {
265 existing.units += &position.units;
266 return;
267 }
268 }
269 }
270
271 self.positions.push(position);
273 }
274
275 pub fn reduce(
288 &mut self,
289 units: &Amount,
290 cost_spec: Option<&CostSpec>,
291 method: BookingMethod,
292 ) -> Result<BookingResult, BookingError> {
293 let spec = cost_spec.cloned().unwrap_or_default();
294
295 match method {
296 BookingMethod::Strict => self.reduce_strict(units, &spec),
297 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
298 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
299 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
300 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
301 BookingMethod::Average => self.reduce_average(units),
302 BookingMethod::None => self.reduce_none(units),
303 }
304 }
305
306 fn reduce_strict(
309 &mut self,
310 units: &Amount,
311 spec: &CostSpec,
312 ) -> Result<BookingResult, BookingError> {
313 let matching_indices: Vec<usize> = self
314 .positions
315 .iter()
316 .enumerate()
317 .filter(|(_, p)| {
318 p.units.currency == units.currency
319 && !p.is_empty()
320 && p.can_reduce(units)
321 && p.matches_cost_spec(spec)
322 })
323 .map(|(i, _)| i)
324 .collect();
325
326 match matching_indices.len() {
327 0 => Err(BookingError::NoMatchingLot {
328 currency: units.currency.clone(),
329 cost_spec: spec.clone(),
330 }),
331 1 => {
332 let idx = matching_indices[0];
333 self.reduce_from_lot(idx, units)
334 }
335 n => {
336 let total_units: Decimal = matching_indices
338 .iter()
339 .map(|&i| self.positions[i].units.number.abs())
340 .sum();
341 if total_units == units.number.abs() {
342 self.reduce_ordered(units, spec, false)
344 } else {
345 Err(BookingError::AmbiguousMatch {
346 num_matches: n,
347 currency: units.currency.clone(),
348 })
349 }
350 }
351 }
352 }
353
354 fn reduce_strict_with_size(
356 &mut self,
357 units: &Amount,
358 spec: &CostSpec,
359 ) -> Result<BookingResult, BookingError> {
360 let matching_indices: Vec<usize> = self
361 .positions
362 .iter()
363 .enumerate()
364 .filter(|(_, p)| {
365 p.units.currency == units.currency
366 && !p.is_empty()
367 && p.can_reduce(units)
368 && p.matches_cost_spec(spec)
369 })
370 .map(|(i, _)| i)
371 .collect();
372
373 match matching_indices.len() {
374 0 => Err(BookingError::NoMatchingLot {
375 currency: units.currency.clone(),
376 cost_spec: spec.clone(),
377 }),
378 1 => {
379 let idx = matching_indices[0];
380 self.reduce_from_lot(idx, units)
381 }
382 n => {
383 let exact_matches: Vec<usize> = matching_indices
385 .iter()
386 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
387 .copied()
388 .collect();
389
390 if exact_matches.is_empty() {
391 let total_units: Decimal = matching_indices
393 .iter()
394 .map(|&i| self.positions[i].units.number.abs())
395 .sum();
396 if total_units == units.number.abs() {
397 self.reduce_ordered(units, spec, false)
398 } else {
399 Err(BookingError::AmbiguousMatch {
400 num_matches: n,
401 currency: units.currency.clone(),
402 })
403 }
404 } else {
405 let idx = exact_matches[0];
407 self.reduce_from_lot(idx, units)
408 }
409 }
410 }
411 }
412
413 fn reduce_fifo(
415 &mut self,
416 units: &Amount,
417 spec: &CostSpec,
418 ) -> Result<BookingResult, BookingError> {
419 self.reduce_ordered(units, spec, false)
420 }
421
422 fn reduce_lifo(
424 &mut self,
425 units: &Amount,
426 spec: &CostSpec,
427 ) -> Result<BookingResult, BookingError> {
428 self.reduce_ordered(units, spec, true)
429 }
430
431 fn reduce_hifo(
433 &mut self,
434 units: &Amount,
435 spec: &CostSpec,
436 ) -> Result<BookingResult, BookingError> {
437 let mut remaining = units.number.abs();
438 let mut matched = Vec::new();
439 let mut cost_basis = Decimal::ZERO;
440 let mut cost_currency = None;
441
442 let mut matching: Vec<(usize, Decimal)> = self
444 .positions
445 .iter()
446 .enumerate()
447 .filter(|(_, p)| {
448 p.units.currency == units.currency
449 && !p.is_empty()
450 && p.units.number.signum() != units.number.signum()
451 && p.matches_cost_spec(spec)
452 })
453 .map(|(i, p)| {
454 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
455 (i, cost)
456 })
457 .collect();
458
459 if matching.is_empty() {
460 return Err(BookingError::NoMatchingLot {
461 currency: units.currency.clone(),
462 cost_spec: spec.clone(),
463 });
464 }
465
466 matching.sort_by(|a, b| b.1.cmp(&a.1));
468
469 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
470
471 for idx in indices {
472 if remaining.is_zero() {
473 break;
474 }
475
476 let pos = &self.positions[idx];
477 let available = pos.units.number.abs();
478 let take = remaining.min(available);
479
480 if let Some(cost) = &pos.cost {
482 cost_basis += take * cost.number;
483 cost_currency = Some(cost.currency.clone());
484 }
485
486 let (taken, _) = pos.split(take * pos.units.number.signum());
488 matched.push(taken);
489
490 let reduction = if units.number.is_sign_negative() {
492 -take
493 } else {
494 take
495 };
496
497 let new_pos = Position {
498 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
499 cost: pos.cost.clone(),
500 };
501 self.positions[idx] = new_pos;
502
503 remaining -= take;
504 }
505
506 if !remaining.is_zero() {
507 let available = units.number.abs() - remaining;
508 return Err(BookingError::InsufficientUnits {
509 currency: units.currency.clone(),
510 requested: units.number.abs(),
511 available,
512 });
513 }
514
515 self.positions.retain(|p| !p.is_empty());
517
518 Ok(BookingResult {
519 matched,
520 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
521 })
522 }
523
524 fn reduce_ordered(
526 &mut self,
527 units: &Amount,
528 spec: &CostSpec,
529 reverse: bool,
530 ) -> Result<BookingResult, BookingError> {
531 let mut remaining = units.number.abs();
532 let mut matched = Vec::new();
533 let mut cost_basis = Decimal::ZERO;
534 let mut cost_currency = None;
535
536 let mut indices: Vec<usize> = self
538 .positions
539 .iter()
540 .enumerate()
541 .filter(|(_, p)| {
542 p.units.currency == units.currency
543 && !p.is_empty()
544 && p.units.number.signum() != units.number.signum()
545 && p.matches_cost_spec(spec)
546 })
547 .map(|(i, _)| i)
548 .collect();
549
550 if reverse {
551 indices.reverse();
552 }
553
554 if indices.is_empty() {
555 return Err(BookingError::NoMatchingLot {
556 currency: units.currency.clone(),
557 cost_spec: spec.clone(),
558 });
559 }
560
561 for idx in indices {
562 if remaining.is_zero() {
563 break;
564 }
565
566 let pos = &self.positions[idx];
567 let available = pos.units.number.abs();
568 let take = remaining.min(available);
569
570 if let Some(cost) = &pos.cost {
572 cost_basis += take * cost.number;
573 cost_currency = Some(cost.currency.clone());
574 }
575
576 let (taken, _) = pos.split(take * pos.units.number.signum());
578 matched.push(taken);
579
580 let reduction = if units.number.is_sign_negative() {
582 -take
583 } else {
584 take
585 };
586
587 let new_pos = Position {
588 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
589 cost: pos.cost.clone(),
590 };
591 self.positions[idx] = new_pos;
592
593 remaining -= take;
594 }
595
596 if !remaining.is_zero() {
597 let available = units.number.abs() - remaining;
598 return Err(BookingError::InsufficientUnits {
599 currency: units.currency.clone(),
600 requested: units.number.abs(),
601 available,
602 });
603 }
604
605 self.positions.retain(|p| !p.is_empty());
607
608 Ok(BookingResult {
609 matched,
610 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
611 })
612 }
613
614 fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
616 let total_units: Decimal = self
618 .positions
619 .iter()
620 .filter(|p| p.units.currency == units.currency && !p.is_empty())
621 .map(|p| p.units.number)
622 .sum();
623
624 if total_units.is_zero() {
625 return Err(BookingError::InsufficientUnits {
626 currency: units.currency.clone(),
627 requested: units.number.abs(),
628 available: Decimal::ZERO,
629 });
630 }
631
632 let reduction = units.number.abs();
634 if reduction > total_units.abs() {
635 return Err(BookingError::InsufficientUnits {
636 currency: units.currency.clone(),
637 requested: reduction,
638 available: total_units.abs(),
639 });
640 }
641
642 let book_values = self.book_value(&units.currency);
644 let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
645 let per_unit_cost = total / total_units;
646 Some(Amount::new(reduction * per_unit_cost, curr.clone()))
647 } else {
648 None
649 };
650
651 let new_units = total_units + units.number;
653
654 let matched: Vec<Position> = self
656 .positions
657 .iter()
658 .filter(|p| p.units.currency == units.currency && !p.is_empty())
659 .cloned()
660 .collect();
661
662 self.positions
663 .retain(|p| p.units.currency != units.currency);
664
665 if !new_units.is_zero() {
667 self.positions.push(Position::simple(Amount::new(
668 new_units,
669 units.currency.clone(),
670 )));
671 }
672
673 Ok(BookingResult {
674 matched,
675 cost_basis,
676 })
677 }
678
679 fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
681 let total_units = self.units(&units.currency);
683
684 if total_units.signum() == units.number.signum() || total_units.is_zero() {
686 self.add(Position::simple(units.clone()));
688 return Ok(BookingResult {
689 matched: vec![],
690 cost_basis: None,
691 });
692 }
693
694 let available = total_units.abs();
695 let requested = units.number.abs();
696
697 if requested > available {
698 return Err(BookingError::InsufficientUnits {
699 currency: units.currency.clone(),
700 requested,
701 available,
702 });
703 }
704
705 self.reduce_ordered(units, &CostSpec::default(), false)
707 }
708
709 fn reduce_from_lot(
711 &mut self,
712 idx: usize,
713 units: &Amount,
714 ) -> Result<BookingResult, BookingError> {
715 let pos = &self.positions[idx];
716 let available = pos.units.number.abs();
717 let requested = units.number.abs();
718
719 if requested > available {
720 return Err(BookingError::InsufficientUnits {
721 currency: units.currency.clone(),
722 requested,
723 available,
724 });
725 }
726
727 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
729
730 let (matched, _) = pos.split(requested * pos.units.number.signum());
732
733 let new_units = pos.units.number + units.number;
735 let new_pos = Position {
736 units: Amount::new(new_units, pos.units.currency.clone()),
737 cost: pos.cost.clone(),
738 };
739 self.positions[idx] = new_pos;
740
741 if self.positions[idx].is_empty() {
743 self.positions.remove(idx);
744 }
745
746 Ok(BookingResult {
747 matched: vec![matched],
748 cost_basis,
749 })
750 }
751
752 pub fn compact(&mut self) {
754 self.positions.retain(|p| !p.is_empty());
755 }
756
757 pub fn merge(&mut self, other: &Self) {
759 for pos in &other.positions {
760 self.add(pos.clone());
761 }
762 }
763
764 #[must_use]
769 pub fn at_cost(&self) -> Self {
770 let mut result = Self::new();
771
772 for pos in &self.positions {
773 if pos.is_empty() {
774 continue;
775 }
776
777 if let Some(cost) = &pos.cost {
778 let total = pos.units.number * cost.number;
780 result.add(Position::simple(Amount::new(total, &cost.currency)));
781 } else {
782 result.add(pos.clone());
784 }
785 }
786
787 result
788 }
789
790 #[must_use]
795 pub fn at_units(&self) -> Self {
796 let mut result = Self::new();
797
798 for pos in &self.positions {
799 if pos.is_empty() {
800 continue;
801 }
802
803 result.add(Position::simple(pos.units.clone()));
805 }
806
807 result
808 }
809}
810
811impl fmt::Display for Inventory {
812 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
813 if self.is_empty() {
814 return write!(f, "(empty)");
815 }
816
817 let non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
818 for (i, pos) in non_empty.iter().enumerate() {
819 if i > 0 {
820 write!(f, ", ")?;
821 }
822 write!(f, "{pos}")?;
823 }
824 Ok(())
825 }
826}
827
828impl FromIterator<Position> for Inventory {
829 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
830 let mut inv = Self::new();
831 for pos in iter {
832 inv.add(pos);
833 }
834 inv
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841 use crate::Cost;
842 use chrono::NaiveDate;
843 use rust_decimal_macros::dec;
844
845 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
846 NaiveDate::from_ymd_opt(year, month, day).unwrap()
847 }
848
849 #[test]
850 fn test_empty_inventory() {
851 let inv = Inventory::new();
852 assert!(inv.is_empty());
853 assert_eq!(inv.len(), 0);
854 }
855
856 #[test]
857 fn test_add_simple() {
858 let mut inv = Inventory::new();
859 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
860
861 assert!(!inv.is_empty());
862 assert_eq!(inv.units("USD"), dec!(100));
863 }
864
865 #[test]
866 fn test_add_merge_simple() {
867 let mut inv = Inventory::new();
868 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
869 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
870
871 assert_eq!(inv.len(), 1);
873 assert_eq!(inv.units("USD"), dec!(150));
874 }
875
876 #[test]
877 fn test_add_with_cost_no_merge() {
878 let mut inv = Inventory::new();
879
880 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
881 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
882
883 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
884 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
885
886 assert_eq!(inv.len(), 2);
888 assert_eq!(inv.units("AAPL"), dec!(15));
889 }
890
891 #[test]
892 fn test_currencies() {
893 let mut inv = Inventory::new();
894 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
895 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
896 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
897
898 let currencies = inv.currencies();
899 assert_eq!(currencies.len(), 3);
900 assert!(currencies.contains(&"USD"));
901 assert!(currencies.contains(&"EUR"));
902 assert!(currencies.contains(&"AAPL"));
903 }
904
905 #[test]
906 fn test_reduce_strict_unique() {
907 let mut inv = Inventory::new();
908 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
909 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
910
911 let result = inv
912 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
913 .unwrap();
914
915 assert_eq!(inv.units("AAPL"), dec!(5));
916 assert!(result.cost_basis.is_some());
917 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
919
920 #[test]
921 fn test_reduce_strict_ambiguous() {
922 let mut inv = Inventory::new();
923
924 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
925 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
926
927 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
928 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
929
930 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
932
933 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
934 }
935
936 #[test]
937 fn test_reduce_strict_with_spec() {
938 let mut inv = Inventory::new();
939
940 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
941 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
942
943 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
944 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
945
946 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
948 let result = inv
949 .reduce(
950 &Amount::new(dec!(-3), "AAPL"),
951 Some(&spec),
952 BookingMethod::Strict,
953 )
954 .unwrap();
955
956 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
959
960 #[test]
961 fn test_reduce_fifo() {
962 let mut inv = Inventory::new();
963
964 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
965 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
966 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
967
968 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
969 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
970 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
971
972 let result = inv
974 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
975 .unwrap();
976
977 assert_eq!(inv.units("AAPL"), dec!(15));
978 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
980 }
981
982 #[test]
983 fn test_reduce_lifo() {
984 let mut inv = Inventory::new();
985
986 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
987 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
988 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
989
990 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
991 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
992 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
993
994 let result = inv
996 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
997 .unwrap();
998
999 assert_eq!(inv.units("AAPL"), dec!(15));
1000 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1002 }
1003
1004 #[test]
1005 fn test_reduce_insufficient() {
1006 let mut inv = Inventory::new();
1007 let cost = Cost::new(dec!(150.00), "USD");
1008 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1009
1010 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1011
1012 assert!(matches!(
1013 result,
1014 Err(BookingError::InsufficientUnits { .. })
1015 ));
1016 }
1017
1018 #[test]
1019 fn test_book_value() {
1020 let mut inv = Inventory::new();
1021
1022 let cost1 = Cost::new(dec!(100.00), "USD");
1023 let cost2 = Cost::new(dec!(150.00), "USD");
1024
1025 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1026 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1027
1028 let book = inv.book_value("AAPL");
1029 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
1031
1032 #[test]
1033 fn test_display() {
1034 let mut inv = Inventory::new();
1035 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1036
1037 let s = format!("{inv}");
1038 assert!(s.contains("100 USD"));
1039 }
1040
1041 #[test]
1042 fn test_display_empty() {
1043 let inv = Inventory::new();
1044 assert_eq!(format!("{inv}"), "(empty)");
1045 }
1046
1047 #[test]
1048 fn test_from_iterator() {
1049 let positions = vec![
1050 Position::simple(Amount::new(dec!(100), "USD")),
1051 Position::simple(Amount::new(dec!(50), "USD")),
1052 ];
1053
1054 let inv: Inventory = positions.into_iter().collect();
1055 assert_eq!(inv.units("USD"), dec!(150));
1056 }
1057}