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, false)
140 .map(|(r, _)| r),
141 BookingMethod::Lifo => self
142 .plan_ordered(units, &spec, LotOrder::Date, true)
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) =
324 self.plan_ordered(units, spec, LotOrder::Date, false)?;
325 return Ok((result, ReductionPlan::Updates(updates)));
326 }
327
328 // Total match exception: if the reduction equals the sum of all
329 // matching lots, every matched lot is consumed, so no lot
330 // survives to carry a date and the choice cannot be observed.
331 // Beancount has this same exception, and for the same reason.
332 let total_units: Decimal = matching_indices
333 .iter()
334 .map(|&i| self.positions[i].units.number.abs())
335 .sum();
336 if total_units == units.number.abs() {
337 let (result, updates) =
338 self.plan_ordered(units, spec, LotOrder::Date, false)?;
339 return Ok((result, ReductionPlan::Updates(updates)));
340 }
341
342 Err(BookingError::AmbiguousMatch {
343 num_matches: n,
344 currency: units.currency.clone(),
345 })
346 }
347 }
348 }
349
350 /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
351 /// `STRICT_WITH_SIZE`: an explicit cost, disambiguated by lot size.
352 ///
353 /// Planned from `&self` so `try_reduce` can preview it without copying the
354 /// inventory. It used to be reachable only through `self.clone().reduce()`
355 /// — an O(lots) copy per reducing posting, which is quadratic across a
356 /// ledger and was most of this method's cost (#2091). The conversion is the
357 /// one #2061 left as "mechanical" when it split STRICT, FIFO and LIFO.
358 pub(super) fn plan_strict_with_size(
359 &self,
360 units: &Amount,
361 spec: &CostSpec,
362 ) -> Result<(BookingResult, ReductionPlan), BookingError> {
363 // Narrow through the cost index before filtering, the way `plan_strict`
364 // does. This walked every slot in the account on every reduction. A
365 // spec naming a cost has a handful of candidates; one that names none
366 // still scans, because it can match anything.
367 let candidates: Vec<usize> = self.cost_candidates(units, spec).unwrap_or_else(|| {
368 self.positions
369 .iter_slots()
370 .filter(|(_, p)| p.units.currency == units.currency)
371 .map(|(i, _)| i)
372 .collect()
373 });
374 let matching_indices: Vec<usize> = candidates
375 .into_iter()
376 .filter(|&i| {
377 self.positions.get(i).is_some_and(|p| {
378 p.units.currency == units.currency
379 && !p.is_empty()
380 && p.can_reduce(units)
381 && p.matches_cost_spec(spec)
382 })
383 })
384 .collect();
385
386 let from_lot = |idx: usize| {
387 self.plan_from_lot(idx, units)
388 .map(|(result, new_units)| (result, ReductionPlan::FromLot { idx, new_units }))
389 };
390
391 match matching_indices.len() {
392 0 => Err(BookingError::NoMatchingLot {
393 currency: units.currency.clone(),
394 cost_spec: spec.clone(),
395 }),
396 1 => from_lot(matching_indices[0]),
397 n => {
398 // A lot of exactly the reduction's size disambiguates. When
399 // SEVERAL do, the OLDEST wins — beancount sorts the size
400 // matches by `cost.date` and takes the first, and the choice
401 // is observable in both the basis realized and the holding
402 // period of whatever survives.
403 //
404 // This used to take the first candidate in slot order, which
405 // is insertion order. That is usually date order and so
406 // usually agreed by accident, but a lot carrying an explicit
407 // cost date (`{100.00 USD, 2030-01-01}`) is inserted when its
408 // transaction is booked and dated whenever the user said. Buy
409 // 10 X {100.00, 2030-01-01} then 10 X {200.00, 2020-01-01} and
410 // sell 10 X {}: beancount sells the 2020 lot and leaves 1000
411 // USD of basis, slot order sells the 2030 lot and leaves 2000.
412 // Neither reports anything.
413 //
414 // Ties break on slot index so the result stays deterministic
415 // when two size matches share a date; `None` dates sort last,
416 // since a booked lot always has one and an unbooked lot is not
417 // the one the user meant.
418 let exact = matching_indices
419 .iter()
420 .copied()
421 .filter(|&i| self.positions[i].units.number.abs() == units.number.abs())
422 .min_by_key(|&i| {
423 (
424 self.positions[i]
425 .cost
426 .as_ref()
427 .and_then(|c| c.date)
428 .map_or((1, NaiveDate::MAX), |d| (0, d)),
429 i,
430 )
431 });
432 if let Some(idx) = exact {
433 return from_lot(idx);
434 }
435 // Total match exception: selling the whole matched inventory
436 // makes the choice of lot irrelevant.
437 let total_units: Decimal = matching_indices
438 .iter()
439 .map(|&i| self.positions[i].units.number.abs())
440 .sum();
441 if total_units == units.number.abs() {
442 let (result, updates) =
443 self.plan_ordered(units, spec, LotOrder::Date, false)?;
444 return Ok((result, ReductionPlan::Updates(updates)));
445 }
446 Err(BookingError::AmbiguousMatch {
447 num_matches: n,
448 currency: units.currency.clone(),
449 })
450 }
451 }
452 }
453
454 pub(super) fn reduce_strict_with_size(
455 &mut self,
456 units: &Amount,
457 spec: &CostSpec,
458 ) -> Result<BookingResult, BookingError> {
459 let (result, plan) = self.plan_strict_with_size(units, spec)?;
460 self.commit_plan(&plan, units);
461 Ok(result)
462 }
463
464 pub(super) fn reduce_fifo(
465 &mut self,
466 units: &Amount,
467 spec: &CostSpec,
468 ) -> Result<BookingResult, BookingError> {
469 self.reduce_ordered(units, spec, false)
470 }
471
472 /// LIFO booking: reduce from newest lots first.
473 pub(super) fn reduce_lifo(
474 &mut self,
475 units: &Amount,
476 spec: &CostSpec,
477 ) -> Result<BookingResult, BookingError> {
478 self.reduce_ordered(units, spec, true)
479 }
480
481 /// HIFO booking: reduce from highest-cost lots first.
482 /// HIFO booking: take from the most expensive lots first.
483 ///
484 /// The plan half of the ordered walk with a cost key, which is all HIFO
485 /// ever was. It used to carry its own copy of that walk — scan every slot,
486 /// sort the survivors by cost, sum them for sufficiency, then take — which
487 /// is O(lots) per reduction and was 18s on a 20,000-transaction ledger
488 /// against FIFO's 0.27s (#2091). Sharing `plan_ordered` gives it the
489 /// maintained index, the early stop, and a plan half so `try_reduce` can
490 /// preview without cloning the inventory.
491 pub(super) fn plan_hifo(
492 &self,
493 units: &Amount,
494 spec: &CostSpec,
495 ) -> Result<(BookingResult, SmallVec<[(usize, Decimal); 1]>), BookingError> {
496 self.plan_ordered(units, spec, LotOrder::CostDescending, false)
497 }
498
499 pub(super) fn reduce_hifo(
500 &mut self,
501 units: &Amount,
502 spec: &CostSpec,
503 ) -> Result<BookingResult, BookingError> {
504 let (result, updates) = self.plan_hifo(units, spec)?;
505 self.commit_updates(&updates);
506 Ok(result)
507 }
508
509 pub(super) fn reduce_ordered(
510 &mut self,
511 units: &Amount,
512 spec: &CostSpec,
513 reverse: bool,
514 ) -> Result<BookingResult, BookingError> {
515 let (result, updates) = self.plan_ordered(units, spec, LotOrder::Date, reverse)?;
516 self.commit_updates(&updates);
517 Ok(result)
518 }
519
520 /// The read-only half of [`Self::reduce_ordered`]: pick the lots, check
521 /// sufficiency, and compute what would be matched — without touching a
522 /// single position.
523 ///
524 /// Returns the result alongside `(index, new units number)` pairs for the
525 /// caller to apply. See [`Self::plan_from_lot`] for why the split exists
526 /// and why the selection logic must not be duplicated into `try_reduce`.
527 pub(super) fn plan_ordered(
528 &self,
529 units: &Amount,
530 spec: &CostSpec,
531 order: LotOrder,
532 reverse: bool,
533 ) -> Result<(BookingResult, SmallVec<[(usize, Decimal); 1]>), BookingError> {
534 let mut remaining = units.number.abs();
535 let mut matched: MatchedLots = SmallVec::new();
536 let mut cost_basis = Decimal::ZERO;
537 let mut cost_currency = None;
538
539 // Candidates in FIFO order.
540 //
541 // `ordered_index` already holds this currency's lots — every one of
542 // them, cost-less included, since an empty spec matches those too — in
543 // (date, slot) order, so the common path neither scans every slot nor
544 // sorts per call. It did both, once per reduction, and `{}` is how
545 // FIFO sells are written — so that was the normal case (#2083).
546 //
547 // The predicate still runs per candidate: the index knows nothing
548 // about sign, emptiness or what this spec matches, and a stale entry
549 // for a drained lot is expected, since removal is best-effort.
550 let scanned: Option<Vec<usize>> =
551 if self.ordered_candidates(&units.currency, order).is_some() {
552 None
553 } else {
554 // No index — a shared snapshot, or one never rebuilt. Scanning is
555 // the only correct answer, and it is what this always did.
556 let mut all: Vec<usize> = self
557 .positions
558 .iter_slots()
559 .filter(|(_, p)| p.units.currency == units.currency)
560 .map(|(i, _)| i)
561 .collect();
562 all.sort_by_key(|&i| self.order_key(order, i));
563 Some(all)
564 };
565 let candidates: &[usize] = match &scanned {
566 Some(all) => all,
567 None => self
568 .ordered_candidates(&units.currency, order)
569 .expect("the branch above proved it is Some"),
570 };
571
572 let keeps = |i: usize| {
573 self.positions.get(i).is_some_and(|p| {
574 p.units.currency == units.currency
575 && !p.is_empty()
576 && p.units.number.signum() != units.number.signum()
577 && p.matches_cost_spec(spec)
578 })
579 };
580
581 // Sufficiency used to be checked up front, by summing every matching
582 // lot before taking any. That was needed when this function mutated;
583 // since the plan/commit split (#2061) it only computes and the caller
584 // commits, so exhausting the walk reports the same shortfall with the
585 // same total, having visited the same lots. The invariant it protected
586 // — a failed reduction leaves the inventory untouched — is structural
587 // now, and `booking_properties.rs` still pins it.
588 let mut updates: SmallVec<[(usize, Decimal); 1]> = SmallVec::new();
589 let mut seen_any = false;
590 let mut available_total = Decimal::ZERO;
591 let mut overflow: Option<OverflowError> = None;
592
593 let mut forward;
594 let mut backward;
595 let walk: &mut dyn Iterator<Item = usize> = if reverse {
596 backward = candidates.iter().rev().copied();
597 &mut backward
598 } else {
599 forward = candidates.iter().copied();
600 &mut forward
601 };
602
603 for idx in walk {
604 if !keeps(idx) {
605 continue;
606 }
607 seen_any = true;
608 let pos = &self.positions[idx];
609 available_total += pos.units.number.abs();
610 let available = pos.units.number.abs();
611 let take = remaining.min(available);
612
613 // Calculate cost basis for this portion (checked — see the
614 // matching site in the FIFO/LIFO ladder above).
615 if let Some(cost) = &pos.cost {
616 if cost_currency.is_none() {
617 cost_currency = Some(cost.currency.clone());
618 }
619 // Recorded, not returned. Sufficiency used to be settled before
620 // any basis arithmetic ran, so a reduction that was BOTH short
621 // of units and unrepresentable reported the shortfall — the
622 // actionable half. Returning here would report the overflow
623 // instead, which is a behavior change nothing asked for.
624 match take
625 .checked_mul(cost.number)
626 .and_then(|v| cost_basis.checked_add(v))
627 {
628 Some(v) => cost_basis = v,
629 None => {
630 overflow.get_or_insert_with(|| OverflowError {
631 currency: cost.currency.clone(),
632 });
633 }
634 }
635 }
636
637 // Record what we matched
638 let (taken, _) = pos.split(take * pos.units.number.signum());
639 matched.push(taken);
640
641 // What the lot WOULD become. Recorded rather than applied so the
642 // preview path can run this same loop against `&self`.
643 let reduction = if units.number.is_sign_negative() {
644 -take
645 } else {
646 take
647 };
648 updates.push((idx, pos.units.number + reduction));
649
650 remaining -= take;
651
652 // Covered: stop here rather than walking the rest of the account.
653 // `available_total` is left partial on purpose — it feeds the
654 // shortfall message only, which is unreachable once the reduction
655 // is satisfied. Walking on to complete a number nobody reads is
656 // what made this O(lots): the spec is concrete by the time `apply`
657 // re-derives, so most later lots fail the predicate and the loop
658 // ran the cost comparison against every one of them.
659 if remaining.is_zero() {
660 break;
661 }
662 }
663
664 // No lot of this currency matched the spec at all.
665 if !seen_any {
666 return Err(BookingError::NoMatchingLot {
667 currency: units.currency.clone(),
668 cost_spec: spec.clone(),
669 });
670 }
671 // Lots matched but did not cover the reduction. Reported with the same
672 // total the up-front sum produced, because reaching here means the
673 // walk visited every matching lot.
674 if !remaining.is_zero() {
675 return Err(BookingError::InsufficientUnits {
676 currency: units.currency.clone(),
677 requested: units.number.abs(),
678 available: available_total,
679 });
680 }
681 // Only now: the reduction is satisfiable, so the arithmetic is what
682 // failed.
683 if let Some(error) = overflow {
684 return Err(BookingError::Overflow(error));
685 }
686
687 Ok((
688 BookingResult {
689 matched,
690 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
691 },
692 updates,
693 ))
694 }
695
696 /// Apply `(index, new units)` pairs from a plan, then restore the caches.
697 ///
698 /// `retain` + `rebuild_index` exactly as the fused `reduce_ordered` did —
699 /// the multi-lot path always paid an O(lots) cache rebuild, and changing
700 /// that is a separate question from removing the preview's clone.
701 /// Apply a multi-lot plan, repairing the caches rather than rebuilding
702 /// them.
703 ///
704 /// This used to `retain` away the drained lots and then `rebuild_index()`,
705 /// which is two walks of every slot plus a rehash of every key — per
706 /// reduction. In a FIFO ledger, where reductions are frequent and lots
707 /// accumulate, that is the whole cost: 20k transactions took 8.9s, of
708 /// which 7.1s was the rebuild (#2083).
709 ///
710 /// `updates` already names every slot that changed, so the repair is
711 /// proportional to the lots the reduction touched rather than to the lots
712 /// the account holds. Same treatment `commit_from_lot` got in #2063, for
713 /// the same reason; this is the multi-lot half of that change.
714 fn commit_updates(&mut self, updates: &[(usize, Decimal)]) {
715 // Every update names a lot of the same currency, because every producer
716 // of `ReductionPlan::Updates` filters on `units.currency` before
717 // planning. This repair DEPENDS on that — it adjusts one currency's
718 // running total — where the old `rebuild_index()` recomputed them all
719 // and so could not notice the invariant being violated.
720 //
721 // Checked here rather than after the loop: by then the drained lots are
722 // tombstones, and indexing one panics.
723 debug_assert!(
724 updates.windows(2).all(|pair| {
725 self.positions[pair[0].0].units.currency == self.positions[pair[1].0].units.currency
726 }),
727 "commit_updates was given lots of more than one currency; the units \
728 cache is adjusted per currency and would silently drift",
729 );
730
731 let mut delta = Decimal::ZERO;
732 let mut currency = None;
733
734 for &(idx, new_units) in updates {
735 let previous = self.positions[idx].units.number;
736 delta += new_units - previous;
737 if currency.is_none() {
738 currency = Some(self.positions[idx].units.currency.clone());
739 }
740
741 // Drop the old classification before overwriting: a reduction can
742 // take a lot through zero and flip its sign bucket.
743 self.sign_index_bump(idx, -1);
744 self.positions[idx].units.number = new_units;
745 self.sign_index_bump(idx, 1);
746
747 if self.positions[idx].is_empty() {
748 self.sign_index_bump(idx, -1);
749 self.cost_index_remove(idx);
750 self.positions.remove(idx);
751 // Removal leaves a tombstone, so no surviving lot is
752 // renumbered and only the entry naming this lot has to go. An
753 // empty cost spec matches a cost-less position, so ordered
754 // selection can drain one and this map can name it.
755 self.units_cache
756 .values_mut()
757 .filter(|stats| stats.simple_slot == Some(idx))
758 .for_each(|stats| stats.simple_slot = None);
759 }
760 }
761
762 // One adjustment for the whole plan: see the assertion at the top.
763 if let Some(currency) = currency
764 && let Some(stats) = self.units_cache.get_mut(¤cy)
765 {
766 stats.total = crate::decimal::add_python_scale(stats.total, delta);
767 }
768 }
769
770 /// AVERAGE booking: merge all lots of the currency.
771 ///
772 /// Stricter than Python: beancount does NOT implement this method.
773 /// `beancount.parser.booking_method.booking_method_AVERAGE` raises
774 /// `AmbiguousMatchError("AVERAGE method is not supported")`, with the real
775 /// implementation left commented out ("DISABLED - This is the code for
776 /// AVERAGE, which is currently disabled"). `Booking.AVERAGE` exists in the
777 /// enum and is accepted in an `open` directive, so a ledger declaring it
778 /// parses and then books nothing.
779 ///
780 /// Two consequences worth knowing before changing anything here:
781 ///
782 /// * The compat oracle cannot referee this. There is no reference answer
783 /// to diff against, so correctness rests on the definition — the merged
784 /// lot's cost is the cost-weighted average of the lots it replaces — and
785 /// on the two rledger surfaces agreeing.
786 /// * That is exactly why #1985 survived: BQL netted by lot key and produced
787 /// a dangling negative position where reports produced the merged lot,
788 /// and no differential test could see it. The internal parity guard
789 /// (`query_report_realization_parity_test`) is what caught it.
790 pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
791 let matching: Vec<&Position> = self
792 .positions
793 .iter()
794 .filter(|p| p.units.currency == units.currency && !p.is_empty())
795 .collect();
796
797 let total_units: Decimal = matching
798 .iter()
799 .try_fold(Decimal::ZERO, |acc, p| acc.checked_add(p.units.number))
800 .ok_or_else(|| {
801 BookingError::Overflow(OverflowError {
802 currency: units.currency.clone(),
803 })
804 })?;
805
806 if total_units.is_zero() {
807 return Err(BookingError::InsufficientUnits {
808 currency: units.currency.clone(),
809 requested: units.number.abs(),
810 available: Decimal::ZERO,
811 });
812 }
813
814 let reduction = units.number.abs();
815 if reduction > total_units.abs() {
816 return Err(BookingError::InsufficientUnits {
817 currency: units.currency.clone(),
818 requested: reduction,
819 available: total_units.abs(),
820 });
821 }
822
823 let avg = average_cost_from_positions(&matching, total_units)?;
824 let cost_basis = avg
825 .as_ref()
826 .map(|(avg_cost, currency)| {
827 reduction
828 .checked_mul(*avg_cost)
829 .map(|n| Amount::new(n, currency.clone()))
830 .ok_or_else(|| {
831 BookingError::Overflow(OverflowError {
832 currency: currency.clone(),
833 })
834 })
835 })
836 .transpose()?;
837
838 // Build a position of `number` units of the reduced currency at the
839 // average cost (or costless if the lots had no cost).
840 let at_avg_cost = |number: Decimal| -> Position {
841 let amount = Amount::new(number, units.currency.clone());
842 match &avg {
843 Some((avg_cost, currency)) => {
844 Position::with_cost(amount, Cost::new(*avg_cost, currency.clone()))
845 }
846 None => Position::simple(amount),
847 }
848 };
849
850 // A reduction under AVERAGE matches a SINGLE synthetic lot of the
851 // reduced quantity at the average cost, not every underlying lot.
852 // Returning the full lot set made the consumer (book.rs) expand the
853 // reduction into one posting per lot and remove the entire position
854 // (and book a garbage gain). The taken units carry the *inventory* sign
855 // (`total_units.signum()`), matching the FIFO/ordered convention — so
856 // covering a short (negative pool) yields a negative matched lot.
857 let matched: MatchedLots = smallvec![at_avg_cost(reduction * total_units.signum())];
858
859 let new_units = total_units + units.number;
860
861 // Remove all positions of this currency
862 self.positions
863 .retain(|p| p.units.currency != units.currency);
864
865 // Add back the remainder (if non-zero) at the average cost, so a later
866 // reduction sees the correct basis instead of a costless position.
867 if !new_units.is_zero() {
868 self.positions.push(at_avg_cost(new_units));
869 }
870
871 self.rebuild_index();
872
873 Ok(BookingResult {
874 matched,
875 cost_basis,
876 })
877 }
878
879 /// Collapse every cost-bearing lot of each currency into a single
880 /// weighted-average-cost lot. Cost-less (cash) positions are left untouched.
881 ///
882 /// This realizes the balance of an AVERAGE-booked account, where all lots of
883 /// a commodity share one running cost. The journal keeps the real per-lot
884 /// costs; only this realized view merges them (matching hledger's pool
885 /// model). A currency whose lots net to zero is removed; a currency whose
886 /// lots have mismatched cost currencies is left untouched.
887 ///
888 /// # Errors
889 ///
890 /// [`OverflowError`] when a currency's lots sum outside `rust_decimal`'s
891 /// range. The merged view is a realized balance, so a clamped total would
892 /// be rendered as an exact position (#1863).
893 pub fn merge_average(&mut self) -> Result<(), OverflowError> {
894 let currencies: std::collections::BTreeSet<Currency> = self
895 .positions
896 .iter()
897 .filter(|p| p.cost.is_some())
898 .map(|p| p.units.currency.clone())
899 .collect();
900
901 for currency in currencies {
902 let (total_units, avg) = {
903 let matching: Vec<&Position> = self
904 .positions
905 .iter()
906 .filter(|p| p.units.currency == currency && p.cost.is_some())
907 .collect();
908 let total_units: Decimal = matching
909 .iter()
910 .try_fold(Decimal::ZERO, |acc, p| acc.checked_add(p.units.number))
911 .ok_or_else(|| OverflowError {
912 currency: currency.clone(),
913 })?;
914 let avg = if total_units.is_zero() {
915 None
916 } else {
917 average_cost_from_positions(&matching, total_units)
918 .ok()
919 .flatten()
920 };
921 (total_units, avg)
922 };
923
924 // Couldn't average a non-zero position (cost-currency mismatch):
925 // leave its lots untouched rather than corrupt them.
926 if !total_units.is_zero() && avg.is_none() {
927 continue;
928 }
929
930 self.positions
931 .retain(|p| !(p.units.currency == currency && p.cost.is_some()));
932 if let Some((avg_cost, cost_currency)) = avg {
933 self.positions.push(Position::with_cost(
934 Amount::new(total_units, currency.clone()),
935 Cost::new(avg_cost, cost_currency),
936 ));
937 }
938 }
939 self.rebuild_index();
940 Ok(())
941 }
942
943 /// What `{*}` would merge, computed from `&self`.
944 ///
945 /// The selection half of [`Self::reduce_merge`], split out so the pool cost
946 /// can be known WITHOUT building it (#2068). `reduce_merge` is the only
947 /// mutating caller and it goes through here, so there is one implementation
948 /// of "which lots merge and at what average" — the duplication that the
949 /// plan/commit split (#2061) exists to prevent.
950 ///
951 /// `pool` is `None` when the matched lots carry no cost, which is
952 /// `reduce_merge`'s AVERAGE fallback rather than an error.
953 fn plan_merge(&self, units: &Amount) -> Result<MergePlan, BookingError> {
954 // Only merge lots with opposite sign (same as other reduce methods).
955 // This prevents accidentally netting long and short positions.
956 let matching: Vec<(usize, &Position)> = self
957 .positions
958 .iter_slots()
959 .filter(|(_, p)| {
960 p.units.currency == units.currency
961 && !p.is_empty()
962 && p.units.number.is_sign_positive() != units.number.is_sign_positive()
963 })
964 .collect();
965
966 if matching.is_empty() {
967 return Err(BookingError::InsufficientUnits {
968 currency: units.currency.clone(),
969 requested: units.number.abs(),
970 available: Decimal::ZERO,
971 });
972 }
973
974 let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
975 let reduction = units.number.abs();
976
977 if reduction > total_units.abs() {
978 return Err(BookingError::InsufficientUnits {
979 currency: units.currency.clone(),
980 requested: reduction,
981 available: total_units.abs(),
982 });
983 }
984
985 let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
986 let pool = average_cost_from_positions(&matching_refs, total_units)?
987 .map(|(number, currency)| Amount::new(number, currency));
988
989 Ok(MergePlan {
990 matching_indices: matching.iter().map(|(i, _)| *i).collect(),
991 total_units,
992 pool,
993 })
994 }
995
996 /// The per-unit pool cost `{*}` would produce here, without producing it.
997 ///
998 /// `Ok(None)` means there is no pool cost to compare against — cost-less
999 /// lots, where `{*}` degrades to AVERAGE. Errors are the ones the reduction
1000 /// itself would raise (no matching lots, insufficient units); a caller
1001 /// checking a precondition should let the reduction report them rather than
1002 /// pre-empting it, so that one function keeps owning the message.
1003 ///
1004 /// Exists so `BookingEngine::apply` can verify a carried `{*}` against the
1005 /// cost booking recorded BEFORE the merge mutates anything (#2068).
1006 pub fn merged_pool_cost(&self, units: &Amount) -> Result<Option<Amount>, BookingError> {
1007 Ok(self.plan_merge(units)?.pool)
1008 }
1009
1010 /// Cost merge `{*}`: merge all lots of the currency into a single
1011 /// weighted-average-cost lot, then reduce from it.
1012 ///
1013 /// Example: 10 AAPL {150 USD} + 10 AAPL {160 USD} merged = 20 AAPL {155 USD}.
1014 /// Reducing 5 AAPL {*} takes 5 from the merged 20 AAPL {155 USD} lot.
1015 pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
1016 let plan = self.plan_merge(units)?;
1017 let MergePlan {
1018 matching_indices,
1019 total_units,
1020 pool: Some(pool),
1021 } = plan
1022 else {
1023 // Cost-less lots: there is no pool to build (`plan_merge` says so),
1024 // so `{*}` degrades to AVERAGE, exactly as before the plan split.
1025 return self.reduce_average(units);
1026 };
1027 let reduction = units.number.abs();
1028 let (avg_cost, cost_currency) = (pool.number, pool.currency);
1029
1030 let cost_basis = Some(Amount::new(
1031 reduction.checked_mul(avg_cost).ok_or_else(|| {
1032 BookingError::Overflow(OverflowError {
1033 currency: cost_currency.clone(),
1034 })
1035 })?,
1036 cost_currency.clone(),
1037 ));
1038
1039 // Return a single synthetic matched position representing the merged lot.
1040 // This prevents the booking engine from expanding the posting into multiple
1041 // postings (one per original lot), which would be incorrect for {*}.
1042 let make_avg_cost = || Cost {
1043 number: avg_cost,
1044 currency: cost_currency.clone(),
1045 date: None,
1046 label: None,
1047 };
1048
1049 let matched: MatchedLots = smallvec![Position::with_cost(
1050 Amount::new(units.number.abs(), units.currency.clone()),
1051 make_avg_cost(),
1052 )];
1053
1054 // Remove all matching lots of this currency
1055 self.positions
1056 .retain_slots(|slot, _| !matching_indices.contains(&slot));
1057
1058 // Add back a single merged lot with the remainder
1059 let remaining = total_units + units.number; // units.number is negative for reductions
1060 if !remaining.is_zero() {
1061 self.positions.push(Position::with_cost(
1062 Amount::new(remaining, units.currency.clone()),
1063 make_avg_cost(),
1064 ));
1065 }
1066
1067 self.rebuild_index();
1068
1069 Ok(BookingResult {
1070 matched,
1071 cost_basis,
1072 })
1073 }
1074
1075 /// NONE booking: reduce without matching lots.
1076 pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
1077 // For NONE booking, we just reduce the total without caring about lots
1078 let total_units = self.units(&units.currency);
1079
1080 // Check we have enough in the right direction
1081 if total_units.signum() == units.number.signum() || total_units.is_zero() {
1082 // This is an augmentation, not a reduction - just add it
1083 self.add(Position::simple(units.clone()))?;
1084 return Ok(BookingResult {
1085 matched: SmallVec::new(),
1086 cost_basis: None,
1087 });
1088 }
1089
1090 let available = total_units.abs();
1091 let requested = units.number.abs();
1092
1093 if requested > available {
1094 // NONE performs no booking, so shorts are always allowed —
1095 // matching beancount's NONE semantics and NONECorrect.tla. This
1096 // arm previously returned InsufficientUnits, which made the
1097 // outcome depend on whether zero was crossed in one step (0 → -2
1098 // was allowed above; +1 → -1 was rejected here). Found by the
1099 // TLA+ behavior-replay suite (#1686): consume everything
1100 // available, then carry the remainder as a negative (short)
1101 // simple position.
1102 let sign = units.number.signum();
1103 let consumed = Amount::new(available * sign, units.currency.clone());
1104 let result = self.reduce_ordered(&consumed, &CostSpec::default(), false)?;
1105 self.add(Position::simple(Amount::new(
1106 (requested - available) * sign,
1107 units.currency.clone(),
1108 )))?;
1109 return Ok(result);
1110 }
1111
1112 // Reduce positions proportionally (simplified: just reduce first matching)
1113 self.reduce_ordered(units, &CostSpec::default(), false)
1114 }
1115
1116 /// Reduce from a specific lot.
1117 pub(super) fn plan_from_lot(
1118 &self,
1119 idx: usize,
1120 units: &Amount,
1121 ) -> Result<(BookingResult, Decimal), BookingError> {
1122 let pos = &self.positions[idx];
1123 let available = pos.units.number.abs();
1124 let requested = units.number.abs();
1125
1126 if requested > available {
1127 return Err(BookingError::InsufficientUnits {
1128 currency: units.currency.clone(),
1129 requested,
1130 available,
1131 });
1132 }
1133
1134 // Calculate cost basis
1135 let cost_basis = pos
1136 .cost
1137 .as_ref()
1138 .map(|c| {
1139 c.total_cost(requested).ok_or_else(|| {
1140 BookingError::Overflow(OverflowError {
1141 currency: c.currency.clone(),
1142 })
1143 })
1144 })
1145 .transpose()?;
1146
1147 // Record matched
1148 let (matched, _) = pos.split(requested * pos.units.number.signum());
1149
1150 // Python scale rule, same as `Inventory::add` — see
1151 // `crate::decimal::add_python_scale`. A reduction that brings a lot
1152 // through zero would otherwise drop the scale here while `add` kept
1153 // it, so the same lot would render differently depending on whether
1154 // it was last touched by an add or a reduce.
1155 let new_units = crate::decimal::add_python_scale(pos.units.number, units.number);
1156
1157 Ok((
1158 BookingResult {
1159 matched: smallvec![matched],
1160 cost_basis,
1161 },
1162 new_units,
1163 ))
1164 }
1165
1166 /// The mutating half of [`Self::reduce_from_lot`], applying a
1167 /// [`Self::plan_from_lot`] result.
1168 ///
1169 /// Keeps the incremental cache maintenance the single-lot path always
1170 /// had: a full `rebuild_index` here would be O(lots) on the commit path
1171 /// that this split is meant to keep cheap.
1172 fn commit_from_lot(&mut self, idx: usize, units: &Amount, new_units: Decimal) {
1173 let currency = self.positions[idx].units.currency.clone();
1174 let new_pos = Position {
1175 units: Amount::new(new_units, currency.clone()),
1176 cost: self.positions[idx].cost.clone(),
1177 };
1178 // Drop the old classification before overwriting: a reduction can take
1179 // a lot through zero and flip its sign bucket.
1180 self.sign_index_bump(idx, -1);
1181 self.positions[idx] = new_pos;
1182 self.sign_index_bump(idx, 1);
1183
1184 // Update units cache incrementally (units.number is negative for reductions)
1185 if let Some(stats) = self.units_cache.get_mut(¤cy) {
1186 stats.total = crate::decimal::add_python_scale(stats.total, units.number);
1187 }
1188
1189 // Remove if empty, then repair `simple_index`.
1190 if self.positions[idx].is_empty() {
1191 self.sign_index_bump(idx, -1);
1192 self.cost_index_remove(idx);
1193 self.positions.remove(idx);
1194
1195 // Removing shifts every later position down one, so the stored
1196 // indices past `idx` are now off by one. Patch the MAP rather than
1197 // rescanning the positions to rebuild it.
1198 //
1199 // `simple_index` holds at most one entry per currency — cost-less
1200 // lots of a currency merge into a single lot — so this is O(number
1201 // of currencies), against O(lots) for the rescan it replaces. On
1202 // an investment account, where every lot carries a cost and the
1203 // map is EMPTY, the rescan walked the entire lot list to find
1204 // nothing at all; it grew 164x for 10x the input on the
1205 // `investment` profiling shape.
1206 //
1207 // Nothing shifted: removal leaves a tombstone, so every
1208 // surviving lot keeps its slot. Only the entry naming the removed
1209 // lot has to go — and it CAN name it, because an empty cost spec
1210 // matches a cost-less position (`matches_cost_spec`:
1211 // `(None, true) => true`), so STRICT can select and drain one.
1212 //
1213 // Before tombstones this also decremented the later entries to
1214 // follow the shift. Keeping that now would renumber indices that
1215 // did not move, pointing `add`'s merge at a tombstone — which is
1216 // exactly what `removing_a_lot_repairs_the_index_of_a_later_cost_less_lot`
1217 // caught.
1218 self.units_cache
1219 .values_mut()
1220 .filter(|stats| stats.simple_slot == Some(idx))
1221 .for_each(|stats| stats.simple_slot = None);
1222 }
1223 }
1224}
1225
1226#[cfg(test)]
1227mod reduction_tests {
1228 //! Direct unit tests for the read-only `try_reduce_*` booking paths.
1229 //!
1230 //! These pin exact cost-basis, lot selection, and guard behavior so
1231 //! the lot-reduction mutants surfaced by the #1309 audit are killed
1232 //! (the public mutating `reduce_*` path was covered indirectly, but
1233 //! the `try_reduce_*` preview path had no direct assertions).
1234 use super::LotOrder;
1235 use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
1236 use rust_decimal::Decimal;
1237 use rust_decimal_macros::dec;
1238
1239 fn d(n: i64) -> Decimal {
1240 Decimal::from(n)
1241 }
1242
1243 /// A cost-bearing lot of `units` STK at `cost` USD, dated 2024-01-`day`.
1244 fn lot(units: i64, cost: i64, day: u32) -> Position {
1245 Position::with_cost(
1246 Amount::new(d(units), "STK"),
1247 Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
1248 )
1249 }
1250
1251 /// A multi-lot reduction removes what it drained, and nothing else.
1252 ///
1253 /// `commit_updates` used to `retain(|p| !p.is_empty())` over the whole
1254 /// inventory, so any reduction that crossed two lots also swept away every
1255 /// unrelated zero-unit position as a side effect. Zero-unit lots are not
1256 /// scrap: `Inventory::len` counts them and `currency_accounts` branches on
1257 /// `len() == 1`, so sweeping them changed what other surfaces reported
1258 /// depending on whether a reduction happened to be multi-lot.
1259 #[test]
1260 fn a_multi_lot_reduction_leaves_unrelated_empty_lots_alone() {
1261 let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1262 // An unrelated zero-unit position in another commodity. Netted to
1263 // zero rather than added as zero: `add` drops a zero-unit position
1264 // outright, so the only way one exists is a cost-less lot merging
1265 // through zero — which is exactly the case `Inventory::len`'s contract
1266 // is about.
1267 inv.add(Position::simple(Amount::new(d(5), "ZERO")))
1268 .expect("fixture fits in Decimal");
1269 inv.add(Position::simple(Amount::new(d(-5), "ZERO")))
1270 .expect("fixture fits in Decimal");
1271 let before = inv.len();
1272
1273 // Cross both STK lots, draining the first.
1274 inv.reduce(
1275 &Amount::new(d(-15), "STK"),
1276 Some(&CostSpec::default()),
1277 BookingMethod::Fifo,
1278 )
1279 .expect("15 of 20 units are there");
1280
1281 assert_eq!(
1282 inv.len(),
1283 before - 1,
1284 "exactly the drained lot should be gone: the untouched zero-unit \
1285 position is not this reduction's business",
1286 );
1287 }
1288
1289 /// Insufficiency outranks overflow, as it did before the walk was made lazy.
1290 #[test]
1291 fn insufficient_units_are_reported_even_when_the_basis_would_overflow() {
1292 // A lot whose cost basis cannot be represented, and a reduction asking
1293 // for more units than exist.
1294 let huge = Decimal::MAX / d(2);
1295 let mut inv = Inventory::new();
1296 inv.add(Position::with_cost(
1297 Amount::new(d(10), "STK"),
1298 Cost::new(huge, "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1299 ))
1300 .expect("fixture fits in Decimal");
1301
1302 let err = inv
1303 .plan_ordered(
1304 &Amount::new(d(-99), "STK"),
1305 &CostSpec::default(),
1306 LotOrder::Date,
1307 false,
1308 )
1309 .expect_err("99 units are not there");
1310 assert!(
1311 matches!(err, crate::BookingError::InsufficientUnits { .. }),
1312 "the shortfall is the actionable error, not the arithmetic it would \
1313 have done on the way: {err:?}",
1314 );
1315 }
1316
1317 /// HIFO takes costed lots before cost-less ones.
1318 ///
1319 /// A cost-less lot has no cost to compare, and an empty cost spec matches
1320 /// it, so it is a candidate. The scan this replaced counted it as zero
1321 /// before reversing, which put it last; ordering on `Option<Decimal>`
1322 /// would put it FIRST, because `None` sorts before `Some`. Same lots,
1323 /// opposite lot chosen.
1324 #[test]
1325 fn hifo_takes_costed_lots_before_costless_ones() {
1326 let mut inv = Inventory::new();
1327 inv.add(Position::simple(Amount::new(d(10), "STK")))
1328 .expect("fixture fits in Decimal");
1329 inv.add(lot(10, 100, 1)).expect("fixture fits in Decimal");
1330
1331 let result = inv
1332 .reduce(
1333 &Amount::new(d(-5), "STK"),
1334 Some(&CostSpec::default()),
1335 BookingMethod::Hifo,
1336 )
1337 .expect("5 of 20 units are there");
1338
1339 assert_eq!(
1340 result.matched[0].cost.as_ref().map(|c| c.number),
1341 Some(d(100)),
1342 "the 100 USD lot outranks the cost-less one",
1343 );
1344 assert_eq!(
1345 result.cost_basis.map(|b| b.number),
1346 Some(d(500)),
1347 "and the basis comes from it",
1348 );
1349 }
1350
1351 /// The ordered index selects exactly what the scan selects (#2083).
1352 ///
1353 /// `plan_ordered` used to sort every matching lot on every call; it now
1354 /// walks a maintained (date, slot) index and stops once the reduction is
1355 /// covered. That is only sound if the index reproduces the scan's order
1356 /// exactly — including the stable-sort tiebreak, `None` dates sorting
1357 /// first, and lots added out of date order — so this runs both paths over
1358 /// the same inventory and compares.
1359 ///
1360 /// Clearing `ordered_index` is what forces the scan: an empty index is
1361 /// how a shared snapshot looks, and the fallback exists for exactly that.
1362 ///
1363 /// What this does NOT check is whether the ORDER is the right one. Both
1364 /// sides call `order_key`, so reversing it moves them together and this
1365 /// test stays green — verified by mutating it. The orderings themselves
1366 /// are pinned by the method tests (`test_hifo_reduces_highest_cost_first`,
1367 /// `test_fifo_respects_dates` and their neighbors), which is the division
1368 /// of labor: those say what order a method takes lots in, this says the
1369 /// index reproduces whatever that order is.
1370 #[test]
1371 fn the_ordered_index_selects_what_the_scan_selects() {
1372 // Deliberately awkward: out-of-order dates, a duplicate date, a
1373 // date-less lot, a second currency, and a cost-less lot.
1374 let mut inv = Inventory::new();
1375 for lot in [
1376 lot(10, 100, 5),
1377 lot(10, 101, 2),
1378 lot(10, 102, 9),
1379 lot(10, 103, 2),
1380 Position::with_cost(Amount::new(d(10), "STK"), Cost::new(d(104), "USD")),
1381 Position::with_cost(Amount::new(d(10), "OTH"), Cost::new(d(105), "USD")),
1382 Position::simple(Amount::new(d(10), "STK")),
1383 ] {
1384 inv.add(lot).expect("fixture fits in Decimal");
1385 }
1386
1387 let specs = [
1388 CostSpec::default(),
1389 CostSpec {
1390 number: Some(crate::CostNumber::PerUnit { value: d(101) }),
1391 currency: Some("USD".into()),
1392 ..CostSpec::default()
1393 },
1394 CostSpec {
1395 date: Some(naive_date(2024, 1, 2).unwrap()),
1396 ..CostSpec::default()
1397 },
1398 ];
1399
1400 for (order, reverse) in [
1401 (LotOrder::Date, false),
1402 (LotOrder::Date, true),
1403 // HIFO: the cost ordering added in #2091. Its tiebreak has to match
1404 // the `sort_by_key(Reverse(cost))` it replaced — stable, so equal
1405 // costs stayed in ascending slot order.
1406 (LotOrder::CostDescending, false),
1407 ] {
1408 for spec in &specs {
1409 {
1410 for take in [1i64, 15, 45] {
1411 let units = Amount::new(d(-take), "STK");
1412
1413 // Build it explicitly: `reduce` is what normally triggers
1414 // the build, and calling `plan_ordered` directly would
1415 // otherwise leave the index empty and compare the scan
1416 // against itself. That vacuous version of this test passed
1417 // against a deliberately reversed tiebreak.
1418 let mut indexing = inv.clone();
1419 indexing.build_ordered_index(order);
1420 assert!(
1421 indexing.ordered_index.is_some(),
1422 "the fixture must produce an index, or this test compares \
1423 the scan against itself",
1424 );
1425 let indexed = indexing.plan_ordered(&units, spec, order, reverse);
1426
1427 let mut scanning = inv.clone();
1428 scanning.ordered_index = None;
1429 let scanned = scanning.plan_ordered(&units, spec, order, reverse);
1430
1431 match (indexed, scanned) {
1432 (Ok((a_result, a_updates)), Ok((b_result, b_updates))) => {
1433 assert_eq!(
1434 a_updates, b_updates,
1435 "index and scan chose different lots for {spec:?} \
1436 reverse={reverse} take={take}",
1437 );
1438 assert_eq!(
1439 a_result.cost_basis, b_result.cost_basis,
1440 "index and scan disagree on cost basis for {spec:?} \
1441 reverse={reverse} take={take}",
1442 );
1443 }
1444 (Err(a), Err(b)) => assert_eq!(
1445 a.to_string(),
1446 b.to_string(),
1447 "index and scan report different errors for {spec:?} \
1448 reverse={reverse} take={take}",
1449 ),
1450 (a, b) => panic!(
1451 "index and scan disagree on success for {spec:?} \
1452 order={order:?} reverse={reverse} take={take}: {a:?} vs {b:?}"
1453 ),
1454 }
1455 }
1456 }
1457 }
1458 }
1459 }
1460
1461 fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
1462 let mut i = Inventory::new();
1463 for l in lots {
1464 i.add(l).expect("fixture fits in Decimal");
1465 }
1466 i
1467 }
1468
1469 fn sell_stk(n: i64) -> Amount {
1470 Amount::new(d(-n), "STK")
1471 }
1472
1473 /// A cost-basis overflow part-way through a multi-lot reduction must leave
1474 /// the inventory untouched.
1475 ///
1476 /// `reduce_ordered` states the rule itself — "a failed reduction must
1477 /// leave the inventory untouched" — and enforced it for the sufficiency
1478 /// check, which runs up front. The overflow check did NOT get the same
1479 /// treatment: it lived inside the mutation loop, so a reduction that
1480 /// overflowed on the third lot returned `Err` with the first two already
1481 /// drained. The validator reduces against live `LedgerState` inventories,
1482 /// so that partial drain corrupts every later balance assertion on the
1483 /// account.
1484 ///
1485 /// Computing the whole plan before committing any of it makes the rule
1486 /// hold for both checks by construction.
1487 #[test]
1488 fn an_overflowing_multi_lot_reduction_leaves_the_inventory_untouched() {
1489 // Two lots whose combined cost basis cannot be represented: each is
1490 // two thirds of the range, so the first accumulates fine and the sum
1491 // overflows on the second.
1492 let huge = Decimal::MAX / Decimal::from(3) * Decimal::from(2);
1493 let mut inv = Inventory::new();
1494 for day in 1..=2 {
1495 let mut cost = Cost::new(huge, "USD");
1496 cost.date = naive_date(2024, 1, day);
1497 inv.add(Position::with_cost(Amount::new(Decimal::ONE, "AAPL"), cost))
1498 .expect("lots fit individually");
1499 }
1500 let before: Vec<Position> = inv.positions().cloned().collect();
1501 assert_eq!(before.len(), 2, "fixture must hold two distinct lots");
1502
1503 let err = inv
1504 .reduce(
1505 &Amount::new(Decimal::from(-2), "AAPL"),
1506 Some(&CostSpec::default()),
1507 BookingMethod::Fifo,
1508 )
1509 .expect_err("the combined cost basis overflows");
1510 assert!(
1511 matches!(err, super::BookingError::Overflow(_)),
1512 "expected an overflow, got {err:?}",
1513 );
1514
1515 let after: Vec<Position> = inv.positions().cloned().collect();
1516 assert_eq!(
1517 after, before,
1518 "the failed reduction drained lots anyway — a partial mutation on \
1519 the error path is what this pins",
1520 );
1521 }
1522
1523 fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
1524 inv.try_reduce(units, Some(&CostSpec::default()), method)
1525 .expect("reduction should succeed")
1526 }
1527
1528 fn basis(r: &super::BookingResult) -> Decimal {
1529 r.cost_basis.as_ref().expect("cost basis present").number
1530 }
1531
1532 // ---- FIFO / LIFO ordered ------------------------------------------
1533
1534 #[test]
1535 fn fifo_partial_multilot_cost_basis_and_order() {
1536 // 10 @ $100 (older), 10 @ $200 (newer); sell 15.
1537 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1538 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
1539 // FIFO: 10@100 + 5@200 = 1000 + 1000 = 2000.
1540 assert_eq!(basis(&r), dec!(2000));
1541 assert_eq!(r.matched.len(), 2);
1542 assert_eq!(r.matched[0].units.number.abs(), dec!(10));
1543 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
1544 assert_eq!(r.matched[1].units.number.abs(), dec!(5));
1545 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1546 }
1547
1548 #[test]
1549 fn lifo_takes_newest_lot_first() {
1550 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1551 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1552 // LIFO: 10@200 + 5@100 = 2000 + 500 = 2500 (distinguishes the
1553 // `reverse` flag from FIFO's 2000).
1554 assert_eq!(basis(&r), dec!(2500));
1555 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
1556 }
1557
1558 #[test]
1559 fn fifo_single_lot_partial_cost_basis() {
1560 let inv = mk([lot(10, 100, 1)]);
1561 let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
1562 assert_eq!(basis(&r), dec!(300)); // 3 * 100
1563 }
1564
1565 // ---- HIFO ---------------------------------------------------------
1566
1567 #[test]
1568 fn hifo_takes_highest_cost_lot_first() {
1569 // costs 100, 300, 200 → HIFO order 300, 200, 100.
1570 let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
1571 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
1572 // 10@300 + 5@200 = 3000 + 1000 = 4000.
1573 assert_eq!(basis(&r), dec!(4000));
1574 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
1575 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1576 }
1577
1578 // ---- AVERAGE ------------------------------------------------------
1579
1580 #[test]
1581 fn average_cost_basis_partial() {
1582 // 10 @ $100, 30 @ $200 → 40 units, $7000 total, avg $175.
1583 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1584 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
1585 assert_eq!(basis(&r), dec!(3500)); // 20 * 175
1586 }
1587
1588 #[test]
1589 fn average_reduce_exact_total_succeeds() {
1590 // Reducing exactly the held quantity must succeed (kills
1591 // `reduction > total` → `>=`/`==`).
1592 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1593 let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
1594 assert_eq!(basis(&r), dec!(7000)); // 40 * 175
1595 }
1596
1597 #[test]
1598 fn average_over_reduction_errors() {
1599 // Reducing more than held must error (kills `>` → `<`).
1600 let inv = mk([lot(10, 100, 1)]);
1601 let err = inv
1602 .try_reduce(
1603 &sell_stk(20),
1604 Some(&CostSpec::default()),
1605 BookingMethod::Average,
1606 )
1607 .unwrap_err();
1608 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1609 }
1610
1611 // ---- Filter isolation (currency / sign) ---------------------------
1612 // One fixture per method: an unrelated OTH lot plus the real STK lot.
1613 // A correct reducer touches ONLY the real STK lot; the currency `==`
1614 // and the `&&` connecting it would pull OTH in (or drop the real
1615 // one), changing the basis. (A zero-units "empty" lot is intentionally
1616 // NOT added here: `Inventory::add` drops empty positions on insert, so
1617 // the `!is_empty()` filter clause is unreachable for add-built
1618 // inventories and can't be exercised this way.)
1619
1620 fn isolation_inv() -> Inventory {
1621 let mut i = Inventory::new();
1622 i.add(Position::with_cost(
1623 Amount::new(dec!(10), "OTH"), // different currency: must be ignored
1624 Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1625 ))
1626 .expect("fixture fits in Decimal");
1627 i.add(lot(10, 100, 2)).expect("fixture fits in Decimal"); // the real STK lot
1628 i
1629 }
1630
1631 fn assert_isolated(method: BookingMethod) {
1632 let inv = isolation_inv();
1633 let r = try_reduce(&inv, &sell_stk(5), method);
1634 assert_eq!(
1635 basis(&r),
1636 dec!(500),
1637 "must reduce only the real STK lot (5 * 100)"
1638 );
1639 assert!(
1640 r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
1641 "no non-STK lot should be matched"
1642 );
1643 }
1644
1645 #[test]
1646 fn fifo_filters_currency() {
1647 assert_isolated(BookingMethod::Fifo);
1648 }
1649
1650 #[test]
1651 fn hifo_filters_currency() {
1652 assert_isolated(BookingMethod::Hifo);
1653 }
1654
1655 #[test]
1656 fn strict_filters_currency() {
1657 assert_isolated(BookingMethod::Strict);
1658 }
1659
1660 #[test]
1661 fn average_filters_currency() {
1662 // average filters by currency + non-empty (no cost-spec / sign filter).
1663 let inv = isolation_inv();
1664 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
1665 // Only the STK lot participates: 10 units @ $100 → avg $100 → 5 * 100.
1666 assert_eq!(basis(&r), dec!(500));
1667 }
1668
1669 // ---- Sign guard ---------------------------------------------------
1670
1671 #[test]
1672 fn does_not_match_same_sign_lot() {
1673 // A short (negative) STK lot must NOT satisfy a sell (negative
1674 // units): same sign. Only the long lot is reducible. Kills the
1675 // `signum() != signum()` → `==` mutant (== would match the short
1676 // lot or nothing).
1677 let mut i = Inventory::new();
1678 i.add(lot(-10, 50, 1)).expect("fixture fits in Decimal"); // short lot, same sign as a sell
1679 i.add(lot(10, 100, 2)).expect("fixture fits in Decimal"); // long lot
1680 let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
1681 assert_eq!(basis(&r), dec!(500)); // 5 * 100 from the long lot only
1682 assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
1683 }
1684
1685 #[test]
1686 fn strict_rejects_when_only_same_sign_lot_present() {
1687 // STRICT against an inventory holding ONLY a same-sign (short)
1688 // lot must return NoMatchingLot — the single reducible lot fails
1689 // `can_reduce`, leaving zero matches. This pins all three `&&`
1690 // connectors in `try_reduce_strict`'s filter: each `&& -> ||`
1691 // mutant wrongly admits the short lot (currency==STK or the
1692 // always-true `matches_cost_spec` on the default spec satisfies
1693 // the disjunction), turning 0 matches into 1 and succeeding via
1694 // `try_reduce_from_lot` instead of erroring.
1695 let mut i = Inventory::new();
1696 i.add(lot(-10, 100, 1)).expect("fixture fits in Decimal"); // short STK only; a sell is the same sign
1697 let res = i.try_reduce(
1698 &sell_stk(5),
1699 Some(&CostSpec::default()),
1700 BookingMethod::Strict,
1701 );
1702 assert!(
1703 matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
1704 "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1705 );
1706 }
1707
1708 // ---- Insufficient-units accounting --------------------------------
1709
1710 #[test]
1711 fn fifo_insufficient_reports_available() {
1712 // `available = requested - remaining`; kills the `-` → `+`/`/`
1713 // mutant in the insufficient branch.
1714 let inv = mk([lot(10, 100, 1)]);
1715 let err = inv
1716 .try_reduce(
1717 &sell_stk(15),
1718 Some(&CostSpec::default()),
1719 BookingMethod::Fifo,
1720 )
1721 .unwrap_err();
1722 match err {
1723 super::BookingError::InsufficientUnits {
1724 requested,
1725 available,
1726 ..
1727 } => {
1728 assert_eq!(requested, dec!(15));
1729 assert_eq!(available, dec!(10)); // 15 requested - 5 remaining
1730 }
1731 other => panic!("expected InsufficientUnits, got {other:?}"),
1732 }
1733 }
1734
1735 // ---- STRICT single-lot path (try_reduce_from_lot) -----------------
1736
1737 #[test]
1738 fn strict_single_lot_partial_cost_basis() {
1739 // Exactly one matching lot → try_reduce_from_lot; partial take.
1740 let inv = mk([lot(10, 100, 1)]);
1741 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1742 assert_eq!(basis(&r), dec!(400)); // 4 * 100
1743 }
1744
1745 #[test]
1746 fn strict_single_lot_over_reduction_errors() {
1747 // from_lot `requested > available` guard.
1748 let inv = mk([lot(10, 100, 1)]);
1749 let err = inv
1750 .try_reduce(
1751 &sell_stk(11),
1752 Some(&CostSpec::default()),
1753 BookingMethod::Strict,
1754 )
1755 .unwrap_err();
1756 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1757 }
1758
1759 #[test]
1760 fn strict_single_lot_exact_full_reduction_succeeds() {
1761 // requested == available must succeed (kills from_lot `>` → `>=`).
1762 let inv = mk([lot(10, 100, 1)]);
1763 let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1764 assert_eq!(basis(&r), dec!(1000));
1765 }
1766
1767 // ---- HIFO matched units + insufficient accounting ----------------
1768
1769 #[test]
1770 fn hifo_matched_units_and_insufficient_available() {
1771 let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1772 let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1773 // 8 taken from the $300 lot (kills the split `take * signum -> +`).
1774 assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1775 let err = inv
1776 .try_reduce(
1777 &sell_stk(25),
1778 Some(&CostSpec::default()),
1779 BookingMethod::Hifo,
1780 )
1781 .unwrap_err();
1782 match err {
1783 super::BookingError::InsufficientUnits { available, .. } => {
1784 assert_eq!(available, dec!(20)); // 20 held; kills `abs - remaining` mutants
1785 }
1786 other => panic!("expected InsufficientUnits, got {other:?}"),
1787 }
1788 }
1789
1790 #[test]
1791 fn strict_from_lot_matched_units() {
1792 let inv = mk([lot(10, 100, 1)]);
1793 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1794 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills from_lot split `* -> +`
1795 }
1796
1797 // ---- StrictWithSize ----------------------------------------------
1798
1799 #[test]
1800 fn strict_with_size_picks_exact_size_lot() {
1801 let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1802 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1803 assert_eq!(basis(&r), dec!(1000)); // 5 @ $200, the exact-size lot
1804 }
1805
1806 #[test]
1807 fn strict_with_size_takes_the_oldest_of_several_exact_size_lots() {
1808 // #2097. Two lots of the reduction's size, so size alone does not
1809 // disambiguate. Beancount sorts the size matches by `cost.date` and
1810 // takes the first; the choice decides both the basis realized and the
1811 // holding period of what survives.
1812 //
1813 // The lots are built in the OPPOSITE order to their dates, which is
1814 // what the old `find`-first-in-slot-order got wrong. Slot order is
1815 // insertion order and usually matches date order by accident — but a
1816 // lot carrying an explicit cost date is inserted when its transaction
1817 // books and dated whenever the user wrote. Verified against beancount
1818 // 3.2.3, which leaves the 100-cost lot standing.
1819 let inv = mk([lot(10, 100, 20), lot(10, 200, 5)]);
1820 let r = try_reduce(&inv, &sell_stk(10), BookingMethod::StrictWithSize);
1821 assert_eq!(
1822 basis(&r),
1823 dec!(2000),
1824 "must realize the OLDEST size match (day 5, cost 200), not the \
1825 first one stored (day 20, cost 100)"
1826 );
1827 }
1828
1829 #[test]
1830 fn strict_with_size_ambiguous_without_exact_or_total() {
1831 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1832 let err = inv
1833 .try_reduce(
1834 &sell_stk(5),
1835 Some(&CostSpec::default()),
1836 BookingMethod::StrictWithSize,
1837 )
1838 .unwrap_err();
1839 assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
1840 }
1841
1842 #[test]
1843 fn strict_with_size_total_match_falls_back_to_fifo() {
1844 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1845 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
1846 assert_eq!(basis(&r), dec!(3000)); // total match → FIFO: 1000 + 2000
1847
1848 // The basis alone cannot tell FIFO from LIFO here: a total match
1849 // consumes every matching lot, so any order sums to 3000. The name of
1850 // this test is about the ORDER, so assert it — flipping the fallback
1851 // to LIFO used to leave this green.
1852 assert_eq!(
1853 r.matched
1854 .iter()
1855 .map(|p| p.cost.as_ref().map(|c| c.number))
1856 .collect::<Vec<_>>(),
1857 vec![Some(dec!(100)), Some(dec!(200))],
1858 "oldest lot first",
1859 );
1860 }
1861
1862 // ---- Mutating reduce() path (reduce_*) ----------------------------
1863
1864 #[test]
1865 fn reduce_fifo_commits_and_basis() {
1866 let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1867 let r = inv
1868 .reduce(
1869 &sell_stk(15),
1870 Some(&CostSpec::default()),
1871 BookingMethod::Fifo,
1872 )
1873 .unwrap();
1874 assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
1875 assert_eq!(inv.units("STK"), dec!(5)); // 20 - 15
1876 }
1877
1878 #[test]
1879 fn reduce_on_large_shared_inventory_does_not_corrupt() {
1880 // Regression: the rich-workload profiler found a heap-corruption /
1881 // SIGSEGV when reducing an inventory that had been cloned (imbl O(1)
1882 // structural share, as the booking engine does for working copies).
1883 // In-place mutation of the SHARED imbl `Vector` double-freed the interned
1884 // `Arc<str>` inside `Position`. Needs >64 distinct lots so the `Vector`
1885 // spans multiple Arc-backed chunks — the representation that actually
1886 // shares (and corrupted). Without the fix this aborts/segfaults on drop.
1887 // 100 distinct-cost lots (>64 = the imbl chunk size) so the `Vector`
1888 // spans multiple Arc-backed chunks — the shared representation that
1889 // corrupted. Day stays a valid 1..=28 (lots remain distinct by cost).
1890 // The Miri CI job (`rustledger-core`, strict provenance) executes this
1891 // and flags the use-after-free deterministically when the guard is gone.
1892 let mut inv = mk((0i64..100).map(|i| lot(10, 100 + i, ((i % 28) + 1) as u32)));
1893 let snapshot = inv.clone(); // structurally shares chunks with `inv`
1894 inv.reduce(
1895 &sell_stk(700),
1896 Some(&CostSpec::default()),
1897 BookingMethod::Fifo,
1898 )
1899 .unwrap();
1900 assert_eq!(inv.units("STK"), dec!(300)); // 1000 - 700
1901 // The shared snapshot stays independent and intact; `units` re-reads
1902 // every interned currency, and dropping both must not double-free.
1903 assert_eq!(snapshot.units("STK"), dec!(1000));
1904 }
1905
1906 #[test]
1907 fn reduce_hifo_commits_basis_units_insufficient() {
1908 let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1909 let r = inv
1910 .reduce(
1911 &sell_stk(15),
1912 Some(&CostSpec::default()),
1913 BookingMethod::Hifo,
1914 )
1915 .unwrap();
1916 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 10@300 + 5@100
1917 assert_eq!(r.matched[0].units.number.abs(), dec!(10)); // kills reduce_hifo split `* -> +`
1918 let mut inv2 = mk([lot(10, 100, 1)]);
1919 let err = inv2
1920 .reduce(
1921 &sell_stk(25),
1922 Some(&CostSpec::default()),
1923 BookingMethod::Hifo,
1924 )
1925 .unwrap_err();
1926 match err {
1927 super::BookingError::InsufficientUnits { available, .. } => {
1928 assert_eq!(available, dec!(10));
1929 }
1930 other => panic!("expected InsufficientUnits, got {other:?}"),
1931 }
1932 }
1933
1934 #[test]
1935 fn reduce_average_only_matching_currency() {
1936 let mut i = Inventory::new();
1937 i.add(lot(10, 100, 2)).expect("fixture fits in Decimal");
1938 i.add(Position::with_cost(
1939 Amount::new(dec!(10), "OTH"),
1940 Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1941 ))
1942 .expect("fixture fits in Decimal");
1943 let r = i
1944 .reduce(
1945 &sell_stk(5),
1946 Some(&CostSpec::default()),
1947 BookingMethod::Average,
1948 )
1949 .unwrap();
1950 assert_eq!(r.cost_basis.unwrap().number, dec!(500)); // only the STK lot
1951 }
1952
1953 #[test]
1954 fn reduce_average_partial_multi_lot_matches_single_synthetic_lot() {
1955 // Regression: a partial AVERAGE sale across multiple lots matches a
1956 // SINGLE synthetic lot of the reduced quantity at the average cost, not
1957 // every underlying lot. Returning the full lot set made the consumer
1958 // (book.rs) expand the reduction into one posting per lot, emptying the
1959 // position and booking a garbage gain.
1960 let mut i = Inventory::new();
1961 i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
1962 i.add(lot(10, 170, 2)).expect("fixture fits in Decimal");
1963 let r = i
1964 .reduce(
1965 &sell_stk(5),
1966 Some(&CostSpec::default()),
1967 BookingMethod::Average,
1968 )
1969 .unwrap();
1970
1971 // One synthetic matched lot at the average cost {160}; basis 5*160=800.
1972 // Long pool: the matched lot carries the inventory (positive) sign.
1973 assert_eq!(r.matched.len(), 1);
1974 assert_eq!(r.cost_basis.as_ref().unwrap().number, dec!(800));
1975 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(160));
1976 assert_eq!(r.matched[0].units.number, dec!(5));
1977
1978 // 15 STK remain as a single lot carrying the average cost {160}.
1979 assert_eq!(i.units("STK"), dec!(15));
1980 let remaining: Vec<&Position> = i
1981 .positions()
1982 .filter(|p| p.units.currency == "STK")
1983 .collect();
1984 assert_eq!(remaining.len(), 1);
1985 assert_eq!(remaining[0].cost.as_ref().unwrap().number, dec!(160));
1986 }
1987
1988 #[test]
1989 fn reduce_average_short_cover_matched_lot_carries_inventory_sign() {
1990 // Covering a short (positive units reducing a negative pool) must return
1991 // a matched lot with the inventory (negative) sign, like FIFO/ordered.
1992 let mut i = Inventory::new();
1993 i.add(Position::with_cost(
1994 Amount::new(dec!(-10), "STK"),
1995 Cost::new(dec!(150), "USD"),
1996 ))
1997 .expect("fixture fits in Decimal");
1998 let r = i
1999 .reduce(
2000 &Amount::new(dec!(5), "STK"),
2001 Some(&CostSpec::default()),
2002 BookingMethod::Average,
2003 )
2004 .unwrap();
2005 assert_eq!(r.matched.len(), 1);
2006 assert_eq!(r.matched[0].units.number, dec!(-5));
2007 // Short pool shrinks from -10 to -5.
2008 assert_eq!(i.units("STK"), dec!(-5));
2009 }
2010
2011 #[test]
2012 fn merge_average_collapses_lots_to_single_weighted_lot() {
2013 // The realized balance of an AVERAGE account is one pool at the
2014 // weighted-average cost: (10*150 + 10*170 - 5*160) / 15 = 160.
2015 let mut i = Inventory::new();
2016 i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2017 i.add(lot(10, 170, 2)).expect("fixture fits in Decimal");
2018 i.add(Position::with_cost(
2019 Amount::new(dec!(-5), "STK"),
2020 Cost::new(dec!(160), "USD"),
2021 ))
2022 .expect("fixture fits in Decimal");
2023 i.merge_average().expect("fixture fits in Decimal");
2024 let stk: Vec<&Position> = i
2025 .positions()
2026 .filter(|p| p.units.currency == "STK")
2027 .collect();
2028 assert_eq!(stk.len(), 1);
2029 assert_eq!(stk[0].units.number, dec!(15));
2030 assert_eq!(stk[0].cost.as_ref().unwrap().number, dec!(160));
2031 }
2032
2033 #[test]
2034 fn merge_average_net_zero_removes_lots() {
2035 let mut i = Inventory::new();
2036 i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2037 i.add(Position::with_cost(
2038 Amount::new(dec!(-10), "STK"),
2039 Cost::new(dec!(160), "USD"),
2040 ))
2041 .expect("fixture fits in Decimal");
2042 i.merge_average().expect("fixture fits in Decimal");
2043 assert_eq!(
2044 i.positions().filter(|p| p.units.currency == "STK").count(),
2045 0
2046 );
2047 }
2048
2049 #[test]
2050 fn merge_average_leaves_costless_positions_untouched() {
2051 let mut i = Inventory::new();
2052 i.add(Position::simple(Amount::new(dec!(100), "USD")))
2053 .expect("fixture fits in Decimal");
2054 i.add(lot(10, 150, 1)).expect("fixture fits in Decimal");
2055 i.merge_average().expect("fixture fits in Decimal");
2056 // Cash stays; the single STK lot stays a single lot.
2057 assert_eq!(i.units("USD"), dec!(100));
2058 assert_eq!(
2059 i.positions().filter(|p| p.units.currency == "STK").count(),
2060 1
2061 );
2062 }
2063
2064 #[test]
2065 fn reduce_from_lot_matched_and_remaining_units() {
2066 let mut inv = mk([lot(10, 100, 1)]);
2067 let r = inv
2068 .reduce(
2069 &sell_stk(4),
2070 Some(&CostSpec::default()),
2071 BookingMethod::Strict,
2072 )
2073 .unwrap();
2074 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); // kills reduce_from_lot split `* -> +`
2075 // Assert the stored POSITION units directly, not `units()` — the
2076 // latter reads a separate incremental cache, so it would not catch
2077 // a bug in `new_units = pos.units.number + units.number`.
2078 let remaining: Vec<_> = inv.position_list();
2079 assert_eq!(remaining.len(), 1);
2080 assert_eq!(remaining[0].units.number, dec!(6)); // 10 + (-4); kills `+ -> -`/`*`
2081 assert_eq!(inv.units("STK"), dec!(6)); // cache stays consistent
2082 }
2083
2084 #[test]
2085 fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
2086 // Merge two long STK lots; a short STK lot (same sign as the
2087 // sell) and an unrelated OTH lot must be excluded from the merge
2088 // AND survive in the inventory.
2089 let mut inv = Inventory::new();
2090 inv.add(lot(10, 100, 1)).expect("fixture fits in Decimal"); // long STK
2091 inv.add(lot(30, 200, 2)).expect("fixture fits in Decimal"); // long STK
2092 inv.add(lot(-5, 999, 3)).expect("fixture fits in Decimal"); // short STK — excluded by the sign filter
2093 inv.add(Position::with_cost(
2094 Amount::new(dec!(10), "OTH"), // different currency — excluded
2095 Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
2096 ))
2097 .expect("fixture fits in Decimal");
2098 let spec = CostSpec {
2099 merge: true,
2100 ..CostSpec::default()
2101 };
2102 let r = inv
2103 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
2104 .unwrap();
2105 // Only the two long STK lots merge: 40 units @ avg $175 → 20 * 175.
2106 // Including the short (sign) or OTH (currency) lot would change this.
2107 assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
2108 // The excluded lots must still be present (kills the retain-index mutant).
2109 assert!(
2110 inv.position_list()
2111 .iter()
2112 .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
2113 "OTH lot must survive the merge"
2114 );
2115 assert!(
2116 inv.position_list()
2117 .iter()
2118 .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
2119 "short STK lot must survive the merge"
2120 );
2121 }
2122
2123 #[test]
2124 fn reduce_none_exact_succeeds_over_reduction_shorts() {
2125 let mut inv = Inventory::new();
2126 inv.add(Position::simple(Amount::new(dec!(10), "STK")))
2127 .expect("fixture fits in Decimal");
2128 assert!(
2129 inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
2130 "exact NONE reduction should succeed"
2131 );
2132 // NONE performs no booking, so over-reduction shorts past zero
2133 // instead of erroring (#1686 — previously InsufficientUnits, which
2134 // made the outcome depend on whether zero was crossed in one step).
2135 let mut inv2 = Inventory::new();
2136 inv2.add(Position::simple(Amount::new(dec!(10), "STK")))
2137 .expect("fixture fits in Decimal");
2138 assert!(
2139 inv2.reduce(&sell_stk(15), None, BookingMethod::None)
2140 .is_ok(),
2141 "NONE over-reduction must short, not error (#1686)"
2142 );
2143 assert_eq!(inv2.units("STK"), dec!(-5));
2144 }
2145
2146 #[test]
2147 fn reduce_merge_uses_weighted_average() {
2148 let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
2149 let spec = CostSpec {
2150 merge: true,
2151 ..CostSpec::default()
2152 };
2153 let r = inv
2154 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
2155 .unwrap();
2156 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); // 20 @ avg $175
2157 assert_eq!(inv.units("STK"), dec!(20)); // 40 - 20
2158 }
2159}