//! Schwab export parsers. //! //! Parses two distinct Schwab inputs: //! //! 1. The per-account positions CSV exported from Schwab's website //! (Accounts → Positions → Export). One file per account. //! //! 2. The freeform account-summary text the user pastes from //! Schwab's Accounts overview page. One paste covers all //! accounts at once but only carries cash + total-value //! aggregates, no per-position detail. //! //! ## Schwab CSV — limitations //! //! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export //! format where every field is double-quoted. //! //! 2. Handles simple quoted fields ("value") but does NOT handle escaped //! quotes ("value with ""quotes"" inside") or multi-line quoted fields. //! Schwab's export does not use these in practice. //! //! 3. The account number and name are extracted from the title line: //! "Positions for account ... as of ..." //! //! 4. Rows with symbol "Cash & Cash Investments" are treated as cash. //! The row with symbol "Positions Total" is skipped. //! //! 5. Hardcodes the expected column layout. If Schwab changes the CSV //! format, this parser will break. The header row is not validated //! beyond being skipped. //! //! ## Schwab summary — limitations //! //! The expected paste format is repeating blocks of 2-3 lines per //! account: //! //! Account Name //! Account number ending in NNN ...NNN //! Type IRA $46.44 $227,058.15 +$1,072.88 +0.47% //! //! 1. NOT a CSV parser — parses freeform text pasted from the Schwab UI. //! //! 2. Identifies account blocks by the "Account number ending in" line. //! The account name is the non-empty line immediately before it. //! //! 3. The values line (cash, total, change, pct) is identified by finding //! dollar amounts. It tolerates missing or extra fields — it looks for //! the first two dollar amounts as cash and total value. //! //! 4. Skips summary lines like "Investment Total", "Day Change Total", //! and "Day Change Percent Total" which appear at the end of the paste. //! //! 5. Tolerant of partial pastes: if the user copies headers once but //! not on subsequent pastes, or includes extra blank lines, the parser //! still finds account blocks by the "Account number ending in" anchor. //! //! 6. The account number is extracted from "...NNN" at the end of the //! account number line (the last whitespace-separated token). const std = @import("std"); const portfolio_mod = @import("../models/portfolio.zig"); const types = @import("types.zig"); const BrokeragePosition = types.BrokeragePosition; const parseDollarAmount = types.parseDollarAmount; const expected_columns = 17; const Col = struct { const symbol = 0; const price = 4; const quantity = 5; const market_value = 8; const cost_basis = 9; const asset_type = 16; }; /// Split a Schwab CSV line on commas, stripping surrounding quotes from each field. /// Returns the number of columns parsed. Fields are slices into the input line. fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize { var col_count: usize = 0; var pos: usize = 0; while (pos < line.len and col_count < expected_columns) { if (line[pos] == '"') { // Quoted field: find closing quote const start = pos + 1; pos = start; while (pos < line.len and line[pos] != '"') : (pos += 1) {} cols[col_count] = line[start..pos]; col_count += 1; if (pos < line.len) pos += 1; // skip closing quote if (pos < line.len and line[pos] == ',') pos += 1; // skip comma } else if (line[pos] == ',') { cols[col_count] = ""; col_count += 1; pos += 1; } else { // Unquoted field const start = pos; while (pos < line.len and line[pos] != ',') : (pos += 1) {} cols[col_count] = line[start..pos]; col_count += 1; if (pos < line.len) pos += 1; // skip comma } } return col_count; } /// Extract account name and number from Schwab title line. /// Format: "Positions for account ... as of