1use anyhow::Result;
3use calamine::{open_workbook, Data, DataType, Reader, Xlsx};
4use csv::ReaderBuilder;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8pub const SKIP_SYMS: &[&str] = &["", "NAN", "82981P105", "SIRI"];
10
11pub 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", ];
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 if sym.contains("BROKERAGE") || sym.contains("CUSIP") || sym.contains("PENDING") {
70 return true;
71 }
72 false
73}
74
75pub 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 } true
88}
89
90pub 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
156pub 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
208pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct TargetPosition {
272 pub symbol: String,
273 pub weight: f64, }
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 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
344pub 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
361pub 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 } 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 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}