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]
74 pub const fn is_empty(&self) -> bool {
75 self.units.is_zero()
76 }
77
78 #[must_use]
80 pub fn currency(&self) -> &str {
81 &self.units.currency
82 }
83
84 #[must_use]
86 pub fn cost_currency(&self) -> Option<&str> {
87 self.cost.as_ref().map(|c| c.currency.as_str())
88 }
89
90 #[must_use]
94 pub fn book_value(&self) -> Option<Amount> {
95 self.cost.as_ref().map(|c| c.total_cost(self.units.number))
96 }
97
98 #[must_use]
104 pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
105 match (&self.cost, spec.is_empty()) {
106 (None, true) => true,
107 (None, false) => false,
108 (Some(cost), _) => spec.matches(cost),
109 }
110 }
111
112 #[must_use]
114 pub fn neg(&self) -> Self {
115 Self {
116 units: -&self.units,
117 cost: self.cost.clone(),
118 }
119 }
120
121 #[must_use]
127 pub fn can_reduce(&self, reduction: &Amount) -> bool {
128 self.units.currency == reduction.currency
129 && self.units.number.signum() != reduction.number.signum()
130 }
131
132 #[must_use]
137 pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
138 if self.units.number.signum() == reduction.signum() {
139 return None; }
141
142 let new_units = self.units.number + reduction;
143
144 if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
146 return None;
147 }
148
149 Some(Self {
150 units: Amount::new(new_units, self.units.currency.clone()),
151 cost: self.cost.clone(),
152 })
153 }
154
155 #[must_use]
160 pub fn split(&self, take_units: Decimal) -> (Self, Self) {
161 let taken = Self {
162 units: Amount::new(take_units, self.units.currency.clone()),
163 cost: self.cost.clone(),
164 };
165 let remaining = Self {
166 units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
167 cost: self.cost.clone(),
168 };
169 (taken, remaining)
170 }
171}
172
173impl fmt::Display for Position {
174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175 write!(f, "{}", self.units)?;
176 if let Some(cost) = &self.cost {
177 write!(f, " {cost}")?;
178 }
179 Ok(())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::NaiveDate;
187 use rust_decimal_macros::dec;
188
189 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
190 crate::naive_date(year, month, day).unwrap()
191 }
192
193 #[test]
194 fn test_simple_position() {
195 let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
196 assert_eq!(pos.units.number, dec!(1000.00));
197 assert_eq!(pos.currency(), "USD");
198 assert!(pos.cost.is_none());
199 }
200
201 #[test]
202 fn test_position_with_cost() {
203 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
204 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
205
206 assert_eq!(pos.units.number, dec!(10));
207 assert_eq!(pos.currency(), "AAPL");
208 assert_eq!(pos.cost_currency(), Some("USD"));
209 }
210
211 #[test]
212 fn test_book_value() {
213 let cost = Cost::new(dec!(150.00), "USD");
214 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
215
216 let book_value = pos.book_value().unwrap();
217 assert_eq!(book_value.number, dec!(1500.00));
218 assert_eq!(book_value.currency, "USD");
219 }
220
221 #[test]
222 fn test_book_value_no_cost() {
223 let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
224 assert!(pos.book_value().is_none());
225 }
226
227 #[test]
228 fn test_is_empty() {
229 let empty = Position::simple(Amount::zero("USD"));
230 assert!(empty.is_empty());
231
232 let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
233 assert!(!non_empty.is_empty());
234 }
235
236 #[test]
237 fn test_neg() {
238 let pos = Position::simple(Amount::new(dec!(100), "USD"));
239 let neg = pos.neg();
240 assert_eq!(neg.units.number, dec!(-100));
241 }
242
243 #[test]
244 fn test_reduce() {
245 let pos = Position::simple(Amount::new(dec!(100), "USD"));
246
247 let reduced = pos.reduce(dec!(-30)).unwrap();
249 assert_eq!(reduced.units.number, dec!(70));
250
251 assert!(pos.reduce(dec!(30)).is_none());
253
254 assert!(pos.reduce(dec!(-150)).is_none());
256
257 let zero = pos.reduce(dec!(-100)).unwrap();
259 assert!(zero.is_empty());
260 }
261
262 #[test]
263 fn test_split() {
264 let cost = Cost::new(dec!(150.00), "USD");
265 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
266
267 let (taken, remaining) = pos.split(dec!(3));
268 assert_eq!(taken.units.number, dec!(3));
269 assert_eq!(remaining.units.number, dec!(7));
270
271 assert_eq!(taken.cost, pos.cost);
273 assert_eq!(remaining.cost, pos.cost);
274 }
275
276 #[test]
277 fn test_matches_cost_spec() {
278 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
279 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
280
281 assert!(pos.matches_cost_spec(&CostSpec::empty()));
283
284 let spec = CostSpec::empty()
286 .with_number_per(dec!(150.00))
287 .with_currency("USD");
288 assert!(pos.matches_cost_spec(&spec));
289
290 let spec = CostSpec::empty().with_number_per(dec!(160.00));
292 assert!(!pos.matches_cost_spec(&spec));
293 }
294
295 #[test]
296 fn test_display() {
297 let cost = Cost::new(dec!(150.00), "USD");
298 let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
299 let s = format!("{pos}");
300 assert!(s.contains("10 AAPL"));
301 assert!(s.contains("150.00 USD"));
302 }
303}