diff --git a/src/commands/audit.zig b/src/commands/audit.zig index eb47419..5aaf749 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -180,6 +180,464 @@ fn parseDollarAmount(raw: []const u8) ?f64 { return if (negative) -val else val; } +// ── 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