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