1use rust_decimal::Decimal;
8use rust_decimal::prelude::Signed;
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 const 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) {
284 if position.is_empty() {
285 return;
286 }
287
288 if position.cost.is_none() {
290 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
291 debug_assert!(self.positions[idx].cost.is_none());
293 self.positions[idx].units += &position.units;
294 return;
295 }
296 let idx = self.positions.len();
298 self.simple_index
299 .insert(position.units.currency.clone(), idx);
300 self.positions.push(position);
301 return;
302 }
303
304 self.positions.push(position);
308 }
309
310 pub fn reduce(
323 &mut self,
324 units: &Amount,
325 cost_spec: Option<&CostSpec>,
326 method: BookingMethod,
327 ) -> Result<BookingResult, BookingError> {
328 let spec = cost_spec.cloned().unwrap_or_default();
329
330 match method {
331 BookingMethod::Strict => self.reduce_strict(units, &spec),
332 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
333 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
334 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
335 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
336 BookingMethod::Average => self.reduce_average(units),
337 BookingMethod::None => self.reduce_none(units),
338 }
339 }
340
341 fn reduce_strict(
344 &mut self,
345 units: &Amount,
346 spec: &CostSpec,
347 ) -> Result<BookingResult, BookingError> {
348 let matching_indices: Vec<usize> = self
349 .positions
350 .iter()
351 .enumerate()
352 .filter(|(_, p)| {
353 p.units.currency == units.currency
354 && !p.is_empty()
355 && p.can_reduce(units)
356 && p.matches_cost_spec(spec)
357 })
358 .map(|(i, _)| i)
359 .collect();
360
361 match matching_indices.len() {
362 0 => Err(BookingError::NoMatchingLot {
363 currency: units.currency.clone(),
364 cost_spec: spec.clone(),
365 }),
366 1 => {
367 let idx = matching_indices[0];
368 self.reduce_from_lot(idx, units)
369 }
370 _n => {
371 self.reduce_ordered(units, spec, false)
376 }
377 }
378 }
379
380 fn reduce_strict_with_size(
382 &mut self,
383 units: &Amount,
384 spec: &CostSpec,
385 ) -> Result<BookingResult, BookingError> {
386 let matching_indices: Vec<usize> = self
387 .positions
388 .iter()
389 .enumerate()
390 .filter(|(_, p)| {
391 p.units.currency == units.currency
392 && !p.is_empty()
393 && p.can_reduce(units)
394 && p.matches_cost_spec(spec)
395 })
396 .map(|(i, _)| i)
397 .collect();
398
399 match matching_indices.len() {
400 0 => Err(BookingError::NoMatchingLot {
401 currency: units.currency.clone(),
402 cost_spec: spec.clone(),
403 }),
404 1 => {
405 let idx = matching_indices[0];
406 self.reduce_from_lot(idx, units)
407 }
408 n => {
409 let exact_matches: Vec<usize> = matching_indices
411 .iter()
412 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
413 .copied()
414 .collect();
415
416 if exact_matches.is_empty() {
417 let total_units: Decimal = matching_indices
419 .iter()
420 .map(|&i| self.positions[i].units.number.abs())
421 .sum();
422 if total_units == units.number.abs() {
423 self.reduce_ordered(units, spec, false)
424 } else {
425 Err(BookingError::AmbiguousMatch {
426 num_matches: n,
427 currency: units.currency.clone(),
428 })
429 }
430 } else {
431 let idx = exact_matches[0];
433 self.reduce_from_lot(idx, units)
434 }
435 }
436 }
437 }
438
439 fn reduce_fifo(
441 &mut self,
442 units: &Amount,
443 spec: &CostSpec,
444 ) -> Result<BookingResult, BookingError> {
445 self.reduce_ordered(units, spec, false)
446 }
447
448 fn reduce_lifo(
450 &mut self,
451 units: &Amount,
452 spec: &CostSpec,
453 ) -> Result<BookingResult, BookingError> {
454 self.reduce_ordered(units, spec, true)
455 }
456
457 fn reduce_hifo(
459 &mut self,
460 units: &Amount,
461 spec: &CostSpec,
462 ) -> Result<BookingResult, BookingError> {
463 let mut remaining = units.number.abs();
464 let mut matched = Vec::new();
465 let mut cost_basis = Decimal::ZERO;
466 let mut cost_currency = None;
467
468 let mut matching: Vec<(usize, Decimal)> = self
470 .positions
471 .iter()
472 .enumerate()
473 .filter(|(_, p)| {
474 p.units.currency == units.currency
475 && !p.is_empty()
476 && p.units.number.signum() != units.number.signum()
477 && p.matches_cost_spec(spec)
478 })
479 .map(|(i, p)| {
480 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
481 (i, cost)
482 })
483 .collect();
484
485 if matching.is_empty() {
486 return Err(BookingError::NoMatchingLot {
487 currency: units.currency.clone(),
488 cost_spec: spec.clone(),
489 });
490 }
491
492 matching.sort_by(|a, b| b.1.cmp(&a.1));
494
495 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
496
497 for idx in indices {
498 if remaining.is_zero() {
499 break;
500 }
501
502 let pos = &self.positions[idx];
503 let available = pos.units.number.abs();
504 let take = remaining.min(available);
505
506 if let Some(cost) = &pos.cost {
508 cost_basis += take * cost.number;
509 cost_currency = Some(cost.currency.clone());
510 }
511
512 let (taken, _) = pos.split(take * pos.units.number.signum());
514 matched.push(taken);
515
516 let reduction = if units.number.is_sign_negative() {
518 -take
519 } else {
520 take
521 };
522
523 let new_pos = Position {
524 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
525 cost: pos.cost.clone(),
526 };
527 self.positions[idx] = new_pos;
528
529 remaining -= take;
530 }
531
532 if !remaining.is_zero() {
533 let available = units.number.abs() - remaining;
534 return Err(BookingError::InsufficientUnits {
535 currency: units.currency.clone(),
536 requested: units.number.abs(),
537 available,
538 });
539 }
540
541 self.positions.retain(|p| !p.is_empty());
543 self.rebuild_index();
544
545 Ok(BookingResult {
546 matched,
547 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
548 })
549 }
550
551 fn reduce_ordered(
553 &mut self,
554 units: &Amount,
555 spec: &CostSpec,
556 reverse: bool,
557 ) -> Result<BookingResult, BookingError> {
558 let mut remaining = units.number.abs();
559 let mut matched = Vec::new();
560 let mut cost_basis = Decimal::ZERO;
561 let mut cost_currency = None;
562
563 let mut indices: Vec<usize> = self
565 .positions
566 .iter()
567 .enumerate()
568 .filter(|(_, p)| {
569 p.units.currency == units.currency
570 && !p.is_empty()
571 && p.units.number.signum() != units.number.signum()
572 && p.matches_cost_spec(spec)
573 })
574 .map(|(i, _)| i)
575 .collect();
576
577 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
580
581 if reverse {
582 indices.reverse();
583 }
584
585 if indices.is_empty() {
586 return Err(BookingError::NoMatchingLot {
587 currency: units.currency.clone(),
588 cost_spec: spec.clone(),
589 });
590 }
591
592 for idx in indices {
593 if remaining.is_zero() {
594 break;
595 }
596
597 let pos = &self.positions[idx];
598 let available = pos.units.number.abs();
599 let take = remaining.min(available);
600
601 if let Some(cost) = &pos.cost {
603 cost_basis += take * cost.number;
604 cost_currency = Some(cost.currency.clone());
605 }
606
607 let (taken, _) = pos.split(take * pos.units.number.signum());
609 matched.push(taken);
610
611 let reduction = if units.number.is_sign_negative() {
613 -take
614 } else {
615 take
616 };
617
618 let new_pos = Position {
619 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
620 cost: pos.cost.clone(),
621 };
622 self.positions[idx] = new_pos;
623
624 remaining -= take;
625 }
626
627 if !remaining.is_zero() {
628 let available = units.number.abs() - remaining;
629 return Err(BookingError::InsufficientUnits {
630 currency: units.currency.clone(),
631 requested: units.number.abs(),
632 available,
633 });
634 }
635
636 self.positions.retain(|p| !p.is_empty());
638 self.rebuild_index();
639
640 Ok(BookingResult {
641 matched,
642 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
643 })
644 }
645
646 fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
648 let total_units: Decimal = self
650 .positions
651 .iter()
652 .filter(|p| p.units.currency == units.currency && !p.is_empty())
653 .map(|p| p.units.number)
654 .sum();
655
656 if total_units.is_zero() {
657 return Err(BookingError::InsufficientUnits {
658 currency: units.currency.clone(),
659 requested: units.number.abs(),
660 available: Decimal::ZERO,
661 });
662 }
663
664 let reduction = units.number.abs();
666 if reduction > total_units.abs() {
667 return Err(BookingError::InsufficientUnits {
668 currency: units.currency.clone(),
669 requested: reduction,
670 available: total_units.abs(),
671 });
672 }
673
674 let book_values = self.book_value(&units.currency);
676 let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
677 let per_unit_cost = total / total_units;
678 Some(Amount::new(reduction * per_unit_cost, curr.clone()))
679 } else {
680 None
681 };
682
683 let new_units = total_units + units.number;
685
686 let matched: Vec<Position> = self
688 .positions
689 .iter()
690 .filter(|p| p.units.currency == units.currency && !p.is_empty())
691 .cloned()
692 .collect();
693
694 self.positions
695 .retain(|p| p.units.currency != units.currency);
696
697 if !new_units.is_zero() {
699 self.positions.push(Position::simple(Amount::new(
700 new_units,
701 units.currency.clone(),
702 )));
703 }
704
705 self.rebuild_index();
707
708 Ok(BookingResult {
709 matched,
710 cost_basis,
711 })
712 }
713
714 fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
716 let total_units = self.units(&units.currency);
718
719 if total_units.signum() == units.number.signum() || total_units.is_zero() {
721 self.add(Position::simple(units.clone()));
723 return Ok(BookingResult {
724 matched: vec![],
725 cost_basis: None,
726 });
727 }
728
729 let available = total_units.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 self.reduce_ordered(units, &CostSpec::default(), false)
742 }
743
744 fn reduce_from_lot(
746 &mut self,
747 idx: usize,
748 units: &Amount,
749 ) -> Result<BookingResult, BookingError> {
750 let pos = &self.positions[idx];
751 let available = pos.units.number.abs();
752 let requested = units.number.abs();
753
754 if requested > available {
755 return Err(BookingError::InsufficientUnits {
756 currency: units.currency.clone(),
757 requested,
758 available,
759 });
760 }
761
762 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
764
765 let (matched, _) = pos.split(requested * pos.units.number.signum());
767
768 let new_units = pos.units.number + units.number;
770 let new_pos = Position {
771 units: Amount::new(new_units, pos.units.currency.clone()),
772 cost: pos.cost.clone(),
773 };
774 self.positions[idx] = new_pos;
775
776 if self.positions[idx].is_empty() {
778 self.positions.remove(idx);
779 }
780
781 Ok(BookingResult {
782 matched: vec![matched],
783 cost_basis,
784 })
785 }
786
787 pub fn compact(&mut self) {
789 self.positions.retain(|p| !p.is_empty());
790 self.rebuild_index();
791 }
792
793 fn rebuild_index(&mut self) {
796 self.simple_index.clear();
797 for (idx, pos) in self.positions.iter().enumerate() {
798 if pos.cost.is_none() {
799 debug_assert!(
800 !self.simple_index.contains_key(&pos.units.currency),
801 "Invariant violated: multiple simple positions for currency {}",
802 pos.units.currency
803 );
804 self.simple_index.insert(pos.units.currency.clone(), idx);
805 }
806 }
807 }
808
809 pub fn merge(&mut self, other: &Self) {
811 for pos in &other.positions {
812 self.add(pos.clone());
813 }
814 }
815
816 #[must_use]
821 pub fn at_cost(&self) -> Self {
822 let mut result = Self::new();
823
824 for pos in &self.positions {
825 if pos.is_empty() {
826 continue;
827 }
828
829 if let Some(cost) = &pos.cost {
830 let total = pos.units.number * cost.number;
832 result.add(Position::simple(Amount::new(total, &cost.currency)));
833 } else {
834 result.add(pos.clone());
836 }
837 }
838
839 result
840 }
841
842 #[must_use]
847 pub fn at_units(&self) -> Self {
848 let mut result = Self::new();
849
850 for pos in &self.positions {
851 if pos.is_empty() {
852 continue;
853 }
854
855 result.add(Position::simple(pos.units.clone()));
857 }
858
859 result
860 }
861}
862
863impl fmt::Display for Inventory {
864 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865 if self.is_empty() {
866 return write!(f, "(empty)");
867 }
868
869 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
871 non_empty.sort_by(|a, b| {
872 let cmp = a.units.currency.cmp(&b.units.currency);
874 if cmp != std::cmp::Ordering::Equal {
875 return cmp;
876 }
877 match (&a.cost, &b.cost) {
879 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
880 (Some(_), None) => std::cmp::Ordering::Greater,
881 (None, Some(_)) => std::cmp::Ordering::Less,
882 (None, None) => std::cmp::Ordering::Equal,
883 }
884 });
885
886 for (i, pos) in non_empty.iter().enumerate() {
887 if i > 0 {
888 write!(f, ", ")?;
889 }
890 write!(f, "{pos}")?;
891 }
892 Ok(())
893 }
894}
895
896impl FromIterator<Position> for Inventory {
897 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
898 let mut inv = Self::new();
899 for pos in iter {
900 inv.add(pos);
901 }
902 inv
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use crate::Cost;
910 use chrono::NaiveDate;
911 use rust_decimal_macros::dec;
912
913 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
914 NaiveDate::from_ymd_opt(year, month, day).unwrap()
915 }
916
917 #[test]
918 fn test_empty_inventory() {
919 let inv = Inventory::new();
920 assert!(inv.is_empty());
921 assert_eq!(inv.len(), 0);
922 }
923
924 #[test]
925 fn test_add_simple() {
926 let mut inv = Inventory::new();
927 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
928
929 assert!(!inv.is_empty());
930 assert_eq!(inv.units("USD"), dec!(100));
931 }
932
933 #[test]
934 fn test_add_merge_simple() {
935 let mut inv = Inventory::new();
936 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
937 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
938
939 assert_eq!(inv.len(), 1);
941 assert_eq!(inv.units("USD"), dec!(150));
942 }
943
944 #[test]
945 fn test_add_with_cost_no_merge() {
946 let mut inv = Inventory::new();
947
948 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
949 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
950
951 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
952 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
953
954 assert_eq!(inv.len(), 2);
956 assert_eq!(inv.units("AAPL"), dec!(15));
957 }
958
959 #[test]
960 fn test_currencies() {
961 let mut inv = Inventory::new();
962 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
963 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
964 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
965
966 let currencies = inv.currencies();
967 assert_eq!(currencies.len(), 3);
968 assert!(currencies.contains(&"USD"));
969 assert!(currencies.contains(&"EUR"));
970 assert!(currencies.contains(&"AAPL"));
971 }
972
973 #[test]
974 fn test_reduce_strict_unique() {
975 let mut inv = Inventory::new();
976 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
977 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
978
979 let result = inv
980 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
981 .unwrap();
982
983 assert_eq!(inv.units("AAPL"), dec!(5));
984 assert!(result.cost_basis.is_some());
985 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
987
988 #[test]
989 fn test_reduce_strict_multiple_match_uses_fifo() {
990 let mut inv = Inventory::new();
991
992 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
993 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
994
995 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
996 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
997
998 let result = inv
1001 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
1002 .unwrap();
1003
1004 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
1008
1009 #[test]
1010 fn test_reduce_strict_with_spec() {
1011 let mut inv = Inventory::new();
1012
1013 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1014 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1015
1016 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1017 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1018
1019 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
1021 let result = inv
1022 .reduce(
1023 &Amount::new(dec!(-3), "AAPL"),
1024 Some(&spec),
1025 BookingMethod::Strict,
1026 )
1027 .unwrap();
1028
1029 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
1032
1033 #[test]
1034 fn test_reduce_fifo() {
1035 let mut inv = Inventory::new();
1036
1037 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1038 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1039 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1040
1041 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1042 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1043 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1044
1045 let result = inv
1047 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
1048 .unwrap();
1049
1050 assert_eq!(inv.units("AAPL"), dec!(15));
1051 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
1053 }
1054
1055 #[test]
1056 fn test_reduce_lifo() {
1057 let mut inv = Inventory::new();
1058
1059 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1060 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1061 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1062
1063 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1064 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1065 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1066
1067 let result = inv
1069 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1070 .unwrap();
1071
1072 assert_eq!(inv.units("AAPL"), dec!(15));
1073 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1075 }
1076
1077 #[test]
1078 fn test_reduce_insufficient() {
1079 let mut inv = Inventory::new();
1080 let cost = Cost::new(dec!(150.00), "USD");
1081 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1082
1083 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1084
1085 assert!(matches!(
1086 result,
1087 Err(BookingError::InsufficientUnits { .. })
1088 ));
1089 }
1090
1091 #[test]
1092 fn test_book_value() {
1093 let mut inv = Inventory::new();
1094
1095 let cost1 = Cost::new(dec!(100.00), "USD");
1096 let cost2 = Cost::new(dec!(150.00), "USD");
1097
1098 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1099 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1100
1101 let book = inv.book_value("AAPL");
1102 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
1104
1105 #[test]
1106 fn test_display() {
1107 let mut inv = Inventory::new();
1108 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1109
1110 let s = format!("{inv}");
1111 assert!(s.contains("100 USD"));
1112 }
1113
1114 #[test]
1115 fn test_display_empty() {
1116 let inv = Inventory::new();
1117 assert_eq!(format!("{inv}"), "(empty)");
1118 }
1119
1120 #[test]
1121 fn test_from_iterator() {
1122 let positions = vec![
1123 Position::simple(Amount::new(dec!(100), "USD")),
1124 Position::simple(Amount::new(dec!(50), "USD")),
1125 ];
1126
1127 let inv: Inventory = positions.into_iter().collect();
1128 assert_eq!(inv.units("USD"), dec!(150));
1129 }
1130
1131 #[test]
1132 fn test_add_costed_positions_kept_separate() {
1133 let mut inv = Inventory::new();
1136
1137 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1138
1139 inv.add(Position::with_cost(
1141 Amount::new(dec!(10), "AAPL"),
1142 cost.clone(),
1143 ));
1144 assert_eq!(inv.len(), 1);
1145 assert_eq!(inv.units("AAPL"), dec!(10));
1146
1147 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1149 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
1152
1153 #[test]
1154 fn test_add_costed_positions_net_units() {
1155 let mut inv = Inventory::new();
1157
1158 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1159
1160 inv.add(Position::with_cost(
1162 Amount::new(dec!(10), "AAPL"),
1163 cost.clone(),
1164 ));
1165
1166 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
1168 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
1171
1172 #[test]
1173 fn test_add_no_cancel_different_cost() {
1174 let mut inv = Inventory::new();
1176
1177 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1178 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1179
1180 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1182
1183 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
1185
1186 assert_eq!(inv.len(), 2);
1188 assert_eq!(inv.units("AAPL"), dec!(5)); }
1190
1191 #[test]
1192 fn test_add_no_cancel_same_sign() {
1193 let mut inv = Inventory::new();
1195
1196 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1197
1198 inv.add(Position::with_cost(
1200 Amount::new(dec!(10), "AAPL"),
1201 cost.clone(),
1202 ));
1203
1204 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1206
1207 assert_eq!(inv.len(), 2);
1209 assert_eq!(inv.units("AAPL"), dec!(15));
1210 }
1211
1212 #[test]
1213 fn test_merge_keeps_lots_separate() {
1214 let mut inv1 = Inventory::new();
1216 let mut inv2 = Inventory::new();
1217
1218 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1219
1220 inv1.add(Position::with_cost(
1222 Amount::new(dec!(10), "AAPL"),
1223 cost.clone(),
1224 ));
1225
1226 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1228
1229 inv1.merge(&inv2);
1231 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
1234}