Skip to main content

rustledger_core/inventory/
mod.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::Decimal;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12
13use crate::intern::InternedStr;
14use crate::{Amount, CostSpec, Position};
15
16mod booking;
17
18/// Booking method determines how lots are matched when reducing positions.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[cfg_attr(
21    feature = "rkyv",
22    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
23)]
24pub enum BookingMethod {
25    /// Lots must match exactly (unambiguous).
26    /// If multiple lots match the cost spec, an error is raised.
27    #[default]
28    Strict,
29    /// Like STRICT, but exact-size matches accept oldest lot.
30    /// If reduction amount equals total inventory, it's considered unambiguous.
31    StrictWithSize,
32    /// First In, First Out. Oldest lots are reduced first.
33    Fifo,
34    /// Last In, First Out. Newest lots are reduced first.
35    Lifo,
36    /// Highest In, First Out. Highest-cost lots are reduced first.
37    Hifo,
38    /// Average cost booking. All lots of a currency are merged.
39    Average,
40    /// No cost tracking. Units are reduced without matching lots.
41    None,
42}
43
44impl FromStr for BookingMethod {
45    type Err = String;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_uppercase().as_str() {
49            "STRICT" => Ok(Self::Strict),
50            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
51            "FIFO" => Ok(Self::Fifo),
52            "LIFO" => Ok(Self::Lifo),
53            "HIFO" => Ok(Self::Hifo),
54            "AVERAGE" => Ok(Self::Average),
55            "NONE" => Ok(Self::None),
56            _ => Err(format!("unknown booking method: {s}")),
57        }
58    }
59}
60
61impl fmt::Display for BookingMethod {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Strict => write!(f, "STRICT"),
65            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
66            Self::Fifo => write!(f, "FIFO"),
67            Self::Lifo => write!(f, "LIFO"),
68            Self::Hifo => write!(f, "HIFO"),
69            Self::Average => write!(f, "AVERAGE"),
70            Self::None => write!(f, "NONE"),
71        }
72    }
73}
74
75/// Result of a booking operation.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BookingResult {
78    /// Positions that were matched/reduced.
79    pub matched: Vec<Position>,
80    /// The cost basis of the matched positions (for capital gains).
81    pub cost_basis: Option<Amount>,
82}
83
84/// Error that can occur during booking.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum BookingError {
87    /// Multiple lots match but booking method requires unambiguous match.
88    AmbiguousMatch {
89        /// Number of lots that matched.
90        num_matches: usize,
91        /// The currency being reduced.
92        currency: InternedStr,
93    },
94    /// No lots match the cost specification.
95    NoMatchingLot {
96        /// The currency being reduced.
97        currency: InternedStr,
98        /// The cost spec that didn't match.
99        cost_spec: CostSpec,
100    },
101    /// Not enough units in matching lots.
102    InsufficientUnits {
103        /// The currency being reduced.
104        currency: InternedStr,
105        /// Units requested.
106        requested: Decimal,
107        /// Units available.
108        available: Decimal,
109    },
110    /// Currency mismatch between reduction and inventory.
111    CurrencyMismatch {
112        /// Expected currency.
113        expected: InternedStr,
114        /// Got currency.
115        got: InternedStr,
116    },
117}
118
119impl fmt::Display for BookingError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::AmbiguousMatch {
123                num_matches,
124                currency,
125            } => write!(
126                f,
127                "Ambiguous match: {num_matches} lots match for {currency}"
128            ),
129            Self::NoMatchingLot {
130                currency,
131                cost_spec,
132            } => {
133                write!(f, "No matching lot for {currency} with cost {cost_spec}")
134            }
135            Self::InsufficientUnits {
136                currency,
137                requested,
138                available,
139            } => write!(
140                f,
141                "Insufficient units of {currency}: requested {requested}, available {available}"
142            ),
143            Self::CurrencyMismatch { expected, got } => {
144                write!(f, "Currency mismatch: expected {expected}, got {got}")
145            }
146        }
147    }
148}
149
150impl std::error::Error for BookingError {}
151
152/// An inventory is a collection of positions.
153///
154/// It tracks all positions for an account and supports booking operations
155/// for adding and reducing positions.
156///
157/// # Examples
158///
159/// ```
160/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
161/// use rust_decimal_macros::dec;
162///
163/// let mut inv = Inventory::new();
164///
165/// // Add a simple position
166/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
167/// assert_eq!(inv.units("USD"), dec!(100));
168///
169/// // Add a position with cost
170/// let cost = Cost::new(dec!(150.00), "USD");
171/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
172/// assert_eq!(inv.units("AAPL"), dec!(10));
173/// ```
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175#[cfg_attr(
176    feature = "rkyv",
177    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
178)]
179pub struct Inventory {
180    positions: Vec<Position>,
181    /// Index for O(1) lookup of simple positions (no cost) by currency.
182    /// Maps currency to position index in the `positions` Vec.
183    /// Not serialized - rebuilt on demand.
184    #[serde(skip)]
185    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
186    simple_index: HashMap<InternedStr, usize>,
187}
188
189impl PartialEq for Inventory {
190    fn eq(&self, other: &Self) -> bool {
191        // Only compare positions, not the index (which is derived data)
192        self.positions == other.positions
193    }
194}
195
196impl Eq for Inventory {}
197
198impl Inventory {
199    /// Create an empty inventory.
200    #[must_use]
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Get all positions.
206    #[must_use]
207    pub fn positions(&self) -> &[Position] {
208        &self.positions
209    }
210
211    /// Get mutable access to all positions.
212    pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
213        &mut self.positions
214    }
215
216    /// Check if inventory is empty.
217    #[must_use]
218    pub fn is_empty(&self) -> bool {
219        self.positions.is_empty()
220            || self
221                .positions
222                .iter()
223                .all(super::position::Position::is_empty)
224    }
225
226    /// Get the number of positions (including empty ones).
227    #[must_use]
228    pub fn len(&self) -> usize {
229        self.positions.len()
230    }
231
232    /// Get total units of a currency (ignoring cost lots).
233    ///
234    /// This sums all positions of the given currency regardless of cost basis.
235    #[must_use]
236    pub fn units(&self, currency: &str) -> Decimal {
237        self.positions
238            .iter()
239            .filter(|p| p.units.currency == currency)
240            .map(|p| p.units.number)
241            .sum()
242    }
243
244    /// Get all currencies in this inventory.
245    #[must_use]
246    pub fn currencies(&self) -> Vec<&str> {
247        let mut currencies: Vec<&str> = self
248            .positions
249            .iter()
250            .filter(|p| !p.is_empty())
251            .map(|p| p.units.currency.as_str())
252            .collect();
253        currencies.sort_unstable();
254        currencies.dedup();
255        currencies
256    }
257
258    /// Get the total book value (cost basis) for a currency.
259    ///
260    /// Returns the sum of all cost bases for positions of the given currency.
261    #[must_use]
262    pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
263        let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
264
265        for pos in &self.positions {
266            if pos.units.currency == units_currency {
267                if let Some(book) = pos.book_value() {
268                    *totals.entry(book.currency.clone()).or_default() += book.number;
269                }
270            }
271        }
272
273        totals
274    }
275
276    /// Add a position to the inventory.
277    ///
278    /// For positions without cost, this merges with existing positions
279    /// of the same currency using O(1) `HashMap` lookup.
280    ///
281    /// For positions with cost, this adds as a new lot (O(1)).
282    /// Lot aggregation for display purposes is handled separately at output time
283    /// (e.g., in the query result formatter).
284    pub fn add(&mut self, position: Position) {
285        if position.is_empty() {
286            return;
287        }
288
289        // For positions without cost, use index for O(1) lookup
290        if position.cost.is_none() {
291            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
292                // Merge with existing position
293                debug_assert!(self.positions[idx].cost.is_none());
294                self.positions[idx].units += &position.units;
295                return;
296            }
297            // No existing position - add new one and index it
298            let idx = self.positions.len();
299            self.simple_index
300                .insert(position.units.currency.clone(), idx);
301            self.positions.push(position);
302            return;
303        }
304
305        // For positions with cost, just add as a new lot.
306        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
307        // Lot aggregation for display purposes is handled separately in query output.
308        self.positions.push(position);
309    }
310
311    /// Reduce positions from the inventory using the specified booking method.
312    ///
313    /// # Arguments
314    ///
315    /// * `units` - The units to reduce (negative for selling)
316    /// * `cost_spec` - Optional cost specification for matching lots
317    /// * `method` - The booking method to use
318    ///
319    /// # Returns
320    ///
321    /// Returns a `BookingResult` with the matched positions and cost basis,
322    /// or a `BookingError` if the reduction cannot be performed.
323    pub fn reduce(
324        &mut self,
325        units: &Amount,
326        cost_spec: Option<&CostSpec>,
327        method: BookingMethod,
328    ) -> Result<BookingResult, BookingError> {
329        let spec = cost_spec.cloned().unwrap_or_default();
330
331        match method {
332            BookingMethod::Strict => self.reduce_strict(units, &spec),
333            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
334            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
335            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
336            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
337            BookingMethod::Average => self.reduce_average(units),
338            BookingMethod::None => self.reduce_none(units),
339        }
340    }
341
342    /// Remove all empty positions.
343    pub fn compact(&mut self) {
344        self.positions.retain(|p| !p.is_empty());
345        self.rebuild_index();
346    }
347
348    /// Rebuild the `simple_index` from positions.
349    /// Called after operations that may invalidate the index (like retain).
350    fn rebuild_index(&mut self) {
351        self.simple_index.clear();
352        for (idx, pos) in self.positions.iter().enumerate() {
353            if pos.cost.is_none() {
354                debug_assert!(
355                    !self.simple_index.contains_key(&pos.units.currency),
356                    "Invariant violated: multiple simple positions for currency {}",
357                    pos.units.currency
358                );
359                self.simple_index.insert(pos.units.currency.clone(), idx);
360            }
361        }
362    }
363
364    /// Merge this inventory with another.
365    pub fn merge(&mut self, other: &Self) {
366        for pos in &other.positions {
367            self.add(pos.clone());
368        }
369    }
370
371    /// Convert inventory to cost basis.
372    ///
373    /// Returns a new inventory where all positions are converted to their
374    /// cost basis. Positions without cost are returned as-is.
375    #[must_use]
376    pub fn at_cost(&self) -> Self {
377        let mut result = Self::new();
378
379        for pos in &self.positions {
380            if pos.is_empty() {
381                continue;
382            }
383
384            if let Some(cost) = &pos.cost {
385                // Convert to cost basis
386                let total = pos.units.number * cost.number;
387                result.add(Position::simple(Amount::new(total, &cost.currency)));
388            } else {
389                // No cost, keep as-is
390                result.add(pos.clone());
391            }
392        }
393
394        result
395    }
396
397    /// Convert inventory to units only.
398    ///
399    /// Returns a new inventory where all positions have their cost removed,
400    /// effectively aggregating by currency only.
401    #[must_use]
402    pub fn at_units(&self) -> Self {
403        let mut result = Self::new();
404
405        for pos in &self.positions {
406            if pos.is_empty() {
407                continue;
408            }
409
410            // Strip cost, keep only units
411            result.add(Position::simple(pos.units.clone()));
412        }
413
414        result
415    }
416}
417
418impl fmt::Display for Inventory {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        if self.is_empty() {
421            return write!(f, "(empty)");
422        }
423
424        // Sort positions alphabetically by currency, then by cost for consistency
425        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
426        non_empty.sort_by(|a, b| {
427            // First by currency
428            let cmp = a.units.currency.cmp(&b.units.currency);
429            if cmp != std::cmp::Ordering::Equal {
430                return cmp;
431            }
432            // Then by cost (if present)
433            match (&a.cost, &b.cost) {
434                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
435                (Some(_), None) => std::cmp::Ordering::Greater,
436                (None, Some(_)) => std::cmp::Ordering::Less,
437                (None, None) => std::cmp::Ordering::Equal,
438            }
439        });
440
441        for (i, pos) in non_empty.iter().enumerate() {
442            if i > 0 {
443                write!(f, ", ")?;
444            }
445            write!(f, "{pos}")?;
446        }
447        Ok(())
448    }
449}
450
451impl FromIterator<Position> for Inventory {
452    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
453        let mut inv = Self::new();
454        for pos in iter {
455            inv.add(pos);
456        }
457        inv
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::Cost;
465    use chrono::NaiveDate;
466    use rust_decimal_macros::dec;
467
468    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
469        NaiveDate::from_ymd_opt(year, month, day).unwrap()
470    }
471
472    #[test]
473    fn test_empty_inventory() {
474        let inv = Inventory::new();
475        assert!(inv.is_empty());
476        assert_eq!(inv.len(), 0);
477    }
478
479    #[test]
480    fn test_add_simple() {
481        let mut inv = Inventory::new();
482        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
483
484        assert!(!inv.is_empty());
485        assert_eq!(inv.units("USD"), dec!(100));
486    }
487
488    #[test]
489    fn test_add_merge_simple() {
490        let mut inv = Inventory::new();
491        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
492        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
493
494        // Should merge into one position
495        assert_eq!(inv.len(), 1);
496        assert_eq!(inv.units("USD"), dec!(150));
497    }
498
499    #[test]
500    fn test_add_with_cost_no_merge() {
501        let mut inv = Inventory::new();
502
503        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
504        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
505
506        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
507        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
508
509        // Should NOT merge - different costs
510        assert_eq!(inv.len(), 2);
511        assert_eq!(inv.units("AAPL"), dec!(15));
512    }
513
514    #[test]
515    fn test_currencies() {
516        let mut inv = Inventory::new();
517        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
518        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
519        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
520
521        let currencies = inv.currencies();
522        assert_eq!(currencies.len(), 3);
523        assert!(currencies.contains(&"USD"));
524        assert!(currencies.contains(&"EUR"));
525        assert!(currencies.contains(&"AAPL"));
526    }
527
528    #[test]
529    fn test_reduce_strict_unique() {
530        let mut inv = Inventory::new();
531        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
532        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
533
534        let result = inv
535            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
536            .unwrap();
537
538        assert_eq!(inv.units("AAPL"), dec!(5));
539        assert!(result.cost_basis.is_some());
540        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
541    }
542
543    #[test]
544    fn test_reduce_strict_multiple_match_uses_fifo() {
545        let mut inv = Inventory::new();
546
547        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
548        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
549
550        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
551        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
552
553        // Reducing without cost spec in STRICT mode falls back to FIFO
554        // (Python beancount behavior: when multiple lots match, use FIFO order)
555        let result = inv
556            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
557            .unwrap();
558
559        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5 remaining
560        // Should reduce from first lot (cost1) at 150.00 USD
561        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
562    }
563
564    #[test]
565    fn test_reduce_strict_with_spec() {
566        let mut inv = Inventory::new();
567
568        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
569        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
570
571        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
572        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
573
574        // Reducing with cost spec should work
575        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
576        let result = inv
577            .reduce(
578                &Amount::new(dec!(-3), "AAPL"),
579                Some(&spec),
580                BookingMethod::Strict,
581            )
582            .unwrap();
583
584        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
585        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
586    }
587
588    #[test]
589    fn test_reduce_fifo() {
590        let mut inv = Inventory::new();
591
592        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
593        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
594        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
595
596        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
597        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
598        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
599
600        // FIFO should reduce from oldest (cost 100) first
601        let result = inv
602            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
603            .unwrap();
604
605        assert_eq!(inv.units("AAPL"), dec!(15));
606        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
607        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
608    }
609
610    #[test]
611    fn test_reduce_lifo() {
612        let mut inv = Inventory::new();
613
614        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
615        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
616        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
617
618        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
619        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
620        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
621
622        // LIFO should reduce from newest (cost 200) first
623        let result = inv
624            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
625            .unwrap();
626
627        assert_eq!(inv.units("AAPL"), dec!(15));
628        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
629        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
630    }
631
632    #[test]
633    fn test_reduce_insufficient() {
634        let mut inv = Inventory::new();
635        let cost = Cost::new(dec!(150.00), "USD");
636        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
637
638        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
639
640        assert!(matches!(
641            result,
642            Err(BookingError::InsufficientUnits { .. })
643        ));
644    }
645
646    #[test]
647    fn test_book_value() {
648        let mut inv = Inventory::new();
649
650        let cost1 = Cost::new(dec!(100.00), "USD");
651        let cost2 = Cost::new(dec!(150.00), "USD");
652
653        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
654        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
655
656        let book = inv.book_value("AAPL");
657        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
658    }
659
660    #[test]
661    fn test_display() {
662        let mut inv = Inventory::new();
663        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
664
665        let s = format!("{inv}");
666        assert!(s.contains("100 USD"));
667    }
668
669    #[test]
670    fn test_display_empty() {
671        let inv = Inventory::new();
672        assert_eq!(format!("{inv}"), "(empty)");
673    }
674
675    #[test]
676    fn test_from_iterator() {
677        let positions = vec![
678            Position::simple(Amount::new(dec!(100), "USD")),
679            Position::simple(Amount::new(dec!(50), "USD")),
680        ];
681
682        let inv: Inventory = positions.into_iter().collect();
683        assert_eq!(inv.units("USD"), dec!(150));
684    }
685
686    #[test]
687    fn test_add_costed_positions_kept_separate() {
688        // Costed positions are kept as separate lots for O(1) add performance.
689        // Aggregation happens at display time (in query output).
690        let mut inv = Inventory::new();
691
692        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
693
694        // Buy 10 shares
695        inv.add(Position::with_cost(
696            Amount::new(dec!(10), "AAPL"),
697            cost.clone(),
698        ));
699        assert_eq!(inv.len(), 1);
700        assert_eq!(inv.units("AAPL"), dec!(10));
701
702        // Sell 10 shares - kept as separate lot for tracking
703        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
704        assert_eq!(inv.len(), 2); // Both lots kept
705        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
706    }
707
708    #[test]
709    fn test_add_costed_positions_net_units() {
710        // Verify that units() correctly sums across all lots
711        let mut inv = Inventory::new();
712
713        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
714
715        // Buy 10 shares
716        inv.add(Position::with_cost(
717            Amount::new(dec!(10), "AAPL"),
718            cost.clone(),
719        ));
720
721        // Sell 3 shares - kept as separate lot
722        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
723        assert_eq!(inv.len(), 2); // Both lots kept
724        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
725    }
726
727    #[test]
728    fn test_add_no_cancel_different_cost() {
729        // Test that different costs don't cancel
730        let mut inv = Inventory::new();
731
732        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
733        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
734
735        // Buy 10 shares at 150
736        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
737
738        // Sell 5 shares at 160 - should NOT cancel (different cost)
739        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
740
741        // Should have two separate lots
742        assert_eq!(inv.len(), 2);
743        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
744    }
745
746    #[test]
747    fn test_add_no_cancel_same_sign() {
748        // Test that same-sign positions don't merge even with same cost
749        let mut inv = Inventory::new();
750
751        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
752
753        // Buy 10 shares
754        inv.add(Position::with_cost(
755            Amount::new(dec!(10), "AAPL"),
756            cost.clone(),
757        ));
758
759        // Buy 5 more shares with same cost - should NOT merge
760        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
761
762        // Should have two separate lots (different acquisitions)
763        assert_eq!(inv.len(), 2);
764        assert_eq!(inv.units("AAPL"), dec!(15));
765    }
766
767    #[test]
768    fn test_merge_keeps_lots_separate() {
769        // Test that merge keeps costed lots separate (aggregation at display time)
770        let mut inv1 = Inventory::new();
771        let mut inv2 = Inventory::new();
772
773        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
774
775        // inv1: buy 10 shares
776        inv1.add(Position::with_cost(
777            Amount::new(dec!(10), "AAPL"),
778            cost.clone(),
779        ));
780
781        // inv2: sell 10 shares
782        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
783
784        // Merge keeps both lots, net units is zero
785        inv1.merge(&inv2);
786        assert_eq!(inv1.len(), 2); // Both lots preserved
787        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
788    }
789
790    // ====================================================================
791    // Phase 2: Additional Coverage Tests for Booking Methods
792    // ====================================================================
793
794    #[test]
795    fn test_hifo_with_tie_breaking() {
796        // When multiple lots have the same cost, HIFO should use insertion order
797        let mut inv = Inventory::new();
798
799        // Three lots with same cost but different dates
800        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
801        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
802        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
803
804        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
805        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
806        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
807
808        // HIFO with tied costs should reduce in some deterministic order
809        let result = inv
810            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
811            .unwrap();
812
813        assert_eq!(inv.units("AAPL"), dec!(15));
814        // All at same cost, so 15 * 100 = 1500
815        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
816    }
817
818    #[test]
819    fn test_hifo_with_different_costs() {
820        // HIFO should reduce highest cost lots first
821        let mut inv = Inventory::new();
822
823        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
824        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
825        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
826
827        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
828        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
829        inv.add(Position::with_cost(
830            Amount::new(dec!(10), "AAPL"),
831            cost_high,
832        ));
833
834        // Reduce 15 shares - should take from highest cost (200) first
835        let result = inv
836            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
837            .unwrap();
838
839        assert_eq!(inv.units("AAPL"), dec!(15));
840        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
841        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
842    }
843
844    #[test]
845    fn test_average_booking_with_pre_existing_positions() {
846        let mut inv = Inventory::new();
847
848        // Add two lots with different costs
849        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
850        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
851
852        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
853        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
854
855        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
856        // Reduce 5 shares using AVERAGE
857        let result = inv
858            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
859            .unwrap();
860
861        assert_eq!(inv.units("AAPL"), dec!(15));
862        // Cost basis for 5 shares at average 150 = 750
863        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
864    }
865
866    #[test]
867    fn test_average_booking_reduces_all() {
868        let mut inv = Inventory::new();
869
870        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
871        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
872
873        // Reduce all shares
874        let result = inv
875            .reduce(
876                &Amount::new(dec!(-10), "AAPL"),
877                None,
878                BookingMethod::Average,
879            )
880            .unwrap();
881
882        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
883        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
884    }
885
886    #[test]
887    fn test_none_booking_augmentation() {
888        // NONE booking with same-sign amounts should augment, not reduce
889        let mut inv = Inventory::new();
890        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
891
892        // Adding more (same sign) - this is an augmentation
893        let result = inv
894            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
895            .unwrap();
896
897        assert_eq!(inv.units("USD"), dec!(150));
898        assert!(result.matched.is_empty()); // No lots matched for augmentation
899        assert!(result.cost_basis.is_none());
900    }
901
902    #[test]
903    fn test_none_booking_reduction() {
904        // NONE booking with opposite-sign should reduce
905        let mut inv = Inventory::new();
906        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
907
908        let result = inv
909            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
910            .unwrap();
911
912        assert_eq!(inv.units("USD"), dec!(70));
913        assert!(!result.matched.is_empty());
914    }
915
916    #[test]
917    fn test_none_booking_insufficient() {
918        let mut inv = Inventory::new();
919        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
920
921        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
922
923        assert!(matches!(
924            result,
925            Err(BookingError::InsufficientUnits { .. })
926        ));
927    }
928
929    #[test]
930    fn test_booking_error_no_matching_lot() {
931        let mut inv = Inventory::new();
932
933        // Add a lot with specific cost
934        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
935        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
936
937        // Try to reduce with a cost spec that doesn't match
938        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
939        let result = inv.reduce(
940            &Amount::new(dec!(-5), "AAPL"),
941            Some(&wrong_spec),
942            BookingMethod::Strict,
943        );
944
945        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
946    }
947
948    #[test]
949    fn test_booking_error_insufficient_units() {
950        let mut inv = Inventory::new();
951
952        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
953        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
954
955        // Try to reduce more than available
956        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
957
958        match result {
959            Err(BookingError::InsufficientUnits {
960                requested,
961                available,
962                ..
963            }) => {
964                assert_eq!(requested, dec!(20));
965                assert_eq!(available, dec!(10));
966            }
967            _ => panic!("Expected InsufficientUnits error"),
968        }
969    }
970
971    #[test]
972    fn test_strict_with_size_exact_match() {
973        let mut inv = Inventory::new();
974
975        // Add two lots with same cost but different sizes
976        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
977        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
978
979        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
980        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
981
982        // Reduce exactly 5 - should match the 5-share lot
983        let result = inv
984            .reduce(
985                &Amount::new(dec!(-5), "AAPL"),
986                None,
987                BookingMethod::StrictWithSize,
988            )
989            .unwrap();
990
991        assert_eq!(inv.units("AAPL"), dec!(10));
992        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
993    }
994
995    #[test]
996    fn test_strict_with_size_total_match() {
997        let mut inv = Inventory::new();
998
999        // Add two lots
1000        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1001        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1002
1003        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1004        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1005
1006        // Reduce exactly 15 (total) - should succeed via total match exception
1007        let result = inv
1008            .reduce(
1009                &Amount::new(dec!(-15), "AAPL"),
1010                None,
1011                BookingMethod::StrictWithSize,
1012            )
1013            .unwrap();
1014
1015        assert_eq!(inv.units("AAPL"), dec!(0));
1016        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1017    }
1018
1019    #[test]
1020    fn test_strict_with_size_ambiguous() {
1021        let mut inv = Inventory::new();
1022
1023        // Add two lots of same size and cost
1024        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1025        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1026
1027        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1028        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1029
1030        // Reduce 7 shares - doesn't match either lot exactly, not total
1031        let result = inv.reduce(
1032            &Amount::new(dec!(-7), "AAPL"),
1033            None,
1034            BookingMethod::StrictWithSize,
1035        );
1036
1037        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1038    }
1039
1040    #[test]
1041    fn test_short_position() {
1042        // Test short selling (negative positions)
1043        let mut inv = Inventory::new();
1044
1045        // Short 10 shares
1046        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1047        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1048
1049        assert_eq!(inv.units("AAPL"), dec!(-10));
1050        assert!(!inv.is_empty());
1051    }
1052
1053    #[test]
1054    fn test_at_cost() {
1055        let mut inv = Inventory::new();
1056
1057        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1058        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1059
1060        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1061        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1062        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1063
1064        let at_cost = inv.at_cost();
1065
1066        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1067        // Plus 100 USD simple position = 1850 USD total
1068        assert_eq!(at_cost.units("USD"), dec!(1850));
1069        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1070    }
1071
1072    #[test]
1073    fn test_at_units() {
1074        let mut inv = Inventory::new();
1075
1076        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1077        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1078
1079        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1080        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1081
1082        let at_units = inv.at_units();
1083
1084        // All AAPL lots merged
1085        assert_eq!(at_units.units("AAPL"), dec!(15));
1086        // Should only have one position after aggregation
1087        assert_eq!(at_units.len(), 1);
1088    }
1089
1090    #[test]
1091    fn test_add_empty_position() {
1092        let mut inv = Inventory::new();
1093        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1094
1095        assert!(inv.is_empty());
1096        assert_eq!(inv.len(), 0);
1097    }
1098
1099    #[test]
1100    fn test_compact() {
1101        let mut inv = Inventory::new();
1102
1103        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1104        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1105
1106        // Reduce all
1107        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1108            .unwrap();
1109
1110        // Compact to remove empty positions
1111        inv.compact();
1112        assert!(inv.is_empty());
1113        assert_eq!(inv.len(), 0);
1114    }
1115
1116    #[test]
1117    fn test_booking_method_from_str() {
1118        assert_eq!(
1119            BookingMethod::from_str("STRICT").unwrap(),
1120            BookingMethod::Strict
1121        );
1122        assert_eq!(
1123            BookingMethod::from_str("fifo").unwrap(),
1124            BookingMethod::Fifo
1125        );
1126        assert_eq!(
1127            BookingMethod::from_str("LIFO").unwrap(),
1128            BookingMethod::Lifo
1129        );
1130        assert_eq!(
1131            BookingMethod::from_str("Hifo").unwrap(),
1132            BookingMethod::Hifo
1133        );
1134        assert_eq!(
1135            BookingMethod::from_str("AVERAGE").unwrap(),
1136            BookingMethod::Average
1137        );
1138        assert_eq!(
1139            BookingMethod::from_str("NONE").unwrap(),
1140            BookingMethod::None
1141        );
1142        assert_eq!(
1143            BookingMethod::from_str("strict_with_size").unwrap(),
1144            BookingMethod::StrictWithSize
1145        );
1146        assert!(BookingMethod::from_str("INVALID").is_err());
1147    }
1148
1149    #[test]
1150    fn test_booking_method_display() {
1151        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1152        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1153        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1154        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1155        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1156        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1157        assert_eq!(
1158            format!("{}", BookingMethod::StrictWithSize),
1159            "STRICT_WITH_SIZE"
1160        );
1161    }
1162
1163    #[test]
1164    fn test_booking_error_display() {
1165        let err = BookingError::AmbiguousMatch {
1166            num_matches: 3,
1167            currency: "AAPL".into(),
1168        };
1169        assert!(format!("{err}").contains("3 lots match"));
1170
1171        let err = BookingError::NoMatchingLot {
1172            currency: "AAPL".into(),
1173            cost_spec: CostSpec::empty(),
1174        };
1175        assert!(format!("{err}").contains("No matching lot"));
1176
1177        let err = BookingError::InsufficientUnits {
1178            currency: "AAPL".into(),
1179            requested: dec!(100),
1180            available: dec!(50),
1181        };
1182        assert!(format!("{err}").contains("requested 100"));
1183        assert!(format!("{err}").contains("available 50"));
1184
1185        let err = BookingError::CurrencyMismatch {
1186            expected: "USD".into(),
1187            got: "EUR".into(),
1188        };
1189        assert!(format!("{err}").contains("expected USD"));
1190        assert!(format!("{err}").contains("got EUR"));
1191    }
1192
1193    #[test]
1194    fn test_book_value_multiple_currencies() {
1195        let mut inv = Inventory::new();
1196
1197        // Cost in USD
1198        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1199        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1200
1201        // Cost in EUR
1202        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1203        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1204
1205        let book = inv.book_value("AAPL");
1206        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1207        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1208    }
1209
1210    #[test]
1211    fn test_reduce_hifo_insufficient_units() {
1212        let mut inv = Inventory::new();
1213
1214        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1215        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1216
1217        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1218
1219        assert!(matches!(
1220            result,
1221            Err(BookingError::InsufficientUnits { .. })
1222        ));
1223    }
1224
1225    #[test]
1226    fn test_reduce_average_insufficient_units() {
1227        let mut inv = Inventory::new();
1228
1229        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1230        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1231
1232        let result = inv.reduce(
1233            &Amount::new(dec!(-20), "AAPL"),
1234            None,
1235            BookingMethod::Average,
1236        );
1237
1238        assert!(matches!(
1239            result,
1240            Err(BookingError::InsufficientUnits { .. })
1241        ));
1242    }
1243
1244    #[test]
1245    fn test_reduce_average_empty_inventory() {
1246        let mut inv = Inventory::new();
1247
1248        let result = inv.reduce(
1249            &Amount::new(dec!(-10), "AAPL"),
1250            None,
1251            BookingMethod::Average,
1252        );
1253
1254        assert!(matches!(
1255            result,
1256            Err(BookingError::InsufficientUnits { .. })
1257        ));
1258    }
1259
1260    #[test]
1261    fn test_inventory_display_sorted() {
1262        let mut inv = Inventory::new();
1263
1264        // Add in non-alphabetical order
1265        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1266        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1267        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1268
1269        let display = format!("{inv}");
1270
1271        // Should be sorted alphabetically: AAPL, EUR, USD
1272        let aapl_pos = display.find("AAPL").unwrap();
1273        let eur_pos = display.find("EUR").unwrap();
1274        let usd_pos = display.find("USD").unwrap();
1275
1276        assert!(aapl_pos < eur_pos);
1277        assert!(eur_pos < usd_pos);
1278    }
1279
1280    #[test]
1281    fn test_inventory_with_cost_display_sorted() {
1282        let mut inv = Inventory::new();
1283
1284        // Add same currency with different costs
1285        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1286        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1287
1288        inv.add(Position::with_cost(
1289            Amount::new(dec!(10), "AAPL"),
1290            cost_high,
1291        ));
1292        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1293
1294        let display = format!("{inv}");
1295
1296        // Both positions should be in the output
1297        assert!(display.contains("AAPL"));
1298        assert!(display.contains("100"));
1299        assert!(display.contains("200"));
1300    }
1301
1302    #[test]
1303    fn test_reduce_hifo_no_matching_lot() {
1304        let mut inv = Inventory::new();
1305
1306        // No AAPL positions
1307        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1308
1309        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1310
1311        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1312    }
1313
1314    #[test]
1315    fn test_fifo_respects_dates() {
1316        // Ensure FIFO uses acquisition date, not insertion order
1317        let mut inv = Inventory::new();
1318
1319        // Add newer lot first (out of order)
1320        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1321        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1322
1323        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1324        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1325
1326        // FIFO should reduce from oldest (cost 100) first
1327        let result = inv
1328            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1329            .unwrap();
1330
1331        // Should use cost from oldest lot (100)
1332        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1333    }
1334
1335    #[test]
1336    fn test_lifo_respects_dates() {
1337        // Ensure LIFO uses acquisition date, not insertion order
1338        let mut inv = Inventory::new();
1339
1340        // Add older lot first
1341        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1342        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1343
1344        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1345        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1346
1347        // LIFO should reduce from newest (cost 200) first
1348        let result = inv
1349            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1350            .unwrap();
1351
1352        // Should use cost from newest lot (200)
1353        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1354    }
1355}