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    /// The read-only preview of [`Self::reduce`]: returns exactly what
55    /// `reduce` would return — the same matched lots and cost basis on
56    /// success, the same error otherwise — without mutating `self`.
57    ///
58    /// Implemented as `reduce` on a clone, so it is equivalent BY
59    /// CONSTRUCTION. It previously re-implemented every booking method's
60    /// selection logic in a parallel `try_*` tree, which drifted from the
61    /// mutating path in three places (STRICT ambiguity, NONE shorting, `{*}`
62    /// merge dispatch) — the recurring one-logic-two-paths class (#1648,
63    /// #1663, #1686). The clone is cheap: `positions` is an
64    /// `imbl::Vector`, so cloning is O(1) structural sharing and `reduce`'s
65    /// copy-on-write rebuild touches only the clone. The
66    /// `try_reduce_predicts_reduce` property test pins the equivalence.
67    ///
68    /// # Arguments
69    ///
70    /// * `units` - The units to reduce (negative for selling)
71    /// * `cost_spec` - Optional cost specification for matching lots
72    /// * `method` - The booking method to use
73    ///
74    /// # Errors
75    ///
76    /// Exactly the errors [`Self::reduce`] would return for the same input.
77    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    /// STRICT booking: require exactly one matching lot, unless either:
87    ///
88    /// - all matching lots are identical in cost, in which case the choice
89    ///   between them is irrelevant and we fall back to the same ordering as
90    ///   FIFO (oldest `cost.date` first — see [`Self::reduce_ordered`]), or
91    /// - the reduction exactly matches the total units available across the
92    ///   matching lots (full liquidation), in which case all of them may be
93    ///   drained together without ambiguity.
94    ///
95    /// If multiple lots with *different* costs match and the reduction does
96    /// not qualify for the full-liquidation exception — for example a
97    /// wildcard reduction `-5 AAPL {}` against an inventory holding both
98    /// `{150 USD}` and `{160 USD}` — the reduction is genuinely ambiguous and
99    /// we return `AmbiguousMatch`, matching Python beancount's
100    /// `AmbiguousMatchError` and the formal `STRICTCorrect.tla` specification.
101    ///
102    /// # The "interchangeable lots" heuristic
103    ///
104    /// We treat two matched lots as interchangeable when their `(cost.number,
105    /// cost.currency)` agree — the user-visible monetary identity. We
106    /// deliberately ignore `cost.date` and `cost.label`: the user's cost spec
107    /// could not have constrained those fields without naming them, so two
108    /// lots that differ only on date/label could not have been distinguished
109    /// by the spec the user wrote, and the date-ordered fallback is
110    /// unambiguous within that equivalence class.
111    ///
112    /// A stricter spec-derived check would compare each pair of matched lots
113    /// on every cost field the spec did *not* constrain. The simpler
114    /// number+currency check matches Python beancount's behavior for the
115    /// real-world cases we know about (see
116    /// `test_reduce_strict_multiple_match_with_identical_costs_uses_fifo` and
117    /// the `test_validate_multiple_lot_match_uses_fifo` integration test for
118    /// the same-cost-different-date case).
119    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                // Are the matched lots financially interchangeable? Two lots
148                // count as identical if they have the same cost number + cost
149                // currency — the user-visible monetary identity. Date and label
150                // differences don't make a reduction ambiguous because the user
151                // could not have observed a different outcome based on the cost
152                // spec they wrote. Beancount falls back to FIFO in that case.
153                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                // Total match exception: if the reduction equals the sum of all
170                // matching lots, the user is selling the entire matched
171                // inventory and the lot choice doesn't matter — accept it.
172                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    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
189    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                // Check for exact-size match with any lot
218                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                    // Total match exception
226                    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                    // Use oldest (first) exact-size match
240                    let idx = exact_matches[0];
241                    self.reduce_from_lot(idx, units)
242                }
243            }
244        }
245    }
246
247    /// FIFO booking: reduce from oldest lots first.
248    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    /// LIFO booking: reduce from newest lots first.
257    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    /// HIFO booking: reduce from highest-cost lots first.
266    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        // Get matching positions with their costs
277        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        // Sort by cost descending (highest first)
301        matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
302
303        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
304
305        // Check sufficiency BEFORE mutating any lot: a failed reduction must
306        // leave the inventory untouched (same invariant as `reduce_ordered`;
307        // see the comment there and `booking_properties.rs`).
308        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            // Calculate cost basis for this portion
330            if let Some(cost) = &pos.cost {
331                cost_basis += take * cost.number;
332                cost_currency = Some(cost.currency.clone());
333            }
334
335            // Record what we matched
336            let (taken, _) = pos.split(take * pos.units.number.signum());
337            matched.push(taken);
338
339            // Reduce the lot
340            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        // Clean up empty positions
356        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    /// Reduce in order (FIFO or LIFO).
366    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        // Get indices of matching positions
378        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        // Sort by date for correct FIFO/LIFO ordering (oldest first)
392        // This ensures we select by acquisition date, not insertion order
393        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        // Get cost currency from first lot (all lots of same commodity have same cost currency)
407        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        // Check sufficiency BEFORE mutating any lot: a failed reduction must
414        // leave the inventory untouched. The validator reduces against the
415        // live `LedgerState` inventories, so a partial drain on the error
416        // path would corrupt every later balance assertion on the account
417        // (found by the failed-reduce-must-not-mutate property in
418        // `rustledger-booking/tests/booking_properties.rs`).
419        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            // Calculate cost basis for this portion
441            if let Some(cost) = &pos.cost {
442                cost_basis += take * cost.number;
443            }
444
445            // Record what we matched
446            let (taken, _) = pos.split(take * pos.units.number.signum());
447            matched.push(taken);
448
449            // Reduce the lot - modify in place to avoid cloning
450            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        // Clean up empty positions
461        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    /// AVERAGE booking: merge all lots of the currency.
471    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        // Build a position of `number` units of the reduced currency at the
503        // average cost (or costless if the lots had no cost).
504        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        // A reduction under AVERAGE matches a SINGLE synthetic lot of the
515        // reduced quantity at the average cost, not every underlying lot.
516        // Returning the full lot set made the consumer (book.rs) expand the
517        // reduction into one posting per lot and remove the entire position
518        // (and book a garbage gain). The taken units carry the *inventory* sign
519        // (`total_units.signum()`), matching the FIFO/ordered convention — so
520        // covering a short (negative pool) yields a negative matched lot.
521        let matched: MatchedLots = smallvec![at_avg_cost(reduction * total_units.signum())];
522
523        let new_units = total_units + units.number;
524
525        // Remove all positions of this currency
526        self.positions
527            .retain(|p| p.units.currency != units.currency);
528
529        // Add back the remainder (if non-zero) at the average cost, so a later
530        // reduction sees the correct basis instead of a costless position.
531        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    /// Collapse every cost-bearing lot of each currency into a single
544    /// weighted-average-cost lot. Cost-less (cash) positions are left untouched.
545    ///
546    /// This realizes the balance of an AVERAGE-booked account, where all lots of
547    /// a commodity share one running cost. The journal keeps the real per-lot
548    /// costs; only this realized view merges them (matching hledger's pool
549    /// model). A currency whose lots net to zero is removed; a currency whose
550    /// lots have mismatched cost currencies is left untouched.
551    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            // Couldn't average a non-zero position (cost-currency mismatch):
578            // leave its lots untouched rather than corrupt them.
579            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    /// Cost merge `{*}`: merge all lots of the currency into a single
596    /// weighted-average-cost lot, then reduce from it.
597    ///
598    /// Example: 10 AAPL {150 USD} + 10 AAPL {160 USD} merged = 20 AAPL {155 USD}.
599    /// Reducing 5 AAPL {*} takes 5 from the merged 20 AAPL {155 USD} lot.
600    pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
601        // Only merge lots with opposite sign (same as other reduce methods).
602        // This prevents accidentally netting long and short positions.
603        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        // Compute weighted-average cost from matching lots.
634        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        // Return a single synthetic matched position representing the merged lot.
644        // This prevents the booking engine from expanding the posting into multiple
645        // postings (one per original lot), which would be incorrect for {*}.
646        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        // Remove all matching lots of this currency
659        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        // Add back a single merged lot with the remainder
669        let remaining = total_units + units.number; // units.number is negative for reductions
670        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    /// NONE booking: reduce without matching lots.
686    pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
687        // For NONE booking, we just reduce the total without caring about lots
688        let total_units = self.units(&units.currency);
689
690        // Check we have enough in the right direction
691        if total_units.signum() == units.number.signum() || total_units.is_zero() {
692            // This is an augmentation, not a reduction - just add it
693            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            // NONE performs no booking, so shorts are always allowed —
705            // matching beancount's NONE semantics and NONECorrect.tla. This
706            // arm previously returned InsufficientUnits, which made the
707            // outcome depend on whether zero was crossed in one step (0 → -2
708            // was allowed above; +1 → -1 was rejected here). Found by the
709            // TLA+ behavior-replay suite (#1686): consume everything
710            // available, then carry the remainder as a negative (short)
711            // simple position.
712            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        // Reduce positions proportionally (simplified: just reduce first matching)
723        self.reduce_ordered(units, &CostSpec::default(), false)
724    }
725
726    /// Reduce from a specific lot.
727    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        // Calculate cost basis
745        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
746
747        // Record matched
748        let (matched, _) = pos.split(requested * pos.units.number.signum());
749
750        // Update the position
751        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        // Update units cache incrementally (units.number is negative for reductions)
760        if let Some(cached) = self.units_cache.get_mut(&currency) {
761            *cached += units.number;
762        }
763
764        // Remove if empty and rebuild simple_index
765        if self.positions[idx].is_empty() {
766            self.positions.remove(idx);
767            // Only rebuild simple_index when position is removed
768            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    //! Direct unit tests for the read-only `try_reduce_*` booking paths.
786    //!
787    //! These pin exact cost-basis, lot selection, and guard behavior so
788    //! the lot-reduction mutants surfaced by the #1309 audit are killed
789    //! (the public mutating `reduce_*` path was covered indirectly, but
790    //! the `try_reduce_*` preview path had no direct assertions).
791    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    /// A cost-bearing lot of `units` STK at `cost` USD, dated 2024-01-`day`.
800    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    // ---- FIFO / LIFO ordered ------------------------------------------
829
830    #[test]
831    fn fifo_partial_multilot_cost_basis_and_order() {
832        // 10 @ $100 (older), 10 @ $200 (newer); sell 15.
833        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
834        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
835        // FIFO: 10@100 + 5@200 = 1000 + 1000 = 2000.
836        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        // LIFO: 10@200 + 5@100 = 2000 + 500 = 2500 (distinguishes the
849        // `reverse` flag from FIFO's 2000).
850        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)); // 3 * 100
859    }
860
861    // ---- HIFO ---------------------------------------------------------
862
863    #[test]
864    fn hifo_takes_highest_cost_lot_first() {
865        // costs 100, 300, 200 → HIFO order 300, 200, 100.
866        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        // 10@300 + 5@200 = 3000 + 1000 = 4000.
869        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    // ---- AVERAGE ------------------------------------------------------
875
876    #[test]
877    fn average_cost_basis_partial() {
878        // 10 @ $100, 30 @ $200 → 40 units, $7000 total, avg $175.
879        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)); // 20 * 175
882    }
883
884    #[test]
885    fn average_reduce_exact_total_succeeds() {
886        // Reducing exactly the held quantity must succeed (kills
887        // `reduction > total` → `>=`/`==`).
888        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)); // 40 * 175
891    }
892
893    #[test]
894    fn average_over_reduction_errors() {
895        // Reducing more than held must error (kills `>` → `<`).
896        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    // ---- Filter isolation (currency / sign) ---------------------------
908    // One fixture per method: an unrelated OTH lot plus the real STK lot.
909    // A correct reducer touches ONLY the real STK lot; the currency `==`
910    // and the `&&` connecting it would pull OTH in (or drop the real
911    // one), changing the basis. (A zero-units "empty" lot is intentionally
912    // NOT added here: `Inventory::add` drops empty positions on insert, so
913    // the `!is_empty()` filter clause is unreachable for add-built
914    // inventories and can't be exercised this way.)
915
916    fn isolation_inv() -> Inventory {
917        let mut i = Inventory::new();
918        i.add(Position::with_cost(
919            Amount::new(dec!(10), "OTH"), // different currency: must be ignored
920            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
921        ));
922        i.add(lot(10, 100, 2)); // the real STK lot
923        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        // average filters by currency + non-empty (no cost-spec / sign filter).
958        let inv = isolation_inv();
959        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
960        // Only the STK lot participates: 10 units @ $100 → avg $100 → 5 * 100.
961        assert_eq!(basis(&r), dec!(500));
962    }
963
964    // ---- Sign guard ---------------------------------------------------
965
966    #[test]
967    fn does_not_match_same_sign_lot() {
968        // A short (negative) STK lot must NOT satisfy a sell (negative
969        // units): same sign. Only the long lot is reducible. Kills the
970        // `signum() != signum()` → `==` mutant (== would match the short
971        // lot or nothing).
972        let mut i = Inventory::new();
973        i.add(lot(-10, 50, 1)); // short lot, same sign as a sell
974        i.add(lot(10, 100, 2)); // long lot
975        let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
976        assert_eq!(basis(&r), dec!(500)); // 5 * 100 from the long lot only
977        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        // STRICT against an inventory holding ONLY a same-sign (short)
983        // lot must return NoMatchingLot — the single reducible lot fails
984        // `can_reduce`, leaving zero matches. This pins all three `&&`
985        // connectors in `try_reduce_strict`'s filter: each `&& -> ||`
986        // mutant wrongly admits the short lot (currency==STK or the
987        // always-true `matches_cost_spec` on the default spec satisfies
988        // the disjunction), turning 0 matches into 1 and succeeding via
989        // `try_reduce_from_lot` instead of erroring.
990        let mut i = Inventory::new();
991        i.add(lot(-10, 100, 1)); // short STK only; a sell is the same sign
992        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    // ---- Insufficient-units accounting --------------------------------
1004
1005    #[test]
1006    fn fifo_insufficient_reports_available() {
1007        // `available = requested - remaining`; kills the `-` → `+`/`/`
1008        // mutant in the insufficient branch.
1009        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)); // 15 requested - 5 remaining
1025            }
1026            other => panic!("expected InsufficientUnits, got {other:?}"),
1027        }
1028    }
1029
1030    // ---- STRICT single-lot path (try_reduce_from_lot) -----------------
1031
1032    #[test]
1033    fn strict_single_lot_partial_cost_basis() {
1034        // Exactly one matching lot → try_reduce_from_lot; partial take.
1035        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)); // 4 * 100
1038    }
1039
1040    #[test]
1041    fn strict_single_lot_over_reduction_errors() {
1042        // from_lot `requested > available` guard.
1043        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        // requested == available must succeed (kills from_lot `>` → `>=`).
1057        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    // ---- HIFO matched units + insufficient accounting ----------------
1063
1064    #[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        // 8 taken from the $300 lot (kills the split `take * signum -> +`).
1069        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)); // 20 held; kills `abs - remaining` mutants
1080            }
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)); // kills from_lot split `* -> +`
1090    }
1091
1092    // ---- StrictWithSize ----------------------------------------------
1093
1094    #[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)); // 5 @ $200, the exact-size lot
1099    }
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)); // total match → FIFO: 1000 + 2000
1119    }
1120
1121    // ---- Mutating reduce() path (reduce_*) ----------------------------
1122
1123    #[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)); // 20 - 15
1135    }
1136
1137    #[test]
1138    fn reduce_on_large_shared_inventory_does_not_corrupt() {
1139        // Regression: the rich-workload profiler found a heap-corruption /
1140        // SIGSEGV when reducing an inventory that had been cloned (imbl O(1)
1141        // structural share, as the booking engine does for working copies).
1142        // In-place mutation of the SHARED imbl `Vector` double-freed the interned
1143        // `Arc<str>` inside `Position`. Needs >64 distinct lots so the `Vector`
1144        // spans multiple Arc-backed chunks — the representation that actually
1145        // shares (and corrupted). Without the fix this aborts/segfaults on drop.
1146        // 100 distinct-cost lots (>64 = the imbl chunk size) so the `Vector`
1147        // spans multiple Arc-backed chunks — the shared representation that
1148        // corrupted. Day stays a valid 1..=28 (lots remain distinct by cost).
1149        // The Miri CI job (`rustledger-core`, strict provenance) executes this
1150        // and flags the use-after-free deterministically when the guard is gone.
1151        let mut inv = mk((0i64..100).map(|i| lot(10, 100 + i, ((i % 28) + 1) as u32)));
1152        let snapshot = inv.clone(); // structurally shares chunks with `inv`
1153        inv.reduce(
1154            &sell_stk(700),
1155            Some(&CostSpec::default()),
1156            BookingMethod::Fifo,
1157        )
1158        .unwrap();
1159        assert_eq!(inv.units("STK"), dec!(300)); // 1000 - 700
1160        // The shared snapshot stays independent and intact; `units` re-reads
1161        // every interned currency, and dropping both must not double-free.
1162        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)); // 10@300 + 5@100
1176        assert_eq!(r.matched[0].units.number.abs(), dec!(10)); // kills reduce_hifo split `* -> +`
1177        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)); // only the STK lot
1209    }
1210
1211    #[test]
1212    fn reduce_average_partial_multi_lot_matches_single_synthetic_lot() {
1213        // Regression: a partial AVERAGE sale across multiple lots matches a
1214        // SINGLE synthetic lot of the reduced quantity at the average cost, not
1215        // every underlying lot. Returning the full lot set made the consumer
1216        // (book.rs) expand the reduction into one posting per lot, emptying the
1217        // position and booking a garbage gain.
1218        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        // One synthetic matched lot at the average cost {160}; basis 5*160=800.
1230        // Long pool: the matched lot carries the inventory (positive) sign.
1231        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        // 15 STK remain as a single lot carrying the average cost {160}.
1237        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        // Covering a short (positive units reducing a negative pool) must return
1249        // a matched lot with the inventory (negative) sign, like FIFO/ordered.
1250        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        // Short pool shrinks from -10 to -5.
1265        assert_eq!(i.units("STK"), dec!(-5));
1266    }
1267
1268    #[test]
1269    fn merge_average_collapses_lots_to_single_weighted_lot() {
1270        // The realized balance of an AVERAGE account is one pool at the
1271        // weighted-average cost: (10*150 + 10*170 - 5*160) / 15 = 160.
1272        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        // Cash stays; the single STK lot stays a single lot.
1311        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)); // kills reduce_from_lot split `* -> +`
1329        // Assert the stored POSITION units directly, not `units()` — the
1330        // latter reads a separate incremental cache, so it would not catch
1331        // a bug in `new_units = pos.units.number + units.number`.
1332        let remaining: Vec<_> = inv.position_list();
1333        assert_eq!(remaining.len(), 1);
1334        assert_eq!(remaining[0].units.number, dec!(6)); // 10 + (-4); kills `+ -> -`/`*`
1335        assert_eq!(inv.units("STK"), dec!(6)); // cache stays consistent
1336    }
1337
1338    #[test]
1339    fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
1340        // Merge two long STK lots; a short STK lot (same sign as the
1341        // sell) and an unrelated OTH lot must be excluded from the merge
1342        // AND survive in the inventory.
1343        let mut inv = Inventory::new();
1344        inv.add(lot(10, 100, 1)); // long STK
1345        inv.add(lot(30, 200, 2)); // long STK
1346        inv.add(lot(-5, 999, 3)); // short STK — excluded by the sign filter
1347        inv.add(Position::with_cost(
1348            Amount::new(dec!(10), "OTH"), // different currency — excluded
1349            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        // Only the two long STK lots merge: 40 units @ avg $175 → 20 * 175.
1359        // Including the short (sign) or OTH (currency) lot would change this.
1360        assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
1361        // The excluded lots must still be present (kills the retain-index mutant).
1362        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        // NONE performs no booking, so over-reduction shorts past zero
1385        // instead of erroring (#1686 — previously InsufficientUnits, which
1386        // made the outcome depend on whether zero was crossed in one step).
1387        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)); // 20 @ avg $175
1408        assert_eq!(inv.units("STK"), dec!(20)); // 40 - 20
1409    }
1410}