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 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 if let Some(book) = pos.book_value() {
295 *totals.entry(book.currency.clone()).or_default() += book.number;
296 }
297 }
298 }
299
300 totals
301 }
302
303 pub fn add(&mut self, position: Position) {
312 if position.is_empty() {
313 return;
314 }
315
316 *self
318 .units_cache
319 .entry(position.units.currency.clone())
320 .or_default() += position.units.number;
321
322 if position.cost.is_none() {
324 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
325 debug_assert!(self.positions[idx].cost.is_none());
327 self.positions[idx].units += &position.units;
328 return;
329 }
330 let idx = self.positions.len();
332 self.simple_index
333 .insert(position.units.currency.clone(), idx);
334 self.positions.push(position);
335 return;
336 }
337
338 self.positions.push(position);
342 }
343
344 pub fn reduce(
357 &mut self,
358 units: &Amount,
359 cost_spec: Option<&CostSpec>,
360 method: BookingMethod,
361 ) -> Result<BookingResult, BookingError> {
362 let spec = cost_spec.cloned().unwrap_or_default();
363
364 match method {
365 BookingMethod::Strict => self.reduce_strict(units, &spec),
366 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
367 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
368 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
369 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
370 BookingMethod::Average => self.reduce_average(units),
371 BookingMethod::None => self.reduce_none(units),
372 }
373 }
374
375 pub fn compact(&mut self) {
377 self.positions.retain(|p| !p.is_empty());
378 self.rebuild_index();
379 }
380
381 fn rebuild_index(&mut self) {
384 self.simple_index.clear();
385 self.units_cache.clear();
386
387 for (idx, pos) in self.positions.iter().enumerate() {
388 *self
390 .units_cache
391 .entry(pos.units.currency.clone())
392 .or_default() += pos.units.number;
393
394 if pos.cost.is_none() {
396 debug_assert!(
397 !self.simple_index.contains_key(&pos.units.currency),
398 "Invariant violated: multiple simple positions for currency {}",
399 pos.units.currency
400 );
401 self.simple_index.insert(pos.units.currency.clone(), idx);
402 }
403 }
404 }
405
406 pub fn merge(&mut self, other: &Self) {
408 for pos in &other.positions {
409 self.add(pos.clone());
410 }
411 }
412
413 #[must_use]
418 pub fn at_cost(&self) -> Self {
419 let mut result = Self::new();
420
421 for pos in &self.positions {
422 if pos.is_empty() {
423 continue;
424 }
425
426 if let Some(cost) = &pos.cost {
427 let total = pos.units.number * cost.number;
429 result.add(Position::simple(Amount::new(total, &cost.currency)));
430 } else {
431 result.add(pos.clone());
433 }
434 }
435
436 result
437 }
438
439 #[must_use]
444 pub fn at_units(&self) -> Self {
445 let mut result = Self::new();
446
447 for pos in &self.positions {
448 if pos.is_empty() {
449 continue;
450 }
451
452 result.add(Position::simple(pos.units.clone()));
454 }
455
456 result
457 }
458}
459
460impl fmt::Display for Inventory {
461 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462 if self.is_empty() {
463 return write!(f, "(empty)");
464 }
465
466 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
468 non_empty.sort_by(|a, b| {
469 let cmp = a.units.currency.cmp(&b.units.currency);
471 if cmp != std::cmp::Ordering::Equal {
472 return cmp;
473 }
474 match (&a.cost, &b.cost) {
476 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
477 (Some(_), None) => std::cmp::Ordering::Greater,
478 (None, Some(_)) => std::cmp::Ordering::Less,
479 (None, None) => std::cmp::Ordering::Equal,
480 }
481 });
482
483 for (i, pos) in non_empty.iter().enumerate() {
484 if i > 0 {
485 write!(f, ", ")?;
486 }
487 write!(f, "{pos}")?;
488 }
489 Ok(())
490 }
491}
492
493impl FromIterator<Position> for Inventory {
494 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
495 let mut inv = Self::new();
496 for pos in iter {
497 inv.add(pos);
498 }
499 inv
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::Cost;
507 use chrono::NaiveDate;
508 use rust_decimal_macros::dec;
509
510 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
511 NaiveDate::from_ymd_opt(year, month, day).unwrap()
512 }
513
514 #[test]
515 fn test_empty_inventory() {
516 let inv = Inventory::new();
517 assert!(inv.is_empty());
518 assert_eq!(inv.len(), 0);
519 }
520
521 #[test]
522 fn test_add_simple() {
523 let mut inv = Inventory::new();
524 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
525
526 assert!(!inv.is_empty());
527 assert_eq!(inv.units("USD"), dec!(100));
528 }
529
530 #[test]
531 fn test_add_merge_simple() {
532 let mut inv = Inventory::new();
533 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
534 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
535
536 assert_eq!(inv.len(), 1);
538 assert_eq!(inv.units("USD"), dec!(150));
539 }
540
541 #[test]
542 fn test_add_with_cost_no_merge() {
543 let mut inv = Inventory::new();
544
545 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
546 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
547
548 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
549 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
550
551 assert_eq!(inv.len(), 2);
553 assert_eq!(inv.units("AAPL"), dec!(15));
554 }
555
556 #[test]
557 fn test_currencies() {
558 let mut inv = Inventory::new();
559 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
560 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
561 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
562
563 let currencies = inv.currencies();
564 assert_eq!(currencies.len(), 3);
565 assert!(currencies.contains(&"USD"));
566 assert!(currencies.contains(&"EUR"));
567 assert!(currencies.contains(&"AAPL"));
568 }
569
570 #[test]
571 fn test_reduce_strict_unique() {
572 let mut inv = Inventory::new();
573 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
574 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
575
576 let result = inv
577 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
578 .unwrap();
579
580 assert_eq!(inv.units("AAPL"), dec!(5));
581 assert!(result.cost_basis.is_some());
582 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
584
585 #[test]
586 fn test_reduce_strict_multiple_match_uses_fifo() {
587 let mut inv = Inventory::new();
588
589 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
590 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
591
592 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
593 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
594
595 let result = inv
598 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
599 .unwrap();
600
601 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
605
606 #[test]
607 fn test_reduce_strict_with_spec() {
608 let mut inv = Inventory::new();
609
610 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
611 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
612
613 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
614 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
615
616 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
618 let result = inv
619 .reduce(
620 &Amount::new(dec!(-3), "AAPL"),
621 Some(&spec),
622 BookingMethod::Strict,
623 )
624 .unwrap();
625
626 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
629
630 #[test]
631 fn test_reduce_fifo() {
632 let mut inv = Inventory::new();
633
634 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
635 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
636 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
637
638 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
639 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
640 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
641
642 let result = inv
644 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
645 .unwrap();
646
647 assert_eq!(inv.units("AAPL"), dec!(15));
648 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
650 }
651
652 #[test]
653 fn test_reduce_lifo() {
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::Lifo)
667 .unwrap();
668
669 assert_eq!(inv.units("AAPL"), dec!(15));
670 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
672 }
673
674 #[test]
675 fn test_reduce_insufficient() {
676 let mut inv = Inventory::new();
677 let cost = Cost::new(dec!(150.00), "USD");
678 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
679
680 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
681
682 assert!(matches!(
683 result,
684 Err(BookingError::InsufficientUnits { .. })
685 ));
686 }
687
688 #[test]
689 fn test_book_value() {
690 let mut inv = Inventory::new();
691
692 let cost1 = Cost::new(dec!(100.00), "USD");
693 let cost2 = Cost::new(dec!(150.00), "USD");
694
695 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
696 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
697
698 let book = inv.book_value("AAPL");
699 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
701
702 #[test]
703 fn test_display() {
704 let mut inv = Inventory::new();
705 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
706
707 let s = format!("{inv}");
708 assert!(s.contains("100 USD"));
709 }
710
711 #[test]
712 fn test_display_empty() {
713 let inv = Inventory::new();
714 assert_eq!(format!("{inv}"), "(empty)");
715 }
716
717 #[test]
718 fn test_from_iterator() {
719 let positions = vec![
720 Position::simple(Amount::new(dec!(100), "USD")),
721 Position::simple(Amount::new(dec!(50), "USD")),
722 ];
723
724 let inv: Inventory = positions.into_iter().collect();
725 assert_eq!(inv.units("USD"), dec!(150));
726 }
727
728 #[test]
729 fn test_add_costed_positions_kept_separate() {
730 let mut inv = Inventory::new();
733
734 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
735
736 inv.add(Position::with_cost(
738 Amount::new(dec!(10), "AAPL"),
739 cost.clone(),
740 ));
741 assert_eq!(inv.len(), 1);
742 assert_eq!(inv.units("AAPL"), dec!(10));
743
744 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
746 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
749
750 #[test]
751 fn test_add_costed_positions_net_units() {
752 let mut inv = Inventory::new();
754
755 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
756
757 inv.add(Position::with_cost(
759 Amount::new(dec!(10), "AAPL"),
760 cost.clone(),
761 ));
762
763 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
765 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
768
769 #[test]
770 fn test_add_no_cancel_different_cost() {
771 let mut inv = Inventory::new();
773
774 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
775 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
776
777 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
779
780 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
782
783 assert_eq!(inv.len(), 2);
785 assert_eq!(inv.units("AAPL"), dec!(5)); }
787
788 #[test]
789 fn test_add_no_cancel_same_sign() {
790 let mut inv = Inventory::new();
792
793 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
794
795 inv.add(Position::with_cost(
797 Amount::new(dec!(10), "AAPL"),
798 cost.clone(),
799 ));
800
801 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
803
804 assert_eq!(inv.len(), 2);
806 assert_eq!(inv.units("AAPL"), dec!(15));
807 }
808
809 #[test]
810 fn test_merge_keeps_lots_separate() {
811 let mut inv1 = Inventory::new();
813 let mut inv2 = Inventory::new();
814
815 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
816
817 inv1.add(Position::with_cost(
819 Amount::new(dec!(10), "AAPL"),
820 cost.clone(),
821 ));
822
823 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
825
826 inv1.merge(&inv2);
828 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
831
832 #[test]
837 fn test_hifo_with_tie_breaking() {
838 let mut inv = Inventory::new();
840
841 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
843 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
844 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
845
846 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
847 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
848 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
849
850 let result = inv
852 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
853 .unwrap();
854
855 assert_eq!(inv.units("AAPL"), dec!(15));
856 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
858 }
859
860 #[test]
861 fn test_hifo_with_different_costs() {
862 let mut inv = Inventory::new();
864
865 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
866 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
867 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
868
869 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
870 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
871 inv.add(Position::with_cost(
872 Amount::new(dec!(10), "AAPL"),
873 cost_high,
874 ));
875
876 let result = inv
878 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
879 .unwrap();
880
881 assert_eq!(inv.units("AAPL"), dec!(15));
882 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
884 }
885
886 #[test]
887 fn test_average_booking_with_pre_existing_positions() {
888 let mut inv = Inventory::new();
889
890 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
892 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
893
894 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
895 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
896
897 let result = inv
900 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
901 .unwrap();
902
903 assert_eq!(inv.units("AAPL"), dec!(15));
904 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
906 }
907
908 #[test]
909 fn test_average_booking_reduces_all() {
910 let mut inv = Inventory::new();
911
912 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
913 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
914
915 let result = inv
917 .reduce(
918 &Amount::new(dec!(-10), "AAPL"),
919 None,
920 BookingMethod::Average,
921 )
922 .unwrap();
923
924 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
925 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
926 }
927
928 #[test]
929 fn test_none_booking_augmentation() {
930 let mut inv = Inventory::new();
932 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
933
934 let result = inv
936 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
937 .unwrap();
938
939 assert_eq!(inv.units("USD"), dec!(150));
940 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
942 }
943
944 #[test]
945 fn test_none_booking_reduction() {
946 let mut inv = Inventory::new();
948 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
949
950 let result = inv
951 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
952 .unwrap();
953
954 assert_eq!(inv.units("USD"), dec!(70));
955 assert!(!result.matched.is_empty());
956 }
957
958 #[test]
959 fn test_none_booking_insufficient() {
960 let mut inv = Inventory::new();
961 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
962
963 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
964
965 assert!(matches!(
966 result,
967 Err(BookingError::InsufficientUnits { .. })
968 ));
969 }
970
971 #[test]
972 fn test_booking_error_no_matching_lot() {
973 let mut inv = Inventory::new();
974
975 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
977 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
978
979 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
981 let result = inv.reduce(
982 &Amount::new(dec!(-5), "AAPL"),
983 Some(&wrong_spec),
984 BookingMethod::Strict,
985 );
986
987 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
988 }
989
990 #[test]
991 fn test_booking_error_insufficient_units() {
992 let mut inv = Inventory::new();
993
994 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
995 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
996
997 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
999
1000 match result {
1001 Err(BookingError::InsufficientUnits {
1002 requested,
1003 available,
1004 ..
1005 }) => {
1006 assert_eq!(requested, dec!(20));
1007 assert_eq!(available, dec!(10));
1008 }
1009 _ => panic!("Expected InsufficientUnits error"),
1010 }
1011 }
1012
1013 #[test]
1014 fn test_strict_with_size_exact_match() {
1015 let mut inv = Inventory::new();
1016
1017 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1019 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1020
1021 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1022 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1023
1024 let result = inv
1026 .reduce(
1027 &Amount::new(dec!(-5), "AAPL"),
1028 None,
1029 BookingMethod::StrictWithSize,
1030 )
1031 .unwrap();
1032
1033 assert_eq!(inv.units("AAPL"), dec!(10));
1034 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1035 }
1036
1037 #[test]
1038 fn test_strict_with_size_total_match() {
1039 let mut inv = Inventory::new();
1040
1041 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1043 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1044
1045 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1046 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1047
1048 let result = inv
1050 .reduce(
1051 &Amount::new(dec!(-15), "AAPL"),
1052 None,
1053 BookingMethod::StrictWithSize,
1054 )
1055 .unwrap();
1056
1057 assert_eq!(inv.units("AAPL"), dec!(0));
1058 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1059 }
1060
1061 #[test]
1062 fn test_strict_with_size_ambiguous() {
1063 let mut inv = Inventory::new();
1064
1065 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1067 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1068
1069 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1070 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1071
1072 let result = inv.reduce(
1074 &Amount::new(dec!(-7), "AAPL"),
1075 None,
1076 BookingMethod::StrictWithSize,
1077 );
1078
1079 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1080 }
1081
1082 #[test]
1083 fn test_short_position() {
1084 let mut inv = Inventory::new();
1086
1087 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1089 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1090
1091 assert_eq!(inv.units("AAPL"), dec!(-10));
1092 assert!(!inv.is_empty());
1093 }
1094
1095 #[test]
1096 fn test_at_cost() {
1097 let mut inv = Inventory::new();
1098
1099 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1100 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1101
1102 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1103 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1104 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1105
1106 let at_cost = inv.at_cost();
1107
1108 assert_eq!(at_cost.units("USD"), dec!(1850));
1111 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1113
1114 #[test]
1115 fn test_at_units() {
1116 let mut inv = Inventory::new();
1117
1118 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1119 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1120
1121 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1122 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1123
1124 let at_units = inv.at_units();
1125
1126 assert_eq!(at_units.units("AAPL"), dec!(15));
1128 assert_eq!(at_units.len(), 1);
1130 }
1131
1132 #[test]
1133 fn test_add_empty_position() {
1134 let mut inv = Inventory::new();
1135 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1136
1137 assert!(inv.is_empty());
1138 assert_eq!(inv.len(), 0);
1139 }
1140
1141 #[test]
1142 fn test_compact() {
1143 let mut inv = Inventory::new();
1144
1145 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1146 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1147
1148 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1150 .unwrap();
1151
1152 inv.compact();
1154 assert!(inv.is_empty());
1155 assert_eq!(inv.len(), 0);
1156 }
1157
1158 #[test]
1159 fn test_booking_method_from_str() {
1160 assert_eq!(
1161 BookingMethod::from_str("STRICT").unwrap(),
1162 BookingMethod::Strict
1163 );
1164 assert_eq!(
1165 BookingMethod::from_str("fifo").unwrap(),
1166 BookingMethod::Fifo
1167 );
1168 assert_eq!(
1169 BookingMethod::from_str("LIFO").unwrap(),
1170 BookingMethod::Lifo
1171 );
1172 assert_eq!(
1173 BookingMethod::from_str("Hifo").unwrap(),
1174 BookingMethod::Hifo
1175 );
1176 assert_eq!(
1177 BookingMethod::from_str("AVERAGE").unwrap(),
1178 BookingMethod::Average
1179 );
1180 assert_eq!(
1181 BookingMethod::from_str("NONE").unwrap(),
1182 BookingMethod::None
1183 );
1184 assert_eq!(
1185 BookingMethod::from_str("strict_with_size").unwrap(),
1186 BookingMethod::StrictWithSize
1187 );
1188 assert!(BookingMethod::from_str("INVALID").is_err());
1189 }
1190
1191 #[test]
1192 fn test_booking_method_display() {
1193 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1194 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1195 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1196 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1197 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1198 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1199 assert_eq!(
1200 format!("{}", BookingMethod::StrictWithSize),
1201 "STRICT_WITH_SIZE"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_booking_error_display() {
1207 let err = BookingError::AmbiguousMatch {
1208 num_matches: 3,
1209 currency: "AAPL".into(),
1210 };
1211 assert!(format!("{err}").contains("3 lots match"));
1212
1213 let err = BookingError::NoMatchingLot {
1214 currency: "AAPL".into(),
1215 cost_spec: CostSpec::empty(),
1216 };
1217 assert!(format!("{err}").contains("No matching lot"));
1218
1219 let err = BookingError::InsufficientUnits {
1220 currency: "AAPL".into(),
1221 requested: dec!(100),
1222 available: dec!(50),
1223 };
1224 assert!(format!("{err}").contains("requested 100"));
1225 assert!(format!("{err}").contains("available 50"));
1226
1227 let err = BookingError::CurrencyMismatch {
1228 expected: "USD".into(),
1229 got: "EUR".into(),
1230 };
1231 assert!(format!("{err}").contains("expected USD"));
1232 assert!(format!("{err}").contains("got EUR"));
1233 }
1234
1235 #[test]
1236 fn test_book_value_multiple_currencies() {
1237 let mut inv = Inventory::new();
1238
1239 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1241 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1242
1243 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1245 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1246
1247 let book = inv.book_value("AAPL");
1248 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1249 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1250 }
1251
1252 #[test]
1253 fn test_reduce_hifo_insufficient_units() {
1254 let mut inv = Inventory::new();
1255
1256 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1257 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1258
1259 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1260
1261 assert!(matches!(
1262 result,
1263 Err(BookingError::InsufficientUnits { .. })
1264 ));
1265 }
1266
1267 #[test]
1268 fn test_reduce_average_insufficient_units() {
1269 let mut inv = Inventory::new();
1270
1271 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1272 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1273
1274 let result = inv.reduce(
1275 &Amount::new(dec!(-20), "AAPL"),
1276 None,
1277 BookingMethod::Average,
1278 );
1279
1280 assert!(matches!(
1281 result,
1282 Err(BookingError::InsufficientUnits { .. })
1283 ));
1284 }
1285
1286 #[test]
1287 fn test_reduce_average_empty_inventory() {
1288 let mut inv = Inventory::new();
1289
1290 let result = inv.reduce(
1291 &Amount::new(dec!(-10), "AAPL"),
1292 None,
1293 BookingMethod::Average,
1294 );
1295
1296 assert!(matches!(
1297 result,
1298 Err(BookingError::InsufficientUnits { .. })
1299 ));
1300 }
1301
1302 #[test]
1303 fn test_inventory_display_sorted() {
1304 let mut inv = Inventory::new();
1305
1306 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1308 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1309 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1310
1311 let display = format!("{inv}");
1312
1313 let aapl_pos = display.find("AAPL").unwrap();
1315 let eur_pos = display.find("EUR").unwrap();
1316 let usd_pos = display.find("USD").unwrap();
1317
1318 assert!(aapl_pos < eur_pos);
1319 assert!(eur_pos < usd_pos);
1320 }
1321
1322 #[test]
1323 fn test_inventory_with_cost_display_sorted() {
1324 let mut inv = Inventory::new();
1325
1326 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1328 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1329
1330 inv.add(Position::with_cost(
1331 Amount::new(dec!(10), "AAPL"),
1332 cost_high,
1333 ));
1334 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1335
1336 let display = format!("{inv}");
1337
1338 assert!(display.contains("AAPL"));
1340 assert!(display.contains("100"));
1341 assert!(display.contains("200"));
1342 }
1343
1344 #[test]
1345 fn test_reduce_hifo_no_matching_lot() {
1346 let mut inv = Inventory::new();
1347
1348 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1350
1351 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1352
1353 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1354 }
1355
1356 #[test]
1357 fn test_fifo_respects_dates() {
1358 let mut inv = Inventory::new();
1360
1361 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1363 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1364
1365 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1366 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1367
1368 let result = inv
1370 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1371 .unwrap();
1372
1373 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1375 }
1376
1377 #[test]
1378 fn test_lifo_respects_dates() {
1379 let mut inv = Inventory::new();
1381
1382 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1384 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1385
1386 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1387 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1388
1389 let result = inv
1391 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1392 .unwrap();
1393
1394 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1396 }
1397}