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}
188
189impl PartialEq for Inventory {
190 fn eq(&self, other: &Self) -> bool {
191 self.positions == other.positions
193 }
194}
195
196impl Eq for Inventory {}
197
198impl Inventory {
199 #[must_use]
201 pub fn new() -> Self {
202 Self::default()
203 }
204
205 #[must_use]
207 pub fn positions(&self) -> &[Position] {
208 &self.positions
209 }
210
211 pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
213 &mut self.positions
214 }
215
216 #[must_use]
218 pub fn is_empty(&self) -> bool {
219 self.positions.is_empty()
220 || self
221 .positions
222 .iter()
223 .all(super::position::Position::is_empty)
224 }
225
226 #[must_use]
228 pub fn len(&self) -> usize {
229 self.positions.len()
230 }
231
232 #[must_use]
236 pub fn units(&self, currency: &str) -> Decimal {
237 self.positions
238 .iter()
239 .filter(|p| p.units.currency == currency)
240 .map(|p| p.units.number)
241 .sum()
242 }
243
244 #[must_use]
246 pub fn currencies(&self) -> Vec<&str> {
247 let mut currencies: Vec<&str> = self
248 .positions
249 .iter()
250 .filter(|p| !p.is_empty())
251 .map(|p| p.units.currency.as_str())
252 .collect();
253 currencies.sort_unstable();
254 currencies.dedup();
255 currencies
256 }
257
258 #[must_use]
262 pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
263 let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
264
265 for pos in &self.positions {
266 if pos.units.currency == units_currency {
267 if let Some(book) = pos.book_value() {
268 *totals.entry(book.currency.clone()).or_default() += book.number;
269 }
270 }
271 }
272
273 totals
274 }
275
276 pub fn add(&mut self, position: Position) {
285 if position.is_empty() {
286 return;
287 }
288
289 if position.cost.is_none() {
291 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
292 debug_assert!(self.positions[idx].cost.is_none());
294 self.positions[idx].units += &position.units;
295 return;
296 }
297 let idx = self.positions.len();
299 self.simple_index
300 .insert(position.units.currency.clone(), idx);
301 self.positions.push(position);
302 return;
303 }
304
305 self.positions.push(position);
309 }
310
311 pub fn reduce(
324 &mut self,
325 units: &Amount,
326 cost_spec: Option<&CostSpec>,
327 method: BookingMethod,
328 ) -> Result<BookingResult, BookingError> {
329 let spec = cost_spec.cloned().unwrap_or_default();
330
331 match method {
332 BookingMethod::Strict => self.reduce_strict(units, &spec),
333 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
334 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
335 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
336 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
337 BookingMethod::Average => self.reduce_average(units),
338 BookingMethod::None => self.reduce_none(units),
339 }
340 }
341
342 pub fn compact(&mut self) {
344 self.positions.retain(|p| !p.is_empty());
345 self.rebuild_index();
346 }
347
348 fn rebuild_index(&mut self) {
351 self.simple_index.clear();
352 for (idx, pos) in self.positions.iter().enumerate() {
353 if pos.cost.is_none() {
354 debug_assert!(
355 !self.simple_index.contains_key(&pos.units.currency),
356 "Invariant violated: multiple simple positions for currency {}",
357 pos.units.currency
358 );
359 self.simple_index.insert(pos.units.currency.clone(), idx);
360 }
361 }
362 }
363
364 pub fn merge(&mut self, other: &Self) {
366 for pos in &other.positions {
367 self.add(pos.clone());
368 }
369 }
370
371 #[must_use]
376 pub fn at_cost(&self) -> Self {
377 let mut result = Self::new();
378
379 for pos in &self.positions {
380 if pos.is_empty() {
381 continue;
382 }
383
384 if let Some(cost) = &pos.cost {
385 let total = pos.units.number * cost.number;
387 result.add(Position::simple(Amount::new(total, &cost.currency)));
388 } else {
389 result.add(pos.clone());
391 }
392 }
393
394 result
395 }
396
397 #[must_use]
402 pub fn at_units(&self) -> Self {
403 let mut result = Self::new();
404
405 for pos in &self.positions {
406 if pos.is_empty() {
407 continue;
408 }
409
410 result.add(Position::simple(pos.units.clone()));
412 }
413
414 result
415 }
416}
417
418impl fmt::Display for Inventory {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 if self.is_empty() {
421 return write!(f, "(empty)");
422 }
423
424 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
426 non_empty.sort_by(|a, b| {
427 let cmp = a.units.currency.cmp(&b.units.currency);
429 if cmp != std::cmp::Ordering::Equal {
430 return cmp;
431 }
432 match (&a.cost, &b.cost) {
434 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
435 (Some(_), None) => std::cmp::Ordering::Greater,
436 (None, Some(_)) => std::cmp::Ordering::Less,
437 (None, None) => std::cmp::Ordering::Equal,
438 }
439 });
440
441 for (i, pos) in non_empty.iter().enumerate() {
442 if i > 0 {
443 write!(f, ", ")?;
444 }
445 write!(f, "{pos}")?;
446 }
447 Ok(())
448 }
449}
450
451impl FromIterator<Position> for Inventory {
452 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
453 let mut inv = Self::new();
454 for pos in iter {
455 inv.add(pos);
456 }
457 inv
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::Cost;
465 use chrono::NaiveDate;
466 use rust_decimal_macros::dec;
467
468 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
469 NaiveDate::from_ymd_opt(year, month, day).unwrap()
470 }
471
472 #[test]
473 fn test_empty_inventory() {
474 let inv = Inventory::new();
475 assert!(inv.is_empty());
476 assert_eq!(inv.len(), 0);
477 }
478
479 #[test]
480 fn test_add_simple() {
481 let mut inv = Inventory::new();
482 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
483
484 assert!(!inv.is_empty());
485 assert_eq!(inv.units("USD"), dec!(100));
486 }
487
488 #[test]
489 fn test_add_merge_simple() {
490 let mut inv = Inventory::new();
491 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
492 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
493
494 assert_eq!(inv.len(), 1);
496 assert_eq!(inv.units("USD"), dec!(150));
497 }
498
499 #[test]
500 fn test_add_with_cost_no_merge() {
501 let mut inv = Inventory::new();
502
503 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
504 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
505
506 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
507 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
508
509 assert_eq!(inv.len(), 2);
511 assert_eq!(inv.units("AAPL"), dec!(15));
512 }
513
514 #[test]
515 fn test_currencies() {
516 let mut inv = Inventory::new();
517 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
518 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
519 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
520
521 let currencies = inv.currencies();
522 assert_eq!(currencies.len(), 3);
523 assert!(currencies.contains(&"USD"));
524 assert!(currencies.contains(&"EUR"));
525 assert!(currencies.contains(&"AAPL"));
526 }
527
528 #[test]
529 fn test_reduce_strict_unique() {
530 let mut inv = Inventory::new();
531 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
532 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
533
534 let result = inv
535 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
536 .unwrap();
537
538 assert_eq!(inv.units("AAPL"), dec!(5));
539 assert!(result.cost_basis.is_some());
540 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
542
543 #[test]
544 fn test_reduce_strict_multiple_match_uses_fifo() {
545 let mut inv = Inventory::new();
546
547 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
548 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
549
550 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
551 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
552
553 let result = inv
556 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
557 .unwrap();
558
559 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
563
564 #[test]
565 fn test_reduce_strict_with_spec() {
566 let mut inv = Inventory::new();
567
568 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
569 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
570
571 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
572 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
573
574 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
576 let result = inv
577 .reduce(
578 &Amount::new(dec!(-3), "AAPL"),
579 Some(&spec),
580 BookingMethod::Strict,
581 )
582 .unwrap();
583
584 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
587
588 #[test]
589 fn test_reduce_fifo() {
590 let mut inv = Inventory::new();
591
592 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
593 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
594 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
595
596 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
597 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
598 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
599
600 let result = inv
602 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
603 .unwrap();
604
605 assert_eq!(inv.units("AAPL"), dec!(15));
606 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
608 }
609
610 #[test]
611 fn test_reduce_lifo() {
612 let mut inv = Inventory::new();
613
614 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
615 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
616 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
617
618 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
619 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
620 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
621
622 let result = inv
624 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
625 .unwrap();
626
627 assert_eq!(inv.units("AAPL"), dec!(15));
628 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
630 }
631
632 #[test]
633 fn test_reduce_insufficient() {
634 let mut inv = Inventory::new();
635 let cost = Cost::new(dec!(150.00), "USD");
636 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
637
638 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
639
640 assert!(matches!(
641 result,
642 Err(BookingError::InsufficientUnits { .. })
643 ));
644 }
645
646 #[test]
647 fn test_book_value() {
648 let mut inv = Inventory::new();
649
650 let cost1 = Cost::new(dec!(100.00), "USD");
651 let cost2 = Cost::new(dec!(150.00), "USD");
652
653 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
654 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
655
656 let book = inv.book_value("AAPL");
657 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
659
660 #[test]
661 fn test_display() {
662 let mut inv = Inventory::new();
663 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
664
665 let s = format!("{inv}");
666 assert!(s.contains("100 USD"));
667 }
668
669 #[test]
670 fn test_display_empty() {
671 let inv = Inventory::new();
672 assert_eq!(format!("{inv}"), "(empty)");
673 }
674
675 #[test]
676 fn test_from_iterator() {
677 let positions = vec![
678 Position::simple(Amount::new(dec!(100), "USD")),
679 Position::simple(Amount::new(dec!(50), "USD")),
680 ];
681
682 let inv: Inventory = positions.into_iter().collect();
683 assert_eq!(inv.units("USD"), dec!(150));
684 }
685
686 #[test]
687 fn test_add_costed_positions_kept_separate() {
688 let mut inv = Inventory::new();
691
692 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
693
694 inv.add(Position::with_cost(
696 Amount::new(dec!(10), "AAPL"),
697 cost.clone(),
698 ));
699 assert_eq!(inv.len(), 1);
700 assert_eq!(inv.units("AAPL"), dec!(10));
701
702 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
704 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
707
708 #[test]
709 fn test_add_costed_positions_net_units() {
710 let mut inv = Inventory::new();
712
713 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
714
715 inv.add(Position::with_cost(
717 Amount::new(dec!(10), "AAPL"),
718 cost.clone(),
719 ));
720
721 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
723 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
726
727 #[test]
728 fn test_add_no_cancel_different_cost() {
729 let mut inv = Inventory::new();
731
732 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
733 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
734
735 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
737
738 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
740
741 assert_eq!(inv.len(), 2);
743 assert_eq!(inv.units("AAPL"), dec!(5)); }
745
746 #[test]
747 fn test_add_no_cancel_same_sign() {
748 let mut inv = Inventory::new();
750
751 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
752
753 inv.add(Position::with_cost(
755 Amount::new(dec!(10), "AAPL"),
756 cost.clone(),
757 ));
758
759 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
761
762 assert_eq!(inv.len(), 2);
764 assert_eq!(inv.units("AAPL"), dec!(15));
765 }
766
767 #[test]
768 fn test_merge_keeps_lots_separate() {
769 let mut inv1 = Inventory::new();
771 let mut inv2 = Inventory::new();
772
773 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
774
775 inv1.add(Position::with_cost(
777 Amount::new(dec!(10), "AAPL"),
778 cost.clone(),
779 ));
780
781 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
783
784 inv1.merge(&inv2);
786 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
789
790 #[test]
795 fn test_hifo_with_tie_breaking() {
796 let mut inv = Inventory::new();
798
799 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
801 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
802 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
803
804 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
805 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
806 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
807
808 let result = inv
810 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
811 .unwrap();
812
813 assert_eq!(inv.units("AAPL"), dec!(15));
814 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
816 }
817
818 #[test]
819 fn test_hifo_with_different_costs() {
820 let mut inv = Inventory::new();
822
823 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
824 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
825 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
826
827 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
828 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
829 inv.add(Position::with_cost(
830 Amount::new(dec!(10), "AAPL"),
831 cost_high,
832 ));
833
834 let result = inv
836 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
837 .unwrap();
838
839 assert_eq!(inv.units("AAPL"), dec!(15));
840 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
842 }
843
844 #[test]
845 fn test_average_booking_with_pre_existing_positions() {
846 let mut inv = Inventory::new();
847
848 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
850 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
851
852 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
853 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
854
855 let result = inv
858 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
859 .unwrap();
860
861 assert_eq!(inv.units("AAPL"), dec!(15));
862 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
864 }
865
866 #[test]
867 fn test_average_booking_reduces_all() {
868 let mut inv = Inventory::new();
869
870 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
871 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
872
873 let result = inv
875 .reduce(
876 &Amount::new(dec!(-10), "AAPL"),
877 None,
878 BookingMethod::Average,
879 )
880 .unwrap();
881
882 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
883 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
884 }
885
886 #[test]
887 fn test_none_booking_augmentation() {
888 let mut inv = Inventory::new();
890 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
891
892 let result = inv
894 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
895 .unwrap();
896
897 assert_eq!(inv.units("USD"), dec!(150));
898 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
900 }
901
902 #[test]
903 fn test_none_booking_reduction() {
904 let mut inv = Inventory::new();
906 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
907
908 let result = inv
909 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
910 .unwrap();
911
912 assert_eq!(inv.units("USD"), dec!(70));
913 assert!(!result.matched.is_empty());
914 }
915
916 #[test]
917 fn test_none_booking_insufficient() {
918 let mut inv = Inventory::new();
919 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
920
921 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
922
923 assert!(matches!(
924 result,
925 Err(BookingError::InsufficientUnits { .. })
926 ));
927 }
928
929 #[test]
930 fn test_booking_error_no_matching_lot() {
931 let mut inv = Inventory::new();
932
933 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 wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
939 let result = inv.reduce(
940 &Amount::new(dec!(-5), "AAPL"),
941 Some(&wrong_spec),
942 BookingMethod::Strict,
943 );
944
945 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
946 }
947
948 #[test]
949 fn test_booking_error_insufficient_units() {
950 let mut inv = Inventory::new();
951
952 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
953 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
954
955 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
957
958 match result {
959 Err(BookingError::InsufficientUnits {
960 requested,
961 available,
962 ..
963 }) => {
964 assert_eq!(requested, dec!(20));
965 assert_eq!(available, dec!(10));
966 }
967 _ => panic!("Expected InsufficientUnits error"),
968 }
969 }
970
971 #[test]
972 fn test_strict_with_size_exact_match() {
973 let mut inv = Inventory::new();
974
975 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
977 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
978
979 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
980 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
981
982 let result = inv
984 .reduce(
985 &Amount::new(dec!(-5), "AAPL"),
986 None,
987 BookingMethod::StrictWithSize,
988 )
989 .unwrap();
990
991 assert_eq!(inv.units("AAPL"), dec!(10));
992 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
993 }
994
995 #[test]
996 fn test_strict_with_size_total_match() {
997 let mut inv = Inventory::new();
998
999 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1001 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1002
1003 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1004 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1005
1006 let result = inv
1008 .reduce(
1009 &Amount::new(dec!(-15), "AAPL"),
1010 None,
1011 BookingMethod::StrictWithSize,
1012 )
1013 .unwrap();
1014
1015 assert_eq!(inv.units("AAPL"), dec!(0));
1016 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1017 }
1018
1019 #[test]
1020 fn test_strict_with_size_ambiguous() {
1021 let mut inv = Inventory::new();
1022
1023 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1025 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1026
1027 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1028 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1029
1030 let result = inv.reduce(
1032 &Amount::new(dec!(-7), "AAPL"),
1033 None,
1034 BookingMethod::StrictWithSize,
1035 );
1036
1037 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1038 }
1039
1040 #[test]
1041 fn test_short_position() {
1042 let mut inv = Inventory::new();
1044
1045 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1047 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1048
1049 assert_eq!(inv.units("AAPL"), dec!(-10));
1050 assert!(!inv.is_empty());
1051 }
1052
1053 #[test]
1054 fn test_at_cost() {
1055 let mut inv = Inventory::new();
1056
1057 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1058 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1059
1060 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1061 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1062 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1063
1064 let at_cost = inv.at_cost();
1065
1066 assert_eq!(at_cost.units("USD"), dec!(1850));
1069 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1071
1072 #[test]
1073 fn test_at_units() {
1074 let mut inv = Inventory::new();
1075
1076 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1077 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1078
1079 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1080 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1081
1082 let at_units = inv.at_units();
1083
1084 assert_eq!(at_units.units("AAPL"), dec!(15));
1086 assert_eq!(at_units.len(), 1);
1088 }
1089
1090 #[test]
1091 fn test_add_empty_position() {
1092 let mut inv = Inventory::new();
1093 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1094
1095 assert!(inv.is_empty());
1096 assert_eq!(inv.len(), 0);
1097 }
1098
1099 #[test]
1100 fn test_compact() {
1101 let mut inv = Inventory::new();
1102
1103 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1104 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1105
1106 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1108 .unwrap();
1109
1110 inv.compact();
1112 assert!(inv.is_empty());
1113 assert_eq!(inv.len(), 0);
1114 }
1115
1116 #[test]
1117 fn test_booking_method_from_str() {
1118 assert_eq!(
1119 BookingMethod::from_str("STRICT").unwrap(),
1120 BookingMethod::Strict
1121 );
1122 assert_eq!(
1123 BookingMethod::from_str("fifo").unwrap(),
1124 BookingMethod::Fifo
1125 );
1126 assert_eq!(
1127 BookingMethod::from_str("LIFO").unwrap(),
1128 BookingMethod::Lifo
1129 );
1130 assert_eq!(
1131 BookingMethod::from_str("Hifo").unwrap(),
1132 BookingMethod::Hifo
1133 );
1134 assert_eq!(
1135 BookingMethod::from_str("AVERAGE").unwrap(),
1136 BookingMethod::Average
1137 );
1138 assert_eq!(
1139 BookingMethod::from_str("NONE").unwrap(),
1140 BookingMethod::None
1141 );
1142 assert_eq!(
1143 BookingMethod::from_str("strict_with_size").unwrap(),
1144 BookingMethod::StrictWithSize
1145 );
1146 assert!(BookingMethod::from_str("INVALID").is_err());
1147 }
1148
1149 #[test]
1150 fn test_booking_method_display() {
1151 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1152 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1153 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1154 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1155 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1156 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1157 assert_eq!(
1158 format!("{}", BookingMethod::StrictWithSize),
1159 "STRICT_WITH_SIZE"
1160 );
1161 }
1162
1163 #[test]
1164 fn test_booking_error_display() {
1165 let err = BookingError::AmbiguousMatch {
1166 num_matches: 3,
1167 currency: "AAPL".into(),
1168 };
1169 assert!(format!("{err}").contains("3 lots match"));
1170
1171 let err = BookingError::NoMatchingLot {
1172 currency: "AAPL".into(),
1173 cost_spec: CostSpec::empty(),
1174 };
1175 assert!(format!("{err}").contains("No matching lot"));
1176
1177 let err = BookingError::InsufficientUnits {
1178 currency: "AAPL".into(),
1179 requested: dec!(100),
1180 available: dec!(50),
1181 };
1182 assert!(format!("{err}").contains("requested 100"));
1183 assert!(format!("{err}").contains("available 50"));
1184
1185 let err = BookingError::CurrencyMismatch {
1186 expected: "USD".into(),
1187 got: "EUR".into(),
1188 };
1189 assert!(format!("{err}").contains("expected USD"));
1190 assert!(format!("{err}").contains("got EUR"));
1191 }
1192
1193 #[test]
1194 fn test_book_value_multiple_currencies() {
1195 let mut inv = Inventory::new();
1196
1197 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1199 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1200
1201 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1203 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1204
1205 let book = inv.book_value("AAPL");
1206 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1207 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1208 }
1209
1210 #[test]
1211 fn test_reduce_hifo_insufficient_units() {
1212 let mut inv = Inventory::new();
1213
1214 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1215 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1216
1217 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1218
1219 assert!(matches!(
1220 result,
1221 Err(BookingError::InsufficientUnits { .. })
1222 ));
1223 }
1224
1225 #[test]
1226 fn test_reduce_average_insufficient_units() {
1227 let mut inv = Inventory::new();
1228
1229 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1230 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1231
1232 let result = inv.reduce(
1233 &Amount::new(dec!(-20), "AAPL"),
1234 None,
1235 BookingMethod::Average,
1236 );
1237
1238 assert!(matches!(
1239 result,
1240 Err(BookingError::InsufficientUnits { .. })
1241 ));
1242 }
1243
1244 #[test]
1245 fn test_reduce_average_empty_inventory() {
1246 let mut inv = Inventory::new();
1247
1248 let result = inv.reduce(
1249 &Amount::new(dec!(-10), "AAPL"),
1250 None,
1251 BookingMethod::Average,
1252 );
1253
1254 assert!(matches!(
1255 result,
1256 Err(BookingError::InsufficientUnits { .. })
1257 ));
1258 }
1259
1260 #[test]
1261 fn test_inventory_display_sorted() {
1262 let mut inv = Inventory::new();
1263
1264 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1266 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1267 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1268
1269 let display = format!("{inv}");
1270
1271 let aapl_pos = display.find("AAPL").unwrap();
1273 let eur_pos = display.find("EUR").unwrap();
1274 let usd_pos = display.find("USD").unwrap();
1275
1276 assert!(aapl_pos < eur_pos);
1277 assert!(eur_pos < usd_pos);
1278 }
1279
1280 #[test]
1281 fn test_inventory_with_cost_display_sorted() {
1282 let mut inv = Inventory::new();
1283
1284 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1286 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1287
1288 inv.add(Position::with_cost(
1289 Amount::new(dec!(10), "AAPL"),
1290 cost_high,
1291 ));
1292 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1293
1294 let display = format!("{inv}");
1295
1296 assert!(display.contains("AAPL"));
1298 assert!(display.contains("100"));
1299 assert!(display.contains("200"));
1300 }
1301
1302 #[test]
1303 fn test_reduce_hifo_no_matching_lot() {
1304 let mut inv = Inventory::new();
1305
1306 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1308
1309 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1310
1311 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1312 }
1313
1314 #[test]
1315 fn test_fifo_respects_dates() {
1316 let mut inv = Inventory::new();
1318
1319 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1321 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1322
1323 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1324 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1325
1326 let result = inv
1328 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1329 .unwrap();
1330
1331 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1333 }
1334
1335 #[test]
1336 fn test_lifo_respects_dates() {
1337 let mut inv = Inventory::new();
1339
1340 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1342 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1343
1344 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1345 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1346
1347 let result = inv
1349 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1350 .unwrap();
1351
1352 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1354 }
1355}