diff options
| author | mo khan <mo@mokhan.ca> | 2025-06-25 21:39:51 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-06-25 21:39:51 -0600 |
| commit | 98268f8a3d4197685e18197fec277fb5dcb06d39 (patch) | |
| tree | b6d1d7d9afc0602a1b6fcbee51295c289e75f7b1 | |
| parent | 02bc80ccd5071de4a1d04ddb9e0e0b8daa7b425d (diff) | |
feat: add CSV export for investments and unified smart import command
- Add --export-csv option to export all investment data to CSV files
- Add --tax-year option for tax-specific reporting
- Export portfolio summary, positions, asset allocation, and performance data
- Export individual account details and tax summaries
- Add smart-import command that auto-detects all file types
- Automatically handle banking files, investment files, CSV/PDF formats
- Recursively scan directories and handle mixed content
- Provide clear import progress and summary statistics
- Create simple wrapper script for even easier usage
- Add SIMPLE_USAGE.md with straightforward documentation
This makes importing financial data as simple as:
cargo run -- smart-import ./data
No more remembering different import commands!
š¤ Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | SIMPLE_USAGE.md | 101 | ||||
| -rw-r--r-- | investment_exports/account_HM5246248.csv | 15 | ||||
| -rw-r--r-- | investment_exports/account_HQ5Y03H44.csv | 16 | ||||
| -rw-r--r-- | investment_exports/account_W635824K8.csv | 12 | ||||
| -rw-r--r-- | investment_exports/account_W67415948.csv | 18 | ||||
| -rw-r--r-- | investment_exports/account_WK2XYHX31.csv | 10 | ||||
| -rw-r--r-- | investment_exports/account_WK40PBG41.csv | 12 | ||||
| -rw-r--r-- | investment_exports/asset_allocation.csv | 6 | ||||
| -rw-r--r-- | investment_exports/performance_summary.csv | 28 | ||||
| -rw-r--r-- | investment_exports/portfolio_summary.csv | 11 | ||||
| -rw-r--r-- | investment_exports/positions.csv | 19 | ||||
| -rw-r--r-- | investment_exports/tax_summary_2024.csv | 14 | ||||
| -rwxr-xr-x | spendr | 12 | ||||
| -rw-r--r-- | src/cli.rs | 11 | ||||
| -rw-r--r-- | src/investment.rs | 275 | ||||
| -rw-r--r-- | src/main.rs | 62 | ||||
| -rw-r--r-- | src/smart_import.rs | 204 |
17 files changed, 824 insertions, 2 deletions
diff --git a/SIMPLE_USAGE.md b/SIMPLE_USAGE.md new file mode 100644 index 0000000..7bb7bdc --- /dev/null +++ b/SIMPLE_USAGE.md @@ -0,0 +1,101 @@ +# š Simple Spendr Usage Guide + +## The ONE Command You Need + +```bash +# Import EVERYTHING (banking + investments) from a directory +cargo run -- smart-import ./data + +# Or use the helper script +./spendr import ./data +``` + +That's it! This command will: +- š Automatically detect all file types +- š³ Import banking transactions (CIBC, BMO, TD, Simplii, etc.) +- š Import investment data (WealthSimple portfolios) +- š Recursively scan subdirectories +- ā
Handle both CSV and PDF files +- š§ Figure out everything automatically + +## Quick Start + +1. **Put all your files in one folder** (they can be in subdirectories) + ``` + data/ + āāā banking/ + ā āāā cibc.csv + ā āāā bmo-statement.pdf + ā āāā simplii-300.csv + āāā investments/ + āāā positions-*.csv + āāā transactions-*.csv + ``` + +2. **Run the magic command** + ```bash + cargo run -- smart-import ./data + ``` + +3. **That's it!** Check your results: + ```bash + # View spending summary + cargo run -- summary --categories + + # View investments + cargo run -- investment --portfolio + + # Export investment data + cargo run -- investment --export-csv ./exports + ``` + +## Common Tasks + +### See What Would Be Imported (Dry Run) +```bash +cargo run -- smart-import ./data --dry-run +``` + +### View Your Financial Summary +```bash +# Monthly spending +cargo run -- summary --monthly + +# By category +cargo run -- summary --categories + +# Recent transactions +cargo run -- recent +``` + +### Investment Analysis +```bash +# Portfolio overview +cargo run -- investment --portfolio + +# Asset allocation +cargo run -- investment --allocation + +# Export to CSV +cargo run -- investment --export-csv ./exports +``` + +## Tips + +1. **Keep it simple**: Just dump all your files in one folder +2. **Don't worry about file types**: The smart import figures it out +3. **Re-import anytime**: It automatically avoids duplicates +4. **Mix everything**: Banking and investment files can be in the same folder + +## Need Help? + +If the smart import misses something, you can still use specific imports: +```bash +# For a specific bank file +cargo run -- import --bank cibc --file ./cibc.csv + +# For investment data +cargo run -- investment --import-wealthsimple ./investment-data +``` + +But you shouldn't need to - the smart import handles 99% of cases!
\ No newline at end of file diff --git a/investment_exports/account_HM5246248.csv b/investment_exports/account_HM5246248.csv new file mode 100644 index 0000000..21a887d --- /dev/null +++ b/investment_exports/account_HM5246248.csv @@ -0,0 +1,15 @@ +Account Details for HM5246248 (Other("investment")) +Broker: WealthSimple +Last Updated: 2025-06-25 15:55:33 + +Summary +Cash Balance,6.43 +Total Market Value,529.69 +Total Book Value,524.66 +Unrealized P&L,-1.40 + +Symbol,Name,Quantity,Avg Cost,Market Price,Book Value,Market Value,Unrealized P&L,P&L % +BB,BlackBerry Limited,10.0000,3.62,3.62,36.20,36.20,0.00,0.00% +GXE,Gear Energy Ltd,18.0000,0.72,0.72,12.87,12.96,0.09,0.70% +LTC,Lotus Creek Exploration Inc.,260.6074,1.70,1.70,443.03,443.03,0.00,0.00% +T,Telus Corp.,1.5649,20.81,19.86,32.56,31.07,-1.49,-4.56% diff --git a/investment_exports/account_HQ5Y03H44.csv b/investment_exports/account_HQ5Y03H44.csv new file mode 100644 index 0000000..8b86029 --- /dev/null +++ b/investment_exports/account_HQ5Y03H44.csv @@ -0,0 +1,16 @@ +Account Details for HQ5Y03H44 (Other("investment")) +Broker: WealthSimple +Last Updated: 2025-06-25 15:55:33 + +Summary +Cash Balance,79525.17 +Total Market Value,137966.54 +Total Book Value,58609.88 +Unrealized P&L,-168.51 + +Symbol,Name,Quantity,Avg Cost,Market Price,Book Value,Market Value,Unrealized P&L,P&L % +CSCO,Cisco Systems; Inc.,6.0661,48.27,48.27,292.80,292.80,0.00,0.00% +GLOV,Goldman Sachs ActiveBeta World Low Vol Plus Equity ETF,235.4022,51.11,52.69,12031.82,12403.43,371.61,3.09% +GOOGL,Alphabet Inc (Class A),0.0056,175.00,175.00,0.98,0.98,0.00,0.00% +META,Meta Platforms Inc.,0.0009,511.11,511.11,0.46,0.46,0.00,0.00% +VTI,Vanguard Group; Inc. - Vanguard Total Stock Market ETF,164.3937,281.54,278.26,46283.82,45743.70,-540.12,-1.17% diff --git a/investment_exports/account_W635824K8.csv b/investment_exports/account_W635824K8.csv new file mode 100644 index 0000000..bf2a5ad --- /dev/null +++ b/investment_exports/account_W635824K8.csv @@ -0,0 +1,12 @@ +Account Details for W635824K8 (Other("cash")) +Broker: WealthSimple +Last Updated: 2025-06-25 15:55:33 + +Summary +Cash Balance,1.17 +Total Market Value,17.55 +Total Book Value,16.50 +Unrealized P&L,-0.12 + +Symbol,Name,Quantity,Avg Cost,Market Price,Book Value,Market Value,Unrealized P&L,P&L % +WSHR,Wealthsimple Shariah World Equity Index ETF,0.5723,28.83,28.62,16.50,16.38,-0.12,-0.75% diff --git a/investment_exports/account_W67415948.csv b/investment_exports/account_W67415948.csv new file mode 100644 index 0000000..3a95d04 --- /dev/null +++ b/investment_exports/account_W67415948.csv @@ -0,0 +1,18 @@ +Account Details for W67415948 (Other("cash")) +Broker: WealthSimple +Last Updated: 2025-06-25 15:55:33 + +Summary +Cash Balance,1135.91 +Total Market Value,112881.21 +Total Book Value,111745.30 +Unrealized P&L,0.00 + +Symbol,Name,Quantity,Avg Cost,Market Price,Book Value,Market Value,Unrealized P&L,P&L % +EEMV,iShares Inc. - MSCI Emerging Markets Min Vol Factor ETF,231.0861,86.48,86.48,19984.76,19984.76,0.00,0.00% +GLDM,SPDR Gold MiniShares Trust,36.7354,91.26,91.26,3352.55,3352.55,0.00,0.00% +GLOV,Goldman Sachs ActiveBeta World Low Vol Plus Equity ETF,152.8171,75.94,75.94,11604.48,11604.48,0.00,0.00% +IEFA,iShares Trust - Core MSCI EAFE,180.1744,115.50,115.50,20810.38,20810.38,0.00,0.00% +QCN,Mackenzie Canadian Equity Index ETF Series E,62.2583,159.39,159.39,9923.21,9923.21,0.00,0.00% +VTI,Vanguard Group; Inc. - Vanguard Total Stock Market ETF,88.2782,410.73,410.73,36258.44,36258.44,0.00,0.00% +ZFL,BMO Long Federal Bond Index ETF,782.4146,12.54,12.54,9811.48,9811.48,0.00,0.00% diff --git a/investment_exports/account_WK2XYHX31.csv b/investment_exports/account_WK2XYHX31.csv new file mode 100644 index 0000000..748531a --- /dev/null +++ b/investment_exports/account_WK2XYHX31.csv @@ -0,0 +1,10 @@ +Account Details for WK2XYHX31 (Other("cash")) +Broker: WealthSimple +Last Updated: 2025-06-25 15:55:33 + +Summary +Cash Balance,10036.05 +Total Market Value,10036.05 +Total Book Value,0.00 +Unrealized P&L,0.00 + diff --git a/investment_exports/account_WK40PBG41.csv b/investment_exports/account_WK40PBG41.csv new file mode 100644 index 0000000..1bc43f1 --- /dev/null +++ b/investment_exports/account_WK40PBG41.csv @@ -0,0 +1,12 @@ +Account Details for WK40PBG41 (Other("cash")) +Broker: WealthSimple +Last Updated: 2025-06-25 15:55:33 + +Summary +Cash Balance,23.43 +Total Market Value,9986.40 +Total Book Value,9962.97 +Unrealized P&L,0.00 + +Symbol,Name,Quantity,Avg Cost,Market Price,Book Value,Market Value,Unrealized P&L,P&L % +WSE300P,WS PRIVATE EQ1 - BUY,996.2970,10.00,10.00,9962.97,9962.97,0.00,0.00% diff --git a/investment_exports/asset_allocation.csv b/investment_exports/asset_allocation.csv new file mode 100644 index 0000000..2b8649d --- /dev/null +++ b/investment_exports/asset_allocation.csv @@ -0,0 +1,6 @@ +Asset Type,Market Value,Percentage,Allocation Bar +ETF,170726.31,62.90%,āāāāāāāāāāāā +Cash,90728.16,33.43%,āāāāāā +Stock,9962.97,3.67%, + +TOTAL,271417.44,100.00%, diff --git a/investment_exports/performance_summary.csv b/investment_exports/performance_summary.csv new file mode 100644 index 0000000..bf7b101 --- /dev/null +++ b/investment_exports/performance_summary.csv @@ -0,0 +1,28 @@ +Top Performers +Symbol,P&L %,Market Value +GLOV,3.09%,12403.43 +GXE,0.70%,12.96 +BB,0.00%,36.20 +LTC,0.00%,443.03 +CSCO,0.00%,292.80 + +Worst Performers +Symbol,P&L %,Market Value +T,-4.56%,31.07 +VTI,-1.17%,45743.70 +WSHR,-0.75%,16.38 +BB,0.00%,36.20 +LTC,0.00%,443.03 + +Largest Positions +Symbol,Market Value,% of Portfolio +VTI,45743.70,16.85% +VTI,36258.44,13.36% +IEFA,20810.38,7.67% +EEMV,19984.76,7.36% +GLOV,12403.43,4.57% +GLOV,11604.48,4.28% +WSE300P,9962.97,3.67% +QCN,9923.21,3.66% +ZFL,9811.48,3.61% +GLDM,3352.55,1.24% diff --git a/investment_exports/portfolio_summary.csv b/investment_exports/portfolio_summary.csv new file mode 100644 index 0000000..34aad56 --- /dev/null +++ b/investment_exports/portfolio_summary.csv @@ -0,0 +1,11 @@ +Account ID,Account Type,Broker,Cash Balance,Total Market Value,Total Book Value,Unrealized P&L,Unrealized P&L %,Last Updated +H02002303,Other("investment"),WealthSimple,0.00,0.00,0.00,0.00,0.00%,2025-06-25 15:55:33 +H67185811,Other("investment"),WealthSimple,0.00,0.00,0.00,0.00,0.00%,2025-06-25 15:55:33 +HM5246248,Other("investment"),WealthSimple,6.43,529.69,524.66,-1.40,-0.27%,2025-06-25 15:55:33 +HQ5Y03H44,Other("investment"),WealthSimple,79525.17,137966.54,58609.88,-168.51,-0.29%,2025-06-25 15:55:33 +W635824K8,Other("cash"),WealthSimple,1.17,17.55,16.50,-0.12,-0.75%,2025-06-25 15:55:33 +W67415948,Other("cash"),WealthSimple,1135.91,112881.21,111745.30,0.00,0.00%,2025-06-25 15:55:33 +WK2XYHX31,Other("cash"),WealthSimple,10036.05,10036.05,0.00,0.00,0.00%,2025-06-25 15:55:33 +WK40PBG41,Other("cash"),WealthSimple,23.43,9986.40,9962.97,0.00,0.00%,2025-06-25 15:55:33 + +TOTAL,,,90728.16,271417.44,180859.31,-170.03,-0.09%, diff --git a/investment_exports/positions.csv b/investment_exports/positions.csv new file mode 100644 index 0000000..0c3506f --- /dev/null +++ b/investment_exports/positions.csv @@ -0,0 +1,19 @@ +Account ID,Symbol,Name,Asset Type,Quantity,Average Cost,Market Price,Book Value,Market Value,Unrealized P&L,Unrealized P&L %,Currency +HM5246248,BB,BlackBerry Limited,ETF,10.0000,3.62,3.62,36.20,36.20,0.00,0.00%,CAD +HM5246248,GXE,Gear Energy Ltd,ETF,18.0000,0.72,0.72,12.87,12.96,0.09,0.70%,CAD +HM5246248,LTC,Lotus Creek Exploration Inc.,ETF,260.6074,1.70,1.70,443.03,443.03,0.00,0.00%,CAD +HM5246248,T,Telus Corp.,ETF,1.5649,20.81,19.86,32.56,31.07,-1.49,-4.56%,CAD +HQ5Y03H44,CSCO,Cisco Systems; Inc.,ETF,6.0661,48.27,48.27,292.80,292.80,0.00,0.00%,USD +HQ5Y03H44,GLOV,Goldman Sachs ActiveBeta World Low Vol Plus Equity ETF,ETF,235.4022,51.11,52.69,12031.82,12403.43,371.61,3.09%,USD +HQ5Y03H44,GOOGL,Alphabet Inc (Class A),ETF,0.0056,175.00,175.00,0.98,0.98,0.00,0.00%,USD +HQ5Y03H44,META,Meta Platforms Inc.,ETF,0.0009,511.11,511.11,0.46,0.46,0.00,0.00%,USD +HQ5Y03H44,VTI,Vanguard Group; Inc. - Vanguard Total Stock Market ETF,ETF,164.3937,281.54,278.26,46283.82,45743.70,-540.12,-1.17%,USD +W635824K8,WSHR,Wealthsimple Shariah World Equity Index ETF,ETF,0.5723,28.83,28.62,16.50,16.38,-0.12,-0.75%,CAD +W67415948,EEMV,iShares Inc. - MSCI Emerging Markets Min Vol Factor ETF,ETF,231.0861,86.48,86.48,19984.76,19984.76,0.00,0.00%,CAD +W67415948,GLDM,SPDR Gold MiniShares Trust,ETF,36.7354,91.26,91.26,3352.55,3352.55,0.00,0.00%,CAD +W67415948,GLOV,Goldman Sachs ActiveBeta World Low Vol Plus Equity ETF,ETF,152.8171,75.94,75.94,11604.48,11604.48,0.00,0.00%,CAD +W67415948,IEFA,iShares Trust - Core MSCI EAFE,ETF,180.1744,115.50,115.50,20810.38,20810.38,0.00,0.00%,CAD +W67415948,QCN,Mackenzie Canadian Equity Index ETF Series E,ETF,62.2583,159.39,159.39,9923.21,9923.21,0.00,0.00%,CAD +W67415948,VTI,Vanguard Group; Inc. - Vanguard Total Stock Market ETF,ETF,88.2782,410.73,410.73,36258.44,36258.44,0.00,0.00%,CAD +W67415948,ZFL,BMO Long Federal Bond Index ETF,ETF,782.4146,12.54,12.54,9811.48,9811.48,0.00,0.00%,CAD +WK40PBG41,WSE300P,WS PRIVATE EQ1 - BUY,Stock,996.2970,10.00,10.00,9962.97,9962.97,0.00,0.00%,CAD diff --git a/investment_exports/tax_summary_2024.csv b/investment_exports/tax_summary_2024.csv new file mode 100644 index 0000000..7444992 --- /dev/null +++ b/investment_exports/tax_summary_2024.csv @@ -0,0 +1,14 @@ +Tax Summary for 2024 - Investment Income + +Note: This summary shows unrealized gains/losses only. +For realized gains/losses, transaction history is required. + +Unrealized Gains/Losses by Account Type +Account Type,Total Book Value,Total Market Value,Unrealized P&L +TFSA,0.00,0.00,0.00 +RRSP,0.00,0.00,0.00 +Taxable,0.00,0.00,0.00 +Other,180859.31,271417.44,-170.03 + +Positions in Taxable Accounts (for capital gains reporting) +Account,Symbol,Quantity,Book Value,Market Value,Unrealized P&L @@ -0,0 +1,12 @@ +#!/bin/bash + +# Simple wrapper script for spendr +# Usage: ./spendr import ./data + +if [ "$1" = "import" ] && [ -n "$2" ]; then + # User just wants to import - use smart import + cargo run -- smart-import "$2" +else + # Pass through all other commands + cargo run -- "$@" +fi
\ No newline at end of file @@ -10,6 +10,13 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { + // New unified import command - just point at a path and it figures everything out + SmartImport { + #[arg(help = "Path to file or directory to import (auto-detects everything)")] + path: String, + #[arg(long, help = "Show what would be imported without actually importing")] + dry_run: bool, + }, Import { #[arg( long, @@ -100,5 +107,9 @@ pub enum Commands { performance: bool, #[arg(long, help = "Import WealthSimple CSV files from directory")] import_wealthsimple: Option<String>, + #[arg(long, help = "Export investment data to CSV")] + export_csv: Option<String>, + #[arg(long, help = "Generate tax summary for year (requires --export-csv)")] + tax_year: Option<i32>, }, } diff --git a/src/investment.rs b/src/investment.rs index f923de9..3cc06a5 100644 --- a/src/investment.rs +++ b/src/investment.rs @@ -2,6 +2,8 @@ use chrono::{NaiveDate, NaiveDateTime}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; +use std::fs::File; +use std::io::Write; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Portfolio { @@ -774,6 +776,279 @@ async fn fetch_alpha_vantage_price(symbol: &str, api_key: &str) -> anyhow::Resul Err(anyhow::anyhow!("Failed to parse price for symbol: {}", symbol)) } +// CSV Export Functions + +pub fn export_portfolios_to_csv(portfolios: &[Portfolio], output_dir: &str) -> anyhow::Result<()> { + let dir_path = Path::new(output_dir); + if !dir_path.exists() { + std::fs::create_dir_all(dir_path)?; + } + + // Export portfolio summary + export_portfolio_summary(portfolios, &dir_path.join("portfolio_summary.csv"))?; + + // Export all positions + export_positions(portfolios, &dir_path.join("positions.csv"))?; + + // Export by account + for portfolio in portfolios { + if portfolio.positions.is_empty() && portfolio.cash_balance == 0.0 { + continue; // Skip empty accounts + } + + let filename = format!("account_{}.csv", portfolio.account_id); + export_account_details(portfolio, &dir_path.join(filename))?; + } + + // Export asset allocation + export_asset_allocation(portfolios, &dir_path.join("asset_allocation.csv"))?; + + // Export performance summary + export_performance_summary(portfolios, &dir_path.join("performance_summary.csv"))?; + + Ok(()) +} + +fn export_portfolio_summary(portfolios: &[Portfolio], path: &Path) -> anyhow::Result<()> { + let mut file = File::create(path)?; + + // Write header + writeln!(file, "Account ID,Account Type,Broker,Cash Balance,Total Market Value,Total Book Value,Unrealized P&L,Unrealized P&L %,Last Updated")?; + + // Write portfolio data + for portfolio in portfolios { + let pnl_percent = if portfolio.total_book_value > 0.0 { + (portfolio.total_unrealized_pnl / portfolio.total_book_value) * 100.0 + } else { + 0.0 + }; + + writeln!(file, "{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2}%,{}", + portfolio.account_id, + portfolio.account_type, + portfolio.broker, + portfolio.cash_balance, + portfolio.total_market_value, + portfolio.total_book_value, + portfolio.total_unrealized_pnl, + pnl_percent, + portfolio.last_updated.format("%Y-%m-%d %H:%M:%S") + )?; + } + + // Write totals + let total_market_value: f64 = portfolios.iter().map(|p| p.total_market_value).sum(); + let total_book_value: f64 = portfolios.iter().map(|p| p.total_book_value).sum(); + let total_cash: f64 = portfolios.iter().map(|p| p.cash_balance).sum(); + let total_unrealized: f64 = portfolios.iter().map(|p| p.total_unrealized_pnl).sum(); + let total_pnl_percent = if total_book_value > 0.0 { + (total_unrealized / total_book_value) * 100.0 + } else { + 0.0 + }; + + writeln!(file, "\nTOTAL,,,{:.2},{:.2},{:.2},{:.2},{:.2}%,", + total_cash, total_market_value, total_book_value, total_unrealized, total_pnl_percent + )?; + + Ok(()) +} + +fn export_positions(portfolios: &[Portfolio], path: &Path) -> anyhow::Result<()> { + let mut file = File::create(path)?; + + // Write header + writeln!(file, "Account ID,Symbol,Name,Asset Type,Quantity,Average Cost,Market Price,Book Value,Market Value,Unrealized P&L,Unrealized P&L %,Currency")?; + + // Write all positions + for portfolio in portfolios { + for position in &portfolio.positions { + writeln!(file, "{},{},{},{:?},{:.4},{:.2},{:.2},{:.2},{:.2},{:.2},{:.2}%,{}", + portfolio.account_id, + position.symbol, + position.name.replace(',', ";"), // Replace commas in names + position.asset_type, + position.quantity, + position.average_cost, + position.market_price, + position.book_value, + position.market_value, + position.unrealized_pnl, + position.unrealized_pnl_percent, + position.currency + )?; + } + } + + Ok(()) +} + +fn export_account_details(portfolio: &Portfolio, path: &Path) -> anyhow::Result<()> { + let mut file = File::create(path)?; + + // Write account header + writeln!(file, "Account Details for {} ({:?})", portfolio.account_id, portfolio.account_type)?; + writeln!(file, "Broker: {}", portfolio.broker)?; + writeln!(file, "Last Updated: {}", portfolio.last_updated.format("%Y-%m-%d %H:%M:%S"))?; + writeln!(file)?; + + // Write summary + writeln!(file, "Summary")?; + writeln!(file, "Cash Balance,{:.2}", portfolio.cash_balance)?; + writeln!(file, "Total Market Value,{:.2}", portfolio.total_market_value)?; + writeln!(file, "Total Book Value,{:.2}", portfolio.total_book_value)?; + writeln!(file, "Unrealized P&L,{:.2}", portfolio.total_unrealized_pnl)?; + writeln!(file)?; + + // Write positions + if !portfolio.positions.is_empty() { + writeln!(file, "Symbol,Name,Quantity,Avg Cost,Market Price,Book Value,Market Value,Unrealized P&L,P&L %")?; + + for position in &portfolio.positions { + writeln!(file, "{},{},{:.4},{:.2},{:.2},{:.2},{:.2},{:.2},{:.2}%", + position.symbol, + position.name.replace(',', ";"), + position.quantity, + position.average_cost, + position.market_price, + position.book_value, + position.market_value, + position.unrealized_pnl, + position.unrealized_pnl_percent + )?; + } + } + + Ok(()) +} + +fn export_asset_allocation(portfolios: &[Portfolio], path: &Path) -> anyhow::Result<()> { + let mut file = File::create(path)?; + let allocation = calculate_asset_allocation(portfolios); + let total_value: f64 = allocation.values().sum(); + + // Write header + writeln!(file, "Asset Type,Market Value,Percentage,Allocation Bar")?; + + // Sort by value + let mut sorted_allocation: Vec<_> = allocation.iter().collect(); + sorted_allocation.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap()); + + // Write allocation data + for (asset_type, value) in sorted_allocation { + let percentage = if total_value > 0.0 { (value / total_value) * 100.0 } else { 0.0 }; + let bar_length = ((percentage / 100.0) * 20.0) as usize; + let bar = "ā".repeat(bar_length.min(20)); + + writeln!(file, "{:?},{:.2},{:.2}%,{}", + asset_type, value, percentage, bar + )?; + } + + // Write total + writeln!(file, "\nTOTAL,{:.2},100.00%,", total_value)?; + + Ok(()) +} + +fn export_performance_summary(portfolios: &[Portfolio], path: &Path) -> anyhow::Result<()> { + let mut file = File::create(path)?; + + // Get performance data + let top_performers = get_top_performers(portfolios); + let worst_performers = get_worst_performers(portfolios); + let largest_positions = get_largest_positions(portfolios); + + // Write top performers + writeln!(file, "Top Performers")?; + writeln!(file, "Symbol,P&L %,Market Value")?; + for (symbol, pnl_pct, value) in &top_performers { + writeln!(file, "{},{:.2}%,{:.2}", symbol, pnl_pct, value)?; + } + writeln!(file)?; + + // Write worst performers + writeln!(file, "Worst Performers")?; + writeln!(file, "Symbol,P&L %,Market Value")?; + for (symbol, pnl_pct, value) in &worst_performers { + writeln!(file, "{},{:.2}%,{:.2}", symbol, pnl_pct, value)?; + } + writeln!(file)?; + + // Write largest positions + writeln!(file, "Largest Positions")?; + writeln!(file, "Symbol,Market Value,% of Portfolio")?; + let total_value: f64 = portfolios.iter().map(|p| p.total_market_value).sum(); + for (symbol, value, _) in &largest_positions { + let percentage = if total_value > 0.0 { (value / total_value) * 100.0 } else { 0.0 }; + writeln!(file, "{},{:.2},{:.2}%", symbol, value, percentage)?; + } + + Ok(()) +} + +// Export for tax reporting +pub fn export_tax_summary(portfolios: &[Portfolio], year: i32, output_path: &str) -> anyhow::Result<()> { + let mut file = File::create(output_path)?; + + writeln!(file, "Tax Summary for {} - Investment Income", year)?; + writeln!(file)?; + + // Calculate realized gains/losses (would need transaction history) + writeln!(file, "Note: This summary shows unrealized gains/losses only.")?; + writeln!(file, "For realized gains/losses, transaction history is required.")?; + writeln!(file)?; + + // Unrealized gains/losses by account type + writeln!(file, "Unrealized Gains/Losses by Account Type")?; + writeln!(file, "Account Type,Total Book Value,Total Market Value,Unrealized P&L")?; + + let mut tfsa_totals = (0.0, 0.0, 0.0); + let mut rrsp_totals = (0.0, 0.0, 0.0); + let mut taxable_totals = (0.0, 0.0, 0.0); + let mut other_totals = (0.0, 0.0, 0.0); + + for portfolio in portfolios { + let totals = match &portfolio.account_type { + AccountType::TFSA => &mut tfsa_totals, + AccountType::RRSP => &mut rrsp_totals, + AccountType::Taxable => &mut taxable_totals, + _ => &mut other_totals, + }; + + totals.0 += portfolio.total_book_value; + totals.1 += portfolio.total_market_value; + totals.2 += portfolio.total_unrealized_pnl; + } + + writeln!(file, "TFSA,{:.2},{:.2},{:.2}", tfsa_totals.0, tfsa_totals.1, tfsa_totals.2)?; + writeln!(file, "RRSP,{:.2},{:.2},{:.2}", rrsp_totals.0, rrsp_totals.1, rrsp_totals.2)?; + writeln!(file, "Taxable,{:.2},{:.2},{:.2}", taxable_totals.0, taxable_totals.1, taxable_totals.2)?; + writeln!(file, "Other,{:.2},{:.2},{:.2}", other_totals.0, other_totals.1, other_totals.2)?; + writeln!(file)?; + + // Positions in taxable accounts + writeln!(file, "Positions in Taxable Accounts (for capital gains reporting)")?; + writeln!(file, "Account,Symbol,Quantity,Book Value,Market Value,Unrealized P&L")?; + + for portfolio in portfolios { + if matches!(portfolio.account_type, AccountType::Taxable) { + for position in &portfolio.positions { + writeln!(file, "{},{},{:.4},{:.2},{:.2},{:.2}", + portfolio.account_id, + position.symbol, + position.quantity, + position.book_value, + position.market_value, + position.unrealized_pnl + )?; + } + } + } + + Ok(()) +} + // Placeholder for reading configuration pub fn load_broker_configs() -> anyhow::Result<Vec<Box<dyn BrokerApi>>> { let clients: Vec<Box<dyn BrokerApi>> = Vec::new(); diff --git a/src/main.rs b/src/main.rs index 0bf680b..f87a146 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod import; mod investment; mod model; mod parser; +mod smart_import; use analytics::{get_spending_trends, get_net_worth_over_time, get_category_insights, generate_spending_recommendations, create_simple_chart}; use clap::Parser; @@ -20,12 +21,32 @@ use db::{ use detector::detect_bank_files; use import::{create_import_result, validate_transactions, detect_internal_duplicates}; use parser::parse_transactions; +use smart_import::{smart_import, print_import_summary}; #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { + Commands::SmartImport { path, dry_run } => { + println!("š Spendr Smart Import - I'll figure out everything for you!"); + println!(); + + match smart_import(&path, dry_run).await { + Ok(stats) => { + print_import_summary(&stats); + + if dry_run { + println!("\nš” This was a dry run. To actually import, run without --dry-run:"); + println!(" cargo run -- smart-import {}", path); + } + } + Err(e) => { + println!("ā Import failed: {}", e); + return Err(e); + } + } + } Commands::Import { bank, file } => { let txns = parse_transactions(&bank, &file)?; let count = insert_transactions(&txns)?; @@ -537,7 +558,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 } => { + Commands::Investment { portfolio, net_worth, allocation, recommendations, sync, performance, import_wealthsimple, export_csv, tax_year } => { if let Some(directory) = import_wealthsimple { println!("š„ Importing WealthSimple CSV files from: {}", directory); match import_wealthsimple_directory(&directory) { @@ -907,8 +928,40 @@ async fn main() -> anyhow::Result<()> { println!(" 5. Consider automated monthly contributions"); } + // Export CSV functionality + if let Some(export_dir) = export_csv { + println!("š Exporting investment data to CSV..."); + match investment::export_portfolios_to_csv(&portfolios, &export_dir) { + Ok(_) => { + println!("ā
Successfully exported investment data to: {}", export_dir); + println!(" š portfolio_summary.csv - Overview of all accounts"); + println!(" š positions.csv - All positions across accounts"); + println!(" š asset_allocation.csv - Asset class breakdown"); + println!(" š performance_summary.csv - Top/worst performers"); + println!(" š account_*.csv - Individual account details"); + + // Export tax summary if year is specified + if let Some(year) = tax_year { + let tax_path = format!("{}/tax_summary_{}.csv", export_dir, year); + match investment::export_tax_summary(&portfolios, year, &tax_path) { + Ok(_) => { + println!(" š tax_summary_{}.csv - Tax reporting summary", year); + } + Err(e) => { + println!("ā ļø Failed to export tax summary: {}", e); + } + } + } + } + Err(e) => { + println!("ā Failed to export CSV files: {}", e); + } + } + return Ok(()); + } + // Default behavior if no flags specified - if !portfolio && !net_worth && !allocation && !recommendations && !sync && !performance && import_wealthsimple.is_none() { + if !portfolio && !net_worth && !allocation && !recommendations && !sync && !performance && import_wealthsimple.is_none() && export_csv.is_none() { println!("š Investment Analysis Overview"); println!("Use one of these flags for detailed analysis:"); println!(" --portfolio Show portfolio overview and performance"); @@ -918,9 +971,14 @@ async fn main() -> anyhow::Result<()> { println!(" --recommendations Get personalized investment advice"); println!(" --sync Sync data from broker APIs"); 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)"); println!(); println!("š” Import your WealthSimple data:"); println!(" cargo run -- investment --import-wealthsimple ./data"); + println!("š” Export your investment data:"); + println!(" cargo run -- investment --export-csv ./exports"); + println!(" cargo run -- investment --export-csv ./exports --tax-year 2024"); println!("š” Coming Soon: Live broker API integration!"); } } diff --git a/src/smart_import.rs b/src/smart_import.rs new file mode 100644 index 0000000..23db54f --- /dev/null +++ b/src/smart_import.rs @@ -0,0 +1,204 @@ +use std::path::Path; +use std::fs; +use anyhow::Result; +use crate::detector::detect_bank_files; +use crate::parser::parse_transactions; +use crate::db::{insert_transactions, save_portfolios}; +use crate::investment::{import_wealthsimple_directory, update_market_prices}; + +#[derive(Debug)] +pub struct SmartImportStats { + pub banking_files: usize, + pub investment_files: usize, + pub transactions_imported: usize, + pub portfolios_imported: usize, + pub errors: Vec<String>, +} + +/// Smart import that figures out everything automatically +pub async fn smart_import(path: &str, dry_run: bool) -> Result<SmartImportStats> { + let mut stats = SmartImportStats { + banking_files: 0, + investment_files: 0, + transactions_imported: 0, + portfolios_imported: 0, + errors: Vec::new(), + }; + + let path = Path::new(path); + + if !path.exists() { + return Err(anyhow::anyhow!("Path does not exist: {}", path.display())); + } + + println!("š§ Smart Import: Analyzing {}...", path.display()); + + if path.is_file() { + // Handle single file + import_single_file(path, dry_run, &mut stats).await?; + } else { + // Handle directory - recursively scan everything + import_directory_recursive(path, dry_run, &mut stats).await?; + } + + Ok(stats) +} + +async fn import_directory_recursive( + dir: &Path, + dry_run: bool, + stats: &mut SmartImportStats +) -> Result<()> { + // First check if this looks like a WealthSimple investment directory + if is_wealthsimple_directory(dir) { + println!("š Detected WealthSimple investment directory"); + if !dry_run { + match import_wealthsimple_directory(dir.to_str().unwrap()) { + Ok(mut portfolios) => { + stats.investment_files += count_csv_files(dir)?; + stats.portfolios_imported += portfolios.len(); + + // Try to fetch market prices + println!("š° Fetching current market prices..."); + if let Err(e) = update_market_prices(&mut portfolios).await { + println!("ā ļø Warning: Failed to update some market prices: {}", e); + } + + // Save to database + save_portfolios(&portfolios)?; + println!("ā
Imported {} portfolios", portfolios.len()); + } + Err(e) => { + stats.errors.push(format!("Investment import error: {}", e)); + } + } + } else { + println!(" Would import WealthSimple data from: {}", dir.display()); + } + return Ok(()); + } + + // Otherwise, scan for banking files + let entries = fs::read_dir(dir)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Recursively process subdirectories using Box::pin to avoid infinite size + Box::pin(import_directory_recursive(&path, dry_run, stats)).await?; + } else if path.is_file() { + import_single_file(&path, dry_run, stats).await?; + } + } + + Ok(()) +} + +async fn import_single_file( + file_path: &Path, + dry_run: bool, + stats: &mut SmartImportStats +) -> Result<()> { + // Skip non-CSV/PDF files + let extension = file_path.extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + if !["csv", "pdf"].contains(&extension.to_lowercase().as_str()) { + return Ok(()); + } + + // Try to detect bank type + let detected_files = detect_bank_files(file_path.parent().unwrap().to_str().unwrap())?; + + if let Some(detected) = detected_files.iter().find(|f| Path::new(&f.path) == file_path) { + let filename = file_path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + println!("š {} ā {} ({}% confidence)", + filename, + detected.bank.to_uppercase(), + (detected.confidence * 100.0) as u8 + ); + + if !dry_run { + match parse_transactions(&detected.bank, file_path.to_str().unwrap()) { + Ok(transactions) => { + let count = insert_transactions(&transactions)?; + stats.banking_files += 1; + stats.transactions_imported += count; + println!(" ā
Imported {} transactions", count); + } + Err(e) => { + stats.errors.push(format!("{}: {}", filename, e)); + println!(" ā Error: {}", e); + } + } + } else { + println!(" Would import as {} transactions", detected.bank); + } + } + + Ok(()) +} + +fn is_wealthsimple_directory(dir: &Path) -> bool { + // Check if directory contains WealthSimple-style CSV files + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries { + if let Ok(entry) = entry { + if let Some(name) = entry.file_name().to_str() { + if name.contains("positions-") && name.ends_with(".csv") { + return true; + } + } + } + } + } + false +} + +fn count_csv_files(dir: &Path) -> Result<usize> { + let mut count = 0; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries { + if let Ok(entry) = entry { + if let Some(ext) = entry.path().extension() { + if ext == "csv" { + count += 1; + } + } + } + } + } + Ok(count) +} + +pub fn print_import_summary(stats: &SmartImportStats) { + println!("\nš Import Summary:"); + println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"); + + if stats.banking_files > 0 { + println!("š³ Banking Files: {}", stats.banking_files); + println!("š Transactions: {}", stats.transactions_imported); + } + + if stats.investment_files > 0 { + println!("š Investment Files: {}", stats.investment_files); + println!("š¼ Portfolios: {}", stats.portfolios_imported); + } + + if stats.errors.is_empty() { + println!("ā
Status: All files imported successfully!"); + } else { + println!("ā ļø Errors: {} file(s) had issues", stats.errors.len()); + for error in &stats.errors { + println!(" ⢠{}", error); + } + } + + println!("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā"); +}
\ No newline at end of file |
