found wells fargo xls-based download, adjust import/reconcile to use it

This commit is contained in:
Emil Lerch 2026-10-04 10:52:52 -07:00
parent 8566e7c0bd
commit 3e3b77ed54
Signed by: lobo
GPG key ID: A7B62D657EF764F8
19 changed files with 1665 additions and 1570 deletions

View file

@ -994,3 +994,4 @@ command.
| [SRF](https://git.lerch.org/lobo/srf) | Cache file format, portfolio/watchlist parsing, serialization | | [SRF](https://git.lerch.org/lobo/srf) | Cache file format, portfolio/watchlist parsing, serialization |
| [libvaxis](https://github.com/rockorager/libvaxis) (v0.6.0) | Terminal UI rendering | | [libvaxis](https://github.com/rockorager/libvaxis) (v0.6.0) | Terminal UI rendering |
| [z2d](https://github.com/vancluever/z2d) (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) | | [z2d](https://github.com/vancluever/z2d) (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
| [biff8](https://git.lerch.org/lobo/biff8) | Legacy `.xls` (BIFF8) reader for the Wells Fargo positions export |

View file

@ -189,7 +189,7 @@ src/
types.zig Normalized BrokeragePosition record + dollar parser types.zig Normalized BrokeragePosition record + dollar parser
fidelity.zig Fidelity "Download Positions" CSV parser fidelity.zig Fidelity "Download Positions" CSV parser
schwab.zig Schwab export parser schwab.zig Schwab export parser
wells_fargo.zig Wells Fargo export parser wells_fargo.zig Wells Fargo positions spreadsheet (.xls) parser
views/ views/
portfolio_sections.zig Portfolio view model (renderer-agnostic StyleIntent) portfolio_sections.zig Portfolio view model (renderer-agnostic StyleIntent)
compare.zig Compare view model compare.zig Compare view model
@ -248,6 +248,7 @@ acknowledgments.srf Acknowledged review-tab findings (app-maintained)
| [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering | | [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.6.0) | Terminal UI rendering |
| [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) | | [z2d](https://github.com/vancluever/z2d) | Git (v0.11.0) | Pixel chart rendering (Kitty graphics protocol) |
| [zeit](https://github.com/rockorager/zeit) | Git (v0.9.0) | Calendar arithmetic + timezone conversion (ET) | | [zeit](https://github.com/rockorager/zeit) | Git (v0.9.0) | Calendar arithmetic + timezone conversion (ET) |
| [biff8](https://git.lerch.org/lobo/biff8) | Git | Wells Fargo `.xls` export reading |
## Building ## Building

View file

@ -35,6 +35,13 @@ pub fn build(b: *std.Build) void {
.optimize = optimize, .optimize = optimize,
}); });
// biff8: read-only legacy .xls (BIFF8) reader, used by the Wells
// Fargo positions-export parser in src/brokerage/wells_fargo.zig.
const biff8_dep = b.dependency("biff8", .{
.target = target,
.optimize = optimize,
});
const srf_mod = srf_dep.module("srf"); const srf_mod = srf_dep.module("srf");
const shiller_mod = b.addModule("shiller_year", .{ const shiller_mod = b.addModule("shiller_year", .{
@ -58,6 +65,7 @@ pub fn build(b: *std.Build) void {
.imports = &.{ .imports = &.{
.{ .name = "srf", .module = srf_mod }, .{ .name = "srf", .module = srf_mod },
.{ .name = "zeit", .module = zeit_dep.module("zeit") }, .{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "biff8", .module = biff8_dep.module("biff8") },
.{ .name = "build_info", .module = build_info }, .{ .name = "build_info", .module = build_info },
}, },
}); });
@ -71,6 +79,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "z2d", .module = z2d_dep.module("z2d") }, .{ .name = "z2d", .module = z2d_dep.module("z2d") },
.{ .name = "zeit", .module = zeit_dep.module("zeit") }, .{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "websocket", .module = websocket_dep.module("websocket") }, .{ .name = "websocket", .module = websocket_dep.module("websocket") },
.{ .name = "biff8", .module = biff8_dep.module("biff8") },
.{ .name = "build_info", .module = build_info }, .{ .name = "build_info", .module = build_info },
.{ .name = "shiller_year", .module = shiller_mod }, .{ .name = "shiller_year", .module = shiller_mod },
.{ .name = "config_docs", .module = configDocsModule(b) }, .{ .name = "config_docs", .module = configDocsModule(b) },
@ -140,6 +149,7 @@ pub fn build(b: *std.Build) void {
.imports = &.{ .imports = &.{
.{ .name = "srf", .module = srf_mod }, .{ .name = "srf", .module = srf_mod },
.{ .name = "zeit", .module = zeit_dep.module("zeit") }, .{ .name = "zeit", .module = zeit_dep.module("zeit") },
.{ .name = "biff8", .module = biff8_dep.module("biff8") },
.{ .name = "build_info", .module = build_info }, .{ .name = "build_info", .module = build_info },
}, },
}), }),

View file

@ -24,6 +24,10 @@
.url = "git+https://github.com/karlseguin/websocket.zig#99df0d3533a41cbcd5ff59b9af68bbfe6169cc62", .url = "git+https://github.com/karlseguin/websocket.zig#99df0d3533a41cbcd5ff59b9af68bbfe6169cc62",
.hash = "websocket-0.1.0-ZPISdangBAAgWPap_MVU6Nk4rj3GT3xuJuF0IIv8HN6G", .hash = "websocket-0.1.0-ZPISdangBAAgWPap_MVU6Nk4rj3GT3xuJuF0IIv8HN6G",
}, },
.biff8 = .{
.url = "git+https://git.lerch.org/lobo/biff8#0d1b71a021a9fbd2de2e034b926505c8ec18872d",
.hash = "biff8-0.0.0-Wpwf6n66AQBH5r944iEGTKIVm4Gd0YTcRDER4VQPFkGW",
},
}, },
.paths = .{ .paths = .{
"build", "build",

View file

@ -20,14 +20,15 @@ from a supported broker.
## Supported brokers and how to export ## Supported brokers and how to export
`zfin audit` reconciles against **Fidelity** and **Schwab**. (Wells `zfin audit` reconciles against **Fidelity**, **Schwab**, and **Wells
Fargo is handled by [`import`](#what-about-wells-fargo), not audit.) 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** (per-account) | *Accounts -> Positions* -> **Export** (one CSV per account) | `--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>` |
The two Schwab inputs differ in detail: the **per-account CSV** has full The two Schwab inputs differ in detail: the **per-account CSV** has full
per-position data (shares, price, value); the **summary paste** carries per-position data (shares, price, value); the **summary paste** carries
@ -260,10 +261,15 @@ What it considers:
`Portfolio_Positions_Jun-19.csv`; the `Portfolio_Positions_Jun-19.csv`; the
recency window keeps zfin reconciling *the one you just pulled*, not recency window keeps zfin reconciling *the one you just pulled*, not
last quarter's. last quarter's.
- **Detected by content, not filename.** zfin sniffs the first lines -- - **Detected by content, not filename.** zfin sniffs the contents --
Fidelity begins `Account Number`/`Account Name`, a Schwab CSV begins Fidelity by its legal footer, a Schwab CSV by its leading `"Positions
`"Positions for ...`, a Schwab summary contains `Account number ending for ...`, a Schwab summary by `Account number ending in`, a Wells
in`. A renamed file still works; an unrelated CSV is skipped. Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
works; an unrelated file is skipped.
- **A Wells Fargo export with none of your accounts is skipped.** One WF
download covers a whole household, so if you also manage someone
else's WF accounts, their export in your download folder is noted and
skipped rather than reported as eight unmapped accounts.
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."
@ -277,10 +283,11 @@ portfolio. It does that through
- 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, Fidelity's from the Account Number column,
the summary's from "...ending in 1234". 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`) **and** `account_number::` match, and compares against that `schwab`, `wells_fargo`) **and** `account_number::` match, and
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 is shown as `unmapped`** and flagged as a
discrepancy. Fix it by adding `institution::` and `account_number::` discrepancy. 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
@ -344,12 +351,13 @@ treatment to track drift. See
## Why it's finicky ## Why it's finicky
- The parsers are **broker-specific and hardcode each export's column - The parsers are **broker-specific**. The CSV parsers hardcode each
layout** -- if Fidelity or Schwab changes their format, parsing can export's column layout -- if Fidelity or Schwab changes their format,
break (Fidelity's header is validated to catch this; Schwab's is not). parsing can break (both validate their header to catch this). They are
They are not full RFC-4180 CSV parsers (no escaped quotes or not full RFC-4180 CSV parsers (no escaped quotes or multi-line fields)
multi-line fields) -- fine for the real exports, not for arbitrary -- fine for the real exports, not for arbitrary CSVs. The Wells Fargo
CSVs. 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.
@ -358,13 +366,24 @@ None of this is a reason to skip it -- it's the single best way to keep
your records honest -- just know it expects some setup and an occasional your records honest -- just know it expects some setup and an occasional
manual nudge. manual nudge.
### What about Wells Fargo? ### Wells Fargo: one file, every account, real lots
Wells Fargo's portal has no clean positions export, so it isn't an The Wells Fargo spreadsheet differs from the CSV exports in two ways
`audit` target. Instead, [`zfin import --wells-fargo`](../reference/cli/import.md) worth knowing:
rebuilds a portfolio file from a paste of the WF positions table (copy
the rendered table from the brokerage portal and save it to a file). - **It covers the whole household.** Every brokerage account is in one
Fidelity and Schwab exports can be imported the same way. file, each row tagged with its account as `*1234`.
- **It lists tax lots, not positions.** Audit sums them per account and
symbol before comparing, so it doesn't matter whether your portfolio
holds the position as one lot or many. It also means
[`zfin import --wells-fargo`](../reference/cli/import.md) can build a
portfolio file with every lot's real trade date and cost, which is the
easy way to keep a WF-managed portfolio current.
Pick **Portfolio-Expanded Detail**, not Collapsed: the collapsed download
has no lot rows, and zfin rejects it with a message saying so. Cash
(balance, sweep, and accrued interest) is compared per account against
your cash lots.
> **Keep brokerage exports private.** They contain real account numbers > **Keep brokerage exports private.** They contain real account numbers
> and holdings. Store them outside any git repository and delete them > and holdings. Store them outside any git repository and delete them
@ -374,7 +393,7 @@ Fidelity and Schwab exports can be imported the same way.
- [`zfin audit` reference](../reference/cli/audit.md) -- every flag. - [`zfin audit` reference](../reference/cli/audit.md) -- every flag.
- [Map your accounts](set-up-accounts.md) -- the `institution` / `account_number` matching keys. - [Map your accounts](set-up-accounts.md) -- the `institution` / `account_number` matching keys.
- [`zfin import`](../reference/cli/import.md) -- build a portfolio file *from* an export (including Wells Fargo). - [`zfin import`](../reference/cli/import.md) -- build a portfolio file *from* an export.
--- ---

View file

@ -16,17 +16,20 @@ discrepancies.
## Options ## Options
| Flag | Effect | | Flag | Effect |
|--------------------|----------------------------------------------------------------------------| |-----------------------|---------------------------------------------------------------------------------------------|
| `--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 per-account positions CSV. |
| `--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"). |
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." unmatched account is reported as "unmapped." A discovered Wells Fargo
export none of whose accounts are mapped is skipped with a note instead,
since it is almost certainly another portfolio's.
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

@ -5,40 +5,63 @@ for managed accounts (direct-indexing baskets, accounts you don't track
at lot granularity). at lot granularity).
``` ```
Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y] Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE) [-y]
``` ```
Each run **replaces** the target portfolio file with synthetic lots -- Each run **replaces** the target portfolio file with lots drawn from the
one per (account, symbol) -- drawn from the export. Per-buy history is export:
lost; git serves as the file-level history.
- **Fidelity and Schwab** exports are per-position, so each (account,
symbol) becomes one synthetic lot. Per-buy history is lost; git serves
as the file-level history.
- **Wells Fargo** exports list every tax lot, so each WF lot becomes a
lot with its real trade date and cost. One WF file covers every
account in the household.
**Re-import merge:** when the target already exists, lots still present **Re-import merge:** when the target already exists, lots still present
in the new export keep their prior `open_date`, `open_price`, and in the new export keep their prior `note::` and every hand-edited field
`note::`, so trailing-return and ST/LT classifications stay stable and (`ticker::`, `label::`, `price::`, `price_ratio::`, `drip::`, ...), so
`git diff` flags only genuine brokerage changes. New positions get an `git diff` flags only genuine brokerage changes. A lot whose export row
`open_date::1970-01-01` sentinel; disappeared positions are dropped. has no buy date (every Fidelity/Schwab position, and a Wells Fargo
Hand-edited fields (`price::`, `ticker::`) are **not** preserved. `Intra-Day` fund) also keeps its prior `open_date` and `open_price`; new
ones get an `open_date::1970-01-01` sentinel. Disappeared positions are
dropped.
## Options ## Options
| Flag | Effect | | Flag | Effect |
|--------------------------|--------------------------------------------------------------------------------------| |--------------------------|-----------------------------------------------------------------|
| `-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 per-account positions CSV. |
| `--wells-fargo <FILE>` | Wells Fargo positions paste (`-` for stdin). | | `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (`-` for stdin). See below. |
| `--account <NAME>` | (Wells Fargo only) account to attribute lots to; must match an `accounts.srf` entry. | | `-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 target that doesn't exist yet is created in the current directory, so
run a first import from the portfolio's own directory.
## Wells Fargo
On the WF site, download the positions spreadsheet with **Download
Type** "Portfolio-Expanded Detail", **Portfolio View** "Positions", for
all brokerage accounts. It saves as `WFA_Portfolio_Positions_*.xls`.
- "Portfolio-Collapsed Detail" has no per-lot rows; import rejects it.
- Each row names its account as `*1234`; map it with
`institution::wells_fargo,account_number::1234`.
- Cash is one lot per account: cash balance, sweep, and accrued
interest combined, matching WF's own cash total.
## Example ## Example
```bash ```bash
zfin -p portfolio_managed.srf import --fidelity ~/Downloads/Portfolio_Positions.csv zfin -p portfolio_managed.srf import --fidelity ~/Downloads/Portfolio_Positions.csv
zfin -p portfolio_wells_fargo.srf import --wells-fargo ~/Downloads/WFA_Portfolio_Positions_100326_1247.xls
``` ```
## See also ## See also

View file

@ -28,8 +28,8 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
|-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------| |-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. | | `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. |
| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. | | `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. |
| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. | | `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by `zfin audit` and `zfin import` to match export rows. |
| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. | | `account_number` | string | No | -- | Matched with `institution` against export rows (WF: `1234` for `*1234`). Use a placeholder, not a full real number. |
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. | | `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). | | `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). | | `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |

View file

@ -81,6 +81,20 @@ pub fn parse(str: []const u8) !Date {
return fromYmd(y, m, d); return fromYmd(y, m, d);
} }
/// Parse US "MM/DD/YYYY" format, as brokerage exports print dates.
/// Exactly two-digit month and day and a four-digit year; the month
/// and day are range-checked against the calendar (so `02/30/2026`
/// is rejected rather than rolled into March), because the input
/// comes from external files rather than zfin's own serializer.
pub fn parseMdy(str: []const u8) !Date {
if (str.len != 10 or str[2] != '/' or str[5] != '/') return error.InvalidDateFormat;
const m = std.fmt.parseInt(u8, str[0..2], 10) catch return error.InvalidDateFormat;
const d = std.fmt.parseInt(u8, str[3..5], 10) catch return error.InvalidDateFormat;
const y = std.fmt.parseInt(i16, str[6..10], 10) catch return error.InvalidDateFormat;
if (m < 1 or m > 12 or d < 1 or d > daysInMonth(y, m)) return error.InvalidDateFormat;
return fromYmd(y, m, d);
}
/// Hook for srf coercion via `FieldIterator.to(T, ...)`. Returns a /// Hook for srf coercion via `FieldIterator.to(T, ...)`. Returns a
/// `CoercionResult(Date)` with `require_free_original = true` so SRF /// `CoercionResult(Date)` with `require_free_original = true` so SRF
/// frees the consumed source string after parsing. /// frees the consumed source string after parsing.
@ -645,3 +659,29 @@ test "parse error cases" {
try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes
try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator
} }
test "parseMdy" {
try std.testing.expect(Date.fromYmd(2026, 10, 3).eql(try Date.parseMdy("10/03/2026")));
try std.testing.expect(Date.fromYmd(2024, 2, 29).eql(try Date.parseMdy("02/29/2024"))); // leap day
try std.testing.expect(Date.fromYmd(1999, 12, 31).eql(try Date.parseMdy("12/31/1999")));
}
test "parseMdy error cases" {
const bad = [_][]const u8{
"2026-10-03", // ISO, not US
"10/3/2026", // single-digit day
"10-03-2026", // wrong separator
"10/03/26", // two-digit year
"aa/03/2026",
"10/bb/2026",
"10/03/yyyy",
"00/10/2026", // month 0
"13/01/2026", // month 13
"01/00/2026", // day 0
"02/30/2026", // past month end
"02/29/2026", // not a leap year
"Intra-Day", // what brokerages print when there is no date
"",
};
for (bad) |s| try std.testing.expectError(error.InvalidDateFormat, Date.parseMdy(s));
}

View file

@ -6,10 +6,12 @@
//! cdLotAllowance, findAbsentAccounts, ...) //! cdLotAllowance, findAbsentAccounts, ...)
//! - `schwab` Schwab positions CSV + summary reconcilers //! - `schwab` Schwab positions CSV + summary reconcilers
//! - `fidelity` Fidelity positions CSV reconciler //! - `fidelity` Fidelity positions CSV reconciler
//! - `wells_fargo` Wells Fargo positions spreadsheet reconciler
pub const common = @import("reconcile/common.zig"); pub const common = @import("reconcile/common.zig");
pub const schwab = @import("reconcile/schwab.zig"); pub const schwab = @import("reconcile/schwab.zig");
pub const fidelity = @import("reconcile/fidelity.zig"); pub const fidelity = @import("reconcile/fidelity.zig");
pub const wells_fargo = @import("reconcile/wells_fargo.zig");
// ── Flat convenience re-exports ────────────────────────────── // ── Flat convenience re-exports ──────────────────────────────
pub const value_tolerance = common.value_tolerance; pub const value_tolerance = common.value_tolerance;
@ -35,3 +37,4 @@ pub const hasSchwabDiscrepancies = schwab.hasSchwabDiscrepancies;
pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions; pub const summaryRatioSuggestions = schwab.summaryRatioSuggestions;
pub const reconcileFidelity = fidelity.reconcile; pub const reconcileFidelity = fidelity.reconcile;
pub const reconcileWellsFargo = wells_fargo.reconcile;

View file

@ -0,0 +1,182 @@
//! Wells Fargo reconciler (pure compute).
//!
//! WF's positions export is one spreadsheet for the whole household
//! (see `brokerage/wells_fargo.zig`). This module wires its positions
//! into the shared per-account comparison engine in `common.zig`; the
//! export lists one row per tax lot, which `compareAccounts` sums per
//! (account, symbol) like any multi-row position. The ANSI display
//! lives in `commands/audit/`.
const std = @import("std");
const biff8 = @import("biff8");
const zfin = @import("../../root.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const wf_parser = @import("../../brokerage/wells_fargo.zig");
/// Reconcile the positions sheet against the portfolio. Returns owned
/// `AccountComparison` results (free each `.comparisons` slice, then
/// the results slice). String fields in the results borrow from
/// `sheet`, which must outlive them.
pub fn reconcile(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
sheet: *const biff8.Sheet,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const positions = try wf_parser.parsePositions(allocator, sheet);
// Result strings borrow from the sheet, not from this slice.
defer allocator.free(positions);
return common.compareAccounts(allocator, portfolio, positions, account_map, wf_parser.institution, prices, as_of);
}
/// A reconciliation of an export file, owning the decoded workbook
/// its results borrow from.
pub const Reconciled = struct {
workbook: biff8.Workbook,
results: []common.AccountComparison,
pub fn deinit(self: *Reconciled, allocator: std.mem.Allocator) void {
for (self.results) |r| allocator.free(r.comparisons);
allocator.free(self.results);
self.workbook.deinit();
}
/// True when at least one of the export's accounts is in
/// `accounts.srf`. An export with none is almost certainly another
/// portfolio's (one household's download sitting in a shared
/// Downloads directory), so auto-discovery skips it rather than
/// reporting every account as a discrepancy.
pub fn mapsAnyAccount(self: Reconciled) bool {
for (self.results) |r| {
if (r.account_name.len > 0) return true;
}
return false;
}
};
/// Decode an export file and reconcile it. Errors are the decode's
/// (`wf_parser.ExportError`) and allocation failure; use
/// `wf_parser.exportHint` to explain the former.
pub fn reconcileXls(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
bytes: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) wf_parser.ExportError!Reconciled {
var workbook = try biff8.Workbook.parse(allocator, bytes);
errdefer workbook.deinit();
const sheet = try wf_parser.positionsSheet(&workbook);
const results = try reconcile(allocator, portfolio, sheet, account_map, prices, as_of);
return .{ .workbook = workbook, .results = results };
}
// ---- Tests ----
const Lot = @import("../../models/portfolio.zig").Lot;
const Portfolio = @import("../../models/portfolio.zig").Portfolio;
const Cell = biff8.Cell;
fn txt(s: []const u8) Cell {
return .{ .text = s };
}
fn num(v: f64) Cell {
return .{ .number = v };
}
const header = [_]Cell{ txt("Description"), txt("Symbol"), txt("Account Number"), txt("Market Value"), txt("Shares"), txt("Total Cost"), txt("Trade Date") };
/// Placeholder household: VTI held as two lots in *1234, plus cash.
const rows = [_][]const Cell{
&.{ txt("Description"), txt("Account Number"), txt("Market Value") },
&.{ txt("Cash Balance"), txt("*1234"), num(40) },
&.{ txt("Bank Deposit Sweep"), txt("*1234"), num(60) },
&.{txt("Total Cash/Cash Alternatives & Margin")},
&header,
&.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), num(6000), num(20), num(4500), txt("Detail") },
&.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), num(3600), num(12), num(2400), txt("02/03/2022") },
&.{ txt("VANGUARD TOTAL STOCK MKT"), txt("VTI"), txt("*1234"), num(2400), num(8), num(2100), txt("09/09/2026") },
&.{txt("Total ETFs")},
};
const test_sheet: biff8.Sheet = .{ .name = wf_parser.sheet_name, .rows = &rows };
test "reconcile: per-lot rows and cash rows sum per account and match the portfolio" {
const allocator = std.testing.allocator;
// The portfolio may hold the position as one lot or as lots of its
// own; either way 20 VTI @ $300 plus $100 of cash matches.
var lots = [_]Lot{
.{ .symbol = "VTI", .shares = 20, .open_date = Date.fromYmd(2022, 2, 3), .open_price = 225, .account = "Sample IRA" },
.{ .symbol = "", .shares = 100, .open_date = Date.epoch, .open_price = 1, .account = "Sample IRA", .security_type = .cash },
};
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTI", 300.0);
const results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample IRA", results[0].account_name);
try std.testing.expectEqualStrings("*1234", results[0].brokerage_name);
try std.testing.expect(!results[0].has_discrepancies);
// VTI consolidated into one comparison, cash into another.
try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len);
}
test "Reconciled.mapsAnyAccount: false when no export account is in accounts.srf" {
const allocator = std.testing.allocator;
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "9012" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var rec: Reconciled = .{
.workbook = .{ .arena = std.heap.ArenaAllocator.init(allocator), .sheets = &.{} },
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
};
defer rec.deinit(allocator);
try std.testing.expect(!rec.mapsAnyAccount());
// With the export's account mapped, it is ours.
entries[0].account_number = "1234";
var mapped: Reconciled = .{
.workbook = .{ .arena = std.heap.ArenaAllocator.init(allocator), .sheets = &.{} },
.results = try reconcile(allocator, portfolio, &test_sheet, acct_map, prices, Date.fromYmd(2026, 10, 3)),
};
defer mapped.deinit(allocator);
try std.testing.expect(mapped.mapsAnyAccount());
}
test "reconcileXls: decode errors surface with their names" {
const allocator = std.testing.allocator;
const portfolio = Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try std.testing.expectError(
error.NotCompoundFile,
reconcileXls(allocator, portfolio, "Symbol,Quantity\nVTI,1\n", acct_map, prices, Date.fromYmd(2026, 10, 3)),
);
}

View file

@ -1,10 +1,11 @@
//! Brokerage export parsers, grouped for downstream consumers. //! Brokerage export parsers, grouped for downstream consumers.
//! //!
//! Each broker module parses its export (positions CSV, and for Schwab //! Each broker module parses its export (positions CSV, for Schwab
//! an account-summary paste) into the normalized `BrokeragePosition` //! an account-summary paste, for Wells Fargo a positions spreadsheet)
//! shape in `types.zig`. Pure functions: `(allocator, data) -> parsed`, //! into the normalized `BrokeragePosition` shape in `types.zig`. Pure
//! no IO. The reconciliation layer (`analytics/reconcile`) and finrev //! functions: `(allocator, data) -> parsed`, no IO. The
//! consume these. //! reconciliation layer (`analytics/reconcile`) and finrev consume
//! these.
pub const types = @import("brokerage/types.zig"); pub const types = @import("brokerage/types.zig");
pub const schwab = @import("brokerage/schwab.zig"); pub const schwab = @import("brokerage/schwab.zig");

View file

@ -14,9 +14,12 @@
//! keeps this module free of a buffer-lifetime contract. //! keeps this module free of a buffer-lifetime contract.
const std = @import("std"); const std = @import("std");
const biff8 = @import("biff8");
const wells_fargo = @import("wells_fargo.zig");
/// Size ceiling for non-CSV candidates. A CSV is exempt because a positions /// Size ceiling for candidates that are not spreadsheet exports. CSV and
/// export legitimately gets large; anything else this big is not an export. /// XLS are exempt because a positions export legitimately gets large;
/// anything else this big is not an export.
const max_size_non_csv = 512 * 1024; const max_size_non_csv = 512 * 1024;
/// Type of a discovered brokerage file. /// Type of a discovered brokerage file.
@ -24,6 +27,7 @@ pub const BrokerFileKind = enum {
fidelity_csv, fidelity_csv,
schwab_csv, schwab_csv,
schwab_summary, schwab_summary,
wells_fargo_xls,
}; };
/// A discovered brokerage file ready for reconciliation. /// A discovered brokerage file ready for reconciliation.
@ -41,6 +45,14 @@ pub const DiscoveredFile = struct {
/// stable self-identifying marker (rather than the column header) means a /// stable self-identifying marker (rather than the column header) means a
/// format tweak surfaces as a specific parse error, not a misroute. /// format tweak surfaces as a specific parse error, not a misroute.
pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind { pub fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
// Wells Fargo positions spreadsheet: a binary .xls whose one sheet is
// named `WFA_Positions`. The sheet name is stored as plain ASCII in
// the workbook's sheet directory, so a byte search finds it without
// decoding the file; the compound-file signature keeps a text file
// that merely mentions the name from matching.
if (biff8.isCompoundFile(data) and std.mem.indexOf(u8, data, wells_fargo.sheet_name) != null)
return .wells_fargo_xls;
// Strip optional UTF-8 BOM // Strip optional UTF-8 BOM
const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF) const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF)
data[3..] data[3..]
@ -104,9 +116,9 @@ pub fn brokerFiles(
const age_s = now_s - mtime_s; const age_s = now_s - mtime_s;
if (age_s > max_age_s) continue; if (age_s > max_age_s) continue;
// Check if it's a CSV (no size limit) or non-CSV (size limit applies) // Spreadsheet exports (no size limit) vs everything else (size limit applies)
const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV"); const is_export_format = isSpreadsheetName(entry.name);
if (!is_csv and stat.size > max_size_non_csv) continue; if (!is_export_format and stat.size > max_size_non_csv) continue;
// Read and detect content type // Read and detect content type
const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue; const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue;
@ -124,6 +136,32 @@ pub fn brokerFiles(
return results.toOwnedSlice(allocator); return results.toOwnedSlice(allocator);
} }
/// `.csv` or `.xls`, either case: the formats brokerages export
/// positions in, exempt from the non-export size ceiling.
fn isSpreadsheetName(name: []const u8) bool {
const ext = std.fs.path.extension(name);
return std.ascii.eqlIgnoreCase(ext, ".csv") or std.ascii.eqlIgnoreCase(ext, ".xls");
}
test "isSpreadsheetName" {
try std.testing.expect(isSpreadsheetName("positions.csv"));
try std.testing.expect(isSpreadsheetName("POSITIONS.CSV"));
try std.testing.expect(isSpreadsheetName("WFA_Portfolio_Positions.xls"));
try std.testing.expect(isSpreadsheetName("export.XLS"));
try std.testing.expect(!isSpreadsheetName("summary.txt"));
try std.testing.expect(!isSpreadsheetName("book.xlsx"));
try std.testing.expect(!isSpreadsheetName("csv"));
}
test "detectBrokerFileKind: wells fargo xls identified by signature + sheet name" {
const sig = [_]u8{ 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 };
try std.testing.expectEqual(BrokerFileKind.wells_fargo_xls, detectBrokerFileKind(&sig ++ "....WFA_Positions....").?);
// Some other legacy Office file: compound file, no WF sheet.
try std.testing.expect(detectBrokerFileKind(&sig ++ "....Sheet1....") == null);
// A text file that mentions the sheet name is not a workbook.
try std.testing.expect(detectBrokerFileKind("notes about WFA_Positions") == null);
}
test "detectBrokerFileKind: fidelity csv identified by legal footer" { test "detectBrokerFileKind: fidelity csv identified by legal footer" {
// Detection keys on the self-identifying legal-entity footer, not the // Detection keys on the self-identifying legal-entity footer, not the
// column header (which Fidelity has already re-cased). A realistic // column header (which Fidelity has already re-cased). A realistic

View file

@ -30,6 +30,12 @@
//! shape - exactly what the audit reconciler needs to compare //! shape - exactly what the audit reconciler needs to compare
//! against the portfolio's mapped view. //! against the portfolio's mapped view.
//! //!
//! Wells Fargo's positions export is the exception to "no buy date":
//! it lists every tax lot with its trade date, so its parser builds
//! real `Lot`s for import directly (`wells_fargo.parseLots`) rather
//! than going through a position. `lotFromPosition` below is the one
//! conversion rule both paths share.
//!
//! ## Memory & lifetime contract //! ## Memory & lifetime contract
//! //!
//! All string fields in records produced by the per-broker parsers //! All string fields in records produced by the per-broker parsers
@ -39,6 +45,9 @@
//! allocator. //! allocator.
const std = @import("std"); const std = @import("std");
const Date = @import("../Date.zig");
const Lot = @import("../models/portfolio.zig").Lot;
const LotType = @import("../models/portfolio.zig").LotType;
// ── Brokerage position (normalized from any source) ───────── // ── Brokerage position (normalized from any source) ─────────
@ -56,6 +65,44 @@ pub const BrokeragePosition = struct {
is_cash: bool, is_cash: bool,
}; };
/// The lot an export row becomes on import, for portfolio account
/// `account`:
///
/// - cash: `security_type::cash`, shares = the dollar value at a
/// $1.00 open price (the convention audit and snapshot use for
/// cash rows)
/// - otherwise: shares from the row, open price = cost basis per
/// share, falling back to current value per share, else 0
/// - `open_date` is `Date.epoch`, import's "unknown" sentinel; a
/// caller that knows the buy date overwrites it
///
/// A non-cash row without a quantity is malformed export data; it
/// becomes a harmless 0-share lot the user will see in `git diff`.
///
/// Strings are borrowed from `pos` and `account`.
pub fn lotFromPosition(pos: BrokeragePosition, account: []const u8) Lot {
if (pos.is_cash) {
return .{
.symbol = pos.symbol,
.shares = pos.quantity orelse pos.current_value orelse 0,
.open_date = Date.epoch,
.open_price = 1.0,
.account = account,
.security_type = .cash,
};
}
const shares = pos.quantity orelse 0;
const total: ?f64 = pos.cost_basis orelse pos.current_value;
return .{
.symbol = pos.symbol,
.shares = shares,
.open_date = Date.epoch,
.open_price = if (total) |t| (if (shares > 0) t / shares else 0) else 0,
.account = account,
.security_type = .stock,
};
}
// ── Dollar-string parsing ──────────────────────────────────── // ── Dollar-string parsing ────────────────────────────────────
/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00". /// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
@ -132,3 +179,36 @@ test "isUnitPriceCash: unparseable inputs return false" {
try std.testing.expect(!isUnitPriceCash("$1.00", "")); try std.testing.expect(!isUnitPriceCash("$1.00", ""));
try std.testing.expect(!isUnitPriceCash("N/A", "$1.00")); try std.testing.expect(!isUnitPriceCash("N/A", "$1.00"));
} }
fn testPosition(quantity: ?f64, value: ?f64, cost: ?f64, is_cash: bool) BrokeragePosition {
return .{ .account_number = "1234", .account_name = "", .symbol = "SAMPLE", .description = "", .quantity = quantity, .current_value = value, .cost_basis = cost, .is_cash = is_cash };
}
test "lotFromPosition: stock open price is cost basis per share" {
const lot = lotFromPosition(testPosition(100, 17500, 12000, false), "Sample Brokerage");
try std.testing.expectEqualStrings("SAMPLE", lot.symbol);
try std.testing.expectEqualStrings("Sample Brokerage", lot.account.?);
try std.testing.expectEqual(LotType.stock, lot.security_type);
try std.testing.expectEqual(@as(f64, 100), lot.shares);
try std.testing.expectEqual(@as(f64, 120), lot.open_price);
try std.testing.expect(Date.epoch.eql(lot.open_date));
}
test "lotFromPosition: no cost basis falls back to value per share, then 0" {
try std.testing.expectEqual(@as(f64, 400), lotFromPosition(testPosition(10, 4000, null, false), "A").open_price);
try std.testing.expectEqual(@as(f64, 0), lotFromPosition(testPosition(10, null, null, false), "A").open_price);
// No quantity: a harmless zero-share lot rather than a divide by zero.
const no_qty = lotFromPosition(testPosition(null, 4000, 3000, false), "A");
try std.testing.expectEqual(@as(f64, 0), no_qty.shares);
try std.testing.expectEqual(@as(f64, 0), no_qty.open_price);
}
test "lotFromPosition: cash is dollars at a $1 open price" {
const lot = lotFromPosition(testPosition(null, 5000, null, true), "A");
try std.testing.expectEqual(LotType.cash, lot.security_type);
try std.testing.expectEqual(@as(f64, 5000), lot.shares);
try std.testing.expectEqual(@as(f64, 1.0), lot.open_price);
// A cash row that reports a quantity uses it.
try std.testing.expectEqual(@as(f64, 4999), lotFromPosition(testPosition(4999, 5000, null, true), "A").shares);
try std.testing.expectEqual(@as(f64, 0), lotFromPosition(testPosition(null, null, null, true), "A").shares);
}

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,12 @@
//! per-responsibility modules in the `audit/` directory: //! per-responsibility modules in the `audit/` directory:
//! //!
//! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags) //! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags)
//! - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler //! - `analytics/reconcile/fidelity.zig` - `--fidelity` positions-CSV
//! reconciler
//! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary` //! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary`
//! reconcilers //! reconcilers
//! - `analytics/reconcile/wells_fargo.zig` - `--wells-fargo`
//! positions-spreadsheet reconciler
//! - `audit/common.zig` - shared comparison types + per-account display //! - `audit/common.zig` - shared comparison types + per-account display
//! //!
//! This file sits beside its `audit/` directory (the `tui.zig` + //! This file sits beside its `audit/` directory (the `tui.zig` +
@ -23,6 +26,8 @@ const framework = @import("framework.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");
const wells_fargo = @import("../analytics/reconcile/wells_fargo.zig");
const wf_parser = @import("../brokerage/wells_fargo.zig");
const schwab = @import("audit/schwab.zig"); const schwab = @import("audit/schwab.zig");
const hygiene = @import("audit/hygiene.zig"); const hygiene = @import("audit/hygiene.zig");
@ -32,6 +37,7 @@ pub const ParsedArgs = struct {
fidelity_csv: ?[]const u8 = null, fidelity_csv: ?[]const u8 = null,
schwab_csv: ?[]const u8 = null, schwab_csv: ?[]const u8 = null,
schwab_summary: bool = false, schwab_summary: bool = false,
wells_fargo_xls: ?[]const u8 = null,
verbose: bool = false, verbose: bool = false,
stale_days: u32 = hygiene.default_stale_days, stale_days: u32 = hygiene.default_stale_days,
}; };
@ -60,10 +66,13 @@ pub const meta: framework.Meta = .{
\\ --schwab <CSV> Schwab per-account positions CSV export \\ --schwab <CSV> Schwab per-account positions CSV export
\\ --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
\\ (Download Type "Portfolio-Expanded Detail",
\\ Portfolio View "Positions")
\\ \\
, ,
.uppercase_first_arg = false, .uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue }, .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue, InvalidExport },
}; };
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
@ -77,6 +86,8 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
parsed.schwab_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); parsed.schwab_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--schwab-summary")) { } else if (std.mem.eql(u8, a, "--schwab-summary")) {
parsed.schwab_summary = true; parsed.schwab_summary = true;
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
parsed.wells_fargo_xls = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--verbose")) { } else if (std.mem.eql(u8, a, "--verbose")) {
parsed.verbose = true; parsed.verbose = true;
} else if (std.mem.eql(u8, a, "--stale-days")) { } else if (std.mem.eql(u8, a, "--stale-days")) {
@ -115,6 +126,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const fidelity_csv = parsed.fidelity_csv; const fidelity_csv = parsed.fidelity_csv;
const schwab_csv = parsed.schwab_csv; const schwab_csv = parsed.schwab_csv;
const schwab_summary = parsed.schwab_summary; const schwab_summary = parsed.schwab_summary;
const wells_fargo_xls = parsed.wells_fargo_xls;
const verbose = parsed.verbose; const verbose = parsed.verbose;
const stale_days = parsed.stale_days; const stale_days = parsed.stale_days;
@ -122,7 +134,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// semantics (git blame, commit SHAs, etc.), so resolve the anchor - // semantics (git blame, commit SHAs, etc.), so resolve the anchor -
// but the large-lot check inside diffs portfolio content and needs // but the large-lot check inside diffs portfolio content and needs
// the merged view, so hand it the whole glob too. // the merged view, so hand it the whole glob too.
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { if (fidelity_csv == null and schwab_csv == null and !schwab_summary and wells_fargo_xls == null) {
const pf = ctx.resolvePortfolioPath(); const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator); defer pf.deinit(allocator);
var all = ctx.resolvePortfolioPaths() catch null; var all = ctx.resolvePortfolioPaths() catch null;
@ -131,7 +143,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
return hygiene.runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, paths, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out); return hygiene.runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, paths, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out);
} }
// Reconciliation modes (--fidelity / --schwab / --schwab-summary): // Reconciliation modes (--fidelity / --schwab / --schwab-summary /
// --wells-fargo):
// load the union of all portfolio files so the comparison sees // load the union of all portfolio files so the comparison sees
// every lot the user holds, even if they're split across multiple // every lot the user holds, even if they're split across multiple
// portfolio_*.srf files. // portfolio_*.srf files.
@ -284,6 +297,38 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(absent); defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "this export", out); try common.displayAbsentAccounts(absent, color, "this export", out);
} }
// Wells Fargo positions spreadsheet (one file, every WF account)
if (wells_fargo_xls) |xls_path| {
const xls_data = std.Io.Dir.cwd().readFileAlloc(io, xls_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read Wells Fargo export {s}: {s}\n", .{ xls_path, @errorName(err) }) catch "Error: Cannot read Wells Fargo export\n";
cli.stderrPrint(io, msg);
return error.InvalidExport;
};
defer allocator.free(xls_data);
var rec = wells_fargo.reconcileXls(allocator, portfolio, xls_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err,
else => |e| {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read Wells Fargo export {s}: {s}\n", .{ xls_path, @errorName(e) }) catch "Error: Cannot read Wells Fargo export\n";
cli.stderrPrint(io, msg);
cli.stderrPrint(io, wf_parser.exportHint(e));
return error.InvalidExport;
},
};
defer rec.deinit(allocator);
try common.displayResults(rec.results, color, out);
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, wf_parser.institution, present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
} }
// ── Tests ──────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────
@ -296,6 +341,7 @@ test "parseArgs: defaults" {
try std.testing.expect(parsed.fidelity_csv == null); try std.testing.expect(parsed.fidelity_csv == null);
try std.testing.expect(parsed.schwab_csv == null); try std.testing.expect(parsed.schwab_csv == null);
try std.testing.expect(!parsed.schwab_summary); try std.testing.expect(!parsed.schwab_summary);
try std.testing.expect(parsed.wells_fargo_xls == null);
try std.testing.expect(!parsed.verbose); try std.testing.expect(!parsed.verbose);
try std.testing.expectEqual(hygiene.default_stale_days, parsed.stale_days); try std.testing.expectEqual(hygiene.default_stale_days, parsed.stale_days);
} }
@ -316,6 +362,21 @@ test "parseArgs: --schwab captures CSV path" {
try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?); try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?);
} }
test "parseArgs: --wells-fargo captures the spreadsheet path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--wells-fargo", "wf.xls" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("wf.xls", parsed.wells_fargo_xls.?);
}
test "parseArgs: --wells-fargo without a value is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--wells-fargo"};
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --schwab-summary boolean" { test "parseArgs: --schwab-summary boolean" {
var ctx: framework.RunCtx = undefined; var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io; ctx.io = std.testing.io;

