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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -229,6 +229,14 @@ fn parseTitle(line: []const u8) ?Title {
return .{ .account = parseAccountLabel(rest[0..label_end]) orelse return null }; 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 /// Running tally of one section's position rows, checked against the
/// section's "Positions Total" row. /// section's "Positions Total" row.
const Section = struct { const Section = struct {
@ -831,3 +839,12 @@ test "parseSummary no accounts" {
const result = parseSummary(allocator, "some random text\nno accounts here\n"); const result = parseSummary(allocator, "some random text\nno accounts here\n");
try std.testing.expectError(error.NoAccountsFound, result); try std.testing.expectError(error.NoAccountsFound, result);
} }
test "isAllAccounts: keys on the title line only" {
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n\"Sample_IRA ...1234\"\n"));
try std.testing.expect(isAllAccounts("\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\""));
try std.testing.expect(!isAllAccounts("\"Positions for account Sample IRA ...1234 as of 10:00 AM ET, 2026/06/27\"\n"));
// A later line mentioning the phrase does not count.
try std.testing.expect(!isAllAccounts("something else\n\"Positions for All-Accounts\"\n"));
try std.testing.expect(!isAllAccounts(""));
}

View file

@ -21,8 +21,10 @@
//! pulling in the reconciliation surface. //! pulling in the reconciliation surface.
const std = @import("std"); const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const framework = @import("framework.zig"); const framework = @import("framework.zig");
const analysis = @import("../analytics/analysis.zig");
const common = @import("audit/common.zig"); const common = @import("audit/common.zig");
const fidelity = @import("../analytics/reconcile/fidelity.zig"); const fidelity = @import("../analytics/reconcile/fidelity.zig");
@ -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"); cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
var stdin_reader_buf: [4096]u8 = undefined; var stdin_reader_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf); var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch { const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch |err| {
cli.stderrPrint(io, "Error: Cannot read stdin\n"); var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read stdin: {s}\n", .{@errorName(err)}) catch "Error: Cannot read stdin\n";
cli.stderrPrint(io, msg);
return; return;
}; };
defer allocator.free(stdin_data); defer allocator.free(stdin_data);
const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) { const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err, error.OutOfMemory => return err,
else => { else => |e| {
cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab summary: {s} (no 'Account number ending in' lines found?)\n", .{@errorName(e)}) catch "Error: Cannot parse Schwab summary\n";
cli.stderrPrint(io, msg);
return; return;
}, },
}; };
defer allocator.free(results); defer allocator.free(results);
try schwab.displaySchwabResults(results, color, out); // A summary paste covers every account on the page.
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out); const view = common.splitUnmapped(schwab.SchwabAccountComparison, results, .all_accounts);
if (!view.skipped()) {
try schwab.displaySchwabResults(view.shown, color, out);
try schwab.displaySchwabSummaryRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, as_of, color, out);
}
try common.displayUnmapped(schwab.SchwabAccountComparison, "schwab summary", view, true, color, out);
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
defer allocator.free(present); defer allocator.free(present);
@ -237,9 +248,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// Fidelity CSV // Fidelity CSV
if (fidelity_csv) |csv_path| { if (fidelity_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [256]u8 = undefined; var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n"; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
cli.stderrPrint(io, msg); cli.stderrPrint(io, msg);
return; return;
}; };
@ -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) { const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err, error.OutOfMemory => return err,
else => { else => |e| {
cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Fidelity CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Fidelity CSV\n";
cli.stderrPrint(io, msg);
return; return;
}, },
}; };
@ -257,8 +270,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(results); allocator.free(results);
} }
try common.displayResults(results, color, out); // Fidelity's download covers every account in the login.
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out); try showAccountExport(allocator, "fidelity", results, .all_accounts, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, results); const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present); defer allocator.free(present);
@ -267,11 +280,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
try common.displayAbsentAccounts(absent, color, "this export", out); try common.displayAbsentAccounts(absent, color, "this export", out);
} }
// Schwab per-account CSV // Schwab positions CSV (one account, or All Brokerage Accounts)
if (schwab_csv) |csv_path| { if (schwab_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
var msg_buf: [256]u8 = undefined; var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n"; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file {s}: {s}\n", .{ csv_path, @errorName(err) }) catch "Error: Cannot read CSV file\n";
cli.stderrPrint(io, msg); cli.stderrPrint(io, msg);
return; return;
}; };
@ -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) { const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) {
error.OutOfMemory => return err, error.OutOfMemory => return err,
else => { else => |e| {
cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse Schwab CSV {s}: {s}\n", .{ csv_path, @errorName(e) }) catch "Error: Cannot parse Schwab CSV\n";
cli.stderrPrint(io, msg);
return; return;
}, },
}; };
@ -289,8 +304,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
allocator.free(results); allocator.free(results);
} }
try common.displayResults(results, color, out); try showAccountExport(allocator, "schwab", results, schwab.csvScope(csv_data), portfolio, prices, account_map, color, out);
try common.displayRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, results); const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present); defer allocator.free(present);
@ -321,8 +335,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}; };
defer rec.deinit(allocator); defer rec.deinit(allocator);
try common.displayResults(rec.results, color, out); // One WF download covers a whole household.
try common.displayRatioSuggestions(allocator, rec.results, portfolio, prices, account_map, color, out); try showAccountExport(allocator, "wells fargo", rec.results, .all_accounts, portfolio, prices, account_map, color, out);
const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results); const present = try common.presentNumbers(allocator, common.AccountComparison, rec.results);
defer allocator.free(present); defer allocator.free(present);
@ -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 ──────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────
test "parseArgs: defaults" { test "parseArgs: defaults" {

View file

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

View file

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

View file

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