Compare commits

..

2 commits

Author SHA1 Message Date
d5459ff39e
audit: better handling of unmapped accounts in multi-account file imports
All checks were successful
Generic zig build / build (push) Successful in 5m28s
Generic zig build / publish-macos (push) Successful in 13s
Generic zig build / deploy (push) Successful in 19s
2026-10-06 10:52:10 -07:00
635a0557b4
handle schwab all accounts csv download 2026-10-06 10:45:07 -07:00
14 changed files with 1134 additions and 257 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
```
@ -266,10 +268,16 @@ What it considers:
for ...`, a Schwab summary by `Account number ending in`, a Wells
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
works; an unrelated file is skipped.
- **A Wells Fargo export with none of your accounts is skipped.** One WF
download covers a whole household, so if you also manage someone
else's WF accounts, their export in your download folder is noted and
skipped rather than reported as eight unmapped accounts.
- **Accounts you don't track get one line, not a wall of
discrepancies.** Fidelity's download, Schwab's All Brokerage Accounts
export and summary paste, and the Wells Fargo spreadsheet cover every
account your login can see -- including ones this portfolio
deliberately leaves out (a relative's account you manage, one that
belongs to another portfolio file). Their unmapped accounts are named
on a muted "not in accounts.srf, not reconciled" line instead of
being reconciled, and an export with *none* of your accounts is noted
and skipped as another portfolio's. See
[How accounts are matched](#how-accounts-are-matched).
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
"download from your broker, run `zfin audit`, done."
@ -282,14 +290,29 @@ 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.
- **No match -> the account is shown as `unmapped`** and flagged as a
discrepancy. Fix it by adding `institution::` and `account_number::`
- **No match -> the account isn't reconciled.** How loudly zfin says so
depends on what you exported:
- A **single-account** Schwab export: you picked that account, so it
is shown as `unmapped` with every holding, and flagged as a
discrepancy.
- An export covering **every account** (Fidelity, Schwab's All
Brokerage Accounts or summary, Wells Fargo): the account is listed
by name and number on one muted "not in accounts.srf, not
reconciled" line, and doesn't count as a discrepancy -- so an
account you deliberately don't track doesn't keep the audit from
coming back clean. If *none* of the export's accounts match, the
whole file is skipped as another portfolio's (the flagless audit
lists its accounts with `--verbose`; a `--fidelity`/`--schwab`/...
flag always does).
Either way, fix it by adding `institution::` and `account_number::`
to that account in `accounts.srf` (a placeholder number you recognize
is fine -- it just has to match what the export shows).
@ -370,11 +393,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 +410,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,15 +21,18 @@ 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"). |
Reconciliation matches export accounts to yours via `institution::` and
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
unmatched account is reported as "unmapped." A discovered Wells Fargo
export none of whose accounts are mapped is skipped with a note instead,
since it is almost certainly another portfolio's.
`account_number::` in [`accounts.srf`](../config/accounts-srf.md). An
unmatched account in a single-account Schwab export is reported as
"unmapped." Exports that cover every account (Fidelity, Schwab's All
Brokerage Accounts and summary, Wells Fargo) instead name their
unmatched accounts on one "not in accounts.srf, not reconciled" line,
and one with no matched accounts at all is skipped as another
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
The hygiene check also flags newly-appeared lots worth at least
$10,000 in a **Large new lots - confirm source** section, so you can

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

@ -24,6 +24,9 @@ pub const cdLotAllowance = common.cdLotAllowance;
pub const accountValueExpectation = common.accountValueExpectation;
pub const compareAccounts = common.compareAccounts;
pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies;
pub const ExportScope = common.ExportScope;
pub const ExportView = common.ExportView;
pub const splitUnmapped = common.splitUnmapped;
pub const presentNumbers = common.presentNumbers;
pub const findAbsentAccounts = common.findAbsentAccounts;
pub const RatioSuggestion = common.RatioSuggestion;
@ -32,6 +35,7 @@ pub const ratioSuggestions = common.ratioSuggestions;
pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
pub const compareSchwabSummary = schwab.compareSchwabSummary;
pub const reconcileCsv = schwab.reconcileCsv;
pub const schwabCsvScope = schwab.csvScope;
pub const reconcileSummary = schwab.reconcileSummary;
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;

View file

