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(¤cy)
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(¤cy) {
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}