handle schwab all accounts csv download

This commit is contained in:
Emil Lerch 2026-10-06 10:45:07 -07:00
parent 858e9874b4
commit 635a0557b4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 518 additions and 134 deletions

View file

@ -26,16 +26,18 @@ Fargo**.
| Broker | How to export | Flag |
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` |
| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab <CSV>` |
| **Schwab** (positions) | *Accounts -> Positions*: pick **All Brokerage Accounts** (or one account) in the dropdown -> **Export** | `--schwab <CSV>` |
| **Schwab** (summary) | *Accounts -> Summary*: select the accounts table and copy it ([what to copy](#schwab-summary-what-to-copy)) | `--schwab-summary` |
| **Wells Fargo** | Download Type **Portfolio-Expanded Detail**, Portfolio View **Positions**, all brokerage accounts (an `.xls`) | `--wells-fargo <XLS>` |
The two Schwab inputs differ in detail: the **per-account CSV** has full
per-position data (shares, price, value); the **summary paste** carries
only each account's cash and total value, so it reconciles *totals*, not
individual holdings. Use the summary for a quick "are my account totals
right?", the CSV for position-level checks. (Fidelity money-market rows
and Schwab "Cash & Cash Investments" rows are recognized as cash.)
The two Schwab inputs differ in detail: the **positions CSV** has full
per-position data (shares, price, value) for whichever accounts you
picked -- every one of them with **All Brokerage Accounts**, which is
the easy choice; the **summary paste** carries only each account's cash
and total value, so it reconciles *totals*, not individual holdings.
Use the summary for a quick "are my account totals right?", the CSV for
position-level checks. (Fidelity money-market rows and Schwab "Cash &
Cash Investments" rows are recognized as cash.)
The Fidelity **Download** isn't a top-level button -- it's behind the
three-dot (**⋮**) menu at the top-right of the positions panel:
@ -86,7 +88,7 @@ the summary reconciles **account totals**, not individual positions.
```bash
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/Positions-Individual.csv
ZFIN_HOME=~/finance zfin audit --schwab ~/Downloads/All-Accounts-Positions.csv
ZFIN_HOME=~/finance zfin audit --schwab-summary # then paste the page, Ctrl-D
```
@ -282,9 +284,10 @@ portfolio. It does that through
[`accounts.srf`](set-up-accounts.md#2-add-institution-and-account-number-for-auditing):
- The export carries an account number -- Schwab's from the "Positions
for account ...1234" title, Fidelity's from the Account Number column,
the summary's from "...ending in 1234", Wells Fargo's from each row's
`*1234`.
for account ...1234" title (or, in an all-accounts export, the
"...1234" line that opens each account's section), Fidelity's from the
Account Number column, the summary's from "...ending in 1234", Wells
Fargo's from each row's `*1234`.
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
`schwab`, `wells_fargo`) **and** `account_number::` match, and
compares against that account's lots.
@ -370,11 +373,13 @@ so auditing against the export you just imported shows no drift.
- The parsers are **broker-specific**. The CSV parsers hardcode each
export's column layout -- if Fidelity or Schwab changes their format,
parsing can break (both validate their header to catch this). They are
not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields)
-- fine for the real exports, not for arbitrary CSVs. The Wells Fargo
parser finds columns by header name and checks every per-account total
against the lots under it, so a layout change fails loudly.
parsing can break (both validate their header to catch this, and the
Schwab parser checks each account's "Positions Total" against the rows
above it). They are not full RFC-4180 CSV parsers (no escaped quotes or
multi-line fields) -- fine for the real exports, not for arbitrary
CSVs. The Wells Fargo parser finds columns by header name and checks
every per-account total against the lots under it, so a layout change
fails loudly.
- Matching is only as good as the `institution::` / `account_number::`
entries you keep in `accounts.srf`.
- Options, CDs, and cash are reconciled separately from share counts.
@ -385,11 +390,11 @@ manual nudge.
### Wells Fargo: one file, every account, real lots
The Wells Fargo spreadsheet differs from the CSV exports in two ways
worth knowing:
Two things about the Wells Fargo spreadsheet are worth knowing:
- **It covers the whole household.** Every brokerage account is in one
file, each row tagged with its account as `*1234`.
- **It covers the whole household.** Like Fidelity's download and
Schwab's **All Brokerage Accounts** export, every brokerage account is
in one file, each row tagged with its account as `*1234`.
- **It lists tax lots, not positions.** Audit sums them per account and
symbol before comparing, so it doesn't matter whether your portfolio
holds the position as one lot or many. It also means

View file

@ -21,7 +21,7 @@ discrepancies.
| `--verbose` | Show full reconciliation output even when clean. |
| `--stale-days <N>` | Manual-price staleness threshold (default 3). |
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
| `--schwab <CSV>` | Schwab per-account positions CSV. |
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
| `--schwab-summary` | Schwab account summary: paste from the summary page to stdin, then Ctrl-D. |
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |

View file

@ -33,14 +33,16 @@ dropped.
|--------------------------|-----------------------------------------------------------------|
| `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
| `--fidelity <CSV>` | Fidelity positions CSV. |
| `--schwab <CSV>` | Schwab per-account positions CSV. |
| `--schwab <CSV>` | Schwab positions CSV (one account or All Brokerage Accounts). |
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
| `-y, --yes` | Don't prompt before overwriting an existing file. |
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
next to the target with `institution::` + `account_number::` entries
matching the export; import refuses to write when an export account is
unmapped.
unmapped. A Schwab **All Brokerage Accounts** export brings every
account into the target, so to import just one account, export that
account alone.
A target that doesn't exist yet is created in the current directory, so
run a first import from the portfolio's own directory.

View file

@ -3,9 +3,9 @@
//! Schwab has two export shapes, so this module carries more than
//! the Fidelity one:
//!
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account
//! positions shape Fidelity uses, so it feeds the shared
//! `common.compareAccounts` engine via `reconcileCsv`.
//! 1. **Positions CSV** (`--schwab`) - one account or all of them,
//! the same per-position shape Fidelity uses, so it feeds the
//! shared `common.compareAccounts` engine via `reconcileCsv`.
//! 2. **Account summary paste** (`--schwab-summary`) - a
//! per-account totals-only view with no per-symbol detail. This
//! is the one genuinely broker-specific reconciler, with its own
@ -44,13 +44,14 @@ pub const SchwabAccountComparison = struct {
has_discrepancy: bool,
};
// ── Per-account positions CSV (--schwab) ─────────────────────
// ── Positions CSV (--schwab) ─────────────────────────────────
/// Parse a Schwab per-account positions CSV and reconcile it against
/// the portfolio via the shared engine. Returns owned
/// `AccountComparison` results (free each `.comparisons` slice, then
/// the results slice). String fields borrow from `csv_data`, which
/// must outlive them. Propagates parser and allocation errors.
/// Parse a Schwab positions CSV - either layout, one account or all
/// of them - and reconcile it against the portfolio via the shared
/// engine. Returns owned `AccountComparison` results, one per account
/// in the export (free each `.comparisons` slice, then the results
/// slice). String fields borrow from `csv_data`, which must outlive
/// them. Propagates parser and allocation errors.
pub fn reconcileCsv(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
@ -59,11 +60,11 @@ pub fn reconcileCsv(
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
const positions = try schwab_parser.parseCsv(allocator, csv_data);
// Result strings borrow from `csv_data`, not the positions slice,
// so freeing the slice array here is safe.
defer allocator.free(parsed.positions);
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
defer allocator.free(positions);
return common.compareAccounts(allocator, portfolio, positions, account_map, "schwab", prices, as_of);
}
// ── Account summary paste (--schwab-summary) ─────────────────
@ -562,6 +563,72 @@ test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
try std.testing.expect(found_amzn);
}
test "reconcileCsv: an all-accounts CSV reconciles each section as its own account" {
const allocator = std.testing.allocator;
const header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
const csv =
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
"\n" ++
"\"Sample_IRA ...1234\"\n" ++
header ++
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
"\n\n" ++
"\"Sample_Brokerage ...5678\"\n" ++
header ++
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"100%\",\"Equity\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
"\n\n" ++
"\"Sample_Account ...9012\"\n" ++
header ++
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$5,000.00\",\"$500.00\",\"10%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$5,000.00\",\"$500.00\",\"10%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample Brokerage" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
// 9012 is deliberately absent: an account in the export that this
// portfolio does not track.
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "5678" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTI", 300);
try prices.put("AAPL", 200);
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 10, 6));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 3), results.len);
var seen: usize = 0;
for (results) |r| {
if (std.mem.eql(u8, r.account_number, "1234")) {
try std.testing.expectEqualStrings("Sample IRA", r.account_name);
try std.testing.expect(!r.has_discrepancies);
} else if (std.mem.eql(u8, r.account_number, "5678")) {
try std.testing.expectEqualStrings("Sample Brokerage", r.account_name);
try std.testing.expect(!r.has_discrepancies);
} else {
try std.testing.expectEqualStrings("9012", r.account_number);
// Unmapped: no portfolio account, broker-side name kept.
try std.testing.expectEqualStrings("", r.account_name);
try std.testing.expectEqualStrings("Sample_Account", r.brokerage_name);
}
seen += 1;
}
try std.testing.expectEqual(@as(usize, 3), seen);
}
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
const allocator = std.testing.allocator;
const data =

View file

@ -71,7 +71,10 @@ pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
return .fidelity_csv;
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
// Schwab positions CSV: starts with a quoted title line - "Positions
// for account ..." (one account) or "Positions for All-Accounts ..."
// (every account). Both are `schwab_csv`; `schwab.parseCsv` reads
// either layout.
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
// Schwab summary: the "Account number ending in" anchor is exactly what
@ -211,6 +214,10 @@ test "detectBrokerFileKind: schwab csv with Positions header" {
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
}
test "detectBrokerFileKind: schwab all-accounts csv is a schwab csv" {
const data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n\"Symbol\",\"Description\"";
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
}
test "detectBrokerFileKind: summary-shaped text without the anchor is not detected" {
// Regression guard against re-introducing the old "account-type label +
// $" fallback. This blob looks summary-ish (account-type words, dollar

View file

@ -2,14 +2,47 @@
//!
//! Parses two distinct Schwab inputs:
//!
//! 1. The per-account positions CSV exported from Schwab's website
//! (Accounts -> Positions -> Export). One file per account.
//! 1. The positions CSV exported from Schwab's website (Accounts ->
//! Positions -> Export). Comes in two layouts, both read by
//! `parseCsv`, depending on the account dropdown:
//! - one account: a single section, account named in the title;
//! - "All Brokerage Accounts": one section 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 - layout
//!
//! Single account:
//!
//! "Positions for account <NAME> ...<NUM> as of <TIME>, <YYYY/MM/DD>"
//! <blank>
//! "Symbol","Description",...,"Asset Type",
//! <position rows>
//! "Cash & Cash Investments",...
//! "Positions Total",...
//!
//! All accounts - the same section repeated, each opened by an account
//! line, sections separated by blank lines:
//!
//! "Positions for All-Accounts as of <TIME>, <MM/DD/YYYY>"
//! <blank>
//! "<NAME> ...<NUM>"
//! "Symbol","Description",...,"Asset Type",
//! <position rows>
//! "Cash & Cash Investments",...
//! "Positions Total",...
//! <blank>
//! <blank>
//! "<NAME> ...<NUM>"
//! ...
//!
//! Account names in the all-accounts layout carry underscores where the
//! single-account title has spaces. Names are display-only - matching
//! against `accounts.srf` is by number - so they are passed through as-is.
//!
//! ## Schwab CSV - limitations
//!
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
@ -19,17 +52,21 @@
//! 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 <NAME> ...<NUM> as of ..."
//! 3. Every position row is stamped with the account of the section it
//! sits in. The grouping by account happens downstream, in
//! `reconcile/common.compareAccounts`.
//!
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
//! The row with symbol "Positions Total" is skipped.
//! The row with symbol "Positions Total" is not a position: it is
//! checked against the market values summed above it (see
//! `checkTotal`), so a mis-split row fails loudly as
//! `error.InconsistentExport`.
//!
//! 5. Hardcodes the expected column layout via fixed indices. The header
//! row is validated (see `validateHeader`) to confirm the columns the
//! parser reads are where it expects them, so a Schwab reorder/rename of
//! a read-column fails loudly as `error.UnexpectedHeader` instead of
//! silently mis-reading a value.
//! 5. Hardcodes the expected column layout via fixed indices. Every
//! section's header row is validated (see `validateHeader`) to confirm
//! the columns the parser reads are where it expects them, so a Schwab
//! reorder/rename of a read-column fails loudly as
//! `error.UnexpectedHeader` instead of silently mis-reading a value.
//!
//! ## Schwab summary - limitations
//!
@ -60,12 +97,16 @@
//! account number line (the last whitespace-separated token).
const std = @import("std");
const builtin = @import("builtin");
const Money = @import("../Money.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig");
const BrokeragePosition = types.BrokeragePosition;
const parseDollarAmount = types.parseDollarAmount;
const log = std.log.scoped(.schwab);
const expected_columns = 17;
const Col = struct {
@ -149,66 +190,141 @@ fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
return col_count;
}
/// Extract account name and number from Schwab title line.
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
/// Returns {name, number} or null if the line doesn't match.
fn parseTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
const prefix = "Positions for account ";
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
const rest = stripped[prefix.len..];
// Find "..." which separates name from account number
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
// Account number: after "..." until " as of" or end
const after_dots = rest[dots_idx + 3 ..];
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
/// The account a run of position rows belongs to.
const Account = struct { name: []const u8, number: []const u8 };
/// Split Schwab's "<NAME> ...<NUM>" account label. The number is what
/// follows the last "..." (numbers never contain dots, names might); the
/// name is everything before it. Null unless both halves are non-empty
/// and the number is a single token.
fn parseAccountLabel(text: []const u8) ?Account {
const stripped = std.mem.trim(u8, text, " ");
const dots_idx = std.mem.lastIndexOf(u8, stripped, "...") orelse return null;
const name = std.mem.trimEnd(u8, stripped[0..dots_idx], " ");
const number = stripped[dots_idx + 3 ..];
if (name.len == 0 or number.len == 0) return null;
if (std.mem.indexOfAny(u8, number, " ,\t") != null) return null;
return .{ .name = name, .number = number };
}
/// Parsed Schwab per-account positions CSV. Returned `positions`
/// slice is heap-allocated; string fields slice into `data`.
pub const CsvResult = struct {
positions: []BrokeragePosition,
account_name: []const u8,
account_number: []const u8,
/// What the title line says about where account identity comes from.
const Title = union(enum) {
/// "Positions for account <NAME> ...<NUM> as of ..." - one account,
/// named here.
account: Account,
/// "Positions for All-Accounts as of ..." - each section names its
/// own account on a "<NAME> ...<NUM>" line.
all_accounts,
};
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
/// Classify a Schwab positions title line. Null if it is neither layout.
fn parseTitle(line: []const u8) ?Title {
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
if (std.mem.startsWith(u8, stripped, "Positions for All-Accounts")) return .all_accounts;
const prefix = "Positions for account ";
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
const rest = stripped[prefix.len..];
const label_end = std.mem.indexOf(u8, rest, " as of") orelse rest.len;
return .{ .account = parseAccountLabel(rest[0..label_end]) orelse return null };
}
/// Running tally of one section's position rows, checked against the
/// section's "Positions Total" row.
const Section = struct {
market_value: f64 = 0,
rows: usize = 0,
};
/// Check a section's "Positions Total" market value against the rows
/// summed above it. Each row is rounded to the cent, so the sum may drift
/// from Schwab's total by at most half a cent per row; anything more
/// means rows were mis-split or mis-attributed. A total that does not
/// parse is not checked.
fn checkTotal(account: Account, section: Section, total_raw: []const u8) error{InconsistentExport}!void {
const total = parseDollarAmount(total_raw) orelse return;
const tolerance = 0.005 * @as(f64, @floatFromInt(section.rows)) + 1e-6;
if (@abs(total - section.market_value) <= tolerance) return;
if (!builtin.is_test)
log.warn("account ...{s}: Positions Total is {f} but its rows sum to {f}", .{ account.number, Money.from(total), Money.from(section.market_value) });
return error.InconsistentExport;
}
/// Ways a Schwab positions CSV can fail to parse.
pub const CsvError = error{
EmptyFile,
/// The title is neither positions layout, or a section's header row
/// is missing, short, or has a read-column out of place.
UnexpectedHeader,
/// An all-accounts export has a row before any "<NAME> ...<NUM>"
/// account line, so there is no account to attribute it to.
MissingAccountLine,
/// An all-accounts export with no account sections at all.
NoAccountsFound,
/// A "Positions Total" row disagrees with the rows above it.
InconsistentExport,
};
/// Parse a Schwab positions CSV in either layout (see the module doc).
/// Every position carries its own account, so callers never need to know
/// which layout it was. The returned slice is heap-allocated; string
/// fields slice into `data`.
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) (CsvError || std.mem.Allocator.Error)![]BrokeragePosition {
var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator);
if (std.mem.trim(u8, data, " \r\n").len == 0) return error.EmptyFile;
var lines = std.mem.splitScalar(u8, data, '\n');
// Line 1: title with account name and number
const title_line = lines.next() orelse return error.EmptyFile;
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
const title = parseTitle(lines.first()) orelse return error.UnexpectedHeader;
// Line 2: blank (skip)
_ = lines.next();
// Line 3: header row - validate the columns we read are where we expect,
// so a Schwab format change surfaces as UnexpectedHeader (and, in the
// flagless audit, a "could not parse" warning) rather than silently
// reading a shifted column.
const header_line = lines.next() orelse return error.UnexpectedHeader;
try validateHeader(std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' }));
// The account the rows being read belong to: named by the title for
// a single-account export, by each section's account line otherwise.
var account: ?Account = switch (title) {
.account => |a| a,
.all_accounts => null,
};
// Every section opens with a header row, validated before any of its
// rows are read so a Schwab format change surfaces as
// UnexpectedHeader (and, in the flagless audit, a "could not parse"
// warning) rather than silently reading a shifted column.
var expect_header = account != null;
var section: Section = .{};
var section_count: usize = 0;
// Data rows
while (lines.next()) |line| {
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
if (trimmed.len == 0) continue;
var cols: [expected_columns][]const u8 = undefined;
const col_count = splitCsvLine(trimmed, &cols);
if (title == .all_accounts and col_count == 1) {
if (parseAccountLabel(cols[0])) |a| {
account = a;
expect_header = true;
section = .{};
section_count += 1;
continue;
}
}
const current = account orelse return error.MissingAccountLine;
if (expect_header) {
try validateHeader(trimmed);
expect_header = false;
continue;
}
if (col_count < expected_columns) continue;
const symbol = cols[Col.symbol];
if (symbol.len == 0) continue;
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
if (std.mem.eql(u8, symbol, "Positions Total")) {
try checkTotal(current, section, cols[Col.market_value]);
section = .{};
continue;
}
// "Cash & Cash Investments" is Schwab's aggregate cash line.
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
@ -217,24 +333,25 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
// "cash" in the account.
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
portfolio_mod.isMoneyMarketSymbol(symbol);
const market_value = parseDollarAmount(cols[Col.market_value]);
try positions.append(allocator, .{
.account_number = title.number,
.account_name = title.name,
.account_number = current.number,
.account_name = current.name,
.symbol = symbol,
.description = if (col_count > 1) cols[1] else "",
.description = cols[1],
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
.current_value = parseDollarAmount(cols[Col.market_value]),
.current_value = market_value,
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
.is_cash = is_cash,
});
section.market_value += market_value orelse 0;
section.rows += 1;
}
return .{
.positions = try positions.toOwnedSlice(allocator),
.account_name = title.name,
.account_number = title.number,
};
if (title == .all_accounts and section_count == 0) return error.NoAccountsFound;
return positions.toOwnedSlice(allocator);
}
/// Account-level summary from a Schwab paste (no per-position detail).
@ -336,17 +453,41 @@ pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSu
// ── Tests ────────────────────────────────────────────────────
test "parseTitle" {
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"");
try std.testing.expect(t1 != null);
try std.testing.expectEqualStrings("Sample Trust", t1.?.name);
try std.testing.expectEqualStrings("1234", t1.?.number);
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"").?;
try std.testing.expectEqualStrings("Sample Trust", t1.account.name);
try std.testing.expectEqualStrings("1234", t1.account.number);
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"");
try std.testing.expect(t2 != null);
try std.testing.expectEqualStrings("Sample IRA", t2.?.name);
try std.testing.expectEqualStrings("5678", t2.?.number);
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\"").?;
try std.testing.expectEqualStrings("Sample IRA", t2.account.name);
try std.testing.expectEqualStrings("5678", t2.account.number);
// The all-accounts title names no account; its date is MM/DD/YYYY.
try std.testing.expect(parseTitle("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"").? == .all_accounts);
try std.testing.expect(parseTitle("some random text") == null);
// Single-account title without the "...NUM" label.
try std.testing.expect(parseTitle("\"Positions for account Sample IRA as of 3:00 PM ET, 2026/04/10\"") == null);
}
test "parseAccountLabel" {
const a = parseAccountLabel("Sample_IRA ...1234").?;
try std.testing.expectEqualStrings("Sample_IRA", a.name);
try std.testing.expectEqualStrings("1234", a.number);
// Spaces in names are kept; surrounding space is trimmed.
const b = parseAccountLabel(" Sample Brokerage ...Z123 ").?;
try std.testing.expectEqualStrings("Sample Brokerage", b.name);
try std.testing.expectEqualStrings("Z123", b.number);
// The number follows the LAST "...", so dots in a name survive.
const c = parseAccountLabel("Sample... Trust ...5678").?;
try std.testing.expectEqualStrings("Sample... Trust", c.name);
try std.testing.expectEqualStrings("5678", c.number);
try std.testing.expect(parseAccountLabel("Sample IRA") == null); // no dots
try std.testing.expect(parseAccountLabel("...1234") == null); // no name
try std.testing.expect(parseAccountLabel("Sample IRA ...") == null); // no number
try std.testing.expect(parseAccountLabel("Sample IRA ...12 34") == null); // not one token
}
test "splitCsvLine" {
@ -368,29 +509,32 @@ test "parseCsv basic" {
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$364,212.62\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
const allocator = std.testing.allocator;
const parsed = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions);
const positions = try parseCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name);
try std.testing.expectEqualStrings("1234", parsed.account_number);
try std.testing.expectEqual(@as(usize, 2), positions.len);
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
// Every row carries the title's account.
for (positions) |p| {
try std.testing.expectEqualStrings("Sample Trust", p.account_name);
try std.testing.expectEqualStrings("1234", p.account_number);
}
// Stock position
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
try std.testing.expect(!parsed.positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 1488), positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), positions[0].cost_basis.?, 0.01);
// Cash
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
try std.testing.expect(parsed.positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
try std.testing.expect(parsed.positions[1].quantity == null);
try std.testing.expectEqualStrings("Cash & Cash Investments", positions[1].symbol);
try std.testing.expect(positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), positions[1].current_value.?, 0.01);
try std.testing.expect(positions[1].quantity == null);
}
test "parseCsv accepts the real Short (Long) header labels" {
@ -403,14 +547,14 @@ test "parseCsv accepts the real Short (Long) header labels" {
"\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"5.55\",\"2.38%\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
const allocator = std.testing.allocator;
const parsed = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions);
const positions = try parseCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), parsed.positions[0].cost_basis.?, 0.01);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[0].cost_basis.?, 0.01);
}
test "parseCsv rejects a header whose read-columns are reordered" {
@ -434,13 +578,13 @@ test "parseCsv tolerates reordering of columns it does not read" {
"\"Symbol\",\"Description\",\"Price Chng % (Price Change %)\",\"Price Chng $ (Price Change $)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"2.38%\",\"5.55\",\"239.20\",\"100\",\"$8.00\",\"2.38%\",\"$23,920.00\",\"$10,000.00\",\"$13,920.00\",\"139%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
const allocator = std.testing.allocator;
const parsed = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions);
const positions = try parseCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len);
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), parsed.positions[0].current_value.?, 0.01);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 23920.00), positions[0].current_value.?, 0.01);
}
test "parseCsv rejects a header with too few columns" {
@ -452,6 +596,166 @@ test "parseCsv rejects a header with too few columns" {
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, csv));
}
test "parseCsv rejects an empty file and an unrecognized title" {
const allocator = std.testing.allocator;
try std.testing.expectError(error.EmptyFile, parseCsv(allocator, ""));
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for nobody\"\n"));
}
test "parseCsv rejects a single-account file with no header" {
const allocator = std.testing.allocator;
try std.testing.expectError(error.UnexpectedHeader, parseCsv(allocator, "\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n\n\"AMZN\"\n"));
}
/// The real "Short (Long)" header, as every section of an export carries it.
const test_header = "\"Symbol\",\"Description\",\"Price Chng $ (Price Change $)\",\"Price Chng % (Price Change %)\",\"Price\",\"Qty (Quantity)\",\"Day Chng $ (Day Change $)\",\"Day Chng % (Day Change %)\",\"Mkt Val (Market Value)\",\"Cost Basis\",\"Gain $ (Gain/Loss $)\",\"Gain % (Gain/Loss %)\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct (% of Account)\",\"Asset Type\",\n";
/// A three-section all-accounts export (fictional figures). The Trust
/// section exercises the two oddities real exports carry: a cost basis
/// Schwab reports as "Unavailable", and a holding with no price or value
/// ("N/A").
const test_all_accounts =
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
"\n" ++
"\"Sample_IRA ...1234\"\n" ++
test_header ++
"\"VTI\",\"VANGUARD TOTAL STOCK MARKET ETF\",\"1.10\",\"0.37%\",\"300.00\",\"100\",\"$110.00\",\"0.37%\",\"$30,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"No\",\"N/A\",\"96.77%\",\"ETFs & Closed End Funds\",\n" ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"3.23%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$110.00\",\"0.36%\",\"$31,000.00\",\"$20,000.00\",\"$10,000.00\",\"50%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
"\n" ++
"\n" ++
"\"Sample_Brokerage ...5678\"\n" ++
test_header ++
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"33.33%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.67%\",\"$3,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
"\n" ++
"\n" ++
"\"Sample_Trust ...9012\"\n" ++
test_header ++
"\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.36%\",\"27.50\",\"200\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"Unavailable\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n" ++
"\"123ABC456\",\"SAMPLE CORP ESCROW\",\"--\",\"--\",\"N/A\",\"5\",\"--\",\"--\",\"N/A\",\"$0.00\",\"--\",\"--\",\"--\",\"No\",\"N/A\",\"--\",\"Equity\",\n" ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"0.36%\",\"$5,500.00\",\"$0.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
test "parseCsv all-accounts: every row carries its own section's account" {
const allocator = std.testing.allocator;
const positions = try parseCsv(allocator, test_all_accounts);
defer allocator.free(positions);
const Expect = struct { symbol: []const u8, number: []const u8, name: []const u8, is_cash: bool };
const expected = [_]Expect{
.{ .symbol = "VTI", .number = "1234", .name = "Sample_IRA", .is_cash = false },
.{ .symbol = "Cash & Cash Investments", .number = "1234", .name = "Sample_IRA", .is_cash = true },
.{ .symbol = "AAPL", .number = "5678", .name = "Sample_Brokerage", .is_cash = false },
.{ .symbol = "Cash & Cash Investments", .number = "5678", .name = "Sample_Brokerage", .is_cash = true },
.{ .symbol = "SCHD", .number = "9012", .name = "Sample_Trust", .is_cash = false },
.{ .symbol = "123ABC456", .number = "9012", .name = "Sample_Trust", .is_cash = false },
.{ .symbol = "Cash & Cash Investments", .number = "9012", .name = "Sample_Trust", .is_cash = true },
};
try std.testing.expectEqual(expected.len, positions.len);
for (expected, positions) |e, p| {
try std.testing.expectEqualStrings(e.symbol, p.symbol);
try std.testing.expectEqualStrings(e.number, p.account_number);
try std.testing.expectEqualStrings(e.name, p.account_name);
try std.testing.expectEqual(e.is_cash, p.is_cash);
}
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[0].quantity.?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 30000), positions[0].current_value.?, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 1000), positions[3].current_value.?, 0.001);
// "Unavailable" cost basis and "N/A" value read as unknown, not zero.
try std.testing.expect(positions[4].cost_basis == null);
try std.testing.expect(positions[5].current_value == null);
try std.testing.expectApproxEqAbs(@as(f64, 5), positions[5].quantity.?, 0.001);
}
test "parseCsv all-accounts: a row before any account line has no account" {
const csv =
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
"\n" ++
test_header ++
"\"Sample_IRA ...1234\"\n";
try std.testing.expectError(error.MissingAccountLine, parseCsv(std.testing.allocator, csv));
}
test "parseCsv all-accounts: every section's header is validated" {
// The second section's Symbol and Price are swapped; the first is fine.
const csv =
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
"\n" ++
"\"Sample_IRA ...1234\"\n" ++
test_header ++
"\n" ++
"\"Sample_Brokerage ...5678\"\n" ++
"\"Price\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Symbol\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n";
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
}
test "parseCsv all-accounts: an account line must be followed by a header" {
const csv =
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
"\n" ++
"\"Sample_IRA ...1234\"\n" ++
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n";
try std.testing.expectError(error.UnexpectedHeader, parseCsv(std.testing.allocator, csv));
}
test "parseCsv all-accounts: no sections is an error, not an empty result" {
try std.testing.expectError(
error.NoAccountsFound,
parseCsv(std.testing.allocator, "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n"),
);
}
test "parseCsv: a Positions Total that disagrees with its rows is rejected" {
// Both layouts check it. Single account: the total claims $1 more than
// the one row.
const single =
"\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n" ++
"\n" ++
test_header ++
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,001.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, single));
// All accounts: the total is only checked against its own section, so
// a row attributed to the wrong section shows up. Here the second
// section's total is the first section's.
const all =
"\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n" ++
"\n" ++
"\"Sample_IRA ...1234\"\n" ++
test_header ++
"\"AAPL\",\"APPLE INC\",\"2.00\",\"1%\",\"200.00\",\"10\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"B\",\"No\",\"N/A\",\"66.67%\",\"Equity\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$20.00\",\"1%\",\"$2,000.00\",\"$1,500.00\",\"$500.00\",\"33.33%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n" ++
"\"Sample_Brokerage ...5678\"\n" ++
test_header ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$1,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"100%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$2,000.00\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
try std.testing.expectError(error.InconsistentExport, parseCsv(std.testing.allocator, all));
}
test "checkTotal: allows half a cent of rounding per row" {
const acct: Account = .{ .name = "Sample IRA", .number = "1234" };
// Three rows: up to 1.5 cents of drift is rounding.
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.01");
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$99.99");
try std.testing.expectError(error.InconsistentExport, checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "$100.02"));
// A total Schwab leaves blank is not checked.
try checkTotal(acct, .{ .market_value = 100.00, .rows = 3 }, "--");
}
test "parseCsv all-accounts: allocation failures are handled" {
const S = struct {
fn run(allocator: std.mem.Allocator) !void {
const positions = try parseCsv(allocator, test_all_accounts);
allocator.free(positions);
}
};
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
}
test "parseSummary basic" {
const data =
\\Sample Roth

View file

@ -63,7 +63,8 @@ pub const meta: framework.Meta = .{
\\ --stale-days <N> Manual price staleness threshold (default 3)
\\ --fidelity <CSV> Fidelity positions CSV export
\\ ("All accounts" -> Positions tab -> Download)
\\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab <CSV> Schwab positions CSV export (one account,
\\ or "All Brokerage Accounts")
\\ --schwab-summary Schwab account summary; copy from accounts
\\ summary page, paste to stdin, then ^D
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet

View file

@ -223,7 +223,8 @@ pub const meta: framework.Meta = .{
\\ concrete path, not a glob). REQUIRED.
\\ --fidelity <CSV> Fidelity positions CSV
\\ ("All accounts" -> Positions tab -> Download)
\\ --schwab <CSV> Schwab per-account positions CSV
\\ --schwab <CSV> Schwab positions CSV (one account, or
\\ "All Brokerage Accounts")
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
\\ (Download Type "Portfolio-Expanded
\\ Detail", Portfolio View "Positions",
@ -346,10 +347,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const positions: []BrokeragePosition = switch (parsed.source) {
.fidelity => try fidelity.parseCsv(allocator, source_data),
.schwab => blk: {
const r = try schwab.parseCsv(allocator, source_data);
break :blk r.positions;
},
.schwab => try schwab.parseCsv(allocator, source_data),
.wells_fargo => blk: {
workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err);
wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err);