1use rust_decimal::Decimal;
8use rust_decimal::prelude::Signed;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12use crate::{Amount, Cost, CostSpec};
13
14#[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 pub units: Amount,
47 pub cost: Option<Cost>,
49}
50
51impl Position {
52 #[must_use]
56 pub const fn simple(units: Amount) -> Self {
57 Self { units, cost: None }
58 }
59
60 #[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 #[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 #[must_use]
95 pub const fn is_empty(&self) -> bool {
96 self.units.is_zero()
97 }
98
99 #[must_use]
101 pub fn currency(&self) -> &str {
102 &self.units.currency
103 }
104
105 #[must_use]
107 pub fn cost_currency(&self) -> Option<&str> {
108 self.cost.as_ref().map(|c| c.currency.as_str())
109 }
110
111 #[must_use]
116 pub fn book_value(&self) -> Option<Amount> {
117 self.cost
118 .as_ref()
119 .and_then(|c| c.total_cost(self.units.number))
120 }
121
122 #[must_use]
128 pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
129 match (&self.cost, spec.is_empty()) {
130 (None, true) => true,
131 (None, false) => false,
132 (Some(_), true) => true,
139 (Some(cost), false) => spec.matches(cost),
140 }
141 }
142
143 #[must_use]
145 pub fn neg(&self) -> Self {
146 Self {
147 units: -&self.units,
148 cost: self.cost.clone(),
149 }
150 }
151
152 #[must_use]
158 pub fn can_reduce(&self, reduction: &Amount) -> bool {
159 self.units.currency == reduction.currency
160 && self.units.number.signum() != reduction.number.signum()
161 }
162
163 #[must_use]
168 pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
169 if self.units.number.signum() == reduction.signum() {
170 return None; }
172
173 let new_units = self.units.number + reduction;
174
175 if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
177 return None;
178 }
179
180 Some(Self {
181 units: Amount::new(new_units, self.units.currency.clone()),
182 cost: self.cost.clone(),
183 })
184 }
185
186 #[must_use]
191 pub fn split(&self, take_units: Decimal) -> (Self, Self) {
192 let taken = Self {
193 units: Amount::new(take_units, self.units.currency.clone()),
194 cost: self.cost.clone(),
195 };
196 let remaining = Self {
197 units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
198 cost: self.cost.clone(),
199 };
200 (taken, remaining)
201 }
202}
203
204impl fmt::Display for Position {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 write!(f, "{}", self.units)?;
207 if let Some(cost) = &self.cost {
208 write!(f, " {cost}")?;
209 }
210 Ok(())
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::NaiveDate;
218 use rust_decimal_macros::dec;
219
220 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
221 crate::naive_date(year, month, day).unwrap()
222 }
223
224 #[test]
225 fn test_simple_position() {
226 let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
227 assert_eq!(pos.units.number, dec!(1000.00));
228 assert_eq!(pos.currency(), "USD");
229 assert!(pos.cost.is_none());
230 }
231
232 #[test]
233 fn test_position_with_cost() {
234 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
235 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
236
237 assert_eq!(pos.units.number, dec!(10));
238 assert_eq!(pos.currency(), "AAPL");
239 assert_eq!(pos.cost_currency(), Some("USD"));
240 }
241
242 #[test]
243 fn test_from_posting_resolves_cost_or_simple() {
244 use crate::{CostNumber, CostSpec};
245 use rust_decimal::Decimal;
246
247 let units = Amount::new(dec!(10), "AAPL");
248 let spec = CostSpec::empty()
250 .with_number(CostNumber::PerUnit { value: dec!(150) })
251 .with_currency("USD");
252 let pos = Position::from_posting(&units, Some(&spec), date(2024, 1, 15));
253 assert_eq!(pos.cost_currency(), Some("USD"));
254 let pos = Position::from_posting(&units, None, date(2024, 1, 15));
256 assert!(pos.cost.is_none());
257 let zero = Amount::new(Decimal::ZERO, "AAPL");
259 let total = CostSpec::empty()
260 .with_number(CostNumber::Total { value: dec!(100) })
261 .with_currency("USD");
262 let pos = Position::from_posting(&zero, Some(&total), date(2024, 1, 15));
263 assert!(
264 pos.cost.is_none(),
265 "zero-units Total cost must book uncosted, not panic"
266 );
267 }
268
269 #[test]
270 fn test_book_value() {
271 let cost = Cost::new(dec!(150.00), "USD");
272 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
273
274 let book_value = pos.book_value().unwrap();
275 assert_eq!(book_value.number, dec!(1500.00));
276 assert_eq!(book_value.currency, "USD");
277 }
278
279 #[test]
280 fn test_book_value_no_cost() {
281 let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
282 assert!(pos.book_value().is_none());
283 }
284
285 #[test]
286 fn test_is_empty() {
287 let empty = Position::simple(Amount::zero("USD"));
288 assert!(empty.is_empty());
289
290 let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
291 assert!(!non_empty.is_empty());
292 }
293
294 #[test]
295 fn test_neg() {
296 let pos = Position::simple(Amount::new(dec!(100), "USD"));
297 let neg = pos.neg();
298 assert_eq!(neg.units.number, dec!(-100));
299 }
300
301 #[test]
302 fn test_reduce() {
303 let pos = Position::simple(Amount::new(dec!(100), "USD"));
304
305 let reduced = pos.reduce(dec!(-30)).unwrap();
307 assert_eq!(reduced.units.number, dec!(70));
308
309 assert!(pos.reduce(dec!(30)).is_none());
311
312 assert!(pos.reduce(dec!(-150)).is_none());
314
315 let zero = pos.reduce(dec!(-100)).unwrap();
317 assert!(zero.is_empty());
318 }
319
320 #[test]
321 fn test_split() {
322 let cost = Cost::new(dec!(150.00), "USD");
323 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
324
325 let (taken, remaining) = pos.split(dec!(3));
326 assert_eq!(taken.units.number, dec!(3));
327 assert_eq!(remaining.units.number, dec!(7));
328
329 assert_eq!(taken.cost, pos.cost);
331 assert_eq!(remaining.cost, pos.cost);
332 }
333
334 #[test]
335 fn test_matches_cost_spec() {
336 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
337 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
338
339 assert!(pos.matches_cost_spec(&CostSpec::empty()));
341
342 let spec = CostSpec::empty()
344 .with_number(crate::CostNumber::PerUnit {
345 value: dec!(150.00),
346 })
347 .with_currency("USD");
348 assert!(pos.matches_cost_spec(&spec));
349
350 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
352 value: dec!(160.00),
353 });
354 assert!(!pos.matches_cost_spec(&spec));
355 }
356
357 #[test]
358 fn test_display() {
359 let cost = Cost::new(dec!(150.00), "USD");
360 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
361 let s = format!("{pos}");
362 assert!(s.contains("10 AAPL"));
363 assert!(s.contains("150.00 USD"));
364 }
365}