@ -661,6 +661,67 @@ pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
return false;
}
// ── Unmapped accounts in an export ───────────────────────────
/// How much of a household an export was asked to cover, which decides
/// what an unmapped account in it means (see `splitUnmapped`).
pub const ExportScope = enum {
/// The user picked one account to export - Schwab's single-account
/// positions CSV. If that account is unmapped, it is a setup error.
one_account,
/// Everything the login can see - Fidelity's download, the Wells
/// Fargo spreadsheet, Schwab's All Brokerage Accounts export and its
/// summary paste. These routinely carry accounts this portfolio
/// deliberately doesn't track: a relative's, a view-only one, or
/// one belonging to another portfolio file.
all_accounts,
};
/// One export's comparison results, split for display. Both slices
/// view the caller's results, which `splitUnmapped` reorders in place.
pub fn ExportView(comptime T: type) type {
return struct {
/// Accounts to reconcile in full.
shown: []T,
/// Unmapped accounts of an all-accounts export, reported on one
/// line rather than as discrepancies.
unmapped: []T,
/// True when nothing in an all-accounts export maps: it is almost
/// certainly another portfolio's file (one household's download
/// in a shared Downloads directory), so it is skipped outright.
pub fn skipped(self: @This()) bool {
return self.shown.len == 0 and self.unmapped.len > 0;
}
};
}
/// Split one export's results into what to reconcile and what to merely
/// note. An unmapped account (empty `account_name`, the convention both
/// `AccountComparison` and `SchwabAccountComparison` share) in an
/// `.all_accounts` export moves to `unmapped`: listing every row of an
/// account you deliberately don't track as a discrepancy would mean the
/// audit can never come back clean. It is still named, so a newly opened
/// account that genuinely needs mapping isn't lost. In a `.one_account`
/// export nothing moves - you asked for that account, so its not mapping
/// is worth the full unmapped report.
///
/// Reorders `results` in place - mapped first, each side keeping its
/// order - so the caller still owns and frees the same slice.
pub fn splitUnmapped(comptime T: type, results: []T, scope: ExportScope) ExportView(T) {
if (scope == .one_account) return .{ .shown = results, .unmapped = results[results.len..] };
const S = struct {
fn mappedFirst(_: void, a: T, b: T) bool {
return a.account_name.len > 0 and b.account_name.len == 0;
}
};
// Insertion sort is stable, and an export carries a handful of accounts.
std.sort.insertion(T, results, {}, S.mappedFirst);
var mapped: usize = 0;
while (mapped < results.len and results[mapped].account_name.len > 0) : (mapped += 1) {}
return .{ .shown = results[0..mapped], .unmapped = results[mapped..] };
}
// ── Portfolio accounts absent from the export ────────────────
/// A portfolio account that maps to the institution under audit and
@ -1977,3 +2038,70 @@ test "collapseOntoProxy: every allocation failure is clean" {
};
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
}
// ── splitUnmapped ────────────────────────────────────────────
/// An account comparison with no rows; only identity matters here.
fn testAcct(account_name: []const u8, number: []const u8) AccountComparison {
return .{
.account_name = account_name,
.brokerage_name = "Sample Account",
.account_number = number,
.comparisons = &.{},
.portfolio_total = 0,
.brokerage_total = 0,
.total_delta = 0,
.option_value_delta = 0,
.has_discrepancies = account_name.len == 0,
};
}
test "splitUnmapped: an all-accounts export sets unmapped accounts aside, keeping order" {
var results = [_]AccountComparison{
testAcct("", "9012"),
testAcct("Sample IRA", "1234"),
testAcct("", "3456"),
testAcct("Sample Brokerage", "5678"),
};
const view = splitUnmapped(AccountComparison, &results, .all_accounts);
try std.testing.expect(!view.skipped());
try std.testing.expectEqual(@as(usize, 2), view.shown.len);
try std.testing.expectEqualStrings("1234", view.shown[0].account_number);
try std.testing.expectEqualStrings("5678", view.shown[1].account_number);
try std.testing.expectEqual(@as(usize, 2), view.unmapped.len);
try std.testing.expectEqualStrings("9012", view.unmapped[0].account_number);
try std.testing.expectEqualStrings("3456", view.unmapped[1].account_number);
// The set aside accounts were the discrepancies; what is shown is clean.
try std.testing.expect(!hasAccountDiscrepancies(view.shown));
}
test "splitUnmapped: a one-account export keeps its unmapped account in full view" {
var results = [_]AccountComparison{testAcct("", "9012")};
const view = splitUnmapped(AccountComparison, &results, .one_account);
try std.testing.expect(!view.skipped());
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
try std.testing.expectEqual(@as(usize, 0), view.unmapped.len);
try std.testing.expect(hasAccountDiscrepancies(view.shown));
}
test "splitUnmapped: an all-accounts export with nothing mapped is skipped" {
var results = [_]AccountComparison{ testAcct("", "9012"), testAcct("", "3456") };
const view = splitUnmapped(AccountComparison, &results, .all_accounts);
try std.testing.expect(view.skipped());
try std.testing.expectEqual(@as(usize, 0), view.shown.len);
try std.testing.expectEqual(@as(usize, 2), view.unmapped.len);
}
test "splitUnmapped: everything mapped, or nothing at all, sets nothing aside" {
var mapped = [_]AccountComparison{ testAcct("Sample IRA", "1234"), testAcct("Sample Brokerage", "5678") };
const view = splitUnmapped(AccountComparison, &mapped, .all_accounts);
try std.testing.expect(!view.skipped());
try std.testing.expectEqual(@as(usize, 2), view.shown.len);
try std.testing.expectEqual(@as(usize, 0), view.unmapped.len);
// An empty export is not "skipped as another portfolio's" - there is
// nothing to attribute to anyone.
var none = [_]AccountComparison{};
try std.testing.expect(!splitUnmapped(AccountComparison, &none, .all_accounts).skipped());
}

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
@ -27,8 +27,12 @@ const AccountSummary = schwab_parser.AccountSummary;
/// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct {
/// Portfolio account name; empty when the account is unmapped.
account_name: []const u8,
schwab_name: []const u8,
/// The account's name as Schwab shows it. Named like
/// `AccountComparison.brokerage_name` so code handling either result
/// type (`common.splitUnmapped`) reads the same field.
brokerage_name: []const u8,
account_number: []const u8,
portfolio_cash: f64,
schwab_cash: ?f64,
@ -44,13 +48,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 +64,19 @@ 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);
}
/// Which `common.ExportScope` a Schwab positions CSV has: an "All
/// Brokerage Accounts" export covers the whole login, while a
/// single-account export is one the user picked on purpose, so its
/// account being unmapped is worth the full report.
pub fn csvScope(csv_data: []const u8) common.ExportScope {
return if (schwab_parser.isAllAccounts(csv_data)) .all_accounts else .one_account;
}
// ── Account summary paste (--schwab-summary) ─────────────────
@ -119,7 +132,7 @@ pub fn compareSchwabSummary(
try results.append(allocator, .{
.account_name = portfolio_acct orelse "",
.schwab_name = sa.account_name,
.brokerage_name = sa.account_name,
.account_number = sa.account_number,
.portfolio_cash = pf_cash,
.schwab_cash = sa.cash,
@ -217,7 +230,7 @@ const portfolio_mod = @import("../../models/portfolio.zig");
test "hasSchwabDiscrepancies" {
const clean = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.brokerage_name = "Roth IRA",
.account_number = "1234",
.portfolio_cash = 100,
.schwab_cash = 100,
@ -231,7 +244,7 @@ test "hasSchwabDiscrepancies" {
const dirty = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.brokerage_name = "Roth IRA",
.account_number = "1234",
.portfolio_cash = 100,
.schwab_cash = 200,
@ -410,7 +423,7 @@ test "compareSchwabSummary: account_number with no match -> empty account_name"
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("", results[0].account_name);
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
try std.testing.expectEqualStrings("Unknown Acct", results[0].brokerage_name);
// No portfolio match -> cash and total are zero, schwab values become deltas
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
@ -562,6 +575,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 =
@ -634,7 +713,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d
try prices.put("SPY", 500.0);
// current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
defer allocator.free(sugg);
@ -655,7 +734,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
defer prices.deinit();
try prices.put("SPY", 500.0);
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
// null account_map -> empty.
@ -681,7 +760,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
};
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
const tiny = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
};
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
defer allocator.free(sugg);
@ -703,3 +782,45 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
try std.testing.expectEqual(@as(usize, 0), sugg.len);
}
}
// ── Export scope ─────────────────────────────────────────────
test "csvScope: only an All Brokerage Accounts export covers the whole login" {
try std.testing.expectEqual(common.ExportScope.all_accounts, csvScope("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
try std.testing.expectEqual(common.ExportScope.one_account, csvScope("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
// Unparseable input never reaches here (reconcileCsv fails first), but
// the conservative answer is the one that keeps unmapped accounts loud.
try std.testing.expectEqual(common.ExportScope.one_account, csvScope("garbage"));
}
test "splitUnmapped: a summary paste sets its unmapped accounts aside" {
const allocator = std.testing.allocator;
const data =
\\Sample Roth
\\Account number ending in 1234 ...1234
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\Sample Account
\\Account number ending in 9012 ...9012
\\Type Brokerage $10.00 $5,000.00 +$1.00 +0.02%
;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "", .shares = 46.44, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1, .account = "Sample Roth IRA", .security_type = .cash },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 10, 6));
defer allocator.free(results);
const view = common.splitUnmapped(SchwabAccountComparison, results, .all_accounts);
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
try std.testing.expectEqualStrings("Sample Roth IRA", view.shown[0].account_name);
try std.testing.expectEqual(@as(usize, 1), view.unmapped.len);
try std.testing.expectEqualStrings("Sample Account", view.unmapped[0].brokerage_name);
try std.testing.expectEqualStrings("9012", view.unmapped[0].account_number);
}

View file

@ -44,18 +44,6 @@ pub const Reconciled = struct {
allocator.free(self.results);
self.workbook.deinit();
}
/// True when at least one of the export's accounts is in
/// `accounts.srf`. An export with none is almost certainly another
/// portfolio's (one household's download sitting in a shared
/// Downloads directory), so auto-discovery skips it rather than
/// reporting every account as a discrepancy.
pub fn mapsAnyAccount(self: Reconciled) bool {
for (self.results) |r| {
if (r.account_name.len > 0) return true;
}
return false;
}
};
/// Decode an export file and reconcile it. Errors are the decode's
@ -140,7 +128,9 @@ test "reconcile: per-lot rows and cash rows sum per account and match the portfo
try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len);
}
test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf" {
test "reconcile: a household export with none of its accounts mapped is skipped" {
// One WF download covers a whole household, so one sitting in a
// shared Downloads directory may be another portfolio's entirely.
const allocator = std.testing.allocator;
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
@ -155,7 +145,7 @@ test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
};
defer rec.deinit(allocator);
try std.testing.expect(!rec.mapsAnyAccount());
try std.testing.expect(common.splitUnmapped(common.AccountComparison, rec.results, .all_accounts).skipped());
// With the export's account mapped, it is ours.
entries[0].account_number = "1234";
@ -164,7 +154,9 @@ test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
};
defer mapped.deinit(allocator);
try std.testing.expect(mapped.mapsAnyAccount());
const view = common.splitUnmapped(common.AccountComparison, mapped.results, .all_accounts);
try std.testing.expect(!view.skipped());
try std.testing.expectEqual(@as(usize, 1), view.shown.len);
}
test "reconcileXls: decode errors surface with their names" {

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,149 @@ 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 };
}
/// True for an "All Brokerage Accounts" export - one that covers every
/// account the login can see, rather than one the user picked. Reads only
/// the title line.
pub fn isAllAccounts(data: []const u8) bool {
const title = parseTitle(data[0 .. std.mem.indexOfScalar(u8, data, '\n') orelse data.len]) orelse return false;
return title == .all_accounts;
}
/// 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 +341,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 +461,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 +517,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 +555,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 +586,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 +604,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
@ -527,3 +839,12 @@ test "parseSummary no accounts" {
const result = parseSummary(allocator, "some random text\nno accounts here\n");
try std.testing.expectError(error.NoAccountsFound, result);
}
test "isAllAccounts: keys on the title line only" {
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\""));
try std.testing.expect(!isAllAccounts("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
// A later line mentioning the phrase does not count.
try std.testing.expect(!isAllAccounts("something else\n\"Positions for All-Accounts\"\n"));
try std.testing.expect(!isAllAccounts(""));
}

View file

@ -21,8 +21,10 @@
//! pulling in the reconciliation surface.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const analysis = @import("../analytics/analysis.zig");
const common = @import("audit/common.zig");
const fidelity = @import("../analytics/reconcile/fidelity.zig");
@ -63,7 +65,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
@ -209,23 +212,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
var stdin_reader_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
cli.stderrPrint(io, "Error: Cannot read stdin\n");
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch |err| {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read stdin: {s}\n", .{@errorName(err)}) catch "Error: Cannot read stdin\n";
cli.stderrPrint(io, msg);
return;
};
defer allocator.free(stdin_data);
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err,
else => {
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
else => |e| {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab summary: {s} (no 'Account number ending in' lines found?)\n", .{@errorName(e)}) catch "Error: Cannot parse Schwab summary\n";
cli.stderrPrint(io, msg);
return;
},
};
defer allocator.free(results);
try schwab.displaySchwabResults(results, color, out);
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
// A summary paste covers every account on the page.
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
if (!view.skipped()) {
try schwab.displaySchwabResults(view.shown, color, out);
try schwab.displaySchwabSummaryRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, as_of, color, out);
}
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, true, color, out);
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
defer allocator.free(present);
@ -236,9 +248,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Fidelity CSV
if (fidelity_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
cli.stderrPrint(io, msg);
return;
};
@ -246,8 +258,10 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err,
else => {
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
else => |e| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Fidelity CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Fidelity CSV\n";
cli.stderrPrint(io, msg);
return;
},
};
@ -256,8 +270,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(results);
}
try common.displayResults(results, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
// Fidelity's download covers every account in the login.
try showAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present);
@ -266,11 +280,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
try common.displayAbsentAccounts(absent, color, "this export", out);
}
// Schwab per-account CSV
// Schwab positions CSV (one account, or All Brokerage Accounts)
if (schwab_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
cli.stderrPrint(io, msg);
return;
};
@ -278,8 +292,10 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err,
else => {
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
else => |e| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Schwab CSV\n";
cli.stderrPrint(io, msg);
return;
},
};
@ -288,8 +304,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(results);
}
try common.displayResults(results, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
try showAccountExport(allocator, "schwab", results, schwab.csvScope(csv_data), portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present);
@ -320,8 +335,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
defer rec.deinit(allocator);
try common.displayResults(rec.results, color, out);
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
// One WF download covers a whole household.
try showAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
defer allocator.free(present);
@ -331,6 +346,31 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}
}
/// Show one explicitly-requested per-position export: the comparison
/// and ratio suggestions for its mapped accounts, then its unmapped ones
/// (see `common.splitUnmapped`). The unmapped list is printed even for an
/// export skipped as another portfolio's - you pointed audit at this
/// file, so the account numbers to map are worth seeing. Reorders
/// `results` in place.
fn showAccountExport(
allocator: std.mem.Allocator,
label: []const u8,
results: []common.AccountComparison,
scope: common.ExportScope,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
const view = common.splitUnmapped(common.AccountComparison, results, scope);
if (!view.skipped()) {
try common.displayResults(view.shown, color, out);
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
}
try common.displayUnmapped(common.AccountComparison, label, view, true, color, out);
}
// ── Tests ────────────────────────────────────────────────────
test "parseArgs: defaults" {

View file

@ -24,10 +24,54 @@ pub const SymbolComparison = reconcile.SymbolComparison;
pub const AccountComparison = reconcile.AccountComparison;
pub const compareAccounts = reconcile.compareAccounts;
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies;
pub const ExportScope = reconcile.ExportScope;
pub const ExportView = reconcile.ExportView;
pub const splitUnmapped = reconcile.splitUnmapped;
pub const AbsentAccount = reconcile.AbsentAccount;
pub const presentNumbers = reconcile.presentNumbers;
pub const findAbsentAccounts = reconcile.findAbsentAccounts;
// ── Unmapped accounts ────────────────────────────────────────
/// Report an export's unmapped accounts (see `splitUnmapped`) in muted
/// text: a line with the count, then one line per account so the number
/// to map is right there. For an export skipped outright, the
/// per-account list is printed only when `list_skipped`: someone else's
/// whole household is noise on every routine audit, but worth seeing
/// when you point audit at the file yourself. Prints nothing when every
/// account is mapped. `T` is `AccountComparison` or
/// `SchwabAccountComparison`; both carry `brokerage_name` and
/// `account_number`.
pub fn displayUnmapped(
comptime T: type,
label: []const u8,
view: ExportView(T),
list_skipped: bool,
color: bool,
out: *std.Io.Writer,
) !void {
const n = view.unmapped.len;
if (n == 0) return;
if (view.skipped()) {
if (n == 1) {
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: its only account is not in accounts.srf; skipped (another portfolio's export?)\n", .{label});
} else {
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{ label, n });
}
if (!list_skipped) return;
} else {
try cli.printFg(out, color, cli.CLR_MUTED, " {s}: {d} {s} not in accounts.srf, not reconciled\n", .{ label, n, accountsNoun(n) });
}
for (view.unmapped) |r| {
try cli.printFg(out, color, cli.CLR_MUTED, " {s} #{s}\n", .{ r.brokerage_name, r.account_number });
}
}
/// "account" or "accounts", for a count of them.
pub fn accountsNoun(n: usize) []const u8 {
return if (n == 1) "account" else "accounts";
}
// ── Ratio suggestions ────────────────────────────────────────
/// After displaying audit results, check for price_ratio positions where
@ -547,3 +591,91 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null);
}
}
// ── displayUnmapped ──────────────────────────────────────────
fn testComparison(account_name: []const u8, brokerage_name: []const u8, number: []const u8) AccountComparison {
return .{
.account_name = account_name,
.brokerage_name = brokerage_name,
.account_number = number,
.comparisons = &.{},
.portfolio_total = 0,
.brokerage_total = 0,
.total_delta = 0,
.option_value_delta = 0,
.has_discrepancies = account_name.len == 0,
};
}
fn renderUnmapped(buf: []u8, results: []AccountComparison, scope: ExportScope, list_skipped: bool) ![]const u8 {
var w: std.Io.Writer = .fixed(buf);
try displayUnmapped(AccountComparison, "schwab", splitUnmapped(AccountComparison, results, scope), list_skipped, false, &w);
return w.buffered();
}
test "displayUnmapped: names each unmapped account of an all-accounts export" {
var results = [_]AccountComparison{
testComparison("Sample IRA", "Sample_IRA", "1234"),
testComparison("", "Sample_Account", "9012"),
};
var buf: [512]u8 = undefined;
const out = try renderUnmapped(&buf, &results, .all_accounts, false);
try std.testing.expectEqualStrings(
" schwab: 1 account not in accounts.srf, not reconciled\n" ++
" Sample_Account #9012\n",
out,
);
// Plural, and the list follows the count.
var two = [_]AccountComparison{
testComparison("Sample IRA", "Sample_IRA", "1234"),
testComparison("", "Sample_Account", "9012"),
testComparison("", "Sample_Trust", "3456"),
};
const out2 = try renderUnmapped(&buf, &two, .all_accounts, false);
try std.testing.expect(std.mem.startsWith(u8, out2, " schwab: 2 accounts not in accounts.srf, not reconciled\n"));
try std.testing.expect(std.mem.indexOf(u8, out2, " Sample_Trust #3456\n") != null);
}
test "displayUnmapped: a skipped export lists its accounts only when asked" {
var buf: [512]u8 = undefined;
var one = [_]AccountComparison{testComparison("", "Sample_Account", "9012")};
try std.testing.expectEqualStrings(
" schwab: its only account is not in accounts.srf; skipped (another portfolio's export?)\n",
try renderUnmapped(&buf, &one, .all_accounts, false),
);
var two = [_]AccountComparison{ testComparison("", "Sample_Account", "9012"), testComparison("", "Sample_Trust", "3456") };
try std.testing.expectEqualStrings(
" schwab: none of its 2 accounts are in accounts.srf; skipped (another portfolio's export?)\n",
try renderUnmapped(&buf, &two, .all_accounts, false),
);
const listed = try renderUnmapped(&buf, &two, .all_accounts, true);
try std.testing.expect(std.mem.indexOf(u8, listed, " Sample_Account #9012\n") != null);
try std.testing.expect(std.mem.indexOf(u8, listed, " Sample_Trust #3456\n") != null);
}
test "displayUnmapped: silent when every account maps, and for a one-account export" {
var buf: [512]u8 = undefined;
var mapped = [_]AccountComparison{testComparison("Sample IRA", "Sample_IRA", "1234")};
try std.testing.expectEqualStrings("", try renderUnmapped(&buf, &mapped, .all_accounts, true));
// A one-account export's unmapped account gets the full report from
// displayResults instead.
var picked = [_]AccountComparison{testComparison("", "Sample_Account", "9012")};
try std.testing.expectEqualStrings("", try renderUnmapped(&buf, &picked, .one_account, true));
}
test "displayUnmapped: color mutes the note" {
var results = [_]AccountComparison{
testComparison("Sample IRA", "Sample_IRA", "1234"),
testComparison("", "Sample_Account", "9012"),
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayUnmapped(AccountComparison, "schwab", splitUnmapped(AccountComparison, &results, .all_accounts), false, true, &w);
var seqbuf: [32]u8 = undefined;
const mute_seq = try std.fmt.bufPrint(&seqbuf, "\x1b[38;2;{d};{d};{d}m", .{ cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2] });
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), mute_seq) != null);
}

