audit: avoid displaying reconciled accounts within multi-account files (other accounts same file have discrepancies)
This commit is contained in:
parent
d5459ff39e
commit
d7917473e8
6 changed files with 404 additions and 25 deletions
|
|
@ -338,7 +338,21 @@ Portfolio Audit (brokerage is source of truth)
|
|||
- **Brokerage-only** rows are holdings missing from your portfolio.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ discrepancies.
|
|||
|
||||
| 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). |
|
||||
| `--fidelity <CSV>` | Fidelity positions CSV ("All accounts" -> Positions tab -> Download). |
|
||||
| `--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
|
||||
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
|
||||
$10,000 in a **Large new lots - confirm source** section, so you can
|
||||
confirm whether each is a real contribution or an unrecorded transfer.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// 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, .{
|
||||
.symbol = bp.symbol,
|
||||
.portfolio_shares = pf_shares,
|
||||
|
|
@ -502,7 +511,7 @@ pub fn compareAccounts(
|
|||
.is_option = is_option,
|
||||
.is_cd = is_cd,
|
||||
.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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
}
|
||||
|
||||
/// 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
|
||||
/// (see `common.splitUnmapped`). The unmapped list is printed even for an
|
||||
/// export skipped as another portfolio's - you pointed audit at this
|
||||
|
|
@ -365,7 +366,7 @@ fn showAccountExport(
|
|||
) !void {
|
||||
const view = common.splitUnmapped(common.AccountComparison, results, scope);
|
||||
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.displayUnmapped(common.AccountComparison, label, view, true, color, out);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ pub const accountValueExpectation = reconcile.accountValueExpectation;
|
|||
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;
|
||||
|
|
@ -129,7 +128,84 @@ pub fn displayRatioSuggestions(
|
|||
|
||||
// ── 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.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\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_option_delta: f64 = 0;
|
||||
var discrepancy_count: usize = 0;
|
||||
var shown: usize = 0;
|
||||
var hidden: usize = 0;
|
||||
|
||||
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
|
||||
if (acct.account_name.len > 0) {
|
||||
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;
|
||||
|
||||
// Classify this row
|
||||
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);
|
||||
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch or cd_value_exceeded;
|
||||
const class = classifyRow(cmp);
|
||||
const shares_ok = class.shares_ok;
|
||||
const is_cash_mismatch = class.is_cash_mismatch;
|
||||
const is_real_mismatch = class.real_mismatch;
|
||||
|
||||
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 out.print("\n\n", .{});
|
||||
}
|
||||
|
||||
total_portfolio += acct.portfolio_total;
|
||||
total_brokerage += acct.brokerage_total;
|
||||
total_option_delta += acct.option_value_delta;
|
||||
// Accounts left out of a `.discrepancies_only` view, named so you can
|
||||
// see they were covered (an account missing from the export entirely
|
||||
// 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
|
||||
|
|
@ -385,7 +482,7 @@ test "displayResults: renders every row classification and the totals block" {
|
|||
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displayResults(&results, false, &w);
|
||||
try displayResults(&results, .all, false, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
// Header + account identity
|
||||
|
|
@ -422,7 +519,7 @@ test "displayResults: color=true emits ANSI and singular mismatch label" {
|
|||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displayResults(&results, true, &w);
|
||||
try displayResults(&results, .all, true, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
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 w: std.Io.Writer = .fixed(&buf);
|
||||
try displayResults(&results, false, &w);
|
||||
try displayResults(&results, .all, false, &w);
|
||||
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, "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 w: std.Io.Writer = .fixed(&buf);
|
||||
try displayResults(&results, false, &w);
|
||||
try displayResults(&results, .all, false, &w);
|
||||
const out = w.buffered();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────
|
||||
|
||||
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {
|
||||
|
|
|
|||
|
|
@ -738,7 +738,8 @@ fn accumulatePresent(
|
|||
}
|
||||
|
||||
/// 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
|
||||
/// `common.splitUnmapped`). Returns false when the export was skipped as
|
||||
/// another portfolio's, so the caller leaves its accounts out of the
|
||||
|
|
@ -761,9 +762,11 @@ fn reportAccountExport(
|
|||
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 common.displayResults(view.shown, color, out);
|
||||
try common.displayResults(view.shown, if (verbose) .all else .discrepancies_only, 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) });
|
||||
}
|
||||
|
|
@ -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 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue