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 jiff::civil::Date as NaiveDate;
8use rust_decimal::Decimal;
9use rust_decimal::prelude::Signed;
10
11use smallvec::{SmallVec, smallvec};
12
13use super::{
14    BookingError, BookingMethod, BookingResult, Inventory, LotOrder, MatchedLots, OverflowError,
15};
16use crate::{Amount, Cost, CostSpec, Currency, Position};
17
18/// Compute weighted-average cost from a set of positions.
19///
20/// Returns `(avg_cost_per_unit, cost_currency)` or `None` if no positions have cost info.
21/// Returns `Err(CurrencyMismatch)` if positions have costs in different currencies.
22fn average_cost_from_positions(
23    positions: &[&Position],
24    total_units: Decimal,
25) -> Result<Option<(Decimal, Currency)>, BookingError> {
26    let mut total_cost = Decimal::ZERO;
27    let mut cost_currency: Option<Currency> = None;
28    let mut has_any_cost = false;
29
30    for pos in positions {
31        if let Some(cost) = &pos.cost {
32            has_any_cost = true;
33            if let Some(ref cc) = cost_currency {
34                if *cc != cost.currency {
35                    return Err(BookingError::CurrencyMismatch {
36                        expected: cc.clone(),
37                        got: cost.currency.clone(),
38                    });
39                }
40            } else {
41                cost_currency = Some(cost.currency.clone());
42            }
43            // Checked: the product needs the sum of its operands' digits, so
44            // it can leave range well below the ceiling (#1863).
45            total_cost = pos
46                .units
47                .number
48                .checked_mul(cost.number)
49                .and_then(|v| total_cost.checked_add(v))
50                .ok_or_else(|| {
51                    BookingError::Overflow(OverflowError {
52                        currency: cost.currency.clone(),
53                    })
54                })?;
55        }
56    }
57
58    if !has_any_cost || cost_currency.is_none() {
59        return Ok(None);
60    }
61
62    Ok(Some((total_cost / total_units, cost_currency.unwrap())))
63}
64
65/// A reduction computed from `&Inventory` but not yet applied.
66///
67/// The two variants mirror the two commit shapes the booking methods already
68/// had: the single-lot path maintains its caches incrementally, the multi-lot
69/// path rewrites lots and then rebuilds. Keeping them distinct means
70/// splitting preview from commit costs the commit path nothing.
71pub(super) enum ReductionPlan {
72    /// One lot reduced to `new_units`.
73    FromLot {
74        /// Index of the lot in `positions`.
75        idx: usize,
76        /// What that lot's units number becomes.
77        new_units: Decimal,
78    },
79    /// `(index, new units number)` pairs, applied in order.
80    Updates(SmallVec<[(usize, Decimal); 1]>),
81}
82
83/// What a `{*}` merge would do, computed without doing it.
84///
85/// The plan half of [`Inventory::reduce_merge`]; see [`Inventory::plan_merge`].
86struct MergePlan {
87    /// Slots that merge into the pool.
88    matching_indices: std::collections::HashSet<usize>,
89    /// Units held across those slots.
90    total_units: Decimal,
91    /// The pool's per-unit cost, or `None` when the lots carry no cost.
92    pool: Option<Amount>,
93}
94
95impl Inventory {
96    /// Try reducing positions without modifying the inventory.
97    ///
98    /// The read-only preview of [`Self::reduce`]: returns exactly what
99    /// `reduce` would return — the same matched lots and cost basis on
100    /// success, the same error otherwise — without mutating `self`.
101    ///
102    /// Implemented as `reduce` on a clone, so it is equivalent BY
103    /// CONSTRUCTION. It previously re-implemented every booking method's
104    /// selection logic in a parallel `try_*` tree, which drifted from the
105    /// mutating path in three places (STRICT ambiguity, NONE shorting, `{*}`
106    /// merge dispatch) — the recurring one-logic-two-paths class (#1648,
107    /// #1663, #1686). The clone is cheap: `positions` is an
108    /// `imbl::Vector`, so cloning is O(1) structural sharing and `reduce`'s
109    /// copy-on-write rebuild touches only the clone. The
110    /// `try_reduce_predicts_reduce` property test pins the equivalence.
111    ///
112    /// # Arguments
113    ///
114    /// * `units` - The units to reduce (negative for selling)
115    /// * `cost_spec` - Optional cost specification for matching lots
116    /// * `method` - The booking method to use
117    ///
118    /// # Errors
119    ///
120    /// Exactly the errors [`Self::reduce`] would return for the same input.
121    pub fn try_reduce(
122        &self,
123        units: &Amount,
124        cost_spec: Option<&CostSpec>,
125        method: BookingMethod,
126    ) -> Result<BookingResult, BookingError> {
127        let spec = cost_spec.cloned().unwrap_or_default();
128
129        // Planned methods answer from `&self`. The rest still preview by
130        // cloning — correct, just O(lots). Converting them is mechanical and
131        // follows the same shape; these are simply not the ones the profiling
132        // shapes exercise.
133        if spec.merge {
134            return self.clone().reduce(units, cost_spec, method);
135        }
136        match method {
137            BookingMethod::Strict => self.plan_strict(units, &spec).map(|(r, _)| r),
138            BookingMethod::Fifo => self
139                .plan_ordered(units, &spec, LotOrder::Date)
140                .map(|(r, _)| r),
141            BookingMethod::Lifo => self
142                .plan_ordered(units, &spec, LotOrder::DateDescending)
143                .map(|(r, _)| r),
144            BookingMethod::Hifo => self.plan_hifo(units, &spec).map(|(r, _)| r),
145            BookingMethod::StrictWithSize => {
146                self.plan_strict_with_size(units, &spec).map(|(r, _)| r)
147            }
148            BookingMethod::Average | BookingMethod::None => {
149                self.clone().reduce(units, cost_spec, method)
150            }
151        }
152    }
153
154    /// Apply a [`ReductionPlan`] produced by one of the `plan_*` methods.
155    fn commit_plan(&mut self, plan: &ReductionPlan, units: &Amount) {
156        match plan {
157            ReductionPlan::FromLot { idx, new_units } => {
158                self.commit_from_lot(*idx, units, *new_units);
159            }
160            ReductionPlan::Updates(updates) => self.commit_updates(updates),
161        }
162    }
163
164    /// STRICT booking: require exactly one matching lot, unless either:
165    ///
166    /// - all matching lots are identical in cost, in which case the choice
167    ///   between them is irrelevant and we fall back to the same ordering as
168    ///   FIFO (oldest `cost.date` first — see [`Self::reduce_ordered`]), or
169    /// - the reduction exactly matches the total units available across the
170    ///   matching lots (full liquidation), in which case all of them may be
171    ///   drained together without ambiguity.
172    ///
173    /// If multiple lots with *different* costs match and the reduction does
174    /// not qualify for the full-liquidation exception — for example a
175    /// wildcard reduction `-5 AAPL {}` against an inventory holding both
176    /// `{150 USD}` and `{160 USD}` — the reduction is genuinely ambiguous and
177    /// we return `AmbiguousMatch`, matching Python beancount's
178    /// `AmbiguousMatchError` and the formal `STRICTCorrect.tla` specification.
179    ///
180    /// # The "interchangeable lots" heuristic
181    ///
182    /// We treat two matched lots as interchangeable when their `(cost.number,
183    /// cost.currency)` agree — the user-visible monetary identity. We
184    /// deliberately ignore `cost.date` and `cost.label`: the user's cost spec
185    /// could not have constrained those fields without naming them, so two
186    /// lots that differ only on date/label could not have been distinguished
187    /// by the spec the user wrote, and the date-ordered fallback is
188    /// unambiguous within that equivalence class.
189    ///
190    /// A stricter spec-derived check would compare each pair of matched lots
191    /// on every cost field the spec did *not* constrain. The simpler
192    /// number+currency check matches Python beancount's behavior for the
193    /// real-world cases we know about (see
194    /// `test_reduce_strict_multiple_match_with_identical_costs_uses_fifo` and
195    /// the `test_validate_multiple_lot_match_uses_fifo` integration test for
196    /// the same-cost-different-date case).
197    pub(super) fn reduce_strict(
198        &mut self,
199        units: &Amount,
200        spec: &CostSpec,
201    ) -> Result<BookingResult, BookingError> {
202        let (result, plan) = self.plan_strict(units, spec)?;
203        self.commit_plan(&plan, units);
204        Ok(result)
205    }
206
207    /// The read-only half of [`Self::reduce_strict`].
208    ///
209    /// STRICT is a dispatcher: one match delegates to the single-lot path,
210    /// financially-interchangeable or wholly-consumed multi-matches fall back
211    /// to FIFO ordering, and anything else is ambiguous and mutates nothing.
212    /// This mirrors that dispatch by delegating to the same two planners the
213    /// mutating path uses. See [`Self::plan_from_lot`] for why the selection
214    /// logic must not be duplicated into `try_reduce`.
215    pub(super) fn plan_strict(
216        &self,
217        units: &Amount,
218        spec: &CostSpec,
219    ) -> Result<(BookingResult, ReductionPlan), BookingError> {
220        // Candidates from the cost index when the spec names a per-unit cost,
221        // otherwise every lot — a spec without one can match anything. Both
222        // arms apply the SAME predicate, so the index never decides a match;
223        // it only narrows what the predicate is run on.
224        //
225        // That asymmetry is the whole safety argument, and it only runs one
226        // way. A STALE entry is harmless: it names a tombstone or a lot that
227        // no longer matches, and the predicate discards it. A MISSING entry is
228        // NOT: the lot is never offered to the predicate at all, so a
229        // reduction that should have matched it reports no matching lot or
230        // books as an augmentation and duplicates the position. Index
231        // maintenance is therefore a correctness obligation, not an
232        // optimization — `draining_a_lot_removes_it_from_the_cost_index` and
233        // the `add`/rebuild mutations are what hold it.
234        let matching_indices: Vec<usize> = match self.cost_candidates(units, spec) {
235            Some(slots) => slots
236                .into_iter()
237                .filter(|i| {
238                    // `get`, not `[]`: indexing a tombstone panics, and a
239                    // stale entry must not be able to crash on user data. With
240                    // `get` it is merely a wasted candidate, which the
241                    // predicate discards anyway.
242                    self.positions.get(*i).is_some_and(|p| {
243                        p.units.currency == units.currency
244                            && !p.is_empty()
245                            && p.can_reduce(units)
246                            && p.matches_cost_spec(spec)
247                    })
248                })
249                .collect(),
250            None => self
251                .positions
252                .iter_slots()
253                .filter(|(_, p)| {
254                    p.units.currency == units.currency
255                        && !p.is_empty()
256                        && p.can_reduce(units)
257                        && p.matches_cost_spec(spec)
258                })
259                .map(|(i, _)| i)
260                .collect(),
261        };
262
263        match matching_indices.len() {
264            0 => Err(BookingError::NoMatchingLot {
265                currency: units.currency.clone(),
266                cost_spec: spec.clone(),
267            }),
268            1 => {
269                let idx = matching_indices[0];
270                let (result, new_units) = self.plan_from_lot(idx, units)?;
271                Ok((result, ReductionPlan::FromLot { idx, new_units }))
272            }
273            n => {
274                // Two or more lots match, so the spec the user wrote does not
275                // name one. STRICT's contract is to refuse to guess, and this
276                // arm is deliberately the whole of it — the only escape is the
277                // total-match exception below.
278                //
279                // The one escape besides that is lots which are identical in
280                // EVERY cost field — number, currency, date and label. Those
281                // are indistinguishable by construction, so draining them in
282                // date order cannot be observed.
283                //
284                // This used to compare the number and currency ALONE, on the
285                // stated grounds that "the user could not have observed a
286                // different outcome" and that "beancount falls back to FIFO in
287                // that case". Both were wrong (#2097). Beancount's
288                // `booking_method_STRICT` has no fallback at all: more than one
289                // match is the total-match exception or an
290                // `AmbiguousMatchError`. And ignoring the date made the outcome
291                // very much observable — selling 16 of
292                // `4 GLOB {74.09, 2022-05-10}` + `16 GLOB {74.09, 2024-02-09}`
293                // leaves 4 GLOB dated 2024 under FIFO and 4 GLOB dated 2022
294                // under any other choice. Same cost basis, which is what made
295                // it quiet, but a different HOLDING PERIOD — and this codebase
296                // acts on holding periods, in `report capgains`'s short/long
297                // split and in the per-lot IRR eligibility predicate. That is a
298                // tax-visible decision, made silently, under the one booking
299                // method whose entire purpose is to make the user state it.
300                //
301                // Why keep the narrowed form rather than delete it outright:
302                // beancount's `Inventory` is keyed by `(currency, cost)`, so
303                // two buys of the same commodity at the same price on the same
304                // day are ONE position there and can never be ambiguous. Ours
305                // stays two lots, so deleting this arm would reject a ledger
306                // beancount accepts — a very ordinary one. Comparing the full
307                // cost reproduces beancount's observable behavior without
308                // changing how positions are stored; merging them at `add`
309                // would be the more faithful model and a much larger change
310                // (`Inventory::len` counts lots, and `currency_accounts`
311                // branches on it).
312                //
313                // The user disambiguates by naming the lot: `{74.09 USD,
314                // 2022-05-10}`, a label, or an account booked FIFO/HIFO if
315                // they genuinely do not care which goes.
316                let first_cost = self.positions[matching_indices[0]].cost.as_ref();
317                let all_indistinguishable = matching_indices
318                    .iter()
319                    .skip(1)
320                    .all(|&i| self.positions[i].cost.as_ref() == first_cost);
321
322                if all_indistinguishable {
323                    let (result, updates) = self.plan_ordered(units, spec, LotOrder::Date)?;
324                    return Ok((result, ReductionPlan::Updates(updates)));
325                }
326
327                // Total match exception: if the reduction equals the sum of all
328                // matching lots, every matched lot is consumed, so no lot
329                // survives to carry a date and the choice cannot be observed.
330                // Beancount has this same exception, and for the same reason.
331                let total_units: Decimal = matching_indices
332                    .iter()
333                    .map(|&i| self.positions[i].units.number.abs())
334                    .sum();
335                if total_units == units.number.abs() {
336                    let (result, updates) = self.plan_ordered(units, spec, LotOrder::Date)?;
337                    return Ok((result, ReductionPlan::Updates(updates)));
338                }
339
340                Err(BookingError::AmbiguousMatch {
341                    num_matches: n,
342                    currency: units.currency.clone(),
343                })
344            }
345        }
346    }
347
348    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
349    /// `STRICT_WITH_SIZE`: an explicit cost, disambiguated by lot size.
350    ///
351    /// Planned from `&self` so `try_reduce` can preview it without copying the
352    /// inventory. It used to be reachable only through `self.clone().reduce()`
353    /// — an O(lots) copy per reducing posting, which is quadratic across a
354    /// ledger and was most of this method's cost (#2091). The conversion is the
355    /// one #2061 left as "mechanical" when it split STRICT, FIFO and LIFO.
356    pub(super) fn plan_strict_with_size(
357        &self,
358        units: &Amount,
359        spec: &CostSpec,
360    ) -> Result<(BookingResult, ReductionPlan), BookingError> {
361        // Narrow through the cost index before filtering, the way `plan_strict`
362        // does. This walked every slot in the account on every reduction. A
363        // spec naming a cost has a handful of candidates; one that names none
364        // still scans, because it can match anything.
365        let candidates: Vec<usize> = self.cost_candidates(units, spec).unwrap_or_else(|| {
366            self.positions
367                .iter_slots()
368                .filter(|(_, p)| p.units.currency == units.currency)
369                .map(|(i, _)| i)
370                .collect()
371        });
372        let matching_indices: Vec<usize> = candidates
373            .into_iter()
374            .filter(|&i| {
375                self.positions.get(i).is_some_and(|p| {
376                    p.units.currency == units.currency
377                        && !p.is_empty()
378                        && p.can_reduce(units)
379                        && p.matches_cost_spec(spec)
380                })
381            })
382            .collect();
383
384        let from_lot = |idx: usize| {
385            self.plan_from_lot(idx, units)
386                .map(|(result, new_units)| (result, ReductionPlan::FromLot { idx, new_units }))
387        };
388
389        match matching_indices.len() {
390            0 => Err(BookingError::NoMatchingLot {
391                currency: units.currency.clone(),
392                cost_spec: spec.clone(),
393            }),
394            1 => from_lot(matching_indices[0]),
395            n => {
396                // A lot of exactly the reduction's size disambiguates. When
397                // SEVERAL do, the OLDEST wins — beancount sorts the size
398                // matches by `cost.date` and takes the first, and the choice
399                // is observable in both the basis realized and the holding
400                // period of whatever survives.
401                //
402                // This used to take the first candidate in slot order, which
403                // is insertion order. That is usually date order and so
404                // usually agreed by accident, but a lot carrying an explicit
405                // cost date (`{100.00 USD, 2030-01-01}`) is inserted when its
406                // transaction is booked and dated whenever the user said. Buy
407                // 10 X {100.00, 2030-01-01} then 10 X {200.00, 2020-01-01} and
408                // sell 10 X {}: beancount sells the 2020 lot and leaves 1000
409                // USD of basis, slot order sells the 2030 lot and leaves 2000.
410                // Neither reports anything.
411                //
412                // Ties break on slot index so the result stays deterministic
413                // when two size matches share a date; `None` dates sort last,
414                // since a booked lot always has one and an unbooked lot is not
415                // the one the user meant.
416                let exact = matching_indices
417                    .iter()
418                    .copied()
419                    .filter(|&i| self.positions[i].units.number.abs() == units.number.abs())
420                    .min_by_key(|&i| {
421                        (
422                            self.positions[i]
423                                .cost
424                                .as_ref()
425                                .and_then(|c| c.date)
426                                .map_or((1, NaiveDate::MAX), |d| (0, d)),
427                            i,
428                        )
429                    });
430                if let Some(idx) = exact {
431                    return from_lot(idx);
432                }
433                // Total match exception: selling the whole matched inventory
434                // makes the choice of lot irrelevant.
435                let total_units: Decimal = matching_indices
436                    .iter()
437                    .map(|&i| self.positions[i].units.number.abs())
438                    .sum();
439                if total_units == units.number.abs() {
440                    let (result, updates) = self.plan_ordered(units, spec, LotOrder::Date)?;
441                    return Ok((result, ReductionPlan::Updates(updates)));
442                }
443                Err(BookingError::AmbiguousMatch {
444                    num_matches: n,
445                    currency: units.currency.clone(),
446                })
447            }
448        }
449    }
450
451    pub(super) fn reduce_strict_with_size(
452        &mut self,
453        units: &Amount,
454        spec: &CostSpec,
455    ) -> Result<BookingResult, BookingError> {
456        let (result, plan) = self.plan_strict_with_size(units, spec)?;
457        self.commit_plan(&plan, units);
458        Ok(result)
459    }
460
461    pub(super) fn reduce_fifo(
462        &mut self,
463        units: &Amount,
464        spec: &CostSpec,
465    ) -> Result<BookingResult, BookingError> {
466        self.reduce_ordered(units, spec, LotOrder::Date)
467    }
468
469    /// LIFO booking: reduce from newest lots first.
470    pub(super) fn reduce_lifo(
471        &mut self,
472        units: &Amount,
473        spec: &CostSpec,
474    ) -> Result<BookingResult, BookingError> {
475        self.reduce_ordered(units, spec, LotOrder::DateDescending)
476    }
477
478    /// HIFO booking: reduce from highest-cost lots first.
479    /// HIFO booking: take from the most expensive lots first.
480    ///
481    /// The plan half of the ordered walk with a cost key, which is all HIFO
482    /// ever was. It used to carry its own copy of that walk — scan every slot,
483    /// sort the survivors by cost, sum them for sufficiency, then take — which
484    /// is O(lots) per reduction and was 18s on a 20,000-transaction ledger
485    /// against FIFO's 0.27s (#2091). Sharing `plan_ordered` gives it the
486    /// maintained index, the early stop, and a plan half so `try_reduce` can
487    /// preview without cloning the inventory.
488    pub(super) fn plan_hifo(
489        &self,
490        units: &Amount,
491        spec: &CostSpec,
492    ) -> Result<(BookingResult, SmallVec<[(usize, Decimal); 1]>), BookingError> {
493        self.plan_ordered(units, spec, LotOrder::CostDescending)
494    }
495
496    pub(super) fn reduce_hifo(
497        &mut self,
498        units: &Amount,
499        spec: &CostSpec,
500    ) -> Result<BookingResult, BookingError> {
501        let (result, updates) = self.plan_hifo(units, spec)?;
502        self.commit_updates(&updates);
503        Ok(result)
504    }
505
506    pub(super) fn reduce_ordered(
507        &mut self,
508        units: &Amount,
509        spec: &CostSpec,
510        order: LotOrder,
511    ) -> Result<BookingResult, BookingError> {
512        let (result, updates) = self.plan_ordered(units, spec, order)?;
513        self.commit_updates(&updates);
514        Ok(result)
515    }
516
517    /// The read-only half of [`Self::reduce_ordered`]: pick the lots, check
518    /// sufficiency, and compute what would be matched — without touching a
519    /// single position.
520    ///
521    /// Returns the result alongside `(index, new units number)` pairs for the
522    /// caller to apply. See [`Self::plan_from_lot`] for why the split exists
523    /// and why the selection logic must not be duplicated into `try_reduce`.
524    pub(super) fn plan_ordered(
525        &self,
526        units: &Amount,
527        spec: &CostSpec,
528        order: LotOrder,
529    ) -> Result<(BookingResult, SmallVec<[(usize, Decimal); 1]>), BookingError> {
530        let mut remaining = units.number.abs();
531        let mut matched: MatchedLots = SmallVec::new();
532        let mut cost_basis = Decimal::ZERO;
533        let mut cost_currency = None;
534
535        // Candidates in FIFO order.
536        //
537        // `ordered_index` already holds this currency's lots — every one of
538        // them, cost-less included, since an empty spec matches those too — in
539        // (date, slot) order, so the common path neither scans every slot nor
540        // sorts per call. It did both, once per reduction, and `{}` is how
541        // FIFO sells are written — so that was the normal case (#2083).
542        //
543        // The predicate still runs per candidate: the index knows nothing
544        // about sign, emptiness or what this spec matches, and a stale entry
545        // for a drained lot is expected, since removal is best-effort.
546        let scanned: Option<Vec<usize>> =
547            if self.ordered_candidates(&units.currency, order).is_some() {
548                None
549            } else {
550                // No index — a shared snapshot, or one never rebuilt. Scanning is
551                // the only correct answer, and it is what this always did.
552                let mut all: Vec<usize> = self
553                    .positions
554                    .iter_slots()
555                    .filter(|(_, p)| p.units.currency == units.currency)
556                    .map(|(i, _)| i)
557                    .collect();
558                all.sort_by_key(|&i| (self.order_key(order, i), i));
559                Some(all)
560            };
561        let candidates: &[usize] = match &scanned {
562            Some(all) => all,
563            None => self
564                .ordered_candidates(&units.currency, order)
565                .expect("the branch above proved it is Some"),
566        };
567
568        let keeps = |i: usize| {
569            self.positions.get(i).is_some_and(|p| {
570                p.units.currency == units.currency
571                    && !p.is_empty()
572                    && p.units.number.signum() != units.number.signum()
573                    && p.matches_cost_spec(spec)
574            })
575        };
576
577        // Sufficiency used to be checked up front, by summing every matching
578        // lot before taking any. That was needed when this function mutated;
579        // since the plan/commit split (#2061) it only computes and the caller
580        // commits, so exhausting the walk reports the same shortfall with the
581        // same total, having visited the same lots. The invariant it protected
582        // — a failed reduction leaves the inventory untouched — is structural
583        // now, and `booking_properties.rs` still pins it.
584        let mut updates: SmallVec<[(usize, Decimal); 1]> = SmallVec::new();
585        let mut seen_any = false;
586        let mut available_total = Decimal::ZERO;
587        let mut overflow: Option<OverflowError> = None;
588
589        // Always forward. The direction of a method lives in its `order_key`,
590        // never here: reversing the WALK reverses the slot tiebreak along with
591        // the sort key, which is how LIFO came to take the last of two
592        // same-date lots while FIFO and HIFO take the first (#2115).
593        for idx in candidates.iter().copied() {
594            if !keeps(idx) {
595                continue;
596            }
597            seen_any = true;
598            let pos = &self.positions[idx];
599            available_total += pos.units.number.abs();
600            let available = pos.units.number.abs();
601            let take = remaining.min(available);
602
603            // Calculate cost basis for this portion (checked — see the
604            // matching site in the FIFO/LIFO ladder above).
605            if let Some(cost) = &pos.cost {
606                if cost_currency.is_none() {
607                    cost_currency = Some(cost.currency.clone());
608                }
609                // Recorded, not returned. Sufficiency used to be settled before
610                // any basis arithmetic ran, so a reduction that was BOTH short
611                // of units and unrepresentable reported the shortfall — the
612                // actionable half. Returning here would report the overflow
613                // instead, which is a behavior change nothing asked for.
614                match take
615                    .checked_mul(cost.number)
616                    .and_then(|v| cost_basis.checked_add(v))
617                {
618                    Some(v) => cost_basis = v,
619                    None => {
620                        overflow.get_or_insert_with(|| OverflowError {
621                            currency: cost.currency.clone(),
622                        });
623                    }
624                }
625            }
626
627            // Record what we matched
628            let (taken, _) = pos.split(take * pos.units.number.signum());
629            matched.push(taken);
630
631            // What the lot WOULD become. Recorded rather than applied so the
632            // preview path can run this same loop against `&self`.
633            let reduction = if units.number.is_sign_negative() {
634                -take
635            } else {
636                take
637            };
638            updates.push((idx, pos.units.number + reduction));
639
640            remaining -= take;
641
642            // Covered: stop here rather than walking the rest of the account.
643            // `available_total` is left partial on purpose — it feeds the
644            // shortfall message only, which is unreachable once the reduction
645            // is satisfied. Walking on to complete a number nobody reads is
646            // what made this O(lots): the spec is concrete by the time `apply`
647            // re-derives, so most later lots fail the predicate and the loop
648            // ran the cost comparison against every one of them.
649            if remaining.is_zero() {
650                break;
651            }
652        }
653
654        // No lot of this currency matched the spec at all.
655        if !seen_any {
656            return Err(BookingError::NoMatchingLot {
657                currency: units.currency.clone(),
658                cost_spec: spec.clone(),
659            });
660        }
661        // Lots matched but did not cover the reduction. Reported with the same
662        // total the up-front sum produced, because reaching here means the
663        // walk visited every matching lot.
664        if !remaining.is_zero() {
665            return Err(BookingError::InsufficientUnits {
666                currency: units.currency.clone(),
667                requested: units.number.abs(),
668                available: available_total,
669            });
670        }
671        // Only now: the reduction is satisfiable, so the arithmetic is what
672        // failed.
673        if let Some(error) = overflow {
674            return Err(BookingError::Overflow(error));
675        }
676
677        Ok((
678            BookingResult {
679                matched,
680                cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
681            },
682            updates,
683        ))
684    }
685
686    /// Apply `(index, new units)` pairs from a plan, then restore the caches.
687    ///
688    /// `retain` + `rebuild_index` exactly as the fused `reduce_ordered` did —
689    /// the multi-lot path always paid an O(lots) cache rebuild, and changing
690    /// that is a separate question from removing the preview's clone.
691    /// Apply a multi-lot plan, repairing the caches rather than rebuilding
692    /// them.
693    ///
694    /// This used to `retain` away the drained lots and then `rebuild_index()`,
695    /// which is two walks of every slot plus a rehash of every key — per
696    /// reduction. In a FIFO ledger, where reductions are frequent and lots
697    /// accumulate, that is the whole cost: 20k transactions took 8.9s, of
698    /// which 7.1s was the rebuild (#2083).
699    ///
700    /// `updates` already names every slot that changed, so the repair is
701    /// proportional to the lots the reduction touched rather than to the lots
702    /// the account holds. Same treatment `commit_from_lot` got in #2063, for
703    /// the same reason; this is the multi-lot half of that change.
704    fn commit_updates(&mut self, updates: &[(usize, Decimal)]) {
705        // Every update names a lot of the same currency, because every producer
706        // of `ReductionPlan::Updates` filters on `units.currency` before
707        // planning. This repair DEPENDS on that — it adjusts one currency's
708        // running total — where the old `rebuild_index()` recomputed them all
709        // and so could not notice the invariant being violated.
710        //
711        // Checked here rather than after the loop: by then the drained lots are
712        // tombstones, and indexing one panics.
713        debug_assert!(
714            updates.windows(2).all(|pair| {
715                self.positions[pair[0].0].units.currency == self.positions[pair[1].0].units.currency
716            }),
717            "commit_updates was given lots of more than one currency; the units \
718             cache is adjusted per currency and would silently drift",
719        );
720
721        let mut delta = Decimal::ZERO;
722        let mut currency = None;
723
724        for &(idx, new_units) in updates {
725            let previous = self.positions[idx].units.number;
726            delta += new_units - previous;
727            if currency.is_none() {
728                currency = Some(self.positions[idx].units.currency.clone());
729            }
730
731            // Drop the old classification before overwriting: a reduction can
732            // take a lot through zero and flip its sign bucket.
733            self.sign_index_bump(idx, -1);
734            self.positions[idx].units.number = new_units;
735            self.sign_index_bump(idx, 1);
736
737            if self.positions[idx].is_empty() {
738                self.sign_index_bump(idx, -1);
739                self.cost_index_remove(idx);
740                self.positions.remove(idx);
741                // Removal leaves a tombstone, so no surviving lot is
742                // renumbered and only the entry naming this lot has to go. An
743                // empty cost spec matches a cost-less position, so ordered
744                // selection can drain one and this map can name it.
745                self.units_cache
746                    .values_mut()
747                    .filter(|stats| stats.simple_slot == Some(idx))
748                    .for_each(|stats| stats.simple_slot = None);
749            }
750        }
751
752        // One adjustment for the whole plan: see the assertion at the top.
753        if let Some(currency) = currency
754            && let Some(stats) = self.units_cache.get_mut(&currency)
755        {
756            stats.total = crate::decimal::add_python_scale(stats.total, delta);
757        }
758    }
759
760    /// AVERAGE booking: merge all lots of the currency.
761    ///
762    /// Stricter than Python: beancount does NOT implement this method.
763    /// `beancount.parser.booking_method.booking_method_AVERAGE` raises
764    /// `AmbiguousMatchError("AVERAGE method is not supported")`, with the real
765    /// implementation left commented out ("DISABLED - This is the code for
766    /// AVERAGE, which is currently disabled"). `Booking.AVERAGE` exists in the
767    /// enum and is accepted in an `open` directive, so a ledger declaring it
768    /// parses and then books nothing.
769    ///
770    /// Two consequences worth knowing before changing anything here:
771    ///
772    /// * The compat oracle cannot referee this. There is no reference answer
773    ///   to diff against, so correctness rests on the definition — the merged
774    ///   lot's cost is the cost-weighted average of the lots it replaces — and
775    ///   on the two rledger surfaces agreeing.
776    /// * That is exactly why #1985 survived: BQL netted by lot key and produced
777    ///   a dangling negative position where reports produced the merged lot,
778    ///   and no differential test could see it. The internal parity guard
779    ///   (`query_report_realization_parity_test`) is what caught it.
780    pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
781        let matching: Vec<&Position> = self
782            .positions
783            .iter()
784            .filter(|p| p.units.currency == units.currency && !p.is_empty())
785            .collect();
786
787        let total_units: Decimal = matching
788            .iter()
789            .try_fold(Decimal::ZERO, |acc, p| acc.checked_add(p.units.number))
790            .ok_or_else(|| {
791                BookingError::Overflow(OverflowError {
792                    currency: units.currency.clone(),
793                })
794            })?;
795
796        if total_units.is_zero() {
797            return Err(BookingError::InsufficientUnits {
798                currency: units.currency.clone(),
799                requested: units.number.abs(),
800                available: Decimal::ZERO,
801            });
802        }
803
804        let reduction = units.number.abs();
805        if reduction > total_units.abs() {
806            return Err(BookingError::InsufficientUnits {
807                currency: units.currency.clone(),
808                requested: reduction,
809                available: total_units.abs(),
810            });
811        }
812
813        let avg = average_cost_from_positions(&matching, total_units)?;
814        let cost_basis = avg
815            .as_ref()
816            .map(|(avg_cost, currency)| {
817                reduction
818                    .checked_mul(*avg_cost)
819                    .map(|n| Amount::new(n, currency.clone()))
820                    .ok_or_else(|| {
821                        BookingError::Overflow(OverflowError {
822                            currency: currency.clone(),
823                        })
824                    })
825            })
826            .transpose()?;
827
828        // Build a position of `number` units of the reduced currency at the
829        // average cost (or costless if the lots had no cost).
830        let at_avg_cost = |number: Decimal| -> Position {
831            let amount = Amount::new(number, units.currency.clone());
832            match &avg {
833                Some((avg_cost, currency)) => {
834                    Position::with_cost(amount, Cost::new(*avg_cost, currency.clone()))
835                }
836                None => Position::simple(amount),
837            }
838        };
839
840        // A reduction under AVERAGE matches a SINGLE synthetic lot of the
841        // reduced quantity at the average cost, not every underlying lot.
842        // Returning the full lot set made the consumer (book.rs) expand the
843        // reduction into one posting per lot and remove the entire position
844        // (and book a garbage gain). The taken units carry the *inventory* sign
845        // (`total_units.signum()`), matching the FIFO/ordered convention — so
846        // covering a short (negative pool) yields a negative matched lot.
847        let matched: MatchedLots = smallvec![at_avg_cost(reduction * total_units.signum())];
848
849        let new_units = total_units + units.number;
850
851        // Remove all positions of this currency
852        self.positions
853            .retain(|p| p.units.currency != units.currency);
854
855        // Add back the remainder (if non-zero) at the average cost, so a later
856        // reduction sees the correct basis instead of a costless position.
857        if !new_units.is_zero() {
858            self.positions.push(at_avg_cost(new_units));
859        }
860
861        self.rebuild_index();
862
863        Ok(BookingResult {
864            matched,
865            cost_basis,
866        })
867    }
868
869    /// Collapse every cost-bearing lot of each currency into a single
870    /// weighted-average-cost lot. Cost-less (cash) positions are left untouched.
871    ///
872    /// This realizes the balance of an AVERAGE-booked account, where all lots of
873    /// a commodity share one running cost. The journal keeps the real per-lot
874    /// costs; only this realized view merges them (matching hledger's pool
875    /// model). A currency whose lots net to zero is removed; a currency whose
876    /// lots have mismatched cost currencies is left untouched.
877    ///
878    /// # Errors
879    ///
880    /// [`OverflowError`] when a currency's lots sum outside `rust_decimal`'s
881    /// range. The merged view is a realized balance, so a clamped total would
882    /// be rendered as an exact position (#1863).
883    pub fn merge_average(&mut self) -> Result<(), OverflowError> {
884        let currencies: std::collections::BTreeSet<Currency> = self
885            .positions
886            .iter()
887            .filter(|p| p.cost.is_some())
888            .map(|p| p.units.currency.clone())
889            .collect();
890
891        for currency in currencies {
892            let (total_units, avg) = {
893                let matching: Vec<&Position> = self
894                    .positions
895                    .iter()
896                    .filter(|p| p.units.currency == currency && p.cost.is_some())
897                    .collect();
898                let total_units: Decimal = matching
899                    .iter()
900                    .try_fold(Decimal::ZERO, |acc, p| acc.checked_add(p.units.number))
901                    .ok_or_else(|| OverflowError {
902                        currency: currency.clone(),
903                    })?;
904                let avg = if total_units.is_zero() {
905                    None
906                } else {
907                    average_cost_from_positions(&matching, total_units)
908                        .ok()
909                        .flatten()
910                };
911                (total_units, avg)
912            };
913
914            // Couldn't average a non-zero position (cost-currency mismatch):
915            // leave its lots untouched rather than corrupt them.
916            if !total_units.is_zero() && avg.is_none() {
917                continue;
918            }
919
920            self.positions
921                .retain(|p| !(p.units.currency == currency && p.cost.is_some()));
922            if let Some((avg_cost, cost_currency)) = avg {
923                self.positions.push(Position::with_cost(
924                    Amount::new(total_units, currency.clone()),
925                    Cost::new(avg_cost, cost_currency),
926                ));
927            }
928        }
929        self.rebuild_index();
930        Ok(())
931    }
932
933    /// What `{*}` would merge, computed from `&self`.
934    ///
935    /// The selection half of [`Self::reduce_merge`], split out so the pool cost
936    /// can be known WITHOUT building it (#2068). `reduce_merge` is the only
937    /// mutating caller and it goes through here, so there is one implementation
938    /// of "which lots merge and at what average" — the duplication that the
939    /// plan/commit split (#2061) exists to prevent.
940    ///
941    /// `pool` is `None` when the matched lots carry no cost, which is
942    /// `reduce_merge`'s AVERAGE fallback rather than an error.
943    fn plan_merge(&self, units: &Amount) -> Result<MergePlan, BookingError> {
944        // Only merge lots with opposite sign (same as other reduce methods).
945        // This prevents accidentally netting long and short positions.
946        let matching: Vec<(usize, &Position)> = self
947            .positions
948            .iter_slots()
949            .filter(|(_, p)| {
950                p.units.currency == units.currency
951                    && !p.is_empty()
952                    && p.units.number.is_sign_positive() != units.number.is_sign_positive()
953            })
954            .collect();
955
956        if matching.is_empty() {
957            return Err(BookingError::InsufficientUnits {
958                currency: units.currency.clone(),
959                requested: units.number.abs(),
960                available: Decimal::ZERO,
961            });
962        }
963
964        let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
965        let reduction = units.number.abs();
966
967        if reduction > total_units.abs() {
968            return Err(BookingError::InsufficientUnits {
969                currency: units.currency.clone(),
970                requested: reduction,
971                available: total_units.abs(),
972            });
973        }
974
975        let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
976        let pool = average_cost_from_positions(&matching_refs, total_units)?
977            .map(|(number, currency)| Amount::new(number, currency));
978
979        Ok(MergePlan {
980            matching_indices: matching.iter().map(|(i, _)| *i).collect(),
981            total_units,
982            pool,
983        })
984    }
985
986    /// The per-unit pool cost `{*}` would produce here, without producing it.
987    ///
988    /// `Ok(None)` means there is no pool cost to compare against — cost-less
989    /// lots, where `{*}` degrades to AVERAGE. Errors are the ones the reduction
990    /// itself would raise (no matching lots, insufficient units); a caller
991    /// checking a precondition should let the reduction report them rather than
992    /// pre-empting it, so that one function keeps owning the message.
993    ///
994    /// Exists so `BookingEngine::apply` can verify a carried `{*}` against the
995    /// cost booking recorded BEFORE the merge mutates anything (#2068).
996    pub fn merged_pool_cost(&self, units: &Amount) -> Result<Option<Amount>, BookingError> {
997        Ok(self.plan_merge(units)?.pool)
998    }
999
1000    /// Cost merge `{*}`: merge all lots of the currency into a single
1001    /// weighted-average-cost lot, then reduce from it.
1002    ///
1003    /// Example: 10 AAPL {150 USD} + 10 AAPL {160 USD} merged = 20 AAPL {155 USD}.
1004    /// Reducing 5 AAPL {*} takes 5 from the merged 20 AAPL {155 USD} lot.
1005    pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
1006        let plan = self.plan_merge(units)?;
1007        let MergePlan {
1008            matching_indices,
1009            total_units,
1010            pool: Some(pool),
1011        } = plan
1012        else {
1013            // Cost-less lots: there is no pool to build (`plan_merge` says so),
1014            // so `{*}` degrades to AVERAGE, exactly as before the plan split.
1015            return self.reduce_average(units);
1016        };
1017        let reduction = units.number.abs();
1018        let (avg_cost, cost_currency) = (pool.number, pool.currency);
1019
1020        let cost_basis = Some(Amount::new(
1021            reduction.checked_mul(avg_cost).ok_or_else(|| {
1022                BookingError::Overflow(OverflowError {
1023                    currency: cost_currency.clone(),
1024                })
1025            })?,
1026            cost_currency.clone(),
1027        ));
1028
1029        // Return a single synthetic matched position representing the merged lot.
1030        // This prevents the booking engine from expanding the posting into multiple
1031        // postings (one per original lot), which would be incorrect for {*}.
1032        let make_avg_cost = || Cost {
1033            number: avg_cost,
1034            currency: cost_currency.clone(),
1035            date: None,
1036            label: None,
1037        };
1038
1039        let matched: MatchedLots = smallvec![Position::with_cost(
1040            Amount::new(units.number.abs(), units.currency.clone()),
1041            make_avg_cost(),
1042        )];
1043
1044        // Remove all matching lots of this currency
1045        self.positions
1046            .retain_slots(|slot, _| !matching_indices.contains(&slot));
1047
1048        // Add back a single merged lot with the remainder
1049        let remaining = total_units + units.number; // units.number is negative for reductions
1050        if !remaining.is_zero() {
1051            self.positions.push(Position::with_cost(
1052                Amount::new(remaining, units.currency.clone()),
1053                make_avg_cost(),
1054            ));
1055        }
1056
1057        self.rebuild_index();
1058
1059        Ok(BookingResult {
1060            matched,
1061            cost_basis,
1062        })
1063    }
1064
1065    /// NONE booking: reduce without matching lots.
1066    pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
1067        // For NONE booking, we just reduce the total without caring about lots
1068        let total_units = self.units(&units.currency);
1069
1070        // Check we have enough in the right direction
1071        if total_units.signum() == units.number.signum() || total_units.is_zero() {
1072            // This is an augmentation, not a reduction - just add it
1073            self.add(Position::simple(units.clone()))?;
1074            return Ok(BookingResult {
1075                matched: SmallVec::new(),
1076                cost_basis: None,
1077            });
1078        }
1079
1080        let available = total_units.abs();
1081        let requested = units.number.abs();
1082
1083        if requested > available {
1084            // NONE performs no booking, so shorts are always allowed —
1085            // matching beancount's NONE semantics and NONECorrect.tla. This
1086            // arm previously returned InsufficientUnits, which made the
1087            // outcome depend on whether zero was crossed in one step (0 → -2
1088            // was allowed above; +1 → -1 was rejected here). Found by the
1089            // TLA+ behavior-replay suite (#1686): consume everything
1090            // available, then carry the remainder as a negative (short)
1091            // simple position.
1092            let sign = units.number.signum();
1093            let consumed = Amount::new(available * sign, units.currency.clone());
1094            let result = self.reduce_ordered(&consumed, &CostSpec::default(), LotOrder::Date)?;
1095            self.add(Position::simple(Amount::new(
1096                (requested - available) * sign,
1097                units.currency.clone(),
1098            )))?;
1099            return Ok(result);
1100        }
1101
1102        // Reduce positions proportionally (simplified: just reduce first matching)
1103        self.reduce_ordered(units, &CostSpec::default(), LotOrder::Date)
1104    }
1105
1106    /// Reduce from a specific lot.
1107    pub(super) fn plan_from_lot(
1108        &self,
1109        idx: usize,
1110        units: &Amount,
1111    ) -> Result<(BookingResult, Decimal), BookingError> {
1112        let pos = &self.positions[idx];
1113        let available = pos.units.number.abs();
1114        let requested = units.number.abs();
1115
1116        if requested > available {
1117            return Err(BookingError::InsufficientUnits {
1118                currency: units.currency.clone(),
1119                requested,
1120                available,
1121            });
1122        }
1123
1124        // Calculate cost basis
1125        let cost_basis = pos
1126            .cost
1127            .as_ref()
1128            .map(|c| {
1129                c.total_cost(requested).ok_or_else(|| {
1130                    BookingError::Overflow(OverflowError {
1131                        currency: c.currency.clone(),
1132                    })
1133                })
1134            })
1135            .transpose()?;
1136
1137        // Record matched
1138        let (matched, _) = pos.split(requested * pos.units.number.signum());
1139
1140        // Python scale rule, same as `Inventory::add` — see
1141        // `crate::decimal::add_python_scale`. A reduction that brings a lot
1142        // through zero would otherwise drop the scale here while `add` kept
1143        // it, so the same lot would render differently depending on whether
1144        // it was last touched by an add or a reduce.
1145        let new_units = crate::decimal::add_python_scale(pos.units.number, units.number);
1146
1147        Ok((
1148            BookingResult {
1149                matched: smallvec![matched],
1150                cost_basis,
1151            },
1152            new_units,
1153        ))
1154    }
1155
1156    /// The mutating half of [`Self::reduce_from_lot`], applying a
1157    /// [`Self::plan_from_lot`] result.
1158    ///
1159    /// Keeps the incremental cache maintenance the single-lot path always
1160    /// had: a full `rebuild_index` here would be O(lots) on the commit path
1161    /// that this split is meant to keep cheap.
1162    fn commit_from_lot(&mut self, idx: usize, units: &Amount, new_units: Decimal) {
1163        let currency = self.positions[idx].units.currency.clone();
1164        let new_pos = Position {
1165            units: Amount::new(new_units, currency.clone()),
1166            cost: self.positions[idx].cost.clone(),
1167        };
1168        // Drop the old classification before overwriting: a reduction can take
1169        // a lot through zero and flip its sign bucket.
1170        self.sign_index_bump(idx, -1);
1171        self.positions[idx] = new_pos;
1172        self.sign_index_bump(idx, 1);
1173
1174        // Update units cache incrementally (units.number is negative for reductions)
1175        if let Some(stats) = self.units_cache.get_mut(&currency) {
1176            stats.total = crate::decimal::add_python_scale(stats.total, units.number);
1177        }
1178
1179        // Remove if empty, then repair `simple_index`.
1180        if self.positions[idx].is_empty() {
1181            self.sign_index_bump(idx, -1);
1182            self.cost_index_remove(idx);
1183            self.positions.remove(idx);
1184
1185            // Removing shifts every later position down one, so the stored
1186            // indices past `idx` are now off by one. Patch the MAP rather than
1187            // rescanning the positions to rebuild it.
1188            //
1189            // `simple_index` holds at most one entry per currency — cost-less
1190            // lots of a currency merge into a single lot — so this is O(number
1191            // of currencies), against O(lots) for the rescan it replaces. On
1192            // an investment account, where every lot carries a cost and the
1193            // map is EMPTY, the rescan walked the entire lot list to find
1194            // nothing at all; it grew 164x for 10x the input on the
1195            // `investment` profiling shape.
1196            //
1197            // Nothing shifted: removal leaves a tombstone, so every
1198            // surviving lot keeps its slot. Only the entry naming the removed
1199            // lot has to go — and it CAN name it, because an empty cost spec
1200            // matches a cost-less position (`matches_cost_spec`:
1201            // `(None, true) => true`), so STRICT can select and drain one.
1202            //
1203            // Before tombstones this also decremented the later entries to
1204            // follow the shift. Keeping that now would renumber indices that
1205            // did not move, pointing `add`'s merge at a tombstone — which is
1206            // exactly what `removing_a_lot_repairs_the_index_of_a_later_cost_less_lot`
1207            // caught.
1208            self.units_cache
1209                .values_mut()
1210                .filter(|stats| stats.simple_slot == Some(idx))
1211                .for_each(|stats| stats.simple_slot = None);
1212        }
1213    }
1214}
1215
1216#[cfg(test)]
1217mod reduction_tests {
1218    //! Direct unit tests for the read-only `try_reduce_*` booking paths.
1219    //!
1220    //! These pin exact cost-basis, lot selection, and guard behavior so
1221    //! the lot-reduction mutants surfaced by the #1309 audit are killed
1222    //! (the public mutating `reduce_*` path was covered indirectly, but
1223    //! the `try_reduce_*` preview path had no direct assertions).
1224    use super::LotOrder;
1225    use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
1226    use rust_decimal::Decimal;
1227    use rust_decimal_macros::dec;
1228
1229    fn d(n: i64) -> Decimal {
1230        Decimal::from(n)
1231    }
1232
1233    /// A cost-bearing lot of `units` STK at `cost` USD, dated 2024-01-`day`.
1234    fn lot(units: i64, cost: i64, day: u32) -> Position {
1235        Position::with_cost(
1236            Amount::new(d(units), "STK"),
1237            Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
1238        )
1239    }
1240
1241    /// A multi-lot reduction removes what it drained, and nothing else.
1242    ///
1243    /// `commit_updates` used to `retain(|p| !p.is_empty())` over the whole
1244    /// inventory, so any reduction that crossed two lots also swept away every
1245    /// unrelated zero-unit position as a side effect. Zero-unit lots are not
1246    /// scrap: `Inventory::len` counts them and `currency_accounts` branches on
1247    /// `len() == 1`, so sweeping them changed what other surfaces reported
1248    /// depending on whether a reduction happened to be multi-lot.
1249    #[test]
1250    fn a_multi_lot_reduction_leaves_unrelated_empty_lots_alone() {
1251        let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1252        // An unrelated zero-unit position in another commodity. Netted to
1253        // zero rather than added as zero: `add` drops a zero-unit position
1254        // outright, so the only way one exists is a cost-less lot merging
1255        // through zero — which is exactly the case `Inventory::len`'s contract
1256        // is about.
1257        inv.add(Position::simple(Amount::new(d(5), "ZERO")))
1258            .expect("fixture fits in Decimal");
1259        inv.add(Position::simple(Amount::new(d(-5), "ZERO")))
1260            .expect("fixture fits in Decimal");
1261        let before = inv.len();
1262
1263        // Cross both STK lots, draining the first.
1264        inv.reduce(
1265            &Amount::new(d(-15), "STK"),
1266            Some(&CostSpec::default()),
1267            BookingMethod::Fifo,
1268        )
1269        .expect("15 of 20 units are there");
1270
1271        assert_eq!(
1272            inv.len(),
1273            before - 1,
1274            "exactly the drained lot should be gone: the untouched zero-unit \
1275             position is not this reduction's business",
1276        );
1277    }
1278
1279    /// Insufficiency outranks overflow, as it did before the walk was made lazy.
1280    #[test]
1281    fn insufficient_units_are_reported_even_when_the_basis_would_overflow() {
1282        // A lot whose cost basis cannot be represented, and a reduction asking
1283        // for more units than exist.
1284        let huge = Decimal::MAX / d(2);
1285        let mut inv = Inventory::new();
1286        inv.add(Position::with_cost(
1287            Amount::new(d(10), "STK"),
1288            Cost::new(huge, "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1289        ))
1290        .expect("fixture fits in Decimal");
1291
1292        let err = inv
1293            .plan_ordered(
1294                &Amount::new(d(-99), "STK"),
1295                &CostSpec::default(),
1296                LotOrder::Date,
1297            )
1298            .expect_err("99 units are not there");
1299        assert!(
1300            matches!(err, crate::BookingError::InsufficientUnits { .. }),
1301            "the shortfall is the actionable error, not the arithmetic it would \
1302             have done on the way: {err:?}",
1303        );
1304    }
1305
1306    /// HIFO takes costed lots before cost-less ones.
1307    ///
1308    /// A cost-less lot has no cost to compare, and an empty cost spec matches
1309    /// it, so it is a candidate. The scan this replaced counted it as zero
1310    /// before reversing, which put it last; ordering on `Option<Decimal>`
1311    /// would put it FIRST, because `None` sorts before `Some`. Same lots,
1312    /// opposite lot chosen.
1313    #[test]
1314    fn hifo_takes_costed_lots_before_costless_ones() {
1315        let mut inv = Inventory::new();
1316        inv.add(Position::simple(Amount::new(d(10), "STK")))
1317            .expect("fixture fits in Decimal");
1318        inv.add(lot(10, 100, 1)).expect("fixture fits in Decimal");
1319
1320        let result = inv
1321            .reduce(
1322                &Amount::new(d(-5), "STK"),
1323                Some(&CostSpec::default()),
1324                BookingMethod::Hifo,
1325            )
1326            .expect("5 of 20 units are there");
1327
1328        assert_eq!(
1329            result.matched[0].cost.as_ref().map(|c| c.number),
1330            Some(d(100)),
1331            "the 100 USD lot outranks the cost-less one",
1332        );
1333        assert_eq!(
1334            result.cost_basis.map(|b| b.number),
1335            Some(d(500)),
1336            "and the basis comes from it",
1337        );
1338    }
1339
1340    /// The ordered index selects exactly what the scan selects (#2083).
1341    ///
1342    /// `plan_ordered` used to sort every matching lot on every call; it now
1343    /// walks a maintained (date, slot) index and stops once the reduction is
1344    /// covered. That is only sound if the index reproduces the scan's order
1345    /// exactly — including the stable-sort tiebreak, `None` dates sorting
1346    /// first, and lots added out of date order — so this runs both paths over
1347    /// the same inventory and compares.
1348    ///
1349    /// Clearing `ordered_index` is what forces the scan: an empty index is
1350    /// how a shared snapshot looks, and the fallback exists for exactly that.
1351    ///
1352    /// What this does NOT check is whether the ORDER is the right one. Both
1353    /// sides call `order_key`, so reversing it moves them together and this
1354    /// test stays green — verified by mutating it. The orderings themselves
1355    /// are pinned by the method tests (`test_hifo_reduces_highest_cost_first`,
1356    /// `test_fifo_respects_dates` and their neighbors), which is the division
1357    /// of labor: those say what order a method takes lots in, this says the
1358    /// index reproduces whatever that order is.
1359    #[test]
1360    fn the_ordered_index_selects_what_the_scan_selects() {
1361        // Deliberately awkward: out-of-order dates, a duplicate date, a
1362        // date-less lot, a second currency, and a cost-less lot.
1363        let mut inv = Inventory::new();
1364        for lot in [
1365            lot(10, 100, 5),
1366            lot(10, 101, 2),
1367            lot(10, 102, 9),
1368            lot(10, 103, 2),
1369            Position::with_cost(Amount::new(d(10), "STK"), Cost::new(d(104), "USD")),
1370            Position::with_cost(Amount::new(d(10), "OTH"), Cost::new(d(105), "USD")),
1371            Position::simple(Amount::new(d(10), "STK")),
1372        ] {
1373            inv.add(lot).expect("fixture fits in Decimal");
1374        }
1375
1376        let specs = [
1377            CostSpec::default(),
1378            CostSpec {
1379                number: Some(crate::CostNumber::PerUnit { value: d(101) }),
1380                currency: Some("USD".into()),
1381                ..CostSpec::default()
1382            },
1383            CostSpec {
1384                date: Some(naive_date(2024, 1, 2).unwrap()),
1385                ..CostSpec::default()
1386            },
1387        ];
1388
1389        for order in [
1390            LotOrder::Date,
1391            // LIFO's ordering. It used to be `(Date, reverse: true)`; the
1392            // direction moved into the key so the slot tiebreak stops being
1393            // reversed with it (#2115).
1394            LotOrder::DateDescending,
1395            // HIFO: the cost ordering added in #2091. Its tiebreak has to match
1396            // the `sort_by_key(Reverse(cost))` it replaced — stable, so equal
1397            // costs stayed in ascending slot order.
1398            LotOrder::CostDescending,
1399        ] {
1400            for spec in &specs {
1401                {
1402                    for take in [1i64, 15, 45] {
1403                        let units = Amount::new(d(-take), "STK");
1404
1405                        // Build it explicitly: `reduce` is what normally triggers
1406                        // the build, and calling `plan_ordered` directly would
1407                        // otherwise leave the index empty and compare the scan
1408                        // against itself. That vacuous version of this test passed
1409                        // against a deliberately reversed tiebreak.
1410                        let mut indexing = inv.clone();
1411                        indexing.build_ordered_index(order);
1412                        assert!(
1413                            indexing.ordered_index.is_some(),
1414                            "the fixture must produce an index, or this test compares \
1415                         the scan against itself",
1416                        );
1417                        let indexed = indexing.plan_ordered(&units, spec, order);
1418
1419                        let mut scanning = inv.clone();
1420                        scanning.ordered_index = None;
1421                        let scanned = scanning.plan_ordered(&units, spec, order);
1422
1423                        match (indexed, scanned) {
1424                            (Ok((a_result, a_updates)), Ok((b_result, b_updates))) => {
1425                                assert_eq!(
1426                                    a_updates, b_updates,
1427                                    "index and scan chose different lots for {spec:?} \
1428                                 take={take}",
1429                                );
1430                                assert_eq!(
1431                                    a_result.cost_basis, b_result.cost_basis,
1432                                    "index and scan disagree on cost basis for {spec:?} \
1433                                 take={take}",
1434                                );
1435                            }
1436                            (Err(a), Err(b)) => assert_eq!(
1437                                a.to_string(),
1438                                b.to_string(),
1439                                "index and scan report different errors for {spec:?} \
1440                             take={take}",
1441                            ),
1442                            (a, b) => panic!(
1443                                "index and scan disagree on success for {spec:?} \
1444                             order={order:?} take={take}: {a:?} vs {b:?}"
1445                            ),
1446                        }
1447                    }
1448                }
1449            }
1450        }
1451    }
1452
1453    fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
1454        let mut i = Inventory::new();
1455        for l in lots {
1456            i.add(l).expect("fixture fits in Decimal");
1457        }
1458        i
1459    }
1460
1461    fn sell_stk(n: i64) -> Amount {
1462        Amount::new(d(-n), "STK")
1463    }
1464
1465    /// A cost-basis overflow part-way through a multi-lot reduction must leave
1466    /// the inventory untouched.
1467    ///
1468    /// `reduce_ordered` states the rule itself — "a failed reduction must
1469    /// leave the inventory untouched" — and enforced it for the sufficiency
1470    /// check, which runs up front. The overflow check did NOT get the same
1471    /// treatment: it lived inside the mutation loop, so a reduction that
1472    /// overflowed on the third lot returned `Err` with the first two already
1473    /// drained. The validator reduces against live `LedgerState` inventories,
1474    /// so that partial drain corrupts every later balance assertion on the
1475    /// account.
1476    ///
1477    /// Computing the whole plan before committing any of it makes the rule
1478    /// hold for both checks by construction.
1479    #[test]
1480    fn an_overflowing_multi_lot_reduction_leaves_the_inventory_untouched() {
1481        // Two lots whose combined cost basis cannot be represented: each is
1482        // two thirds of the range, so the first accumulates fine and the sum
1483        // overflows on the second.
1484        let huge = Decimal::MAX / Decimal::from(3) * Decimal::from(2);
1485        let mut inv = Inventory::new();
1486        for day in 1..=2 {
1487            let mut cost = Cost::new(huge, "USD");
1488            cost.date = naive_date(2024, 1, day);
1489            inv.add(Position::with_cost(Amount::new(Decimal::ONE, "AAPL"), cost))
1490                .expect("lots fit individually");
1491        }
1492        let before: Vec<Position> = inv.positions().cloned().collect();
1493        assert_eq!(before.len(), 2, "fixture must hold two distinct lots");
1494
1495        let err = inv
1496            .reduce(
1497                &Amount::new(Decimal::from(-2), "AAPL"),
1498                Some(&CostSpec::default()),
1499                BookingMethod::Fifo,
1500            )
1501            .expect_err("the combined cost basis overflows");
1502        assert!(
1503            matches!(err, super::BookingError::Overflow(_)),
1504            "expected an overflow, got {err:?}",
1505        );
1506
1507        let after: Vec<Position> = inv.positions().cloned().collect();
1508        assert_eq!(
1509            after, before,
1510            "the failed reduction drained lots anyway — a partial mutation on \
1511             the error path is what this pins",
1512        );
1513    }
1514
1515    fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
1516        inv.try_reduce(units, Some(&CostSpec::default()), method)
1517            .expect("reduction should succeed")
1518    }
1519
1520    fn basis(r: &super::BookingResult) -> Decimal {
1521        r.cost_basis.as_ref().expect("cost basis present").number
1522    }
1523
1524    // ---- FIFO / LIFO ordered ------------------------------------------
1525
1526    #[test]
1527    fn fifo_partial_multilot_cost_basis_and_order() {
1528        // 10 @ $100 (older), 10 @ $200 (newer); sell 15.
1529        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1530        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
1531        // FIFO: 10@100 + 5@200 = 1000 + 1000 = 2000.
1532        assert_eq!(basis(&r), dec!(2000));
1533        assert_eq!(r.matched.len(), 2);
1534        assert_eq!(r.matched[0].units.number.abs(), dec!(10));
1535        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
1536        assert_eq!(r.matched[1].units.number.abs(), dec!(5));
1537        assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1538    }
1539
1540    #[test]
1541    fn lifo_takes_newest_lot_first() {
1542        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1543        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1544        // LIFO: 10@200 + 5@100 = 2000 + 500 = 2500 (distinguishes the
1545        // `DateDescending` ordering from FIFO's 2000).
1546        assert_eq!(basis(&r), dec!(2500));
1547        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
1548    }
1549
1550    /// Every ordered method breaks a tie the same way: the lot that appears
1551    /// FIRST in the file wins.
1552    ///
1553    /// This is the property a reversed walk silently broke. LIFO used to take
1554    /// the LAST of two same-date lots while FIFO and HIFO took the first,
1555    /// because `candidates.iter().rev()` reversed the slot tiebreak along with
1556    /// the sort key (#2115). Nothing caught it: the tiebreak is invisible to
1557    /// any assertion on units or total basis, so `test_hifo_with_tie_breaking`
1558    /// — whose whole subject is this — passed with the tiebreak reversed at
1559    /// every sort site, because all of its lots share one cost.
1560    ///
1561    /// Each case below ties on the key its OWN method sorts by, so the primary
1562    /// key cannot decide the outcome and only the tiebreak can. Asserting on
1563    /// the identity of the consumed lot rather than on its value is the point:
1564    /// consuming the wrong lot at the same total is exactly the failure that
1565    /// hides from a basis assertion.
1566    #[test]
1567    fn ordered_methods_break_ties_on_insertion_order() {
1568        // Same date, two costs. FIFO and LIFO both sort on date, so neither
1569        // can separate these by its primary key.
1570        for method in [BookingMethod::Fifo, BookingMethod::Lifo] {
1571            let inv = mk([lot(1, 10, 3), lot(1, 12, 3)]);
1572            let r = try_reduce(&inv, &sell_stk(1), method);
1573            assert_eq!(
1574                r.matched[0].cost.as_ref().unwrap().number,
1575                dec!(10),
1576                "{method:?} must consume the FIRST of two same-date lots, not the last",
1577            );
1578        }
1579
1580        // Same cost, two dates. HIFO sorts on cost, so its primary key cannot
1581        // separate these.
1582        let inv = mk([lot(1, 11, 3), lot(1, 11, 4)]);
1583        let r = try_reduce(&inv, &sell_stk(1), BookingMethod::Hifo);
1584        assert_eq!(
1585            r.matched[0].cost.as_ref().unwrap().date,
1586            Some(naive_date(2024, 1, 3).unwrap()),
1587            "HIFO must consume the FIRST of two same-cost lots, not the last",
1588        );
1589    }
1590
1591    /// A date-less lot is consumed LAST under LIFO, not first.
1592    ///
1593    /// `DateDescending` holds `Reverse<Option<NaiveDate>>`, so `None` — which
1594    /// normally sorts before every `Some` — lands at the END. That is where
1595    /// the reversed walk it replaced left date-less lots, so this is the half
1596    /// of the #2115 change that must NOT move.
1597    ///
1598    /// The distinction is one layer of nesting: `Option<Reverse<NaiveDate>>`
1599    /// is equally plausible to write and puts date-less lots FIRST. No other
1600    /// test separates the two. `the_ordered_index_selects_what_the_scan_selects`
1601    /// calls `order_key` on both sides, so it moves with any change to the
1602    /// key, and every other LIFO test uses dated lots only.
1603    #[test]
1604    fn lifo_takes_the_date_less_lot_last() {
1605        let mut inv = Inventory::new();
1606        // Date-less first in the file, so slot order alone would take it first
1607        // and cannot be what produces the expected answer.
1608        inv.add(Position::with_cost(
1609            Amount::new(d(10), "STK"),
1610            Cost::new(d(100), "USD"),
1611        ))
1612        .expect("fixture fits in Decimal");
1613        inv.add(lot(10, 200, 1)).expect("fixture fits in Decimal");
1614
1615        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1616        assert_eq!(
1617            r.matched[0].cost.as_ref().unwrap().number,
1618            dec!(200),
1619            "LIFO must reach the DATED lot before the date-less one",
1620        );
1621        // 10 @200 then 5 @100 = 2500. Date-less-first would be 2000.
1622        assert_eq!(basis(&r), dec!(2500));
1623    }
1624
1625    /// A lot acquired AFTER the index exists lands in LIFO order too.
1626    ///
1627    /// `ordered_index` is built on the first ordered reduction and then
1628    /// maintained by `ordered_index_insert` on every later `add`. Those are
1629    /// two different code paths and only the first one was covered:
1630    /// `the_ordered_index_selects_what_the_scan_selects` calls
1631    /// `build_ordered_index` directly, so it never exercises an incremental
1632    /// insert at all.
1633    ///
1634    /// The distinction matters most for `DateDescending`, where a newly
1635    /// acquired lot is the NEWEST and therefore belongs at the FRONT — the
1636    /// opposite end from where every ascending order puts it. An insert that
1637    /// appended would be invisible to FIFO and wrong for LIFO.
1638    #[test]
1639    fn lifo_orders_a_lot_added_after_the_index_was_built() {
1640        let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1641
1642        // Forces the index to exist; 20 held, take 5 off the newest (day 2).
1643        let first = inv
1644            .reduce(&sell_stk(5), None, BookingMethod::Lifo)
1645            .expect("15 units remain");
1646        assert_eq!(first.matched[0].cost.as_ref().unwrap().number, dec!(200));
1647
1648        // Acquired last, so LIFO must reach it FIRST — and it has to travel
1649        // to the front of a descending index to get there.
1650        inv.add(lot(10, 300, 3)).expect("fixture fits in Decimal");
1651
1652        let r = inv
1653            .reduce(&sell_stk(10), None, BookingMethod::Lifo)
1654            .expect("25 units remain");
1655        assert_eq!(
1656            r.matched[0].cost.as_ref().unwrap().number,
1657            dec!(300),
1658            "a lot added after the index was built must still sort newest-first",
1659        );
1660        assert_eq!(basis(&r), dec!(3000));
1661    }
1662
1663    /// Reordering INTERCHANGEABLE acquisitions does not change what is
1664    /// consumed.
1665    ///
1666    /// Two lots agreeing on commodity, cost, cost currency, date and label are
1667    /// interchangeable: no recorded attribute separates them, so `add` stores
1668    /// them as ONE position. That is what makes the outcome independent of the
1669    /// order they happen to be written in, and it is why a cost basis no
1670    /// longer depends on an editing accident (#2118).
1671    ///
1672    /// Before this rule, `A B C` and `A C B` disagreed by 50 USD of remaining
1673    /// basis on exactly these fixtures.
1674    ///
1675    /// Both code paths are exercised deliberately. `try_reduce` on a fresh
1676    /// inventory sorts a scanned list; a reduction after the index exists
1677    /// places later acquisitions through `ordered_index_insert`.
1678    #[test]
1679    fn interchangeable_lots_consume_independently_of_write_order() {
1680        let a = || lot(10, 10, 2);
1681        let b = || lot(10, 20, 2);
1682        let c = || lot(10, 10, 2);
1683
1684        // A and C merge, so any ledger writing B after the first acquisition
1685        // holds the same two positions and consumes identically.
1686        let consumed = |lots: [Position; 3]| -> Vec<(Decimal, Decimal)> {
1687            let inv = mk(lots);
1688            assert_eq!(inv.len(), 2, "A and C are interchangeable: one position");
1689            try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo)
1690                .matched
1691                .iter()
1692                .map(|m| (m.cost.as_ref().unwrap().number, m.units.number.abs()))
1693                .collect()
1694        };
1695
1696        let after_first = [
1697            ("A B C", consumed([a(), b(), c()])),
1698            ("A C B", consumed([a(), c(), b()])),
1699            ("C A B", consumed([c(), a(), b()])),
1700            ("C B A", consumed([c(), b(), a()])),
1701        ];
1702        for (name, got) in &after_first {
1703            assert_eq!(
1704                got,
1705                &vec![(dec!(10), dec!(15))],
1706                "{name}: 15 units all come from the merged 10.00 position",
1707            );
1708        }
1709
1710        // The same property through the MAINTAINED INDEX rather than the scan.
1711        let via_index = |lots: [Position; 3]| -> Vec<Decimal> {
1712            let mut inv = mk([lots[0].clone()]);
1713            inv.reduce(&sell_stk(1), None, BookingMethod::Fifo)
1714                .expect("one unit is there");
1715            for l in &lots[1..] {
1716                inv.add(l.clone()).expect("fixture fits in Decimal");
1717            }
1718            inv.reduce(&sell_stk(14), None, BookingMethod::Fifo)
1719                .expect("29 units remain")
1720                .matched
1721                .iter()
1722                .map(|m| m.cost.as_ref().unwrap().number)
1723                .collect()
1724        };
1725        assert_eq!(
1726            via_index([a(), b(), c()]),
1727            via_index([a(), c(), b()]),
1728            "the maintained index must order merged lots the same way the scan does",
1729        );
1730
1731        // B written FIRST is distinguishable and legitimately differs: it is
1732        // not interchangeable with either of the others.
1733        for (name, lots) in [("B A C", [b(), a(), c()]), ("B C A", [b(), c(), a()])] {
1734            let inv = mk(lots);
1735            let got: Vec<Decimal> = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo)
1736                .matched
1737                .iter()
1738                .map(|m| m.cost.as_ref().unwrap().number)
1739                .collect();
1740            assert_eq!(
1741                got,
1742                vec![dec!(20), dec!(10)],
1743                "{name}: B first must be consumed first, got {got:?}",
1744            );
1745        }
1746    }
1747
1748    #[test]
1749    fn fifo_single_lot_partial_cost_basis() {
1750        let inv = mk([lot(10, 100, 1)]);
1751        let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
1752        assert_eq!(basis(&r), dec!(300)); // 3 * 100
1753    }
1754
1755    // ---- HIFO ---------------------------------------------------------
1756
1757    #[test]
1758    fn hifo_takes_highest_cost_lot_first() {
1759        // costs 100, 300, 200 → HIFO order 300, 200, 100.
1760        let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
1761        let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
1762        // 10@300 + 5@200 = 3000 + 1000 = 4000.
1763        assert_eq!(basis(&r), dec!(4000));
1764        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
1765        assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1766    }
1767
1768    // ---- AVERAGE ------------------------------------------------------
1769
1770    #[test]
1771    fn average_cost_basis_partial() {
1772        // 10 @ $100, 30 @ $200 → 40 units, $7000 total, avg $175.
1773        let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1774        let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
1775        assert_eq!(basis(&r), dec!(3500)); // 20 * 175
1776    }
1777
1778    #[test]
1779    fn average_reduce_exact_total_succeeds() {
1780        // Reducing exactly the held quantity must succeed (kills
1781        // `reduction > total` → `>=`/`==`).
1782        let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1783        let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
1784        assert_eq!(basis(&r), dec!(7000)); // 40 * 175
1785    }
1786
1787    #[test]
1788    fn average_over_reduction_errors() {
1789        // Reducing more than held must error (kills `>` → `<`).
1790        let inv = mk([lot(10, 100, 1)]);
1791        let err = inv
1792            .try_reduce(
1793                &sell_stk(20),
1794                Some(&CostSpec::default()),
1795                BookingMethod::Average,
1796            )
1797            .unwrap_err();
1798        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1799    }
1800
1801    // ---- Filter isolation (currency / sign) ---------------------------
1802    // One fixture per method: an unrelated OTH lot plus the real STK lot.
1803    // A correct reducer touches ONLY the real STK lot; the currency `==`
1804    // and the `&&` connecting it would pull OTH in (or drop the real
1805    // one), changing the basis. (A zero-units "empty" lot is intentionally
1806    // NOT added here: `Inventory::add` drops empty positions on insert, so
1807    // the `!is_empty()` filter clause is unreachable for add-built
1808    // inventories and can't be exercised this way.)
1809
1810    fn isolation_inv() -> Inventory {
1811        let mut i = Inventory::new();
1812        i.add(Position::with_cost(
1813            Amount::new(dec!(10), "OTH"), // different currency: must be ignored
1814            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1815        ))
1816        .expect("fixture fits in Decimal");
1817        i.add(lot(10, 100, 2)).expect("fixture fits in Decimal"); // the real STK lot
1818        i
1819    }
1820
1821    fn assert_isolated(method: BookingMethod) {
1822        let inv = isolation_inv();
1823        let r = try_reduce(&inv, &sell_stk(5), method);
1824        assert_eq!(
1825            basis(&r),
1826            dec!(500),
1827            "must reduce only the real STK lot (5 * 100)"
1828        );
1829        assert!(
1830            r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
1831            "no non-STK lot should be matched"
1832        );
1833    }
1834
1835    #[test]
1836    fn fifo_filters_currency() {
1837        assert_isolated(BookingMethod::Fifo);
1838    }
1839
1840    #[test]
1841    fn hifo_filters_currency() {
1842        assert_isolated(BookingMethod::Hifo);
1843    }
1844
1845    #[test]
1846    fn strict_filters_currency() {
1847        assert_isolated(BookingMethod::Strict);
1848    }
1849
1850    #[test]
1851    fn average_filters_currency() {
1852        // average filters by currency + non-empty (no cost-spec / sign filter).
1853        let inv = isolation_inv();
1854        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
1855        // Only the STK lot participates: 10 units @ $100 → avg $100 → 5 * 100.
1856        assert_eq!(basis(&r), dec!(500));
1857    }
1858
1859    // ---- Sign guard ---------------------------------------------------
1860
1861    #[test]
1862    fn does_not_match_same_sign_lot() {
1863        // A short (negative) STK lot must NOT satisfy a sell (negative
1864        // units): same sign. Only the long lot is reducible. Kills the
1865        // `signum() != signum()` → `==` mutant (== would match the short
1866        // lot or nothing).
1867        let mut i = Inventory::new();
1868        i.add(lot(-10, 50, 1)).expect("fixture fits in Decimal"); // short lot, same sign as a sell
1869        i.add(lot(10, 100, 2)).expect("fixture fits in Decimal"); // long lot
1870        let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
1871        assert_eq!(basis(&r), dec!(500)); // 5 * 100 from the long lot only
1872        assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
1873    }
1874
1875    #[test]
1876    fn strict_rejects_when_only_same_sign_lot_present() {
1877        // STRICT against an inventory holding ONLY a same-sign (short)
1878        // lot must return NoMatchingLot — the single reducible lot fails
1879        // `can_reduce`, leaving zero matches. This pins all three `&&`
1880        // connectors in `try_reduce_strict`'s filter: each `&& -> ||`
1881        // mutant wrongly admits the short lot (currency==STK or the
1882        // always-true `matches_cost_spec` on the default spec satisfies
1883        // the disjunction), turning 0 matches into 1 and succeeding via
1884        // `try_reduce_from_lot` instead of erroring.
1885        let mut i = Inventory::new();
1886        i.add(lot(-10, 100, 1)).expect("fixture fits in Decimal"); // short STK only; a sell is the same sign
1887        let res = i.try_reduce(
1888            &sell_stk(5),
1889            Some(&CostSpec::default()),
1890            BookingMethod::Strict,
1891        );
1892        assert!(
1893            matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
1894            "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1895        );
1896    }
1897
1898    // ---- Insufficient-units accounting --------------------------------
1899
1900    #[test]
1901    fn fifo_insufficient_reports_available() {
1902        // `available = requested - remaining`; kills the `-` → `+`/`/`
1903        // mutant in the insufficient branch.
1904        let inv = mk([lot(10, 100, 1)]);
1905        let err = inv
1906            .try_reduce(
1907                &sell_stk(15),
1908                Some(&CostSpec::default()),
1909                BookingMethod::Fifo,
1910            )
1911            .unwrap_err();
1912        match err {
1913            super::BookingError::InsufficientUnits {
1914                requested,
1915                available,
1916                ..
1917            } => {
1918                assert_eq!(requested, dec!(15));
1919                assert_eq!(available, dec!(10)); // 15 requested - 5 remaining
1920            }
1921            other => panic!("expected InsufficientUnits, got {other:?}"),
1922        }
1923    }
1924
1925    // ---- STRICT single-lot path (try_reduce_from_lot) -----------------
1926
1927    #[test]
1928    fn strict_single_lot_partial_cost_basis() {
1929        // Exactly one matching lot → try_reduce_from_lot; partial take.
1930        let inv = mk([lot(10, 100, 1)]);
1931        let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1932        assert_eq!(basis(&r), dec!(400)); // 4 * 100
1933    }
1934
1935    #[test]
1936    fn strict_single_lot_over_reduction_errors() {
1937        // from_lot `requested > available` guard.
1938        let inv = mk([lot(10, 100, 1)]);
1939        let err = inv
1940            .try_reduce(
1941                &sell_stk(11),
1942                Some(&CostSpec::default()),
1943                BookingMethod::Strict,
1944            )
1945            .unwrap_err();
1946        assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1947    }
1948
1949    #[test]
1950    fn strict_single_lot_exact_full_reduction_succeeds() {
1951        // requested == available must succeed (kills from_lot `>` → `>=`).
1952        let inv = mk([lot(10, 100, 1)]);
1953        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1954        assert_eq!(basis(&r), dec!(1000));
1955    }
1956
1957    // ---- HIFO matched units + insufficient accounting ----------------
1958
1959    #[test]
1960    fn hifo_matched_units_and_insufficient_available() {
1961        let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1962        let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1963        // 8 taken from the $300 lot (kills the split `take * signum -> +`).
1964        assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1965        let err = inv
1966            .try_reduce(
1967                &sell_stk(25),
1968                Some(&CostSpec::default()),
1969                BookingMethod::Hifo,
1970            )
1971            .unwrap_err();
1972        match err {
1973            super::BookingError::InsufficientUnits { available, .. } => {
1974                assert_eq!(available, dec!(20)); // 20 held; kills `abs - remaining` mutants
1975            }
1976            other => panic!("expected InsufficientUnits, got {other:?}"),
1977        }
1978    }
1979
1980    #[test]
1981    fn strict_from_lot_matched_units() {
1982        let inv = mk([lot(10, 100, 1)]);
1983        let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1984        assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills from_lot split `* -> +`
1985    }
1986
1987    // ---- StrictWithSize ----------------------------------------------
1988
1989    #[test]
1990    fn strict_with_size_picks_exact_size_lot() {
1991        let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1992        let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1993        assert_eq!(basis(&r), dec!(1000)); // 5 @ $200, the exact-size lot
1994    }
1995
1996    #[test]
1997    fn strict_with_size_takes_the_oldest_of_several_exact_size_lots() {
1998        // #2097. Two lots of the reduction's size, so size alone does not
1999        // disambiguate. Beancount sorts the size matches by `cost.date` and
2000        // takes the first; the choice decides both the basis realized and the
2001        // holding period of what survives.
2002        //
2003        // The lots are built in the OPPOSITE order to their dates, which is
2004        // what the old `find`-first-in-slot-order got wrong. Slot order is
2005        // insertion order and usually matches date order by accident — but a
2006        // lot carrying an explicit cost date is inserted when its transaction
2007        // books and dated whenever the user wrote. Verified against beancount
2008        // 3.2.3, which leaves the 100-cost lot standing.
2009        let inv = mk([lot(10, 100, 20), lot(10, 200, 5)]);
2010        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::StrictWithSize);
2011        assert_eq!(
2012            basis(&r),
2013            dec!(2000),
2014            "must realize the OLDEST size match (day 5, cost 200), not the \
2015             first one stored (day 20, cost 100)"
2016        );
2017    }
2018
2019    /// Two size matches sharing a date resolve by insertion order.
2020    ///
2021    /// The date comparison cannot separate these, so only the `i` in
2022    /// `min_by_key`'s key decides — and the comment above it promises a
2023    /// deterministic result. Nothing pinned that: reversing the slot tiebreak
2024    /// to `Reverse(i)` leaves all 485 core tests passing.
2025    ///
2026    /// Determinism here is not decoration. `report capgains` splits short from
2027    /// long on the surviving lot's acquisition date, and per-lot IRR keys
2028    /// eligibility off it, so a reduction that picks arbitrarily between two
2029    /// same-date lots moves tax figures between runs.
2030    #[test]
2031    fn strict_with_size_breaks_a_date_tie_by_insertion_order() {
2032        // Same date, same size, different costs — so the choice is visible in
2033        // the basis and nothing but the tiebreak can make it.
2034        let inv = mk([lot(10, 100, 7), lot(10, 200, 7)]);
2035        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::StrictWithSize);
2036        assert_eq!(
2037            basis(&r),
2038            dec!(1000),
2039            "must take the FIRST of two same-date size matches (cost 100)",
2040        );
2041    }
2042
2043    /// An undated size match loses to a dated one.
2044    ///
2045    /// `map_or((1, NaiveDate::MAX), ..)` sorts `None` last, on the reasoning
2046    /// that a booked lot always carries a date and an unbooked one is not what
2047    /// the user meant. That is a real decision — `None` sorts BEFORE `Some`
2048    /// naturally, so the encoding exists precisely to override it — and it was
2049    /// unpinned: flipping it to sort `None` first leaves the whole core suite
2050    /// green, along with `rustledger`, `rustledger-validate` and
2051    /// `rustledger-query`.
2052    #[test]
2053    fn strict_with_size_prefers_a_dated_lot_over_an_undated_lot() {
2054        let mut inv = Inventory::new();
2055        // Undated FIRST, so insertion order alone would pick it and cannot
2056        // be what produces the expected answer.
2057        inv.add(Position::with_cost(
2058            Amount::new(d(10), "STK"),
2059            Cost::new(d(100), "USD"),
2060        ))
2061        .expect("fixture fits in Decimal");
2062        inv.add(lot(10, 200, 9)).expect("fixture fits in Decimal");
2063
2064        let r = try_reduce(&inv, &sell_stk(10), BookingMethod::StrictWithSize);
2065        assert_eq!(
2066            basis(&r),
2067            dec!(2000),
2068            "must take the DATED size match (cost 200), not the undated one",
2069        );
2070    }
2071
2072    #[test]
2073    fn strict_with_size_ambiguous_without_exact_or_total() {
2074        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
2075        let err = inv
2076            .try_reduce(
2077                &sell_stk(5),
2078                Some(&CostSpec::default()),
2079                BookingMethod::StrictWithSize,
2080            )
2081            .unwrap_err();
2082        assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
2083    }
2084
2085    #[test]
2086    fn strict_with_size_total_match_falls_back_to_fifo() {
2087        let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
2088        let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
2089        assert_eq!(basis(&r), dec!(3000)); // total match → FIFO: 1000 + 2000
2090
2091        // The basis alone cannot tell FIFO from LIFO here: a total match
2092        // consumes every matching lot, so any order sums to 3000. The name of
2093        // this test is about the ORDER, so assert it — flipping the fallback
2094        // to LIFO used to leave this green.
2095        assert_eq!(
2096            r.matched
2097                .iter()
2098                .map(|p| p.cost.as_ref().map(|c| c.number))
2099                .collect::<Vec<_>>(),
2100            vec![Some(dec!(100)), Some(dec!(200))],
2101            "oldest lot first",
2102        );
2103    }
2104
2105    // ---- Mutating reduce() path (reduce_*) ----------------------------
2106
2107    #[test]
2108    fn reduce_fifo_commits_and_basis() {
2109        let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
2110        let r = inv
2111            .reduce(
2112                &sell_stk(15),
2113                Some(&CostSpec::default()),
2114                BookingMethod::Fifo,
2115            )
2116            .unwrap();
2117        assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
2118        assert_eq!(inv.units("STK"), dec!(5)); // 20 - 15
2119    }
2120
2121    #[test]
2122    fn reduce_on_large_shared_inventory_does_not_corrupt() {
2123        // Regression: the rich-workload profiler found a heap-corruption /
2124        // SIGSEGV when reducing an inventory that had been cloned (imbl O(1)
2125        // structural share, as the booking engine does for working copies).
2126        // In-place mutation of the SHARED imbl `Vector` double-freed the interned
2127        // `Arc<str>` inside `Position`. Needs >64 distinct lots so the `Vector`
2128        // spans multiple Arc-backed chunks — the representation that actually
2129        // shares (and corrupted). Without the fix this aborts/segfaults on drop.
2130        // 100 distinct-cost lots (>64 = the imbl chunk size) so the `Vector`
2131        // spans multiple Arc-backed chunks — the shared representation that
2132        // corrupted. Day stays a valid 1..=28 (lots remain distinct by cost).
2133        // The Miri CI job (`rustledger-core`, strict provenance) executes this
2134        // and flags the use-after-free deterministically when the guard is gone.
2135        let mut inv = mk((0i64..100).map(|i| lot(10, 100 + i, ((i % 28) + 1) as u32)));
2136        let snapshot = inv.clone(); // structurally shares chunks with `inv`
2137        inv.reduce(
2138            &sell_stk(700),
2139            Some(&CostSpec::default()),
2140            BookingMethod::Fifo,
2141        )
2142        .unwrap();
2143        assert_eq!(inv.units("STK"), dec!(300)); // 1000 - 700
2144        // The shared snapshot stays independent and intact; `units` re-reads
2145        // every interned currency, and dropping both must not double-free.
2146        assert_eq!(snapshot.units("STK"), dec!(1000));
2147    }
2148
2149    #[test]
2150    fn reduce_hifo_commits_basis_units_insufficient() {
2151        let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
2152        let r = inv
2153            .reduce(
2154                &sell_stk(15),
2155                Some(&CostSpec::default()),
2156                BookingMethod::Hifo,
2157            )
2158            .unwrap();
2159        assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 10@300 + 5@100
2160        assert_eq!(r.matched[0].units.number.abs(), dec!(10)); // kills reduce_hifo split `* -> +`
2161        let mut inv2 = mk([lot(10, 100, 1)]);
2162        let err = inv2
2163            .reduce(
2164                &sell_stk(25),
2165                Some(&CostSpec::default()),
2166                BookingMethod::Hifo,
2167            )
2168            .unwrap_err();
2169        match err {
2170            super::BookingError::InsufficientUnits { available, .. } => {
2171                assert_eq!(available, dec!(10));
2172            }
2173            other => panic!("expected InsufficientUnits, got {other:?}"),
2174        }
2175    }
2176
2177    #[test]
2178    fn reduce_average_only_matching_currency() {
2179        let mut i = Inventory::new();
2180        i.add(lot(10, 100, 2)).expect("fixture fits in Decimal");
2181        i.add(Position::with_cost(
2182            Amount::new(dec!(10), "OTH"),
2183            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
2184        ))
2185        .expect("fixture fits in Decimal");
2186        let r = i
2187            .reduce(
2188                &sell_stk(5),
2189                Some(&CostSpec::default()),
2190                BookingMethod::Average,
2191            )
2192            .unwrap();
2193        assert_eq!(r.cost_basis.unwrap().number, dec!(500)); // only the STK lot
2194    }
2195
2196    #[test]
2197    fn reduce_average_partial_multi_lot_matches_single_synthetic_lot() {
2198        // Regression: a partial AVERAGE sale across multiple lots matches a
2199        // SINGLE synthetic lot of the reduced quantity at the average cost, not
2200        // every underlying lot. Returning the full lot set made the consumer
2201        // (book.rs) expand the reduction into one posting per lot, emptying the
2202        // position and booking a garbage gain.
2203        let mut i = Inventory::new();
2204        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2205        i.add(lot(10, 170, 2)).expect("fixture fits in Decimal");
2206        let r = i
2207            .reduce(
2208                &sell_stk(5),
2209                Some(&CostSpec::default()),
2210                BookingMethod::Average,
2211            )
2212            .unwrap();
2213
2214        // One synthetic matched lot at the average cost {160}; basis 5*160=800.
2215        // Long pool: the matched lot carries the inventory (positive) sign.
2216        assert_eq!(r.matched.len(), 1);
2217        assert_eq!(r.cost_basis.as_ref().unwrap().number, dec!(800));
2218        assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(160));
2219        assert_eq!(r.matched[0].units.number, dec!(5));
2220
2221        // 15 STK remain as a single lot carrying the average cost {160}.
2222        assert_eq!(i.units("STK"), dec!(15));
2223        let remaining: Vec<&Position> = i
2224            .positions()
2225            .filter(|p| p.units.currency == "STK")
2226            .collect();
2227        assert_eq!(remaining.len(), 1);
2228        assert_eq!(remaining[0].cost.as_ref().unwrap().number, dec!(160));
2229    }
2230
2231    #[test]
2232    fn reduce_average_short_cover_matched_lot_carries_inventory_sign() {
2233        // Covering a short (positive units reducing a negative pool) must return
2234        // a matched lot with the inventory (negative) sign, like FIFO/ordered.
2235        let mut i = Inventory::new();
2236        i.add(Position::with_cost(
2237            Amount::new(dec!(-10), "STK"),
2238            Cost::new(dec!(150), "USD"),
2239        ))
2240        .expect("fixture fits in Decimal");
2241        let r = i
2242            .reduce(
2243                &Amount::new(dec!(5), "STK"),
2244                Some(&CostSpec::default()),
2245                BookingMethod::Average,
2246            )
2247            .unwrap();
2248        assert_eq!(r.matched.len(), 1);
2249        assert_eq!(r.matched[0].units.number, dec!(-5));
2250        // Short pool shrinks from -10 to -5.
2251        assert_eq!(i.units("STK"), dec!(-5));
2252    }
2253
2254    #[test]
2255    fn merge_average_collapses_lots_to_single_weighted_lot() {
2256        // The realized balance of an AVERAGE account is one pool at the
2257        // weighted-average cost: (10*150 + 10*170 - 5*160) / 15 = 160.
2258        let mut i = Inventory::new();
2259        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2260        i.add(lot(10, 170, 2)).expect("fixture fits in Decimal");
2261        i.add(Position::with_cost(
2262            Amount::new(dec!(-5), "STK"),
2263            Cost::new(dec!(160), "USD"),
2264        ))
2265        .expect("fixture fits in Decimal");
2266        i.merge_average().expect("fixture fits in Decimal");
2267        let stk: Vec<&Position> = i
2268            .positions()
2269            .filter(|p| p.units.currency == "STK")
2270            .collect();
2271        assert_eq!(stk.len(), 1);
2272        assert_eq!(stk[0].units.number, dec!(15));
2273        assert_eq!(stk[0].cost.as_ref().unwrap().number, dec!(160));
2274    }
2275
2276    #[test]
2277    fn merge_average_net_zero_removes_lots() {
2278        let mut i = Inventory::new();
2279        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2280        i.add(Position::with_cost(
2281            Amount::new(dec!(-10), "STK"),
2282            Cost::new(dec!(160), "USD"),
2283        ))
2284        .expect("fixture fits in Decimal");
2285        i.merge_average().expect("fixture fits in Decimal");
2286        assert_eq!(
2287            i.positions().filter(|p| p.units.currency == "STK").count(),
2288            0
2289        );
2290    }
2291
2292    #[test]
2293    fn merge_average_leaves_costless_positions_untouched() {
2294        let mut i = Inventory::new();
2295        i.add(Position::simple(Amount::new(dec!(100), "USD")))
2296            .expect("fixture fits in Decimal");
2297        i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2298        i.merge_average().expect("fixture fits in Decimal");
2299        // Cash stays; the single STK lot stays a single lot.
2300        assert_eq!(i.units("USD"), dec!(100));
2301        assert_eq!(
2302            i.positions().filter(|p| p.units.currency == "STK").count(),
2303            1
2304        );
2305    }
2306
2307    #[test]
2308    fn reduce_from_lot_matched_and_remaining_units() {
2309        let mut inv = mk([lot(10, 100, 1)]);
2310        let r = inv
2311            .reduce(
2312                &sell_stk(4),
2313                Some(&CostSpec::default()),
2314                BookingMethod::Strict,
2315            )
2316            .unwrap();
2317        assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills reduce_from_lot split `* -> +`
2318        // Assert the stored POSITION units directly, not `units()` — the
2319        // latter reads a separate incremental cache, so it would not catch
2320        // a bug in `new_units = pos.units.number + units.number`.
2321        let remaining: Vec<_> = inv.position_list();
2322        assert_eq!(remaining.len(), 1);
2323        assert_eq!(remaining[0].units.number, dec!(6)); // 10 + (-4); kills `+ -> -`/`*`
2324        assert_eq!(inv.units("STK"), dec!(6)); // cache stays consistent
2325    }
2326
2327    #[test]
2328    fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
2329        // Merge two long STK lots; a short STK lot (same sign as the
2330        // sell) and an unrelated OTH lot must be excluded from the merge
2331        // AND survive in the inventory.
2332        let mut inv = Inventory::new();
2333        inv.add(lot(10, 100, 1)).expect("fixture fits in Decimal"); // long STK
2334        inv.add(lot(30, 200, 2)).expect("fixture fits in Decimal"); // long STK
2335        inv.add(lot(-5, 999, 3)).expect("fixture fits in Decimal"); // short STK — excluded by the sign filter
2336        inv.add(Position::with_cost(
2337            Amount::new(dec!(10), "OTH"), // different currency — excluded
2338            Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
2339        ))
2340        .expect("fixture fits in Decimal");
2341        let spec = CostSpec {
2342            merge: true,
2343            ..CostSpec::default()
2344        };
2345        let r = inv
2346            .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
2347            .unwrap();
2348        // Only the two long STK lots merge: 40 units @ avg $175 → 20 * 175.
2349        // Including the short (sign) or OTH (currency) lot would change this.
2350        assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
2351        // The excluded lots must still be present (kills the retain-index mutant).
2352        assert!(
2353            inv.position_list()
2354                .iter()
2355                .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
2356            "OTH lot must survive the merge"
2357        );
2358        assert!(
2359            inv.position_list()
2360                .iter()
2361                .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
2362            "short STK lot must survive the merge"
2363        );
2364    }
2365
2366    #[test]
2367    fn reduce_none_exact_succeeds_over_reduction_shorts() {
2368        let mut inv = Inventory::new();
2369        inv.add(Position::simple(Amount::new(dec!(10), "STK")))
2370            .expect("fixture fits in Decimal");
2371        assert!(
2372            inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
2373            "exact NONE reduction should succeed"
2374        );
2375        // NONE performs no booking, so over-reduction shorts past zero
2376        // instead of erroring (#1686 — previously InsufficientUnits, which
2377        // made the outcome depend on whether zero was crossed in one step).
2378        let mut inv2 = Inventory::new();
2379        inv2.add(Position::simple(Amount::new(dec!(10), "STK")))
2380            .expect("fixture fits in Decimal");
2381        assert!(
2382            inv2.reduce(&sell_stk(15), None, BookingMethod::None)
2383                .is_ok(),
2384            "NONE over-reduction must short, not error (#1686)"
2385        );
2386        assert_eq!(inv2.units("STK"), dec!(-5));
2387    }
2388
2389    #[test]
2390    fn reduce_merge_uses_weighted_average() {
2391        let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
2392        let spec = CostSpec {
2393            merge: true,
2394            ..CostSpec::default()
2395        };
2396        let r = inv
2397            .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
2398            .unwrap();
2399        assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 20 @ avg $175
2400        assert_eq!(inv.units("STK"), dec!(20)); // 40 - 20
2401    }
2402}