rustledger_core/
position.rs1use rust_decimal::prelude::Signed;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12use crate::{Amount, Cost, CostSpec};
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41pub struct Position {
42 pub units: Amount,
44 pub cost: Option<Cost>,
46}
47
48impl Position {
49 #[must_use]
53 pub const fn simple(units: Amount) -> Self {
54 Self { units, cost: None }
55 }
56
57 #[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 #[must_use]
71 pub const fn is_empty(&self) -> bool {
72 self.units.is_zero()
73 }
74
75 #[must_use]
77 pub fn currency(&self) -> &str {
78 &self.units.currency
79 }
80
81 #[must_use]
83 pub fn cost_currency(&self) -> Option<&str> {
84 self.cost.as_ref().map(|c| c.currency.as_str())
85 }
86
87 #[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 #[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 #[must_use]
111 pub fn neg(&self) -> Self {
112 Self {
113 units: -&self.units,
114 cost: self.cost.clone(),
115 }
116 }
117
118 #[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 #[must_use]
134 pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
135 if self.units.number.signum() == reduction.signum() {
136 return None; }
138
139 let new_units = self.units.number + reduction;
140
141 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 #[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 let reduced = pos.reduce(dec!(-30)).unwrap();
246 assert_eq!(reduced.units.number, dec!(70));
247
248 assert!(pos.reduce(dec!(30)).is_none());
250
251 assert!(pos.reduce(dec!(-150)).is_none());
253
254 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 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 assert!(pos.matches_cost_spec(&CostSpec::empty()));
280
281 let spec = CostSpec::empty()
283 .with_number_per(dec!(150.00))
284 .with_currency("USD");
285 assert!(pos.matches_cost_spec(&spec));
286
287 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}