rustledger_core/
inventory.rs

1//! Inventory type representing a collection of positions.
2//!
3//! An [`Inventory`] tracks the holdings of an account as a collection of
4//! [`Position`]s. It provides methods for adding and reducing positions
5//! using different booking methods (FIFO, LIFO, STRICT, NONE).
6
7use rust_decimal::prelude::Signed;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use std::str::FromStr;
13
14use crate::{Amount, CostSpec, Position};
15
16/// Booking method determines how lots are matched when reducing positions.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
18pub enum BookingMethod {
19    /// Lots must match exactly (unambiguous).
20    /// If multiple lots match the cost spec, an error is raised.
21    #[default]
22    Strict,
23    /// Like STRICT, but exact-size matches accept oldest lot.
24    /// If reduction amount equals total inventory, it's considered unambiguous.
25    StrictWithSize,
26    /// First In, First Out. Oldest lots are reduced first.
27    Fifo,
28    /// Last In, First Out. Newest lots are reduced first.
29    Lifo,
30    /// Highest In, First Out. Highest-cost lots are reduced first.
31    Hifo,
32    /// Average cost booking. All lots of a currency are merged.
33    Average,
34    /// No cost tracking. Units are reduced without matching lots.
35    None,
36}
37
38impl FromStr for BookingMethod {
39    type Err = String;
40
41    fn from_str(s: &str) -> Result<Self, Self::Err> {
42        match s.to_uppercase().as_str() {
43            "STRICT" => Ok(Self::Strict),
44            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
45            "FIFO" => Ok(Self::Fifo),
46            "LIFO" => Ok(Self::Lifo),
47            "HIFO" => Ok(Self::Hifo),
48            "AVERAGE" => Ok(Self::Average),
49            "NONE" => Ok(Self::None),
50            _ => Err(format!("unknown booking method: {s}")),
51        }
52    }
53}
54
55impl fmt::Display for BookingMethod {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            Self::Strict => write!(f, "STRICT"),
59            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
60            Self::Fifo => write!(f, "FIFO"),
61            Self::Lifo => write!(f, "LIFO"),
62            Self::Hifo => write!(f, "HIFO"),
63            Self::Average => write!(f, "AVERAGE"),
64            Self::None => write!(f, "NONE"),
65        }
66    }
67}
68
69/// Result of a booking operation.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct BookingResult {
72    /// Positions that were matched/reduced.
73    pub matched: Vec<Position>,
74    /// The cost basis of the matched positions (for capital gains).
75    pub cost_basis: Option<Amount>,
76}
77
78/// Error that can occur during booking.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum BookingError {
81    /// Multiple lots match but booking method requires unambiguous match.
82    AmbiguousMatch {
83        /// Number of lots that matched.
84        num_matches: usize,
85        /// The currency being reduced.
86        currency: String,
87    },
88    /// No lots match the cost specification.
89    NoMatchingLot {
90        /// The currency being reduced.
91        currency: String,
92        /// The cost spec that didn't match.
93        cost_spec: CostSpec,
94    },
95    /// Not enough units in matching lots.
96    InsufficientUnits {
97        /// The currency being reduced.
98        currency: String,
99        /// Units requested.
100        requested: Decimal,
101        /// Units available.
102        available: Decimal,
103    },
104    /// Currency mismatch between reduction and inventory.
105    CurrencyMismatch {
106        /// Expected currency.
107        expected: String,
108        /// Got currency.
109        got: String,
110    },
111}
112
113impl fmt::Display for BookingError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::AmbiguousMatch {
117                num_matches,
118                currency,
119            } => write!(
120                f,
121                "Ambiguous match: {num_matches} lots match for {currency}"
122            ),
123            Self::NoMatchingLot {
124                currency,
125                cost_spec,
126            } => {
127                write!(f, "No matching lot for {currency} with cost {cost_spec}")
128            }
129            Self::InsufficientUnits {
130                currency,
131                requested,
132                available,
133            } => write!(
134                f,
135                "Insufficient units of {currency}: requested {requested}, available {available}"
136            ),
137            Self::CurrencyMismatch { expected, got } => {
138                write!(f, "Currency mismatch: expected {expected}, got {got}")
139            }
140        }
141    }
142}
143
144impl std::error::Error for BookingError {}
145
146/// An inventory is a collection of positions.
147///
148/// It tracks all positions for an account and supports booking operations
149/// for adding and reducing positions.
150///
151/// # Examples
152///
153/// ```
154/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
155/// use rust_decimal_macros::dec;
156///
157/// let mut inv = Inventory::new();
158///
159/// // Add a simple position
160/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
161/// assert_eq!(inv.units("USD"), dec!(100));
162///
163/// // Add a position with cost
164/// let cost = Cost::new(dec!(150.00), "USD");
165/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
166/// assert_eq!(inv.units("AAPL"), dec!(10));
167/// ```
168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169pub struct Inventory {
170    positions: Vec<Position>,
171}
172
173impl Inventory {
174    /// Create an empty inventory.
175    #[must_use]
176    pub fn new() -> Self {
177        Self::default()
178    }
179
180    /// Get all positions.
181    #[must_use]
182    pub fn positions(&self) -> &[Position] {
183        &self.positions
184    }
185
186    /// Get mutable access to all positions.
187    pub fn positions_mut(&mut self) -> &mut Vec<Position> {
188        &mut self.positions
189    }
190
191    /// Check if inventory is empty.
192    #[must_use]
193    pub fn is_empty(&self) -> bool {
194        self.positions.is_empty()
195            || self
196                .positions
197                .iter()
198                .all(super::position::Position::is_empty)
199    }
200
201    /// Get the number of positions (including empty ones).
202    #[must_use]
203    pub fn len(&self) -> usize {
204        self.positions.len()
205    }
206
207    /// Get total units of a currency (ignoring cost lots).
208    ///
209    /// This sums all positions of the given currency regardless of cost basis.
210    #[must_use]
211    pub fn units(&self, currency: &str) -> Decimal {
212        self.positions
213            .iter()
214            .filter(|p| p.units.currency == currency)
215            .map(|p| p.units.number)
216            .sum()
217    }
218
219    /// Get all currencies in this inventory.
220    #[must_use]
221    pub fn currencies(&self) -> Vec<&str> {
222        let mut currencies: Vec<&str> = self
223            .positions
224            .iter()
225            .filter(|p| !p.is_empty())
226            .map(|p| p.units.currency.as_str())
227            .collect();
228        currencies.sort_unstable();
229        currencies.dedup();
230        currencies
231    }
232
233    /// Get the total book value (cost basis) for a currency.
234    ///
235    /// Returns the sum of all cost bases for positions of the given currency.
236    #[must_use]
237    pub fn book_value(&self, units_currency: &str) -> HashMap<String, Decimal> {
238        let mut totals: HashMap<String, Decimal> = HashMap::new();
239
240        for pos in &self.positions {
241            if pos.units.currency == units_currency {
242                if let Some(book) = pos.book_value() {
243                    *totals.entry(book.currency).or_default() += book.number;
244                }
245            }
246        }
247
248        totals
249    }
250
251    /// Add a position to the inventory.
252    ///
253    /// For positions with cost, this creates a new lot.
254    /// For positions without cost, this merges with existing positions
255    /// of the same currency.
256    pub fn add(&mut self, position: Position) {
257        if position.is_empty() {
258            return;
259        }
260
261        // For positions without cost, try to merge
262        if position.cost.is_none() {
263            for existing in &mut self.positions {
264                if existing.cost.is_none() && existing.units.currency == position.units.currency {
265                    existing.units += &position.units;
266                    return;
267                }
268            }
269        }
270
271        // Otherwise, add as new lot
272        self.positions.push(position);
273    }
274
275    /// Reduce positions from the inventory using the specified booking method.
276    ///
277    /// # Arguments
278    ///
279    /// * `units` - The units to reduce (negative for selling)
280    /// * `cost_spec` - Optional cost specification for matching lots
281    /// * `method` - The booking method to use
282    ///
283    /// # Returns
284    ///
285    /// Returns a `BookingResult` with the matched positions and cost basis,
286    /// or a `BookingError` if the reduction cannot be performed.
287    pub fn reduce(
288        &mut self,
289        units: &Amount,
290        cost_spec: Option<&CostSpec>,
291        method: BookingMethod,
292    ) -> Result<BookingResult, BookingError> {
293        let spec = cost_spec.cloned().unwrap_or_default();
294
295        match method {
296            BookingMethod::Strict => self.reduce_strict(units, &spec),
297            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
298            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
299            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
300            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
301            BookingMethod::Average => self.reduce_average(units),
302            BookingMethod::None => self.reduce_none(units),
303        }
304    }
305
306    /// STRICT booking: require exactly one matching lot.
307    /// Also allows "total match exception": if reduction equals total inventory, accept.
308    fn reduce_strict(
309        &mut self,
310        units: &Amount,
311        spec: &CostSpec,
312    ) -> Result<BookingResult, BookingError> {
313        let matching_indices: Vec<usize> = self
314            .positions
315            .iter()
316            .enumerate()
317            .filter(|(_, p)| {
318                p.units.currency == units.currency
319                    && !p.is_empty()
320                    && p.can_reduce(units)
321                    && p.matches_cost_spec(spec)
322            })
323            .map(|(i, _)| i)
324            .collect();
325
326        match matching_indices.len() {
327            0 => Err(BookingError::NoMatchingLot {
328                currency: units.currency.clone(),
329                cost_spec: spec.clone(),
330            }),
331            1 => {
332                let idx = matching_indices[0];
333                self.reduce_from_lot(idx, units)
334            }
335            n => {
336                // Total match exception: if reduction equals total inventory, it's unambiguous
337                let total_units: Decimal = matching_indices
338                    .iter()
339                    .map(|&i| self.positions[i].units.number.abs())
340                    .sum();
341                if total_units == units.number.abs() {
342                    // Reduce from all matching lots (use FIFO order)
343                    self.reduce_ordered(units, spec, false)
344                } else {
345                    Err(BookingError::AmbiguousMatch {
346                        num_matches: n,
347                        currency: units.currency.clone(),
348                    })
349                }
350            }
351        }
352    }
353
354    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
355    fn reduce_strict_with_size(
356        &mut self,
357        units: &Amount,
358        spec: &CostSpec,
359    ) -> Result<BookingResult, BookingError> {
360        let matching_indices: Vec<usize> = self
361            .positions
362            .iter()
363            .enumerate()
364            .filter(|(_, p)| {
365                p.units.currency == units.currency
366                    && !p.is_empty()
367                    && p.can_reduce(units)
368                    && p.matches_cost_spec(spec)
369            })
370            .map(|(i, _)| i)
371            .collect();
372
373        match matching_indices.len() {
374            0 => Err(BookingError::NoMatchingLot {
375                currency: units.currency.clone(),
376                cost_spec: spec.clone(),
377            }),
378            1 => {
379                let idx = matching_indices[0];
380                self.reduce_from_lot(idx, units)
381            }
382            n => {
383                // Check for exact-size match with any lot
384                let exact_matches: Vec<usize> = matching_indices
385                    .iter()
386                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
387                    .copied()
388                    .collect();
389
390                if exact_matches.is_empty() {
391                    // Total match exception
392                    let total_units: Decimal = matching_indices
393                        .iter()
394                        .map(|&i| self.positions[i].units.number.abs())
395                        .sum();
396                    if total_units == units.number.abs() {
397                        self.reduce_ordered(units, spec, false)
398                    } else {
399                        Err(BookingError::AmbiguousMatch {
400                            num_matches: n,
401                            currency: units.currency.clone(),
402                        })
403                    }
404                } else {
405                    // Use oldest (first) exact-size match
406                    let idx = exact_matches[0];
407                    self.reduce_from_lot(idx, units)
408                }
409            }
410        }
411    }
412
413    /// FIFO booking: reduce from oldest lots first.
414    fn reduce_fifo(
415        &mut self,
416        units: &Amount,
417        spec: &CostSpec,
418    ) -> Result<BookingResult, BookingError> {
419        self.reduce_ordered(units, spec, false)
420    }
421
422    /// LIFO booking: reduce from newest lots first.
423    fn reduce_lifo(
424        &mut self,
425        units: &Amount,
426        spec: &CostSpec,
427    ) -> Result<BookingResult, BookingError> {
428        self.reduce_ordered(units, spec, true)
429    }
430
431    /// HIFO booking: reduce from highest-cost lots first.
432    fn reduce_hifo(
433        &mut self,
434        units: &Amount,
435        spec: &CostSpec,
436    ) -> Result<BookingResult, BookingError> {
437        let mut remaining = units.number.abs();
438        let mut matched = Vec::new();
439        let mut cost_basis = Decimal::ZERO;
440        let mut cost_currency = None;
441
442        // Get matching positions with their costs
443        let mut matching: Vec<(usize, Decimal)> = self
444            .positions
445            .iter()
446            .enumerate()
447            .filter(|(_, p)| {
448                p.units.currency == units.currency
449                    && !p.is_empty()
450                    && p.units.number.signum() != units.number.signum()
451                    && p.matches_cost_spec(spec)
452            })
453            .map(|(i, p)| {
454                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
455                (i, cost)
456            })
457            .collect();
458
459        if matching.is_empty() {
460            return Err(BookingError::NoMatchingLot {
461                currency: units.currency.clone(),
462                cost_spec: spec.clone(),
463            });
464        }
465
466        // Sort by cost descending (highest first)
467        matching.sort_by(|a, b| b.1.cmp(&a.1));
468
469        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
470
471        for idx in indices {
472            if remaining.is_zero() {
473                break;
474            }
475
476            let pos = &self.positions[idx];
477            let available = pos.units.number.abs();
478            let take = remaining.min(available);
479
480            // Calculate cost basis for this portion
481            if let Some(cost) = &pos.cost {
482                cost_basis += take * cost.number;
483                cost_currency = Some(cost.currency.clone());
484            }
485
486            // Record what we matched
487            let (taken, _) = pos.split(take * pos.units.number.signum());
488            matched.push(taken);
489
490            // Reduce the lot
491            let reduction = if units.number.is_sign_negative() {
492                -take
493            } else {
494                take
495            };
496
497            let new_pos = Position {
498                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
499                cost: pos.cost.clone(),
500            };
501            self.positions[idx] = new_pos;
502
503            remaining -= take;
504        }
505
506        if !remaining.is_zero() {
507            let available = units.number.abs() - remaining;
508            return Err(BookingError::InsufficientUnits {
509                currency: units.currency.clone(),
510                requested: units.number.abs(),
511                available,
512            });
513        }
514
515        // Clean up empty positions
516        self.positions.retain(|p| !p.is_empty());
517
518        Ok(BookingResult {
519            matched,
520            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
521        })
522    }
523
524    /// Reduce in order (FIFO or LIFO).
525    fn reduce_ordered(
526        &mut self,
527        units: &Amount,
528        spec: &CostSpec,
529        reverse: bool,
530    ) -> Result<BookingResult, BookingError> {
531        let mut remaining = units.number.abs();
532        let mut matched = Vec::new();
533        let mut cost_basis = Decimal::ZERO;
534        let mut cost_currency = None;
535
536        // Get indices of matching positions
537        let mut indices: Vec<usize> = self
538            .positions
539            .iter()
540            .enumerate()
541            .filter(|(_, p)| {
542                p.units.currency == units.currency
543                    && !p.is_empty()
544                    && p.units.number.signum() != units.number.signum()
545                    && p.matches_cost_spec(spec)
546            })
547            .map(|(i, _)| i)
548            .collect();
549
550        if reverse {
551            indices.reverse();
552        }
553
554        if indices.is_empty() {
555            return Err(BookingError::NoMatchingLot {
556                currency: units.currency.clone(),
557                cost_spec: spec.clone(),
558            });
559        }
560
561        for idx in indices {
562            if remaining.is_zero() {
563                break;
564            }
565
566            let pos = &self.positions[idx];
567            let available = pos.units.number.abs();
568            let take = remaining.min(available);
569
570            // Calculate cost basis for this portion
571            if let Some(cost) = &pos.cost {
572                cost_basis += take * cost.number;
573                cost_currency = Some(cost.currency.clone());
574            }
575
576            // Record what we matched
577            let (taken, _) = pos.split(take * pos.units.number.signum());
578            matched.push(taken);
579
580            // Reduce the lot
581            let reduction = if units.number.is_sign_negative() {
582                -take
583            } else {
584                take
585            };
586
587            let new_pos = Position {
588                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
589                cost: pos.cost.clone(),
590            };
591            self.positions[idx] = new_pos;
592
593            remaining -= take;
594        }
595
596        if !remaining.is_zero() {
597            let available = units.number.abs() - remaining;
598            return Err(BookingError::InsufficientUnits {
599                currency: units.currency.clone(),
600                requested: units.number.abs(),
601                available,
602            });
603        }
604
605        // Clean up empty positions
606        self.positions.retain(|p| !p.is_empty());
607
608        Ok(BookingResult {
609            matched,
610            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
611        })
612    }
613
614    /// AVERAGE booking: merge all lots of the currency.
615    fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
616        // Calculate average cost
617        let total_units: Decimal = self
618            .positions
619            .iter()
620            .filter(|p| p.units.currency == units.currency && !p.is_empty())
621            .map(|p| p.units.number)
622            .sum();
623
624        if total_units.is_zero() {
625            return Err(BookingError::InsufficientUnits {
626                currency: units.currency.clone(),
627                requested: units.number.abs(),
628                available: Decimal::ZERO,
629            });
630        }
631
632        // Check sufficient units
633        let reduction = units.number.abs();
634        if reduction > total_units.abs() {
635            return Err(BookingError::InsufficientUnits {
636                currency: units.currency.clone(),
637                requested: reduction,
638                available: total_units.abs(),
639            });
640        }
641
642        // Calculate total cost basis
643        let book_values = self.book_value(&units.currency);
644        let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
645            let per_unit_cost = total / total_units;
646            Some(Amount::new(reduction * per_unit_cost, curr.clone()))
647        } else {
648            None
649        };
650
651        // Create merged position
652        let new_units = total_units + units.number;
653
654        // Remove all positions of this currency
655        let matched: Vec<Position> = self
656            .positions
657            .iter()
658            .filter(|p| p.units.currency == units.currency && !p.is_empty())
659            .cloned()
660            .collect();
661
662        self.positions
663            .retain(|p| p.units.currency != units.currency);
664
665        // Add back the remainder if non-zero
666        if !new_units.is_zero() {
667            self.positions.push(Position::simple(Amount::new(
668                new_units,
669                units.currency.clone(),
670            )));
671        }
672
673        Ok(BookingResult {
674            matched,
675            cost_basis,
676        })
677    }
678
679    /// NONE booking: reduce without matching lots.
680    fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
681        // For NONE booking, we just reduce the total without caring about lots
682        let total_units = self.units(&units.currency);
683
684        // Check we have enough in the right direction
685        if total_units.signum() == units.number.signum() || total_units.is_zero() {
686            // This is an augmentation, not a reduction - just add it
687            self.add(Position::simple(units.clone()));
688            return Ok(BookingResult {
689                matched: vec![],
690                cost_basis: None,
691            });
692        }
693
694        let available = total_units.abs();
695        let requested = units.number.abs();
696
697        if requested > available {
698            return Err(BookingError::InsufficientUnits {
699                currency: units.currency.clone(),
700                requested,
701                available,
702            });
703        }
704
705        // Reduce positions proportionally (simplified: just reduce first matching)
706        self.reduce_ordered(units, &CostSpec::default(), false)
707    }
708
709    /// Reduce from a specific lot.
710    fn reduce_from_lot(
711        &mut self,
712        idx: usize,
713        units: &Amount,
714    ) -> Result<BookingResult, BookingError> {
715        let pos = &self.positions[idx];
716        let available = pos.units.number.abs();
717        let requested = units.number.abs();
718
719        if requested > available {
720            return Err(BookingError::InsufficientUnits {
721                currency: units.currency.clone(),
722                requested,
723                available,
724            });
725        }
726
727        // Calculate cost basis
728        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
729
730        // Record matched
731        let (matched, _) = pos.split(requested * pos.units.number.signum());
732
733        // Update the position
734        let new_units = pos.units.number + units.number;
735        let new_pos = Position {
736            units: Amount::new(new_units, pos.units.currency.clone()),
737            cost: pos.cost.clone(),
738        };
739        self.positions[idx] = new_pos;
740
741        // Remove if empty
742        if self.positions[idx].is_empty() {
743            self.positions.remove(idx);
744        }
745
746        Ok(BookingResult {
747            matched: vec![matched],
748            cost_basis,
749        })
750    }
751
752    /// Remove all empty positions.
753    pub fn compact(&mut self) {
754        self.positions.retain(|p| !p.is_empty());
755    }
756
757    /// Merge this inventory with another.
758    pub fn merge(&mut self, other: &Self) {
759        for pos in &other.positions {
760            self.add(pos.clone());
761        }
762    }
763
764    /// Convert inventory to cost basis.
765    ///
766    /// Returns a new inventory where all positions are converted to their
767    /// cost basis. Positions without cost are returned as-is.
768    #[must_use]
769    pub fn at_cost(&self) -> Self {
770        let mut result = Self::new();
771
772        for pos in &self.positions {
773            if pos.is_empty() {
774                continue;
775            }
776
777            if let Some(cost) = &pos.cost {
778                // Convert to cost basis
779                let total = pos.units.number * cost.number;
780                result.add(Position::simple(Amount::new(total, &cost.currency)));
781            } else {
782                // No cost, keep as-is
783                result.add(pos.clone());
784            }
785        }
786
787        result
788    }
789
790    /// Convert inventory to units only.
791    ///
792    /// Returns a new inventory where all positions have their cost removed,
793    /// effectively aggregating by currency only.
794    #[must_use]
795    pub fn at_units(&self) -> Self {
796        let mut result = Self::new();
797
798        for pos in &self.positions {
799            if pos.is_empty() {
800                continue;
801            }
802
803            // Strip cost, keep only units
804            result.add(Position::simple(pos.units.clone()));
805        }
806
807        result
808    }
809}
810
811impl fmt::Display for Inventory {
812    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
813        if self.is_empty() {
814            return write!(f, "(empty)");
815        }
816
817        let non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
818        for (i, pos) in non_empty.iter().enumerate() {
819            if i > 0 {
820                write!(f, ", ")?;
821            }
822            write!(f, "{pos}")?;
823        }
824        Ok(())
825    }
826}
827
828impl FromIterator<Position> for Inventory {
829    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
830        let mut inv = Self::new();
831        for pos in iter {
832            inv.add(pos);
833        }
834        inv
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use crate::Cost;
842    use chrono::NaiveDate;
843    use rust_decimal_macros::dec;
844
845    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
846        NaiveDate::from_ymd_opt(year, month, day).unwrap()
847    }
848
849    #[test]
850    fn test_empty_inventory() {
851        let inv = Inventory::new();
852        assert!(inv.is_empty());
853        assert_eq!(inv.len(), 0);
854    }
855
856    #[test]
857    fn test_add_simple() {
858        let mut inv = Inventory::new();
859        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
860
861        assert!(!inv.is_empty());
862        assert_eq!(inv.units("USD"), dec!(100));
863    }
864
865    #[test]
866    fn test_add_merge_simple() {
867        let mut inv = Inventory::new();
868        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
869        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
870
871        // Should merge into one position
872        assert_eq!(inv.len(), 1);
873        assert_eq!(inv.units("USD"), dec!(150));
874    }
875
876    #[test]
877    fn test_add_with_cost_no_merge() {
878        let mut inv = Inventory::new();
879
880        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
881        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
882
883        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
884        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
885
886        // Should NOT merge - different costs
887        assert_eq!(inv.len(), 2);
888        assert_eq!(inv.units("AAPL"), dec!(15));
889    }
890
891    #[test]
892    fn test_currencies() {
893        let mut inv = Inventory::new();
894        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
895        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
896        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
897
898        let currencies = inv.currencies();
899        assert_eq!(currencies.len(), 3);
900        assert!(currencies.contains(&"USD"));
901        assert!(currencies.contains(&"EUR"));
902        assert!(currencies.contains(&"AAPL"));
903    }
904
905    #[test]
906    fn test_reduce_strict_unique() {
907        let mut inv = Inventory::new();
908        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
909        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
910
911        let result = inv
912            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
913            .unwrap();
914
915        assert_eq!(inv.units("AAPL"), dec!(5));
916        assert!(result.cost_basis.is_some());
917        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
918    }
919
920    #[test]
921    fn test_reduce_strict_ambiguous() {
922        let mut inv = Inventory::new();
923
924        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
925        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
926
927        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
928        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
929
930        // Reducing without cost spec should fail (ambiguous)
931        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
932
933        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
934    }
935
936    #[test]
937    fn test_reduce_strict_with_spec() {
938        let mut inv = Inventory::new();
939
940        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
941        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
942
943        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
944        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
945
946        // Reducing with cost spec should work
947        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
948        let result = inv
949            .reduce(
950                &Amount::new(dec!(-3), "AAPL"),
951                Some(&spec),
952                BookingMethod::Strict,
953            )
954            .unwrap();
955
956        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
957        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
958    }
959
960    #[test]
961    fn test_reduce_fifo() {
962        let mut inv = Inventory::new();
963
964        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
965        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
966        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
967
968        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
969        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
970        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
971
972        // FIFO should reduce from oldest (cost 100) first
973        let result = inv
974            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
975            .unwrap();
976
977        assert_eq!(inv.units("AAPL"), dec!(15));
978        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
979        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
980    }
981
982    #[test]
983    fn test_reduce_lifo() {
984        let mut inv = Inventory::new();
985
986        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
987        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
988        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
989
990        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
991        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
992        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
993
994        // LIFO should reduce from newest (cost 200) first
995        let result = inv
996            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
997            .unwrap();
998
999        assert_eq!(inv.units("AAPL"), dec!(15));
1000        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
1001        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1002    }
1003
1004    #[test]
1005    fn test_reduce_insufficient() {
1006        let mut inv = Inventory::new();
1007        let cost = Cost::new(dec!(150.00), "USD");
1008        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1009
1010        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1011
1012        assert!(matches!(
1013            result,
1014            Err(BookingError::InsufficientUnits { .. })
1015        ));
1016    }
1017
1018    #[test]
1019    fn test_book_value() {
1020        let mut inv = Inventory::new();
1021
1022        let cost1 = Cost::new(dec!(100.00), "USD");
1023        let cost2 = Cost::new(dec!(150.00), "USD");
1024
1025        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1026        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1027
1028        let book = inv.book_value("AAPL");
1029        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
1030    }
1031
1032    #[test]
1033    fn test_display() {
1034        let mut inv = Inventory::new();
1035        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1036
1037        let s = format!("{inv}");
1038        assert!(s.contains("100 USD"));
1039    }
1040
1041    #[test]
1042    fn test_display_empty() {
1043        let inv = Inventory::new();
1044        assert_eq!(format!("{inv}"), "(empty)");
1045    }
1046
1047    #[test]
1048    fn test_from_iterator() {
1049        let positions = vec![
1050            Position::simple(Amount::new(dec!(100), "USD")),
1051            Position::simple(Amount::new(dec!(50), "USD")),
1052        ];
1053
1054        let inv: Inventory = positions.into_iter().collect();
1055        assert_eq!(inv.units("USD"), dec!(150));
1056    }
1057}