use std::collections::HashMap;
use std::fmt;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use super::Money;
use crate::strategy::RebalanceAction;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlannedOrder {
pub symbol: String,
pub exchange: String,
pub direction: String,
pub lots: u32,
pub estimated_price: Money,
pub estimated_value: Money,
pub current_percent: Decimal,
pub target_percent: Decimal,
pub account_id: String,
pub reason: Option<String>,
}
impl PlannedOrder {
#[must_use]
pub fn from_rebalance_action(action: &RebalanceAction, account_id: &str) -> Self {
Self {
symbol: action.symbol.clone(),
exchange: action.exchange.clone(),
direction: if action.is_buy() {
"BUY".to_string()
} else {
"SELL".to_string()
},
lots: action.lots,
estimated_price: action.estimated_price.clone(),
estimated_value: action.estimated_value.clone(),
current_percent: action.current_percent,
target_percent: action.target_percent,
account_id: account_id.to_string(),
reason: None,
}
}
#[must_use]
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = Some(reason.into());
self
}
}
impl fmt::Display for PlannedOrder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} lots of {} @ {} (value: {}) [{:.2}% -> {:.2}%]",
self.direction,
self.lots,
self.symbol,
self.estimated_price,
self.estimated_value,
self.current_percent,
self.target_percent
)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlannedOrders {
orders: HashMap<String, Vec<PlannedOrder>>,
total_buy_value: Option<Money>,
total_sell_value: Option<Money>,
}
impl PlannedOrders {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_order(&mut self, order: PlannedOrder) {
let account_id = order.account_id.clone();
self.orders.entry(account_id).or_default().push(order);
}
pub fn add_orders(&mut self, account_id: &str, orders: impl IntoIterator<Item = PlannedOrder>) {
for mut order in orders {
order.account_id = account_id.to_string();
self.add_order(order);
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.orders.is_empty() || self.orders.values().all(|v| v.is_empty())
}
#[must_use]
pub fn total_count(&self) -> usize {
self.orders.values().map(Vec::len).sum()
}
#[must_use]
pub fn account_count(&self) -> usize {
self.orders.values().filter(|v| !v.is_empty()).count()
}
pub fn accounts(&self) -> impl Iterator<Item = &str> {
self.orders.keys().map(String::as_str)
}
#[must_use]
pub fn orders_for_account(&self, account_id: &str) -> Option<&[PlannedOrder]> {
self.orders.get(account_id).map(Vec::as_slice)
}
pub fn all_orders(&self) -> impl Iterator<Item = &PlannedOrder> {
self.orders.values().flat_map(|v| v.iter())
}
pub fn set_total_buy_value(&mut self, value: Money) {
self.total_buy_value = Some(value);
}
pub fn set_total_sell_value(&mut self, value: Money) {
self.total_sell_value = Some(value);
}
#[must_use]
pub fn total_buy_value(&self) -> Option<&Money> {
self.total_buy_value.as_ref()
}
#[must_use]
pub fn total_sell_value(&self) -> Option<&Money> {
self.total_sell_value.as_ref()
}
pub fn display(&self) {
if self.is_empty() {
println!("\n📋 PLAN MODE: No orders would be placed");
println!(" Portfolio is already balanced within tolerance.");
return;
}
println!("\n╔════════════════════════════════════════════════════════════════╗");
println!("║ 📋 PLANNED ORDERS ║");
println!("║ (Plan mode - no actual orders will be placed) ║");
println!("╚════════════════════════════════════════════════════════════════╝\n");
for account_id in self.accounts() {
if let Some(orders) = self.orders_for_account(account_id) {
if orders.is_empty() {
continue;
}
println!("Account: {}", account_id);
println!("{}", "-".repeat(60));
let buys: Vec<_> = orders.iter().filter(|o| o.direction == "BUY").collect();
let sells: Vec<_> = orders.iter().filter(|o| o.direction == "SELL").collect();
if !sells.is_empty() {
println!(" SELL orders:");
for order in &sells {
println!(
" • {} {} lots @ {} = {}",
order.symbol, order.lots, order.estimated_price, order.estimated_value
);
println!(
" Allocation: {:.2}% → {:.2}%",
order.current_percent, order.target_percent
);
}
}
if !buys.is_empty() {
println!(" BUY orders:");
for order in &buys {
println!(
" • {} {} lots @ {} = {}",
order.symbol, order.lots, order.estimated_price, order.estimated_value
);
println!(
" Allocation: {:.2}% → {:.2}%",
order.current_percent, order.target_percent
);
}
}
println!();
}
}
println!("═══════════════════════════════════════════════════════════════");
println!("SUMMARY:");
println!(" Total accounts: {}", self.account_count());
println!(" Total orders: {}", self.total_count());
if let Some(sell_value) = &self.total_sell_value {
println!(" Total sells: {}", sell_value);
}
if let Some(buy_value) = &self.total_buy_value {
println!(" Total buys: {}", buy_value);
}
println!("═══════════════════════════════════════════════════════════════\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_usd(amount: Decimal) -> Money {
Money::new(amount, "USD")
}
#[test]
fn test_planned_order_display() {
let order = PlannedOrder {
symbol: "AAPL".to_string(),
exchange: "NASDAQ".to_string(),
direction: "BUY".to_string(),
lots: 10,
estimated_price: make_usd(dec!(150)),
estimated_value: make_usd(dec!(1500)),
current_percent: dec!(20),
target_percent: dec!(30),
account_id: "acc1".to_string(),
reason: None,
};
let display = format!("{order}");
assert!(display.contains("BUY"));
assert!(display.contains("10 lots"));
assert!(display.contains("AAPL"));
assert!(display.contains("20.00%"));
assert!(display.contains("30.00%"));
}
#[test]
fn test_planned_orders_collection() {
let mut planned = PlannedOrders::new();
assert!(planned.is_empty());
assert_eq!(planned.total_count(), 0);
let order1 = PlannedOrder {
symbol: "AAPL".to_string(),
exchange: "NASDAQ".to_string(),
direction: "BUY".to_string(),
lots: 10,
estimated_price: make_usd(dec!(150)),
estimated_value: make_usd(dec!(1500)),
current_percent: dec!(20),
target_percent: dec!(30),
account_id: "acc1".to_string(),
reason: None,
};
let order2 = PlannedOrder {
symbol: "GOOGL".to_string(),
exchange: "NASDAQ".to_string(),
direction: "SELL".to_string(),
lots: 5,
estimated_price: make_usd(dec!(2800)),
estimated_value: make_usd(dec!(14000)),
current_percent: dec!(40),
target_percent: dec!(25),
account_id: "acc1".to_string(),
reason: None,
};
planned.add_order(order1);
planned.add_order(order2);
assert!(!planned.is_empty());
assert_eq!(planned.total_count(), 2);
assert_eq!(planned.account_count(), 1);
let acc_orders = planned.orders_for_account("acc1").unwrap();
assert_eq!(acc_orders.len(), 2);
}
#[test]
fn test_planned_order_with_reason() {
let order = PlannedOrder {
symbol: "AAPL".to_string(),
exchange: "NASDAQ".to_string(),
direction: "BUY".to_string(),
lots: 10,
estimated_price: make_usd(dec!(150)),
estimated_value: make_usd(dec!(1500)),
current_percent: dec!(20),
target_percent: dec!(30),
account_id: "acc1".to_string(),
reason: None,
};
let order = order.with_reason("Underweight in portfolio");
assert_eq!(order.reason, Some("Underweight in portfolio".to_string()));
}
#[test]
fn test_planned_orders_multiple_accounts() {
let mut planned = PlannedOrders::new();
let order1 = PlannedOrder {
symbol: "AAPL".to_string(),
exchange: "NASDAQ".to_string(),
direction: "BUY".to_string(),
lots: 10,
estimated_price: make_usd(dec!(150)),
estimated_value: make_usd(dec!(1500)),
current_percent: dec!(20),
target_percent: dec!(30),
account_id: "acc1".to_string(),
reason: None,
};
let order2 = PlannedOrder {
symbol: "MSFT".to_string(),
exchange: "NASDAQ".to_string(),
direction: "BUY".to_string(),
lots: 5,
estimated_price: make_usd(dec!(400)),
estimated_value: make_usd(dec!(2000)),
current_percent: dec!(10),
target_percent: dec!(25),
account_id: "acc2".to_string(),
reason: None,
};
planned.add_order(order1);
planned.add_order(order2);
assert_eq!(planned.account_count(), 2);
assert_eq!(planned.total_count(), 2);
assert!(planned.orders_for_account("acc1").is_some());
assert!(planned.orders_for_account("acc2").is_some());
assert!(planned.orders_for_account("acc3").is_none());
}
}