View file

@ -737,6 +737,42 @@ fn accumulatePresent(
for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
}
/// Report one discovered per-position export (Fidelity, a Schwab CSV,
/// Wells Fargo): the full comparison when `verbose` or anything is off,
/// else a one-line all-clear; then the export's unmapped accounts (see
/// `common.splitUnmapped`). Returns false when the export was skipped as
/// another portfolio's, so the caller leaves its accounts out of the
/// "not found in any export" present-set. Reorders `results` in place.
fn reportAccountExport(
allocator: std.mem.Allocator,
label: []const u8,
results: []common.AccountComparison,
scope: common.ExportScope,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: analysis.AccountMap,
verbose: bool,
color: bool,
out: *std.Io.Writer,
) !bool {
const view = common.splitUnmapped(common.AccountComparison, results, scope);
if (view.skipped()) {
try common.displayUnmapped(common.AccountComparison, label, view, verbose, color, out);
return false;
}
if (verbose or common.hasAccountDiscrepancies(view.shown)) {
try out.print("\n", .{});
try common.displayResults(view.shown, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
}
// Always show ratio suggestions, even in compact mode.
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
try common.displayUnmapped(common.AccountComparison, label, view, verbose, color, out);
return true;
}
/// Run the flagless portfolio hygiene check.
pub fn runHygieneCheck(
io: std.Io,
@ -1156,22 +1192,25 @@ pub fn runHygieneCheck(
};
defer allocator.free(results);
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try schwab.displaySchwabResults(results, color, out);
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
} else {
var acct_count: usize = 0;
for (results) |r| {
if (r.account_name.len > 0) acct_count += 1;
}
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
// Always show ratio suggestions even in compact
// mode - direct-indexing drift may cause a
// non-zero delta that still deserves a nudge.
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
// A summary paste covers every account on the page.
const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
if (view.skipped()) {
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
continue;
}
if (verbose or schwab.hasSchwabDiscrepancies(view.shown)) {
try out.print("\n", .{});
try schwab.displaySchwabResults(view.shown, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} {s}, no discrepancies\n", .{ view.shown.len, common.accountsNoun(view.shown.len) });
}
// Always show ratio suggestions, even in compact mode -
// direct-indexing drift may cause a non-zero delta that
// still deserves a nudge.
try schwab.displaySchwabSummaryRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, as_of, color, out);
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
},
.fidelity_csv => {
@ -1184,17 +1223,9 @@ pub fn runHygieneCheck(
allocator.free(results);
}
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
// Always show ratio suggestions even in compact mode
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
// Fidelity's download covers every account in the login.
if (try reportAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
},
.schwab_csv => {
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| {
@ -1206,16 +1237,8 @@ pub fn runHygieneCheck(
allocator.free(results);
}
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
if (try reportAccountExport(allocator, "schwab", results, schwab.csvScope(file_data), portfolio, prices, account_map, verbose, color, out))
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
},
.wells_fargo_xls => {
var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) {
@ -1227,25 +1250,9 @@ pub fn runHygieneCheck(
};
defer rec.deinit(allocator);
// One WF download covers a whole household. If none of
// its accounts is in this portfolio's accounts.srf, it is
// someone else's export sitting in a shared directory;
// listing every account as a discrepancy would be noise.
if (!rec.mapsAnyAccount()) {
try cli.printFg(out, color, cli.CLR_MUTED, " wells fargo: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{rec.results.len});
continue;
}
if (verbose or common.hasAccountDiscrepancies(rec.results)) {
try out.print("\n", .{});
try common.displayResults(rec.results, color, out);
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " wells fargo: {d} accounts, no discrepancies\n", .{rec.results.len});
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
// One WF download covers a whole household.
if (try reportAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
},
}
}
@ -2702,3 +2709,99 @@ test "printLargeLotWarning: an undated stock lot keeps the literal date in dest_
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::<DATE>,") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != null);
}
test "runHygieneCheck: discovered exports set unmapped accounts aside unless one was picked on purpose" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{
.sub_path = "portfolio.srf",
.data = "#!srfv1\nsymbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA\n",
});
try tmp.dir.writeFile(io, .{
.sub_path = "accounts.srf",
.data = "#!srfv1\naccount::Sample IRA,tax_type::traditional,institution::schwab,account_number::1234\n",
});
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 vti = "\"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";
const aapl = "\"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";
const all_title = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n";
try tmp.dir.createDirPath(io, "audit");
// Every account: ours (1234), plus one this portfolio doesn't track.
try tmp.dir.writeFile(io, .{
.sub_path = "audit/all.csv",
.data = all_title ++ "\"Sample_IRA ...1234\"\n" ++ header ++ vti ++ "\n\n\"Sample_Account ...9012\"\n" ++ header ++ aapl,
});
// One account, picked on purpose - and unmapped, which is a setup error.
try tmp.dir.writeFile(io, .{
.sub_path = "audit/one.csv",
.data = "\"Positions for account Sample Brokerage ...5678 as of 10:00 AM ET, 2026/06/27\"\n\n" ++ header ++ aapl,
});
// Every account of a login none of whose accounts are ours.
try tmp.dir.writeFile(io, .{
.sub_path = "audit/other.csv",
.data = all_title ++ "\"Sample_Other ...3456\"\n" ++ header ++ aapl,
});
// Same rule for other brokers' all-accounts exports: a Fidelity
// download of someone else's login is skipped...
try tmp.dir.writeFile(io, .{
.sub_path = "audit/fidelity.csv",
.data = "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\n" ++
"Z123,Sample Fid,AAPL,APPLE INC,10,$200.00,+$2.00,$2000.00,+$20.00,+1%,+$500.00,+33%,100%,$1500.00,$150.00,Margin,\n" ++
"\n" ++
"\"Brokerage services are provided by Fidelity Brokerage Services LLC (FBS), 900 Salem Street, Smithfield, RI 02917. ...\"\n",
});
// ...and a summary paste reconciles our account, noting the other.
try tmp.dir.writeFile(io, .{
.sub_path = "audit/summary.txt",
.data = "Sample IRA\nAccount number ending in 1234 ...1234\nType IRA $0.00 $30,000.00 +$0.00 +0%\n" ++
"Sample Account\nAccount number ending in 9012 ...9012\nType Brokerage $0.00 $2,000.00 +$0.00 +0%\n",
});
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
// No API keys / no server / no ZFIN_AUDIT_FILES -> hermetic; only
// the portfolio's own audit/ directory is searched.
var svc = zfin.DataService.init(io, allocator, .{ .cache_dir = dir });
defer svc.deinit();
var env = try std.testing.environ.createMap(allocator);
defer env.deinit();
const pf_path = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
defer allocator.free(pf_path);
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
// wall-clock required: discovery keeps only exports modified in the
// last 24 hours, judged against the real mtimes of the files above.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, false, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
const out = aw.written();
// all.csv: the mapped account reconciles clean; the other is named
// on one muted line, not reported as a discrepancy.
try std.testing.expect(std.mem.indexOf(u8, out, " schwab: 1 account, no discrepancies\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " schwab: 1 account not in accounts.srf, not reconciled\n Sample_Account #9012\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample_Account #9012 (unmapped") == null);
// one.csv: the full unmapped report, as before.
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage #5678 (unmapped - add account_number to accounts.srf)") != null);
// other.csv: skipped, and its account not listed in a routine audit.
try std.testing.expect(std.mem.indexOf(u8, out, " schwab: its only account is not in accounts.srf; skipped (another portfolio's export?)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample_Other") == null);
// fidelity.csv: likewise skipped.
try std.testing.expect(std.mem.indexOf(u8, out, " fidelity: its only account is not in accounts.srf; skipped (another portfolio's export?)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Fid") == null);
// summary.txt: totals reconcile for ours; the other is noted.
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account, no discrepancies\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " schwab summary: 1 account not in accounts.srf, not reconciled\n Sample Account #9012\n") != null);
}

