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    /// Check if this position is empty (zero units).
73    #[must_use]
74    pub const fn is_empty(&self) -> bool {
75        self.units.is_zero()
76    }
77
78    /// Get the currency of this position's units.
79    #[must_use]
80    pub fn currency(&self) -> &str {
81        &self.units.currency
82    }
83
84    /// Get the cost currency, if this position has a cost.
85    #[must_use]
86    pub fn cost_currency(&self) -> Option<&str> {
87        self.cost.as_ref().map(|c| c.currency.as_str())
88    }
89
90    /// Calculate the book value (total cost) of this position.
91    ///
92    /// Returns `None` if there is no cost.
93    #[must_use]
94    pub fn book_value(&self) -> Option<Amount> {
95        self.cost.as_ref().map(|c| c.total_cost(self.units.number))
96    }
97
98    /// Check if this position matches a cost specification.
99    ///
100    /// Returns `true` if:
101    /// - Both have no cost, or
102    /// - The position's cost matches the spec
103    #[must_use]
104    pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
105        match (&self.cost, spec.is_empty()) {
106            (None, true) => true,
107            (None, false) => false,
108            (Some(cost), _) => spec.matches(cost),
109        }
110    }
111
112    /// Negate this position (reverse the sign of units).
113    #[must_use]
114    pub fn neg(&self) -> Self {
115        Self {
116            units: -&self.units,
117            cost: self.cost.clone(),
118        }
119    }
120
121    /// Check if this position can be reduced by another amount.
122    ///
123    /// A position can be reduced if:
124    /// - The currencies match
125    /// - The reduction is in the opposite direction (selling what you have)
126    #[must_use]
127    pub fn can_reduce(&self, reduction: &Amount) -> bool {
128        self.units.currency == reduction.currency
129            && self.units.number.signum() != reduction.number.signum()
130    }
131
132    /// Reduce this position by some units.
133    ///
134    /// Returns `Some(remaining)` if the reduction is valid, `None` otherwise.
135    /// The reduction must be in the opposite direction of the position.
136    #[must_use]
137    pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
138        if self.units.number.signum() == reduction.signum() {
139            return None; // Can't reduce in same direction
140        }
141
142        let new_units = self.units.number + reduction;
143
144        // Check if we're crossing zero (over-reducing)
145        if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
146            return None;
147        }
148
149        Some(Self {
150            units: Amount::new(new_units, self.units.currency.clone()),
151            cost: self.cost.clone(),
152        })
153    }
154
155    /// Split this position, taking some units and leaving the rest.
156    ///
157    /// Returns `(taken, remaining)` where `taken` has the specified units
158    /// and `remaining` has the rest. Both share the same cost.
159    #[must_use]
160    pub fn split(&self, take_units: Decimal) -> (Self, Self) {
161        let taken = Self {
162            units: Amount::new(take_units, self.units.currency.clone()),
163            cost: self.cost.clone(),
164        };
165        let remaining = Self {
166            units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
167            cost: self.cost.clone(),
168        };
169        (taken, remaining)
170    }
171}
172
173impl fmt::Display for Position {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(f, "{}", self.units)?;
176        if let Some(cost) = &self.cost {
177            write!(f, " {cost}")?;
178        }
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::NaiveDate;
187    use rust_decimal_macros::dec;
188
189    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
190        crate::naive_date(year, month, day).unwrap()
191    }
192
193    #[test]
194    fn test_simple_position() {
195        let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
196        assert_eq!(pos.units.number, dec!(1000.00));
197        assert_eq!(pos.currency(), "USD");
198        assert!(pos.cost.is_none());
199    }
200
201    #[test]
202    fn test_position_with_cost() {
203        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
204        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
205
206        assert_eq!(pos.units.number, dec!(10));
207        assert_eq!(pos.currency(), "AAPL");
208        assert_eq!(pos.cost_currency(), Some("USD"));
209    }
210
211    #[test]
212    fn test_book_value() {
213        let cost = Cost::new(dec!(150.00), "USD");
214        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
215
216        let book_value = pos.book_value().unwrap();
217        assert_eq!(book_value.number, dec!(1500.00));
218        assert_eq!(book_value.currency, "USD");
219    }
220
221    #[test]
222    fn test_book_value_no_cost() {
223        let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
224        assert!(pos.book_value().is_none());
225    }
226
227    #[test]
228    fn test_is_empty() {
229        let empty = Position::simple(Amount::zero("USD"));
230        assert!(empty.is_empty());
231
232        let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
233        assert!(!non_empty.is_empty());
234    }
235
236    #[test]
237    fn test_neg() {
238        let pos = Position::simple(Amount::new(dec!(100), "USD"));
239        let neg = pos.neg();
240        assert_eq!(neg.units.number, dec!(-100));
241    }
242
243    #[test]
244    fn test_reduce() {
245        let pos = Position::simple(Amount::new(dec!(100), "USD"));
246
247        // Valid reduction
248        let reduced = pos.reduce(dec!(-30)).unwrap();
249        assert_eq!(reduced.units.number, dec!(70));
250
251        // Can't reduce in same direction
252        assert!(pos.reduce(dec!(30)).is_none());
253
254        // Can't over-reduce
255        assert!(pos.reduce(dec!(-150)).is_none());
256
257        // Can reduce to zero
258        let zero = pos.reduce(dec!(-100)).unwrap();
259        assert!(zero.is_empty());
260    }
261
262    #[test]
263    fn test_split() {
264        let cost = Cost::new(dec!(150.00), "USD");
265        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
266
267        let (taken, remaining) = pos.split(dec!(3));
268        assert_eq!(taken.units.number, dec!(3));
269        assert_eq!(remaining.units.number, dec!(7));
270
271        // Both share same cost
272        assert_eq!(taken.cost, pos.cost);
273        assert_eq!(remaining.cost, pos.cost);
274    }
275
276    #[test]
277    fn test_matches_cost_spec() {
278        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
279        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
280
281        // Empty spec matches
282        assert!(pos.matches_cost_spec(&CostSpec::empty()));
283
284        // Matching spec
285        let spec = CostSpec::empty()
286            .with_number_per(dec!(150.00))
287            .with_currency("USD");
288        assert!(pos.matches_cost_spec(&spec));
289
290        // Non-matching spec
291        let spec = CostSpec::empty().with_number_per(dec!(160.00));
292        assert!(!pos.matches_cost_spec(&spec));
293    }
294
295    #[test]
296    fn test_display() {
297        let cost = Cost::new(dec!(150.00), "USD");
298        let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
299        let s = format!("{pos}");
300        assert!(s.contains("10 AAPL"));
301        assert!(s.contains("150.00 USD"));
302    }
303}