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 | | Broker | How to export | Flag |
|--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------| |--------------------------|-------------------------------------------------------------------------------------------------------------|-----------------------|
| **Fidelity** | *Positions* tab -> the three-dot (**⋮**) menu -> **Download** (a CSV) | `--fidelity <CSV>` | | **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` | | **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>` | | **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 The two Schwab inputs differ in detail: the **positions CSV** has full
per-position data (shares, price, value); the **summary paste** carries per-position data (shares, price, value) for whichever accounts you
only each account's cash and total value, so it reconciles *totals*, not picked -- every one of them with **All Brokerage Accounts**, which is
individual holdings. Use the summary for a quick "are my account totals the easy choice; the **summary paste** carries only each account's cash
right?", the CSV for position-level checks. (Fidelity money-market rows and total value, so it reconciles *totals*, not individual holdings.
and Schwab "Cash & Cash Investments" rows are recognized as cash.) 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 The Fidelity **Download** isn't a top-level button -- it's behind the
three-dot (**⋮**) menu at the top-right of the positions panel: three-dot (**⋮**) menu at the top-right of the positions panel:
@ -86,7 +88,7 @@ the summary reconciles **account totals**, not individual positions.
```bash ```bash
ZFIN_HOME=~/finance zfin audit --fidelity ~/Downloads/Portfolio_Positions.csv 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 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 for ...`, a Schwab summary by `Account number ending in`, a Wells
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
works; an unrelated file is skipped. works; an unrelated file is skipped.
- **A Wells Fargo export with none of your accounts is skipped.** One WF - **Accounts you don't track get one line, not a wall of
download covers a whole household, so if you also manage someone discrepancies.** Fidelity's download, Schwab's All Brokerage Accounts
else's WF accounts, their export in your download folder is noted and export and summary paste, and the Wells Fargo spreadsheet cover every
skipped rather than reported as eight unmapped accounts. 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 So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
"download from your broker, run `zfin audit`, done." "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): [`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 - The export carries an account number -- Schwab's from the "Positions
for account ...1234" title, Fidelity's from the Account Number column, for account ...1234" title (or, in an all-accounts export, the
the summary's from "...ending in 1234", Wells Fargo's from each row's "...1234" line that opens each account's section), Fidelity's from the
`*1234`. 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`, - zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
`schwab`, `wells_fargo`) **and** `account_number::` match, and `schwab`, `wells_fargo`) **and** `account_number::` match, and
compares against that account's lots. compares against that account's lots.
- **No match -> the account is shown as `unmapped`** and flagged as a - **No match -> the account isn't reconciled.** How loudly zfin says so
discrepancy. Fix it by adding `institution::` and `account_number::` 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 to that account in `accounts.srf` (a placeholder number you recognize
is fine -- it just has to match what the export shows). 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 - The parsers are **broker-specific**. The CSV parsers hardcode each
export's column layout -- if Fidelity or Schwab changes their format, export's column layout -- if Fidelity or Schwab changes their format,
parsing can break (both validate their header to catch this). They are parsing can break (both validate their header to catch this, and the
not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields) Schwab parser checks each account's "Positions Total" against the rows
-- fine for the real exports, not for arbitrary CSVs. The Wells Fargo above it). They are not full RFC-4180 CSV parsers (no escaped quotes or
parser finds columns by header name and checks every per-account total multi-line fields) -- fine for the real exports, not for arbitrary
against the lots under it, so a layout change fails loudly. 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::` - Matching is only as good as the `institution::` / `account_number::`
entries you keep in `accounts.srf`. entries you keep in `accounts.srf`.
- Options, CDs, and cash are reconciled separately from share counts. - Options, CDs, and cash are reconciled separately from share counts.
@ -385,11 +410,11 @@ manual nudge.
### Wells Fargo: one file, every account, real lots ### Wells Fargo: one file, every account, real lots
The Wells Fargo spreadsheet differs from the CSV exports in two ways Two things about the Wells Fargo spreadsheet are worth knowing:
worth knowing:
- **It covers the whole household.** Every brokerage account is in one - **It covers the whole household.** Like Fidelity's download and
file, each row tagged with its account as `*1234`. 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 - **It lists tax lots, not positions.** Audit sums them per account and
symbol before comparing, so it doesn't matter whether your portfolio symbol before comparing, so it doesn't matter whether your portfolio
holds the position as one lot or many. It also means 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. | | `--verbose` | Show full reconciliation output even when clean. |
| `--stale-days <N>` | Manual-price staleness threshold (default 3). | | `--stale-days <N>` | Manual-price staleness threshold (default 3). |
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | | `--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. | | `--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"). | | `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
Reconciliation matches export accounts to yours via `institution::` and Reconciliation matches export accounts to yours via `institution::` and
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an `account_number::` in [`accounts.srf`](../config/accounts-srf.md). An
unmatched account is reported as "unmapped." A discovered Wells Fargo unmatched account in a single-account Schwab export is reported as
export none of whose accounts are mapped is skipped with a note instead, "unmapped." Exports that cover every account (Fidelity, Schwab's All
since it is almost certainly another portfolio's. 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 The hygiene check also flags newly-appeared lots worth at least
$10,000 in a **Large new lots - confirm source** section, so you can $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.** | | `-p, --portfolio <FILE>` | Target file (a single concrete path, not a glob). **Required.** |
| `--fidelity <CSV>` | Fidelity positions CSV. | | `--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. | | `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
| `-y, --yes` | Don't prompt before overwriting an existing file. | | `-y, --yes` | Don't prompt before overwriting an existing file. |
Account resolution needs an [`accounts.srf`](../config/accounts-srf.md) Account resolution needs an [`accounts.srf`](../config/accounts-srf.md)
next to the target with `institution::` + `account_number::` entries next to the target with `institution::` + `account_number::` entries
matching the export; import refuses to write when an export account is 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 A target that doesn't exist yet is created in the current directory, so
run a first import from the portfolio's own directory. 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 accountValueExpectation = common.accountValueExpectation;
pub const compareAccounts = common.compareAccounts; pub const compareAccounts = common.compareAccounts;
pub const hasAccountDiscrepancies = common.hasAccountDiscrepancies; 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 presentNumbers = common.presentNumbers;
pub const findAbsentAccounts = common.findAbsentAccounts; pub const findAbsentAccounts = common.findAbsentAccounts;
pub const RatioSuggestion = common.RatioSuggestion; pub const RatioSuggestion = common.RatioSuggestion;
@ -32,6 +35,7 @@ pub const ratioSuggestions = common.ratioSuggestions;
pub const SchwabAccountComparison = schwab.SchwabAccountComparison; pub const SchwabAccountComparison = schwab.SchwabAccountComparison;
pub const compareSchwabSummary = schwab.compareSchwabSummary; pub const compareSchwabSummary = schwab.compareSchwabSummary;
pub const reconcileCsv = schwab.reconcileCsv; pub const reconcileCsv = schwab.reconcileCsv;
pub const schwabCsvScope = schwab.csvScope;
pub const reconcileSummary = schwab.reconcileSummary; pub const reconcileSummary = schwab.reconcileSummary;
pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies; pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions; pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;

View file

@ -661,6 +661,67 @@ pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
return false; 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 ──────────────── // ── Portfolio accounts absent from the export ────────────────
/// A portfolio account that maps to the institution under audit and /// 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, .{}); 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 //! Schwab has two export shapes, so this module carries more than
//! the Fidelity one: //! the Fidelity one:
//! //!
//! 1. **Per-account positions CSV** (`--schwab`) - same per-account //! 1. **Positions CSV** (`--schwab`) - one account or all of them,
//! positions shape Fidelity uses, so it feeds the shared //! the same per-position shape Fidelity uses, so it feeds the
//! `common.compareAccounts` engine via `reconcileCsv`. //! shared `common.compareAccounts` engine via `reconcileCsv`.
//! 2. **Account summary paste** (`--schwab-summary`) - a //! 2. **Account summary paste** (`--schwab-summary`) - a
//! per-account totals-only view with no per-symbol detail. This //! per-account totals-only view with no per-symbol detail. This
//! is the one genuinely broker-specific reconciler, with its own //! 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. /// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct { pub const SchwabAccountComparison = struct {
/// Portfolio account name; empty when the account is unmapped.
account_name: []const u8, 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, account_number: []const u8,
portfolio_cash: f64, portfolio_cash: f64,
schwab_cash: ?f64, schwab_cash: ?f64,
@ -44,13 +48,14 @@ pub const SchwabAccountComparison = struct {
has_discrepancy: bool, has_discrepancy: bool,
}; };
// ── Per-account positions CSV (--schwab) ───────────────────── // ── Positions CSV (--schwab) ─────────────────────────────────
/// Parse a Schwab per-account positions CSV and reconcile it against /// Parse a Schwab positions CSV - either layout, one account or all
/// the portfolio via the shared engine. Returns owned /// of them - and reconcile it against the portfolio via the shared
/// `AccountComparison` results (free each `.comparisons` slice, then /// engine. Returns owned `AccountComparison` results, one per account
/// the results slice). String fields borrow from `csv_data`, which /// in the export (free each `.comparisons` slice, then the results
/// must outlive them. Propagates parser and allocation errors. /// slice). String fields borrow from `csv_data`, which must outlive
/// them. Propagates parser and allocation errors.
pub fn reconcileCsv( pub fn reconcileCsv(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
portfolio: zfin.Portfolio, portfolio: zfin.Portfolio,
@ -59,11 +64,19 @@ pub fn reconcileCsv(
prices: std.StringHashMap(f64), prices: std.StringHashMap(f64),
as_of: Date, as_of: Date,
) ![]common.AccountComparison { ) ![]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, // Result strings borrow from `csv_data`, not the positions slice,
// so freeing the slice array here is safe. // so freeing the slice array here is safe.
defer allocator.free(parsed.positions); defer allocator.free(positions);
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of); 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) ───────────────── // ── Account summary paste (--schwab-summary) ─────────────────
@ -119,7 +132,7 @@ pub fn compareSchwabSummary(
try results.append(allocator, .{ try results.append(allocator, .{
.account_name = portfolio_acct orelse "", .account_name = portfolio_acct orelse "",
.schwab_name = sa.account_name, .brokerage_name = sa.account_name,
.account_number = sa.account_number, .account_number = sa.account_number,
.portfolio_cash = pf_cash, .portfolio_cash = pf_cash,
.schwab_cash = sa.cash, .schwab_cash = sa.cash,
@ -217,7 +230,7 @@ const portfolio_mod = @import("../../models/portfolio.zig");
test "hasSchwabDiscrepancies" { test "hasSchwabDiscrepancies" {
const clean = [_]SchwabAccountComparison{.{ const clean = [_]SchwabAccountComparison{.{
.account_name = "IRA", .account_name = "IRA",
.schwab_name = "Roth IRA", .brokerage_name = "Roth IRA",
.account_number = "1234", .account_number = "1234",
.portfolio_cash = 100, .portfolio_cash = 100,
.schwab_cash = 100, .schwab_cash = 100,
@ -231,7 +244,7 @@ test "hasSchwabDiscrepancies" {
const dirty = [_]SchwabAccountComparison{.{ const dirty = [_]SchwabAccountComparison{.{
.account_name = "IRA", .account_name = "IRA",
.schwab_name = "Roth IRA", .brokerage_name = "Roth IRA",
.account_number = "1234", .account_number = "1234",
.portfolio_cash = 100, .portfolio_cash = 100,
.schwab_cash = 200, .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.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("", results[0].account_name); 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 // 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, 0), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 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); 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" { test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const data = const data =
@ -634,7 +713,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d
try prices.put("SPY", 500.0); try prices.put("SPY", 500.0);
// current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%. // current = 100*500*1.0 = 50000; target = 51000; suggested = 1.02, +2%.
const results = [_]SchwabAccountComparison{ 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)); const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
defer allocator.free(sugg); defer allocator.free(sugg);
@ -655,7 +734,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
defer prices.deinit(); defer prices.deinit();
try prices.put("SPY", 500.0); try prices.put("SPY", 500.0);
const results = [_]SchwabAccountComparison{ 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. // 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 am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
const tiny = [_]SchwabAccountComparison{ 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)); const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
defer allocator.free(sugg); 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); 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); allocator.free(self.results);
self.workbook.deinit(); 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 /// 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); 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 allocator = std.testing.allocator;
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator }; const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{ 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)), .results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
}; };
defer rec.deinit(allocator); 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. // With the export's account mapped, it is ours.
entries[0].account_number = "1234"; 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)), .results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
}; };
defer mapped.deinit(allocator); 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" { 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) if (std.mem.indexOf(u8, content, "Fidelity Brokerage Services LLC") != null)
return .fidelity_csv; 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; if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
// Schwab summary: the "Account number ending in" anchor is exactly what // 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"; 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).?); 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" { test "detectBrokerFileKind: summary-shaped text without the anchor is not detected" {
// Regression guard against re-introducing the old "account-type label + // Regression guard against re-introducing the old "account-type label +
// $" fallback. This blob looks summary-ish (account-type words, dollar // $" fallback. This blob looks summary-ish (account-type words, dollar

View file

@ -2,14 +2,47 @@
//! //!
//! Parses two distinct Schwab inputs: //! Parses two distinct Schwab inputs:
//! //!
//! 1. The per-account positions CSV exported from Schwab's website //! 1. The positions CSV exported from Schwab's website (Accounts ->
//! (Accounts -> Positions -> Export). One file per account. //! 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 //! 2. The freeform account-summary text the user pastes from
//! Schwab's Accounts overview page. One paste covers all //! Schwab's Accounts overview page. One paste covers all
//! accounts at once but only carries cash + total-value //! accounts at once but only carries cash + total-value
//! aggregates, no per-position detail. //! 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 //! ## Schwab CSV - limitations
//! //!
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export //! 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. //! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
//! Schwab's export does not use these in practice. //! Schwab's export does not use these in practice.
//! //!
//! 3. The account number and name are extracted from the title line: //! 3. Every position row is stamped with the account of the section it
//! "Positions for account <NAME> ...<NUM> as of ..." //! sits in. The grouping by account happens downstream, in
//! `reconcile/common.compareAccounts`.
//! //!
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash. //! 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 //! 5. Hardcodes the expected column layout via fixed indices. Every
//! row is validated (see `validateHeader`) to confirm the columns the //! section's header row is validated (see `validateHeader`) to confirm
//! parser reads are where it expects them, so a Schwab reorder/rename of //! the columns the parser reads are where it expects them, so a Schwab
//! a read-column fails loudly as `error.UnexpectedHeader` instead of //! reorder/rename of a read-column fails loudly as
//! silently mis-reading a value. //! `error.UnexpectedHeader` instead of silently mis-reading a value.
//! //!
//! ## Schwab summary - limitations //! ## Schwab summary - limitations
//! //!
@ -60,12 +97,16 @@
//! account number line (the last whitespace-separated token). //! account number line (the last whitespace-separated token).
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const Money = @import("../Money.zig");
const portfolio_mod = @import("../models/portfolio.zig"); const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig"); const types = @import("types.zig");
const BrokeragePosition = types.BrokeragePosition; const BrokeragePosition = types.BrokeragePosition;
const parseDollarAmount = types.parseDollarAmount; const parseDollarAmount = types.parseDollarAmount;
const log = std.log.scoped(.schwab);
const expected_columns = 17; const expected_columns = 17;
const Col = struct { const Col = struct {
@ -149,66 +190,149 @@ fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
return col_count; return col_count;
} }
/// Extract account name and number from Schwab title line. /// The account a run of position rows belongs to.
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>" const Account = struct { name: []const u8, number: []const u8 };
/// 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], &.{' '});
/// 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 }; return .{ .name = name, .number = number };
} }
/// Parsed Schwab per-account positions CSV. Returned `positions` /// What the title line says about where account identity comes from.
/// slice is heap-allocated; string fields slice into `data`. const Title = union(enum) {
pub const CsvResult = struct { /// "Positions for account <NAME> ...<NUM> as of ..." - one account,
positions: []BrokeragePosition, /// named here.
account_name: []const u8, account: Account,
account_number: []const u8, /// "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; var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator); 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'); var lines = std.mem.splitScalar(u8, data, '\n');
// Line 1: title with account name and number const title = parseTitle(lines.first()) orelse return error.UnexpectedHeader;
const title_line = lines.next() orelse return error.EmptyFile;
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
// Line 2: blank (skip) // The account the rows being read belong to: named by the title for
_ = lines.next(); // a single-account export, by each section's account line otherwise.
// Line 3: header row - validate the columns we read are where we expect, var account: ?Account = switch (title) {
// so a Schwab format change surfaces as UnexpectedHeader (and, in the .account => |a| a,
// flagless audit, a "could not parse" warning) rather than silently .all_accounts => null,
// reading a shifted column. };
const header_line = lines.next() orelse return error.UnexpectedHeader; // Every section opens with a header row, validated before any of its
try validateHeader(std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' })); // 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| { while (lines.next()) |line| {
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' }); const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
if (trimmed.len == 0) continue; if (trimmed.len == 0) continue;
var cols: [expected_columns][]const u8 = undefined; var cols: [expected_columns][]const u8 = undefined;
const col_count = splitCsvLine(trimmed, &cols); 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; if (col_count < expected_columns) continue;
const symbol = cols[Col.symbol]; const symbol = cols[Col.symbol];
if (symbol.len == 0) continue; 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. // "Cash & Cash Investments" is Schwab's aggregate cash line.
// Actual money-market holdings (SWVXX, etc.) appear as normal rows // 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. // "cash" in the account.
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
portfolio_mod.isMoneyMarketSymbol(symbol); portfolio_mod.isMoneyMarketSymbol(symbol);
const market_value = parseDollarAmount(cols[Col.market_value]);
try positions.append(allocator, .{ try positions.append(allocator, .{
.account_number = title.number, .account_number = current.number,
.account_name = title.name, .account_name = current.name,
.symbol = symbol, .symbol = symbol,
.description = if (col_count > 1) cols[1] else "", .description = cols[1],
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]), .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]), .cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
.is_cash = is_cash, .is_cash = is_cash,
}); });
section.market_value += market_value orelse 0;
section.rows += 1;
} }
return .{ if (title == .all_accounts and section_count == 0) return error.NoAccountsFound;
.positions = try positions.toOwnedSlice(allocator),
.account_name = title.name, return positions.toOwnedSlice(allocator);
.account_number = title.number,
};
} }
/// Account-level summary from a Schwab paste (no per-position detail). /// 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 ──────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────
test "parseTitle" { test "parseTitle" {
const t1 = parseTitle("\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\""); 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.account.name);
try std.testing.expectEqualStrings("Sample Trust", t1.?.name); try std.testing.expectEqualStrings("1234", t1.account.number);
try std.testing.expectEqualStrings("1234", t1.?.number);
const t2 = parseTitle("\"Positions for account Sample IRA ...5678 as of 3:00 PM ET, 2026/04/10\""); 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.account.name);
try std.testing.expectEqualStrings("Sample IRA", t2.?.name); try std.testing.expectEqualStrings("5678", t2.account.number);
try std.testing.expectEqualStrings("5678", t2.?.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); 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" { 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" ++ "\"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" ++ "\"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" ++ "\"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 allocator = std.testing.allocator;
const parsed = try parseCsv(allocator, csv); const positions = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions); defer allocator.free(positions);
try std.testing.expectEqualStrings("Sample Trust", parsed.account_name); try std.testing.expectEqual(@as(usize, 2), positions.len);
try std.testing.expectEqualStrings("1234", parsed.account_number);
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 // Stock position
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol); try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
try std.testing.expect(!parsed.positions[0].is_cash); try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1488), 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, 355941.50), positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 110243.38), positions[0].cost_basis.?, 0.01);
// Cash // Cash
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol); try std.testing.expectEqualStrings("Cash & Cash Investments", positions[1].symbol);
try std.testing.expect(parsed.positions[1].is_cash); try std.testing.expect(positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 8271.12), positions[1].current_value.?, 0.01);
try std.testing.expect(parsed.positions[1].quantity == null); try std.testing.expect(positions[1].quantity == null);
} }
test "parseCsv accepts the real Short (Long) header labels" { 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" ++ "\"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"; "\"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 allocator = std.testing.allocator;
const parsed = try parseCsv(allocator, csv); const positions = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions); defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len); try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol); try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 100), 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, 23920.00), positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), parsed.positions[0].cost_basis.?, 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" { 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" ++ "\"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"; "\"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 allocator = std.testing.allocator;
const parsed = try parseCsv(allocator, csv); const positions = try parseCsv(allocator, csv);
defer allocator.free(parsed.positions); defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), parsed.positions.len); try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol); try std.testing.expectEqualStrings("AMZN", positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 100), parsed.positions[0].quantity.?, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 100), 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, 23920.00), positions[0].current_value.?, 0.01);
} }
test "parseCsv rejects a header with too few columns" { 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)); 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" { test "parseSummary basic" {
const data = const data =
\\Sample Roth \\Sample Roth
@ -527,3 +839,12 @@ test "parseSummary no accounts" {
const result = parseSummary(allocator, "some random text\nno accounts here\n"); const result = parseSummary(allocator, "some random text\nno accounts here\n");
try std.testing.expectError(error.NoAccountsFound, result); 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. //! pulling in the reconciliation surface.
const std = @import("std"); const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const analysis = @import("../analytics/analysis.zig");
const common = @import("audit/common.zig"); const common = @import("audit/common.zig");
const fidelity = @import("../analytics/reconcile/fidelity.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) \\ --stale-days <N> Manual price staleness threshold (default 3)
\\ --fidelity <CSV> Fidelity positions CSV export \\ --fidelity <CSV> Fidelity positions CSV export
\\ ("All accounts" -> Positions tab -> Download) \\ ("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 \\ --schwab-summary Schwab account summary; copy from accounts
\\ summary page, paste to stdin, then ^D \\ summary page, paste to stdin, then ^D
\\ --wells-fargo <XLS> Wells Fargo positions spreadsheet \\ --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"); cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
var stdin_reader_buf: [4096]u8 = undefined; var stdin_reader_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf); var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch { const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch |err| {
cli.stderrPrint(io, "Error: Cannot read stdin\n"); 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; return;
}; };
defer allocator.free(stdin_data); defer allocator.free(stdin_data);
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) { const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err, error.OutOfMemory => return err,
else => { else => |e| {
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); 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; return;
}, },
}; };
defer allocator.free(results); defer allocator.free(results);
try schwab.displaySchwabResults(results, color, out); // A summary paste covers every account on the page.
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out); 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); const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
defer allocator.free(present); defer allocator.free(present);
@ -236,9 +248,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Fidelity CSV // Fidelity CSV
if (fidelity_csv) |csv_path| { if (fidelity_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [256]u8 = undefined; var msg_buf: [512]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 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); cli.stderrPrint(io, msg);
return; 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) { const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err, error.OutOfMemory => return err,
else => { else => |e| {
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); 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; return;
}, },
}; };
@ -256,8 +270,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(results); allocator.free(results);
} }
try common.displayResults(results, color, out); // Fidelity's download covers every account in the login.
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); try showAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, results); const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present); 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); 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| { if (schwab_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [256]u8 = undefined; var msg_buf: [512]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 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); cli.stderrPrint(io, msg);
return; 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) { const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err, error.OutOfMemory => return err,
else => { else => |e| {
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); 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; return;
}, },
}; };
@ -288,8 +304,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(results); allocator.free(results);
} }
try common.displayResults(results, color, out); try showAccountExport(allocator, "schwab", results, schwab.csvScope(csv_data), portfolio, prices, account_map, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, results); const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present); defer allocator.free(present);
@ -320,8 +335,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}; };
defer rec.deinit(allocator); defer rec.deinit(allocator);
try common.displayResults(rec.results, color, out); // One WF download covers a whole household.
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out); 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); const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
defer allocator.free(present); 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 ──────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────
test "parseArgs: defaults" { test "parseArgs: defaults" {

View file

@ -24,10 +24,54 @@ pub const SymbolComparison = reconcile.SymbolComparison;
pub const AccountComparison = reconcile.AccountComparison; pub const AccountComparison = reconcile.AccountComparison;
pub const compareAccounts = reconcile.compareAccounts; pub const compareAccounts = reconcile.compareAccounts;
pub const hasAccountDiscrepancies = reconcile.hasAccountDiscrepancies; 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 AbsentAccount = reconcile.AbsentAccount;
pub const presentNumbers = reconcile.presentNumbers; pub const presentNumbers = reconcile.presentNumbers;
pub const findAbsentAccounts = reconcile.findAbsentAccounts; 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 ──────────────────────────────────────── // ── Ratio suggestions ────────────────────────────────────────
/// After displaying audit results, check for price_ratio positions where /// 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); 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)); 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. /// Run the flagless portfolio hygiene check.
pub fn runHygieneCheck( pub fn runHygieneCheck(
io: std.Io, io: std.Io,
@ -1156,22 +1192,25 @@ pub fn runHygieneCheck(
}; };
defer allocator.free(results); defer allocator.free(results);
if (verbose or schwab.hasSchwabDiscrepancies(results)) { // A summary paste covers every account on the page.
try out.print("\n", .{}); const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
try schwab.displaySchwabResults(results, color, out); if (view.skipped()) {
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out); try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, verbose, color, out);
} else { continue;
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);
} }
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); try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
}, },
.fidelity_csv => { .fidelity_csv => {
@ -1184,17 +1223,9 @@ pub fn runHygieneCheck(
allocator.free(results); allocator.free(results);
} }
if (verbose or common.hasAccountDiscrepancies(results)) { // Fidelity's download covers every account in the login.
try out.print("\n", .{}); if (try reportAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
try common.displayResults(results, color, out); try accumulatePresent(allocator, &fidelity_present, common.AccountComparison, results);
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);
}, },
.schwab_csv => { .schwab_csv => {
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| { 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); allocator.free(results);
} }
if (verbose or common.hasAccountDiscrepancies(results)) { if (try reportAccountExport(allocator, "schwab", results, schwab.csvScope(file_data), portfolio, prices, account_map, verbose, color, out))
try out.print("\n", .{}); try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
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);
}, },
.wells_fargo_xls => { .wells_fargo_xls => {
var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) { 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); defer rec.deinit(allocator);
// One WF download covers a whole household. If none of // One WF download covers a whole household.
// its accounts is in this portfolio's accounts.srf, it is if (try reportAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, verbose, color, out))
// someone else's export sitting in a shared directory; try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
// 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);
}, },
} }
} }
@ -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, "transfer::<DATE>,") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "dest_lot::VTI@1970-01-01") != 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 SchwabAccountComparison = reconcile.SchwabAccountComparison;
pub const compareSchwabSummary = reconcile.compareSchwabSummary; pub const compareSchwabSummary = reconcile.compareSchwabSummary;
pub const reconcileCsv = reconcile.reconcileCsv; pub const reconcileCsv = reconcile.reconcileCsv;
pub const csvScope = reconcile.schwabCsvScope;
pub const reconcileSummary = reconcile.reconcileSummary; pub const reconcileSummary = reconcile.reconcileSummary;
pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies; pub const hasSchwabDiscrepancies = reconcile.hasSchwabDiscrepancies;
@ -33,7 +34,7 @@ pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: boo
var discrepancy_count: usize = 0; var discrepancy_count: usize = 0;
for (results) |r| { 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_cash_buf: [24]u8 = undefined;
var br_total_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" { test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
const results = [_]SchwabAccountComparison{ const results = [_]SchwabAccountComparison{
// clean mapped -> no status // 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 // 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 // value delta within the CD band (cash ok) -> muted "Value
// +$100.00", shown but not counted as a real mismatch // +$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 // 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; 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, "Cash +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != 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, "Sample Brokerage 3456") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null); try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != 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" { test "displaySchwabResults: color=true emits ANSI and singular label" {
const results = [_]SchwabAccountComparison{ 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 buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); 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 // 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. // off (a missing lot). No CD, no options -> a real, counted warning.
const results = [_]SchwabAccountComparison{ 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 buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); 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. // is warning-colored and neither counts as a mismatch.
const results = [_]SchwabAccountComparison{ const results = [_]SchwabAccountComparison{
// CD account: $8.70 mark sits within a $154 band -> expected/muted. // 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. // 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 buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); 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. // total_delta 1000 on a 50000 stock value -> suggested ratio 1.02 vs 1.0.
const results = [_]SchwabAccountComparison{ 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; var buf: [2048]u8 = undefined;
@ -330,7 +331,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
defer prices.deinit(); defer prices.deinit();
const results = [_]SchwabAccountComparison{ 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 buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); 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); try prices.put("SPY", 500.0);
const results = [_]SchwabAccountComparison{ 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 buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);

View file

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