aggregate "missing accounts" check for flagless audit
This commit is contained in:
parent
0882e6321f
commit
378c0d0e84
3 changed files with 130 additions and 26 deletions
|
|
@ -205,7 +205,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
||||||
defer allocator.free(absent);
|
defer allocator.free(absent);
|
||||||
try common.displayAbsentAccounts(absent, color, out);
|
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fidelity CSV
|
// Fidelity CSV
|
||||||
|
|
@ -237,7 +237,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
|
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of);
|
||||||
defer allocator.free(absent);
|
defer allocator.free(absent);
|
||||||
try common.displayAbsentAccounts(absent, color, out);
|
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schwab per-account CSV
|
// Schwab per-account CSV
|
||||||
|
|
@ -269,7 +269,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of);
|
||||||
defer allocator.free(absent);
|
defer allocator.free(absent);
|
||||||
try common.displayAbsentAccounts(absent, color, out);
|
try common.displayAbsentAccounts(absent, color, "this export", out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -863,14 +863,19 @@ pub fn findAbsentAccounts(
|
||||||
return results.toOwnedSlice(allocator);
|
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
|
/// Silent when `absent` is empty, so it composes cleanly after both
|
||||||
/// the verbose audit table and the compact "no discrepancies" line.
|
/// 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;
|
if (absent.len == 0) return;
|
||||||
|
|
||||||
try out.print("\n", .{});
|
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", .{});
|
try cli.printFg(out, color, cli.CLR_MUTED, " (stale, or not exported?)\n", .{});
|
||||||
for (absent) |a| {
|
for (absent) |a| {
|
||||||
try out.print(" ", .{});
|
try out.print(" ", .{});
|
||||||
|
|
@ -1652,7 +1657,7 @@ test "displayAbsentAccounts: silent when empty, renders names + totals otherwise
|
||||||
// Empty -> no output.
|
// Empty -> no output.
|
||||||
{
|
{
|
||||||
var w = std.Io.Writer.fixed(&buf);
|
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);
|
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{
|
const absent = [_]AbsentAccount{
|
||||||
.{ .account_name = "Sample IRA", .account_number = "1234", .portfolio_total = 2100.0 },
|
.{ .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();
|
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, "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, "Sample IRA") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "#1234") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "$2,100") != 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Run the flagless portfolio hygiene check.
|
||||||
pub fn runHygieneCheck(
|
pub fn runHygieneCheck(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
|
|
@ -860,6 +881,23 @@ pub fn runHygieneCheck(
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try cli.printBold(out, color, " Reconciliation\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| {
|
for (all_files.items) |f| {
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
|
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
|
||||||
defer allocator.free(file_data);
|
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;
|
const results = schwab.reconcileSummary(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
|
||||||
defer allocator.free(results);
|
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)) {
|
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
try schwab.displaySchwabResults(results, color, out);
|
||||||
|
|
@ -889,7 +922,8 @@ pub fn runHygieneCheck(
|
||||||
// non-zero delta that still deserves a nudge.
|
// non-zero delta that still deserves a nudge.
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
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 => {
|
.fidelity_csv => {
|
||||||
const results = fidelity.reconcile(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
|
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);
|
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)) {
|
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try common.displayResults(results, color, out);
|
try common.displayResults(results, color, out);
|
||||||
|
|
@ -912,7 +941,8 @@ pub fn runHygieneCheck(
|
||||||
// Always show ratio suggestions even in compact mode
|
// Always show ratio suggestions even in compact mode
|
||||||
try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
|
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 => {
|
.schwab_csv => {
|
||||||
const results = schwab.reconcileCsv(allocator, portfolio, file_data, account_map, prices, as_of) catch continue;
|
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);
|
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)) {
|
if (verbose or common.hasAccountDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try common.displayResults(results, color, out);
|
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 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.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 ──
|
// ── Section 5: Large new lots - confirm source ──
|
||||||
|
|
@ -970,6 +1012,53 @@ pub fn runHygieneCheck(
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── 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" {
|
test "detectBrokerFileKind: fidelity csv" {
|
||||||
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
||||||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue