Skip to main content

rustledger_core/inventory/
booking.rs

1//! Booking method implementations for Inventory.
2//!
3//! This module contains the implementation of all booking methods (STRICT,
4//! `STRICT_WITH_SIZE`, FIFO, LIFO, HIFO, AVERAGE, NONE) used to reduce positions
5//! from an inventory.
6
7use 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
15/// Compute weighted-average cost from a set of positions.
16///
17/// Returns `(avg_cost_per_unit, cost_currency)` or `None` if no positions have cost info.
18/// Returns `Err(CurrencyMismatch)` if positions have costs in different currencies.
19fn 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    /// Try reducing positions without modifying the inventory.
53    ///
54    /// This is a read-only version of `reduce()` that returns what would be matched
55    /// without actually modifying the inventory. Useful for previewing booking results
56    /// before committing.
57    ///
58    /// # Arguments
59    ///
60    /// * `units` - The units to reduce (negative for selling)
61    /// * `cost_spec` - Optional cost specification for matching lots
62    /// * `method` - The booking method to use
63    ///
64    /// # Returns
65    ///
66    /// Returns a `BookingResult` with the positions that would be matched and cost basis,
67    /// or a `BookingError` if the reduction cannot be performed.
68    pub fn try_reduce(
69        &self,
70        units: &Amount,
71        cost_spec: Option<&CostSpec>,
72        method: BookingMethod,
73    ) -> Result<BookingResult, BookingError> {
74        let spec = cost_spec.cloned().unwrap_or_default();
75
76        // {*} merge operator: use average-cost semantics (read-only preview)
77        if spec.merge {
78            return self.try_reduce_average(units);
79        }
80
81        match method {
82            BookingMethod::Strict | BookingMethod::StrictWithSize => {
83                self.try_reduce_strict(units, &spec, method == BookingMethod::StrictWithSize)
84            }
85            BookingMethod::Fifo => self.try_reduce_ordered(units, &spec, false),
86            BookingMethod::Lifo => self.try_reduce_ordered(units, &spec, true),
87            BookingMethod::Hifo => self.try_reduce_hifo(units, &spec),
88            BookingMethod::Average => self.try_reduce_average(units),
89            BookingMethod::None => self.try_reduce_ordered(units, &CostSpec::default(), false),
90        }
91    }
92
93    /// Try `STRICT`/`STRICT_WITH_SIZE` booking without modifying inventory.
94    fn try_reduce_strict(
95        &self,
96        units: &Amount,
97        spec: &CostSpec,
98        with_size: bool,
99    ) -> Result<BookingResult, BookingError> {
100        let matching_indices: Vec<usize> = self
101            .positions
102            .iter()
103            .enumerate()
104            .filter(|(_, p)| {
105                p.units.currency == units.currency
106                    && !p.is_empty()
107                    && p.can_reduce(units)
108                    && p.matches_cost_spec(spec)
109            })
110            .map(|(i, _)| i)
111            .collect();
112
113        match matching_indices.len() {
114            0 => Err(BookingError::NoMatchingLot {
115                currency: units.currency.clone(),
116                cost_spec: spec.clone(),
117            }),
118            1 => {
119                let idx = matching_indices[0];
120                self.try_reduce_from_lot(idx, units)
121            }
122            n => {
123                if with_size {
124                    // Check for exact-size match with any lot
125                    let exact_matches: Vec<usize> = matching_indices
126                        .iter()
127                        .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
128                        .copied()
129                        .collect();
130
131                    if exact_matches.is_empty() {
132                        // Total match exception
133                        let total_units: Decimal = matching_indices
134                            .iter()
135                            .map(|&i| self.positions[i].units.number.abs())
136                            .sum();
137                        if total_units == units.number.abs() {
138                            self.try_reduce_ordered(units, spec, false)
139                        } else {
140                            Err(BookingError::AmbiguousMatch {
141                                num_matches: n,
142                                currency: units.currency.clone(),
143                            })
144                        }
145                    } else {
146                        let idx = exact_matches[0];
147                        self.try_reduce_from_lot(idx, units)
148                    }
149                } else {
150                    // STRICT: fall back to FIFO when multiple match
151                    self.try_reduce_ordered(units, spec, false)
152                }
153            }
154        }
155    }
156
157    /// Try ordered (FIFO/LIFO) booking without modifying inventory.
158    fn try_reduce_ordered(
159        &self,
160        units: &Amount,
161        spec: &CostSpec,
162        reverse: bool,
163    ) -> Result<BookingResult, BookingError> {
164        let mut remaining = units.number.abs();
165        let mut matched: MatchedLots = SmallVec::new();
166        let mut cost_basis = Decimal::ZERO;
167        let mut cost_currency = None;
168
169        // Get indices of matching positions
170        let mut indices: Vec<usize> = self
171            .positions
172            .iter()
173            .enumerate()
174            .filter(|(_, p)| {
175                p.units.currency == units.currency
176                    && !p.is_empty()
177                    && p.units.number.signum() != units.number.signum()
178                    && p.matches_cost_spec(spec)
179            })
180            .map(|(i, _)| i)
181            .collect();
182
183        // Sort by date for correct FIFO/LIFO ordering
184        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
185
186        if reverse {
187            indices.reverse();
188        }
189
190        if indices.is_empty() {
191            return Err(BookingError::NoMatchingLot {
192                currency: units.currency.clone(),
193                cost_spec: spec.clone(),
194            });
195        }
196
197        for idx in indices {
198            if remaining.is_zero() {
199                break;
200            }
201
202            let pos = &self.positions[idx];
203            let available = pos.units.number.abs();
204            let take = remaining.min(available);
205
206            // Calculate cost basis for this portion
207            if let Some(cost) = &pos.cost {
208                cost_basis += take * cost.number;
209                cost_currency = Some(cost.currency.clone());
210            }
211
212            // Record what we would match (using split which is read-only)
213            let (taken, _) = pos.split(take * pos.units.number.signum());
214            matched.push(taken);
215
216            remaining -= take;
217        }
218
219        if !remaining.is_zero() {
220            let available = units.number.abs() - remaining;
221            return Err(BookingError::InsufficientUnits {
222                currency: units.currency.clone(),
223                requested: units.number.abs(),
224                available,
225            });
226        }
227
228        Ok(BookingResult {
229            matched,
230            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
231        })
232    }
233
234    /// Try HIFO booking without modifying inventory.
235    fn try_reduce_hifo(
236        &self,
237        units: &Amount,
238        spec: &CostSpec,
239    ) -> Result<BookingResult, BookingError> {
240        let mut remaining = units.number.abs();
241        let mut matched: MatchedLots = SmallVec::new();
242        let mut cost_basis = Decimal::ZERO;
243        let mut cost_currency = None;
244
245        // Get matching positions with their costs
246        let mut matching: Vec<(usize, Decimal)> = self
247            .positions
248            .iter()
249            .enumerate()
250            .filter(|(_, p)| {
251                p.units.currency == units.currency
252                    && !p.is_empty()
253                    && p.units.number.signum() != units.number.signum()
254                    && p.matches_cost_spec(spec)
255            })
256            .map(|(i, p)| {
257                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
258                (i, cost)
259            })
260            .collect();
261
262        if matching.is_empty() {
263            return Err(BookingError::NoMatchingLot {
264                currency: units.currency.clone(),
265                cost_spec: spec.clone(),
266            });
267        }
268
269        // Sort by cost descending (highest first)
270        matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
271
272        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
273
274        for idx in indices {
275            if remaining.is_zero() {
276                break;
277            }
278
279            let pos = &self.positions[idx];
280            let available = pos.units.number.abs();
281            let take = remaining.min(available);
282
283            // Calculate cost basis for this portion
284            if let Some(cost) = &pos.cost {
285                cost_basis += take * cost.number;
286                cost_currency = Some(cost.currency.clone());
287            }
288
289            // Record what we would match
290            let (taken, _) = pos.split(take * pos.units.number.signum());
291            matched.push(taken);
292
293            remaining -= take;
294        }
295
296        if !remaining.is_zero() {
297            let available = units.number.abs() - remaining;
298            return Err(BookingError::InsufficientUnits {
299                currency: units.currency.clone(),
300                requested: units.number.abs(),
301                available,
302            });
303        }
304
305        Ok(BookingResult {
306            matched,
307            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
308        })
309    }
310
311    /// Try AVERAGE booking without modifying inventory.
312    fn try_reduce_average(&self, units: &Amount) -> Result<BookingResult, BookingError> {
313        let matching: Vec<&Position> = self
314            .positions
315            .iter()
316            .filter(|p| p.units.currency == units.currency && !p.is_empty())
317            .collect();
318
319        let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
320
321        if total_units.is_zero() {
322            return Err(BookingError::InsufficientUnits {
323                currency: units.currency.clone(),
324                requested: units.number.abs(),
325                available: Decimal::ZERO,
326            });
327        }
328
329        let reduction = units.number.abs();
330        if reduction > total_units.abs() {
331            return Err(BookingError::InsufficientUnits {
332                currency: units.currency.clone(),
333                requested: reduction,
334                available: total_units.abs(),
335            });
336        }
337
338        let cost_basis = average_cost_from_positions(&matching, total_units)?
339            .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
340
341        let matched: MatchedLots = matching.into_iter().cloned().collect();
342
343        Ok(BookingResult {
344            matched,
345            cost_basis,
346        })
347    }
348
349    /// Try reducing from a specific lot without modifying inventory.
350    fn try_reduce_from_lot(
351        &self,
352        idx: usize,
353        units: &Amount,
354    ) -> Result<BookingResult, BookingError> {
355        let pos = &self.positions[idx];
356        let available = pos.units.number.abs();
357        let requested = units.number.abs();
358
359        if requested > available {
360            return Err(BookingError::InsufficientUnits {
361                currency: units.currency.clone(),
362                requested,
363                available,
364            });
365        }
366
367        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
368        let (matched, _) = pos.split(requested * pos.units.number.signum());
369
370        Ok(BookingResult {
371            matched: smallvec![matched],
372            cost_basis,
373        })
374    }
375}
376
377impl Inventory {
378    /// STRICT booking: require exactly one matching lot, unless either:
379    ///
380    /// - all matching lots are identical in cost, in which case the choice
381    ///   between them is irrelevant and we fall back to the same ordering as
382    ///   FIFO (oldest `cost.date` first — see [`Self::reduce_ordered`]), or
383    /// - the reduction exactly matches the total units available across the
384    ///   matching lots (full liquidation), in which case all of them may be
385    ///   drained together without ambiguity.
386    ///
387    /// If multiple lots with *different* costs match and the reduction does
388    /// not qualify for the full-liquidation exception — for example a
389    /// wildcard reduction `-5 AAPL {}` against an inventory holding both
390    /// `{150 USD}` and `{160 USD}` — the reduction is genuinely ambiguous and
391    /// we return `AmbiguousMatch`, matching Python beancount's
392    /// `AmbiguousMatchError` and the formal `STRICTCorrect.tla` specification.
393    ///
394    /// # The "interchangeable lots" heuristic
395    ///
396    /// We treat two matched lots as interchangeable when their `(cost.number,
397    /// cost.currency)` agree — the user-visible monetary identity. We
398    /// deliberately ignore `cost.date` and `cost.label`: the user's cost spec
399    /// could not have constrained those fields without naming them, so two
400    /// lots that differ only on date/label could not have been distinguished
401    /// by the spec the user wrote, and the date-ordered fallback is
402    /// unambiguous within that equivalence class.
403    ///
404    /// A stricter spec-derived check would compare each pair of matched lots
405    /// on every cost field the spec did *not* constrain. The simpler
406    /// number+currency check matches Python beancount's behavior for the
407    /// real-world cases we know about (see
408    /// `test_reduce_strict_multiple_match_with_identical_costs_uses_fifo` and
409    /// the `test_validate_multiple_lot_match_uses_fifo` integration test for
410    /// the same-cost-different-date case).
411    pub(super) fn reduce_strict(
412        &mut self,
413        units: &Amount,
414        spec: &CostSpec,
415    ) -> Result<BookingResult, BookingError> {
416        let matching_indices: Vec<usize> = self
417            .positions
418            .iter()
419            .enumerate()
420            .filter(|(_, p)| {
421                p.units.currency == units.currency
422                    && !p.is_empty()
423                    && p.can_reduce(units)
424                    && p.matches_cost_spec(spec)
425            })
426            .map(|(i, _)| i)
427            .collect();
428
429        match matching_indices.len() {
430            0 => Err(BookingError::NoMatchingLot {
431                currency: units.currency.clone(),
432                cost_spec: spec.clone(),
433            }),
434            1 => {
435                let idx = matching_indices[0];
436                self.reduce_from_lot(idx, units)
437            }
438            n => {
439                // Are the matched lots financially interchangeable? Two lots
440                // count as identical if they have the same cost number + cost
441                // currency — the user-visible monetary identity. Date and label
442                // differences don't make a reduction ambiguous because the user
443                // could not have observed a different outcome based on the cost
444                // spec they wrote. Beancount falls back to FIFO in that case.
445                let first_key = self.positions[matching_indices[0]]
446                    .cost
447                    .as_ref()
448                    .map(|c| (c.number, c.currency.clone()));
449                let all_same_value = matching_indices.iter().skip(1).all(|&i| {
450                    let key = self.positions[i]
451                        .cost
452                        .as_ref()
453                        .map(|c| (c.number, c.currency.clone()));
454                    key == first_key
455                });
456
457                if all_same_value {
458                    return self.reduce_ordered(units, spec, false);
459                }
460
461                // Total match exception: if the reduction equals the sum of all
462                // matching lots, the user is selling the entire matched
463                // inventory and the lot choice doesn't matter — accept it.
464                let total_units: Decimal = matching_indices
465                    .iter()
466                    .map(|&i| self.positions[i].units.number.abs())
467                    .sum();
468                if total_units == units.number.abs() {
469                    return self.reduce_ordered(units, spec, false);
470                }
471
472                Err(BookingError::AmbiguousMatch {
473                    num_matches: n,
474                    currency: units.currency.clone(),
475                })
476            }
477        }
478    }
479
480    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
481    pub(super) fn reduce_strict_with_size(
482        &mut self,
483        units: &Amount,
484        spec: &CostSpec,
485    ) -> Result<BookingResult, BookingError> {
486        let matching_indices: Vec<usize> = self
487            .positions
488            .iter()
489            .enumerate()
490            .filter(|(_, p)| {
491                p.units.currency == units.currency
492                    && !p.is_empty()
493                    && p.can_reduce(units)
494                    && p.matches_cost_spec(spec)
495            })
496            .map(|(i, _)| i)
497            .collect();
498
499        match matching_indices.len() {
500            0 => Err(BookingError::NoMatchingLot {
501                currency: units.currency.clone(),
502                cost_spec: spec.clone(),
503            }),
504            1 => {
505                let idx = matching_indices[0];
506                self.reduce_from_lot(idx, units)
507            }
508            n => {
509                // Check for exact-size match with any lot
510                let exact_matches: Vec<usize> = matching_indices
511                    .iter()
512                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
513                    .copied()
514                    .collect();
515
516                if exact_matches.is_empty() {
517                    // Total match exception
518                    let total_units: Decimal = matching_indices
519                        .iter()
520                        .map(|&i| self.positions[i].units.number.abs())
521                        .sum();
522                    if total_units == units.number.abs() {
523                        self.reduce_ordered(units, spec, false)
524                    } else {
525                        Err(BookingError::AmbiguousMatch {
526                            num_matches: n,
527                            currency: units.currency.clone(),
528                        })
529                    }
530                } else {
531                    // Use oldest (first) exact-size match
532                    let idx = exact_matches[0];
533                    self.reduce_from_lot(idx, units)
534                }
535            }
536        }
537    }
538
539    /// FIFO booking: reduce from oldest lots first.
540    pub(super) fn reduce_fifo(
541        &mut self,
542        units: &Amount,
543        spec: &CostSpec,
544    ) -> Result<BookingResult, BookingError> {
545        self.reduce_ordered(units, spec, false)
546    }
547
548    /// LIFO booking: reduce from newest lots first.
549    pub(super) fn reduce_lifo(
550        &mut self,
551        units: &Amount,
552        spec: &CostSpec,
553    ) -> Result<BookingResult, BookingError> {
554        self.reduce_ordered(units, spec, true)
555    }
556
557    /// HIFO booking: reduce from highest-cost lots first.
558    pub(super) fn reduce_hifo(
559        &mut self,
560        units: &Amount,
561        spec: &CostSpec,
562    ) -> Result<BookingResult, BookingError> {
563        let mut remaining = units.number.abs();
564        let mut matched: MatchedLots = SmallVec::new();
565        let mut cost_basis = Decimal::ZERO;
566        let mut cost_currency = None;
567
568        // Get matching positions with their costs
569        let mut matching: Vec<(usize, Decimal)> = self
570            .positions
571            .iter()
572            .enumerate()
573            .filter(|(_, p)| {
574                p.units.currency == units.currency
575                    && !p.is_empty()
576                    && p.units.number.signum() != units.number.signum()
577                    && p.matches_cost_spec(spec)
578            })
579            .map(|(i, p)| {
580                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
581                (i, cost)
582            })
583            .collect();
584
585        if matching.is_empty() {
586            return Err(BookingError::NoMatchingLot {
587                currency: units.currency.clone(),
588                cost_spec: spec.clone(),
589            });
590        }
591
592        // Sort by cost descending (highest first)
593        matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
594
595        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
596
597        for idx in indices {
598            if remaining.is_zero() {
599                break;
600            }
601
602            let pos = &self.positions[idx];
603            let available = pos.units.number.abs();
604            let take = remaining.min(available);
605
606            // Calculate cost basis for this portion
607            if let Some(cost) = &pos.cost {
608                cost_basis += take * cost.number;
609                cost_currency = Some(cost.currency.clone());
610            }
611
612            // Record what we matched
613            let (taken, _) = pos.split(take * pos.units.number.signum());
614            matched.push(taken);
615
616            // Reduce the lot
617            let reduction = if units.number.is_sign_negative() {
618                -take
619            } else {
620                take
621            };
622
623            let new_pos = Position {
624                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
625                cost: pos.cost.clone(),
626            };
627            self.positions[idx] = new_pos;
628
629            remaining -= take;
630        }
631
632        if !remaining.is_zero() {
633            let available = units.number.abs() - remaining;
634            return Err(BookingError::InsufficientUnits {
635                currency: units.currency.clone(),
636                requested: units.number.abs(),
637                available,
638            });
639        }
640
641        // Clean up empty positions
642        self.positions.retain(|p| !p.is_empty());
643        self.rebuild_index();
644
645        Ok(BookingResult {
646            matched,
647            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
648        })
649    }
650
651    /// Reduce in order (FIFO or LIFO).
652    pub(super) fn reduce_ordered(
653        &mut self,
654        units: &Amount,
655        spec: &CostSpec,
656        reverse: bool,
657    ) -> Result<BookingResult, BookingError> {
658        let mut remaining = units.number.abs();
659        let mut matched: MatchedLots = SmallVec::new();
660        let mut cost_basis = Decimal::ZERO;
661        let mut cost_currency = None;
662
663        // Get indices of matching positions
664        let mut indices: Vec<usize> = self
665            .positions
666            .iter()
667            .enumerate()
668            .filter(|(_, p)| {
669                p.units.currency == units.currency
670                    && !p.is_empty()
671                    && p.units.number.signum() != units.number.signum()
672                    && p.matches_cost_spec(spec)
673            })
674            .map(|(i, _)| i)
675            .collect();
676
677        // Sort by date for correct FIFO/LIFO ordering (oldest first)
678        // This ensures we select by acquisition date, not insertion order
679        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
680
681        if reverse {
682            indices.reverse();
683        }
684
685        if indices.is_empty() {
686            return Err(BookingError::NoMatchingLot {
687                currency: units.currency.clone(),
688                cost_spec: spec.clone(),
689            });
690        }
691
692        // Get cost currency from first lot (all lots of same commodity have same cost currency)
693        if let Some(&first_idx) = indices.first()
694            && let Some(cost) = &self.positions[first_idx].cost
695        {
696            cost_currency = Some(cost.currency.clone());
697        }
698
699        for idx in indices {
700            if remaining.is_zero() {
701                break;
702            }
703
704            let pos = &mut self.positions[idx];
705            let available = pos.units.number.abs();
706            let take = remaining.min(available);
707
708            // Calculate cost basis for this portion
709            if let Some(cost) = &pos.cost {
710                cost_basis += take * cost.number;
711            }
712
713            // Record what we matched
714            let (taken, _) = pos.split(take * pos.units.number.signum());
715            matched.push(taken);
716
717            // Reduce the lot - modify in place to avoid cloning
718            let reduction = if units.number.is_sign_negative() {
719                -take
720            } else {
721                take
722            };
723            pos.units.number += reduction;
724
725            remaining -= take;
726        }
727
728        if !remaining.is_zero() {
729            let available = units.number.abs() - remaining;
730            return Err(BookingError::InsufficientUnits {
731                currency: units.currency.clone(),
732                requested: units.number.abs(),
733                available,
734            });
735        }
736
737        // Clean up empty positions
738        self.positions.retain(|p| !p.is_empty());
739        self.rebuild_index();
740
741        Ok(BookingResult {
742            matched,
743            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
744        })
745    }
746
747    /// AVERAGE booking: merge all lots of the currency.
748    pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
749        let matching: Vec<&Position> = self
750            .positions
751            .iter()
752            .filter(|p| p.units.currency == units.currency && !p.is_empty())
753            .collect();
754
755        let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
756
757        if total_units.is_zero() {
758            return Err(BookingError::InsufficientUnits {
759                currency: units.currency.clone(),
760                requested: units.number.abs(),
761                available: Decimal::ZERO,
762            });
763        }
764
765        let reduction = units.number.abs();
766        if reduction > total_units.abs() {
767            return Err(BookingError::InsufficientUnits {
768                currency: units.currency.clone(),
769                requested: reduction,
770                available: total_units.abs(),
771            });
772        }
773
774        let cost_basis = average_cost_from_positions(&matching, total_units)?
775            .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
776
777        let matched: MatchedLots = matching.into_iter().cloned().collect();
778        let new_units = total_units + units.number;
779
780        // Remove all positions of this currency
781        self.positions
782            .retain(|p| p.units.currency != units.currency);
783
784        // Add back the remainder if non-zero
785        if !new_units.is_zero() {
786            self.positions.push_back(Position::simple(Amount::new(
787                new_units,
788                units.currency.clone(),
789            )));
790        }
791
792        self.rebuild_index();
793
794        Ok(BookingResult {
795            matched,
796            cost_basis,
797        })
798    }
799
800    /// Cost merge `{*}`: merge all lots of the currency into a single
801    /// weighted-average-cost lot, then reduce from it.
802    ///
803    /// Example: 10 AAPL {150 USD} + 10 AAPL {160 USD} merged = 20 AAPL {155 USD}.
804    /// Reducing 5 AAPL {*} takes 5 from the merged 20 AAPL {155 USD} lot.
805    pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
806        // Only merge lots with opposite sign (same as other reduce methods).
807        // This prevents accidentally netting long and short positions.
808        let matching: Vec<(usize, &Position)> = self
809            .positions
810            .iter()
811            .enumerate()
812            .filter(|(_, p)| {
813                p.units.currency == units.currency
814                    && !p.is_empty()
815                    && p.units.number.is_sign_positive() != units.number.is_sign_positive()
816            })
817            .collect();
818
819        if matching.is_empty() {
820            return Err(BookingError::InsufficientUnits {
821                currency: units.currency.clone(),
822                requested: units.number.abs(),
823                available: Decimal::ZERO,
824            });
825        }
826
827        let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
828        let reduction = units.number.abs();
829
830        if reduction > total_units.abs() {
831            return Err(BookingError::InsufficientUnits {
832                currency: units.currency.clone(),
833                requested: reduction,
834                available: total_units.abs(),
835            });
836        }
837
838        // Compute weighted-average cost from matching lots.
839        let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
840        let (avg_cost, cost_currency) =
841            match average_cost_from_positions(&matching_refs, total_units)? {
842                Some(result) => result,
843                None => return self.reduce_average(units),
844            };
845
846        let cost_basis = Some(Amount::new(reduction * avg_cost, cost_currency.clone()));
847
848        // Return a single synthetic matched position representing the merged lot.
849        // This prevents the booking engine from expanding the posting into multiple
850        // postings (one per original lot), which would be incorrect for {*}.
851        let make_avg_cost = || Cost {
852            number: avg_cost,
853            currency: cost_currency.clone(),
854            date: None,
855            label: None,
856        };
857
858        let matched: MatchedLots = smallvec![Position::with_cost(
859            Amount::new(units.number.abs(), units.currency.clone()),
860            make_avg_cost(),
861        )];
862
863        // Remove all matching lots of this currency
864        let matching_indices: std::collections::HashSet<usize> =
865            matching.iter().map(|(i, _)| *i).collect();
866        let mut idx = 0;
867        self.positions.retain(|_| {
868            let keep = !matching_indices.contains(&idx);
869            idx += 1;
870            keep
871        });
872
873        // Add back a single merged lot with the remainder
874        let remaining = total_units + units.number; // units.number is negative for reductions
875        if !remaining.is_zero() {
876            self.positions.push_back(Position::with_cost(
877                Amount::new(remaining, units.currency.clone()),
878                make_avg_cost(),
879            ));
880        }
881
882        self.rebuild_index();
883
884        Ok(BookingResult {
885            matched,
886            cost_basis,
887        })
888    }
889
890    /// NONE booking: reduce without matching lots.
891    pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
892        // For NONE booking, we just reduce the total without caring about lots
893        let total_units = self.units(&units.currency);
894
895        // Check we have enough in the right direction
896        if total_units.signum() == units.number.signum() || total_units.is_zero() {
897            // This is an augmentation, not a reduction - just add it
898            self.add(Position::simple(units.clone()));
899            return Ok(BookingResult {
900                matched: SmallVec::new(),
901                cost_basis: None,
902            });
903        }
904
905        let available = total_units.abs();
906        let requested = units.number.abs();
907
908        if requested > available {
909            return Err(BookingError::InsufficientUnits {
910                currency: units.currency.clone(),
911                requested,
912                available,
913            });
914        }
915
916        // Reduce positions proportionally (simplified: just reduce first matching)
917        self.reduce_ordered(units, &CostSpec::default(), false)
918    }
919
920    /// Reduce from a specific lot.
921    pub(super) fn reduce_from_lot(
922        &mut self,
923        idx: usize,
924        units: &Amount,
925    ) -> Result<BookingResult, BookingError> {
926        let pos = &self.positions[idx];
927        let available = pos.units.number.abs();
928        let requested = units.number.abs();
929
930        if requested > available {
931            return Err(BookingError::InsufficientUnits {
932                currency: units.currency.clone(),
933                requested,
934                available,
935            });
936        }
937
938        // Calculate cost basis
939        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
940
941        // Record matched
942        let (matched, _) = pos.split(requested * pos.units.number.signum());
943
944        // Update the position
945        let currency = pos.units.currency.clone();
946        let new_units = pos.units.number + units.number;
947        let new_pos = Position {
948            units: Amount::new(new_units, currency.clone()),
949            cost: pos.cost.clone(),
950        };
951        self.positions[idx] = new_pos;
952
953        // Update units cache incrementally (units.number is negative for reductions)
954        if let Some(cached) = self.units_cache.get_mut(&currency) {
955            *cached += units.number;
956        }
957
958        // Remove if empty and rebuild simple_index
959        if self.positions[idx].is_empty() {
960            self.positions.remove(idx);
961            // Only rebuild simple_index when position is removed
962            self.simple_index.clear();
963            for (i, p) in self.positions.iter().enumerate() {
964                if p.cost.is_none() {
965                    self.simple_index.insert(p.units.currency.clone(), i);
966                }
967            }
968        }
969
970        Ok(BookingResult {
971            matched: smallvec![matched],
972            cost_basis,
973        })
974    }
975}
976
977#[cfg(test)]
978mod reduction_tests {
979    //! Direct unit tests for the read-only `try_reduce_*` booking paths.
980    //!
981    //! These pin exact cost-basis, lot selection, and guard behavior so
982    //! the lot-reduction mutants surfaced by the #1309 audit are killed
983    //! (the public mutating `reduce_*` path was covered indirectly, but
984    //! the `try_reduce_*` preview path had no direct assertions).
985    use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
986    use rust_decimal::Decimal;
987    use rust_decimal_macros::dec;
988
989    fn d(n: i64) -> Decimal {
990        Decimal::from(n)
991    }
992
993    /// A cost-bearing lot of `units` STK at `cost` USD, dated 2024-01-`day`.
994    fn lot(units: i64, cost: i64, day: u32) -> Position {
995        Position::with_cost(
996            Amount::new(d(units), "STK"),
997            Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
998        )
999    }
1000
1001    fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
1002        let mut i = Inventory::new();
1003        for l in lots {
1004            i.add(l);
1005        }
1006        i
1007    }
1008
1009    fn sell_stk(n: i64) -> Amount {
1010        Amount::new(d(-n), "STK")
1011    }
1012
1013    fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
1014        inv.try_reduce(units, Some(&CostSpec::default()), method)
1015            .expect("reduction should succeed")
1016    }
1017
1018    fn basis(r: &super::BookingResult) -> Decimal {
1019        r.cost_basis.as_ref().expect("cost basis present").number
1020    }
1021
1022    // ---- FIFO / LIFO ordered ------------------------------------------
1023
1024    #[test]
1025    fn fifo_partial_multilot_cost_basis_and_order() {
1026        // 10 @ $100 (older), 10 @ $200 (newer); sell 15.
1027        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1028        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
1029        // FIFO: 10@100 + 5@200 = 1000 + 1000 = 2000.
1030        assert_eq!(basis(&r), dec!(2000));
1031        assert_eq!(r.matched.len(), 2);
1032        assert_eq!(r.matched[0].units.number.abs(), dec!(10));
1033        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
1034        assert_eq!(r.matched[1].units.number.abs(), dec!(5));
1035        assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1036    }
1037
1038    #[test]
1039    fn lifo_takes_newest_lot_first() {
1040        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1041        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1042        // LIFO: 10@200 + 5@100 = 2000 + 500 = 2500 (distinguishes the
1043        // `reverse` flag from FIFO's 2000).
1044        assert_eq!(basis(&r), dec!(2500));
1045        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
1046    }
1047
1048    #[test]
1049    fn fifo_single_lot_partial_cost_basis() {
1050        let inv = mk([lot(10, 100, 1)]);
1051        let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
1052        assert_eq!(basis(&r), dec!(300)); // 3 * 100
1053    }
1054
1055    // ---- HIFO ---------------------------------------------------------
1056
1057    #[test]
1058    fn hifo_takes_highest_cost_lot_first() {
1059        // costs 100, 300, 200 → HIFO order 300, 200, 100.
1060        let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
1061        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
1062        // 10@300 + 5@200 = 3000 + 1000 = 4000.
1063        assert_eq!(basis(&r), dec!(4000));
1064        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
1065        assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1066    }
1067
1068    // ---- AVERAGE ------------------------------------------------------
1069
1070    #[test]
1071    fn average_cost_basis_partial() {
1072        // 10 @ $100, 30 @ $200 → 40 units, $7000 total, avg $175.
1073        let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1074        let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
1075        assert_eq!(basis(&r), dec!(3500)); // 20 * 175
1076    }
1077
1078    #[test]
1079    fn average_reduce_exact_total_succeeds() {
1080        // Reducing exactly the held quantity must succeed (kills
1081        // `reduction > total` → `>=`/`==`).
1082        let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1083        let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
1084        assert_eq!(basis(&r), dec!(7000)); // 40 * 175
1085    }
1086
1087    #[test]
1088    fn average_over_reduction_errors() {
1089        // Reducing more than held must error (kills `>` → `<`).
1090        let inv = mk([lot(10, 100, 1)]);
1091        let err = inv
1092            .try_reduce(
1093                &sell_stk(20),
1094                Some(&CostSpec::default()),
1095                BookingMethod::Average,
1096            )
1097            .unwrap_err();
1098        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1099    }
1100
1101    // ---- Filter isolation (currency / sign) ---------------------------
1102    // One fixture per method: an unrelated OTH lot plus the real STK lot.
1103    // A correct reducer touches ONLY the real STK lot; the currency `==`
1104    // and the `&&` connecting it would pull OTH in (or drop the real
1105    // one), changing the basis. (A zero-units "empty" lot is intentionally
1106    // NOT added here: `Inventory::add` drops empty positions on insert, so
1107    // the `!is_empty()` filter clause is unreachable for add-built
1108    // inventories and can't be exercised this way.)
1109
1110    fn isolation_inv() -> Inventory {
1111        let mut i = Inventory::new();
1112        i.add(Position::with_cost(
1113            Amount::new(dec!(10), "OTH"), // different currency: must be ignored
1114            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1115        ));
1116        i.add(lot(10, 100, 2)); // the real STK lot
1117        i
1118    }
1119
1120    fn assert_isolated(method: BookingMethod) {
1121        let inv = isolation_inv();
1122        let r = try_reduce(&inv, &sell_stk(5), method);
1123        assert_eq!(
1124            basis(&r),
1125            dec!(500),
1126            "must reduce only the real STK lot (5 * 100)"
1127        );
1128        assert!(
1129            r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
1130            "no non-STK lot should be matched"
1131        );
1132    }
1133
1134    #[test]
1135    fn fifo_filters_currency() {
1136        assert_isolated(BookingMethod::Fifo);
1137    }
1138
1139    #[test]
1140    fn hifo_filters_currency() {
1141        assert_isolated(BookingMethod::Hifo);
1142    }
1143
1144    #[test]
1145    fn strict_filters_currency() {
1146        assert_isolated(BookingMethod::Strict);
1147    }
1148
1149    #[test]
1150    fn average_filters_currency() {
1151        // average filters by currency + non-empty (no cost-spec / sign filter).
1152        let inv = isolation_inv();
1153        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
1154        // Only the STK lot participates: 10 units @ $100 → avg $100 → 5 * 100.
1155        assert_eq!(basis(&r), dec!(500));
1156    }
1157
1158    // ---- Sign guard ---------------------------------------------------
1159
1160    #[test]
1161    fn does_not_match_same_sign_lot() {
1162        // A short (negative) STK lot must NOT satisfy a sell (negative
1163        // units): same sign. Only the long lot is reducible. Kills the
1164        // `signum() != signum()` → `==` mutant (== would match the short
1165        // lot or nothing).
1166        let mut i = Inventory::new();
1167        i.add(lot(-10, 50, 1)); // short lot, same sign as a sell
1168        i.add(lot(10, 100, 2)); // long lot
1169        let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
1170        assert_eq!(basis(&r), dec!(500)); // 5 * 100 from the long lot only
1171        assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
1172    }
1173
1174    #[test]
1175    fn strict_rejects_when_only_same_sign_lot_present() {
1176        // STRICT against an inventory holding ONLY a same-sign (short)
1177        // lot must return NoMatchingLot — the single reducible lot fails
1178        // `can_reduce`, leaving zero matches. This pins all three `&&`
1179        // connectors in `try_reduce_strict`'s filter: each `&& -> ||`
1180        // mutant wrongly admits the short lot (currency==STK or the
1181        // always-true `matches_cost_spec` on the default spec satisfies
1182        // the disjunction), turning 0 matches into 1 and succeeding via
1183        // `try_reduce_from_lot` instead of erroring.
1184        let mut i = Inventory::new();
1185        i.add(lot(-10, 100, 1)); // short STK only; a sell is the same sign
1186        let res = i.try_reduce(
1187            &sell_stk(5),
1188            Some(&CostSpec::default()),
1189            BookingMethod::Strict,
1190        );
1191        assert!(
1192            matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
1193            "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1194        );
1195    }
1196
1197    // ---- Insufficient-units accounting --------------------------------
1198
1199    #[test]
1200    fn fifo_insufficient_reports_available() {
1201        // `available = requested - remaining`; kills the `-` → `+`/`/`
1202        // mutant in the insufficient branch.
1203        let inv = mk([lot(10, 100, 1)]);
1204        let err = inv
1205            .try_reduce(
1206                &sell_stk(15),
1207                Some(&CostSpec::default()),
1208                BookingMethod::Fifo,
1209            )
1210            .unwrap_err();
1211        match err {
1212            super::BookingError::InsufficientUnits {
1213                requested,
1214                available,
1215                ..
1216            } => {
1217                assert_eq!(requested, dec!(15));
1218                assert_eq!(available, dec!(10)); // 15 requested - 5 remaining
1219            }
1220            other => panic!("expected InsufficientUnits, got {other:?}"),
1221        }
1222    }
1223
1224    // ---- STRICT single-lot path (try_reduce_from_lot) -----------------
1225
1226    #[test]
1227    fn strict_single_lot_partial_cost_basis() {
1228        // Exactly one matching lot → try_reduce_from_lot; partial take.
1229        let inv = mk([lot(10, 100, 1)]);
1230        let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1231        assert_eq!(basis(&r), dec!(400)); // 4 * 100
1232    }
1233
1234    #[test]
1235    fn strict_single_lot_over_reduction_errors() {
1236        // from_lot `requested > available` guard.
1237        let inv = mk([lot(10, 100, 1)]);
1238        let err = inv
1239            .try_reduce(
1240                &sell_stk(11),
1241                Some(&CostSpec::default()),
1242                BookingMethod::Strict,
1243            )
1244            .unwrap_err();
1245        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1246    }
1247
1248    #[test]
1249    fn strict_single_lot_exact_full_reduction_succeeds() {
1250        // requested == available must succeed (kills from_lot `>` → `>=`).
1251        let inv = mk([lot(10, 100, 1)]);
1252        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1253        assert_eq!(basis(&r), dec!(1000));
1254    }
1255
1256    // ---- HIFO matched units + insufficient accounting ----------------
1257
1258    #[test]
1259    fn hifo_matched_units_and_insufficient_available() {
1260        let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1261        let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1262        // 8 taken from the $300 lot (kills the split `take * signum -> +`).
1263        assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1264        let err = inv
1265            .try_reduce(
1266                &sell_stk(25),
1267                Some(&CostSpec::default()),
1268                BookingMethod::Hifo,
1269            )
1270            .unwrap_err();
1271        match err {
1272            super::BookingError::InsufficientUnits { available, .. } => {
1273                assert_eq!(available, dec!(20)); // 20 held; kills `abs - remaining` mutants
1274            }
1275            other => panic!("expected InsufficientUnits, got {other:?}"),
1276        }
1277    }
1278
1279    #[test]
1280    fn strict_from_lot_matched_units() {
1281        let inv = mk([lot(10, 100, 1)]);
1282        let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1283        assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills from_lot split `* -> +`
1284    }
1285
1286    // ---- StrictWithSize ----------------------------------------------
1287
1288    #[test]
1289    fn strict_with_size_picks_exact_size_lot() {
1290        let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1291        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1292        assert_eq!(basis(&r), dec!(1000)); // 5 @ $200, the exact-size lot
1293    }
1294
1295    #[test]
1296    fn strict_with_size_ambiguous_without_exact_or_total() {
1297        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1298        let err = inv
1299            .try_reduce(
1300                &sell_stk(5),
1301                Some(&CostSpec::default()),
1302                BookingMethod::StrictWithSize,
1303            )
1304            .unwrap_err();
1305        assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
1306    }
1307
1308    #[test]
1309    fn strict_with_size_total_match_falls_back_to_fifo() {
1310        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1311        let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
1312        assert_eq!(basis(&r), dec!(3000)); // total match → FIFO: 1000 + 2000
1313    }
1314
1315    // ---- Mutating reduce() path (reduce_*) ----------------------------
1316
1317    #[test]
1318    fn reduce_fifo_commits_and_basis() {
1319        let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1320        let r = inv
1321            .reduce(
1322                &sell_stk(15),
1323                Some(&CostSpec::default()),
1324                BookingMethod::Fifo,
1325            )
1326            .unwrap();
1327        assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
1328        assert_eq!(inv.units("STK"), dec!(5)); // 20 - 15
1329    }
1330
1331    #[test]
1332    fn reduce_hifo_commits_basis_units_insufficient() {
1333        let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1334        let r = inv
1335            .reduce(
1336                &sell_stk(15),
1337                Some(&CostSpec::default()),
1338                BookingMethod::Hifo,
1339            )
1340            .unwrap();
1341        assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 10@300 + 5@100
1342        assert_eq!(r.matched[0].units.number.abs(), dec!(10)); // kills reduce_hifo split `* -> +`
1343        let mut inv2 = mk([lot(10, 100, 1)]);
1344        let err = inv2
1345            .reduce(
1346                &sell_stk(25),
1347                Some(&CostSpec::default()),
1348                BookingMethod::Hifo,
1349            )
1350            .unwrap_err();
1351        match err {
1352            super::BookingError::InsufficientUnits { available, .. } => {
1353                assert_eq!(available, dec!(10));
1354            }
1355            other => panic!("expected InsufficientUnits, got {other:?}"),
1356        }
1357    }
1358
1359    #[test]
1360    fn reduce_average_only_matching_currency() {
1361        let mut i = Inventory::new();
1362        i.add(lot(10, 100, 2));
1363        i.add(Position::with_cost(
1364            Amount::new(dec!(10), "OTH"),
1365            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1366        ));
1367        let r = i
1368            .reduce(
1369                &sell_stk(5),
1370                Some(&CostSpec::default()),
1371                BookingMethod::Average,
1372            )
1373            .unwrap();
1374        assert_eq!(r.cost_basis.unwrap().number, dec!(500)); // only the STK lot
1375    }
1376
1377    #[test]
1378    fn reduce_from_lot_matched_and_remaining_units() {
1379        let mut inv = mk([lot(10, 100, 1)]);
1380        let r = inv
1381            .reduce(
1382                &sell_stk(4),
1383                Some(&CostSpec::default()),
1384                BookingMethod::Strict,
1385            )
1386            .unwrap();
1387        assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills reduce_from_lot split `* -> +`
1388        // Assert the stored POSITION units directly, not `units()` — the
1389        // latter reads a separate incremental cache, so it would not catch
1390        // a bug in `new_units = pos.units.number + units.number`.
1391        let remaining: Vec<_> = inv.position_list();
1392        assert_eq!(remaining.len(), 1);
1393        assert_eq!(remaining[0].units.number, dec!(6)); // 10 + (-4); kills `+ -> -`/`*`
1394        assert_eq!(inv.units("STK"), dec!(6)); // cache stays consistent
1395    }
1396
1397    #[test]
1398    fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
1399        // Merge two long STK lots; a short STK lot (same sign as the
1400        // sell) and an unrelated OTH lot must be excluded from the merge
1401        // AND survive in the inventory.
1402        let mut inv = Inventory::new();
1403        inv.add(lot(10, 100, 1)); // long STK
1404        inv.add(lot(30, 200, 2)); // long STK
1405        inv.add(lot(-5, 999, 3)); // short STK — excluded by the sign filter
1406        inv.add(Position::with_cost(
1407            Amount::new(dec!(10), "OTH"), // different currency — excluded
1408            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
1409        ));
1410        let spec = CostSpec {
1411            merge: true,
1412            ..CostSpec::default()
1413        };
1414        let r = inv
1415            .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1416            .unwrap();
1417        // Only the two long STK lots merge: 40 units @ avg $175 → 20 * 175.
1418        // Including the short (sign) or OTH (currency) lot would change this.
1419        assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
1420        // The excluded lots must still be present (kills the retain-index mutant).
1421        assert!(
1422            inv.position_list()
1423                .iter()
1424                .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
1425            "OTH lot must survive the merge"
1426        );
1427        assert!(
1428            inv.position_list()
1429                .iter()
1430                .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
1431            "short STK lot must survive the merge"
1432        );
1433    }
1434
1435    #[test]
1436    fn reduce_none_exact_succeeds_over_reduction_errors() {
1437        let mut inv = Inventory::new();
1438        inv.add(Position::simple(Amount::new(dec!(10), "STK")));
1439        assert!(
1440            inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
1441            "exact NONE reduction should succeed"
1442        );
1443        let mut inv2 = Inventory::new();
1444        inv2.add(Position::simple(Amount::new(dec!(10), "STK")));
1445        let err = inv2
1446            .reduce(&sell_stk(15), None, BookingMethod::None)
1447            .unwrap_err();
1448        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1449    }
1450
1451    #[test]
1452    fn reduce_merge_uses_weighted_average() {
1453        let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1454        let spec = CostSpec {
1455            merge: true,
1456            ..CostSpec::default()
1457        };
1458        let r = inv
1459            .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1460            .unwrap();
1461        assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 20 @ avg $175
1462        assert_eq!(inv.units("STK"), dec!(20)); // 40 - 20
1463    }
1464}