audit: avoid displaying reconciled accounts within multi-account files (other accounts same file have discrepancies)

This commit is contained in:
Emil Lerch 2026-10-13 08:51:53 -07:00
parent d5459ff39e
commit d7917473e8
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 404 additions and 25 deletions

View file

@ -338,7 +338,21 @@ Portfolio Audit (brokerage is source of truth)
- **Brokerage-only** rows are holdings missing from your portfolio. - **Brokerage-only** rows are holdings missing from your portfolio.
- A share or value **delta** flags a count or price mismatch. - A share or value **delta** flags a count or price mismatch.
`--verbose` prints the full comparison even when everything reconciles. When the flagless audit finds an export that covers several accounts, it
only shows a table for the accounts with something to look at. That
includes muted rows: an account whose only difference is a stale price
still gets its table. The other accounts are named on one line, so you
can see they were checked, and the totals still include them:
```
2 more accounts, no discrepancies: Sample IRA, Sample Trust
Total: portfolio $412,350.00 brokerage $412,670.40 delta +$320.40
```
`--verbose` shows a table for every account, and prints the full
comparison even when everything reconciles. The explicit flags
(`--schwab FILE` etc.) always show every account in the file.
### Discrepancies, and which ones are muted ### Discrepancies, and which ones are muted

View file

@ -18,7 +18,7 @@ discrepancies.
| Flag | Effect | | Flag | Effect |
|-----------------------|---------------------------------------------------------------------------------------------| |-----------------------|---------------------------------------------------------------------------------------------|
| `--verbose` | Show full reconciliation output even when clean. | | `--verbose` | Show a table for every account, not just mismatched ones, even when clean. |
| `--stale-days <N>` | Manual-price staleness threshold (default 3). | | `--stale-days <N>` | Manual-price staleness threshold (default 3). |
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). | | `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
| `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". | | `--schwab <CSV>` | Schwab positions CSV: one account, or "All Brokerage Accounts". |
@ -34,6 +34,12 @@ unmatched accounts on one "not in accounts.srf, not reconciled" line,
and one with no matched accounts at all is skipped as another 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). portfolio's. See [how accounts are matched](../../guides/audit-against-brokerage.md#how-accounts-are-matched).
When the flagless audit reconciles a discovered export, only accounts
with a discrepancy get a full table. The rest of that export's accounts
are named on one "N more accounts, no discrepancies" line, and the
totals still cover all of them. `--verbose` and the explicit brokerage
flags show a table for every account.
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
confirm whether each is a real contribution or an unrecorded transfer. confirm whether each is a real contribution or an unrecorded transfer.

View file

@ -488,6 +488,15 @@ pub fn compareAccounts(
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null; const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
// Nothing in the portfolio for this row. A broker cash line
// that is itself (near) zero is agreement, not a holding the
// portfolio is missing: Schwab always emits a "Cash & Cash
// Investments" row, even at $0.00, and cash rows carry no
// quantity, so without this a fully-invested account with no
// cash lot showed a "Brokerage only" mismatch that
// `has_discrepancies` (value delta $0) disagreed with.
const zero_cash_row = bp.is_cash and @abs(bp_value) < cash_tolerance;
try comparisons.append(allocator, .{ try comparisons.append(allocator, .{
.symbol = bp.symbol, .symbol = bp.symbol,
.portfolio_shares = pf_shares, .portfolio_shares = pf_shares,
@ -502,7 +511,7 @@ pub fn compareAccounts(
.is_option = is_option, .is_option = is_option,
.is_cd = is_cd, .is_cd = is_cd,
.cd_allowance = cd_allowance, .cd_allowance = cd_allowance,
.only_in_brokerage = pf_shares == 0 and pf_value == 0, .only_in_brokerage = pf_shares == 0 and pf_value == 0 and !zero_cash_row,
.only_in_portfolio = false, .only_in_portfolio = false,
}); });
} }
@ -1366,6 +1375,58 @@ test "compareAccounts: no cash row and no portfolio cash stays silent" {
} }
} }
test "compareAccounts: a $0.00 broker cash row with no cash lot is agreement, not brokerage-only" {
const allocator = std.testing.allocator;
// Schwab emits its "Cash & Cash Investments" line even for a fully
// invested account. With no cash lot in the portfolio, both sides
// say zero - which used to render as a counted "Brokerage only"
// mismatch while `has_discrepancies` said the account was clean.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTI", 300);
const Case = struct { cash: f64, flagged: bool };
// $0.00 and sub-penny agree; a real balance with no lot is still
// brokerage-only cash the portfolio is missing.
const cases = [_]Case{
.{ .cash = 0, .flagged = false },
.{ .cash = 0.004, .flagged = false },
.{ .cash = 50, .flagged = true },
};
for (cases) |c| {
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "Sample_IRA", .symbol = "VTI", .description = "", .quantity = 100, .current_value = 30000, .cost_basis = 20000, .is_cash = false },
.{ .account_number = "1234", .account_name = "Sample_IRA", .symbol = "Cash & Cash Investments", .description = "", .quantity = null, .current_value = c.cash, .cost_basis = null, .is_cash = true },
};
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 8, 1));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqual(c.flagged, results[0].has_discrepancies);
var found = false;
for (results[0].comparisons) |cmp| {
if (!cmp.is_cash) continue;
found = true;
try std.testing.expectEqual(c.flagged, cmp.only_in_brokerage);
}
try std.testing.expect(found);
}
}
test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" { test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;

