use rust_decimal::Decimal;
use rust_decimal::prelude::Signed;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::{Amount, Cost, CostSpec};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(
feature = "rkyv",
derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
)]
pub struct Position {
pub units: Amount,
pub cost: Option<Cost>,
}
impl Position {
#[must_use]
pub const fn simple(units: Amount) -> Self {
Self { units, cost: None }
}
#[must_use]
pub const fn with_cost(units: Amount, cost: Cost) -> Self {
Self {
units,
cost: Some(cost),
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.units.is_zero()
}
#[must_use]
pub fn currency(&self) -> &str {
&self.units.currency
}
#[must_use]
pub fn cost_currency(&self) -> Option<&str> {
self.cost.as_ref().map(|c| c.currency.as_str())
}
#[must_use]
pub fn book_value(&self) -> Option<Amount> {
self.cost.as_ref().map(|c| c.total_cost(self.units.number))
}
#[must_use]
pub fn matches_cost_spec(&self, spec: &CostSpec) -> bool {
match (&self.cost, spec.is_empty()) {
(None, true) => true,
(None, false) => false,
(Some(cost), _) => spec.matches(cost),
}
}
#[must_use]
pub fn neg(&self) -> Self {
Self {
units: -&self.units,
cost: self.cost.clone(),
}
}
#[must_use]
pub fn can_reduce(&self, reduction: &Amount) -> bool {
self.units.currency == reduction.currency
&& self.units.number.signum() != reduction.number.signum()
}
#[must_use]
pub fn reduce(&self, reduction: Decimal) -> Option<Self> {
if self.units.number.signum() == reduction.signum() {
return None; }
let new_units = self.units.number + reduction;
if new_units.signum() != self.units.number.signum() && !new_units.is_zero() {
return None;
}
Some(Self {
units: Amount::new(new_units, self.units.currency.clone()),
cost: self.cost.clone(),
})
}
#[must_use]
pub fn split(&self, take_units: Decimal) -> (Self, Self) {
let taken = Self {
units: Amount::new(take_units, self.units.currency.clone()),
cost: self.cost.clone(),
};
let remaining = Self {
units: Amount::new(self.units.number - take_units, self.units.currency.clone()),
cost: self.cost.clone(),
};
(taken, remaining)
}
}
impl fmt::Display for Position {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.units)?;
if let Some(cost) = &self.cost {
write!(f, " {cost}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NaiveDate;
use rust_decimal_macros::dec;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
crate::naive_date(year, month, day).unwrap()
}
#[test]
fn test_simple_position() {
let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
assert_eq!(pos.units.number, dec!(1000.00));
assert_eq!(pos.currency(), "USD");
assert!(pos.cost.is_none());
}
#[test]
fn test_position_with_cost() {
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
assert_eq!(pos.units.number, dec!(10));
assert_eq!(pos.currency(), "AAPL");
assert_eq!(pos.cost_currency(), Some("USD"));
}
#[test]
fn test_book_value() {
let cost = Cost::new(dec!(150.00), "USD");
let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
let book_value = pos.book_value().unwrap();
assert_eq!(book_value.number, dec!(1500.00));
assert_eq!(book_value.currency, "USD");
}
#[test]
fn test_book_value_no_cost() {
let pos = Position::simple(Amount::new(dec!(1000.00), "USD"));
assert!(pos.book_value().is_none());
}
#[test]
fn test_is_empty() {
let empty = Position::simple(Amount::zero("USD"));
assert!(empty.is_empty());
let non_empty = Position::simple(Amount::new(dec!(100), "USD"));
assert!(!non_empty.is_empty());
}
#[test]
fn test_neg() {
let pos = Position::simple(Amount::new(dec!(100), "USD"));
let neg = pos.neg();
assert_eq!(neg.units.number, dec!(-100));
}
#[test]
fn test_reduce() {
let pos = Position::simple(Amount::new(dec!(100), "USD"));
let reduced = pos.reduce(dec!(-30)).unwrap();
assert_eq!(reduced.units.number, dec!(70));
assert!(pos.reduce(dec!(30)).is_none());
assert!(pos.reduce(dec!(-150)).is_none());
let zero = pos.reduce(dec!(-100)).unwrap();
assert!(zero.is_empty());
}
#[test]
fn test_split() {
let cost = Cost::new(dec!(150.00), "USD");
let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
let (taken, remaining) = pos.split(dec!(3));
assert_eq!(taken.units.number, dec!(3));
assert_eq!(remaining.units.number, dec!(7));
assert_eq!(taken.cost, pos.cost);
assert_eq!(remaining.cost, pos.cost);
}
#[test]
fn test_matches_cost_spec() {
let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
assert!(pos.matches_cost_spec(&CostSpec::empty()));
let spec = CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD");
assert!(pos.matches_cost_spec(&spec));
let spec = CostSpec::empty().with_number_per(dec!(160.00));
assert!(!pos.matches_cost_spec(&spec));
}
#[test]
fn test_display() {
let cost = Cost::new(dec!(150.00), "USD");
let pos = Position::with_cost(Amount::new(dec!(10), "AAPL"), cost);
let s = format!("{pos}");
assert!(s.contains("10 AAPL"));
assert!(s.contains("150.00 USD"));
}
}