use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::{Money, Position};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct DesiredAllocation {
allocations: HashMap<String, Decimal>,
}
impl DesiredAllocation {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_map(allocations: HashMap<String, Decimal>) -> Self {
Self { allocations }
}
pub fn set(&mut self, symbol: impl Into<String>, percentage: Decimal) {
self.allocations.insert(symbol.into(), percentage);
}
#[must_use]
pub fn get(&self, symbol: &str) -> Option<Decimal> {
self.allocations.get(symbol).copied()
}
pub fn symbols(&self) -> impl Iterator<Item = &String> {
self.allocations.keys()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &Decimal)> {
self.allocations.iter()
}
#[must_use]
pub fn len(&self) -> usize {
self.allocations.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.allocations.is_empty()
}
#[must_use]
pub fn total_percentage(&self) -> Decimal {
self.allocations.values().sum()
}
#[must_use]
pub fn is_valid(&self) -> bool {
let total = self.total_percentage();
let diff = (total - Decimal::from(100)).abs();
diff < Decimal::new(1, 2) }
#[must_use]
pub fn normalized(&self) -> Self {
let total = self.total_percentage();
if total.is_zero() {
return self.clone();
}
let normalized: HashMap<String, Decimal> = self
.allocations
.iter()
.map(|(k, v)| (k.clone(), *v * Decimal::from(100) / total))
.collect();
Self {
allocations: normalized,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Wallet {
base_currency: String,
cash: Money,
positions: Vec<Position>,
}
impl Wallet {
#[must_use]
pub fn new(base_currency: impl Into<String>) -> Self {
let currency: String = base_currency.into();
Self {
cash: Money::zero(¤cy),
base_currency: currency,
positions: Vec::new(),
}
}
#[must_use]
pub fn with_cash(base_currency: impl Into<String>, cash: Money) -> Self {
let currency: String = base_currency.into();
Self {
cash,
base_currency: currency,
positions: Vec::new(),
}
}
#[must_use]
pub fn base_currency(&self) -> &str {
&self.base_currency
}
#[must_use]
pub const fn cash(&self) -> &Money {
&self.cash
}
#[must_use]
pub fn positions(&self) -> &[Position] {
&self.positions
}
pub fn positions_mut(&mut self) -> &mut Vec<Position> {
&mut self.positions
}
pub fn add_position(&mut self, position: Position) {
if let Some(existing) = self
.positions
.iter_mut()
.find(|p| p.symbol() == position.symbol())
{
let new_qty = existing.quantity() + position.quantity();
existing.update_quantity(new_qty);
existing.update_price(position.current_price().clone());
} else {
self.positions.push(position);
}
}
pub fn remove_position(&mut self, symbol: &str) -> Option<Position> {
if let Some(idx) = self.positions.iter().position(|p| p.symbol() == symbol) {
Some(self.positions.remove(idx))
} else {
None
}
}
#[must_use]
pub fn get_position(&self, symbol: &str) -> Option<&Position> {
self.positions.iter().find(|p| p.symbol() == symbol)
}
pub fn get_position_mut(&mut self, symbol: &str) -> Option<&mut Position> {
self.positions.iter_mut().find(|p| p.symbol() == symbol)
}
pub fn set_cash(&mut self, cash: Money) {
self.cash = cash;
}
pub fn add_cash(&mut self, amount: Money) {
if let Some(new_cash) = self.cash.checked_add(&amount) {
self.cash = new_cash;
}
}
pub fn subtract_cash(&mut self, amount: Money) {
if let Some(new_cash) = self.cash.checked_sub(&amount) {
self.cash = new_cash;
}
}
#[must_use]
pub fn total_positions_value(&self) -> Money {
let total: Decimal = self
.positions
.iter()
.map(|p| p.market_value().amount())
.sum();
Money::new(total, &self.base_currency)
}
#[must_use]
pub fn total_value(&self) -> Money {
let positions_value = self.total_positions_value();
self.cash
.checked_add(&positions_value)
.unwrap_or_else(|| positions_value)
}
#[must_use]
pub fn current_allocation(&self) -> DesiredAllocation {
let total = self.total_value().amount();
if total.is_zero() {
return DesiredAllocation::new();
}
let allocations: HashMap<String, Decimal> = self
.positions
.iter()
.map(|p| {
let pct = p.market_value().amount() / total * Decimal::from(100);
(p.symbol().to_string(), pct)
})
.collect();
DesiredAllocation::from_map(allocations)
}
#[must_use]
pub fn cash_percentage(&self) -> Decimal {
let total = self.total_value().amount();
if total.is_zero() {
return Decimal::ZERO;
}
self.cash.amount() / total * Decimal::from(100)
}
#[must_use]
pub fn position_count(&self) -> usize {
self.positions.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_usd(amount: Decimal) -> Money {
Money::new(amount, "USD")
}
#[test]
fn test_desired_allocation() {
let mut alloc = DesiredAllocation::new();
alloc.set("AAPL", dec!(30));
alloc.set("GOOGL", dec!(30));
alloc.set("MSFT", dec!(40));
assert_eq!(alloc.get("AAPL"), Some(dec!(30)));
assert_eq!(alloc.total_percentage(), dec!(100));
assert!(alloc.is_valid());
}
#[test]
fn test_allocation_normalization() {
let mut alloc = DesiredAllocation::new();
alloc.set("A", dec!(10));
alloc.set("B", dec!(20));
alloc.set("C", dec!(30));
let normalized = alloc.normalized();
let total = normalized.total_percentage();
assert!((total - dec!(100)).abs() < dec!(0.01));
}
#[test]
fn test_wallet_creation() {
let wallet = Wallet::with_cash("USD", make_usd(dec!(10000)));
assert_eq!(wallet.base_currency(), "USD");
assert_eq!(wallet.cash().amount(), dec!(10000));
assert_eq!(wallet.positions().len(), 0);
}
#[test]
fn test_wallet_add_position() {
let mut wallet = Wallet::with_cash("USD", make_usd(dec!(5000)));
let pos = Position::new("AAPL", "NASDAQ", dec!(10), make_usd(dec!(150)));
wallet.add_position(pos);
assert_eq!(wallet.position_count(), 1);
assert_eq!(wallet.get_position("AAPL").unwrap().quantity(), dec!(10));
}
#[test]
fn test_wallet_total_value() {
let mut wallet = Wallet::with_cash("USD", make_usd(dec!(5000)));
wallet.add_position(Position::new(
"AAPL",
"NASDAQ",
dec!(10),
make_usd(dec!(150)),
));
wallet.add_position(Position::new(
"GOOGL",
"NASDAQ",
dec!(5),
make_usd(dec!(100)),
));
assert_eq!(wallet.total_value().amount(), dec!(7000));
}
#[test]
fn test_wallet_current_allocation() {
let mut wallet = Wallet::with_cash("USD", make_usd(dec!(0)));
wallet.add_position(Position::new(
"AAPL",
"NASDAQ",
dec!(10),
make_usd(dec!(100)),
));
wallet.add_position(Position::new(
"GOOGL",
"NASDAQ",
dec!(10),
make_usd(dec!(100)),
));
let alloc = wallet.current_allocation();
assert_eq!(alloc.get("AAPL"), Some(dec!(50)));
assert_eq!(alloc.get("GOOGL"), Some(dec!(50)));
}
#[test]
fn test_wallet_merge_positions() {
let mut wallet = Wallet::new("USD");
wallet.add_position(Position::new(
"AAPL",
"NASDAQ",
dec!(10),
make_usd(dec!(150)),
));
wallet.add_position(Position::new(
"AAPL",
"NASDAQ",
dec!(5),
make_usd(dec!(155)),
));
assert_eq!(wallet.position_count(), 1);
assert_eq!(wallet.get_position("AAPL").unwrap().quantity(), dec!(15));
}
}