View file

@ -347,6 +347,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
} }
/// Show one explicitly-requested per-position export: the comparison /// Show one explicitly-requested per-position export: the comparison
/// (a table for every account, clean or not - you asked about this file)
/// and ratio suggestions for its mapped accounts, then its unmapped ones /// and ratio suggestions for its mapped accounts, then its unmapped ones
/// (see `common.splitUnmapped`). The unmapped list is printed even for an /// (see `common.splitUnmapped`). The unmapped list is printed even for an
/// export skipped as another portfolio's - you pointed audit at this /// export skipped as another portfolio's - you pointed audit at this
@ -365,7 +366,7 @@ fn showAccountExport(
) !void { ) !void {
const view = common.splitUnmapped(common.AccountComparison, results, scope); const view = common.splitUnmapped(common.AccountComparison, results, scope);
if (!view.skipped()) { if (!view.skipped()) {
try common.displayResults(view.shown, color, out); try common.displayResults(view.shown, .all, color, out);
try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out); try common.displayRatioSuggestions(allocator, view.shown, portfolio, prices, account_map, color, out);
} }
try common.displayUnmapped(common.AccountComparison, label, view, true, color, out); try common.displayUnmapped(common.AccountComparison, label, view, true, color, out);

View file

@ -23,7 +23,6 @@ pub const accountValueExpectation = reconcile.accountValueExpectation;
pub const SymbolComparison = reconcile.SymbolComparison; 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 ExportScope = reconcile.ExportScope; pub const ExportScope = reconcile.ExportScope;
pub const ExportView = reconcile.ExportView; pub const ExportView = reconcile.ExportView;
pub const splitUnmapped = reconcile.splitUnmapped; pub const splitUnmapped = reconcile.splitUnmapped;
@ -129,7 +128,84 @@ pub fn displayRatioSuggestions(
// ── Display ───────────────────────────────────────────────── // ── Display ─────────────────────────────────────────────────
pub fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { /// How one comparison row reads in the audit table. Shared by the
/// renderer and by `accountNeedsDetail`, so the rows counted "to
/// investigate" and the accounts given a table can't drift apart.
const RowClass = struct {
shares_ok: bool,
is_cash_mismatch: bool,
/// A warning-coloured, counted discrepancy - as opposed to a muted
/// stale value or in-band CD mark, which is shown but not counted.
real_mismatch: bool,
};
fn classifyRow(cmp: SymbolComparison) RowClass {
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage;
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false);
// A CD's value delta is an expected dealer mark up to its
// band; only a delta beyond what its terms can explain is a
// real discrepancy. Stale stock prices (shares match, no CD)
// stay muted, as before.
const cd_band = @max(value_tolerance, cmp.cd_allowance);
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
return .{
.shares_ok = shares_ok,
.is_cash_mismatch = is_cash_mismatch,
.real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded,
};
}
/// True when the row is one of the "N mismatches to investigate".
pub fn isRealMismatch(cmp: SymbolComparison) bool {
return classifyRow(cmp).real_mismatch;
}
/// Does this account warrant its own table in the routine audit?
///
/// True when the reconciler flagged anything (`has_discrepancies`: a row
/// off beyond its tolerance, which includes the muted stale-price and
/// in-band CD rows - muted is shown, never hidden), or when any row is a
/// counted mismatch. The second half is what guarantees the "to
/// investigate" count never includes a row in an account that isn't
/// shown: the two predicates disagree for a brokerage-only row with no
/// quantity and a value under the $1 securities tolerance.
///
/// An account's summed sub-tolerance rounding (many rows each a few
/// cents off) does not qualify on its own, matching what the per-file
/// gate this replaced did.
pub fn accountNeedsDetail(acct: AccountComparison) bool {
if (acct.has_discrepancies) return true;
for (acct.comparisons) |cmp| {
if (isRealMismatch(cmp)) return true;
}
return false;
}
/// True when any account in `results` needs a table (see
/// `accountNeedsDetail`).
pub fn anyAccountNeedsDetail(results: []const AccountComparison) bool {
for (results) |acct| {
if (accountNeedsDetail(acct)) return true;
}
return false;
}
/// How much of an export `displayResults` renders as tables.
pub const Detail = enum {
/// Every account gets a table: `--verbose`, and an export audit was
/// pointed at explicitly (`--schwab FILE` etc.).
all,
/// Only accounts that `accountNeedsDetail`; the others are named on
/// one line above the totals. The flagless audit's routine view, so
/// a single bad account in an all-accounts export doesn't print a
/// screenful of matching ones.
discrepancies_only,
};
/// Render one export's comparison. The totals and the "to investigate"
/// count always cover every account in `results`, whatever `detail`
/// leaves out - an account left out has no counted rows by construction.
pub fn displayResults(results: []const AccountComparison, detail: Detail, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nPortfolio Audit", .{}); try cli.printBold(out, color, "\nPortfolio Audit", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
try out.print("========================================\n\n", .{}); try out.print("========================================\n\n", .{});
@ -138,8 +214,20 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
var total_brokerage: f64 = 0; var total_brokerage: f64 = 0;
var total_option_delta: f64 = 0; var total_option_delta: f64 = 0;
var discrepancy_count: usize = 0; var discrepancy_count: usize = 0;
var shown: usize = 0;
var hidden: usize = 0;
for (results) |acct| { for (results) |acct| {
total_portfolio += acct.portfolio_total;
total_brokerage += acct.brokerage_total;
total_option_delta += acct.option_value_delta;
if (detail == .discrepancies_only and !accountNeedsDetail(acct)) {
hidden += 1;
continue;
}
shown += 1;
// Account header // Account header
if (acct.account_name.len > 0) { if (acct.account_name.len > 0) {
try cli.printBold(out, color, " {s}", .{acct.account_name}); try cli.printBold(out, color, " {s}", .{acct.account_name});
@ -161,15 +249,10 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
var br_price_buf: [16]u8 = undefined; var br_price_buf: [16]u8 = undefined;
// Classify this row // Classify this row
const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage; const class = classifyRow(cmp);
const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= cash_tolerance else false); const shares_ok = class.shares_ok;
// A CD's value delta is an expected dealer mark up to its const is_cash_mismatch = class.is_cash_mismatch;
// band; only a delta beyond what its terms can explain is a const is_real_mismatch = class.real_mismatch;
// real discrepancy. Stale stock prices (shares match, no CD)
// stay muted, as before.
const cd_band = @max(value_tolerance, cmp.cd_allowance);
const cd_value_exceeded = cmp.is_cd and (if (cmp.value_delta) |d| @abs(d) > cd_band else false);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded;
if (is_real_mismatch) discrepancy_count += 1; if (is_real_mismatch) discrepancy_count += 1;
@ -296,10 +379,24 @@ pub fn displayResults(results: []const AccountComparison, color: bool, out: *std
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) }); try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) });
} }
try out.print("\n\n", .{}); try out.print("\n\n", .{});
}
total_portfolio += acct.portfolio_total; // Accounts left out of a `.discrepancies_only` view, named so you can
total_brokerage += acct.brokerage_total; // see they were covered (an account missing from the export entirely
total_option_delta += acct.option_value_delta; // is the "not found in any export" advisory's job, not this line's).
if (hidden > 0) {
const more: []const u8 = if (shown > 0) " more" else "";
try cli.printFg(out, color, cli.CLR_POSITIVE, " {d}{s} {s}, no discrepancies: ", .{ hidden, more, accountsNoun(hidden) });
var first = true;
for (results) |acct| {
if (accountNeedsDetail(acct)) continue;
if (!first) try cli.printFg(out, color, cli.CLR_POSITIVE, ", ", .{});
first = false;
// Always non-empty: an unmapped account is a discrepancy, so
// it never lands here.
try cli.printFg(out, color, cli.CLR_POSITIVE, "{s}", .{acct.account_name});
}
try out.print("\n\n", .{});
} }
// Grand totals // Grand totals
@ -385,7 +482,7 @@ test "displayResults: renders every row classification and the totals block" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w); try displayResults(&results, .all, false, &w);
const out = w.buffered(); const out = w.buffered();
// Header + account identity // Header + account identity
@ -422,7 +519,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, true, &w); try displayResults(&results, .all, true, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
@ -443,7 +540,7 @@ test "displayResults: CD value delta within band is muted+uncounted, beyond band
}; };
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w); try displayResults(&results, .all, false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden try std.testing.expect(std.mem.indexOf(u8, out, "Value -") != null); // shown, never hidden
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null); // not counted
@ -458,13 +555,132 @@ test "displayResults: CD value delta within band is muted+uncounted, beyond band
}; };
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w); try displayResults(&results, .all, false, &w);
const out = w.buffered(); const out = w.buffered();
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, "1 mismatch to investigate") != null); // counted try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null); // counted
} }
} }
// ── displayResults: per-account detail ───────────────────────
/// A matched, clean row: shares and value agree.
fn cleanRow(symbol: []const u8, value: f64) SymbolComparison {
return .{ .symbol = symbol, .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = value / 10, .brokerage_price = value / 10, .portfolio_value = value, .brokerage_value = value, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false };
}
fn cleanAccount(name: []const u8, number: []const u8, cmps: []const SymbolComparison, total: f64) AccountComparison {
return .{ .account_name = name, .brokerage_name = "Sample_Account", .account_number = number, .comparisons = cmps, .portfolio_total = total, .brokerage_total = total, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false };
}
/// The line of `out` starting at the first `prefix`, or "" if none.
fn lineStarting(out: []const u8, prefix: []const u8) []const u8 {
const rest = out[std.mem.indexOf(u8, out, prefix) orelse out.len ..];
return rest[0 .. std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len];
}
test "displayResults: discrepancies_only tables the accounts that need it and names the rest" {
// One share mismatch, one muted stale value (flagged by the
// reconciler, so it keeps its table: muted is shown, never hidden),
// and two clean accounts.
const dirty_cmps = [_]SymbolComparison{
.{ .symbol = "MSFT", .portfolio_shares = 5, .brokerage_shares = 7, .portfolio_price = 300, .brokerage_price = 300, .portfolio_value = 1500, .brokerage_value = 2100, .shares_delta = 2, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const stale_cmps = [_]SymbolComparison{
.{ .symbol = "QTUM", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 116.0, .brokerage_price = 116.5, .portfolio_value = 11600, .brokerage_value = 11650, .shares_delta = 0, .value_delta = 50, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const roth_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
const trust_cmps = [_]SymbolComparison{cleanRow("SCHD", 4000)};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "Sample_IRA", .account_number = "1234", .comparisons = &dirty_cmps, .portfolio_total = 1500, .brokerage_total = 2100, .total_delta = 600, .option_value_delta = 0, .has_discrepancies = true },
cleanAccount("Sample Roth", "5678", &roth_cmps, 3000),
.{ .account_name = "Sample HSA", .brokerage_name = "Sample_HSA", .account_number = "9012", .comparisons = &stale_cmps, .portfolio_total = 11600, .brokerage_total = 11650, .total_delta = 50, .option_value_delta = 0, .has_discrepancies = true },
cleanAccount("Sample Trust", "3456", &trust_cmps, 4000),
};
var only_buf: [8192]u8 = undefined;
var only_w: std.Io.Writer = .fixed(&only_buf);
try displayResults(&results, .discrepancies_only, false, &only_w);
const only = only_w.buffered();
var all_buf: [8192]u8 = undefined;
var all_w: std.Io.Writer = .fixed(&all_buf);
try displayResults(&results, .all, false, &all_w);
const all = all_w.buffered();
// Tables: the share mismatch and the muted stale value.
try std.testing.expect(std.mem.indexOf(u8, only, " Sample IRA (Sample_IRA, #1234)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, only, " Sample HSA (Sample_HSA, #9012)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, only, "Value +$50.00") != null);
// The clean two: no table, named once on the summary line.
try std.testing.expect(std.mem.indexOf(u8, only, "#5678") == null);
try std.testing.expect(std.mem.indexOf(u8, only, "#3456") == null);
try std.testing.expect(std.mem.indexOf(u8, only, "VTI") == null);
try std.testing.expect(std.mem.indexOf(u8, only, "\n 2 more accounts, no discrepancies: Sample Roth, Sample Trust\n\n Total:") != null);
// `.all` tables everyone and has no summary line.
try std.testing.expect(std.mem.indexOf(u8, all, " Sample Roth (Sample_Account, #5678)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, all, " Sample Trust (Sample_Account, #3456)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, all, "no discrepancies") == null);
// Totals and the "to investigate" count cover every account, so
// hiding the clean ones changes neither.
const only_total = lineStarting(only, " Total:");
try std.testing.expect(std.mem.indexOf(u8, only_total, "portfolio $20,100.00 brokerage $20,750.00") != null);
try std.testing.expectEqualStrings(lineStarting(all, " Total:"), only_total);
try std.testing.expectEqualStrings(" 1 mismatch to investigate", lineStarting(only, " 1 mismatch"));
try std.testing.expectEqualStrings(lineStarting(all, " 1 mismatch"), lineStarting(only, " 1 mismatch"));
}
test "displayResults: a counted row keeps its table even when the reconciler saw nothing" {
// A brokerage-only row with no quantity and a value under the $1
// securities tolerance: `has_discrepancies` stays false, but the
// table counts it. Hiding it would leave "1 mismatch to investigate"
// pointing at an account that isn't shown.
const odd_cmps = [_]SymbolComparison{
.{ .symbol = "SMPLX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 0, .brokerage_value = 0.5, .shares_delta = null, .value_delta = 0.5, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
};
const roth_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
const results = [_]AccountComparison{
cleanAccount("Sample IRA", "1234", &odd_cmps, 0),
cleanAccount("Sample Roth", "5678", &roth_cmps, 3000),
};
try std.testing.expect(accountNeedsDetail(results[0]));
try std.testing.expect(!accountNeedsDetail(results[1]));
try std.testing.expect(anyAccountNeedsDetail(&results));
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, .discrepancies_only, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage only") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
// Singular.
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample Roth\n") != null);
}
test "displayResults: discrepancies_only with nothing to table drops the \"more\"" {
// Callers gate on `anyAccountNeedsDetail` so this shouldn't happen,
// but the summary line must still read correctly if it does.
const ira_cmps = [_]SymbolComparison{cleanRow("VTI", 3000)};
const roth_cmps = [_]SymbolComparison{cleanRow("SCHD", 4000)};
const results = [_]AccountComparison{
cleanAccount("Sample IRA", "1234", &ira_cmps, 3000),
cleanAccount("Sample Roth", "5678", &roth_cmps, 4000),
};
try std.testing.expect(!anyAccountNeedsDetail(&results));
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, .discrepancies_only, true, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "2 accounts, no discrepancies: ") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "more") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "to investigate") == null);
}
// ── displayRatioSuggestions ────────────────────────────────── // ── displayRatioSuggestions ──────────────────────────────────
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" { test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {

View file

@ -738,7 +738,8 @@ fn accumulatePresent(
} }
/// Report one discovered per-position export (Fidelity, a Schwab CSV, /// Report one discovered per-position export (Fidelity, a Schwab CSV,
/// Wells Fargo): the full comparison when `verbose` or anything is off, /// Wells Fargo): a table for each account with something to look at
/// (every account when `verbose`) and the clean rest named on one line,
/// else a one-line all-clear; then the export's unmapped accounts (see /// else a one-line all-clear; then the export's unmapped accounts (see
/// `common.splitUnmapped`). Returns false when the export was skipped as /// `common.splitUnmapped`). Returns false when the export was skipped as
/// another portfolio's, so the caller leaves its accounts out of the /// another portfolio's, so the caller leaves its accounts out of the
@ -761,9 +762,11 @@ fn reportAccountExport(
return false; return false;
} }
if (verbose or common.hasAccountDiscrepancies(view.shown)) { // Same predicate as `.discrepancies_only` filters by, so that mode
// always has at least one table to show.
if (verbose or common.anyAccountNeedsDetail(view.shown)) {
try out.print("\n", .{}); try out.print("\n", .{});
try common.displayResults(view.shown, color, out); try common.displayResults(view.shown, if (verbose) .all else .discrepancies_only, color, out);
} else { } else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) }); try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}: {d} {s}, no discrepancies\n", .{ label, view.shown.len, common.accountsNoun(view.shown.len) });
} }
@ -2805,3 +2808,81 @@ test "runHygieneCheck: discovered exports set unmapped accounts aside unless one
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, 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); 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);
} }
test "runHygieneCheck: one bad account in an all-accounts export tables only that account unless verbose" {
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\n" ++
"symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:200,price:num:300,account::Sample IRA\n" ++
"symbol::SCHD,shares:num:200,open_date::2024-01-15,open_price:num:25,price:num:30,account::Sample Roth\n",
});
try tmp.dir.writeFile(io, .{
.sub_path = "accounts.srf",
.data = "#!srfv1\n" ++
"account::Sample IRA,tax_type::traditional,institution::schwab,account_number::1234\n" ++
"account::Sample Roth,tax_type::roth,institution::schwab,account_number::5678\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";
// 210 shares at the broker vs 200 in the portfolio.
const schd = "\"SCHD\",\"SCHWAB US DIVIDEND EQUITY ETF\",\"0.10\",\"0.33%\",\"30.00\",\"210\",\"$21.00\",\"0.33%\",\"$6,300.00\",\"$5,250.00\",\"$1,050.00\",\"20%\",\"--\",\"No\",\"N/A\",\"100%\",\"ETFs & Closed End Funds\",\n";
try tmp.dir.createDirPath(io, "audit");
try tmp.dir.writeFile(io, .{
.sub_path = "audit/all.csv",
.data = "\"Positions for All-Accounts as of 08:47 AM ET, 10/06/2026\"\n\n" ++
"\"Sample_IRA ...1234\"\n" ++ header ++ vti ++ "\n\n" ++
"\"Sample_Roth ...5678\"\n" ++ header ++ schd,
});
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.
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);
// 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();
// Routine audit: the bad account gets its table, the clean one is
// named on one line, and the totals still cover both.
{
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
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();
try std.testing.expect(std.mem.indexOf(u8, out, " Sample Roth (Sample_Roth, #5678)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Shares +10.000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Sample IRA (Sample_IRA, #1234)") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "\n 1 more account, no discrepancies: Sample IRA\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " 1 mismatch to investigate\n") != null);
}
// --verbose: every account gets its table.
{
var aw: std.Io.Writer.Allocating = .init(allocator);
defer aw.deinit();
try runHygieneCheck(io, allocator, &env, &svc, pf_path, &.{pf_path}, 3, true, zfin.Date.fromYmd(2026, 10, 6), now_s, false, .never, &aw.writer);
const out = aw.written();
try std.testing.expect(std.mem.indexOf(u8, out, " Sample Roth (Sample_Roth, #5678)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, " Sample IRA (Sample_IRA, #1234)\n") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "no discrepancies: ") == null);
try std.testing.expect(std.mem.indexOf(u8, out, " Total: portfolio $36,000.00 brokerage $36,300.00") != null);
}
}