Skip to main content

rustledger_core/
position.rs

1//! Position type representing units held at a cost.
2//!
3//! A [`Position`] represents a holding of some units of a currency or commodity,
4//! optionally with an associated cost basis (lot). Positions with costs are used
5//! for tracking investments and calculating capital gains.
6
7use rust_decimal::Decimal;
8use rust_decimal::prelude::Signed;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12use crate::{Amount, Cost, CostSpec};
13
14/// A position is units of a currency held at an optional cost.
15///
16/// For simple currencies (cash), positions typically have no cost.
17/// For investments (stocks, crypto), positions track the cost basis
18/// for capital gains calculations.
19///
20/// # Examples
21///
22/// ```
23/// use rustledger_core::{Amount, Cost, Position};
24/// use rust_decimal_macros::dec;
25///
26/// // Simple position (no cost)
27/// let cash = Position::simple(Amount::new(dec!(1000.00), "USD"));
28/// assert!(cash.cost.is_none());
29///
30/// // Position with cost (lot)
31/// let cost = Cost::new(dec!(150.00), "USD")
32///     .with_date(rustledger_core::naive_date(2024, 1, 15).unwrap());
33/// let stock = Position::with_cost(
34///     Amount::new(dec!(10), "AAPL"),
35///     cost
36/// );
37/// assert!(stock.cost.is_some());
38/// ```
39#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[cfg_attr(
41    feature = "rkyv",
42    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
43)]
44pub struct Position {
45    /// The units held (number + currency/commodity)
46    pub units: Amount,
47    /// The cost basis (if tracked)
48    pub cost: Option<Cost>,
49}
50
51impl Position {
52    /// Create a new position without cost tracking.
53    ///
54    /// Use this for simple currency positions like cash.
55    #[must_use]
56    pub const fn simple(units: Amount) -> Self {
57        Self { units, cost: None }
58    }
59
60    /// Create a new position with cost tracking.
61    ///
62    /// Use this for investment positions (stocks, crypto, etc.)
63    /// where cost basis matters.
64    #[must_use]
65    pub const fn with_cost(units: Amount, cost: Cost) -> Self {
66        Self {
67            units,
68            cost: Some(cost),
69        }
70    }
71
72    /// Build the booked position for a posting's `units` and optional cost
73    /// spec: [`Self::with_cost`] when the cost spec resolves to a [`Cost`],
74    /// otherwise [`Self::simple`].
75    ///
76    /// Single source for the validator's inventory-addition path and the pad
77    /// engine, which had byte-identical copies (mirrors the cost-bearing add
78    /// branch of `BookingEngine::apply`). [`CostSpec::resolve`] returns `None`
79    /// for an empty `{}` spec or a zero-units `Total` cost, so those book as a
80    /// simple (uncosted) position rather than panicking.
81    #[must_use]
82    pub fn from_posting(
83        units: &Amount,
84        cost_spec: Option<&CostSpec>,
85        date: crate::NaiveDate,
86    ) -> Self {
87        match cost_spec.and_then(|cs| cs.resolve(units.number, date)) {
88            Some(cost) => Self::with_cost(units.clone(), cost),
89            None => Self::simple(units.clone()),
90        }
91    }
92
93    /// Check if this position is empty (zero units).
94    #[must_use]
95    pub const fn is_empty(&self) -> bool {
96        self.units.is_zero()
97    }
98
99    /// Get the currency of this position's units.
100    #[must_use]
101    pub fn currency(&self) -> &str {
102        &self.units.currency
103    }
104
105    /// Get the cost currency, if this position has a cost.
106    #[must_use]
107    pub fn cost_currency(&self) -> Option<&str> {
108        self.cost.as_ref().map(|c| c.currency.as_str())
109    }
110
111    /// Calculate the book value (total cost) of this position.
112    ///
113    /// Returns `None` if there is no cost, or if `units × cost` leaves
114    /// `rust_decimal`'s range (see [`Cost::total_cost`]).
115    #[must_use]
116    pub fn book_value(&self) -> Option<Amount> {
117        self.cost
118            .as_ref()
119            .and_then(|c| c.total_cost(self.units.number))
120    }
121
122    /// Check if this position matches a cost specification.
123    ///
124    /// Returns `true` if:
125    /// - Both have no cost, or
126    /// - The position's cost matches the spec
127    #[must_use]
128    pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
129        match (&self.cost, spec.is_empty()) {
130            (None, true) => true,
131            (None, false) => false,
132            // A spec that constrains nothing matches every lot, so skip the
133            // field-by-field walk. `matches` returns `true` for an all-`None`
134            // spec by construction, which makes this a short-circuit rather
135            // than a second rule. Ordered selection calls this once per lot
136            // per reduction, and the bare `{}` FIFO sell is the spec that
137            // reaches it most (#2083).
138            (Some(_), true) => true,
139            (Some(cost), false) => spec.matches(cost),
140        }
141    }
142
143    /// Negate this position (reverse the sign of units).
144    #[must_use]
145    pub fn neg(&self) -> Self {
146        Self {
147            units: -&self.units,
148            cost: self.cost.clone(),
149        }
150    }
151
152    /// Check if this position can be reduced by another amount.
153    ///
154    /// A position can be reduced if:
155    /// - The currencies match
156    /// - The reduction is in the opposite direction (selling what you have)
157    #[must_use]
158    pub fn can_reduce(&self, reduction: &Amount) -> bool {
159        self.units.currency == reduction.currency
160            && self.units.number.signum() != reduction.number.signum()
161    }
162
163    /// Reduce this position by some units.
164    ///
165    /// Returns `Some(remaining)` if the reduction is valid, `None` otherwise.
166    /// The reduction must be in the opposite direction of the position.
167    #[must_use]
168    pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
169        if self.units.number.signum() == reduction.signum() {
170            return None; // Can't reduce in same direction
171        }
172
173        let new_units = self.units.number + reduction;
174
175        // Check if we're crossing zero (over-reducing)
176        if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
177            return None;
178        }
179
180        Some(Self {
181            units: Amount::new(new_units, self.units.currency.clone()),
182            cost: self.cost.clone(),
183        })
184    }
185
186    /// Split this position, taking some units and leaving the rest.
187    ///
188    /// Returns `(taken, remaining)` where `taken` has the specified units
189    /// and `remaining` has the rest. Both share the same cost.
190    #[must_use]
191    pub fn split(&self, take_units: Decimal) -> (Self, Self) {
192        let taken = Self {
193            units: Amount::new(take_units, self.units.currency.clone()),
194            cost: self.cost.clone(),
195        };
196        let remaining = Self {
197            units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
198            cost: self.cost.clone(),
199        };
200        (taken, remaining)
201    }
202}
203
204impl fmt::Display for Position {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        write!(f, "{}", self.units)?;
207        if let Some(cost) = &self.cost {
208            write!(f, " {cost}")?;
209        }
210        Ok(())
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::NaiveDate;
218    use rust_decimal_macros::dec;
219
220    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
221        crate::naive_date(year, month, day).unwrap()
222    }
223
224    #[test]
225    fn test_simple_position() {
226        let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
227        assert_eq!(pos.units.number, dec!(1000.00));
228        assert_eq!(pos.currency(), "USD");
229        assert!(pos.cost.is_none());
230    }
231
232    #[test]
233    fn test_position_with_cost() {
234        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
235        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
236
237        assert_eq!(pos.units.number, dec!(10));
238        assert_eq!(pos.currency(), "AAPL");
239        assert_eq!(pos.cost_currency(), Some("USD"));
240    }
241
242    #[test]
243    fn test_from_posting_resolves_cost_or_simple() {
244        use crate::{CostNumber, CostSpec};
245        use rust_decimal::Decimal;
246
247        let units = Amount::new(dec!(10), "AAPL");
248        // Resolvable cost spec → with_cost.
249        let spec = CostSpec::empty()
250            .with_number(CostNumber::PerUnit { value: dec!(150) })
251            .with_currency("USD");
252        let pos = Position::from_posting(&units, Some(&spec), date(2024, 1, 15));
253        assert_eq!(pos.cost_currency(), Some("USD"));
254        // No cost spec → simple.
255        let pos = Position::from_posting(&units, None, date(2024, 1, 15));
256        assert!(pos.cost.is_none());
257        // Zero-units Total cost → simple (no cost), NOT a divide-by-zero panic.
258        let zero = Amount::new(Decimal::ZERO, "AAPL");
259        let total = CostSpec::empty()
260            .with_number(CostNumber::Total { value: dec!(100) })
261            .with_currency("USD");
262        let pos = Position::from_posting(&zero, Some(&total), date(2024, 1, 15));
263        assert!(
264            pos.cost.is_none(),
265            "zero-units Total cost must book uncosted, not panic"
266        );
267    }
268
269    #[test]
270    fn test_book_value() {
271        let cost = Cost::new(dec!(150.00), "USD");
272        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
273
274        let book_value = pos.book_value().unwrap();
275        assert_eq!(book_value.number, dec!(1500.00));
276        assert_eq!(book_value.currency, "USD");
277    }
278
279    #[test]
280    fn test_book_value_no_cost() {
281        let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
282        assert!(pos.book_value().is_none());
283    }
284
285    #[test]
286    fn test_is_empty() {
287        let empty = Position::simple(Amount::zero("USD"));
288        assert!(empty.is_empty());
289
290        let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
291        assert!(!non_empty.is_empty());
292    }
293
294    #[test]
295    fn test_neg() {
296        let pos = Position::simple(Amount::new(dec!(100), "USD"));
297        let neg = pos.neg();
298        assert_eq!(neg.units.number, dec!(-100));
299    }
300
301    #[test]
302    fn test_reduce() {
303        let pos = Position::simple(Amount::new(dec!(100), "USD"));
304
305        // Valid reduction
306        let reduced = pos.reduce(dec!(-30)).unwrap();
307        assert_eq!(reduced.units.number, dec!(70));
308
309        // Can't reduce in same direction
310        assert!(pos.reduce(dec!(30)).is_none());
311
312        // Can't over-reduce
313        assert!(pos.reduce(dec!(-150)).is_none());
314
315        // Can reduce to zero
316        let zero = pos.reduce(dec!(-100)).unwrap();
317        assert!(zero.is_empty());
318    }
319
320    #[test]
321    fn test_split() {
322        let cost = Cost::new(dec!(150.00), "USD");
323        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
324
325        let (taken, remaining) = pos.split(dec!(3));
326        assert_eq!(taken.units.number, dec!(3));
327        assert_eq!(remaining.units.number, dec!(7));
328
329        // Both share same cost
330        assert_eq!(taken.cost, pos.cost);
331        assert_eq!(remaining.cost, pos.cost);
332    }
333
334    #[test]
335    fn test_matches_cost_spec() {
336        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
337        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
338
339        // Empty spec matches
340        assert!(pos.matches_cost_spec(&CostSpec::empty()));
341
342        // Matching spec
343        let spec = CostSpec::empty()
344            .with_number(crate::CostNumber::PerUnit {
345                value: dec!(150.00),
346            })
347            .with_currency("USD");
348        assert!(pos.matches_cost_spec(&spec));
349
350        // Non-matching spec
351        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
352            value: dec!(160.00),
353        });
354        assert!(!pos.matches_cost_spec(&spec));
355    }
356
357    #[test]
358    fn test_display() {
359        let cost = Cost::new(dec!(150.00), "USD");
360        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
361        let s = format!("{pos}");
362        assert!(s.contains("10 AAPL"));
363        assert!(s.contains("150.00 USD"));
364    }
365}