summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-06-25 22:36:55 -0600
committermo khan <mo@mokhan.ca>2025-06-25 22:36:55 -0600
commit3d2c137ce82efc33779cff9d20ae1ada83d27ece (patch)
tree378657873c502eb1168a33cc82a9c8808952a487
parent8c16c1f0f348bbb44dd06452943262e58cba52e0 (diff)
feat: add portfolio rebalancing tool
- Calculate exact trades needed to reach target allocation - Support multiple strategies: conservative, balanced, growth, aggressive - Canadian Couch Potato portfolio support - Show specific buy/sell recommendations with share counts - Calculate tax implications for each trade - Identify best accounts for tax efficiency - Warn when insufficient cash for rebalancing - Customizable rebalancing threshold (default 5%) Usage: cargo run -- investment --rebalance cargo run -- investment --rebalance --strategy growth cargo run -- investment --rebalance --strategy couch-potato --threshold 3 The tool helps optimize your portfolio allocation with specific, actionable trades! ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--src/cli.rs6
-rw-r--r--src/main.rs54
-rw-r--r--src/rebalance.rs541
3 files changed, 599 insertions, 2 deletions
diff --git a/src/cli.rs b/src/cli.rs
index 1e4778b..51e04cc 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -103,5 +103,11 @@ pub enum Commands {
dashboard: bool,
#[arg(long, help = "Show position-level performance analysis")]
position_analysis: bool,
+ #[arg(long, help = "Analyze portfolio rebalancing needs")]
+ rebalance: bool,
+ #[arg(long, help = "Target allocation strategy: conservative, balanced, growth, aggressive")]
+ strategy: Option<String>,
+ #[arg(long, default_value = "5.0", help = "Rebalancing threshold percentage")]
+ threshold: f64,
},
}
diff --git a/src/main.rs b/src/main.rs
index ba50d25..3eb8803 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,6 +8,7 @@ mod model;
mod parser;
mod smart_import;
mod performance;
+mod rebalance;
use analytics::{get_spending_trends, get_net_worth_over_time, get_category_insights, generate_spending_recommendations, create_simple_chart};
use clap::Parser;
@@ -412,7 +413,7 @@ async fn main() -> anyhow::Result<()> {
println!(" --months Number of months to analyze (default: 12)");
}
}
- Commands::Investment { portfolio, net_worth, allocation, recommendations, sync, performance, import_wealthsimple, export_csv, tax_year, dashboard, position_analysis } => {
+ Commands::Investment { portfolio, net_worth, allocation, recommendations, sync, performance, import_wealthsimple, export_csv, tax_year, dashboard, position_analysis, rebalance, strategy, threshold } => {
if let Some(directory) = import_wealthsimple {
println!("๐Ÿ“ฅ Importing WealthSimple CSV files from: {}", directory);
match import_wealthsimple_directory(&directory) {
@@ -918,8 +919,56 @@ async fn main() -> anyhow::Result<()> {
println!();
}
+ // Portfolio Rebalancing
+ if rebalance {
+ println!("โš–๏ธ Portfolio Rebalancing Analysis");
+ println!("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•");
+ println!();
+
+ // Get target allocation based on strategy
+ let strategy_name = strategy.as_deref().unwrap_or("balanced");
+ let targets = if strategy_name == "couch-potato" {
+ rebalance::get_couch_potato_allocation("classic")
+ } else {
+ rebalance::get_preset_allocations(strategy_name)
+ };
+
+ println!("๐Ÿ“‹ Target Strategy: {} allocation", strategy_name);
+ println!("๐ŸŽฏ Rebalancing Threshold: {}%", threshold);
+ println!();
+
+ // Calculate rebalancing needs
+ let analysis = rebalance::calculate_rebalancing_trades(&portfolios, &targets, threshold);
+ let summary = rebalance::generate_rebalancing_summary(&analysis);
+ println!("{}", summary);
+
+ // Additional insights
+ if !analysis.trades_needed.is_empty() {
+ let total_cash: f64 = portfolios.iter().map(|p| p.cash_balance).sum();
+ let buy_amount: f64 = analysis.trades_needed.iter()
+ .filter(|t| t.action == rebalance::TradeAction::Buy)
+ .map(|t| t.amount)
+ .sum();
+
+ if buy_amount > total_cash {
+ println!("โš ๏ธ Warning: Insufficient cash for all buy orders");
+ println!(" Cash available: ${:.2}", total_cash);
+ println!(" Cash needed: ${:.2}", buy_amount);
+ println!(" Consider selling positions first or adding funds");
+ }
+
+ println!("\n๐Ÿ’ก Rebalancing Tips:");
+ println!(" โ€ข Execute sells in taxable accounts last (tax implications)");
+ println!(" โ€ข Use limit orders to reduce market impact");
+ println!(" โ€ข Consider rebalancing with new contributions instead");
+ println!(" โ€ข Rebalance quarterly or when deviation exceeds {}%", threshold);
+ }
+
+ println!();
+ }
+
// Default behavior if no flags specified
- if !portfolio && !net_worth && !allocation && !recommendations && !sync && !performance && import_wealthsimple.is_none() && export_csv.is_none() && !dashboard && !position_analysis {
+ if !portfolio && !net_worth && !allocation && !recommendations && !sync && !performance && import_wealthsimple.is_none() && export_csv.is_none() && !dashboard && !position_analysis && !rebalance {
println!("๐Ÿ“ˆ Investment Analysis Overview");
println!("Use one of these flags for detailed analysis:");
println!(" --portfolio Show portfolio overview and performance");
@@ -930,6 +979,7 @@ async fn main() -> anyhow::Result<()> {
println!(" --sync Sync data from broker APIs");
println!(" --dashboard Show performance dashboard with benchmarks");
println!(" --position-analysis Detailed position-level performance");
+ println!(" --rebalance Calculate trades needed to rebalance portfolio");
println!(" --import-wealthsimple <DIR> Import WealthSimple CSV files");
println!(" --export-csv <DIR> Export investment data to CSV files");
println!(" --tax-year <YEAR> Generate tax summary for specific year (with --export-csv)");
diff --git a/src/rebalance.rs b/src/rebalance.rs
new file mode 100644
index 0000000..b47f7b5
--- /dev/null
+++ b/src/rebalance.rs
@@ -0,0 +1,541 @@
+use std::collections::HashMap;
+use crate::investment::{Portfolio, AssetType, AccountType};
+
+#[derive(Debug, Clone)]
+pub struct RebalanceTarget {
+ pub asset_type: AssetType,
+ pub target_percent: f64,
+ pub min_percent: f64,
+ pub max_percent: f64,
+}
+
+#[derive(Debug, Clone)]
+pub struct RebalanceTrade {
+ pub symbol: String,
+ pub name: String,
+ pub action: TradeAction,
+ pub shares: f64,
+ pub amount: f64,
+ pub current_price: f64,
+ pub account: String,
+ pub reason: String,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TradeAction {
+ Buy,
+ Sell,
+}
+
+#[derive(Debug, Clone)]
+pub struct RebalanceAnalysis {
+ pub current_allocation: HashMap<AssetType, f64>,
+ pub target_allocation: HashMap<AssetType, f64>,
+ pub trades_needed: Vec<RebalanceTrade>,
+ pub estimated_cost: f64,
+ pub tax_implications: Vec<TaxImplication>,
+}
+
+#[derive(Debug, Clone)]
+pub struct TaxImplication {
+ pub account_type: AccountType,
+ pub symbol: String,
+ pub capital_gain: f64,
+ pub tax_free: bool,
+ pub message: String,
+}
+
+// Preset allocation strategies
+pub fn get_preset_allocations(risk_profile: &str) -> Vec<RebalanceTarget> {
+ match risk_profile {
+ "conservative" => vec![
+ RebalanceTarget {
+ asset_type: AssetType::Bond,
+ target_percent: 60.0,
+ min_percent: 55.0,
+ max_percent: 65.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Stock,
+ target_percent: 30.0,
+ min_percent: 25.0,
+ max_percent: 35.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Cash,
+ target_percent: 10.0,
+ min_percent: 5.0,
+ max_percent: 15.0,
+ },
+ ],
+ "balanced" => vec![
+ RebalanceTarget {
+ asset_type: AssetType::Stock,
+ target_percent: 50.0,
+ min_percent: 45.0,
+ max_percent: 55.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Bond,
+ target_percent: 40.0,
+ min_percent: 35.0,
+ max_percent: 45.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Cash,
+ target_percent: 10.0,
+ min_percent: 5.0,
+ max_percent: 15.0,
+ },
+ ],
+ "growth" => vec![
+ RebalanceTarget {
+ asset_type: AssetType::Stock,
+ target_percent: 70.0,
+ min_percent: 65.0,
+ max_percent: 75.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::ETF,
+ target_percent: 20.0,
+ min_percent: 15.0,
+ max_percent: 25.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Cash,
+ target_percent: 10.0,
+ min_percent: 5.0,
+ max_percent: 15.0,
+ },
+ ],
+ "aggressive" => vec![
+ RebalanceTarget {
+ asset_type: AssetType::Stock,
+ target_percent: 80.0,
+ min_percent: 75.0,
+ max_percent: 85.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::ETF,
+ target_percent: 15.0,
+ min_percent: 10.0,
+ max_percent: 20.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Cash,
+ target_percent: 5.0,
+ min_percent: 0.0,
+ max_percent: 10.0,
+ },
+ ],
+ _ => get_preset_allocations("balanced"), // Default to balanced
+ }
+}
+
+// Canadian Couch Potato portfolios
+pub fn get_couch_potato_allocation(portfolio_type: &str) -> Vec<RebalanceTarget> {
+ match portfolio_type {
+ "simple" => vec![
+ RebalanceTarget {
+ asset_type: AssetType::ETF, // Assume ETFs for index funds
+ target_percent: 100.0,
+ min_percent: 95.0,
+ max_percent: 100.0,
+ },
+ ],
+ "classic" => vec![
+ RebalanceTarget {
+ asset_type: AssetType::Stock, // Canadian equity
+ target_percent: 20.0,
+ min_percent: 15.0,
+ max_percent: 25.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::ETF, // US/International equity
+ target_percent: 60.0,
+ min_percent: 55.0,
+ max_percent: 65.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Bond,
+ target_percent: 20.0,
+ min_percent: 15.0,
+ max_percent: 25.0,
+ },
+ ],
+ _ => get_couch_potato_allocation("classic"),
+ }
+}
+
+// Calculate current allocation from portfolios
+pub fn calculate_current_allocation(portfolios: &[Portfolio]) -> HashMap<AssetType, f64> {
+ let mut allocation = HashMap::new();
+ let total_value: f64 = portfolios.iter().map(|p| p.total_market_value).sum();
+
+ // Aggregate by asset type
+ for portfolio in portfolios {
+ for position in &portfolio.positions {
+ let current = allocation.get(&position.asset_type).unwrap_or(&0.0);
+ allocation.insert(position.asset_type.clone(), current + position.market_value);
+ }
+
+ // Add cash
+ if portfolio.cash_balance > 0.0 {
+ let current_cash = allocation.get(&AssetType::Cash).unwrap_or(&0.0);
+ allocation.insert(AssetType::Cash, current_cash + portfolio.cash_balance);
+ }
+ }
+
+ // Convert to percentages
+ let mut percentage_allocation = HashMap::new();
+ for (asset_type, value) in allocation {
+ let percentage = (value / total_value) * 100.0;
+ percentage_allocation.insert(asset_type, percentage);
+ }
+
+ percentage_allocation
+}
+
+// Calculate trades needed to reach target allocation
+pub fn calculate_rebalancing_trades(
+ portfolios: &[Portfolio],
+ targets: &[RebalanceTarget],
+ threshold: f64, // Minimum deviation to trigger rebalancing (e.g., 5%)
+) -> RebalanceAnalysis {
+ let total_value: f64 = portfolios.iter().map(|p| p.total_market_value).sum();
+ let current_allocation = calculate_current_allocation(portfolios);
+ let mut target_allocation = HashMap::new();
+ let mut trades_needed = Vec::new();
+
+ // Build target allocation map
+ for target in targets {
+ target_allocation.insert(target.asset_type.clone(), target.target_percent);
+ }
+
+ // Calculate deviations and needed adjustments
+ let mut adjustments: HashMap<AssetType, f64> = HashMap::new();
+
+ for target in targets {
+ let current_pct = current_allocation.get(&target.asset_type).unwrap_or(&0.0);
+ let deviation = current_pct - target.target_percent;
+
+ // Only rebalance if outside min/max range or deviation exceeds threshold
+ if *current_pct < target.min_percent ||
+ *current_pct > target.max_percent ||
+ deviation.abs() > threshold {
+ let target_value = (target.target_percent / 100.0) * total_value;
+ let current_value = (current_pct / 100.0) * total_value;
+ let adjustment = target_value - current_value;
+ adjustments.insert(target.asset_type.clone(), adjustment);
+ }
+ }
+
+ // Generate specific trades
+ // For simplicity, we'll suggest selling overweight positions and buying underweight ones
+
+ // First, identify what to sell
+ let mut sell_candidates = Vec::new();
+ for portfolio in portfolios {
+ for position in &portfolio.positions {
+ if let Some(adjustment) = adjustments.get(&position.asset_type) {
+ if *adjustment < 0.0 {
+ // This asset type needs to be reduced
+ sell_candidates.push((portfolio, position, adjustment.abs()));
+ }
+ }
+ }
+ }
+
+ // Sort by size to sell largest positions first
+ sell_candidates.sort_by(|a, b| b.1.market_value.partial_cmp(&a.1.market_value).unwrap());
+
+ let mut remaining_sells = adjustments.clone();
+ for (portfolio, position, _) in sell_candidates {
+ if let Some(adjustment) = remaining_sells.get_mut(&position.asset_type) {
+ if *adjustment < 0.0 {
+ let sell_amount = adjustment.abs().min(position.market_value);
+ let shares_to_sell = (sell_amount / position.market_price).floor();
+
+ if shares_to_sell > 0.0 {
+ trades_needed.push(RebalanceTrade {
+ symbol: position.symbol.clone(),
+ name: position.name.clone(),
+ action: TradeAction::Sell,
+ shares: shares_to_sell,
+ amount: shares_to_sell * position.market_price,
+ current_price: position.market_price,
+ account: portfolio.account_id.clone(),
+ reason: format!("Reduce {:?} allocation", position.asset_type),
+ });
+
+ *adjustment += sell_amount;
+ }
+ }
+ }
+ }
+
+ // Calculate tax implications
+ let tax_implications = calculate_tax_implications(portfolios, &trades_needed);
+
+ // For buys, suggest ETFs or index funds
+ for (asset_type, adjustment) in &adjustments {
+ if *adjustment > 0.0 {
+ trades_needed.push(RebalanceTrade {
+ symbol: get_suggested_etf(asset_type),
+ name: format!("{:?} Index Fund", asset_type),
+ action: TradeAction::Buy,
+ shares: 0.0, // Will be calculated based on available funds
+ amount: *adjustment,
+ current_price: 0.0, // Would need market data
+ account: find_best_account_for_purchase(portfolios, asset_type),
+ reason: format!("Increase {:?} allocation", asset_type),
+ });
+ }
+ }
+
+ let estimated_cost = trades_needed.iter()
+ .filter(|t| t.action == TradeAction::Buy)
+ .map(|t| t.amount)
+ .sum::<f64>() * 0.01; // Assume 1% transaction costs
+
+ RebalanceAnalysis {
+ current_allocation,
+ target_allocation,
+ trades_needed,
+ estimated_cost,
+ tax_implications,
+ }
+}
+
+// Calculate tax implications of trades
+fn calculate_tax_implications(
+ portfolios: &[Portfolio],
+ trades: &[RebalanceTrade]
+) -> Vec<TaxImplication> {
+ let mut implications = Vec::new();
+
+ for trade in trades {
+ if trade.action == TradeAction::Sell {
+ // Find the portfolio and position
+ for portfolio in portfolios {
+ if portfolio.account_id == trade.account {
+ for position in &portfolio.positions {
+ if position.symbol == trade.symbol {
+ let capital_gain = (trade.shares / position.quantity) * position.unrealized_pnl;
+ let tax_free = matches!(
+ portfolio.account_type,
+ AccountType::TFSA | AccountType::RRSP | AccountType::RESP
+ );
+
+ implications.push(TaxImplication {
+ account_type: portfolio.account_type.clone(),
+ symbol: trade.symbol.clone(),
+ capital_gain,
+ tax_free,
+ message: if tax_free {
+ "Tax-free account - no tax implications".to_string()
+ } else if capital_gain > 0.0 {
+ format!("Capital gain of ${:.2} (50% taxable = ${:.2})",
+ capital_gain, capital_gain * 0.5)
+ } else {
+ format!("Capital loss of ${:.2} (can offset gains)", capital_gain.abs())
+ },
+ });
+ break;
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ implications
+}
+
+// Suggest ETF based on asset type
+fn get_suggested_etf(asset_type: &AssetType) -> String {
+ match asset_type {
+ AssetType::Stock => "VCN.TO".to_string(), // Vanguard Canada All Cap
+ AssetType::ETF => "VTI".to_string(), // Vanguard Total Market
+ AssetType::Bond => "ZAG.TO".to_string(), // BMO Aggregate Bond
+ AssetType::Cash => "CASH".to_string(),
+ _ => "VFV.TO".to_string(), // S&P 500 CAD hedged
+ }
+}
+
+// Find best account for tax efficiency
+fn find_best_account_for_purchase(portfolios: &[Portfolio], asset_type: &AssetType) -> String {
+ // Prioritize tax-advantaged accounts for income-producing assets
+ let prioritize_registered = matches!(asset_type, AssetType::Bond | AssetType::MutualFund);
+
+ for portfolio in portfolios {
+ if prioritize_registered {
+ if matches!(portfolio.account_type, AccountType::TFSA | AccountType::RRSP) {
+ if portfolio.cash_balance > 1000.0 {
+ return portfolio.account_id.clone();
+ }
+ }
+ } else {
+ // Growth assets can go in taxable accounts
+ if portfolio.cash_balance > 1000.0 {
+ return portfolio.account_id.clone();
+ }
+ }
+ }
+
+ // Default to first account with cash
+ portfolios.iter()
+ .find(|p| p.cash_balance > 0.0)
+ .map(|p| p.account_id.clone())
+ .unwrap_or_else(|| portfolios[0].account_id.clone())
+}
+
+// Generate rebalancing summary
+pub fn generate_rebalancing_summary(analysis: &RebalanceAnalysis) -> String {
+ let mut summary = String::new();
+
+ summary.push_str("๐ŸŽฏ Rebalancing Analysis\n");
+ summary.push_str("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n\n");
+
+ summary.push_str("Current vs Target Allocation:\n");
+ for (asset_type, current_pct) in &analysis.current_allocation {
+ let target_pct = analysis.target_allocation.get(asset_type).unwrap_or(&0.0);
+ let deviation = current_pct - target_pct;
+ let indicator = if deviation.abs() < 1.0 {
+ "โœ…"
+ } else if deviation > 0.0 {
+ "๐Ÿ“ˆ"
+ } else {
+ "๐Ÿ“‰"
+ };
+
+ summary.push_str(&format!("{} {:?}: {:.1}% โ†’ {:.1}% ({:+.1}%)\n",
+ indicator, asset_type, current_pct, target_pct, deviation));
+ }
+
+ if analysis.trades_needed.is_empty() {
+ summary.push_str("\nโœ… Portfolio is well-balanced! No trades needed.\n");
+ } else {
+ summary.push_str(&format!("\n๐Ÿ“‹ {} Trades Recommended:\n", analysis.trades_needed.len()));
+
+ // Group by action
+ let sells: Vec<_> = analysis.trades_needed.iter()
+ .filter(|t| t.action == TradeAction::Sell)
+ .collect();
+ let buys: Vec<_> = analysis.trades_needed.iter()
+ .filter(|t| t.action == TradeAction::Buy)
+ .collect();
+
+ if !sells.is_empty() {
+ summary.push_str("\n๐Ÿ”ด SELL:\n");
+ for trade in sells {
+ summary.push_str(&format!(" {} shares of {} (${:.2})\n",
+ trade.shares as i32, trade.symbol, trade.amount));
+ summary.push_str(&format!(" Account: {} - {}\n", trade.account, trade.reason));
+ }
+ }
+
+ if !buys.is_empty() {
+ summary.push_str("\n๐ŸŸข BUY:\n");
+ for trade in buys {
+ summary.push_str(&format!(" ${:.2} of {} ({})\n",
+ trade.amount, trade.symbol, trade.reason));
+ summary.push_str(&format!(" Suggested Account: {}\n", trade.account));
+ }
+ }
+
+ summary.push_str(&format!("\n๐Ÿ’ฐ Estimated Transaction Costs: ${:.2}\n", analysis.estimated_cost));
+ }
+
+ // Tax implications
+ if !analysis.tax_implications.is_empty() {
+ summary.push_str("\n๐Ÿงพ Tax Implications:\n");
+ for implication in &analysis.tax_implications {
+ summary.push_str(&format!(" {} in {:?}: {}\n",
+ implication.symbol, implication.account_type, implication.message));
+ }
+ }
+
+ summary
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::investment::{Portfolio, Position};
+ use chrono::Utc;
+
+ fn create_test_portfolio() -> Portfolio {
+ Portfolio {
+ broker: "Test".to_string(),
+ account_id: "TFSA123".to_string(),
+ account_type: AccountType::TFSA,
+ positions: vec![
+ Position {
+ symbol: "VTI".to_string(),
+ name: "Vanguard Total Market".to_string(),
+ asset_type: AssetType::ETF,
+ quantity: 100.0,
+ market_price: 220.0,
+ market_value: 22000.0,
+ book_value: 20000.0,
+ average_cost: 200.0,
+ unrealized_pnl: 2000.0,
+ unrealized_pnl_percent: 10.0,
+ currency: "USD".to_string(),
+ },
+ ],
+ cash_balance: 5000.0,
+ total_market_value: 27000.0,
+ total_book_value: 25000.0,
+ total_unrealized_pnl: 2000.0,
+ last_updated: Utc::now().naive_utc(),
+ }
+ }
+
+ #[test]
+ fn test_preset_allocations() {
+ let conservative = get_preset_allocations("conservative");
+ assert_eq!(conservative.len(), 3);
+ assert_eq!(conservative[0].target_percent, 60.0); // Bonds
+
+ let aggressive = get_preset_allocations("aggressive");
+ assert_eq!(aggressive[0].target_percent, 80.0); // Stocks
+ }
+
+ #[test]
+ fn test_current_allocation() {
+ let portfolios = vec![create_test_portfolio()];
+ let allocation = calculate_current_allocation(&portfolios);
+
+ assert!(allocation.contains_key(&AssetType::ETF));
+ assert!(allocation.contains_key(&AssetType::Cash));
+ assert!((allocation[&AssetType::ETF] - 81.48).abs() < 0.1);
+ assert!((allocation[&AssetType::Cash] - 18.52).abs() < 0.1);
+ }
+
+ #[test]
+ fn test_rebalancing_calculation() {
+ let portfolios = vec![create_test_portfolio()];
+ let targets = vec![
+ RebalanceTarget {
+ asset_type: AssetType::ETF,
+ target_percent: 70.0,
+ min_percent: 65.0,
+ max_percent: 75.0,
+ },
+ RebalanceTarget {
+ asset_type: AssetType::Cash,
+ target_percent: 30.0,
+ min_percent: 25.0,
+ max_percent: 35.0,
+ },
+ ];
+
+ let analysis = calculate_rebalancing_trades(&portfolios, &targets, 5.0);
+
+ // Should recommend selling some ETF and increasing cash
+ assert!(analysis.trades_needed.len() > 0);
+ }
+} \ No newline at end of file