From 635a0557b4e59e06968790c05fb08b696ebdb522 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 6 Oct 2026 10:45:07 -0700 Subject: [PATCH] handle schwab all accounts csv download --- docs/guides/audit-against-brokerage.md | 45 ++- docs/reference/cli/audit.md | 2 +- docs/reference/cli/import.md | 6 +- src/analytics/reconcile/schwab.zig | 91 ++++- src/brokerage/discover.zig | 9 +- src/brokerage/schwab.zig | 488 ++++++++++++++++++++----- src/commands/audit.zig | 3 +- src/commands/import.zig | 8 +- 8 files changed, 518 insertions(+), 134 deletions(-) diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md index 4fc6a2b..6781350 100644 --- a/docs/guides/audit-against-brokerage.md +++ b/docs/guides/audit-against-brokerage.md @@ -26,16 +26,18 @@ Fargo**. | Broker | How to export | Flag | |--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------| | **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity ` | -| **Schwab** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--schwab ` | +| **Schwab** (positions) | *Accounts -> Positions*: pick **All Brokerage Accounts** (or one account) in the dropdown -> **Export** | `--schwab ` | | **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 ` | -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 diff --git a/docs/reference/cli/audit.md b/docs/reference/cli/audit.md index df72130..bf3936c 100644 --- a/docs/reference/cli/audit.md +++ b/docs/reference/cli/audit.md @@ -21,7 +21,7 @@ discrepancies. | `--verbose` | Show full reconciliation output even when clean. | | `--stale-days ` | Manual-price staleness threshold (default 3). | | `--fidelity ` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | -| `--schwab ` | Schwab per-account positions CSV. | +| `--schwab ` | 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 ` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). | diff --git a/docs/reference/cli/import.md b/docs/reference/cli/import.md index d865808..f5d367d 100644 --- a/docs/reference/cli/import.md +++ b/docs/reference/cli/import.md @@ -33,14 +33,16 @@ dropped. |--------------------------|-----------------------------------------------------------------| | `-p, --portfolio ` | Target file (a single concrete path, not a glob). **Required.** | | `--fidelity ` | Fidelity positions CSV. | -| `--schwab ` | Schwab per-account positions CSV. | +| `--schwab ` | Schwab positions CSV (one account or All Brokerage Accounts). | | `--wells-fargo ` | 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. diff --git a/src/analytics/reconcile/schwab.zig b/src/analytics/reconcile/schwab.zig index 00a00ff..e0aca20 100644 --- a/src/analytics/reconcile/schwab.zig +++ b/src/analytics/reconcile/schwab.zig @@ -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 = diff --git a/src/brokerage/discover.zig b/src/brokerage/discover.zig index 0487d61..cf1e0bb 100644 --- a/src/brokerage/discover.zig +++ b/src/brokerage/discover.zig @@ -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 diff --git a/src/brokerage/schwab.zig b/src/brokerage/schwab.zig index de107fa..e167b9a 100644 --- a/src/brokerage/schwab.zig +++ b/src/brokerage/schwab.zig @@ -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 ... as of