1use rust_decimal::Decimal;
8use rust_decimal::prelude::Signed;
9
10use smallvec::{SmallVec, smallvec};
11
12use super::{BookingError, BookingMethod, BookingResult, Inventory, MatchedLots};
13use crate::{Amount, Cost, CostSpec, Currency, Position};
14
15fn average_cost_from_positions(
20 positions: &[&Position],
21 total_units: Decimal,
22) -> Result<Option<(Decimal, Currency)>, BookingError> {
23 let mut total_cost = Decimal::ZERO;
24 let mut cost_currency: Option<Currency> = None;
25 let mut has_any_cost = false;
26
27 for pos in positions {
28 if let Some(cost) = &pos.cost {
29 has_any_cost = true;
30 if let Some(ref cc) = cost_currency {
31 if *cc != cost.currency {
32 return Err(BookingError::CurrencyMismatch {
33 expected: cc.clone(),
34 got: cost.currency.clone(),
35 });
36 }
37 } else {
38 cost_currency = Some(cost.currency.clone());
39 }
40 total_cost += pos.units.number * cost.number;
41 }
42 }
43
44 if !has_any_cost || cost_currency.is_none() {
45 return Ok(None);
46 }
47
48 Ok(Some((total_cost / total_units, cost_currency.unwrap())))
49}
50
51impl Inventory {
52 pub fn try_reduce(
78 &self,
79 units: &Amount,
80 cost_spec: Option<&CostSpec>,
81 method: BookingMethod,
82 ) -> Result<BookingResult, BookingError> {
83 self.clone().reduce(units, cost_spec, method)
84 }
85
86 pub(super) fn reduce_strict(
120 &mut self,
121 units: &Amount,
122 spec: &CostSpec,
123 ) -> Result<BookingResult, BookingError> {
124 let matching_indices: Vec<usize> = self
125 .positions
126 .iter()
127 .enumerate()
128 .filter(|(_, p)| {
129 p.units.currency == units.currency
130 && !p.is_empty()
131 && p.can_reduce(units)
132 && p.matches_cost_spec(spec)
133 })
134 .map(|(i, _)| i)
135 .collect();
136
137 match matching_indices.len() {
138 0 => Err(BookingError::NoMatchingLot {
139 currency: units.currency.clone(),
140 cost_spec: spec.clone(),
141 }),
142 1 => {
143 let idx = matching_indices[0];
144 self.reduce_from_lot(idx, units)
145 }
146 n => {
147 let first_key = self.positions[matching_indices[0]]
154 .cost
155 .as_ref()
156 .map(|c| (c.number, c.currency.clone()));
157 let all_same_value = matching_indices.iter().skip(1).all(|&i| {
158 let key = self.positions[i]
159 .cost
160 .as_ref()
161 .map(|c| (c.number, c.currency.clone()));
162 key == first_key
163 });
164
165 if all_same_value {
166 return self.reduce_ordered(units, spec, false);
167 }
168
169 let total_units: Decimal = matching_indices
173 .iter()
174 .map(|&i| self.positions[i].units.number.abs())
175 .sum();
176 if total_units == units.number.abs() {
177 return self.reduce_ordered(units, spec, false);
178 }
179
180 Err(BookingError::AmbiguousMatch {
181 num_matches: n,
182 currency: units.currency.clone(),
183 })
184 }
185 }
186 }
187
188 pub(super) fn reduce_strict_with_size(
190 &mut self,
191 units: &Amount,
192 spec: &CostSpec,
193 ) -> Result<BookingResult, BookingError> {
194 let matching_indices: Vec<usize> = self
195 .positions
196 .iter()
197 .enumerate()
198 .filter(|(_, p)| {
199 p.units.currency == units.currency
200 && !p.is_empty()
201 && p.can_reduce(units)
202 && p.matches_cost_spec(spec)
203 })
204 .map(|(i, _)| i)
205 .collect();
206
207 match matching_indices.len() {
208 0 => Err(BookingError::NoMatchingLot {
209 currency: units.currency.clone(),
210 cost_spec: spec.clone(),
211 }),
212 1 => {
213 let idx = matching_indices[0];
214 self.reduce_from_lot(idx, units)
215 }
216 n => {
217 let exact_matches: Vec<usize> = matching_indices
219 .iter()
220 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
221 .copied()
222 .collect();
223
224 if exact_matches.is_empty() {
225 let total_units: Decimal = matching_indices
227 .iter()
228 .map(|&i| self.positions[i].units.number.abs())
229 .sum();
230 if total_units == units.number.abs() {
231 self.reduce_ordered(units, spec, false)
232 } else {
233 Err(BookingError::AmbiguousMatch {
234 num_matches: n,
235 currency: units.currency.clone(),
236 })
237 }
238 } else {
239 let idx = exact_matches[0];
241 self.reduce_from_lot(idx, units)
242 }
243 }
244 }
245 }
246
247 pub(super) fn reduce_fifo(
249 &mut self,
250 units: &Amount,
251 spec: &CostSpec,
252 ) -> Result<BookingResult, BookingError> {
253 self.reduce_ordered(units, spec, false)
254 }
255
256 pub(super) fn reduce_lifo(
258 &mut self,
259 units: &Amount,
260 spec: &CostSpec,
261 ) -> Result<BookingResult, BookingError> {
262 self.reduce_ordered(units, spec, true)
263 }
264
265 pub(super) fn reduce_hifo(
267 &mut self,
268 units: &Amount,
269 spec: &CostSpec,
270 ) -> Result<BookingResult, BookingError> {
271 let mut remaining = units.number.abs();
272 let mut matched: MatchedLots = SmallVec::new();
273 let mut cost_basis = Decimal::ZERO;
274 let mut cost_currency = None;
275
276 let mut matching: Vec<(usize, Decimal)> = self
278 .positions
279 .iter()
280 .enumerate()
281 .filter(|(_, p)| {
282 p.units.currency == units.currency
283 && !p.is_empty()
284 && p.units.number.signum() != units.number.signum()
285 && p.matches_cost_spec(spec)
286 })
287 .map(|(i, p)| {
288 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
289 (i, cost)
290 })
291 .collect();
292
293 if matching.is_empty() {
294 return Err(BookingError::NoMatchingLot {
295 currency: units.currency.clone(),
296 cost_spec: spec.clone(),
297 });
298 }
299
300 matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
302
303 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
304
305 let available: Decimal = indices
309 .iter()
310 .map(|&i| self.positions[i].units.number.abs())
311 .sum();
312 if available < remaining {
313 return Err(BookingError::InsufficientUnits {
314 currency: units.currency.clone(),
315 requested: remaining,
316 available,
317 });
318 }
319
320 for idx in indices {
321 if remaining.is_zero() {
322 break;
323 }
324
325 let pos = &self.positions[idx];
326 let available = pos.units.number.abs();
327 let take = remaining.min(available);
328
329 if let Some(cost) = &pos.cost {
331 cost_basis += take * cost.number;
332 cost_currency = Some(cost.currency.clone());
333 }
334
335 let (taken, _) = pos.split(take * pos.units.number.signum());
337 matched.push(taken);
338
339 let reduction = if units.number.is_sign_negative() {
341 -take
342 } else {
343 take
344 };
345
346 let new_pos = Position {
347 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
348 cost: pos.cost.clone(),
349 };
350 self.positions[idx] = new_pos;
351
352 remaining -= take;
353 }
354
355 self.positions.retain(|p| !p.is_empty());
357 self.rebuild_index();
358
359 Ok(BookingResult {
360 matched,
361 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
362 })
363 }
364
365 pub(super) fn reduce_ordered(
367 &mut self,
368 units: &Amount,
369 spec: &CostSpec,
370 reverse: bool,
371 ) -> Result<BookingResult, BookingError> {
372 let mut remaining = units.number.abs();
373 let mut matched: MatchedLots = SmallVec::new();
374 let mut cost_basis = Decimal::ZERO;
375 let mut cost_currency = None;
376
377 let mut indices: Vec<usize> = self
379 .positions
380 .iter()
381 .enumerate()
382 .filter(|(_, p)| {
383 p.units.currency == units.currency
384 && !p.is_empty()
385 && p.units.number.signum() != units.number.signum()
386 && p.matches_cost_spec(spec)
387 })
388 .map(|(i, _)| i)
389 .collect();
390
391 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
394
395 if reverse {
396 indices.reverse();
397 }
398
399 if indices.is_empty() {
400 return Err(BookingError::NoMatchingLot {
401 currency: units.currency.clone(),
402 cost_spec: spec.clone(),
403 });
404 }
405
406 if let Some(&first_idx) = indices.first()
408 && let Some(cost) = &self.positions[first_idx].cost
409 {
410 cost_currency = Some(cost.currency.clone());
411 }
412
413 let available: Decimal = indices
420 .iter()
421 .map(|&i| self.positions[i].units.number.abs())
422 .sum();
423 if available < remaining {
424 return Err(BookingError::InsufficientUnits {
425 currency: units.currency.clone(),
426 requested: remaining,
427 available,
428 });
429 }
430
431 for idx in indices {
432 if remaining.is_zero() {
433 break;
434 }
435
436 let pos = &mut self.positions[idx];
437 let available = pos.units.number.abs();
438 let take = remaining.min(available);
439
440 if let Some(cost) = &pos.cost {
442 cost_basis += take * cost.number;
443 }
444
445 let (taken, _) = pos.split(take * pos.units.number.signum());
447 matched.push(taken);
448
449 let reduction = if units.number.is_sign_negative() {
451 -take
452 } else {
453 take
454 };
455 pos.units.number += reduction;
456
457 remaining -= take;
458 }
459
460 self.positions.retain(|p| !p.is_empty());
462 self.rebuild_index();
463
464 Ok(BookingResult {
465 matched,
466 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
467 })
468 }
469
470 pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
472 let matching: Vec<&Position> = self
473 .positions
474 .iter()
475 .filter(|p| p.units.currency == units.currency && !p.is_empty())
476 .collect();
477
478 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
479
480 if total_units.is_zero() {
481 return Err(BookingError::InsufficientUnits {
482 currency: units.currency.clone(),
483 requested: units.number.abs(),
484 available: Decimal::ZERO,
485 });
486 }
487
488 let reduction = units.number.abs();
489 if reduction > total_units.abs() {
490 return Err(BookingError::InsufficientUnits {
491 currency: units.currency.clone(),
492 requested: reduction,
493 available: total_units.abs(),
494 });
495 }
496
497 let avg = average_cost_from_positions(&matching, total_units)?;
498 let cost_basis = avg
499 .as_ref()
500 .map(|(avg_cost, currency)| Amount::new(reduction * *avg_cost, currency.clone()));
501
502 let at_avg_cost = |number: Decimal| -> Position {
505 let amount = Amount::new(number, units.currency.clone());
506 match &avg {
507 Some((avg_cost, currency)) => {
508 Position::with_cost(amount, Cost::new(*avg_cost, currency.clone()))
509 }
510 None => Position::simple(amount),
511 }
512 };
513
514 let matched: MatchedLots = smallvec![at_avg_cost(reduction * total_units.signum())];
522
523 let new_units = total_units + units.number;
524
525 self.positions
527 .retain(|p| p.units.currency != units.currency);
528
529 if !new_units.is_zero() {
532 self.positions.push_back(at_avg_cost(new_units));
533 }
534
535 self.rebuild_index();
536
537 Ok(BookingResult {
538 matched,
539 cost_basis,
540 })
541 }
542
543 pub fn merge_average(&mut self) {
552 let currencies: std::collections::BTreeSet<Currency> = self
553 .positions
554 .iter()
555 .filter(|p| p.cost.is_some())
556 .map(|p| p.units.currency.clone())
557 .collect();
558
559 for currency in currencies {
560 let (total_units, avg) = {
561 let matching: Vec<&Position> = self
562 .positions
563 .iter()
564 .filter(|p| p.units.currency == currency && p.cost.is_some())
565 .collect();
566 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
567 let avg = if total_units.is_zero() {
568 None
569 } else {
570 average_cost_from_positions(&matching, total_units)
571 .ok()
572 .flatten()
573 };
574 (total_units, avg)
575 };
576
577 if !total_units.is_zero() && avg.is_none() {
580 continue;
581 }
582
583 self.positions
584 .retain(|p| !(p.units.currency == currency && p.cost.is_some()));
585 if let Some((avg_cost, cost_currency)) = avg {
586 self.positions.push_back(Position::with_cost(
587 Amount::new(total_units, currency.clone()),
588 Cost::new(avg_cost, cost_currency),
589 ));
590 }
591 }
592 self.rebuild_index();
593 }
594
595 pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
601 let matching: Vec<(usize, &Position)> = self
604 .positions
605 .iter()
606 .enumerate()
607 .filter(|(_, p)| {
608 p.units.currency == units.currency
609 && !p.is_empty()
610 && p.units.number.is_sign_positive() != units.number.is_sign_positive()
611 })
612 .collect();
613
614 if matching.is_empty() {
615 return Err(BookingError::InsufficientUnits {
616 currency: units.currency.clone(),
617 requested: units.number.abs(),
618 available: Decimal::ZERO,
619 });
620 }
621
622 let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
623 let reduction = units.number.abs();
624
625 if reduction > total_units.abs() {
626 return Err(BookingError::InsufficientUnits {
627 currency: units.currency.clone(),
628 requested: reduction,
629 available: total_units.abs(),
630 });
631 }
632
633 let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
635 let (avg_cost, cost_currency) =
636 match average_cost_from_positions(&matching_refs, total_units)? {
637 Some(result) => result,
638 None => return self.reduce_average(units),
639 };
640
641 let cost_basis = Some(Amount::new(reduction * avg_cost, cost_currency.clone()));
642
643 let make_avg_cost = || Cost {
647 number: avg_cost,
648 currency: cost_currency.clone(),
649 date: None,
650 label: None,
651 };
652
653 let matched: MatchedLots = smallvec![Position::with_cost(
654 Amount::new(units.number.abs(), units.currency.clone()),
655 make_avg_cost(),
656 )];
657
658 let matching_indices: std::collections::HashSet<usize> =
660 matching.iter().map(|(i, _)| *i).collect();
661 let mut idx = 0;
662 self.positions.retain(|_| {
663 let keep = !matching_indices.contains(&idx);
664 idx += 1;
665 keep
666 });
667
668 let remaining = total_units + units.number; if !remaining.is_zero() {
671 self.positions.push_back(Position::with_cost(
672 Amount::new(remaining, units.currency.clone()),
673 make_avg_cost(),
674 ));
675 }
676
677 self.rebuild_index();
678
679 Ok(BookingResult {
680 matched,
681 cost_basis,
682 })
683 }
684
685 pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
687 let total_units = self.units(&units.currency);
689
690 if total_units.signum() == units.number.signum() || total_units.is_zero() {
692 self.add(Position::simple(units.clone()));
694 return Ok(BookingResult {
695 matched: SmallVec::new(),
696 cost_basis: None,
697 });
698 }
699
700 let available = total_units.abs();
701 let requested = units.number.abs();
702
703 if requested > available {
704 let sign = units.number.signum();
713 let consumed = Amount::new(available * sign, units.currency.clone());
714 let result = self.reduce_ordered(&consumed, &CostSpec::default(), false)?;
715 self.add(Position::simple(Amount::new(
716 (requested - available) * sign,
717 units.currency.clone(),
718 )));
719 return Ok(result);
720 }
721
722 self.reduce_ordered(units, &CostSpec::default(), false)
724 }
725
726 pub(super) fn reduce_from_lot(
728 &mut self,
729 idx: usize,
730 units: &Amount,
731 ) -> Result<BookingResult, BookingError> {
732 let pos = &self.positions[idx];
733 let available = pos.units.number.abs();
734 let requested = units.number.abs();
735
736 if requested > available {
737 return Err(BookingError::InsufficientUnits {
738 currency: units.currency.clone(),
739 requested,
740 available,
741 });
742 }
743
744 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
746
747 let (matched, _) = pos.split(requested * pos.units.number.signum());
749
750 let currency = pos.units.currency.clone();
752 let new_units = pos.units.number + units.number;
753 let new_pos = Position {
754 units: Amount::new(new_units, currency.clone()),
755 cost: pos.cost.clone(),
756 };
757 self.positions[idx] = new_pos;
758
759 if let Some(cached) = self.units_cache.get_mut(¤cy) {
761 *cached += units.number;
762 }
763
764 if self.positions[idx].is_empty() {
766 self.positions.remove(idx);
767 self.simple_index.clear();
769 for (i, p) in self.positions.iter().enumerate() {
770 if p.cost.is_none() {
771 self.simple_index.insert(p.units.currency.clone(), i);
772 }
773 }
774 }
775
776 Ok(BookingResult {
777 matched: smallvec![matched],
778 cost_basis,
779 })
780 }
781}
782
783#[cfg(test)]
784mod reduction_tests {
785 use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
792 use rust_decimal::Decimal;
793 use rust_decimal_macros::dec;
794
795 fn d(n: i64) -> Decimal {
796 Decimal::from(n)
797 }
798
799 fn lot(units: i64, cost: i64, day: u32) -> Position {
801 Position::with_cost(
802 Amount::new(d(units), "STK"),
803 Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
804 )
805 }
806
807 fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
808 let mut i = Inventory::new();
809 for l in lots {
810 i.add(l);
811 }
812 i
813 }
814
815 fn sell_stk(n: i64) -> Amount {
816 Amount::new(d(-n), "STK")
817 }
818
819 fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
820 inv.try_reduce(units, Some(&CostSpec::default()), method)
821 .expect("reduction should succeed")
822 }
823
824 fn basis(r: &super::BookingResult) -> Decimal {
825 r.cost_basis.as_ref().expect("cost basis present").number
826 }
827
828 #[test]
831 fn fifo_partial_multilot_cost_basis_and_order() {
832 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
834 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
835 assert_eq!(basis(&r), dec!(2000));
837 assert_eq!(r.matched.len(), 2);
838 assert_eq!(r.matched[0].units.number.abs(), dec!(10));
839 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
840 assert_eq!(r.matched[1].units.number.abs(), dec!(5));
841 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
842 }
843
844 #[test]
845 fn lifo_takes_newest_lot_first() {
846 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
847 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
848 assert_eq!(basis(&r), dec!(2500));
851 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
852 }
853
854 #[test]
855 fn fifo_single_lot_partial_cost_basis() {
856 let inv = mk([lot(10, 100, 1)]);
857 let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
858 assert_eq!(basis(&r), dec!(300)); }
860
861 #[test]
864 fn hifo_takes_highest_cost_lot_first() {
865 let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
867 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
868 assert_eq!(basis(&r), dec!(4000));
870 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
871 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
872 }
873
874 #[test]
877 fn average_cost_basis_partial() {
878 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
880 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
881 assert_eq!(basis(&r), dec!(3500)); }
883
884 #[test]
885 fn average_reduce_exact_total_succeeds() {
886 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
889 let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
890 assert_eq!(basis(&r), dec!(7000)); }
892
893 #[test]
894 fn average_over_reduction_errors() {
895 let inv = mk([lot(10, 100, 1)]);
897 let err = inv
898 .try_reduce(
899 &sell_stk(20),
900 Some(&CostSpec::default()),
901 BookingMethod::Average,
902 )
903 .unwrap_err();
904 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
905 }
906
907 fn isolation_inv() -> Inventory {
917 let mut i = Inventory::new();
918 i.add(Position::with_cost(
919 Amount::new(dec!(10), "OTH"), Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
921 ));
922 i.add(lot(10, 100, 2)); i
924 }
925
926 fn assert_isolated(method: BookingMethod) {
927 let inv = isolation_inv();
928 let r = try_reduce(&inv, &sell_stk(5), method);
929 assert_eq!(
930 basis(&r),
931 dec!(500),
932 "must reduce only the real STK lot (5 * 100)"
933 );
934 assert!(
935 r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
936 "no non-STK lot should be matched"
937 );
938 }
939
940 #[test]
941 fn fifo_filters_currency() {
942 assert_isolated(BookingMethod::Fifo);
943 }
944
945 #[test]
946 fn hifo_filters_currency() {
947 assert_isolated(BookingMethod::Hifo);
948 }
949
950 #[test]
951 fn strict_filters_currency() {
952 assert_isolated(BookingMethod::Strict);
953 }
954
955 #[test]
956 fn average_filters_currency() {
957 let inv = isolation_inv();
959 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
960 assert_eq!(basis(&r), dec!(500));
962 }
963
964 #[test]
967 fn does_not_match_same_sign_lot() {
968 let mut i = Inventory::new();
973 i.add(lot(-10, 50, 1)); i.add(lot(10, 100, 2)); let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
976 assert_eq!(basis(&r), dec!(500)); assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
978 }
979
980 #[test]
981 fn strict_rejects_when_only_same_sign_lot_present() {
982 let mut i = Inventory::new();
991 i.add(lot(-10, 100, 1)); let res = i.try_reduce(
993 &sell_stk(5),
994 Some(&CostSpec::default()),
995 BookingMethod::Strict,
996 );
997 assert!(
998 matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
999 "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1000 );
1001 }
1002
1003 #[test]
1006 fn fifo_insufficient_reports_available() {
1007 let inv = mk([lot(10, 100, 1)]);
1010 let err = inv
1011 .try_reduce(
1012 &sell_stk(15),
1013 Some(&CostSpec::default()),
1014 BookingMethod::Fifo,
1015 )
1016 .unwrap_err();
1017 match err {
1018 super::BookingError::InsufficientUnits {
1019 requested,
1020 available,
1021 ..
1022 } => {
1023 assert_eq!(requested, dec!(15));
1024 assert_eq!(available, dec!(10)); }
1026 other => panic!("expected InsufficientUnits, got {other:?}"),
1027 }
1028 }
1029
1030 #[test]
1033 fn strict_single_lot_partial_cost_basis() {
1034 let inv = mk([lot(10, 100, 1)]);
1036 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1037 assert_eq!(basis(&r), dec!(400)); }
1039
1040 #[test]
1041 fn strict_single_lot_over_reduction_errors() {
1042 let inv = mk([lot(10, 100, 1)]);
1044 let err = inv
1045 .try_reduce(
1046 &sell_stk(11),
1047 Some(&CostSpec::default()),
1048 BookingMethod::Strict,
1049 )
1050 .unwrap_err();
1051 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1052 }
1053
1054 #[test]
1055 fn strict_single_lot_exact_full_reduction_succeeds() {
1056 let inv = mk([lot(10, 100, 1)]);
1058 let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1059 assert_eq!(basis(&r), dec!(1000));
1060 }
1061
1062 #[test]
1065 fn hifo_matched_units_and_insufficient_available() {
1066 let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1067 let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1068 assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1070 let err = inv
1071 .try_reduce(
1072 &sell_stk(25),
1073 Some(&CostSpec::default()),
1074 BookingMethod::Hifo,
1075 )
1076 .unwrap_err();
1077 match err {
1078 super::BookingError::InsufficientUnits { available, .. } => {
1079 assert_eq!(available, dec!(20)); }
1081 other => panic!("expected InsufficientUnits, got {other:?}"),
1082 }
1083 }
1084
1085 #[test]
1086 fn strict_from_lot_matched_units() {
1087 let inv = mk([lot(10, 100, 1)]);
1088 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1089 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); }
1091
1092 #[test]
1095 fn strict_with_size_picks_exact_size_lot() {
1096 let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1097 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1098 assert_eq!(basis(&r), dec!(1000)); }
1100
1101 #[test]
1102 fn strict_with_size_ambiguous_without_exact_or_total() {
1103 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1104 let err = inv
1105 .try_reduce(
1106 &sell_stk(5),
1107 Some(&CostSpec::default()),
1108 BookingMethod::StrictWithSize,
1109 )
1110 .unwrap_err();
1111 assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
1112 }
1113
1114 #[test]
1115 fn strict_with_size_total_match_falls_back_to_fifo() {
1116 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1117 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
1118 assert_eq!(basis(&r), dec!(3000)); }
1120
1121 #[test]
1124 fn reduce_fifo_commits_and_basis() {
1125 let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1126 let r = inv
1127 .reduce(
1128 &sell_stk(15),
1129 Some(&CostSpec::default()),
1130 BookingMethod::Fifo,
1131 )
1132 .unwrap();
1133 assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
1134 assert_eq!(inv.units("STK"), dec!(5)); }
1136
1137 #[test]
1138 fn reduce_on_large_shared_inventory_does_not_corrupt() {
1139 let mut inv = mk((0i64..100).map(|i| lot(10, 100 + i, ((i % 28) + 1) as u32)));
1152 let snapshot = inv.clone(); inv.reduce(
1154 &sell_stk(700),
1155 Some(&CostSpec::default()),
1156 BookingMethod::Fifo,
1157 )
1158 .unwrap();
1159 assert_eq!(inv.units("STK"), dec!(300)); assert_eq!(snapshot.units("STK"), dec!(1000));
1163 }
1164
1165 #[test]
1166 fn reduce_hifo_commits_basis_units_insufficient() {
1167 let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1168 let r = inv
1169 .reduce(
1170 &sell_stk(15),
1171 Some(&CostSpec::default()),
1172 BookingMethod::Hifo,
1173 )
1174 .unwrap();
1175 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); assert_eq!(r.matched[0].units.number.abs(), dec!(10)); let mut inv2 = mk([lot(10, 100, 1)]);
1178 let err = inv2
1179 .reduce(
1180 &sell_stk(25),
1181 Some(&CostSpec::default()),
1182 BookingMethod::Hifo,
1183 )
1184 .unwrap_err();
1185 match err {
1186 super::BookingError::InsufficientUnits { available, .. } => {
1187 assert_eq!(available, dec!(10));
1188 }
1189 other => panic!("expected InsufficientUnits, got {other:?}"),
1190 }
1191 }
1192
1193 #[test]
1194 fn reduce_average_only_matching_currency() {
1195 let mut i = Inventory::new();
1196 i.add(lot(10, 100, 2));
1197 i.add(Position::with_cost(
1198 Amount::new(dec!(10), "OTH"),
1199 Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1200 ));
1201 let r = i
1202 .reduce(
1203 &sell_stk(5),
1204 Some(&CostSpec::default()),
1205 BookingMethod::Average,
1206 )
1207 .unwrap();
1208 assert_eq!(r.cost_basis.unwrap().number, dec!(500)); }
1210
1211 #[test]
1212 fn reduce_average_partial_multi_lot_matches_single_synthetic_lot() {
1213 let mut i = Inventory::new();
1219 i.add(lot(10, 150, 1));
1220 i.add(lot(10, 170, 2));
1221 let r = i
1222 .reduce(
1223 &sell_stk(5),
1224 Some(&CostSpec::default()),
1225 BookingMethod::Average,
1226 )
1227 .unwrap();
1228
1229 assert_eq!(r.matched.len(), 1);
1232 assert_eq!(r.cost_basis.as_ref().unwrap().number, dec!(800));
1233 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(160));
1234 assert_eq!(r.matched[0].units.number, dec!(5));
1235
1236 assert_eq!(i.units("STK"), dec!(15));
1238 let remaining: Vec<&Position> = i
1239 .positions()
1240 .filter(|p| p.units.currency == "STK")
1241 .collect();
1242 assert_eq!(remaining.len(), 1);
1243 assert_eq!(remaining[0].cost.as_ref().unwrap().number, dec!(160));
1244 }
1245
1246 #[test]
1247 fn reduce_average_short_cover_matched_lot_carries_inventory_sign() {
1248 let mut i = Inventory::new();
1251 i.add(Position::with_cost(
1252 Amount::new(dec!(-10), "STK"),
1253 Cost::new(dec!(150), "USD"),
1254 ));
1255 let r = i
1256 .reduce(
1257 &Amount::new(dec!(5), "STK"),
1258 Some(&CostSpec::default()),
1259 BookingMethod::Average,
1260 )
1261 .unwrap();
1262 assert_eq!(r.matched.len(), 1);
1263 assert_eq!(r.matched[0].units.number, dec!(-5));
1264 assert_eq!(i.units("STK"), dec!(-5));
1266 }
1267
1268 #[test]
1269 fn merge_average_collapses_lots_to_single_weighted_lot() {
1270 let mut i = Inventory::new();
1273 i.add(lot(10, 150, 1));
1274 i.add(lot(10, 170, 2));
1275 i.add(Position::with_cost(
1276 Amount::new(dec!(-5), "STK"),
1277 Cost::new(dec!(160), "USD"),
1278 ));
1279 i.merge_average();
1280 let stk: Vec<&Position> = i
1281 .positions()
1282 .filter(|p| p.units.currency == "STK")
1283 .collect();
1284 assert_eq!(stk.len(), 1);
1285 assert_eq!(stk[0].units.number, dec!(15));
1286 assert_eq!(stk[0].cost.as_ref().unwrap().number, dec!(160));
1287 }
1288
1289 #[test]
1290 fn merge_average_net_zero_removes_lots() {
1291 let mut i = Inventory::new();
1292 i.add(lot(10, 150, 1));
1293 i.add(Position::with_cost(
1294 Amount::new(dec!(-10), "STK"),
1295 Cost::new(dec!(160), "USD"),
1296 ));
1297 i.merge_average();
1298 assert_eq!(
1299 i.positions().filter(|p| p.units.currency == "STK").count(),
1300 0
1301 );
1302 }
1303
1304 #[test]
1305 fn merge_average_leaves_costless_positions_untouched() {
1306 let mut i = Inventory::new();
1307 i.add(Position::simple(Amount::new(dec!(100), "USD")));
1308 i.add(lot(10, 150, 1));
1309 i.merge_average();
1310 assert_eq!(i.units("USD"), dec!(100));
1312 assert_eq!(
1313 i.positions().filter(|p| p.units.currency == "STK").count(),
1314 1
1315 );
1316 }
1317
1318 #[test]
1319 fn reduce_from_lot_matched_and_remaining_units() {
1320 let mut inv = mk([lot(10, 100, 1)]);
1321 let r = inv
1322 .reduce(
1323 &sell_stk(4),
1324 Some(&CostSpec::default()),
1325 BookingMethod::Strict,
1326 )
1327 .unwrap();
1328 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); let remaining: Vec<_> = inv.position_list();
1333 assert_eq!(remaining.len(), 1);
1334 assert_eq!(remaining[0].units.number, dec!(6)); assert_eq!(inv.units("STK"), dec!(6)); }
1337
1338 #[test]
1339 fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
1340 let mut inv = Inventory::new();
1344 inv.add(lot(10, 100, 1)); inv.add(lot(30, 200, 2)); inv.add(lot(-5, 999, 3)); inv.add(Position::with_cost(
1348 Amount::new(dec!(10), "OTH"), Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
1350 ));
1351 let spec = CostSpec {
1352 merge: true,
1353 ..CostSpec::default()
1354 };
1355 let r = inv
1356 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1357 .unwrap();
1358 assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
1361 assert!(
1363 inv.position_list()
1364 .iter()
1365 .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
1366 "OTH lot must survive the merge"
1367 );
1368 assert!(
1369 inv.position_list()
1370 .iter()
1371 .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
1372 "short STK lot must survive the merge"
1373 );
1374 }
1375
1376 #[test]
1377 fn reduce_none_exact_succeeds_over_reduction_shorts() {
1378 let mut inv = Inventory::new();
1379 inv.add(Position::simple(Amount::new(dec!(10), "STK")));
1380 assert!(
1381 inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
1382 "exact NONE reduction should succeed"
1383 );
1384 let mut inv2 = Inventory::new();
1388 inv2.add(Position::simple(Amount::new(dec!(10), "STK")));
1389 assert!(
1390 inv2.reduce(&sell_stk(15), None, BookingMethod::None)
1391 .is_ok(),
1392 "NONE over-reduction must short, not error (#1686)"
1393 );
1394 assert_eq!(inv2.units("STK"), dec!(-5));
1395 }
1396
1397 #[test]
1398 fn reduce_merge_uses_weighted_average() {
1399 let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1400 let spec = CostSpec {
1401 merge: true,
1402 ..CostSpec::default()
1403 };
1404 let r = inv
1405 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1406 .unwrap();
1407 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); assert_eq!(inv.units("STK"), dec!(20)); }
1410}