use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::domain::{Money, OrderDirection};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RebalanceAction {
pub symbol: String,
pub exchange: String,
pub direction: OrderDirection,
pub lots: u32,
pub estimated_price: Money,
pub estimated_value: Money,
pub current_percent: Decimal,
pub target_percent: Decimal,
pub priority: u32,
}
impl RebalanceAction {
#[must_use]
pub const fn is_buy(&self) -> bool {
matches!(self.direction, OrderDirection::Buy)
}
#[must_use]
pub const fn is_sell(&self) -> bool {
matches!(self.direction, OrderDirection::Sell)
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RebalancePlan {
pub actions: Vec<RebalanceAction>,
pub total_sell_value: Money,
pub total_buy_value: Money,
pub trade_count: usize,
pub dry_run: bool,
pub empty_reason: Option<String>,
}
impl RebalancePlan {
#[must_use]
pub fn new(currency: &str) -> Self {
Self {
actions: Vec::new(),
total_sell_value: Money::zero(currency),
total_buy_value: Money::zero(currency),
trade_count: 0,
dry_run: false,
empty_reason: None,
}
}
#[must_use]
pub fn empty_with_reason(currency: &str, reason: impl Into<String>) -> Self {
Self {
actions: Vec::new(),
total_sell_value: Money::zero(currency),
total_buy_value: Money::zero(currency),
trade_count: 0,
dry_run: false,
empty_reason: Some(reason.into()),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
pub fn sells(&self) -> impl Iterator<Item = &RebalanceAction> {
self.actions.iter().filter(|a| a.is_sell())
}
pub fn buys(&self) -> impl Iterator<Item = &RebalanceAction> {
self.actions.iter().filter(|a| a.is_buy())
}
#[must_use]
pub fn sell_count(&self) -> usize {
self.actions.iter().filter(|a| a.is_sell()).count()
}
#[must_use]
pub fn buy_count(&self) -> usize {
self.actions.iter().filter(|a| a.is_buy()).count()
}
pub fn add_action(&mut self, action: RebalanceAction) {
if action.is_sell() {
if let Some(new_total) = self.total_sell_value.checked_add(&action.estimated_value) {
self.total_sell_value = new_total;
}
} else if let Some(new_total) = self.total_buy_value.checked_add(&action.estimated_value) {
self.total_buy_value = new_total;
}
self.actions.push(action);
self.trade_count = self.actions.len();
}
pub fn set_dry_run(&mut self, dry_run: bool) {
self.dry_run = dry_run;
}
pub fn sort_by_priority(&mut self) {
self.actions.sort_by_key(|a| a.priority);
}
#[must_use]
pub fn summary(&self) -> String {
if self.is_empty() {
return self
.empty_reason
.clone()
.unwrap_or_else(|| "No rebalancing needed".into());
}
format!(
"{} trades ({} sells for {}, {} buys for {})",
self.trade_count,
self.sell_count(),
self.total_sell_value,
self.buy_count(),
self.total_buy_value
)
}
}
#[allow(dead_code)]
pub struct RebalancePlanBuilder {
currency: String,
actions: Vec<RebalanceAction>,
dry_run: bool,
}
#[allow(dead_code)]
impl RebalancePlanBuilder {
#[must_use]
pub fn new(currency: impl Into<String>) -> Self {
Self {
currency: currency.into(),
actions: Vec::new(),
dry_run: false,
}
}
#[must_use]
pub fn sell(
mut self,
symbol: impl Into<String>,
exchange: impl Into<String>,
lots: u32,
price: Money,
current_percent: Decimal,
target_percent: Decimal,
) -> Self {
let estimated_value = price.multiply(Decimal::from(lots));
self.actions.push(RebalanceAction {
symbol: symbol.into(),
exchange: exchange.into(),
direction: OrderDirection::Sell,
lots,
estimated_price: price,
estimated_value,
current_percent,
target_percent,
priority: self.actions.len() as u32,
});
self
}
#[must_use]
pub fn buy(
mut self,
symbol: impl Into<String>,
exchange: impl Into<String>,
lots: u32,
price: Money,
current_percent: Decimal,
target_percent: Decimal,
) -> Self {
let estimated_value = price.multiply(Decimal::from(lots));
self.actions.push(RebalanceAction {
symbol: symbol.into(),
exchange: exchange.into(),
direction: OrderDirection::Buy,
lots,
estimated_price: price,
estimated_value,
current_percent,
target_percent,
priority: self.actions.len() as u32,
});
self
}
#[must_use]
pub const fn dry_run(mut self) -> Self {
self.dry_run = true;
self
}
#[must_use]
pub fn build(self) -> RebalancePlan {
let mut plan = RebalancePlan::new(&self.currency);
plan.dry_run = self.dry_run;
for action in self.actions {
plan.add_action(action);
}
plan.sort_by_priority();
plan
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_usd(amount: Decimal) -> Money {
Money::new(amount, "USD")
}
#[test]
fn test_rebalance_plan_builder() {
let plan = RebalancePlanBuilder::new("USD")
.sell(
"GOOGL",
"NASDAQ",
5,
make_usd(dec!(100)),
dec!(40),
dec!(30),
)
.buy("AAPL", "NASDAQ", 3, make_usd(dec!(150)), dec!(20), dec!(30))
.build();
assert_eq!(plan.trade_count, 2);
assert_eq!(plan.sell_count(), 1);
assert_eq!(plan.buy_count(), 1);
assert_eq!(plan.total_sell_value.amount(), dec!(500)); assert_eq!(plan.total_buy_value.amount(), dec!(450)); }
#[test]
fn test_empty_plan() {
let plan = RebalancePlan::empty_with_reason("USD", "Portfolio already balanced");
assert!(plan.is_empty());
assert_eq!(plan.empty_reason, Some("Portfolio already balanced".into()));
}
#[test]
fn test_plan_summary() {
let plan = RebalancePlanBuilder::new("USD")
.sell("A", "X", 10, make_usd(dec!(100)), dec!(30), dec!(20))
.buy("B", "X", 5, make_usd(dec!(200)), dec!(10), dec!(20))
.build();
let summary = plan.summary();
assert!(summary.contains("2 trades"));
assert!(summary.contains("1 sells"));
assert!(summary.contains("1 buys"));
}
#[test]
fn test_action_direction() {
let buy_action = RebalanceAction {
symbol: "A".into(),
exchange: "X".into(),
direction: OrderDirection::Buy,
lots: 1,
estimated_price: make_usd(dec!(100)),
estimated_value: make_usd(dec!(100)),
current_percent: dec!(10),
target_percent: dec!(20),
priority: 0,
};
assert!(buy_action.is_buy());
assert!(!buy_action.is_sell());
}
}