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