From d5459ff39e10b5f625112d615d457a74e8f8d16e Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 6 Oct 2026 10:52:10 -0700 Subject: [PATCH] audit: better handling of unmapped accounts in multi-account file imports --- docs/guides/audit-against-brokerage.md | 32 +++- docs/reference/cli/audit.md | 11 +- src/analytics/reconcile.zig | 4 + src/analytics/reconcile/common.zig | 128 ++++++++++++++ src/analytics/reconcile/schwab.zig | 70 +++++++- src/analytics/reconcile/wells_fargo.zig | 22 +-- src/brokerage/schwab.zig | 17 ++ src/commands/audit.zig | 85 +++++++--- src/commands/audit/common.zig | 132 +++++++++++++++ src/commands/audit/hygiene.zig | 211 ++++++++++++++++++------ src/commands/audit/schwab.zig | 27 +-- 11 files changed, 616 insertions(+), 123 deletions(-) diff --git a/docs/guides/audit-against-brokerage.md b/docs/guides/audit-against-brokerage.md index 6781350..aef3276 100644 --- a/docs/guides/audit-against-brokerage.md +++ b/docs/guides/audit-against-brokerage.md @@ -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). diff --git a/docs/reference/cli/audit.md b/docs/reference/cli/audit.md index bf3936c..bdd44ff 100644 --- a/docs/reference/cli/audit.md +++ b/docs/reference/cli/audit.md @@ -26,10 +26,13 @@ discrepancies. | `--wells-fargo ` | 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 diff --git a/src/analytics/reconcile.zig b/src/analytics/reconcile.zig index 3d78b95..7a8f241 100644 --- a/src/analytics/reconcile.zig +++ b/src/analytics/reconcile.zig @@ -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; diff --git a/src/analytics/reconcile/common.zig b/src/analytics/reconcile/common.zig index 741dc91..8945309 100644 --- a/src/analytics/reconcile/common.zig +++ b/src/analytics/reconcile/common.zig @@ -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()); +} diff --git a/src/analytics/reconcile/schwab.zig b/src/analytics/reconcile/schwab.zig index e0aca20..b93ca61 100644 --- a/src/analytics/reconcile/schwab.zig +++ b/src/analytics/reconcile/schwab.zig @@ -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); +} diff --git a/src/analytics/reconcile/wells_fargo.zig b/src/analytics/reconcile/wells_fargo.zig index 01cfda5..38cd34f 100644 --- a/src/analytics/reconcile/wells_fargo.zig +++ b/src/analytics/reconcile/wells_fargo.zig @@ -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" { diff --git a/src/brokerage/schwab.zig b/src/brokerage/schwab.zig index e167b9a..ca4373e 100644 --- a/src/brokerage/schwab.zig +++ b/src/brokerage/schwab.zig @@ -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("")); +} diff --git a/src/commands/audit.zig b/src/commands/audit.zig index a0a19aa..c5dc6f9 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -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" { diff --git a/src/commands/audit/common.zig b/src/commands/audit/common.zig index 0e4a54d..8d201f5 100644 --- a/src/commands/audit/common.zig +++ b/src/commands/audit/common.zig @@ -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); +} diff --git a/src/commands/audit/hygiene.zig b/src/commands/audit/hygiene.zig index 304b050..41cab38 100644 --- a/src/commands/audit/hygiene.zig +++ b/src/commands/audit/hygiene.zig @@ -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::,") != 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); +} diff --git a/src/commands/audit/schwab.zig b/src/commands/audit/schwab.zig index eda2897..f099426 100644 --- a/src/commands/audit/schwab.zig +++ b/src/commands/audit/schwab.zig @@ -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);