View file

@ -31,6 +31,8 @@ const test_git = @import("../../testutil/git.zig");
const common = @import("common.zig"); const common = @import("common.zig");
const fidelity = @import("../../analytics/reconcile/fidelity.zig"); const fidelity = @import("../../analytics/reconcile/fidelity.zig");
const wells_fargo = @import("../../analytics/reconcile/wells_fargo.zig");
const wf_parser = @import("../../brokerage/wells_fargo.zig");
const schwab = @import("schwab.zig"); const schwab = @import("schwab.zig");
const discover = zfin.brokerage.discover; const discover = zfin.brokerage.discover;
const fmt = cli.fmt; const fmt = cli.fmt;
@ -1087,6 +1089,7 @@ pub fn runHygieneCheck(
.fidelity_csv => "fidelity", .fidelity_csv => "fidelity",
.schwab_csv => "schwab csv", .schwab_csv => "schwab csv",
.schwab_summary => "schwab summary", .schwab_summary => "schwab summary",
.wells_fargo_xls => "wells fargo",
}; };
try out.print(" {s:<52} {s}\n", .{ f.path, kind_label }); try out.print(" {s:<52} {s}\n", .{ f.path, kind_label });
} }
@ -1131,11 +1134,14 @@ pub fn runHygieneCheck(
// `file_data`, which is freed per loop iteration. // `file_data`, which is freed per loop iteration.
var fidelity_present: std.ArrayList([]const u8) = .empty; var fidelity_present: std.ArrayList([]const u8) = .empty;
var schwab_present: std.ArrayList([]const u8) = .empty; var schwab_present: std.ArrayList([]const u8) = .empty;
var wells_fargo_present: std.ArrayList([]const u8) = .empty;
defer { defer {
for (fidelity_present.items) |s| allocator.free(s); for (fidelity_present.items) |s| allocator.free(s);
fidelity_present.deinit(allocator); fidelity_present.deinit(allocator);
for (schwab_present.items) |s| allocator.free(s); for (schwab_present.items) |s| allocator.free(s);
schwab_present.deinit(allocator); schwab_present.deinit(allocator);
for (wells_fargo_present.items) |s| allocator.free(s);
wells_fargo_present.deinit(allocator);
} }
for (all_files.items) |f| { for (all_files.items) |f| {
@ -1211,6 +1217,36 @@ pub fn runHygieneCheck(
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results); try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
}, },
.wells_fargo_xls => {
var rec = wells_fargo.reconcileXls(allocator, portfolio, file_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err,
else => |e| {
try cli.printFg(out, color, cli.CLR_WARNING, " {s}: detected as wells fargo export but could not parse ({s}); skipped\n", .{ f.path, @errorName(e) });
continue;
},
};
defer rec.deinit(allocator);
// One WF download covers a whole household. If none of
// its accounts is in this portfolio's accounts.srf, it is
// someone else's export sitting in a shared directory;
// listing every account as a discrepancy would be noise.
if (!rec.mapsAnyAccount()) {
try cli.printFg(out, color, cli.CLR_MUTED, " wells fargo: none of its {d} accounts are in accounts.srf; skipped (another portfolio's export?)\n", .{rec.results.len});
continue;
}
if (verbose or common.hasAccountDiscrepancies(rec.results)) {
try out.print("\n", .{});
try common.displayResults(rec.results, color, out);
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " wells fargo: {d} accounts, no discrepancies\n", .{rec.results.len});
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out);
}
try accumulatePresent(allocator, &wells_fargo_present, common.AccountComparison, rec.results);
},
} }
} }
@ -1229,6 +1265,11 @@ pub fn runHygieneCheck(
defer allocator.free(absent); defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "any export", out); try common.displayAbsentAccounts(absent, color, "any export", out);
} }
if (wells_fargo_present.items.len > 0) {
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, wf_parser.institution, wells_fargo_present.items, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "any export", out);
}
} }
// ── Section 6: Large new lots - confirm source ── // ── Section 6: Large new lots - confirm source ──

