aggregate "missing accounts" check for flagless audit
Some checks failed
Generic zig build / build (push) Failing after 31s
Generic zig build / deploy (push) Has been skipped
Generic zig build / publish-macos (push) Has been skipped

This commit is contained in:
Emil Lerch 2026-06-27 12:23:54 -07:00
parent 0882e6321f
commit 378c0d0e84
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 130 additions and 26 deletions

View file

@ -205,7 +205,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, out);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
// Fidelity CSV
@ -237,7 +237,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, out);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
// Schwab per-account CSV
@ -269,7 +269,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, out);
try common.displayAbsentAccounts(absent, color, "this export", out);
}
}

View file

@ -863,14 +863,19 @@ pub fn findAbsentAccounts(
return results.toOwnedSlice(allocator);
}
/// Render the "portfolio accounts not found in this export" advisory.
/// Render the "portfolio accounts not found in <scope>" advisory.
/// Silent when `absent` is empty, so it composes cleanly after both
/// the verbose audit table and the compact "no discrepancies" line.
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, out: *std.Io.Writer) !void {
///
/// `scope_phrase` names what the absence is relative to: the explicit
/// single-file command passes "this export"; the flagless audit, which
/// unions the present accounts across every discovered file before
/// computing absence, passes "any export".
pub fn displayAbsentAccounts(absent: []const AbsentAccount, color: bool, scope_phrase: []const u8, out: *std.Io.Writer) !void {
if (absent.len == 0) return;
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in this export", .{});
try cli.printFg(out, color, cli.CLR_WARNING, " Portfolio accounts not found in {s}", .{scope_phrase});
try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{});
for (absent) |a| {
try out.print(" ", .{});
@ -1652,7 +1657,7 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
// Empty -> no output.
{
var w = std.Io.Writer.fixed(&buf);
try displayAbsentAccounts(&.{}, false, &w);
try displayAbsentAccounts(&.{}, false, "this export", &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
@ -1662,11 +1667,21 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
const absent = [_]AbsentAccount{
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
};
try displayAbsentAccounts(&absent, false, &w);
try displayAbsentAccounts(&absent, false, "this export", &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "not found in this export") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != null);
}
// The scope phrase is caller-supplied (flagless audit passes "any export").
{
var w = std.Io.Writer.fixed(&buf);
const absent = [_]AbsentAccount{
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
};
try displayAbsentAccounts(&absent, false, "any export", &w);
try std.testing.expect(std.mem.indexOf(u8, w.buffered(), "not found in any export") != null);
}
}

View file

@ -499,6 +499,27 @@ fn printLargeLotWarning(
}
}
/// Append the account numbers present in `results` to `dst`, duping
/// each string into `allocator` so it outlives the per-file buffer the
/// reconciler's result slices borrow from. Used by the flagless audit
/// to union the present accounts across every discovered export before
/// computing the "accounts not found in any export" advisory once, so a
/// single-account positions CSV no longer flags every sibling account
/// as missing. `T` is the reconciler's comparison type
/// (`common.AccountComparison` or `schwab.SchwabAccountComparison`) -
/// both expose an `account_number` field, which `common.presentNumbers`
/// collects.
fn accumulatePresent(
allocator: std.mem.Allocator,
dst: *std.ArrayList([]const u8),
comptime T: type,
results: []const T,
) !void {
const present = try common.presentNumbers(allocator, T, results);
defer allocator.free(present);
for (present) |num| try dst.append(allocator, try allocator.dupe(u8, num));
}
/// Run the flagless portfolio hygiene check.
pub fn runHygieneCheck(
io: std.Io,
@ -860,6 +881,23 @@ pub fn runHygieneCheck(
try out.print("\n", .{});
try cli.printBold(out, color, " Reconciliation\n", .{});
// Present account numbers per institution, unioned across every
// discovered file. The "accounts not found" advisory is computed
// once from these unions AFTER the loop - not per file - so a
// single-account positions CSV no longer flags every other
// account in the institution as missing (it's present in a
// sibling export or the summary). Strings are duped because the
// borrowed account-number slices point into each file's
// `file_data`, which is freed per loop iteration.
var fidelity_present: std.ArrayList([]const u8) = .empty;
var schwab_present: std.ArrayList([]const u8) = .empty;
defer {
for (fidelity_present.items) |s| allocator.free(s);
fidelity_present.deinit(allocator);
for (schwab_present.items) |s| allocator.free(s);
schwab_present.deinit(allocator);
}
for (all_files.items) |f| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
defer allocator.free(file_data);
@ -869,11 +907,6 @@ pub fn runHygieneCheck(
const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
defer allocator.free(results);
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try schwab.displaySchwabResults(results, color, out);
@ -889,7 +922,8 @@ pub fn runHygieneCheck(
// non-zero delta that still deserves a nudge.
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
try common.displayAbsentAccounts(absent, color, out);
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
},
.fidelity_csv => {
const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
@ -898,11 +932,6 @@ pub fn runHygieneCheck(
allocator.free(results);
}
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
defer allocator.free(absent);
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
@ -912,7 +941,8 @@ pub fn runHygieneCheck(
// Always show ratio suggestions even in compact mode
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
try common.displayAbsentAccounts(absent, 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 continue;
@ -921,11 +951,6 @@ pub fn runHygieneCheck(
allocator.free(results);
}
const present = try common.presentNumbers(allocator, common.AccountComparison, results);
defer allocator.free(present);
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
defer allocator.free(absent);
if (verbose or common.hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try common.displayResults(results, color, out);
@ -934,10 +959,27 @@ pub fn runHygieneCheck(
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
try common.displayAbsentAccounts(absent, color, out);
try accumulatePresent(allocator, &schwab_present, common.AccountComparison, results);
},
}
}
// One advisory per institution that contributed an export,
// computed against the unioned present-set. "any export"
// reflects that absence is now relative to ALL discovered files
// (see `displayAbsentAccounts`), so an account covered by any
// sibling CSV or the summary no longer surfaces here.
if (fidelity_present.items.len > 0) {
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", fidelity_present.items, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "any export", out);
}
if (schwab_present.items.len > 0) {
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", schwab_present.items, prices, as_of);
defer allocator.free(absent);
try common.displayAbsentAccounts(absent, color, "any export", out);
}
}
// Section 5: Large new lots - confirm source
@ -970,6 +1012,53 @@ pub fn runHygieneCheck(
// Tests
test "accumulatePresent: unions account numbers across calls" {
const allocator = std.testing.allocator;
var dst: std.ArrayList([]const u8) = .empty;
defer {
for (dst.items) |s| allocator.free(s);
dst.deinit(allocator);
}
const batch1 = [_]common.AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
const batch2 = [_]common.AccountComparison{
.{ .account_name = "Sample Brokerage", .brokerage_name = "Brokerage", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch1);
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch2);
try std.testing.expectEqual(@as(usize, 2), dst.items.len);
try std.testing.expectEqualStrings("1234", dst.items[0]);
try std.testing.expectEqualStrings("5678", dst.items[1]);
}
test "accumulatePresent: result strings are owned copies (survive source free)" {
// The whole point of duping: the borrowed account_number points into
// a per-file buffer freed each loop iteration. Prove the accumulator
// keeps its own copy by freeing the source out from under it.
const allocator = std.testing.allocator;
var dst: std.ArrayList([]const u8) = .empty;
defer {
for (dst.items) |s| allocator.free(s);
dst.deinit(allocator);
}
const num = try allocator.dupe(u8, "9012");
{
const batch = [_]common.AccountComparison{
.{ .account_name = "X", .brokerage_name = "X", .account_number = num, .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
try accumulatePresent(allocator, &dst, common.AccountComparison, &batch);
}
allocator.free(num); // source gone; dst must hold its own copy
try std.testing.expectEqual(@as(usize, 1), dst.items.len);
try std.testing.expectEqualStrings("9012", dst.items[0]);
}
test "detectBrokerFileKind: fidelity csv" {
const fidelity_header = "Account Number,Account Name,Symbol,Description";
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);