const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; const analysis = @import("../analytics/analysis.zig"); // ── Brokerage position (normalized from any source) ───────── /// A single position row from a brokerage export, normalized to a common format. pub const BrokeragePosition = struct { account_number: []const u8, account_name: []const u8, symbol: []const u8, description: []const u8, quantity: ?f64, current_value: ?f64, cost_basis: ?f64, is_cash: bool, }; /// Parsed brokerage data grouped by account. pub const BrokerageAccount = struct { account_number: []const u8, account_name: []const u8, portfolio_account: ?[]const u8, positions: []const BrokeragePosition, total_value: f64, cash_value: f64, }; // ── Fidelity CSV parser ───────────────────────────────────── // // Limitations of this CSV parser: // // 1. NOT a general-purpose CSV parser. It handles the specific format // exported by Fidelity's "Download Positions" feature. // // 2. Does NOT handle quoted fields containing commas. Fidelity's export // does not quote fields with commas in practice (description fields // use spaces, not commas), but a truly compliant RFC 4180 parser would // need to handle "field,with,commas" as a single value. // // 3. Does NOT handle escaped quotes ("" inside quoted fields). // // 4. Does NOT handle multi-line values (newlines inside quoted fields). // // 5. Assumes UTF-8 with optional BOM (which Fidelity includes). // // 6. Stops parsing at the first blank line, which separates position data // from the Fidelity legal disclaimer footer. // // 7. Hardcodes the expected column layout. If Fidelity changes the CSV // format (adds/removes/reorders columns), this parser will break. // The header row is validated to catch this. // // 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of // $, commas, and leading +/- signs. Negative values wrapped in // parentheses are NOT handled (Fidelity uses -$X.XX format). // // 9. Money market rows (symbol ending in **) are treated as cash. // // For a production-grade CSV parser, consider a library that handles // RFC 4180 fully (quoted fields, escaping, multi-line values). const fidelity_expected_columns = 16; /// Column indices in the Fidelity CSV export. /// Based on: Account Number, Account Name, Symbol, Description, Quantity, /// Last Price, Last Price Change, Current Value, Today's Gain/Loss Dollar, /// Today's Gain/Loss Percent, Total Gain/Loss Dollar, Total Gain/Loss Percent, /// Percent Of Account, Cost Basis Total, Average Cost Basis, Type const FidelityCol = struct { const account_number = 0; const account_name = 1; const symbol = 2; const description = 3; const quantity = 4; const last_price = 5; const current_value = 7; const cost_basis_total = 13; const avg_cost_basis = 14; const type_col = 15; }; /// Parse a Fidelity CSV positions export into BrokeragePosition slices. /// All string fields in the returned positions are slices into `data`, /// so the caller must keep `data` alive for as long as the positions are used. /// Only the returned slice itself is heap-allocated (caller must free it). pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition { var positions = std.ArrayList(BrokeragePosition).empty; errdefer positions.deinit(allocator); // Skip UTF-8 BOM if present var content = data; if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) { content = content[3..]; } var lines = std.mem.splitScalar(u8, content, '\n'); // Validate header row const header_line = lines.next() orelse return error.EmptyFile; const header_trimmed = std.mem.trimRight(u8, header_line, &.{ '\r', ' ' }); if (header_trimmed.len == 0) return error.EmptyFile; if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) { return error.UnexpectedHeader; } // Parse data rows while (lines.next()) |line| { const trimmed = std.mem.trimRight(u8, line, &.{ '\r', ' ' }); if (trimmed.len == 0) break; // Skip lines starting with " (disclaimer text) if (trimmed[0] == '"') break; var col_iter = std.mem.splitScalar(u8, trimmed, ','); var cols: [fidelity_expected_columns][]const u8 = undefined; var col_count: usize = 0; while (col_iter.next()) |col| { if (col_count < fidelity_expected_columns) { cols[col_count] = col; col_count += 1; } } if (col_count < fidelity_expected_columns) continue; const symbol_raw = std.mem.trim(u8, cols[FidelityCol.symbol], &.{ ' ', '"' }); if (symbol_raw.len == 0) continue; // Money market funds (symbol ending in **) are cash positions. // Note: Fidelity's Type column says "Cash" vs "Margin" to indicate // the account settlement type, NOT that the security is cash. // Only the ** suffix reliably identifies money market / cash holdings. // Also treat positions as cash when both price and cost basis are $1.00 // (e.g. FDRXX "FID GOV CASH RESERVE" — no ** suffix). const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]); // Strip ** suffix from money market symbols for display const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**")) symbol_raw[0 .. symbol_raw.len - 2] else symbol_raw; try positions.append(allocator, .{ .account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }), .account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }), .symbol = symbol_clean, .description = std.mem.trim(u8, cols[FidelityCol.description], &.{ ' ', '"' }), .quantity = if (is_cash) null else parseDollarAmount(cols[FidelityCol.quantity]), .current_value = parseDollarAmount(cols[FidelityCol.current_value]), .cost_basis = if (is_cash) null else parseDollarAmount(cols[FidelityCol.cost_basis_total]), .is_cash = is_cash, }); } return positions.toOwnedSlice(allocator); } /// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00". /// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values. fn parseDollarAmount(raw: []const u8) ?f64 { const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' }); if (trimmed.len == 0) return null; // Strip leading +/- and $, remove commas var buf: [64]u8 = undefined; var pos: usize = 0; var negative = false; for (trimmed) |c| { if (c == '-') { negative = true; } else if (c == '$' or c == '+' or c == ',') { continue; } else { if (pos >= buf.len) return null; buf[pos] = c; pos += 1; } } if (pos == 0) return null; const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null; return if (negative) -val else val; } /// Returns true when both the last price and average cost basis parse to exactly $1.00, /// indicating a money-market or cash-equivalent position (e.g. FDRXX). fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool { const price = parseDollarAmount(price_raw) orelse return false; const cost = parseDollarAmount(cost_raw) orelse return false; return price == 1.0 and cost == 1.0; } // ── Schwab CSV parser ─────────────────────────────────────── // // Parses the per-account positions CSV exported from Schwab's website // (Accounts → Positions → Export for a single account). // // 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. const schwab_expected_columns = 17; const SchwabCol = 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 splitSchwabCsvLine(line: []const u8, cols: *[schwab_expected_columns][]const u8) usize { var col_count: usize = 0; var pos: usize = 0; while (pos < line.len and col_count < schwab_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