View file

@ -34,6 +34,17 @@
//! carries these. //! carries these.
//! - `security_type::cash` for cash-classified positions //! - `security_type::cash` for cash-classified positions
//! //!
//! ## Wells Fargo: real lots
//!
//! Wells Fargo's "Portfolio-Expanded Detail" download lists every tax
//! lot with its trade date and cost, for every account in the
//! household, so a WF import writes one lot per WF lot instead of one
//! synthetic lot per position (`wells_fargo.parseLots`). Each lot's
//! `open_date` and `open_price` come from the export; only lots WF
//! shows without a date (`Intra-Day` funds) take the sentinel and fall
//! back to the prior lot as below. Hand-edited fields and the note are
//! inherited the same way for every source.
//!
//! ## Re-import merge //! ## Re-import merge
//! //!
//! When the target portfolio file already exists, `import` reads //! When the target portfolio file already exists, `import` reads
@ -46,7 +57,9 @@
//! hand-edited field (`Lot.hand_edited_fields`: `ticker`, //! hand-edited field (`Lot.hand_edited_fields`: `ticker`,
//! `label`, `price`, `price_ratio`, `drip`, ...) from the prior //! `label`, `price`, `price_ratio`, `drip`, ...) from the prior
//! lot; only `shares` and `security_type` come from the new //! lot; only `shares` and `security_type` come from the new
//! export. A re-import of an unchanged held position produces //! export. (A lot whose export row carries a real buy date -
//! Wells Fargo - keeps its own `open_date` and `open_price`.)
//! A re-import of an unchanged held position produces
//! byte-identical output, so `git diff` only surfaces actual //! byte-identical output, so `git diff` only surfaces actual
//! brokerage changes (lot-size drift, real cost-basis //! brokerage changes (lot-size drift, real cost-basis
//! adjustments). //! adjustments).
@ -120,11 +133,13 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const biff8 = @import("biff8");
const zfin = @import("../root.zig"); 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 Date = @import("../Date.zig"); const Date = @import("../Date.zig");
const portfolio_mod = @import("../models/portfolio.zig"); const Lot = @import("../models/portfolio.zig").Lot;
const LotType = @import("../models/portfolio.zig").LotType;
const cache = @import("../cache/store.zig"); const cache = @import("../cache/store.zig");
const atomic = @import("../atomic.zig"); const atomic = @import("../atomic.zig");
const fidelity = @import("../brokerage/fidelity.zig"); const fidelity = @import("../brokerage/fidelity.zig");
@ -135,37 +150,24 @@ const analysis = @import("../analytics/analysis.zig");
const BrokeragePosition = brokerage_types.BrokeragePosition; const BrokeragePosition = brokerage_types.BrokeragePosition;
/// Source brokerage for the import. Fidelity and Schwab carry an /// Source brokerage for the import, carrying the export's path.
/// account number in the export rows themselves; Wells Fargo's /// Every source's export carries account numbers per row.
/// paste does not, so the WF variant carries an optional explicit
/// account-name override (`--account NAME`) that the resolver
/// uses when filename inference fails.
pub const Source = union(enum) { pub const Source = union(enum) {
fidelity: []const u8, fidelity: []const u8,
schwab: []const u8, schwab: []const u8,
wells_fargo: WellsFargoArgs, wells_fargo: []const u8,
pub const WellsFargoArgs = struct {
path: []const u8,
/// `--account NAME` value, if the user supplied one.
/// Otherwise the resolver falls back to filename
/// inference and finally to a single-WF-entry lookup.
account_override: ?[]const u8,
};
pub fn label(self: Source) []const u8 { pub fn label(self: Source) []const u8 {
return switch (self) { return switch (self) {
.fidelity => "fidelity", .fidelity => "fidelity",
.schwab => "schwab", .schwab => "schwab",
.wells_fargo => "wells_fargo", .wells_fargo => wells_fargo.institution,
}; };
} }
pub fn path(self: Source) []const u8 { pub fn path(self: Source) []const u8 {
return switch (self) { return switch (self) {
.fidelity => |p| p, inline else => |p| p,
.schwab => |p| p,
.wells_fargo => |a| a.path,
}; };
} }
}; };
@ -181,11 +183,13 @@ pub const meta: framework.Meta = .{
.group = .hygiene, .group = .hygiene,
.synopsis = "Synthesize a portfolio file from a brokerage holdings export", .synopsis = "Synthesize a portfolio file from a brokerage holdings export",
.help = .help =
\\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y] \\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE) [-y]
\\ \\
\\Synthesize a portfolio file from a brokerage positions export. \\Synthesize a portfolio file from a brokerage positions export.
\\Each run REPLACES the target portfolio file with synthetic lots \\Each run REPLACES the target portfolio file with lots drawn
\\drawn from the export - one lot per (account, symbol). \\from the export: one synthetic lot per (account, symbol) for
\\Fidelity and Schwab, one lot per tax lot for Wells Fargo
\\(whose export carries each lot's trade date and cost).
\\ \\
\\Designed for managed accounts (direct-indexing baskets, accounts \\Designed for managed accounts (direct-indexing baskets, accounts
\\you don't track at lot granularity). Per-buy history is lost; \\you don't track at lot granularity). Per-buy history is lost;
@ -200,10 +204,9 @@ pub const meta: framework.Meta = .{
\\`git diff` only flags genuine brokerage changes. Newly-introduced \\`git diff` only flags genuine brokerage changes. Newly-introduced
\\positions get `open_date::1970-01-01` (a "we don't know" \\positions get `open_date::1970-01-01` (a "we don't know"
\\sentinel; the next import will treat it as the prior anchor). \\sentinel; the next import will treat it as the prior anchor).
\\Wells Fargo lots keep the export's own date and cost.
\\Lots that disappear from the export are silently dropped - if \\Lots that disappear from the export are silently dropped - if
\\you sold a position between imports, it just stops appearing. \\you sold a position between imports, it just stops appearing.
\\Only `shares` and `security_type` come from the export; every
\\hand-edited field on a prior lot is preserved.
\\ \\
\\Required: \\Required:
\\ -p, --portfolio <FILE> Target portfolio file (must be a single \\ -p, --portfolio <FILE> Target portfolio file (must be a single
@ -211,19 +214,14 @@ pub const meta: framework.Meta = .{
\\ --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 per-account positions CSV
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered \\ --wells-fargo <XLS> Wells Fargo positions spreadsheet
\\ positions table from the WF portal \\ (Download Type "Portfolio-Expanded
\\ and save to a file). Pass `-` to \\ Detail", Portfolio View "Positions",
\\ all brokerage accounts). Covers every
\\ WF account in one file. Pass `-` to
\\ read from stdin. \\ read from stdin.
\\ \\
\\Options: \\Options:
\\ --account <NAME> (Wells Fargo only) explicit account
\\ name to attribute the lots to. Must
\\ match an entry in accounts.srf. If
\\ omitted, the importer infers the
\\ account from the filename, then
\\ falls back to the single WF entry
\\ in accounts.srf.
\\ -y, --yes Don't prompt before overwriting an \\ -y, --yes Don't prompt before overwriting an
\\ existing file. \\ existing file.
\\ \\
@ -246,8 +244,7 @@ pub const meta: framework.Meta = .{
CannotReadCsv, CannotReadCsv,
CannotReadAccountsFile, CannotReadAccountsFile,
UnmappedAccount, UnmappedAccount,
AmbiguousWellsFargoAccount, InvalidExport,
UnknownAccount,
UserDeclined, UserDeclined,
WriteFailed, WriteFailed,
}, },
@ -257,7 +254,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
var fidelity_path: ?[]const u8 = null; var fidelity_path: ?[]const u8 = null;
var schwab_path: ?[]const u8 = null; var schwab_path: ?[]const u8 = null;
var wells_fargo_path: ?[]const u8 = null; var wells_fargo_path: ?[]const u8 = null;
var account_override: ?[]const u8 = null;
var yes = false; var yes = false;
var i: usize = 0; var i: usize = 0;
@ -269,8 +265,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
schwab_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); schwab_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--wells-fargo")) { } else if (std.mem.eql(u8, a, "--wells-fargo")) {
wells_fargo_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); wells_fargo_path = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--account")) {
account_override = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) { } else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
yes = true; yes = true;
} else { } else {
@ -292,20 +286,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
return error.ConflictingSources; return error.ConflictingSources;
} }
// `--account` is a Wells-Fargo-only knob; the other sources
// carry per-row account_numbers in the export. Reject up
// front so the user notices early.
if (account_override != null and wells_fargo_path == null) {
cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n");
return error.UnexpectedArg;
}
const source: Source = if (fidelity_path) |p| const source: Source = if (fidelity_path) |p|
.{ .fidelity = p } .{ .fidelity = p }
else if (schwab_path) |p| else if (schwab_path) |p|
.{ .schwab = p } .{ .schwab = p }
else if (wells_fargo_path) |p| else if (wells_fargo_path) |p|
.{ .wells_fargo = .{ .path = p, .account_override = account_override } } .{ .wells_fargo = p }
else { else {
cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n"); cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
return error.MissingSource; return error.MissingSource;
@ -331,21 +317,32 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// ── Read the brokerage export ───────────────────────────── // ── Read the brokerage export ─────────────────────────────
// //
// `-` selects stdin (for the paste-from-clipboard workflow); // `-` selects stdin; any other path reads from disk. Same
// any other path reads from disk. Same convention regardless // convention regardless of source.
// of source. const source_path = parsed.source.path();
const csv_path = parsed.source.path(); const source_data = try readSourceData(io, allocator, source_path);
const csv_data = try readSourceData(io, allocator, csv_path); defer allocator.free(source_data);
defer allocator.free(csv_data);
// ── Parse ───────────────────────────────────────────────── // ── Parse ─────────────────────────────────────────────────
//
// The Wells Fargo export is a binary workbook; its positions
// borrow strings from the decoded sheet, so the workbook lives
// for the rest of the run.
var workbook: ?biff8.Workbook = null;
defer if (workbook) |*wb| wb.deinit();
var wf_sheet: ?*const biff8.Sheet = null;
const positions: []BrokeragePosition = switch (parsed.source) { const positions: []BrokeragePosition = switch (parsed.source) {
.fidelity => try fidelity.parseCsv(allocator, csv_data), .fidelity => try fidelity.parseCsv(allocator, source_data),
.schwab => blk: { .schwab => blk: {
const r = try schwab.parseCsv(allocator, csv_data); const r = try schwab.parseCsv(allocator, source_data);
break :blk r.positions; break :blk r.positions;
}, },
.wells_fargo => try wells_fargo.parsePaste(allocator, csv_data), .wells_fargo => blk: {
workbook = biff8.Workbook.parse(allocator, source_data) catch |err| return badWellsFargoExport(io, source_path, err);
wf_sheet = wells_fargo.positionsSheet(&workbook.?) catch |err| return badWellsFargoExport(io, source_path, err);
break :blk wells_fargo.parsePositions(allocator, wf_sheet.?) catch |err| return badWellsFargoExport(io, source_path, err);
},
}; };
defer allocator.free(positions); defer allocator.free(positions);
@ -369,21 +366,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}; };
defer account_map.deinit(); defer account_map.deinit();
// ── WF: resolve and patch the per-row account_number ──────
//
// Wells Fargo pastes don't carry an account identifier, so
// every position came back with `account_number = ""`. Defer
// to `wells_fargo.applyAccountToPositions` to resolve
// (explicit `--account` -> filename-inferred -> single-WF-entry
// fallback) and rewrite every position's
// account_number/account_name accordingly. The downstream
// `synthesizeLots` lookup then works uniformly across
// brokerages.
if (parsed.source == .wells_fargo) {
const wf_args = parsed.source.wells_fargo;
try wells_fargo.applyAccountToPositions(io, account_map, csv_path, wf_args.account_override, positions);
}
// ── Read the existing target file (if any) for merge ────── // ── Read the existing target file (if any) for merge ──────
// //
// When the target file already exists, we treat its lots as // When the target file already exists, we treat its lots as
@ -419,13 +401,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
} }
// ── Synthesize lots ─────────────────────────────────────── // ── Synthesize lots ───────────────────────────────────────
const lots = synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt) catch |err| switch (err) { //
error.UnmappedAccount => { // `UnmappedAccount` arrives with the offending account numbers
// synthesizeLots already printed the offending account // already printed to stderr.
// numbers to stderr; just propagate as a user-level error. const lots = switch (parsed.source) {
return err; .wells_fargo => try wellsFargoLots(io, allocator, wf_sheet.?, source_path, positions, account_map, ctx.today, prior_lookup_opt),
}, else => try synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt),
else => |e| return e,
}; };
defer freeLots(allocator, lots); defer freeLots(allocator, lots);
@ -536,14 +517,27 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![
}; };
return data; return data;
} }
return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch { return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [512]u8 = undefined; var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file: {s}\n", .{path}) catch "Error: Cannot read source file\n"; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file {s}: {s}\n", .{ path, @errorName(err) }) catch "Error: Cannot read source file\n";
cli.stderrPrint(io, msg); cli.stderrPrint(io, msg);
return error.CannotReadCsv; return error.CannotReadCsv;
}; };
} }
/// Explain a Wells Fargo export that could not be read, then return
/// the user-level error.
fn badWellsFargoExport(io: std.Io, path: []const u8, err: wells_fargo.ExportError) error{ InvalidExport, OutOfMemory } {
if (err == error.OutOfMemory) return error.OutOfMemory;
// Quiet under tests, like `requireMapped`; the error is what they check.
if (builtin.is_test) return error.InvalidExport;
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read Wells Fargo export {s}: {s}\n", .{ path, @errorName(err) }) catch "Error: Cannot read Wells Fargo export\n";
cli.stderrPrint(io, msg);
cli.stderrPrint(io, wells_fargo.exportHint(err));
return error.InvalidExport;
}
/// Prompt for y/N confirmation on stderr, read one line from stdin. /// Prompt for y/N confirmation on stderr, read one line from stdin.
/// Returns true only on a 'y' or 'Y' answer (with optional whitespace); /// Returns true only on a 'y' or 'Y' answer (with optional whitespace);
/// anything else (including EOF / empty) is "no". /// anything else (including EOF / empty) is "no".
@ -582,11 +576,11 @@ fn confirmOverwrite(io: std.Io, path: []const u8) !bool {
/// `Portfolio` and remain valid as long as the source portfolio /// `Portfolio` and remain valid as long as the source portfolio
/// does. /// does.
const PriorLotsLookup = struct { const PriorLotsLookup = struct {
map: std.StringHashMap(*const portfolio_mod.Lot), map: std.StringHashMap(*const Lot),
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup { fn init(allocator: std.mem.Allocator, lots: []const Lot, today: Date) !PriorLotsLookup {
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator); var map = std.StringHashMap(*const Lot).init(allocator);
errdefer { errdefer {
var it = map.keyIterator(); var it = map.keyIterator();
while (it.next()) |k| allocator.free(k.*); while (it.next()) |k| allocator.free(k.*);
@ -633,7 +627,7 @@ const PriorLotsLookup = struct {
/// Find the prior `Lot` for `(symbol, account)`, returning /// Find the prior `Lot` for `(symbol, account)`, returning
/// null when no match exists. Caller must keep the source /// null when no match exists. Caller must keep the source
/// portfolio alive while using the returned pointer. /// portfolio alive while using the returned pointer.
fn find(self: PriorLotsLookup, symbol: []const u8, account: []const u8) !?*const portfolio_mod.Lot { fn find(self: PriorLotsLookup, symbol: []const u8, account: []const u8) !?*const Lot {
var key_buf: [256]u8 = undefined; var key_buf: [256]u8 = undefined;
// Fast path: stack-allocate the key. Fall back to heap // Fast path: stack-allocate the key. Fall back to heap
// for unusually long symbols/accounts. // for unusually long symbols/accounts.
@ -654,28 +648,15 @@ const PriorLotsLookup = struct {
} }
}; };
/// Synthesize a `Lot` per `BrokeragePosition`. Resolves each /// Synthesize a `Lot` per `BrokeragePosition` (Fidelity, Schwab).
/// brokerage account_number to a portfolio account name via /// Resolves each brokerage account_number to a portfolio account
/// `account_map`; refuses (with a stderr listing of unmapped /// name via `account_map`; refuses (with a stderr listing of
/// numbers) if any can't be resolved. /// unmapped numbers) if any can't be resolved.
/// ///
/// `today` stamps the `note::` field on lots that don't have a /// Each position becomes a lot by `brokerage_types.lotFromPosition`
/// matching prior entry (`note::imported <broker> YYYY-MM-DD`). /// (cost-basis open price, cash at $1, `Date.epoch` open date), then
/// Lots that DO match a prior entry inherit that prior lot's /// takes what the export cannot know from the prior file via
/// note (which carries the original first-seen date), so a /// `inheritFromPrior`.
/// re-import of an unchanged held position produces a
/// byte-identical line - the `git diff` only shows genuine
/// brokerage changes.
///
/// `prior_lookup` (if non-null) carries the lots from the
/// existing target file. For each synthesized lot, we look up
/// `(symbol, account)`:
/// - **Match:** preserve `open_date`, `open_price`, and `note`
/// from the prior lot. Brokerage shares/cost-basis still come
/// from the new export.
/// - **No match:** new position. Use `Date.epoch` for
/// `open_date` (no signal to do better) and stamp today's
/// date in the note.
/// ///
/// Takes `io` so it can print the unmapped-account-number /// Takes `io` so it can print the unmapped-account-number
/// enumeration directly to stderr - easier than threading the list /// enumeration directly to stderr - easier than threading the list
@ -693,12 +674,85 @@ fn synthesizeLots(
source: Source, source: Source,
today: Date, today: Date,
prior_lookup: ?PriorLotsLookup, prior_lookup: ?PriorLotsLookup,
) ![]portfolio_mod.Lot { ) ![]Lot {
const institution = source.label(); const institution = source.label();
try requireMapped(io, allocator, positions, account_map, institution);
// First pass: collect any unmapped account numbers so we can var lots = std.ArrayList(Lot).empty;
// report all of them at once instead of failing on the first errdefer {
// and making the user re-run for each. for (lots.items) |lot| freeLot(allocator, lot);
lots.deinit(allocator);
}
var fresh_note_buf: [64]u8 = undefined;
const fresh_note = try freshNote(&fresh_note_buf, institution, today);
for (positions) |pos| {
const acct_name = account_map.findByInstitutionAccount(institution, pos.account_number).?;
var lot = brokerage_types.lotFromPosition(pos, acct_name);
lot.symbol = try allocator.dupe(u8, lot.symbol);
lot.account = allocator.dupe(u8, acct_name) catch |err| {
allocator.free(lot.symbol);
return err;
};
errdefer freeLot(allocator, lot);
try inheritFromPrior(allocator, &lot, prior_lookup, fresh_note);
try lots.append(allocator, lot);
}
return lots.toOwnedSlice(allocator);
}
/// The Wells Fargo lots: real per-lot dates and costs from the
/// export (`wells_fargo.parseLots`), then the same prior-file
/// inheritance as every other source.
///
/// `positions` is the same export's `parsePositions` output, used
/// for the unmapped-account check so the message matches the other
/// sources'. Caller must call `freeLots`.
fn wellsFargoLots(
io: std.Io,
allocator: std.mem.Allocator,
sheet: *const biff8.Sheet,
source_path: []const u8,
positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
today: Date,
prior_lookup: ?PriorLotsLookup,
) ![]Lot {
try requireMapped(io, allocator, positions, account_map, wells_fargo.institution);
const lots = wells_fargo.parseLots(allocator, sheet, account_map) catch |err| switch (err) {
// requireMapped checked these very account numbers.
error.UnmappedAccount => return err,
else => |e| return badWellsFargoExport(io, source_path, e),
};
errdefer freeLots(allocator, lots);
var fresh_note_buf: [64]u8 = undefined;
const fresh_note = try freshNote(&fresh_note_buf, wells_fargo.institution, today);
for (lots) |*lot| try inheritFromPrior(allocator, lot, prior_lookup, fresh_note);
return lots;
}
/// `note::` stamp for lots with no prior match. Held positions
/// inherit their prior note instead, so the `git diff` between two
/// imports of an unchanged held position stays empty.
fn freshNote(buf: *[64]u8, institution: []const u8, today: Date) ![]const u8 {
return std.fmt.bufPrint(buf, "imported {s} {f}", .{ institution, today });
}
/// Refuse the import if any position's account number has no
/// `accounts.srf` entry for `institution`, listing every unmapped
/// number at once rather than failing on the first and making the
/// user re-run for each.
fn requireMapped(
io: std.Io,
allocator: std.mem.Allocator,
positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
institution: []const u8,
) !void {
var unmapped: std.ArrayList([]const u8) = .empty; var unmapped: std.ArrayList([]const u8) = .empty;
defer unmapped.deinit(allocator); defer unmapped.deinit(allocator);
{ {
@ -713,133 +767,95 @@ fn synthesizeLots(
} }
} }
} }
if (unmapped.items.len > 0) { if (unmapped.items.len == 0) return;
// Skip the human-readable error message under tests to keep
// test output clean; the returned error is what the test // Skip the human-readable error message under tests to keep
// assertions rely on. // test output clean; the returned error is what the test
if (!builtin.is_test) { // assertions rely on.
var stderr_buf: [4096]u8 = undefined; if (!builtin.is_test) {
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf); var stderr_buf: [4096]u8 = undefined;
try stderr_writer.interface.print( var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
"Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n", try stderr_writer.interface.print(
.{ "Error: {d} account number{s} from the {s} export {s} not mapped in accounts.srf:\n",
unmapped.items.len, .{
if (unmapped.items.len == 1) "" else "s", unmapped.items.len,
institution, if (unmapped.items.len == 1) "" else "s",
if (unmapped.items.len == 1) "is" else "are", institution,
}, if (unmapped.items.len == 1) "is" else "are",
); },
for (unmapped.items) |num| { );
try stderr_writer.interface.print(" - {s}\n", .{num}); for (unmapped.items) |num| {
} try stderr_writer.interface.print(" - {s}\n", .{num});
try stderr_writer.interface.print(
"\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++
"`account_number::` value, then rerun the import.\n",
.{institution},
);
try stderr_writer.interface.flush();
} }
return error.UnmappedAccount; try stderr_writer.interface.print(
"\nAdd entries to accounts.srf with `institution::{s}` and the matching\n" ++
"`account_number::` value, then rerun the import.\n",
.{institution},
);
try stderr_writer.interface.flush();
} }
return error.UnmappedAccount;
}
var lots = std.ArrayList(portfolio_mod.Lot).empty; /// Fill in what an export cannot know from the prior file's lot for
errdefer { /// the same `(symbol, account)`:
for (lots.items) |lot| freeLot(allocator, lot); ///
lots.deinit(allocator); /// - **Open date and price**, only when the export had no buy date
/// (`Date.epoch`, which is every Fidelity/Schwab position and a
/// Wells Fargo `Intra-Day` lot). A lot with a real date keeps it.
/// - **Note**: the prior lot's (carrying its original first-seen
/// date), else `fresh_note`.
/// - **Every hand-edited field** (`Lot.hand_edited_fields`). The
/// set is declared once on `Lot`, so a new hand-edited field is
/// preserved here automatically. None ever appears in an export,
/// so without this a re-import would drop the user's annotations.
///
/// Cash lots are never matched: they have no account-meaningful
/// identity, and the broker's balance is the truth every time.
///
/// Strings are duped against `allocator`; a field is only assigned
/// once its copy exists, so `freeLot` is always safe on `lot`.
fn inheritFromPrior(
allocator: std.mem.Allocator,
lot: *Lot,
prior_lookup: ?PriorLotsLookup,
fresh_note: []const u8,
) !void {
const prior: ?*const Lot = if (lot.security_type == .cash)
null
else if (prior_lookup) |pl|
try pl.find(lot.symbol, lot.account.?)
else
null;
lot.note = try allocator.dupe(u8, if (prior) |prev| prev.note orelse fresh_note else fresh_note);
const p = prior orelse return;
if (lot.open_date.eql(Date.epoch)) {
lot.open_date = p.open_date;
lot.open_price = p.open_price;
} }
// String fields are duped into our allocator; value fields
// Stamp for newly-introduced lots (no prior match). Held // (numbers, bools, dates) copy directly.
// positions inherit their prior note instead, so the inline for (Lot.hand_edited_fields) |fname| {
// `git diff` between two imports of an unchanged held if (@TypeOf(@field(p, fname)) == ?[]const u8) {
// position stays empty. @field(lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null;
var fresh_note_buf: [64]u8 = undefined; } else {
const fresh_note = try std.fmt.bufPrint(&fresh_note_buf, "imported {s} {f}", .{ institution, today }); @field(lot, fname) = @field(p, fname);
for (positions) |pos| {
const acct_name = account_map.findByInstitutionAccount(institution, pos.account_number).?;
const shares = pos.quantity orelse blk: {
// Cash positions have null quantity; the convention
// elsewhere in zfin (audit, snapshot's cash row) is
// shares=current_value, open_price=$1. Mirror that.
if (pos.is_cash) break :blk pos.current_value orelse 0;
// Non-cash without quantity is malformed export data.
// Treat as 0 shares so the lot is harmless; the user
// will see it in `git diff` and either fix the export
// or hand-edit the lot.
break :blk 0;
};
const synthesized_open_price: f64 = if (pos.is_cash)
1.0
else if (pos.cost_basis) |cb|
if (shares > 0) cb / shares else 0
else if (pos.current_value) |cv|
if (shares > 0) cv / shares else 0
else
0;
const security_type: portfolio_mod.LotType = if (pos.is_cash) .cash else .stock;
// Merge the prior lot's open_date / open_price / note if
// we have one. Cash lots are excluded from `prior_lookup`
// (they have no account-meaningful identity) and always
// get the synthesized values.
const prior: ?*const portfolio_mod.Lot = if (prior_lookup) |pl|
(if (security_type == .cash) null else try pl.find(pos.symbol, acct_name))
else
null;
const open_date: Date = if (prior) |p| p.open_date else Date.epoch;
const open_price: f64 = if (prior) |p| p.open_price else synthesized_open_price;
const note_text: []const u8 = if (prior) |p|
(if (p.note) |n| n else fresh_note)
else
fresh_note;
var new_lot = portfolio_mod.Lot{
.symbol = try allocator.dupe(u8, pos.symbol),
.shares = shares,
.open_date = open_date,
.open_price = open_price,
.account = try allocator.dupe(u8, acct_name),
.security_type = security_type,
.note = try allocator.dupe(u8, note_text),
};
// Carry every hand-edited field forward from the prior lot.
// The set is declared once on `Lot.hand_edited_fields`, so a
// new hand-edited field is preserved here automatically.
// String fields are duped into our allocator; value fields
// (numbers, bools, dates) copy directly. None of these are
// ever present in a brokerage export, so without this a
// re-import would silently drop the user's annotations.
if (prior) |p| {
inline for (portfolio_mod.Lot.hand_edited_fields) |fname| {
if (@TypeOf(@field(p, fname)) == ?[]const u8) {
@field(new_lot, fname) = if (@field(p, fname)) |s| try allocator.dupe(u8, s) else null;
} else {
@field(new_lot, fname) = @field(p, fname);
}
}
} }
try lots.append(allocator, new_lot);
} }
return lots.toOwnedSlice(allocator);
} }
/// Free per-lot allocator-owned strings + the slice. Mirror of the /// Free per-lot allocator-owned strings + the slice. Mirror of the
/// internal cleanup in `Portfolio.deinit` (which we'd use directly /// internal cleanup in `Portfolio.deinit` (which we'd use directly
/// except we don't construct a Portfolio here - `serializePortfolio` /// except we don't construct a Portfolio here - `serializePortfolio`
/// takes a bare `[]const Lot`). /// takes a bare `[]const Lot`).
fn freeLots(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) void { fn freeLots(allocator: std.mem.Allocator, lots: []const Lot) void {
for (lots) |lot| freeLot(allocator, lot); for (lots) |lot| freeLot(allocator, lot);
allocator.free(lots); allocator.free(lots);
} }
fn freeLot(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) void { fn freeLot(allocator: std.mem.Allocator, lot: Lot) void {
allocator.free(lot.symbol); allocator.free(lot.symbol);
if (lot.note) |n| allocator.free(n); if (lot.note) |n| allocator.free(n);
if (lot.label) |l| allocator.free(l); if (lot.label) |l| allocator.free(l);
@ -898,7 +914,7 @@ test "synthesizeLots: stock positions get open_price = cost_basis / quantity" {
// `open_date` is the sentinel and the note carries the // `open_date` is the sentinel and the note carries the
// import date so the user can tell when it was first seen. // import date so the user can tell when it was first seen.
try testing.expectEqual(Date.epoch.days, lots[0].open_date.days); try testing.expectEqual(Date.epoch.days, lots[0].open_date.days);
try testing.expectEqual(portfolio_mod.LotType.stock, lots[0].security_type); try testing.expectEqual(LotType.stock, lots[0].security_type);
try testing.expect(lots[0].note != null); try testing.expect(lots[0].note != null);
try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "imported fidelity") != null); try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "imported fidelity") != null);
try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "2026-05-21") != null); try testing.expect(std.mem.indexOf(u8, lots[0].note.?, "2026-05-21") != null);
@ -957,7 +973,7 @@ test "synthesizeLots: cash positions become security_type=cash with shares=value
defer freeLots(allocator, lots); defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 1), lots.len); try testing.expectEqual(@as(usize, 1), lots.len);
try testing.expectEqual(portfolio_mod.LotType.cash, lots[0].security_type); try testing.expectEqual(LotType.cash, lots[0].security_type);
try testing.expectApproxEqAbs(@as(f64, 5000), lots[0].shares, 0.01); try testing.expectApproxEqAbs(@as(f64, 5000), lots[0].shares, 0.01);
try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].open_price, 0.01); try testing.expectApproxEqAbs(@as(f64, 1.0), lots[0].open_price, 0.01);
} }
@ -985,7 +1001,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
// by (symbol, account). The merge path should preserve every // by (symbol, account). The merge path should preserve every
// field of this lot (open_date, open_price, note) regardless // field of this lot (open_date, open_price, note) regardless
// of the import date. // of the import date.
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "AAPL", .symbol = "AAPL",
.shares = 1, .shares = 1,
@ -1126,7 +1142,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
}); });
defer account_map.deinit(); defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "AAPL", .symbol = "AAPL",
.shares = 100, .shares = 100,
@ -1173,7 +1189,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" {
}); });
defer account_map.deinit(); defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "02315N600", .symbol = "02315N600",
.shares = 100, .shares = 100,
@ -1250,7 +1266,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
// Prior has only AAPL; new export has both AAPL and a new // Prior has only AAPL; new export has both AAPL and a new
// GOOG. // GOOG.
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "AAPL", .symbol = "AAPL",
.shares = 100, .shares = 100,
@ -1296,7 +1312,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
}); });
defer account_map.deinit(); defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "AAPL", .symbol = "AAPL",
.shares = 50, .shares = 50,
@ -1350,7 +1366,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" {
}); });
defer account_map.deinit(); defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "AAPL", .symbol = "AAPL",
.shares = 100, .shares = 100,
@ -1391,7 +1407,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
}); });
defer account_map.deinit(); defer account_map.deinit();
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "AAPL", .symbol = "AAPL",
.shares = 100, .shares = 100,
@ -1432,7 +1448,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" {
// every time. Pin that they don't enter the lookup so we // every time. Pin that they don't enter the lookup so we
// don't accidentally inherit a stale cash open_price/note. // don't accidentally inherit a stale cash open_price/note.
const allocator = testing.allocator; const allocator = testing.allocator;
const prior_lots = [_]portfolio_mod.Lot{ const prior_lots = [_]Lot{
.{ .{
.symbol = "FZFXX", .symbol = "FZFXX",
.shares = 1000, .shares = 1000,
@ -1521,20 +1537,200 @@ test "parseArgs: --wells-fargo accepts the lone '-' stdin sentinel" {
ctx.io = testing.io; ctx.io = testing.io;
const args = [_][]const u8{ "--wells-fargo", "-" }; const args = [_][]const u8{ "--wells-fargo", "-" };
const parsed = try parseArgs(&ctx, &args); const parsed = try parseArgs(&ctx, &args);
switch (parsed.source) { try testing.expectEqualStrings("-", parsed.source.wells_fargo);
.wells_fargo => |wf| try testing.expectEqualStrings("-", wf.path), }
else => try testing.expect(false),
} test "parseArgs: --account is gone (the WF export names every account)" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--wells-fargo", "wf.xls", "--account", "Sample IRA" };
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parseArgs: --wells-fargo conflicts with another source" {
var ctx: framework.RunCtx = undefined;
ctx.io = testing.io;
const args = [_][]const u8{ "--wells-fargo", "wf.xls", "--fidelity", "f.csv" };
try testing.expectError(error.ConflictingSources, parseArgs(&ctx, &args));
} }
test "Source.label: returns broker name" { test "Source.label: returns broker name" {
try testing.expectEqualStrings("fidelity", (Source{ .fidelity = "" }).label()); try testing.expectEqualStrings("fidelity", (Source{ .fidelity = "" }).label());
try testing.expectEqualStrings("schwab", (Source{ .schwab = "" }).label()); try testing.expectEqualStrings("schwab", (Source{ .schwab = "" }).label());
try testing.expectEqualStrings("wells_fargo", (Source{ .wells_fargo = .{ .path = "", .account_override = null } }).label()); try testing.expectEqualStrings("wells_fargo", (Source{ .wells_fargo = "" }).label());
} }
test "Source.path: returns the CSV path" { test "Source.path: returns the export path" {
try testing.expectEqualStrings("/x.csv", (Source{ .fidelity = "/x.csv" }).path()); try testing.expectEqualStrings("/x.csv", (Source{ .fidelity = "/x.csv" }).path());
try testing.expectEqualStrings("/y.csv", (Source{ .schwab = "/y.csv" }).path()); try testing.expectEqualStrings("/y.csv", (Source{ .schwab = "/y.csv" }).path());
try testing.expectEqualStrings("/z.txt", (Source{ .wells_fargo = .{ .path = "/z.txt", .account_override = null } }).path()); try testing.expectEqualStrings("/z.xls", (Source{ .wells_fargo = "/z.xls" }).path());
}
/// An owned lot, as `wells_fargo.parseLots` returns them.
fn ownedLot(allocator: std.mem.Allocator, lot: Lot) !Lot {
var owned = lot;
owned.symbol = try allocator.dupe(u8, lot.symbol);
owned.account = try allocator.dupe(u8, lot.account.?);
return owned;
}
test "inheritFromPrior: a lot with a real buy date keeps it, but takes the prior note and annotations" {
const allocator = testing.allocator;
const prior_lots = [_]Lot{.{
.symbol = "VTI",
.shares = 10,
.open_date = Date.fromYmd(2020, 1, 1),
.open_price = 150,
.account = "Sample IRA",
.note = "imported wells_fargo 2026-01-01",
.label = "Total Market",
.price_ratio = 2.0,
}};
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3));
defer prior.deinit();
var lot = try ownedLot(allocator, .{ .symbol = "VTI", .shares = 8, .open_date = Date.fromYmd(2026, 9, 9), .open_price = 262.5, .account = "Sample IRA" });
defer freeLot(allocator, lot);
try inheritFromPrior(allocator, &lot, prior, "imported wells_fargo 2026-10-03");
try testing.expect(Date.fromYmd(2026, 9, 9).eql(lot.open_date));
try testing.expectEqual(@as(f64, 262.5), lot.open_price);
try testing.expectEqualStrings("imported wells_fargo 2026-01-01", lot.note.?);
try testing.expectEqualStrings("Total Market", lot.label.?);
try testing.expectEqual(@as(f64, 2.0), lot.price_ratio);
}
test "inheritFromPrior: an undated lot takes the prior date and price" {
const allocator = testing.allocator;
const prior_lots = [_]Lot{.{ .symbol = "SMPLX", .shares = 250, .open_date = Date.fromYmd(2019, 5, 5), .open_price = 12, .account = "Sample IRA" }};
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3));
defer prior.deinit();
var lot = try ownedLot(allocator, .{ .symbol = "SMPLX", .shares = 250, .open_date = Date.epoch, .open_price = 20, .account = "Sample IRA" });
defer freeLot(allocator, lot);
try inheritFromPrior(allocator, &lot, prior, "imported wells_fargo 2026-10-03");
try testing.expect(Date.fromYmd(2019, 5, 5).eql(lot.open_date));
try testing.expectEqual(@as(f64, 12), lot.open_price);
// The prior lot had no note, so this one gets today's stamp.
try testing.expectEqualStrings("imported wells_fargo 2026-10-03", lot.note.?);
}
test "inheritFromPrior: cash never inherits" {
const allocator = testing.allocator;
const prior_lots = [_]Lot{.{ .symbol = "", .shares = 5, .open_date = Date.fromYmd(2019, 5, 5), .open_price = 1, .account = "Sample IRA", .security_type = .cash, .note = "old" }};
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3));
defer prior.deinit();
var lot = try ownedLot(allocator, .{ .symbol = "", .shares = 7, .open_date = Date.epoch, .open_price = 1, .account = "Sample IRA", .security_type = .cash });
defer freeLot(allocator, lot);
try inheritFromPrior(allocator, &lot, prior, "fresh");
try testing.expect(Date.epoch.eql(lot.open_date));
try testing.expectEqualStrings("fresh", lot.note.?);
}
test "synthesizeLots: every allocation failure is clean" {
const S = struct {
fn run(allocator: std.mem.Allocator, prior: PriorLotsLookup) !void {
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
};
const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator };
const positions = [_]BrokeragePosition{
.{ .account_number = "Z123", .account_name = "I", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 1500, .cost_basis = 1500, .is_cash = false },
.{ .account_number = "Z123", .account_name = "I", .symbol = "02315N600", .description = "", .quantity = 5, .current_value = 500, .cost_basis = 400, .is_cash = false },
};
freeLots(allocator, try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior));
}
};
const prior_lots = [_]Lot{.{ .symbol = "02315N600", .shares = 5, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 90, .account = "Sample Brokerage", .note = "n", .ticker = "VTTHX", .label = "TGT2035" }};
var prior = try PriorLotsLookup.init(testing.allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
defer prior.deinit();
try testing.checkAllAllocationFailures(testing.allocator, S.run, .{prior});
}
// ---- Wells Fargo lots ----
fn wfText(s: []const u8) biff8.Cell {
return .{ .text = s };
}
fn wfNum(v: f64) biff8.Cell {
return .{ .number = v };
}
const wf_header = [_]biff8.Cell{ wfText("Description"), wfText("Symbol"), wfText("Account Number"), wfText("Market Value"), wfText("Shares"), wfText("Total Cost"), wfText("Trade Date") };
/// Placeholder export: two VTI lots and an Intra-Day fund in *1234.
const wf_rows = [_][]const biff8.Cell{
&wf_header,
&.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(6000), wfNum(20), wfNum(4500), wfText("Detail") },
&.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(3600), wfNum(12), wfNum(2400), wfText("02/03/2022") },
&.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(2400), wfNum(8), wfNum(2100), wfText("09/09/2026") },
&.{ wfText("SAMPLE GROWTH FUND"), wfText("SMPLX"), wfText("*1234"), wfNum(5000), wfNum(250), wfText("N/A"), wfText("Intra-Day") },
};
const wf_sheet_fixture: biff8.Sheet = .{ .name = wells_fargo.sheet_name, .rows = &wf_rows };
test "wellsFargoLots: real lots, with prior notes, annotations, and undated fallbacks" {
const allocator = testing.allocator;
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" },
};
const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator };
const prior_lots = [_]Lot{
.{ .symbol = "VTI", .shares = 20, .open_date = Date.fromYmd(2022, 2, 3), .open_price = 200, .account = "Sample IRA", .note = "imported wells_fargo 2026-01-01", .label = "Total Market" },
.{ .symbol = "SMPLX", .shares = 250, .open_date = Date.fromYmd(2018, 8, 8), .open_price = 11, .account = "Sample IRA" },
};
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 10, 3));
defer prior.deinit();
const positions = try wells_fargo.parsePositions(allocator, &wf_sheet_fixture);
defer allocator.free(positions);
const lots = try wellsFargoLots(testing.io, allocator, &wf_sheet_fixture, "wf.xls", positions, account_map, Date.fromYmd(2026, 10, 3), prior);
defer freeLots(allocator, lots);
try testing.expectEqual(@as(usize, 3), lots.len);
// Dated lots keep the export's own date and cost per share...
try testing.expect(Date.fromYmd(2026, 9, 9).eql(lots[1].open_date));
try testing.expectEqual(@as(f64, 262.5), lots[1].open_price);
// ...and inherit the position's note and annotations.
try testing.expectEqualStrings("imported wells_fargo 2026-01-01", lots[1].note.?);
try testing.expectEqualStrings("Total Market", lots[1].label.?);
// The Intra-Day fund falls back to the prior lot's date and price.
try testing.expectEqualStrings("SMPLX", lots[2].symbol);
try testing.expect(Date.fromYmd(2018, 8, 8).eql(lots[2].open_date));
try testing.expectEqual(@as(f64, 11), lots[2].open_price);
try testing.expectEqualStrings("imported wells_fargo 2026-10-03", lots[2].note.?);
}
test "wellsFargoLots: an unmapped account is refused before any lot is built" {
const allocator = testing.allocator;
const account_map: analysis.AccountMap = .{ .entries = &.{}, .allocator = allocator };
const positions = try wells_fargo.parsePositions(allocator, &wf_sheet_fixture);
defer allocator.free(positions);
try testing.expectError(error.UnmappedAccount, wellsFargoLots(testing.io, allocator, &wf_sheet_fixture, "wf.xls", positions, account_map, Date.fromYmd(2026, 10, 3), null));
}
test "wellsFargoLots: a lot layout parsePositions accepts but parseLots rejects is InvalidExport" {
// parsePositions does not read trade dates; parseLots does. A date
// it cannot read is a wrong-download problem, reported as such.
const allocator = testing.allocator;
const rows = [_][]const biff8.Cell{
&wf_header,
&.{ wfText("VANGUARD TOTAL STOCK MKT"), wfText("VTI"), wfText("*1234"), wfNum(3600), wfNum(12), wfNum(2400), wfText("Multiple") },
};
const sheet: biff8.Sheet = .{ .name = wells_fargo.sheet_name, .rows = &rows };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "1234" },
};
const account_map: analysis.AccountMap = .{ .entries = &entries, .allocator = allocator };
const positions = try wells_fargo.parsePositions(allocator, &sheet);
defer allocator.free(positions);
try testing.expectError(error.InvalidExport, wellsFargoLots(testing.io, allocator, &sheet, "wf.xls", positions, account_map, Date.fromYmd(2026, 10, 3), null));
}
test "badWellsFargoExport: allocation failure stays an allocation failure" {
try testing.expectError(error.OutOfMemory, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.OutOfMemory)));
try testing.expectError(error.InvalidExport, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.NotCompoundFile)));
} }

View file

@ -103,8 +103,9 @@ pub const analysis = @import("analytics/analysis.zig");
/// audit command renders these; downstream tools consume them. /// audit command renders these; downstream tools consume them.
pub const reconcile = @import("analytics/reconcile.zig"); pub const reconcile = @import("analytics/reconcile.zig");
/// Brokerage export parsers (Schwab/Fidelity/Wells Fargo positions + /// Brokerage export parsers (Schwab/Fidelity positions CSVs + Schwab
/// summary) and the normalized `BrokeragePosition` shape. /// summary, Wells Fargo positions spreadsheet) and the normalized
/// `BrokeragePosition` shape.
pub const brokerage = @import("brokerage.zig"); pub const brokerage = @import("brokerage.zig");
/// Portfolio loading from srf files: the union-merge loader the CLI uses /// Portfolio loading from srf files: the union-merge loader the CLI uses