Skip to main content

portfolio_analyzer/
portfolio.rs

1/// Portfolio parsing: Fidelity All Accounts, Chase, Citi, Excel targets
2use anyhow::Result;
3use calamine::{open_workbook, Data, DataType, Reader, Xlsx};
4use csv::ReaderBuilder;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8// True junk: no meaningful value, never a real holding
9pub const SKIP_SYMS: &[&str] = &["", "NAN", "82981P105", "SIRI"];
10
11// Cash / money-market: real holdings, use CSV face value (NAV ≈ $1)
12pub const CASH_SYMS: &[&str] = &[
13    "SPAXX**",
14    "FCASH**",
15    "FDRXX**",
16    "FDRXX",
17    "FZBXX",
18    "FSBXX",
19    "FZDXX",
20    "CORE**",
21    "CASH_USD",
22    "SPAXX",
23    "CUSTODIAL_CASH", // Orion SMA fund custodial cash
24];
25
26pub const TAXABLE_NAMES: &[&str] = &[
27    "TRUST: Suresh 1self managed",
28    "TRUST: Suresh 2 self managed",
29];
30
31pub const NONTAXABLE_NAMES: &[&str] = &[
32    "ROLLOVER IRA Suresh",
33    "ROLLOVER IRA Check capital",
34    "ROTH IRA- Suresh",
35    "ROTH IRA Check capital",
36    "ROLL OVER IRA  FISHER",
37    "Orion TRADITIONAL IRA",
38];
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Position {
42    pub symbol: String,
43    pub quantity: f64,
44    pub value: f64,
45    pub account: String,
46    pub unrealized_gain: f64,
47    pub cost_basis: f64,
48}
49
50fn clean_curr(s: &str) -> f64 {
51    s.replace('$', "")
52        .replace(',', "")
53        .replace('+', "")
54        .replace("--", "0")
55        .replace('%', "")
56        .trim()
57        .parse::<f64>()
58        .unwrap_or(0.0)
59}
60
61fn is_junk(sym: &str) -> bool {
62    if sym.is_empty() {
63        return true;
64    }
65    if SKIP_SYMS.contains(&sym) {
66        return true;
67    }
68    // sym is already uppercased at call sites
69    if sym.contains("BROKERAGE") || sym.contains("CUSIP") || sym.contains("PENDING") {
70        return true;
71    }
72    false
73}
74
75// True for real equity (not cash/MM, not options, not junk).
76// Use for weight/overlap computations and Yahoo Finance price fetching.
77pub fn is_equity(sym: &str) -> bool {
78    if is_junk(sym) {
79        return false;
80    }
81    if CASH_SYMS.contains(&sym) {
82        return false;
83    }
84    if sym.starts_with('-') {
85        return false;
86    } // options positions
87    true
88}
89
90// ── Fidelity All Accounts CSV ─────────────────────────────────────────────────
91pub fn parse_fidelity_all_accounts(path: &str) -> Result<Vec<Position>> {
92    let mut rdr = ReaderBuilder::new().flexible(true).from_path(path)?;
93    let headers = rdr.headers()?.clone();
94    let h: Vec<&str> = headers.iter().collect();
95
96    let find = |name: &str| -> Option<usize> {
97        h.iter()
98            .position(|&s| s.trim_start_matches('\u{feff}').trim() == name)
99    };
100
101    let acct_name_idx = find("Account Name").unwrap_or(1);
102    let sym_idx = find("Symbol").unwrap_or(2);
103    let qty_idx = find("Quantity").unwrap_or(4);
104    let cv_idx = find("Current Value").unwrap_or(7);
105    let gain_idx = find("Total Gain/Loss Dollar");
106    let cost_idx = find("Cost Basis Total");
107
108    let mut positions = Vec::new();
109    for result in rdr.records() {
110        let rec = match result {
111            Ok(r) => r,
112            Err(_) => continue,
113        };
114        let need = cv_idx.max(qty_idx).max(sym_idx).max(acct_name_idx);
115        if rec.len() <= need {
116            continue;
117        }
118
119        let acct_name = rec.get(acct_name_idx).unwrap_or("").trim().to_string();
120        let sym = rec.get(sym_idx).unwrap_or("").trim().to_uppercase();
121
122        if is_junk(&sym) {
123            continue;
124        }
125
126        let is_taxable = TAXABLE_NAMES.iter().any(|&t| acct_name == t);
127        let is_nontax = NONTAXABLE_NAMES.iter().any(|&t| acct_name == t);
128        if !is_taxable && !is_nontax {
129            continue;
130        }
131
132        let value = clean_curr(rec.get(cv_idx).unwrap_or("0"));
133        let quantity = clean_curr(rec.get(qty_idx).unwrap_or("0"));
134        let unrealized_gain = gain_idx
135            .map(|i| clean_curr(rec.get(i).unwrap_or("0")))
136            .unwrap_or(0.0);
137        let cost_basis = cost_idx
138            .map(|i| clean_curr(rec.get(i).unwrap_or("0")))
139            .unwrap_or(0.0);
140        if value <= 0.0 {
141            continue;
142        }
143
144        positions.push(Position {
145            symbol: sym,
146            quantity,
147            value,
148            account: acct_name,
149            unrealized_gain,
150            cost_basis,
151        });
152    }
153    Ok(positions)
154}
155
156// ── Chase CSV ─────────────────────────────────────────────────────────────────
157pub fn parse_chase(path: &str) -> Result<Vec<Position>> {
158    let mut rdr = ReaderBuilder::new().flexible(true).from_path(path)?;
159    let headers = rdr.headers()?.clone();
160    let h: Vec<&str> = headers.iter().collect();
161
162    let ticker_idx = h.iter().position(|&s| s.trim() == "Ticker").unwrap_or(4);
163    let qty_idx = h.iter().position(|&s| s.trim() == "Quantity").unwrap_or(6);
164    let value_idx = h.iter().position(|&s| s.trim() == "Value").unwrap_or(15);
165    let acct_idx = h
166        .iter()
167        .position(|&s| s.trim() == "Acct Type")
168        .unwrap_or(61);
169    let gain_idx = h.iter().position(|&s| s.trim() == "Unrealized G/L Amt.");
170    let cost_idx = h.iter().position(|&s| s.trim() == "Cost");
171
172    let mut out = Vec::new();
173    for result in rdr.records() {
174        let rec = match result {
175            Ok(r) => r,
176            Err(_) => continue,
177        };
178        if rec.get(acct_idx).unwrap_or("").trim() != "Cash" {
179            continue;
180        }
181        let sym = rec.get(ticker_idx).unwrap_or("").trim().to_uppercase();
182        if is_junk(&sym) {
183            continue;
184        }
185        let value = clean_curr(rec.get(value_idx).unwrap_or("0"));
186        let quantity = clean_curr(rec.get(qty_idx).unwrap_or("0"));
187        let unrealized_gain = gain_idx
188            .map(|i| clean_curr(rec.get(i).unwrap_or("0")))
189            .unwrap_or(0.0);
190        let cost_basis = cost_idx
191            .map(|i| clean_curr(rec.get(i).unwrap_or("0")))
192            .unwrap_or(0.0);
193        if value <= 0.0 {
194            continue;
195        }
196        out.push(Position {
197            symbol: sym,
198            quantity,
199            value,
200            account: "Chase Brokerage".to_string(),
201            unrealized_gain,
202            cost_basis,
203        });
204    }
205    Ok(out)
206}
207
208// ── Citi CSV ──────────────────────────────────────────────────────────────────
209pub fn parse_citi(path: &str) -> Result<Vec<Position>> {
210    let content = std::fs::read_to_string(path)?;
211    let lines: Vec<&str> = content.lines().collect();
212    if lines.len() <= 10 {
213        return Ok(vec![]);
214    }
215
216    let csv_part = lines[10..].join("\n");
217    let mut rdr = ReaderBuilder::new()
218        .flexible(true)
219        .from_reader(csv_part.as_bytes());
220    let headers = rdr.headers()?.clone();
221    let h: Vec<&str> = headers.iter().collect();
222
223    let sym_idx = h
224        .iter()
225        .position(|&s| s.trim() == "Security ID")
226        .unwrap_or(1);
227    let qty_idx = h.iter().position(|&s| s.trim() == "Quantity").unwrap_or(7);
228    let mv_idx = h
229        .iter()
230        .position(|&s| s.trim() == "Market Value")
231        .unwrap_or(13);
232    let gain_idx = h.iter().position(|&s| s.trim() == "Gain/Loss $");
233
234    let mut out = Vec::new();
235    for result in rdr.records() {
236        let rec = match result {
237            Ok(r) => r,
238            Err(_) => continue,
239        };
240        let sym = rec
241            .get(sym_idx)
242            .unwrap_or("")
243            .replace(' ', "")
244            .to_uppercase();
245        if is_junk(&sym) {
246            continue;
247        }
248        let value = clean_curr(rec.get(mv_idx).unwrap_or("0"));
249        let quantity = clean_curr(rec.get(qty_idx).unwrap_or("0"));
250        let unrealized_gain = gain_idx
251            .map(|i| clean_curr(rec.get(i).unwrap_or("0")))
252            .unwrap_or(0.0);
253        let cost_basis = (value - unrealized_gain).max(0.0);
254        if value <= 0.0 {
255            continue;
256        }
257        out.push(Position {
258            symbol: sym,
259            quantity,
260            value,
261            account: "Citi Brokerage".to_string(),
262            unrealized_gain,
263            cost_basis,
264        });
265    }
266    Ok(out)
267}
268
269// ── Excel target portfolios ───────────────────────────────────────────────────
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct TargetPosition {
272    pub symbol: String,
273    pub weight: f64, // as fraction (0..1)
274}
275
276pub fn parse_excel_target(path: &str) -> Result<Vec<TargetPosition>> {
277    let mut wb: Xlsx<_> = open_workbook(path)?;
278    let sheet_name = wb
279        .sheet_names()
280        .first()
281        .cloned()
282        .ok_or_else(|| anyhow::anyhow!("no sheets"))?;
283    let ws = wb
284        .worksheet_range(&sheet_name)
285        .map_err(|e| anyhow::anyhow!("range error: {e}"))?;
286
287    let mut ticker_col = None;
288    let mut weight_col = None;
289    let mut data_start = 0usize;
290
291    'outer: for (row_idx, row) in ws.rows().enumerate() {
292        for (col_idx, cell) in row.iter().enumerate() {
293            if let Data::String(s) = cell {
294                let s = s.trim();
295                if s == "Ticker" {
296                    ticker_col = Some(col_idx);
297                }
298                if s == "Opt %" {
299                    weight_col = Some(col_idx);
300                }
301            }
302        }
303        if ticker_col.is_some() && weight_col.is_some() {
304            data_start = row_idx + 1;
305            break 'outer;
306        }
307    }
308
309    let tc = ticker_col.ok_or_else(|| anyhow::anyhow!("no Ticker column"))?;
310    let wc = weight_col.ok_or_else(|| anyhow::anyhow!("no Opt % column"))?;
311
312    let rows_vec: Vec<Vec<Data>> = ws.rows().skip(data_start).map(|r| r.to_vec()).collect();
313
314    // Collect equity rows with raw weights first, then renormalise over kept rows only.
315    // This ensures cash/junk rows excluded by is_equity() don't dilute the equity weights.
316    let mut positions = Vec::new();
317    for row in &rows_vec {
318        let sym = match row.get(tc) {
319            Some(Data::String(s)) => s.trim().to_uppercase(),
320            _ => continue,
321        };
322        let w = match row.get(wc) {
323            Some(c) => c.get_float().unwrap_or(0.0),
324            None => continue,
325        };
326        if w <= 0.0 || !is_equity(&sym) {
327            continue;
328        }
329        positions.push(TargetPosition {
330            symbol: sym,
331            weight: w,
332        });
333    }
334
335    let kept_total: f64 = positions.iter().map(|p| p.weight).sum();
336    if kept_total > 0.0 {
337        for p in &mut positions {
338            p.weight /= kept_total;
339        }
340    }
341    Ok(positions)
342}
343
344// ── Aggregate positions ────────────────────────────────────────────────────────
345pub fn aggregate(positions: &[Position]) -> HashMap<String, f64> {
346    let mut map: HashMap<String, f64> = HashMap::new();
347    for p in positions {
348        *map.entry(p.symbol.clone()).or_insert(0.0) += p.value;
349    }
350    map
351}
352
353pub fn to_weights(agg: &HashMap<String, f64>) -> HashMap<String, f64> {
354    let total: f64 = agg.values().sum();
355    if total == 0.0 {
356        return HashMap::new();
357    }
358    agg.iter().map(|(k, &v)| (k.clone(), v / total)).collect()
359}
360
361// ── Orion SMA fund CSV ────────────────────────────────────────────────────────
362// Two formats supported:
363//   Brinker:       Name, Ticker, Shares, Price, Market Value, Portfolio %
364//   Capital Group: Company Name, Ticker, Shares/Quantity, Price, Total Value, Portfolio Weight
365pub fn parse_orion_fund(path: &str, fund_name: &str) -> Result<Vec<Position>> {
366    let mut rdr = ReaderBuilder::new().flexible(true).from_path(path)?;
367    let headers = rdr.headers()?.clone();
368    let h: Vec<&str> = headers.iter().collect();
369
370    let ticker_idx = h.iter().position(|&s| s.trim() == "Ticker").unwrap_or(1);
371    let qty_idx = h
372        .iter()
373        .position(|&s| {
374            let t = s.trim();
375            t == "Shares" || t == "Shares/Quantity"
376        })
377        .unwrap_or(2);
378    let val_idx = h
379        .iter()
380        .position(|&s| {
381            let t = s.trim();
382            t == "Market Value" || t == "Total Value"
383        })
384        .unwrap_or(4);
385
386    let mut out = Vec::new();
387    for result in rdr.records() {
388        let rec = match result {
389            Ok(r) => r,
390            Err(_) => continue,
391        };
392        let raw_sym = rec.get(ticker_idx).unwrap_or("").trim().to_uppercase();
393        if raw_sym.is_empty() {
394            continue;
395        } // total row
396
397        let value = clean_curr(rec.get(val_idx).unwrap_or("0"));
398        let quantity = clean_curr(rec.get(qty_idx).unwrap_or("0"));
399        if value <= 0.0 {
400            continue;
401        }
402
403        // CUSTODIAL_CASH is in CASH_SYMS → is_equity returns false, included at face value
404        // Other junk skipped
405        if raw_sym != "CUSTODIAL_CASH" && is_junk(&raw_sym) {
406            continue;
407        }
408
409        out.push(Position {
410            symbol: raw_sym,
411            quantity,
412            value,
413            account: fund_name.to_string(),
414            unrealized_gain: 0.0,
415            cost_basis: 0.0,
416        });
417    }
418    Ok(out)
419}