initial wells fargo parsing implementation

This commit is contained in:
Emil Lerch 2026-05-21 16:14:38 -07:00
parent 21279c1aeb
commit 809146b111
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 1053 additions and 25 deletions

View file

@ -437,6 +437,30 @@ test "parseAccountsFile" {
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
}
test "parseAccountsFile: institution + account_number round-trip via findByInstitutionAccount" {
// The import command's WF resolver, the audit reconciler's
// schwab/fidelity match logic, and the snapshot writer all
// depend on `findByInstitutionAccount` finding entries that
// were parsed from `accounts.srf`. Pin the round-trip so a
// future change to either parseAccountsFile or
// findByInstitutionAccount can't silently drop the link.
const data =
\\#!srfv1
\\account::Mom Fidelity Brokerage,tax_type::taxable,institution::fidelity,account_number::Z123
\\account::Schwab Trust,tax_type::taxable,institution::schwab,account_number::716
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
try std.testing.expectEqualStrings("Mom Fidelity Brokerage", am.findByInstitutionAccount("fidelity", "Z123").?);
try std.testing.expectEqualStrings("Schwab Trust", am.findByInstitutionAccount("schwab", "716").?);
// Wrong institution / wrong number null.
try std.testing.expect(am.findByInstitutionAccount("schwab", "Z123") == null);
try std.testing.expect(am.findByInstitutionAccount("fidelity", "ZZZ") == null);
}
test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
const data =
\\#!srfv1

View file

