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.
114    #[must_use]
115    pub fn book_value(&self) -> Option<Amount> {
116        self.cost.as_ref().map(|c| c.total_cost(self.units.number))
117    }
118
119    /// Check if this position matches a cost specification.
120    ///
121    /// Returns `true` if:
122    /// - Both have no cost, or
123    /// - The position's cost matches the spec
124    #[must_use]
125    pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
126        match (&self.cost, spec.is_empty()) {
127            (None, true) => true,
128            (None, false) => false,
129            (Some(cost), _) => spec.matches(cost),
130        }
131    }
132
133    /// Negate this position (reverse the sign of units).
134    #[must_use]
135    pub fn neg(&self) -> Self {
136        Self {
137            units: -&self.units,
138            cost: self.cost.clone(),
139        }
140    }
141
142    /// Check if this position can be reduced by another amount.
143    ///
144    /// A position can be reduced if:
145    /// - The currencies match
146    /// - The reduction is in the opposite direction (selling what you have)
147    #[must_use]
148    pub fn can_reduce(&self, reduction: &Amount) -> bool {
149        self.units.currency == reduction.currency
150            && self.units.number.signum() != reduction.number.signum()
151    }
152
153    /// Reduce this position by some units.
154    ///
155    /// Returns `Some(remaining)` if the reduction is valid, `None` otherwise.
156    /// The reduction must be in the opposite direction of the position.
157    #[must_use]
158    pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
159        if self.units.number.signum() == reduction.signum() {
160            return None; // Can't reduce in same direction
161        }
162
163        let new_units = self.units.number + reduction;
164
165        // Check if we're crossing zero (over-reducing)
166        if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
167            return None;
168        }
169
170        Some(Self {
171            units: Amount::new(new_units, self.units.currency.clone()),
172            cost: self.cost.clone(),
173        })
174    }
175
176    /// Split this position, taking some units and leaving the rest.
177    ///
178    /// Returns `(taken, remaining)` where `taken` has the specified units
179    /// and `remaining` has the rest. Both share the same cost.
180    #[must_use]
181    pub fn split(&self, take_units: Decimal) -> (Self, Self) {
182        let taken = Self {
183            units: Amount::new(take_units, self.units.currency.clone()),
184            cost: self.cost.clone(),
185        };
186        let remaining = Self {
187            units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
188            cost: self.cost.clone(),
189        };
190        (taken, remaining)
191    }
192}
193
194impl fmt::Display for Position {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        write!(f, "{}", self.units)?;
197        if let Some(cost) = &self.cost {
198            write!(f, " {cost}")?;
199        }
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::NaiveDate;
208    use rust_decimal_macros::dec;
209
210    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
211        crate::naive_date(year, month, day).unwrap()
212    }
213
214    #[test]
215    fn test_simple_position() {
216        let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
217        assert_eq!(pos.units.number, dec!(1000.00));
218        assert_eq!(pos.currency(), "USD");
219        assert!(pos.cost.is_none());
220    }
221
222    #[test]
223    fn test_position_with_cost() {
224        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
225        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
226
227        assert_eq!(pos.units.number, dec!(10));
228        assert_eq!(pos.currency(), "AAPL");
229        assert_eq!(pos.cost_currency(), Some("USD"));
230    }
231
232    #[test]
233    fn test_from_posting_resolves_cost_or_simple() {
234        use crate::{CostNumber, CostSpec};
235        use rust_decimal::Decimal;
236
237        let units = Amount::new(dec!(10), "AAPL");
238        // Resolvable cost spec → with_cost.
239        let spec = CostSpec::empty()
240            .with_number(CostNumber::PerUnit { value: dec!(150) })
241            .with_currency("USD");
242        let pos = Position::from_posting(&units, Some(&spec), date(2024, 1, 15));
243        assert_eq!(pos.cost_currency(), Some("USD"));
244        // No cost spec → simple.
245        let pos = Position::from_posting(&units, None, date(2024, 1, 15));
246        assert!(pos.cost.is_none());
247        // Zero-units Total cost → simple (no cost), NOT a divide-by-zero panic.
248        let zero = Amount::new(Decimal::ZERO, "AAPL");
249        let total = CostSpec::empty()
250            .with_number(CostNumber::Total { value: dec!(100) })
251            .with_currency("USD");
252        let pos = Position::from_posting(&zero, Some(&total), date(2024, 1, 15));
253        assert!(
254            pos.cost.is_none(),
255            "zero-units Total cost must book uncosted, not panic"
256        );
257    }
258
259    #[test]
260    fn test_book_value() {
261        let cost = Cost::new(dec!(150.00), "USD");
262        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
263
264        let book_value = pos.book_value().unwrap();
265        assert_eq!(book_value.number, dec!(1500.00));
266        assert_eq!(book_value.currency, "USD");
267    }
268
269    #[test]
270    fn test_book_value_no_cost() {
271        let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
272        assert!(pos.book_value().is_none());
273    }
274
275    #[test]
276    fn test_is_empty() {
277        let empty = Position::simple(Amount::zero("USD"));
278        assert!(empty.is_empty());
279
280        let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
281        assert!(!non_empty.is_empty());
282    }
283
284    #[test]
285    fn test_neg() {
286        let pos = Position::simple(Amount::new(dec!(100), "USD"));
287        let neg = pos.neg();
288        assert_eq!(neg.units.number, dec!(-100));
289    }
290
291    #[test]
292    fn test_reduce() {
293        let pos = Position::simple(Amount::new(dec!(100), "USD"));
294
295        // Valid reduction
296        let reduced = pos.reduce(dec!(-30)).unwrap();
297        assert_eq!(reduced.units.number, dec!(70));
298
299        // Can't reduce in same direction
300        assert!(pos.reduce(dec!(30)).is_none());
301
302        // Can't over-reduce
303        assert!(pos.reduce(dec!(-150)).is_none());
304
305        // Can reduce to zero
306        let zero = pos.reduce(dec!(-100)).unwrap();
307        assert!(zero.is_empty());
308    }
309
310    #[test]
311    fn test_split() {
312        let cost = Cost::new(dec!(150.00), "USD");
313        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
314
315        let (taken, remaining) = pos.split(dec!(3));
316        assert_eq!(taken.units.number, dec!(3));
317        assert_eq!(remaining.units.number, dec!(7));
318
319        // Both share same cost
320        assert_eq!(taken.cost, pos.cost);
321        assert_eq!(remaining.cost, pos.cost);
322    }
323
324    #[test]
325    fn test_matches_cost_spec() {
326        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
327        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
328
329        // Empty spec matches
330        assert!(pos.matches_cost_spec(&CostSpec::empty()));
331
332        // Matching spec
333        let spec = CostSpec::empty()
334            .with_number(crate::CostNumber::PerUnit {
335                value: dec!(150.00),
336            })
337            .with_currency("USD");
338        assert!(pos.matches_cost_spec(&spec));
339
340        // Non-matching spec
341        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
342            value: dec!(160.00),
343        });
344        assert!(!pos.matches_cost_spec(&spec));
345    }
346
347    #[test]
348    fn test_display() {
349        let cost = Cost::new(dec!(150.00), "USD");
350        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
351        let s = format!("{pos}");
352        assert!(s.contains("10 AAPL"));
353        assert!(s.contains("150.00 USD"));
354    }
355}