View file

@ -15,6 +15,7 @@ const reconcile = @import("../../analytics/reconcile.zig");
pub const SchwabAccountComparison = reconcile.SchwabAccountComparison;
pub const compareSchwabSummary = reconcile.compareSchwabSummary;
pub const reconcileCsv = reconcile.reconcileCsv;
pub const csvScope = reconcile.schwabCsvScope;
pub const reconcileSummary = reconcile.reconcileSummary;
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
@ -33,7 +34,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
var discrepancy_count: usize = 0;
for (results) |r| {
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
const label = if (r.account_name.len > 0) r.account_name else r.brokerage_name;
var br_cash_buf: [24]u8 = undefined;
var br_total_buf: [24]u8 = undefined;
@ -200,14 +201,14 @@ pub fn displaySchwabSummaryRatioSuggestions(
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
const results = [_]SchwabAccountComparison{
// clean mapped -> no status
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
.{ .account_name = "Sample Roth", .brokerage_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
// cash mismatch -> "Cash +$5.00", counts as a real mismatch
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
.{ .account_name = "Sample Trust", .brokerage_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
// value delta within the CD band (cash ok) -> muted "Value
// +$100.00", shown but not counted as a real mismatch
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .cd_allowance = 200, .has_discrepancy = false },
.{ .account_name = "Sample HSA", .brokerage_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .cd_allowance = 200, .has_discrepancy = false },
// unmapped, null broker fields -> "Unmapped" + "--", counts as a real mismatch
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
.{ .account_name = "", .brokerage_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
@ -220,7 +221,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
// unmapped row falls back to the schwab_name label
// unmapped row falls back to the brokerage_name label
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
@ -232,7 +233,7 @@ test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals"
test "displaySchwabResults: color=true emits ANSI and singular label" {
const results = [_]SchwabAccountComparison{
.{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
.{ .account_name = "", .brokerage_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
@ -249,7 +250,7 @@ test "displaySchwabResults: value delta with no CD or options warns and counts"
// The Kelly-IRA shape: cash matches to the penny, but the total is
// off (a missing lot). No CD, no options -> a real, counted warning.
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 250000, .schwab_total = 251740.29, .total_delta = 1740.29, .has_discrepancy = true },
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 250000, .schwab_total = 251740.29, .total_delta = 1740.29, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
@ -269,9 +270,9 @@ test "displaySchwabResults: CD-band and option value deltas are muted, shown, an
// is warning-colored and neither counts as a mismatch.
const results = [_]SchwabAccountComparison{
// CD account: $8.70 mark sits within a $154 band -> expected/muted.
.{ .account_name = "Sample IRA", .schwab_name = "IRA", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 100000, .schwab_total = 99991.30, .total_delta = -8.70, .cd_allowance = 154, .has_discrepancy = true },
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 100000, .schwab_total = 99991.30, .total_delta = -8.70, .cd_allowance = 154, .has_discrepancy = true },
// Option account: cost-vs-market gap is unbounded -> muted, drill down.
.{ .account_name = "Sample Roth", .schwab_name = "Roth", .account_number = "5678", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 55000, .total_delta = 5000, .has_options = true, .has_discrepancy = true },
.{ .account_name = "Sample Roth", .brokerage_name = "Roth", .account_number = "5678", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 55000, .total_delta = 5000, .has_options = true, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
@ -310,7 +311,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
// total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [2048]u8 = undefined;
@ -330,7 +331,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
defer prices.deinit();
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
@ -355,7 +356,7 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp
try prices.put("SPY", 500.0);
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

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);