@ -0,0 +1,901 @@
//! Wells Fargo paste parser.
//!
//! Wells Fargo's brokerage portal doesn't offer a clean CSV export
//! for positions, so the user copies the rendered HTML table and
//! pastes the result into a file. The paste is a tab-separated
//! multi-line layout, one record per holding plus a (sometimes
//! present) totals block at the end.
//!
//! ## Format
//!
//! Header preamble (optional present when the user's paste
//! includes the column headers, absent when they paste only the
//! rows). When present, it spans the first ~12 lines and starts
//! with `Symbol/Description`. The parser scans for the first
//! `<SYMBOL> , popup` line as the data anchor and ignores
//! everything before it.
//!
//! Each position record looks like:
//!
//! ```
//! GSLC , popup record anchor: <SYMBOL> , popup
//! GOLDMAN ACTIVEBETA ETF description
//! Multiple(3) or MM/DD/YYYY lot count or single-buy date
//! 906 shares (may have commas)
//! @ $129.97 average cost basis per share
//! <blank-tab>
//! $140.90 last price
//! +$0.31 day change ($)
//! <blank-tab>
//! $127,655.40 market value
//! +$280.86 (+0.22%) day change ($, %)
//! <blank-tab>
//! +$9,906.42 unrealized gain/loss ($)
//! +8.41% unrealized gain/loss (%)
//! <blank-tab>
//! $1,203.17 est. annual income
//! <blank-tab> record separator
//! ```
//!
//! Footer (optional sometimes a totals block appears, sometimes
//! the paste ends after the last record's est-annual-income).
//! The parser stops on a line that begins with a known total
//! sentinel ("ETFs Total", "Total", etc.) OR on EOF.
//!
//! ## Limitations
//!
//! 1. Format is layout-fragile if WF changes the table structure,
//! this parser breaks. We re-anchor on `<SYMBOL> , popup` per
//! record, which gives some robustness against extra blank
//! lines or stray whitespace, but column reordering would
//! require updating the field offsets below.
//!
//! 2. WF pastes don't carry an account identifier; the import
//! command resolves the account separately (filename inference
//! + `--account` override + accounts.srf lookup).
//!
//! 3. Cost basis is computed from `shares × avg_cost`. WF doesn't
//! print "total cost basis" alongside; the multiplication is
//! a re-derivation from the per-share avg.
//!
//! 4. No cash classification. Wells Fargo positions of cash /
//! money-market funds may need the standard `isMoneyMarketSymbol`
//! fallback; the format itself doesn't tag cash distinctly.
const std = @import("std");
const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig");
const analysis = @import("../analytics/analysis.zig");
const BrokeragePosition = types.BrokeragePosition;
const parseDollarAmount = types.parseDollarAmount;
/// Institution name used for `accounts.srf` lookups
/// (`institution::wells_fargo`). Held as a constant so the parser
/// and the resolver don't drift on the spelling.
pub const institution = "wells_fargo";
/// Parse a Wells Fargo paste into BrokeragePosition slices.
///
/// All string fields in the returned positions are slices into
/// `data` (caller must keep `data` alive). The returned slice
/// itself is heap-allocated against `allocator`.
///
/// `account_number` and `account_name` are left as empty strings
/// WF pastes don't carry account identity. The import command
/// fills these in from filename inference / accounts.srf lookup.
pub fn parsePaste(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator);
var lines = std.mem.splitScalar(u8, data, '\n');
// Buffer up to N lookahead lines so the parser can slide a
// window over the per-record layout without juggling iterator
// state. Per-record this is at most ~16 lines.
var staged: std.ArrayList([]const u8) = .empty;
defer staged.deinit(allocator);
while (lines.next()) |raw| {
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '\t', '\r' });
try staged.append(allocator, trimmed);
}
// Locate each record by scanning for `<SYMBOL> , popup`. WF
// emits this exact suffix as part of the symbol column's
// hover-popup affordance; it's a very stable record anchor.
var i: usize = 0;
while (i < staged.items.len) : (i += 1) {
const line = staged.items[i];
// Stop on totals footer (`ETFs Total`, `Total`, etc).
if (isTotalLine(line)) break;
if (!isPopupAnchor(line)) continue;
const symbol = popupSymbol(line) orelse continue;
// Walk forward collecting the rest of the fields. Each
// field-find returns the index it consumed up to, or
// null if the record is truncated. We tolerate stray
// blank lines between fields because WF pastes
// sometimes carry extra whitespace.
var cur = i + 1;
// Description: first non-empty line after the anchor.
const description = nextNonEmpty(staged.items, &cur) orelse break;
// Trade-date column: either `Multiple(N)` or `MM/DD/YYYY`.
// We don't use the value but consuming it advances `cur`
// to the shares line. Single-date format pastes have an
// extra blank line between the description and the date,
// so step past blanks.
_ = nextNonEmpty(staged.items, &cur) orelse break;
// Shares: integer with optional thousands commas, no $.
const shares_text = nextNonEmpty(staged.items, &cur) orelse break;
const shares = parseSharesAmount(shares_text) orelse continue;
// Avg cost: line starts with `@ $`.
const cost_text = nextNonEmpty(staged.items, &cur) orelse break;
if (!std.mem.startsWith(u8, cost_text, "@ ")) continue;
const avg_cost = parseDollarAmount(cost_text[2..]) orelse continue;
// Last price (skip), day-change-$ (skip), day-change-%
// (skip): three lines of price-detail we don't currently
// need. We could surface them later if a caller wants
// them, but for synthesis the avg cost + market value is
// enough.
_ = nextNonEmpty(staged.items, &cur) orelse break; // last price
_ = nextNonEmpty(staged.items, &cur) orelse break; // day change $
// Market value: dollar amount, no parens.
const mv_text = nextNonEmpty(staged.items, &cur) orelse break;
const market_value = parseDollarAmount(mv_text) orelse continue;
// The remaining lines (day-change-$/%, unreal G/L $/%,
// est annual income) are the rest of this record but
// we don't need them for synthesis. The next record's
// popup anchor is what we'll find on the outer loop's
// next iteration.
i = cur; // resume scan past the consumed market-value line
try positions.append(allocator, .{
.account_number = "",
.account_name = "",
.symbol = symbol,
.description = description,
.quantity = shares,
.current_value = market_value,
.cost_basis = shares * avg_cost,
.is_cash = portfolio_mod.isMoneyMarketSymbol(symbol),
});
}
return positions.toOwnedSlice(allocator);
}
/// True when `line` is a record-start anchor like `GSLC , popup`.
/// The trailing `, popup` is the stable signal WF's hover
/// affordance. Whitespace between the symbol and the comma is
/// tolerated (WF emits exactly one space, but trim defends
/// against future-proofing).
fn isPopupAnchor(line: []const u8) bool {
return std.mem.endsWith(u8, line, ", popup");
}
/// Extract the symbol from a popup anchor line. Returns null if
/// the line is the right shape but the symbol part is empty.
fn popupSymbol(line: []const u8) ?[]const u8 {
if (!isPopupAnchor(line)) return null;
const before_comma = line[0 .. line.len - ", popup".len];
const symbol = std.mem.trim(u8, before_comma, &.{ ' ', '\t' });
if (symbol.len == 0) return null;
return symbol;
}
/// True when `line` looks like a footer-totals sentinel. WF's
/// paste sometimes ends with one or two `ETFs Total` blocks; we
/// also generously accept any line ending with " Total" so
/// "Stocks Total", "Bonds Total", etc. don't slip through if a
/// future paste includes them.
fn isTotalLine(line: []const u8) bool {
if (std.mem.eql(u8, line, "Total")) return true;
return std.mem.endsWith(u8, line, " Total");
}
/// Advance `cur_idx` past blank lines in `lines`, then return
/// (and consume) the first non-blank line. Returns null if no
/// non-blank line remains.
fn nextNonEmpty(lines: []const []const u8, cur_idx: *usize) ?[]const u8 {
while (cur_idx.* < lines.len) {
const line = lines[cur_idx.*];
cur_idx.* += 1;
if (line.len == 0) continue;
if (isTotalLine(line)) return null;
return line;
}
return null;
}
/// Parse a shares value like "906" or "1,020" integers with
/// optional thousands commas, no $ prefix. Returns null on any
/// other shape (which lets the parent loop skip the record
/// without aborting the whole paste).
fn parseSharesAmount(raw: []const u8) ?f64 {
// Reuse parseDollarAmount: it strips $/+/-/comma and parses
// the rest as a float. WF shares lines have no $ but the
// function is happy without one.
return parseDollarAmount(raw);
}
// Account resolution
//
// Wells Fargo pastes carry no in-band account identifier (no
// header, no per-row column, no embedded account number see
// the module doc-block). So after parsing we have to resolve the
// account name from outside the paste: `accounts.srf` plus any
// hints from the file path or an explicit `--account` override.
//
// This block is the WF-specific piece of import. Fidelity and
// Schwab don't need it because their exports stamp the account
// number per-row (Fidelity) or in the title line (Schwab).
/// Resolution result: borrowed slices into the matching
/// `accounts.srf` entry. Both fields live as long as the
/// `AccountMap` does.
pub const Resolved = struct {
account_number: []const u8,
account_name: []const u8,
};
/// Determine which `accounts.srf` entry a Wells Fargo paste
/// belongs to. Resolution order:
///
/// 1. **Explicit `--account NAME`.** Match against
/// `account::` exactly. If the override doesn't match any
/// `institution::wells_fargo` entry, error out.
/// 2. **Filename inference.** Take the basename of the source
/// path (without extension) and try to match it against the
/// `account::` field of each WF entry, allowing for
/// case-insensitive substring overlap on the trailing
/// `*NNNN` digits. Filename "Elizabeth_IRA_3522.txt" matches
/// "Elizabeth IRA *3522".
/// 3. **Single-WF-entry fallback.** If accounts.srf has exactly
/// one `institution::wells_fargo` entry, use it. Helpful for
/// users with one WF account; harmless when there are many
/// (the lookup just falls through to the error).
///
/// On no-match, prints a stderr listing of the available WF
/// entries so the user can pick one with `--account`.
///
/// Errors:
/// - `error.UnknownAccount`: `--account NAME` didn't match a WF
/// entry, or a matching entry has no `account_number::` field
/// (which the downstream lookup keys on).
/// - `error.AmbiguousWellsFargoAccount`: zero or 2+ WF entries in
/// accounts.srf and no other signal to disambiguate.
pub fn resolveAccount(
io: std.Io,
account_map: analysis.AccountMap,
source_path: []const u8,
explicit: ?[]const u8,
) !Resolved {
// 1. Explicit override.
if (explicit) |name| {
for (account_map.entries) |e| {
const inst = e.institution orelse continue;
if (!std.mem.eql(u8, inst, institution)) continue;
if (std.mem.eql(u8, e.account, name)) {
return resolutionFor(io, e);
}
}
var stderr_buf: [4096]u8 = undefined;
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
try sw.interface.print(
"Error: --account '{s}' did not match any `institution::wells_fargo` entry in accounts.srf.\n",
.{name},
);
try printEntries(&sw.interface, account_map);
try sw.interface.flush();
return error.UnknownAccount;
}
// 2. Filename inference. Take everything after the last
// `/` and before the extension. Stdin's `-` returns no
// useful base; the inference simply fails and we fall
// through to step 3.
const inferred: ?analysis.AccountTaxEntry = blk: {
if (std.mem.eql(u8, source_path, "-")) break :blk null;
const base_with_ext = std.fs.path.basename(source_path);
const dot_idx = std.mem.lastIndexOfScalar(u8, base_with_ext, '.');
const base = if (dot_idx) |i| base_with_ext[0..i] else base_with_ext;
var match: ?analysis.AccountTaxEntry = null;
for (account_map.entries) |e| {
const inst = e.institution orelse continue;
if (!std.mem.eql(u8, inst, institution)) continue;
if (filenameMatchesAccount(base, e.account)) {
if (match != null) {
// More than one WF entry matched the
// filename punt to the user.
break :blk null;
}
match = e;
}
}
break :blk match;
};
if (inferred) |e| return resolutionFor(io, e);
// 3. Single-WF-entry fallback.
var single: ?analysis.AccountTaxEntry = null;
var wf_count: usize = 0;
for (account_map.entries) |e| {
const inst = e.institution orelse continue;
if (!std.mem.eql(u8, inst, institution)) continue;
wf_count += 1;
single = e;
}
if (wf_count == 1) return resolutionFor(io, single.?);
// Couldn't pick. Print enumerated guidance.
var stderr_buf: [4096]u8 = undefined;
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
if (wf_count == 0) {
try sw.interface.print(
"Error: no `institution::wells_fargo` entries found in accounts.srf.\n" ++
" Add one (e.g. `account::Elizabeth IRA *3522,tax_type::roth,institution::wells_fargo,account_number::3522`)\n" ++
" and rerun the import.\n",
.{},
);
} else {
try sw.interface.print(
"Error: {d} Wells Fargo accounts in accounts.srf; cannot pick one automatically.\n" ++
" Pass --account NAME to disambiguate. Candidates:\n",
.{wf_count},
);
try printEntries(&sw.interface, account_map);
}
try sw.interface.flush();
return error.AmbiguousWellsFargoAccount;
}
/// Convenience: resolve the account once and then patch every
/// position's `account_number` / `account_name` fields with the
/// resolved values. Used by `import` after `parsePaste`. The
/// patched slices borrow from `account_map`, which the caller
/// must keep alive for the lifetime of the positions.
pub fn applyAccountToPositions(
io: std.Io,
account_map: analysis.AccountMap,
source_path: []const u8,
explicit: ?[]const u8,
positions: []BrokeragePosition,
) !void {
const resolved = try resolveAccount(io, account_map, source_path, explicit);
var idx: usize = 0;
while (idx < positions.len) : (idx += 1) {
positions[idx].account_number = resolved.account_number;
positions[idx].account_name = resolved.account_name;
}
}
/// Build a `Resolved` from an `accounts.srf` entry. Errors when
/// the entry has no `account_number::` field, because the
/// downstream `findByInstitutionAccount` lookup keys on it.
fn resolutionFor(io: std.Io, entry: analysis.AccountTaxEntry) !Resolved {
const num = entry.account_number orelse {
var stderr_buf: [512]u8 = undefined;
var sw = std.Io.File.stderr().writer(io, &stderr_buf);
try sw.interface.print(
"Error: WF account '{s}' has no `account_number::` field in accounts.srf.\n" ++
" Add one (the trailing digits after `*` work well, e.g. `account_number::3522`).\n",
.{entry.account},
);
try sw.interface.flush();
return error.UnknownAccount;
};
return .{ .account_number = num, .account_name = entry.account };
}
/// True when the source file's basename (without extension)
/// looks like it refers to `account_name`. Implemented as a
/// case-insensitive substring overlap on the trailing-digits
/// tail of the account name (after `*` or end-of-string), with
/// underscores and spaces treated as equivalent.
///
/// Examples:
/// filenameMatchesAccount("Elizabeth_IRA_3522", "Elizabeth IRA *3522") true
/// filenameMatchesAccount("eliz-ira-3522", "Elizabeth IRA *3522") true (digits match)
/// filenameMatchesAccount("portfolio_mom", "Elizabeth IRA *3522") false
fn filenameMatchesAccount(filename: []const u8, account_name: []const u8) bool {
// Extract the trailing digit run from the account name.
// "Elizabeth IRA *3522" "3522".
var digits_start: usize = account_name.len;
while (digits_start > 0) {
const c = account_name[digits_start - 1];
if (c < '0' or c > '9') break;
digits_start -= 1;
}
const digits = account_name[digits_start..];
// If the account name ends in digits, the filename must
// contain that exact digit run somewhere. This is the
// strongest signal WF account suffixes are unique within
// a household.
if (digits.len > 0 and std.mem.indexOf(u8, filename, digits) != null) return true;
// No digit suffix to compare; fall back to a fuzzy
// letters-only overlap. Lowercase both sides; compare
// alphanumeric runs only. If every alphanumeric run of the
// account name appears in the filename in order, it's a
// match.
return alphaRunsContained(filename, account_name);
}
/// True when every maximal alphanumeric run in `account_name`
/// appears (case-insensitive, in order) somewhere inside
/// `filename`. Used as a fallback in `filenameMatchesAccount`
/// when the account has no digit suffix to anchor on.
fn alphaRunsContained(filename: []const u8, account_name: []const u8) bool {
var f_lower_buf: [256]u8 = undefined;
if (filename.len > f_lower_buf.len) return false;
for (filename, 0..) |c, i| f_lower_buf[i] = std.ascii.toLower(c);
const f_lower = f_lower_buf[0..filename.len];
var i: usize = 0;
var search_from: usize = 0;
while (i < account_name.len) {
// Skip non-alphanum.
while (i < account_name.len and !std.ascii.isAlphanumeric(account_name[i])) : (i += 1) {}
const start = i;
while (i < account_name.len and std.ascii.isAlphanumeric(account_name[i])) : (i += 1) {}
if (start == i) break;
const acct_run = account_name[start..i];
if (acct_run.len == 0) continue;
// Lowercase the run and find it in f_lower starting at
// search_from.
var run_lower_buf: [128]u8 = undefined;
if (acct_run.len > run_lower_buf.len) return false;
for (acct_run, 0..) |c, k| run_lower_buf[k] = std.ascii.toLower(c);
const run_lower = run_lower_buf[0..acct_run.len];
const found = std.mem.indexOfPos(u8, f_lower, search_from, run_lower) orelse return false;
search_from = found + acct_run.len;
}
return true;
}
/// Helper: print every `institution::wells_fargo` entry from
/// the account map onto the given writer, one per line, indented.
fn printEntries(w: *std.Io.Writer, account_map: analysis.AccountMap) !void {
for (account_map.entries) |e| {
const inst = e.institution orelse continue;
if (!std.mem.eql(u8, inst, institution)) continue;
try w.print(" - {s}\n", .{e.account});
}
}
// Tests
const testing = std.testing;
test "isPopupAnchor: recognizes WF record anchors" {
try testing.expect(isPopupAnchor("GSLC , popup"));
try testing.expect(isPopupAnchor("VTV , popup"));
try testing.expect(!isPopupAnchor("GOLDMAN ACTIVEBETA ETF"));
try testing.expect(!isPopupAnchor("ETFs Total"));
try testing.expect(!isPopupAnchor(""));
}
test "popupSymbol: extracts symbol token before ', popup'" {
try testing.expectEqualStrings("GSLC", popupSymbol("GSLC , popup").?);
try testing.expectEqualStrings("VO", popupSymbol("VO , popup").?);
// Empty symbol part null.
try testing.expect(popupSymbol(", popup") == null);
// Wrong shape null.
try testing.expect(popupSymbol("GSLC popup") == null);
}
test "isTotalLine: matches WF footer sentinels" {
try testing.expect(isTotalLine("ETFs Total"));
try testing.expect(isTotalLine("Stocks Total"));
try testing.expect(isTotalLine("Total"));
try testing.expect(!isTotalLine("Subtotal"));
try testing.expect(!isTotalLine("GSLC , popup"));
}
test "parseSharesAmount: accepts integers with thousands commas" {
try testing.expectApproxEqAbs(@as(f64, 906), parseSharesAmount("906").?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 1020), parseSharesAmount("1,020").?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 2597), parseSharesAmount("2,597").?, 0.01);
}
test "parsePaste: header preamble plus three records" {
const allocator = testing.allocator;
// Mirrors the wf.txt structure header preamble, then a
// few records, then the totals footer. Tabs and blank
// lines are intentional; the trim+nextNonEmpty pipeline
// should handle them.
const data =
"Symbol/Description,click to sort \tTrade Date,click to sort \tShares\n" ++
"@ Cost\n" ++
",click to sort \tLast Price/\n" ++
"Change\n" ++
",click to sort \tMarket Value/\n" ++
"Today's Change\n" ++
"\tUnreal.\n" ++
"Gain/Loss\n" ++
",click to sort \tEstimated\n" ++
"Annual Income\n" ++
",click to sort\n" ++
"\t\n" ++
"GSLC , popup\n" ++
"GOLDMAN ACTIVEBETA ETF\n" ++
"\tMultiple(3) \t\n" ++
"906\n" ++
"@ $129.97\n" ++
"\t\n" ++
"$140.90\n" ++
"+$0.31\n" ++
"\t\n" ++
"$127,655.40\n" ++
"+$280.86 (+0.22%)\n" ++
"\t\n" ++
"+$9,906.42\n" ++
"+8.41%\n" ++
"\t\n" ++
"$1,203.17\n" ++
"\t\n" ++
"VO , popup\n" ++
"VANGUARD MID CAP ETF\n" ++
"\tMultiple(2) \t\n" ++
"1,020\n" ++
"@ $74.30\n" ++
"\t\n" ++
"$77.41\n" ++
"+$0.35\n" ++
"\t\n" ++
"$78,958.20\n" ++
"+$357.00 (+0.45%)\n" ++
"\t\n" ++
"+$3,174.66\n" ++
"+4.19%\n" ++
"\t\n" ++
"$1,104.66\n" ++
"\t\n" ++
"EEM , popup\n" ++
"ISHARES MSCI EMRG MK ETF\n" ++
"\t\n" ++
"02/24/2026\n" ++
"\t\n" ++
"875\n" ++
"@ $62.71\n" ++
"\t\n" ++
"$66.03\n" ++
"+$0.57\n" ++
"\t\n" ++
"$57,776.25\n" ++
"+$498.75 (+0.87%)\n" ++
"\t\n" ++
"+$2,906.67\n" ++
"+5.30%\n" ++
"\t\n" ++
"$1,063.12\n" ++
"\t\n" ++
"ETFs Total\n" ++
"\t\t\t\t\n" ++
"$264,389.85\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 3), positions.len);
// GSLC: 906 shares × $129.97 avg = $117,752.82 cost basis;
// market value $127,655.40.
try testing.expectEqualStrings("GSLC", positions[0].symbol);
try testing.expectEqualStrings("GOLDMAN ACTIVEBETA ETF", positions[0].description);
try testing.expectApproxEqAbs(@as(f64, 906), positions[0].quantity.?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 117_752.82), positions[0].cost_basis.?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 127_655.40), positions[0].current_value.?, 0.01);
try testing.expect(!positions[0].is_cash);
// VO: 1,020 × $74.30 = $75,786 cost; market $78,958.20.
try testing.expectEqualStrings("VO", positions[1].symbol);
try testing.expectApproxEqAbs(@as(f64, 1020), positions[1].quantity.?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 75_786.00), positions[1].cost_basis.?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 78_958.20), positions[1].current_value.?, 0.01);
// EEM: single-date format (`02/24/2026` instead of `Multiple(N)`),
// so the parser handles both shapes by treating the trade-date
// column as a generic skip.
try testing.expectEqualStrings("EEM", positions[2].symbol);
try testing.expectApproxEqAbs(@as(f64, 875), positions[2].quantity.?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 875.0 * 62.71), positions[2].cost_basis.?, 0.01);
try testing.expectApproxEqAbs(@as(f64, 57_776.25), positions[2].current_value.?, 0.01);
}
test "parsePaste: no header preamble, no footer totals" {
// Mirrors wf2.txt same record format, no preamble at
// top, no totals at bottom. Parser must reach EOF cleanly.
const allocator = testing.allocator;
const data =
"\n" ++
"GSLC , popup\n" ++
"GOLDMAN ACTIVEBETA ETF\n" ++
"\tMultiple(3) \t\n" ++
"906\n" ++
"@ $129.97\n" ++
"\t\n" ++
"$140.90\n" ++
"+$0.31\n" ++
"\t\n" ++
"$127,655.40\n" ++
"+$280.86 (+0.22%)\n" ++
"\t\n" ++
"+$9,906.42\n" ++
"+8.41%\n" ++
"\t\n" ++
"$1,203.17\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 1), positions.len);
try testing.expectEqualStrings("GSLC", positions[0].symbol);
try testing.expectApproxEqAbs(@as(f64, 906), positions[0].quantity.?, 0.01);
}
test "parsePaste: empty input yields zero positions" {
const allocator = testing.allocator;
const positions = try parsePaste(allocator, "");
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 0), positions.len);
}
test "parsePaste: input with only header preamble (no records) yields zero" {
const allocator = testing.allocator;
const data =
"Symbol/Description,click to sort \tTrade Date,click to sort \tShares\n" ++
"@ Cost\n" ++
",click to sort \tLast Price/\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 0), positions.len);
}
test "parsePaste: stops at ETFs Total footer (doesn't parse past it)" {
const allocator = testing.allocator;
// Two records, then a footer, then more positions that
// SHOULD NOT be parsed (they belong to a phantom second
// table that the user shouldn't have included). The
// totals-line stop ensures we don't accidentally double-
// count.
const data =
"GSLC , popup\n" ++
"GOLDMAN ACTIVEBETA ETF\n" ++
"\tMultiple(3) \t\n" ++
"906\n" ++
"@ $129.97\n" ++
"\t\n" ++
"$140.90\n" ++
"+$0.31\n" ++
"\t\n" ++
"$127,655.40\n" ++
"+$280.86 (+0.22%)\n" ++
"\t\n" ++
"+$9,906.42\n" ++
"+8.41%\n" ++
"\t\n" ++
"$1,203.17\n" ++
"\t\n" ++
"ETFs Total\n" ++
"$127,655.40\n" ++
"GHOST , popup\n" ++ // should NOT be parsed
"SHOULD NOT APPEAR\n" ++
"\tMultiple(1) \t\n" ++
"1\n" ++
"@ $1.00\n" ++
"\t\n" ++
"$1.00\n" ++
"+$0.00\n" ++
"\t\n" ++
"$1.00\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 1), positions.len);
try testing.expectEqualStrings("GSLC", positions[0].symbol);
}
test "parsePaste: money-market symbol gets is_cash=true" {
const allocator = testing.allocator;
// WMPXX is the Allspring (née Wells Fargo) money-market
// fund; it's in the canonical money-market list, so even
// without a `**` suffix or unit-price hint, the parser
// tags it as cash. Using a WF-house ticker here keeps the
// fixture credible SWVXX would never show up on a Wells
// Fargo holdings page.
const data =
"WMPXX , popup\n" ++
"ALLSPRING MONEY MARKET FUND\n" ++
"\tMultiple(1) \t\n" ++
"5000\n" ++
"@ $1.00\n" ++
"\t\n" ++
"$1.00\n" ++
"$0.00\n" ++
"\t\n" ++
"$5,000.00\n" ++
"+$0.00 (0.00%)\n" ++
"\t\n" ++
"+$0.00\n" ++
"0.00%\n" ++
"\t\n" ++
"$200.00\n";
const positions = try parsePaste(allocator, data);
defer allocator.free(positions);
try testing.expectEqual(@as(usize, 1), positions.len);
try testing.expect(positions[0].is_cash);
}
// Resolver tests
/// Test helper: build an `AccountMap` from compile-time entries.
/// Mirrors the helper in `commands/import.zig`'s test block;
/// duplicated here so resolver tests don't depend on import's
/// test-only infrastructure.
fn testAccountMap(allocator: std.mem.Allocator, entries: []const analysis.AccountTaxEntry) !analysis.AccountMap {
var owned = try allocator.alloc(analysis.AccountTaxEntry, entries.len);
for (entries, 0..) |e, i| {
owned[i] = .{
.account = try allocator.dupe(u8, e.account),
.tax_type = e.tax_type,
.institution = if (e.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (e.account_number) |s| try allocator.dupe(u8, s) else null,
};
}
return .{ .entries = owned, .allocator = allocator };
}
test "filenameMatchesAccount: trailing-digit anchor wins" {
// Strongest signal WF account suffixes are unique within
// a household, so a digit-run match is unambiguous.
try testing.expect(filenameMatchesAccount("Elizabeth_IRA_3522", "Elizabeth IRA *3522"));
try testing.expect(filenameMatchesAccount("3522.txt", "Elizabeth IRA *3522"));
try testing.expect(filenameMatchesAccount("eliz-ira-3522", "Elizabeth IRA *3522"));
// Different digit suffix no match.
try testing.expect(!filenameMatchesAccount("Elizabeth_IRA_7891", "Elizabeth IRA *3522"));
try testing.expect(!filenameMatchesAccount("portfolio_mom", "Elizabeth IRA *3522"));
}
test "filenameMatchesAccount: alpha-only fallback when account has no digit suffix" {
// No trailing digits to anchor on falls through to the
// alpha-runs-contained check.
try testing.expect(filenameMatchesAccount("emils_brokerage", "Emils Brokerage"));
// Out-of-order tokens don't match: alphaRunsContained
// requires every account-name run to appear in order in
// the filename.
try testing.expect(!filenameMatchesAccount("Brokerage_Emils", "Emils Brokerage"));
// Partial overlap also doesn't match every run must be
// present.
try testing.expect(!filenameMatchesAccount("emils_only", "Emils Brokerage"));
}
test "filenameMatchesAccount: case-insensitive fallback" {
try testing.expect(filenameMatchesAccount("EMILS_brokerage", "Emils Brokerage"));
try testing.expect(filenameMatchesAccount("emils_BROKERAGE", "Emils Brokerage"));
}
test "alphaRunsContained: every alphanumeric run from account appears in order" {
try testing.expect(alphaRunsContained("emils_brokerage", "Emils Brokerage"));
try testing.expect(alphaRunsContained("--emils-brokerage--", "Emils Brokerage"));
try testing.expect(!alphaRunsContained("brokerage_emils", "Emils Brokerage")); // order matters
try testing.expect(!alphaRunsContained("emils_only", "Emils Brokerage")); // missing run
// Empty account name has no runs trivially true.
try testing.expect(alphaRunsContained("anything", ""));
}
test "resolveAccount: explicit override matches a WF entry" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" },
.{ .account = "Elizabeth Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" },
});
defer account_map.deinit();
const r = try resolveAccount(testing.io, account_map, "anything.txt", "Elizabeth IRA *3522");
try testing.expectEqualStrings("3522", r.account_number);
try testing.expectEqualStrings("Elizabeth IRA *3522", r.account_name);
}
test "resolveAccount: explicit override that doesn't match → UnknownAccount" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" },
});
defer account_map.deinit();
try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "anything.txt", "Wrong Account"));
}
test "resolveAccount: filename inference picks the right entry from multiple WF accounts" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" },
.{ .account = "Elizabeth Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" },
});
defer account_map.deinit();
const r = try resolveAccount(testing.io, account_map, "/path/to/Elizabeth_IRA_3522.txt", null);
try testing.expectEqualStrings("3522", r.account_number);
}
test "resolveAccount: single-WF-entry fallback when filename has no signal" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" },
// Non-WF entry shouldn't interfere.
.{ .account = "Mom Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
const r = try resolveAccount(testing.io, account_map, "unrelated_filename.txt", null);
try testing.expectEqualStrings("3522", r.account_number);
}
test "resolveAccount: ambiguous when 2+ WF entries and no signal" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" },
.{ .account = "Elizabeth Brokerage *7891", .tax_type = .taxable, .institution = "wells_fargo", .account_number = "7891" },
});
defer account_map.deinit();
try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "unrelated_filename.txt", null));
}
test "resolveAccount: zero WF entries → AmbiguousWellsFargoAccount with helpful message" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Mom Fid", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
});
defer account_map.deinit();
try testing.expectError(error.AmbiguousWellsFargoAccount, resolveAccount(testing.io, account_map, "anything.txt", null));
}
test "resolveAccount: WF entry without account_number → UnknownAccount" {
// Pins the requirement that WF entries in accounts.srf MUST
// carry an `account_number::` field the downstream
// `findByInstitutionAccount` lookup keys on it. Without
// this guard the import would silently produce
// "unmapped account" errors at synthesizeLots time with
// no useful hint about why.
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA", .tax_type = .roth, .institution = "wells_fargo", .account_number = null },
});
defer account_map.deinit();
try testing.expectError(error.UnknownAccount, resolveAccount(testing.io, account_map, "Elizabeth_IRA.txt", null));
}
test "applyAccountToPositions: patches every position's account fields" {
const allocator = testing.allocator;
var account_map = try testAccountMap(allocator, &.{
.{ .account = "Elizabeth IRA *3522", .tax_type = .roth, .institution = "wells_fargo", .account_number = "3522" },
});
defer account_map.deinit();
var positions = [_]BrokeragePosition{
.{ .account_number = "", .account_name = "", .symbol = "VTI", .description = "", .quantity = 10, .current_value = 1000, .cost_basis = 800, .is_cash = false },
.{ .account_number = "", .account_name = "", .symbol = "AAPL", .description = "", .quantity = 5, .current_value = 1000, .cost_basis = 750, .is_cash = false },
};
try applyAccountToPositions(testing.io, account_map, "Elizabeth_IRA_3522.txt", null, &positions);
try testing.expectEqualStrings("3522", positions[0].account_number);
try testing.expectEqualStrings("Elizabeth IRA *3522", positions[0].account_name);
try testing.expectEqualStrings("3522", positions[1].account_number);
try testing.expectEqualStrings("Elizabeth IRA *3522", positions[1].account_name);
}

