audit: better handling of unmapped accounts in multi-account file imports
All checks were successful
Generic zig build / build (push) Successful in 5m28s
Generic zig build / publish-macos (push) Successful in 13s
Generic zig build / deploy (push) Successful in 19s

This commit is contained in:
Emil Lerch 2026-10-06 10:52:10 -07:00
parent 635a0557b4
commit d5459ff39e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 616 additions and 123 deletions

View file

@ -268,10 +268,16 @@ What it considers:
for ...`, a Schwab summary by `Account number ending in`, a Wells
Fargo spreadsheet by its `WFA_Positions` sheet. A renamed file still
works; an unrelated file is skipped.
- **A Wells Fargo export with none of your accounts is skipped.** One WF
download covers a whole household, so if you also manage someone
else's WF accounts, their export in your download folder is noted and
skipped rather than reported as eight unmapped accounts.
- **Accounts you don't track get one line, not a wall of
discrepancies.** Fidelity's download, Schwab's All Brokerage Accounts
export and summary paste, and the Wells Fargo spreadsheet cover every
account your login can see -- including ones this portfolio
deliberately leaves out (a relative's account you manage, one that
belongs to another portfolio file). Their unmapped accounts are named
on a muted "not in accounts.srf, not reconciled" line instead of
being reconciled, and an export with *none* of your accounts is noted
and skipped as another portfolio's. See
[How accounts are matched](#how-accounts-are-matched).
So with `ZFIN_AUDIT_FILES=~/Downloads`, the workflow collapses to
"download from your broker, run `zfin audit`, done."
@ -291,8 +297,22 @@ portfolio. It does that through
- zfin finds the `accounts.srf` entry whose `institution::` (`fidelity`,
`schwab`, `wells_fargo`) **and** `account_number::` match, and
compares against that account's lots.
- **No match -> the account is shown as `unmapped`** and flagged as a
discrepancy. Fix it by adding `institution::` and `account_number::`
- **No match -> the account isn't reconciled.** How loudly zfin says so
depends on what you exported:
- A **single-account** Schwab export: you picked that account, so it
is shown as `unmapped` with every holding, and flagged as a
discrepancy.
- An export covering **every account** (Fidelity, Schwab's All
Brokerage Accounts or summary, Wells Fargo): the account is listed
by name and number on one muted "not in accounts.srf, not
reconciled" line, and doesn't count as a discrepancy -- so an
account you deliberately don't track doesn't keep the audit from
coming back clean. If *none* of the export's accounts match, the
whole file is skipped as another portfolio's (the flagless audit
lists its accounts with `--verbose`; a `--fidelity`/`--schwab`/...
flag always does).
Either way, fix it by adding `institution::` and `account_number::`
to that account in `accounts.srf` (a placeholder number you recognize
is fine -- it just has to match what the export shows).

View file

@ -26,10 +26,13 @@ discrepancies.
| `--wells-fargo <XLS>` | Wells Fargo positions spreadsheet (Download Type "Portfolio-Expanded Detail", "Positions"). |
Reconciliation matches export accounts to yours via `institution::` and
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
unmatched account is reported as "unmapped." A discovered Wells Fargo
export none of whose accounts are mapped is skipped with a note instead,
since it is almost certainly another portfolio's.
`account_number::` in [`accounts.srf`](../config/accounts-srf.md). An
unmatched account in a single-account Schwab export is reported as
"unmapped." Exports that cover every account (Fidelity, Schwab's All
Brokerage Accounts and summary, Wells Fargo) instead name their
unmatched accounts on one "not in accounts.srf, not reconciled" line,
and one with no matched accounts at all is skipped as another
portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
The hygiene check also flags newly-appeared lots worth at least
$10,000 in a **Large new lots - confirm source** section, so you can

View file

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

View file

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

View file

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

View file

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

View file

@ -229,6 +229,14 @@ fn parseTitle(line: []const u8) ?Title {
return .{ .account = parseAccountLabel(rest[0..label_end]) orelse return null };
}
/// True for an "All Brokerage Accounts" export - one that covers every
/// account the login can see, rather than one the user picked. Reads only
/// the title line.
pub fn isAllAccounts(data: []const u8) bool {
const title = parseTitle(data[0 .. std.mem.indexOfScalar(u8, data, '\n') orelse data.len]) orelse return false;
return title == .all_accounts;
}
/// Running tally of one section's position rows, checked against the
/// section's "Positions Total" row.
const Section = struct {
@ -831,3 +839,12 @@ test "parseSummary no accounts" {
const result = parseSummary(allocator, "some random text\nno accounts here\n");
try std.testing.expectError(error.NoAccountsFound, result);
}
test "isAllAccounts: keys on the title line only" {
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\""));
try std.testing.expect(!isAllAccounts("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
// A later line mentioning the phrase does not count.
try std.testing.expect(!isAllAccounts("something else\n\"Positions for All-Accounts\"\n"));
try std.testing.expect(!isAllAccounts(""));
}

View file

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

View file

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

View file

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

View file

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