1use 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)]
41#[cfg_attr(
42 feature = "rkyv",
43 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
44)]
45pub struct Position {
46 pub units: Amount,
48 pub cost: Option<Cost>,
50}
51
52impl Position {
53 #[must_use]
57 pub const fn simple(units: Amount) -> Self {
58 Self { units, cost: None }
59 }
60
61 #[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 #[must_use]
75 pub const fn is_empty(&self) -> bool {
76 self.units.is_zero()
77 }
78
79 #[must_use]
81 pub fn currency(&self) -> &str {
82 &self.units.currency
83 }
84
85 #[must_use]
87 pub fn cost_currency(&self) -> Option<&str> {
88 self.cost.as_ref().map(|c| c.currency.as_str())
89 }
90
91 #[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 #[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 #[must_use]
115 pub fn neg(&self) -> Self {
116 Self {
117 units: -&self.units,
118 cost: self.cost.clone(),
119 }
120 }
121
122 #[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 #[must_use]
138 pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
139 if self.units.number.signum() == reduction.signum() {
140 return None; }
142
143 let new_units = self.units.number + reduction;
144
145 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 #[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 let reduced = pos.reduce(dec!(-30)).unwrap();
250 assert_eq!(reduced.units.number, dec!(70));
251
252 assert!(pos.reduce(dec!(30)).is_none());
254
255 assert!(pos.reduce(dec!(-150)).is_none());
257
258 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 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 assert!(pos.matches_cost_spec(&CostSpec::empty()));
284
285 let spec = CostSpec::empty()
287 .with_number_per(dec!(150.00))
288 .with_currency("USD");
289 assert!(pos.matches_cost_spec(&spec));
290
291 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}