View file

@ -91,22 +91,35 @@ const cache = @import("../cache/store.zig");
const atomic = @import("../atomic.zig");
const fidelity = @import("../brokerage/fidelity.zig");
const schwab = @import("../brokerage/schwab.zig");
const wells_fargo = @import("../brokerage/wells_fargo.zig");
const brokerage_types = @import("../brokerage/types.zig");
const analysis = @import("../analytics/analysis.zig");
const BrokeragePosition = brokerage_types.BrokeragePosition;
/// Source brokerage for the import. Each variant carries the path
/// to the export file. We accept exactly one source per run; the
/// parser rejects mixed flags.
/// Source brokerage for the import. Fidelity and Schwab carry an
/// account number in the export rows themselves; Wells Fargo's
/// 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) {
fidelity: []const u8,
schwab: []const u8,
wells_fargo: WellsFargoArgs,
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 {
return switch (self) {
.fidelity => "fidelity",
.schwab => "schwab",
.wells_fargo => "wells_fargo",
};
}
@ -114,6 +127,7 @@ pub const Source = union(enum) {
return switch (self) {
.fidelity => |p| p,
.schwab => |p| p,
.wells_fargo => |a| a.path,
};
}
};
@ -129,7 +143,7 @@ pub const meta: framework.Meta = .{
.group = .hygiene,
.synopsis = "Synthesize a portfolio file from a brokerage holdings export",
.help =
\\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE) [-y]
\\Usage: zfin -p <PORTFOLIO> import (--fidelity FILE | --schwab FILE | --wells-fargo FILE [--account NAME]) [-y]
\\
\\Synthesize a portfolio file from a brokerage positions export.
\\Each run REPLACES the target portfolio file with synthetic lots
@ -145,15 +159,26 @@ pub const meta: framework.Meta = .{
\\inert rather than misleading.
\\
\\Required:
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
\\ concrete path, not a glob). REQUIRED.
\\ --fidelity <CSV> Fidelity positions CSV
\\ ("All accounts" → Positions tab → Download)
\\ --schwab <CSV> Schwab per-account positions CSV
\\ -p, --portfolio <FILE> Target portfolio file (must be a single
\\ concrete path, not a glob). REQUIRED.
\\ --fidelity <CSV> Fidelity positions CSV
\\ ("All accounts" → Positions tab → Download)
\\ --schwab <CSV> Schwab per-account positions CSV
\\ --wells-fargo <FILE> Wells Fargo paste (copy the rendered
\\ positions table from the WF portal
\\ and save to a file). Pass `-` to
\\ read from stdin.
\\
\\Options:
\\ -y, --yes Don't prompt before overwriting an
\\ existing file.
\\ --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
\\ existing file.
\\
\\Account-name resolution requires `accounts.srf` next to the
\\target file with `institution::` + `account_number::` entries
@ -173,6 +198,8 @@ pub const meta: framework.Meta = .{
CannotReadCsv,
CannotReadAccountsFile,
UnmappedAccount,
AmbiguousWellsFargoAccount,
UnknownAccount,
UserDeclined,
WriteFailed,
},
@ -181,6 +208,8 @@ pub const meta: framework.Meta = .{
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var fidelity_path: ?[]const u8 = null;
var schwab_path: ?[]const u8 = null;
var wells_fargo_path: ?[]const u8 = null;
var account_override: ?[]const u8 = null;
var yes = false;
var i: usize = 0;
@ -200,6 +229,20 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
}
i += 1;
schwab_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--wells-fargo")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n");
return error.UnexpectedArg;
}
i += 1;
wells_fargo_path = cmd_args[i];
} else if (std.mem.eql(u8, a, "--account")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(ctx.io, "Error: --account requires a name\n");
return error.UnexpectedArg;
}
i += 1;
account_override = cmd_args[i];
} else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) {
yes = true;
} else {
@ -210,17 +253,33 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
}
}
if (fidelity_path != null and schwab_path != null) {
try cli.stderrPrint(ctx.io, "Error: --fidelity and --schwab are mutually exclusive (one source per import)\n");
// Mutual-exclusion check across the three source flags. Two
// of three set still counts as a conflict.
var source_count: u8 = 0;
if (fidelity_path != null) source_count += 1;
if (schwab_path != null) source_count += 1;
if (wells_fargo_path != null) source_count += 1;
if (source_count > 1) {
try cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n");
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) {
try 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|
.{ .fidelity = p }
else if (schwab_path) |p|
.{ .schwab = p }
else if (wells_fargo_path) |p|
.{ .wells_fargo = .{ .path = p, .account_override = account_override } }
else {
try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE or --schwab FILE)\n");
try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n");
return error.MissingSource;
};
@ -242,22 +301,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// multiple paths, they need to pick one explicitly.
const target_path = try resolveSingleTarget(ctx);
// Read & parse the brokerage export
// Read the brokerage export
//
// `-` selects stdin (for the paste-from-clipboard workflow);
// any other path reads from disk. Same convention regardless
// of source.
const csv_path = parsed.source.path();
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
try cli.stderrPrint(io, msg);
return error.CannotReadCsv;
};
const csv_data = try readSourceData(io, allocator, csv_path);
defer allocator.free(csv_data);
const positions: []const BrokeragePosition = switch (parsed.source) {
// Parse
const positions: []BrokeragePosition = switch (parsed.source) {
.fidelity => try fidelity.parseCsv(allocator, csv_data),
.schwab => blk: {
const r = try schwab.parseCsv(allocator, csv_data);
break :blk r.positions;
},
.wells_fargo => try wells_fargo.parsePaste(allocator, csv_data),
};
defer allocator.free(positions);
@ -281,6 +341,21 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
};
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);
}
// Synthesize lots
const lots = synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today) catch |err| switch (err) {
error.UnmappedAccount => {
@ -330,7 +405,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
for (lots) |lot| {
if (lot.account) |a| {
if (!seen.contains(a)) {
seen.put(a, {}) catch {};
try seen.put(a, {});
account_count += 1;
}
}
@ -383,6 +458,28 @@ fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 {
return pat;
}
/// Read the source bytes from `path`. The literal path `-` reads
/// from stdin so users can paste directly into a heredoc-style
/// invocation; any other value reads from disk. Returns owned
/// bytes the caller must free.
fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
if (std.mem.eql(u8, path, "-")) {
var stdin_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf);
const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read source data from stdin\n");
return error.CannotReadCsv;
};
return data;
}
return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch {
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";
try cli.stderrPrint(io, msg);
return error.CannotReadCsv;
};
}
/// Prompt for y/N confirmation on stderr, read one line from stdin.
/// Returns true only on a 'y' or 'Y' answer (with optional whitespace);
/// anything else (including EOF / empty) is "no".
@ -443,7 +540,7 @@ fn synthesizeLots(
for (positions) |pos| {
if (account_map.findByInstitutionAccount(institution, pos.account_number) == null) {
if (!seen.contains(pos.account_number)) {
seen.put(pos.account_number, {}) catch {};
try seen.put(pos.account_number, {});
try unmapped.append(allocator, pos.account_number);
}
}
@ -854,9 +951,11 @@ test "parseArgs: --fidelity without value errors" {
test "Source.label: returns broker name" {
try testing.expectEqualStrings("fidelity", (Source{ .fidelity = "" }).label());
try testing.expectEqualStrings("schwab", (Source{ .schwab = "" }).label());
try testing.expectEqualStrings("wells_fargo", (Source{ .wells_fargo = .{ .path = "", .account_override = null } }).label());
}
test "Source.path: returns the 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("/z.txt", (Source{ .wells_fargo = .{ .path = "/z.txt", .account_override = null } }).path());
}

View file

@ -85,8 +85,12 @@ pub const money_market_symbols = [_][]const u8{
"SPAXX",
"SPRXX", "FDRXX", "FDLXX", "FZFXX", "FZDXX", "FTEXX",
"FDIXX",
// Wells Fargo / Allspring (Allspring is the WAM rebrand;
// tickers retained their classic letters for legacy holders)
"WMPXX", "WFFXX", "NWGXX", "GVIXX",
// Federated, BlackRock, JPM common tickers
"GOFXX", "TSCXX", "MJLXX",
"GOFXX",
"TSCXX", "MJLXX",
};
/// Returns true when `symbol` appears in the well-known money-market