1use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12
13use crate::intern::InternedStr;
14use crate::{Amount, CostSpec, Position};
15
16mod booking;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[cfg_attr(
21 feature = "rkyv",
22 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
23)]
24pub enum BookingMethod {
25 #[default]
28 Strict,
29 StrictWithSize,
32 Fifo,
34 Lifo,
36 Hifo,
38 Average,
40 None,
42}
43
44impl FromStr for BookingMethod {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_uppercase().as_str() {
49 "STRICT" => Ok(Self::Strict),
50 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
51 "FIFO" => Ok(Self::Fifo),
52 "LIFO" => Ok(Self::Lifo),
53 "HIFO" => Ok(Self::Hifo),
54 "AVERAGE" => Ok(Self::Average),
55 "NONE" => Ok(Self::None),
56 _ => Err(format!("unknown booking method: {s}")),
57 }
58 }
59}
60
61impl fmt::Display for BookingMethod {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::Strict => write!(f, "STRICT"),
65 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
66 Self::Fifo => write!(f, "FIFO"),
67 Self::Lifo => write!(f, "LIFO"),
68 Self::Hifo => write!(f, "HIFO"),
69 Self::Average => write!(f, "AVERAGE"),
70 Self::None => write!(f, "NONE"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BookingResult {
78 pub matched: Vec<Position>,
80 pub cost_basis: Option<Amount>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum BookingError {
87 AmbiguousMatch {
89 num_matches: usize,
91 currency: InternedStr,
93 },
94 NoMatchingLot {
96 currency: InternedStr,
98 cost_spec: CostSpec,
100 },
101 InsufficientUnits {
103 currency: InternedStr,
105 requested: Decimal,
107 available: Decimal,
109 },
110 CurrencyMismatch {
112 expected: InternedStr,
114 got: InternedStr,
116 },
117}
118
119impl fmt::Display for BookingError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::AmbiguousMatch {
123 num_matches,
124 currency,
125 } => write!(
126 f,
127 "Ambiguous match: {num_matches} lots match for {currency}"
128 ),
129 Self::NoMatchingLot {
130 currency,
131 cost_spec,
132 } => {
133 write!(f, "No matching lot for {currency} with cost {cost_spec}")
134 }
135 Self::InsufficientUnits {
136 currency,
137 requested,
138 available,
139 } => write!(
140 f,
141 "Insufficient units of {currency}: requested {requested}, available {available}"
142 ),
143 Self::CurrencyMismatch { expected, got } => {
144 write!(f, "Currency mismatch: expected {expected}, got {got}")
145 }
146 }
147 }
148}
149
150impl std::error::Error for BookingError {}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175#[cfg_attr(
176 feature = "rkyv",
177 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
178)]
179pub struct Inventory {
180 positions: Vec<Position>,
181 #[serde(skip)]
185 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
186 simple_index: HashMap<InternedStr, usize>,
187 #[serde(skip)]
191 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
192 units_cache: HashMap<InternedStr, Decimal>,
193}
194
195impl PartialEq for Inventory {
196 fn eq(&self, other: &Self) -> bool {
197 self.positions == other.positions
199 }
200}
201
202impl Eq for Inventory {}
203
204impl Inventory {
205 #[must_use]
207 pub fn new() -> Self {
208 Self::default()
209 }
210
211 #[must_use]
213 pub fn positions(&self) -> &[Position] {
214 &self.positions
215 }
216
217 pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
219 &mut self.positions
220 }
221
222 #[must_use]
224 pub fn is_empty(&self) -> bool {
225 self.positions.is_empty()
226 || self
227 .positions
228 .iter()
229 .all(super::position::Position::is_empty)
230 }
231
232 #[must_use]
234 pub const fn len(&self) -> usize {
235 self.positions.len()
236 }
237
238 #[must_use]
243 pub fn units(&self, currency: &str) -> Decimal {
244 self.units_cache.get(currency).copied().unwrap_or_else(|| {
247 self.positions
249 .iter()
250 .filter(|p| p.units.currency == currency)
251 .map(|p| p.units.number)
252 .sum()
253 })
254 }
255
256 #[must_use]
258 pub fn currencies(&self) -> Vec<&str> {
259 let mut currencies: Vec<&str> = self
260 .positions
261 .iter()
262 .filter(|p| !p.is_empty())
263 .map(|p| p.units.currency.as_str())
264 .collect();
265 currencies.sort_unstable();
266 currencies.dedup();
267 currencies
268 }
269
270 #[must_use]
278 pub fn is_reduced_by(&self, units: &Amount) -> bool {
279 self.positions.iter().any(|pos| {
280 pos.units.currency == units.currency
281 && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
282 })
283 }
284
285 #[must_use]
289 pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
290 let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
291
292 for pos in &self.positions {
293 if pos.units.currency == units_currency
294 && let Some(book) = pos.book_value()
295 {
296 *totals.entry(book.currency.clone()).or_default() += book.number;
297 }
298 }
299
300 totals
301 }
302
303 pub fn add(&mut self, position: Position) {
320 if position.is_empty() {
321 return;
322 }
323
324 *self
326 .units_cache
327 .entry(position.units.currency.clone())
328 .or_default() += position.units.number;
329
330 if position.cost.is_none() {
332 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
333 debug_assert!(self.positions[idx].cost.is_none());
335 self.positions[idx].units += &position.units;
336 return;
337 }
338 let idx = self.positions.len();
340 self.simple_index
341 .insert(position.units.currency.clone(), idx);
342 self.positions.push(position);
343 return;
344 }
345
346 self.positions.push(position);
350 }
351
352 pub fn reduce(
379 &mut self,
380 units: &Amount,
381 cost_spec: Option<&CostSpec>,
382 method: BookingMethod,
383 ) -> Result<BookingResult, BookingError> {
384 let spec = cost_spec.cloned().unwrap_or_default();
385
386 match method {
387 BookingMethod::Strict => self.reduce_strict(units, &spec),
388 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
389 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
390 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
391 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
392 BookingMethod::Average => self.reduce_average(units),
393 BookingMethod::None => self.reduce_none(units),
394 }
395 }
396
397 pub fn compact(&mut self) {
399 self.positions.retain(|p| !p.is_empty());
400 self.rebuild_index();
401 }
402
403 fn rebuild_index(&mut self) {
406 self.simple_index.clear();
407 self.units_cache.clear();
408
409 for (idx, pos) in self.positions.iter().enumerate() {
410 *self
412 .units_cache
413 .entry(pos.units.currency.clone())
414 .or_default() += pos.units.number;
415
416 if pos.cost.is_none() {
418 debug_assert!(
419 !self.simple_index.contains_key(&pos.units.currency),
420 "Invariant violated: multiple simple positions for currency {}",
421 pos.units.currency
422 );
423 self.simple_index.insert(pos.units.currency.clone(), idx);
424 }
425 }
426 }
427
428 pub fn merge(&mut self, other: &Self) {
430 for pos in &other.positions {
431 self.add(pos.clone());
432 }
433 }
434
435 #[must_use]
440 pub fn at_cost(&self) -> Self {
441 let mut result = Self::new();
442
443 for pos in &self.positions {
444 if pos.is_empty() {
445 continue;
446 }
447
448 if let Some(cost) = &pos.cost {
449 let total = pos.units.number * cost.number;
451 result.add(Position::simple(Amount::new(total, &cost.currency)));
452 } else {
453 result.add(pos.clone());
455 }
456 }
457
458 result
459 }
460
461 #[must_use]
466 pub fn at_units(&self) -> Self {
467 let mut result = Self::new();
468
469 for pos in &self.positions {
470 if pos.is_empty() {
471 continue;
472 }
473
474 result.add(Position::simple(pos.units.clone()));
476 }
477
478 result
479 }
480}
481
482impl fmt::Display for Inventory {
483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484 if self.is_empty() {
485 return write!(f, "(empty)");
486 }
487
488 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
490 non_empty.sort_by(|a, b| {
491 let cmp = a.units.currency.cmp(&b.units.currency);
493 if cmp != std::cmp::Ordering::Equal {
494 return cmp;
495 }
496 match (&a.cost, &b.cost) {
498 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
499 (Some(_), None) => std::cmp::Ordering::Greater,
500 (None, Some(_)) => std::cmp::Ordering::Less,
501 (None, None) => std::cmp::Ordering::Equal,
502 }
503 });
504
505 for (i, pos) in non_empty.iter().enumerate() {
506 if i > 0 {
507 write!(f, ", ")?;
508 }
509 write!(f, "{pos}")?;
510 }
511 Ok(())
512 }
513}
514
515impl FromIterator<Position> for Inventory {
516 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
517 let mut inv = Self::new();
518 for pos in iter {
519 inv.add(pos);
520 }
521 inv
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::Cost;
529 use chrono::NaiveDate;
530 use rust_decimal_macros::dec;
531
532 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
533 NaiveDate::from_ymd_opt(year, month, day).unwrap()
534 }
535
536 #[test]
537 fn test_empty_inventory() {
538 let inv = Inventory::new();
539 assert!(inv.is_empty());
540 assert_eq!(inv.len(), 0);
541 }
542
543 #[test]
544 fn test_add_simple() {
545 let mut inv = Inventory::new();
546 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
547
548 assert!(!inv.is_empty());
549 assert_eq!(inv.units("USD"), dec!(100));
550 }
551
552 #[test]
553 fn test_add_merge_simple() {
554 let mut inv = Inventory::new();
555 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
556 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
557
558 assert_eq!(inv.len(), 1);
560 assert_eq!(inv.units("USD"), dec!(150));
561 }
562
563 #[test]
564 fn test_add_with_cost_no_merge() {
565 let mut inv = Inventory::new();
566
567 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
568 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
569
570 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
571 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
572
573 assert_eq!(inv.len(), 2);
575 assert_eq!(inv.units("AAPL"), dec!(15));
576 }
577
578 #[test]
579 fn test_currencies() {
580 let mut inv = Inventory::new();
581 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
582 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
583 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
584
585 let currencies = inv.currencies();
586 assert_eq!(currencies.len(), 3);
587 assert!(currencies.contains(&"USD"));
588 assert!(currencies.contains(&"EUR"));
589 assert!(currencies.contains(&"AAPL"));
590 }
591
592 #[test]
593 fn test_reduce_strict_unique() {
594 let mut inv = Inventory::new();
595 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
596 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
597
598 let result = inv
599 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
600 .unwrap();
601
602 assert_eq!(inv.units("AAPL"), dec!(5));
603 assert!(result.cost_basis.is_some());
604 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
606
607 #[test]
608 fn test_reduce_strict_multiple_match_uses_fifo() {
609 let mut inv = Inventory::new();
610
611 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
612 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
613
614 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
615 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
616
617 let result = inv
620 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
621 .unwrap();
622
623 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
627
628 #[test]
629 fn test_reduce_strict_with_spec() {
630 let mut inv = Inventory::new();
631
632 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
633 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
634
635 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
636 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
637
638 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
640 let result = inv
641 .reduce(
642 &Amount::new(dec!(-3), "AAPL"),
643 Some(&spec),
644 BookingMethod::Strict,
645 )
646 .unwrap();
647
648 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
651
652 #[test]
653 fn test_reduce_fifo() {
654 let mut inv = Inventory::new();
655
656 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
657 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
658 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
659
660 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
661 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
662 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
663
664 let result = inv
666 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
667 .unwrap();
668
669 assert_eq!(inv.units("AAPL"), dec!(15));
670 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
672 }
673
674 #[test]
675 fn test_reduce_lifo() {
676 let mut inv = Inventory::new();
677
678 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
679 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
680 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
681
682 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
683 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
684 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
685
686 let result = inv
688 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
689 .unwrap();
690
691 assert_eq!(inv.units("AAPL"), dec!(15));
692 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
694 }
695
696 #[test]
697 fn test_reduce_insufficient() {
698 let mut inv = Inventory::new();
699 let cost = Cost::new(dec!(150.00), "USD");
700 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
701
702 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
703
704 assert!(matches!(
705 result,
706 Err(BookingError::InsufficientUnits { .. })
707 ));
708 }
709
710 #[test]
711 fn test_book_value() {
712 let mut inv = Inventory::new();
713
714 let cost1 = Cost::new(dec!(100.00), "USD");
715 let cost2 = Cost::new(dec!(150.00), "USD");
716
717 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
718 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
719
720 let book = inv.book_value("AAPL");
721 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
723
724 #[test]
725 fn test_display() {
726 let mut inv = Inventory::new();
727 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
728
729 let s = format!("{inv}");
730 assert!(s.contains("100 USD"));
731 }
732
733 #[test]
734 fn test_display_empty() {
735 let inv = Inventory::new();
736 assert_eq!(format!("{inv}"), "(empty)");
737 }
738
739 #[test]
740 fn test_from_iterator() {
741 let positions = vec![
742 Position::simple(Amount::new(dec!(100), "USD")),
743 Position::simple(Amount::new(dec!(50), "USD")),
744 ];
745
746 let inv: Inventory = positions.into_iter().collect();
747 assert_eq!(inv.units("USD"), dec!(150));
748 }
749
750 #[test]
751 fn test_add_costed_positions_kept_separate() {
752 let mut inv = Inventory::new();
755
756 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
757
758 inv.add(Position::with_cost(
760 Amount::new(dec!(10), "AAPL"),
761 cost.clone(),
762 ));
763 assert_eq!(inv.len(), 1);
764 assert_eq!(inv.units("AAPL"), dec!(10));
765
766 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
768 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
771
772 #[test]
773 fn test_add_costed_positions_net_units() {
774 let mut inv = Inventory::new();
776
777 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
778
779 inv.add(Position::with_cost(
781 Amount::new(dec!(10), "AAPL"),
782 cost.clone(),
783 ));
784
785 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
787 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
790
791 #[test]
792 fn test_add_no_cancel_different_cost() {
793 let mut inv = Inventory::new();
795
796 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
797 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
798
799 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
801
802 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
804
805 assert_eq!(inv.len(), 2);
807 assert_eq!(inv.units("AAPL"), dec!(5)); }
809
810 #[test]
811 fn test_add_no_cancel_same_sign() {
812 let mut inv = Inventory::new();
814
815 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
816
817 inv.add(Position::with_cost(
819 Amount::new(dec!(10), "AAPL"),
820 cost.clone(),
821 ));
822
823 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
825
826 assert_eq!(inv.len(), 2);
828 assert_eq!(inv.units("AAPL"), dec!(15));
829 }
830
831 #[test]
832 fn test_merge_keeps_lots_separate() {
833 let mut inv1 = Inventory::new();
835 let mut inv2 = Inventory::new();
836
837 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
838
839 inv1.add(Position::with_cost(
841 Amount::new(dec!(10), "AAPL"),
842 cost.clone(),
843 ));
844
845 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
847
848 inv1.merge(&inv2);
850 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
853
854 #[test]
859 fn test_hifo_with_tie_breaking() {
860 let mut inv = Inventory::new();
862
863 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
865 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
866 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
867
868 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
869 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
870 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
871
872 let result = inv
874 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
875 .unwrap();
876
877 assert_eq!(inv.units("AAPL"), dec!(15));
878 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
880 }
881
882 #[test]
883 fn test_hifo_with_different_costs() {
884 let mut inv = Inventory::new();
886
887 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
888 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
889 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
890
891 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
892 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
893 inv.add(Position::with_cost(
894 Amount::new(dec!(10), "AAPL"),
895 cost_high,
896 ));
897
898 let result = inv
900 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
901 .unwrap();
902
903 assert_eq!(inv.units("AAPL"), dec!(15));
904 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
906 }
907
908 #[test]
909 fn test_average_booking_with_pre_existing_positions() {
910 let mut inv = Inventory::new();
911
912 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
914 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
915
916 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
917 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
918
919 let result = inv
922 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
923 .unwrap();
924
925 assert_eq!(inv.units("AAPL"), dec!(15));
926 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
928 }
929
930 #[test]
931 fn test_average_booking_reduces_all() {
932 let mut inv = Inventory::new();
933
934 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
935 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
936
937 let result = inv
939 .reduce(
940 &Amount::new(dec!(-10), "AAPL"),
941 None,
942 BookingMethod::Average,
943 )
944 .unwrap();
945
946 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
947 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
948 }
949
950 #[test]
951 fn test_none_booking_augmentation() {
952 let mut inv = Inventory::new();
954 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
955
956 let result = inv
958 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
959 .unwrap();
960
961 assert_eq!(inv.units("USD"), dec!(150));
962 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
964 }
965
966 #[test]
967 fn test_none_booking_reduction() {
968 let mut inv = Inventory::new();
970 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
971
972 let result = inv
973 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
974 .unwrap();
975
976 assert_eq!(inv.units("USD"), dec!(70));
977 assert!(!result.matched.is_empty());
978 }
979
980 #[test]
981 fn test_none_booking_insufficient() {
982 let mut inv = Inventory::new();
983 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
984
985 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
986
987 assert!(matches!(
988 result,
989 Err(BookingError::InsufficientUnits { .. })
990 ));
991 }
992
993 #[test]
994 fn test_booking_error_no_matching_lot() {
995 let mut inv = Inventory::new();
996
997 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
999 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1000
1001 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1003 let result = inv.reduce(
1004 &Amount::new(dec!(-5), "AAPL"),
1005 Some(&wrong_spec),
1006 BookingMethod::Strict,
1007 );
1008
1009 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1010 }
1011
1012 #[test]
1013 fn test_booking_error_insufficient_units() {
1014 let mut inv = Inventory::new();
1015
1016 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1017 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1018
1019 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1021
1022 match result {
1023 Err(BookingError::InsufficientUnits {
1024 requested,
1025 available,
1026 ..
1027 }) => {
1028 assert_eq!(requested, dec!(20));
1029 assert_eq!(available, dec!(10));
1030 }
1031 _ => panic!("Expected InsufficientUnits error"),
1032 }
1033 }
1034
1035 #[test]
1036 fn test_strict_with_size_exact_match() {
1037 let mut inv = Inventory::new();
1038
1039 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1041 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1042
1043 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1044 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1045
1046 let result = inv
1048 .reduce(
1049 &Amount::new(dec!(-5), "AAPL"),
1050 None,
1051 BookingMethod::StrictWithSize,
1052 )
1053 .unwrap();
1054
1055 assert_eq!(inv.units("AAPL"), dec!(10));
1056 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1057 }
1058
1059 #[test]
1060 fn test_strict_with_size_total_match() {
1061 let mut inv = Inventory::new();
1062
1063 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1065 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1066
1067 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1068 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1069
1070 let result = inv
1072 .reduce(
1073 &Amount::new(dec!(-15), "AAPL"),
1074 None,
1075 BookingMethod::StrictWithSize,
1076 )
1077 .unwrap();
1078
1079 assert_eq!(inv.units("AAPL"), dec!(0));
1080 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1081 }
1082
1083 #[test]
1084 fn test_strict_with_size_ambiguous() {
1085 let mut inv = Inventory::new();
1086
1087 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1089 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1090
1091 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1092 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1093
1094 let result = inv.reduce(
1096 &Amount::new(dec!(-7), "AAPL"),
1097 None,
1098 BookingMethod::StrictWithSize,
1099 );
1100
1101 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1102 }
1103
1104 #[test]
1105 fn test_short_position() {
1106 let mut inv = Inventory::new();
1108
1109 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1111 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1112
1113 assert_eq!(inv.units("AAPL"), dec!(-10));
1114 assert!(!inv.is_empty());
1115 }
1116
1117 #[test]
1118 fn test_at_cost() {
1119 let mut inv = Inventory::new();
1120
1121 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1122 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1123
1124 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1125 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1126 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1127
1128 let at_cost = inv.at_cost();
1129
1130 assert_eq!(at_cost.units("USD"), dec!(1850));
1133 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1135
1136 #[test]
1137 fn test_at_units() {
1138 let mut inv = Inventory::new();
1139
1140 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1141 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1142
1143 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1144 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1145
1146 let at_units = inv.at_units();
1147
1148 assert_eq!(at_units.units("AAPL"), dec!(15));
1150 assert_eq!(at_units.len(), 1);
1152 }
1153
1154 #[test]
1155 fn test_add_empty_position() {
1156 let mut inv = Inventory::new();
1157 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1158
1159 assert!(inv.is_empty());
1160 assert_eq!(inv.len(), 0);
1161 }
1162
1163 #[test]
1164 fn test_compact() {
1165 let mut inv = Inventory::new();
1166
1167 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1168 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1169
1170 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1172 .unwrap();
1173
1174 inv.compact();
1176 assert!(inv.is_empty());
1177 assert_eq!(inv.len(), 0);
1178 }
1179
1180 #[test]
1181 fn test_booking_method_from_str() {
1182 assert_eq!(
1183 BookingMethod::from_str("STRICT").unwrap(),
1184 BookingMethod::Strict
1185 );
1186 assert_eq!(
1187 BookingMethod::from_str("fifo").unwrap(),
1188 BookingMethod::Fifo
1189 );
1190 assert_eq!(
1191 BookingMethod::from_str("LIFO").unwrap(),
1192 BookingMethod::Lifo
1193 );
1194 assert_eq!(
1195 BookingMethod::from_str("Hifo").unwrap(),
1196 BookingMethod::Hifo
1197 );
1198 assert_eq!(
1199 BookingMethod::from_str("AVERAGE").unwrap(),
1200 BookingMethod::Average
1201 );
1202 assert_eq!(
1203 BookingMethod::from_str("NONE").unwrap(),
1204 BookingMethod::None
1205 );
1206 assert_eq!(
1207 BookingMethod::from_str("strict_with_size").unwrap(),
1208 BookingMethod::StrictWithSize
1209 );
1210 assert!(BookingMethod::from_str("INVALID").is_err());
1211 }
1212
1213 #[test]
1214 fn test_booking_method_display() {
1215 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1216 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1217 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1218 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1219 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1220 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1221 assert_eq!(
1222 format!("{}", BookingMethod::StrictWithSize),
1223 "STRICT_WITH_SIZE"
1224 );
1225 }
1226
1227 #[test]
1228 fn test_booking_error_display() {
1229 let err = BookingError::AmbiguousMatch {
1230 num_matches: 3,
1231 currency: "AAPL".into(),
1232 };
1233 assert!(format!("{err}").contains("3 lots match"));
1234
1235 let err = BookingError::NoMatchingLot {
1236 currency: "AAPL".into(),
1237 cost_spec: CostSpec::empty(),
1238 };
1239 assert!(format!("{err}").contains("No matching lot"));
1240
1241 let err = BookingError::InsufficientUnits {
1242 currency: "AAPL".into(),
1243 requested: dec!(100),
1244 available: dec!(50),
1245 };
1246 assert!(format!("{err}").contains("requested 100"));
1247 assert!(format!("{err}").contains("available 50"));
1248
1249 let err = BookingError::CurrencyMismatch {
1250 expected: "USD".into(),
1251 got: "EUR".into(),
1252 };
1253 assert!(format!("{err}").contains("expected USD"));
1254 assert!(format!("{err}").contains("got EUR"));
1255 }
1256
1257 #[test]
1258 fn test_book_value_multiple_currencies() {
1259 let mut inv = Inventory::new();
1260
1261 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1263 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1264
1265 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1267 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1268
1269 let book = inv.book_value("AAPL");
1270 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1271 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1272 }
1273
1274 #[test]
1275 fn test_reduce_hifo_insufficient_units() {
1276 let mut inv = Inventory::new();
1277
1278 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1279 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1280
1281 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1282
1283 assert!(matches!(
1284 result,
1285 Err(BookingError::InsufficientUnits { .. })
1286 ));
1287 }
1288
1289 #[test]
1290 fn test_reduce_average_insufficient_units() {
1291 let mut inv = Inventory::new();
1292
1293 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1294 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1295
1296 let result = inv.reduce(
1297 &Amount::new(dec!(-20), "AAPL"),
1298 None,
1299 BookingMethod::Average,
1300 );
1301
1302 assert!(matches!(
1303 result,
1304 Err(BookingError::InsufficientUnits { .. })
1305 ));
1306 }
1307
1308 #[test]
1309 fn test_reduce_average_empty_inventory() {
1310 let mut inv = Inventory::new();
1311
1312 let result = inv.reduce(
1313 &Amount::new(dec!(-10), "AAPL"),
1314 None,
1315 BookingMethod::Average,
1316 );
1317
1318 assert!(matches!(
1319 result,
1320 Err(BookingError::InsufficientUnits { .. })
1321 ));
1322 }
1323
1324 #[test]
1325 fn test_inventory_display_sorted() {
1326 let mut inv = Inventory::new();
1327
1328 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1330 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1331 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1332
1333 let display = format!("{inv}");
1334
1335 let aapl_pos = display.find("AAPL").unwrap();
1337 let eur_pos = display.find("EUR").unwrap();
1338 let usd_pos = display.find("USD").unwrap();
1339
1340 assert!(aapl_pos < eur_pos);
1341 assert!(eur_pos < usd_pos);
1342 }
1343
1344 #[test]
1345 fn test_inventory_with_cost_display_sorted() {
1346 let mut inv = Inventory::new();
1347
1348 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1350 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1351
1352 inv.add(Position::with_cost(
1353 Amount::new(dec!(10), "AAPL"),
1354 cost_high,
1355 ));
1356 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1357
1358 let display = format!("{inv}");
1359
1360 assert!(display.contains("AAPL"));
1362 assert!(display.contains("100"));
1363 assert!(display.contains("200"));
1364 }
1365
1366 #[test]
1367 fn test_reduce_hifo_no_matching_lot() {
1368 let mut inv = Inventory::new();
1369
1370 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1372
1373 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1374
1375 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1376 }
1377
1378 #[test]
1379 fn test_fifo_respects_dates() {
1380 let mut inv = Inventory::new();
1382
1383 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1385 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1386
1387 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1388 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1389
1390 let result = inv
1392 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1393 .unwrap();
1394
1395 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1397 }
1398
1399 #[test]
1400 fn test_lifo_respects_dates() {
1401 let mut inv = Inventory::new();
1403
1404 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1406 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1407
1408 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1409 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1410
1411 let result = inv
1413 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1414 .unwrap();
1415
1416 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1418 }
1419}