Skip to main content

rustledger_core/inventory/
booking.rs

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