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]
115 pub fn book_value(&self) -> Option<Amount> {
116 self.cost.as_ref().map(|c| c.total_cost(self.units.number))
117 }
118
119 #[must_use]
125 pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
126 match (&self.cost, spec.is_empty()) {
127 (None, true) => true,
128 (None, false) => false,
129 (Some(cost), _) => spec.matches(cost),
130 }
131 }
132
133 #[must_use]
135 pub fn neg(&self) -> Self {
136 Self {
137 units: -&self.units,
138 cost: self.cost.clone(),
139 }
140 }
141
142 #[must_use]
148 pub fn can_reduce(&self, reduction: &Amount) -> bool {
149 self.units.currency == reduction.currency
150 && self.units.number.signum() != reduction.number.signum()
151 }
152
153 #[must_use]
158 pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
159 if self.units.number.signum() == reduction.signum() {
160 return None; }
162
163 let new_units = self.units.number + reduction;
164
165 if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
167 return None;
168 }
169
170 Some(Self {
171 units: Amount::new(new_units, self.units.currency.clone()),
172 cost: self.cost.clone(),
173 })
174 }
175
176 #[must_use]
181 pub fn split(&self, take_units: Decimal) -> (Self, Self) {
182 let taken = Self {
183 units: Amount::new(take_units, self.units.currency.clone()),
184 cost: self.cost.clone(),
185 };
186 let remaining = Self {
187 units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
188 cost: self.cost.clone(),
189 };
190 (taken, remaining)
191 }
192}
193
194impl fmt::Display for Position {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 write!(f, "{}", self.units)?;
197 if let Some(cost) = &self.cost {
198 write!(f, " {cost}")?;
199 }
200 Ok(())
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::NaiveDate;
208 use rust_decimal_macros::dec;
209
210 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
211 crate::naive_date(year, month, day).unwrap()
212 }
213
214 #[test]
215 fn test_simple_position() {
216 let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
217 assert_eq!(pos.units.number, dec!(1000.00));
218 assert_eq!(pos.currency(), "USD");
219 assert!(pos.cost.is_none());
220 }
221
222 #[test]
223 fn test_position_with_cost() {
224 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
225 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
226
227 assert_eq!(pos.units.number, dec!(10));
228 assert_eq!(pos.currency(), "AAPL");
229 assert_eq!(pos.cost_currency(), Some("USD"));
230 }
231
232 #[test]
233 fn test_from_posting_resolves_cost_or_simple() {
234 use crate::{CostNumber, CostSpec};
235 use rust_decimal::Decimal;
236
237 let units = Amount::new(dec!(10), "AAPL");
238 let spec = CostSpec::empty()
240 .with_number(CostNumber::PerUnit { value: dec!(150) })
241 .with_currency("USD");
242 let pos = Position::from_posting(&units, Some(&spec), date(2024, 1, 15));
243 assert_eq!(pos.cost_currency(), Some("USD"));
244 let pos = Position::from_posting(&units, None, date(2024, 1, 15));
246 assert!(pos.cost.is_none());
247 let zero = Amount::new(Decimal::ZERO, "AAPL");
249 let total = CostSpec::empty()
250 .with_number(CostNumber::Total { value: dec!(100) })
251 .with_currency("USD");
252 let pos = Position::from_posting(&zero, Some(&total), date(2024, 1, 15));
253 assert!(
254 pos.cost.is_none(),
255 "zero-units Total cost must book uncosted, not panic"
256 );
257 }
258
259 #[test]
260 fn test_book_value() {
261 let cost = Cost::new(dec!(150.00), "USD");
262 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
263
264 let book_value = pos.book_value().unwrap();
265 assert_eq!(book_value.number, dec!(1500.00));
266 assert_eq!(book_value.currency, "USD");
267 }
268
269 #[test]
270 fn test_book_value_no_cost() {
271 let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
272 assert!(pos.book_value().is_none());
273 }
274
275 #[test]
276 fn test_is_empty() {
277 let empty = Position::simple(Amount::zero("USD"));
278 assert!(empty.is_empty());
279
280 let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
281 assert!(!non_empty.is_empty());
282 }
283
284 #[test]
285 fn test_neg() {
286 let pos = Position::simple(Amount::new(dec!(100), "USD"));
287 let neg = pos.neg();
288 assert_eq!(neg.units.number, dec!(-100));
289 }
290
291 #[test]
292 fn test_reduce() {
293 let pos = Position::simple(Amount::new(dec!(100), "USD"));
294
295 let reduced = pos.reduce(dec!(-30)).unwrap();
297 assert_eq!(reduced.units.number, dec!(70));
298
299 assert!(pos.reduce(dec!(30)).is_none());
301
302 assert!(pos.reduce(dec!(-150)).is_none());
304
305 let zero = pos.reduce(dec!(-100)).unwrap();
307 assert!(zero.is_empty());
308 }
309
310 #[test]
311 fn test_split() {
312 let cost = Cost::new(dec!(150.00), "USD");
313 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
314
315 let (taken, remaining) = pos.split(dec!(3));
316 assert_eq!(taken.units.number, dec!(3));
317 assert_eq!(remaining.units.number, dec!(7));
318
319 assert_eq!(taken.cost, pos.cost);
321 assert_eq!(remaining.cost, pos.cost);
322 }
323
324 #[test]
325 fn test_matches_cost_spec() {
326 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
327 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
328
329 assert!(pos.matches_cost_spec(&CostSpec::empty()));
331
332 let spec = CostSpec::empty()
334 .with_number(crate::CostNumber::PerUnit {
335 value: dec!(150.00),
336 })
337 .with_currency("USD");
338 assert!(pos.matches_cost_spec(&spec));
339
340 let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
342 value: dec!(160.00),
343 });
344 assert!(!pos.matches_cost_spec(&spec));
345 }
346
347 #[test]
348 fn test_display() {
349 let cost = Cost::new(dec!(150.00), "USD");
350 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
351 let s = format!("{pos}");
352 assert!(s.contains("10 AAPL"));
353 assert!(s.contains("150.00 USD"));
354 }
355}