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, FIFO,
4//! LIFO, HIFO, AVERAGE, NONE) used to reduce positions from an inventory.
5
6use rust_decimal::Decimal;
7use rust_decimal::prelude::Signed;
8
9use smallvec::{SmallVec, smallvec};
10
11use super::{BookingError, BookingMethod, BookingResult, Inventory, MatchedLots};
12use crate::{Amount, Cost, CostSpec, InternedStr, Position};
13
14/// Compute weighted-average cost from a set of positions.
15///
16/// Returns `(avg_cost_per_unit, cost_currency)` or `None` if no positions have cost info.
17/// Returns `Err(CurrencyMismatch)` if positions have costs in different currencies.
18fn average_cost_from_positions(
19    positions: &[&Position],
20    total_units: Decimal,
21) -> Result<Option<(Decimal, InternedStr)>, BookingError> {
22    let mut total_cost = Decimal::ZERO;
23    let mut cost_currency: Option<InternedStr> = None;
24    let mut has_any_cost = false;
25
26    for pos in positions {
27        if let Some(cost) = &pos.cost {
28            has_any_cost = true;
29            if let Some(ref cc) = cost_currency {
30                if *cc != cost.currency {
31                    return Err(BookingError::CurrencyMismatch {
32                        expected: cc.clone(),
33                        got: cost.currency.clone(),
34                    });
35                }
36            } else {
37                cost_currency = Some(cost.currency.clone());
38            }
39            total_cost += pos.units.number * cost.number;
40        }
41    }
42
43    if !has_any_cost || cost_currency.is_none() {
44        return Ok(None);
45    }
46
47    Ok(Some((total_cost / total_units, cost_currency.unwrap())))
48}
49
50impl Inventory {
51    /// Try reducing positions without modifying the inventory.
52    ///
53    /// This is a read-only version of `reduce()` that returns what would be matched
54    /// without actually modifying the inventory. Useful for previewing booking results
55    /// before committing.
56    ///
57    /// # Arguments
58    ///
59    /// * `units` - The units to reduce (negative for selling)
60    /// * `cost_spec` - Optional cost specification for matching lots
61    /// * `method` - The booking method to use
62    ///
63    /// # Returns
64    ///
65    /// Returns a `BookingResult` with the positions that would be matched and cost basis,
66    /// or a `BookingError` if the reduction cannot be performed.
67    pub fn try_reduce(
68        &self,
69        units: &Amount,
70        cost_spec: Option<&CostSpec>,
71        method: BookingMethod,
72    ) -> Result<BookingResult, BookingError> {
73        let spec = cost_spec.cloned().unwrap_or_default();
74
75        // {*} merge operator: use average-cost semantics (read-only preview)
76        if spec.merge {
77            return self.try_reduce_average(units);
78        }
79
80        match method {
81            BookingMethod::Strict | BookingMethod::StrictWithSize => {
82                self.try_reduce_strict(units, &spec, method == BookingMethod::StrictWithSize)
83            }
84            BookingMethod::Fifo => self.try_reduce_ordered(units, &spec, false),
85            BookingMethod::Lifo => self.try_reduce_ordered(units, &spec, true),
86            BookingMethod::Hifo => self.try_reduce_hifo(units, &spec),
87            BookingMethod::Average => self.try_reduce_average(units),
88            BookingMethod::None => self.try_reduce_ordered(units, &CostSpec::default(), false),
89        }
90    }
91
92    /// Try `STRICT`/`STRICT_WITH_SIZE` booking without modifying inventory.
93    fn try_reduce_strict(
94        &self,
95        units: &Amount,
96        spec: &CostSpec,
97        with_size: bool,
98    ) -> Result<BookingResult, BookingError> {
99        let matching_indices: Vec<usize> = self
100            .positions
101            .iter()
102            .enumerate()
103            .filter(|(_, p)| {
104                p.units.currency == units.currency
105                    && !p.is_empty()
106                    && p.can_reduce(units)
107                    && p.matches_cost_spec(spec)
108            })
109            .map(|(i, _)| i)
110            .collect();
111
112        match matching_indices.len() {
113            0 => Err(BookingError::NoMatchingLot {
114                currency: units.currency.clone(),
115                cost_spec: spec.clone(),
116            }),
117            1 => {
118                let idx = matching_indices[0];
119                self.try_reduce_from_lot(idx, units)
120            }
121            n => {
122                if with_size {
123                    // Check for exact-size match with any lot
124                    let exact_matches: Vec<usize> = matching_indices
125                        .iter()
126                        .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
127                        .copied()
128                        .collect();
129
130                    if exact_matches.is_empty() {
131                        // Total match exception
132                        let total_units: Decimal = matching_indices
133                            .iter()
134                            .map(|&i| self.positions[i].units.number.abs())
135                            .sum();
136                        if total_units == units.number.abs() {
137                            self.try_reduce_ordered(units, spec, false)
138                        } else {
139                            Err(BookingError::AmbiguousMatch {
140                                num_matches: n,
141                                currency: units.currency.clone(),
142                            })
143                        }
144                    } else {
145                        let idx = exact_matches[0];
146                        self.try_reduce_from_lot(idx, units)
147                    }
148                } else {
149                    // STRICT: fall back to FIFO when multiple match
150                    self.try_reduce_ordered(units, spec, false)
151                }
152            }
153        }
154    }
155
156    /// Try ordered (FIFO/LIFO) booking without modifying inventory.
157    fn try_reduce_ordered(
158        &self,
159        units: &Amount,
160        spec: &CostSpec,
161        reverse: bool,
162    ) -> Result<BookingResult, BookingError> {
163        let mut remaining = units.number.abs();
164        let mut matched: MatchedLots = SmallVec::new();
165        let mut cost_basis = Decimal::ZERO;
166        let mut cost_currency = None;
167
168        // Get indices of matching positions
169        let mut indices: Vec<usize> = self
170            .positions
171            .iter()
172            .enumerate()
173            .filter(|(_, p)| {
174                p.units.currency == units.currency
175                    && !p.is_empty()
176                    && p.units.number.signum() != units.number.signum()
177                    && p.matches_cost_spec(spec)
178            })
179            .map(|(i, _)| i)
180            .collect();
181
182        // Sort by date for correct FIFO/LIFO ordering
183        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
184
185        if reverse {
186            indices.reverse();
187        }
188
189        if indices.is_empty() {
190            return Err(BookingError::NoMatchingLot {
191                currency: units.currency.clone(),
192                cost_spec: spec.clone(),
193            });
194        }
195
196        for idx in indices {
197            if remaining.is_zero() {
198                break;
199            }
200
201            let pos = &self.positions[idx];
202            let available = pos.units.number.abs();
203            let take = remaining.min(available);
204
205            // Calculate cost basis for this portion
206            if let Some(cost) = &pos.cost {
207                cost_basis += take * cost.number;
208                cost_currency = Some(cost.currency.clone());
209            }
210
211            // Record what we would match (using split which is read-only)
212            let (taken, _) = pos.split(take * pos.units.number.signum());
213            matched.push(taken);
214
215            remaining -= take;
216        }
217
218        if !remaining.is_zero() {
219            let available = units.number.abs() - remaining;
220            return Err(BookingError::InsufficientUnits {
221                currency: units.currency.clone(),
222                requested: units.number.abs(),
223                available,
224            });
225        }
226
227        Ok(BookingResult {
228            matched,
229            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
230        })
231    }
232
233    /// Try HIFO booking without modifying inventory.
234    fn try_reduce_hifo(
235        &self,
236        units: &Amount,
237        spec: &CostSpec,
238    ) -> Result<BookingResult, BookingError> {
239        let mut remaining = units.number.abs();
240        let mut matched: MatchedLots = SmallVec::new();
241        let mut cost_basis = Decimal::ZERO;
242        let mut cost_currency = None;
243
244        // Get matching positions with their costs
245        let mut matching: Vec<(usize, Decimal)> = self
246            .positions
247            .iter()
248            .enumerate()
249            .filter(|(_, p)| {
250                p.units.currency == units.currency
251                    && !p.is_empty()
252                    && p.units.number.signum() != units.number.signum()
253                    && p.matches_cost_spec(spec)
254            })
255            .map(|(i, p)| {
256                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
257                (i, cost)
258            })
259            .collect();
260
261        if matching.is_empty() {
262            return Err(BookingError::NoMatchingLot {
263                currency: units.currency.clone(),
264                cost_spec: spec.clone(),
265            });
266        }
267
268        // Sort by cost descending (highest first)
269        matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
270
271        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
272
273        for idx in indices {
274            if remaining.is_zero() {
275                break;
276            }
277
278            let pos = &self.positions[idx];
279            let available = pos.units.number.abs();
280            let take = remaining.min(available);
281
282            // Calculate cost basis for this portion
283            if let Some(cost) = &pos.cost {
284                cost_basis += take * cost.number;
285                cost_currency = Some(cost.currency.clone());
286            }
287
288            // Record what we would match
289            let (taken, _) = pos.split(take * pos.units.number.signum());
290            matched.push(taken);
291
292            remaining -= take;
293        }
294
295        if !remaining.is_zero() {
296            let available = units.number.abs() - remaining;
297            return Err(BookingError::InsufficientUnits {
298                currency: units.currency.clone(),
299                requested: units.number.abs(),
300                available,
301            });
302        }
303
304        Ok(BookingResult {
305            matched,
306            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
307        })
308    }
309
310    /// Try AVERAGE booking without modifying inventory.
311    fn try_reduce_average(&self, units: &Amount) -> Result<BookingResult, BookingError> {
312        let matching: Vec<&Position> = self
313            .positions
314            .iter()
315            .filter(|p| p.units.currency == units.currency && !p.is_empty())
316            .collect();
317
318        let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
319
320        if total_units.is_zero() {
321            return Err(BookingError::InsufficientUnits {
322                currency: units.currency.clone(),
323                requested: units.number.abs(),
324                available: Decimal::ZERO,
325            });
326        }
327
328        let reduction = units.number.abs();
329        if reduction > total_units.abs() {
330            return Err(BookingError::InsufficientUnits {
331                currency: units.currency.clone(),
332                requested: reduction,
333                available: total_units.abs(),
334            });
335        }
336
337        let cost_basis = average_cost_from_positions(&matching, total_units)?
338            .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
339
340        let matched: MatchedLots = matching.into_iter().cloned().collect();
341
342        Ok(BookingResult {
343            matched,
344            cost_basis,
345        })
346    }
347
348    /// Try reducing from a specific lot without modifying inventory.
349    fn try_reduce_from_lot(
350        &self,
351        idx: usize,
352        units: &Amount,
353    ) -> Result<BookingResult, BookingError> {
354        let pos = &self.positions[idx];
355        let available = pos.units.number.abs();
356        let requested = units.number.abs();
357
358        if requested > available {
359            return Err(BookingError::InsufficientUnits {
360                currency: units.currency.clone(),
361                requested,
362                available,
363            });
364        }
365
366        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
367        let (matched, _) = pos.split(requested * pos.units.number.signum());
368
369        Ok(BookingResult {
370            matched: smallvec![matched],
371            cost_basis,
372        })
373    }
374}
375
376impl Inventory {
377    /// STRICT booking: require exactly one matching lot, unless either:
378    ///
379    /// - all matching lots are identical in cost, in which case the choice
380    ///   between them is irrelevant and we fall back to the same ordering as
381    ///   FIFO (oldest `cost.date` first — see [`Self::reduce_ordered`]), or
382    /// - the reduction exactly matches the total units available across the
383    ///   matching lots (full liquidation), in which case all of them may be
384    ///   drained together without ambiguity.
385    ///
386    /// If multiple lots with *different* costs match and the reduction does
387    /// not qualify for the full-liquidation exception — for example a
388    /// wildcard reduction `-5 AAPL {}` against an inventory holding both
389    /// `{150 USD}` and `{160 USD}` — the reduction is genuinely ambiguous and
390    /// we return `AmbiguousMatch`, matching Python beancount's
391    /// `AmbiguousMatchError` and the formal `STRICTCorrect.tla` specification.
392    ///
393    /// # The "interchangeable lots" heuristic
394    ///
395    /// We treat two matched lots as interchangeable when their `(cost.number,
396    /// cost.currency)` agree — the user-visible monetary identity. We
397    /// deliberately ignore `cost.date` and `cost.label`: the user's cost spec
398    /// could not have constrained those fields without naming them, so two
399    /// lots that differ only on date/label could not have been distinguished
400    /// by the spec the user wrote, and the date-ordered fallback is
401    /// unambiguous within that equivalence class.
402    ///
403    /// A stricter spec-derived check would compare each pair of matched lots
404    /// on every cost field the spec did *not* constrain. The simpler
405    /// number+currency check matches Python beancount's behavior for the
406    /// real-world cases we know about (see
407    /// `test_reduce_strict_multiple_match_with_identical_costs_uses_fifo` and
408    /// the `test_validate_multiple_lot_match_uses_fifo` integration test for
409    /// the same-cost-different-date case).
410    pub(super) fn reduce_strict(
411        &mut self,
412        units: &Amount,
413        spec: &CostSpec,
414    ) -> Result<BookingResult, BookingError> {
415        let matching_indices: Vec<usize> = self
416            .positions
417            .iter()
418            .enumerate()
419            .filter(|(_, p)| {
420                p.units.currency == units.currency
421                    && !p.is_empty()
422                    && p.can_reduce(units)
423                    && p.matches_cost_spec(spec)
424            })
425            .map(|(i, _)| i)
426            .collect();
427
428        match matching_indices.len() {
429            0 => Err(BookingError::NoMatchingLot {
430                currency: units.currency.clone(),
431                cost_spec: spec.clone(),
432            }),
433            1 => {
434                let idx = matching_indices[0];
435                self.reduce_from_lot(idx, units)
436            }
437            n => {
438                // Are the matched lots financially interchangeable? Two lots
439                // count as identical if they have the same cost number + cost
440                // currency — the user-visible monetary identity. Date and label
441                // differences don't make a reduction ambiguous because the user
442                // could not have observed a different outcome based on the cost
443                // spec they wrote. Beancount falls back to FIFO in that case.
444                let first_key = self.positions[matching_indices[0]]
445                    .cost
446                    .as_ref()
447                    .map(|c| (c.number, c.currency.clone()));
448                let all_same_value = matching_indices.iter().skip(1).all(|&i| {
449                    let key = self.positions[i]
450                        .cost
451                        .as_ref()
452                        .map(|c| (c.number, c.currency.clone()));
453                    key == first_key
454                });
455
456                if all_same_value {
457                    return self.reduce_ordered(units, spec, false);
458                }
459
460                // Total match exception: if the reduction equals the sum of all
461                // matching lots, the user is selling the entire matched
462                // inventory and the lot choice doesn't matter — accept it.
463                let total_units: Decimal = matching_indices
464                    .iter()
465                    .map(|&i| self.positions[i].units.number.abs())
466                    .sum();
467                if total_units == units.number.abs() {
468                    return self.reduce_ordered(units, spec, false);
469                }
470
471                Err(BookingError::AmbiguousMatch {
472                    num_matches: n,
473                    currency: units.currency.clone(),
474                })
475            }
476        }
477    }
478
479    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
480    pub(super) fn reduce_strict_with_size(
481        &mut self,
482        units: &Amount,
483        spec: &CostSpec,
484    ) -> Result<BookingResult, BookingError> {
485        let matching_indices: Vec<usize> = self
486            .positions
487            .iter()
488            .enumerate()
489            .filter(|(_, p)| {
490                p.units.currency == units.currency
491                    && !p.is_empty()
492                    && p.can_reduce(units)
493                    && p.matches_cost_spec(spec)
494            })
495            .map(|(i, _)| i)
496            .collect();
497
498        match matching_indices.len() {
499            0 => Err(BookingError::NoMatchingLot {
500                currency: units.currency.clone(),
501                cost_spec: spec.clone(),
502            }),
503            1 => {
504                let idx = matching_indices[0];
505                self.reduce_from_lot(idx, units)
506            }
507            n => {
508                // Check for exact-size match with any lot
509                let exact_matches: Vec<usize> = matching_indices
510                    .iter()
511                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
512                    .copied()
513                    .collect();
514
515                if exact_matches.is_empty() {
516                    // Total match exception
517                    let total_units: Decimal = matching_indices
518                        .iter()
519                        .map(|&i| self.positions[i].units.number.abs())
520                        .sum();
521                    if total_units == units.number.abs() {
522                        self.reduce_ordered(units, spec, false)
523                    } else {
524                        Err(BookingError::AmbiguousMatch {
525                            num_matches: n,
526                            currency: units.currency.clone(),
527                        })
528                    }
529                } else {
530                    // Use oldest (first) exact-size match
531                    let idx = exact_matches[0];
532                    self.reduce_from_lot(idx, units)
533                }
534            }
535        }
536    }
537
538    /// FIFO booking: reduce from oldest lots first.
539    pub(super) fn reduce_fifo(
540        &mut self,
541        units: &Amount,
542        spec: &CostSpec,
543    ) -> Result<BookingResult, BookingError> {
544        self.reduce_ordered(units, spec, false)
545    }
546
547    /// LIFO booking: reduce from newest lots first.
548    pub(super) fn reduce_lifo(
549        &mut self,
550        units: &Amount,
551        spec: &CostSpec,
552    ) -> Result<BookingResult, BookingError> {
553        self.reduce_ordered(units, spec, true)
554    }
555
556    /// HIFO booking: reduce from highest-cost lots first.
557    pub(super) fn reduce_hifo(
558        &mut self,
559        units: &Amount,
560        spec: &CostSpec,
561    ) -> Result<BookingResult, BookingError> {
562        let mut remaining = units.number.abs();
563        let mut matched: MatchedLots = SmallVec::new();
564        let mut cost_basis = Decimal::ZERO;
565        let mut cost_currency = None;
566
567        // Get matching positions with their costs
568        let mut matching: Vec<(usize, Decimal)> = self
569            .positions
570            .iter()
571            .enumerate()
572            .filter(|(_, p)| {
573                p.units.currency == units.currency
574                    && !p.is_empty()
575                    && p.units.number.signum() != units.number.signum()
576                    && p.matches_cost_spec(spec)
577            })
578            .map(|(i, p)| {
579                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
580                (i, cost)
581            })
582            .collect();
583
584        if matching.is_empty() {
585            return Err(BookingError::NoMatchingLot {
586                currency: units.currency.clone(),
587                cost_spec: spec.clone(),
588            });
589        }
590
591        // Sort by cost descending (highest first)
592        matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
593
594        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
595
596        for idx in indices {
597            if remaining.is_zero() {
598                break;
599            }
600
601            let pos = &self.positions[idx];
602            let available = pos.units.number.abs();
603            let take = remaining.min(available);
604
605            // Calculate cost basis for this portion
606            if let Some(cost) = &pos.cost {
607                cost_basis += take * cost.number;
608                cost_currency = Some(cost.currency.clone());
609            }
610
611            // Record what we matched
612            let (taken, _) = pos.split(take * pos.units.number.signum());
613            matched.push(taken);
614
615            // Reduce the lot
616            let reduction = if units.number.is_sign_negative() {
617                -take
618            } else {
619                take
620            };
621
622            let new_pos = Position {
623                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
624                cost: pos.cost.clone(),
625            };
626            self.positions[idx] = new_pos;
627
628            remaining -= take;
629        }
630
631        if !remaining.is_zero() {
632            let available = units.number.abs() - remaining;
633            return Err(BookingError::InsufficientUnits {
634                currency: units.currency.clone(),
635                requested: units.number.abs(),
636                available,
637            });
638        }
639
640        // Clean up empty positions
641        self.positions.retain(|p| !p.is_empty());
642        self.rebuild_index();
643
644        Ok(BookingResult {
645            matched,
646            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
647        })
648    }
649
650    /// Reduce in order (FIFO or LIFO).
651    pub(super) fn reduce_ordered(
652        &mut self,
653        units: &Amount,
654        spec: &CostSpec,
655        reverse: bool,
656    ) -> Result<BookingResult, BookingError> {
657        let mut remaining = units.number.abs();
658        let mut matched: MatchedLots = SmallVec::new();
659        let mut cost_basis = Decimal::ZERO;
660        let mut cost_currency = None;
661
662        // Get indices of matching positions
663        let mut indices: Vec<usize> = self
664            .positions
665            .iter()
666            .enumerate()
667            .filter(|(_, p)| {
668                p.units.currency == units.currency
669                    && !p.is_empty()
670                    && p.units.number.signum() != units.number.signum()
671                    && p.matches_cost_spec(spec)
672            })
673            .map(|(i, _)| i)
674            .collect();
675
676        // Sort by date for correct FIFO/LIFO ordering (oldest first)
677        // This ensures we select by acquisition date, not insertion order
678        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
679
680        if reverse {
681            indices.reverse();
682        }
683
684        if indices.is_empty() {
685            return Err(BookingError::NoMatchingLot {
686                currency: units.currency.clone(),
687                cost_spec: spec.clone(),
688            });
689        }
690
691        // Get cost currency from first lot (all lots of same commodity have same cost currency)
692        if let Some(&first_idx) = indices.first()
693            && let Some(cost) = &self.positions[first_idx].cost
694        {
695            cost_currency = Some(cost.currency.clone());
696        }
697
698        for idx in indices {
699            if remaining.is_zero() {
700                break;
701            }
702
703            let pos = &mut self.positions[idx];
704            let available = pos.units.number.abs();
705            let take = remaining.min(available);
706
707            // Calculate cost basis for this portion
708            if let Some(cost) = &pos.cost {
709                cost_basis += take * cost.number;
710            }
711
712            // Record what we matched
713            let (taken, _) = pos.split(take * pos.units.number.signum());
714            matched.push(taken);
715
716            // Reduce the lot - modify in place to avoid cloning
717            let reduction = if units.number.is_sign_negative() {
718                -take
719            } else {
720                take
721            };
722            pos.units.number += reduction;
723
724            remaining -= take;
725        }
726
727        if !remaining.is_zero() {
728            let available = units.number.abs() - remaining;
729            return Err(BookingError::InsufficientUnits {
730                currency: units.currency.clone(),
731                requested: units.number.abs(),
732                available,
733            });
734        }
735
736        // Clean up empty positions
737        self.positions.retain(|p| !p.is_empty());
738        self.rebuild_index();
739
740        Ok(BookingResult {
741            matched,
742            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
743        })
744    }
745
746    /// AVERAGE booking: merge all lots of the currency.
747    pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
748        let matching: Vec<&Position> = self
749            .positions
750            .iter()
751            .filter(|p| p.units.currency == units.currency && !p.is_empty())
752            .collect();
753
754        let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
755
756        if total_units.is_zero() {
757            return Err(BookingError::InsufficientUnits {
758                currency: units.currency.clone(),
759                requested: units.number.abs(),
760                available: Decimal::ZERO,
761            });
762        }
763
764        let reduction = units.number.abs();
765        if reduction > total_units.abs() {
766            return Err(BookingError::InsufficientUnits {
767                currency: units.currency.clone(),
768                requested: reduction,
769                available: total_units.abs(),
770            });
771        }
772
773        let cost_basis = average_cost_from_positions(&matching, total_units)?
774            .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
775
776        let matched: MatchedLots = matching.into_iter().cloned().collect();
777        let new_units = total_units + units.number;
778
779        // Remove all positions of this currency
780        self.positions
781            .retain(|p| p.units.currency != units.currency);
782
783        // Add back the remainder if non-zero
784        if !new_units.is_zero() {
785            self.positions.push_back(Position::simple(Amount::new(
786                new_units,
787                units.currency.clone(),
788            )));
789        }
790
791        self.rebuild_index();
792
793        Ok(BookingResult {
794            matched,
795            cost_basis,
796        })
797    }
798
799    /// Cost merge `{*}`: merge all lots of the currency into a single
800    /// weighted-average-cost lot, then reduce from it.
801    ///
802    /// Example: 10 AAPL {150 USD} + 10 AAPL {160 USD} merged = 20 AAPL {155 USD}.
803    /// Reducing 5 AAPL {*} takes 5 from the merged 20 AAPL {155 USD} lot.
804    pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
805        // Only merge lots with opposite sign (same as other reduce methods).
806        // This prevents accidentally netting long and short positions.
807        let matching: Vec<(usize, &Position)> = self
808            .positions
809            .iter()
810            .enumerate()
811            .filter(|(_, p)| {
812                p.units.currency == units.currency
813                    && !p.is_empty()
814                    && p.units.number.is_sign_positive() != units.number.is_sign_positive()
815            })
816            .collect();
817
818        if matching.is_empty() {
819            return Err(BookingError::InsufficientUnits {
820                currency: units.currency.clone(),
821                requested: units.number.abs(),
822                available: Decimal::ZERO,
823            });
824        }
825
826        let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
827        let reduction = units.number.abs();
828
829        if reduction > total_units.abs() {
830            return Err(BookingError::InsufficientUnits {
831                currency: units.currency.clone(),
832                requested: reduction,
833                available: total_units.abs(),
834            });
835        }
836
837        // Compute weighted-average cost from matching lots.
838        let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
839        let (avg_cost, cost_currency) =
840            match average_cost_from_positions(&matching_refs, total_units)? {
841                Some(result) => result,
842                None => return self.reduce_average(units),
843            };
844
845        let cost_basis = Some(Amount::new(reduction * avg_cost, cost_currency.clone()));
846
847        // Return a single synthetic matched position representing the merged lot.
848        // This prevents the booking engine from expanding the posting into multiple
849        // postings (one per original lot), which would be incorrect for {*}.
850        let make_avg_cost = || Cost {
851            number: avg_cost,
852            currency: cost_currency.clone(),
853            date: None,
854            label: None,
855        };
856
857        let matched: MatchedLots = smallvec![Position::with_cost(
858            Amount::new(units.number.abs(), units.currency.clone()),
859            make_avg_cost(),
860        )];
861
862        // Remove all matching lots of this currency
863        let matching_indices: std::collections::HashSet<usize> =
864            matching.iter().map(|(i, _)| *i).collect();
865        let mut idx = 0;
866        self.positions.retain(|_| {
867            let keep = !matching_indices.contains(&idx);
868            idx += 1;
869            keep
870        });
871
872        // Add back a single merged lot with the remainder
873        let remaining = total_units + units.number; // units.number is negative for reductions
874        if !remaining.is_zero() {
875            self.positions.push_back(Position::with_cost(
876                Amount::new(remaining, units.currency.clone()),
877                make_avg_cost(),
878            ));
879        }
880
881        self.rebuild_index();
882
883        Ok(BookingResult {
884            matched,
885            cost_basis,
886        })
887    }
888
889    /// NONE booking: reduce without matching lots.
890    pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
891        // For NONE booking, we just reduce the total without caring about lots
892        let total_units = self.units(&units.currency);
893
894        // Check we have enough in the right direction
895        if total_units.signum() == units.number.signum() || total_units.is_zero() {
896            // This is an augmentation, not a reduction - just add it
897            self.add(Position::simple(units.clone()));
898            return Ok(BookingResult {
899                matched: SmallVec::new(),
900                cost_basis: None,
901            });
902        }
903
904        let available = total_units.abs();
905        let requested = units.number.abs();
906
907        if requested > available {
908            return Err(BookingError::InsufficientUnits {
909                currency: units.currency.clone(),
910                requested,
911                available,
912            });
913        }
914
915        // Reduce positions proportionally (simplified: just reduce first matching)
916        self.reduce_ordered(units, &CostSpec::default(), false)
917    }
918
919    /// Reduce from a specific lot.
920    pub(super) fn reduce_from_lot(
921        &mut self,
922        idx: usize,
923        units: &Amount,
924    ) -> Result<BookingResult, BookingError> {
925        let pos = &self.positions[idx];
926        let available = pos.units.number.abs();
927        let requested = units.number.abs();
928
929        if requested > available {
930            return Err(BookingError::InsufficientUnits {
931                currency: units.currency.clone(),
932                requested,
933                available,
934            });
935        }
936
937        // Calculate cost basis
938        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
939
940        // Record matched
941        let (matched, _) = pos.split(requested * pos.units.number.signum());
942
943        // Update the position
944        let currency = pos.units.currency.clone();
945        let new_units = pos.units.number + units.number;
946        let new_pos = Position {
947            units: Amount::new(new_units, currency.clone()),
948            cost: pos.cost.clone(),
949        };
950        self.positions[idx] = new_pos;
951
952        // Update units cache incrementally (units.number is negative for reductions)
953        if let Some(cached) = self.units_cache.get_mut(&currency) {
954            *cached += units.number;
955        }
956
957        // Remove if empty and rebuild simple_index
958        if self.positions[idx].is_empty() {
959            self.positions.remove(idx);
960            // Only rebuild simple_index when position is removed
961            self.simple_index.clear();
962            for (i, p) in self.positions.iter().enumerate() {
963                if p.cost.is_none() {
964                    self.simple_index.insert(p.units.currency.clone(), i);
965                }
966            }
967        }
968
969        Ok(BookingResult {
970            matched: smallvec![matched],
971            cost_basis,
972        })
973    }
974}