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