diff options
| author | mo khan <mo@mokhan.ca> | 2025-06-25 22:36:55 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-06-25 22:36:55 -0600 |
| commit | 3d2c137ce82efc33779cff9d20ae1ada83d27ece (patch) | |
| tree | 378657873c502eb1168a33cc82a9c8808952a487 | |
| parent | 8c16c1f0f348bbb44dd06452943262e58cba52e0 (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.rs | 6 | ||||
| -rw-r--r-- | src/main.rs | 54 | ||||
| -rw-r--r-- | src/rebalance.rs | 541 |
3 files changed, 599 insertions, 2 deletions
@@ -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 |
