zfin/src/commands/audit/common.zig

1672 lines
79 KiB
Zig

//! Shared reconciliation engine for the `audit` command.
//!
//! Holds the broker-agnostic pieces every per-account positions
//! reconciler needs: the normalized comparison types
//! (`SymbolComparison` / `AccountComparison`), the
//! portfolio-vs-export comparator (`compareAccounts`, parameterized
//! by institution string), the price-provenance helper
//! (`resolvePositionValue`), and the CSV-style display
//! (`displayResults` / `displayRatioSuggestions`).
//!
//! Per-broker modules (`fidelity.zig`, `schwab.zig`) consume their
//! `brokerage/*` parser and feed the parsed positions into this
//! engine. The Schwab-summary path is the one genuinely
//! broker-specific reconciler and lives in `schwab.zig`.
const std = @import("std");
const zfin = @import("../../root.zig");
const cli = @import("../common.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const brokerage_types = @import("../../brokerage/types.zig");
const portfolio_mod = @import("../../models/portfolio.zig");
const option = @import("../../models/option.zig");
const Date = @import("../../Date.zig");
const BrokeragePosition = brokerage_types.BrokeragePosition;
/// Reconciliation match tolerances.
///
/// Securities get $1 of slack to absorb NAV-rounding on large
/// positions: a sub-cent per-share NAV difference between the broker
/// and zfin's fetched price on a six-figure mutual-fund position
/// easily exceeds a dollar, and that's not an actionable discrepancy.
///
/// Cash is different - it has no NAV and no share count, it's an exact
/// dollar figure on both sides. It must match to the penny; the $1
/// securities slack would otherwise silently hide real money-market
/// dividend accrual between updates (the whole point of the audit).
pub const value_tolerance: f64 = 1.0;
pub const cash_tolerance: f64 = 0.01;
/// Resolved position value for audit display: effective per-share price
/// and total market value, with correct `price_ratio` handling based on
/// the price's provenance.
///
/// Two sources feed `prices`:
/// 1. Live candle close - NOT preadjusted for the lot's share class,
/// so `price_ratio` must be applied.
/// 2. `pos.avg_cost` fallback - already in the lot's share-class
/// terms (user paid institutional-class prices to open the lot),
/// so `price_ratio` must be skipped.
///
/// See the "Pricing model" block in `models/portfolio.zig` for the full
/// treatment. This helper is the audit-side mirror of the snapshot
/// side's `buildFallbackPrices` + `manual_set` pair.
const ResolvedValue = struct { price: f64, value: f64 };
fn resolvePositionValue(pos: zfin.Position, prices: std.StringHashMap(f64)) ResolvedValue {
if (prices.get(pos.symbol)) |live| {
return .{
.price = pos.effectivePrice(live, false),
.value = pos.marketValue(live, false),
};
}
// Fallback: avg_cost. Already preadjusted.
return .{
.price = pos.effectivePrice(pos.avg_cost, true),
.value = pos.marketValue(pos.avg_cost, true),
};
}
// ── Audit logic ─────────────────────────────────────────────
/// Comparison result for a single symbol within an account.
pub const SymbolComparison = struct {
symbol: []const u8,
portfolio_shares: f64,
brokerage_shares: ?f64,
portfolio_price: ?f64,
brokerage_price: ?f64,
portfolio_value: f64,
brokerage_value: ?f64,
shares_delta: ?f64,
value_delta: ?f64,
is_cash: bool,
is_option: bool,
only_in_brokerage: bool,
only_in_portfolio: bool,
};
/// Comparison result for a single account.
pub const AccountComparison = struct {
account_name: []const u8,
brokerage_name: []const u8,
account_number: []const u8,
comparisons: []const SymbolComparison,
portfolio_total: f64,
brokerage_total: f64,
total_delta: f64,
option_value_delta: f64,
has_discrepancies: bool,
};
/// Consolidate broker rows that share a symbol within the same
/// account into a single position. Some brokers split a single
/// stock holding into separate "Cash" and "Margin" rows for the
/// same ticker in the same account - Fidelity does this when a
/// freshly-credited lot (e.g. an RSU distribution) hasn't yet
/// cleared settlement (T+1 / T+2) and is therefore considered
/// un-marginable, while the older settled shares stay in the
/// margin sub-account. Without consolidation, the audit would
/// double-count when matching against the portfolio's
/// account-level aggregate.
///
/// Aggregation rules:
/// - `quantity` and `current_value` are summed across rows
/// (treating null as 0 for the sum, but preserving null when
/// no row supplied a value).
/// - `cost_basis` is summed the same way.
/// - `is_cash` is OR-ed across rows: any cash row in the group
/// marks the consolidated entry as cash. In practice a single
/// symbol is either always-cash (money market) or never (stock),
/// so this is just defensive.
/// - `account_number`, `account_name`, `description` are taken
/// from the first row in the group.
///
/// Caller owns the returned ArrayList.
fn consolidateBySymbol(
allocator: std.mem.Allocator,
rows: []const BrokeragePosition,
) !std.ArrayList(BrokeragePosition) {
var by_symbol = std.StringHashMap(usize).init(allocator);
defer by_symbol.deinit();
var out: std.ArrayList(BrokeragePosition) = .empty;
errdefer out.deinit(allocator);
for (rows) |bp| {
if (by_symbol.get(bp.symbol)) |idx| {
const existing = &out.items[idx];
// Sum quantity (null + value = value; null + null = null).
existing.quantity = sumOptional(existing.quantity, bp.quantity);
existing.current_value = sumOptional(existing.current_value, bp.current_value);
existing.cost_basis = sumOptional(existing.cost_basis, bp.cost_basis);
existing.is_cash = existing.is_cash or bp.is_cash;
} else {
try by_symbol.put(bp.symbol, out.items.len);
try out.append(allocator, bp);
}
}
return out;
}
fn sumOptional(a: ?f64, b: ?f64) ?f64 {
if (a == null and b == null) return null;
return (a orelse 0) + (b orelse 0);
}
/// Build per-account comparisons between portfolio.srf and brokerage data.
pub fn compareAccounts(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
brokerage_positions: []const BrokeragePosition,
account_map: analysis.AccountMap,
institution: []const u8,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]AccountComparison {
var results = std.ArrayList(AccountComparison).empty;
errdefer results.deinit(allocator);
// Group brokerage positions by account number
var brokerage_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator);
defer {
var it = brokerage_accounts.valueIterator();
while (it.next()) |v| v.deinit(allocator);
brokerage_accounts.deinit();
}
for (brokerage_positions) |bp| {
const entry = try brokerage_accounts.getOrPut(bp.account_number);
if (!entry.found_existing) {
entry.value_ptr.* = .empty;
}
try entry.value_ptr.append(allocator, bp);
}
// Aggregate same-symbol rows within each account. Some brokers
// report a single security as multiple rows when a position
// straddles sub-account contexts. The motivating case is
// Fidelity's margin-eligible accounts: when a freshly-credited
// lot (e.g. an RSU distribution) hasn't yet cleared settlement
// (T+1 / T+2), Fidelity classifies the new shares as
// un-marginable "Cash" and the older settled shares as
// "Margin", reporting them as two CSV rows for the same
// ticker in the same account number. Once settlement clears,
// the rows usually consolidate back into one - but until
// then, the audit needs to consolidate them itself, otherwise
// it'd match each broker row independently against the
// (already-aggregated) portfolio total and report a phantom
// discrepancy on every duplicate. Aggregating here lets the
// rest of the comparator stay (account, symbol)-keyed
// regardless of how the broker chose to slice the rows.
var consolidated_accounts = std.StringHashMap(std.ArrayList(BrokeragePosition)).init(allocator);
defer {
var it = consolidated_accounts.valueIterator();
while (it.next()) |v| v.deinit(allocator);
consolidated_accounts.deinit();
}
{
var acct_it = brokerage_accounts.iterator();
while (acct_it.next()) |kv| {
const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items);
try consolidated_accounts.put(kv.key_ptr.*, consolidated);
}
}
// For each brokerage account, find the matching portfolio account and compare
var acct_iter = consolidated_accounts.iterator();
while (acct_iter.next()) |kv| {
const acct_num = kv.key_ptr.*;
const broker_positions = kv.value_ptr.items;
if (broker_positions.len == 0) continue;
const broker_name = broker_positions[0].account_name;
const portfolio_acct_name = account_map.findByInstitutionAccount(institution, acct_num);
var comparisons = std.ArrayList(SymbolComparison).empty;
errdefer comparisons.deinit(allocator);
var portfolio_total: f64 = 0;
var brokerage_total: f64 = 0;
var option_value_delta: f64 = 0;
var has_discrepancies = false;
// Track which portfolio symbols we've matched
var matched_symbols = std.StringHashMap(void).init(allocator);
defer matched_symbols.deinit();
// Compare each brokerage position against portfolio
for (broker_positions) |bp| {
const bp_value = bp.current_value orelse 0;
brokerage_total += bp_value;
if (portfolio_acct_name == 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;
try comparisons.append(allocator, .{
.symbol = bp.symbol,
.portfolio_shares = 0,
.brokerage_shares = bp.quantity,
.portfolio_price = null,
.brokerage_price = br_price,
.portfolio_value = 0,
.brokerage_value = bp.current_value,
.shares_delta = if (bp.quantity) |q| q else null,
.value_delta = bp.current_value,
.is_cash = bp.is_cash,
.is_option = false,
.only_in_brokerage = true,
.only_in_portfolio = false,
});
has_discrepancies = true;
continue;
}
// Sum portfolio lots for this symbol+account
var pf_shares: f64 = 0;
var pf_value: f64 = 0;
var pf_price: ?f64 = null;
var is_option = false;
if (bp.is_cash) {
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
pf_value = pf_shares;
} else {
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
defer allocator.free(acct_positions);
var found_stock = false;
for (acct_positions) |pos| {
if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
!std.mem.eql(u8, pos.lot_symbol, bp.symbol))
continue;
pf_shares = pos.shares;
const v = resolvePositionValue(pos, prices);
pf_price = v.price;
pf_value = v.value;
try matched_symbols.put(pos.symbol, {});
try matched_symbols.put(pos.lot_symbol, {});
found_stock = true;
}
if (!found_stock) {
for (portfolio.lots) |lot| {
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue;
if (!lot.isOpen(as_of)) continue;
// Match by exact symbol, or by parsed option components
// (brokers export a compact symbol like "-AMZN260515C220"
// while the portfolio uses "AMZN 05/15/2026 220.00 C")
if (!std.mem.eql(u8, lot.symbol, bp.symbol) and
!option.symbolMatchesLot(bp.symbol, lot)) continue;
switch (lot.security_type) {
.cd => {
pf_shares += lot.shares;
pf_value += lot.shares;
pf_price = 1.0;
},
.option => {
pf_shares += lot.shares;
pf_value += @abs(lot.shares) * lot.open_price * lot.multiplier;
pf_price = lot.open_price * lot.multiplier;
is_option = true;
},
else => {},
}
// Track the lot's own symbol so the portfolio-only pass skips it
try matched_symbols.put(lot.symbol, {});
}
if (pf_shares != 0) try matched_symbols.put(bp.symbol, {});
}
}
try matched_symbols.put(bp.symbol, {});
portfolio_total += pf_value;
const shares_delta = if (bp.quantity) |bq| bq - pf_shares else null;
const value_delta = if (bp.current_value) |bv| bv - pf_value else null;
const shares_match = if (shares_delta) |d| @abs(d) < 0.01 else true;
// Cash matches to the penny; securities get $1 of NAV-rounding slack.
const tol: f64 = if (bp.is_cash) cash_tolerance else value_tolerance;
const value_match = if (value_delta) |d| @abs(d) < tol else true;
// Option value deltas are expected (cost basis vs mark-to-market)
// - track them separately rather than flagging as discrepancies
if (is_option) {
if (value_delta) |d| option_value_delta += d;
if (!shares_match) has_discrepancies = true;
} else {
if (!shares_match or !value_match) has_discrepancies = true;
}
const br_price: ?f64 = if (bp.quantity) |q| if (bp.current_value) |v| if (q != 0) v / q else null else null else null;
try comparisons.append(allocator, .{
.symbol = bp.symbol,
.portfolio_shares = pf_shares,
.brokerage_shares = bp.quantity,
.portfolio_price = pf_price,
.brokerage_price = br_price,
.portfolio_value = pf_value,
.brokerage_value = bp.current_value,
.shares_delta = shares_delta,
.value_delta = value_delta,
.is_cash = bp.is_cash,
.is_option = is_option,
.only_in_brokerage = pf_shares == 0 and pf_value == 0,
.only_in_portfolio = false,
});
}
// Find portfolio-only positions (in portfolio but not in brokerage)
if (portfolio_acct_name) |pa| {
const acct_positions = portfolio.positionsForAccount(as_of, allocator, pa) catch &.{};
defer allocator.free(acct_positions);
for (acct_positions) |pos| {
if (matched_symbols.contains(pos.symbol)) continue;
if (matched_symbols.contains(pos.lot_symbol)) continue;
try matched_symbols.put(pos.symbol, {});
const v = resolvePositionValue(pos, prices);
const mv = v.value;
portfolio_total += mv;
has_discrepancies = true;
try comparisons.append(allocator, .{
.symbol = pos.symbol,
.portfolio_shares = pos.shares,
.brokerage_shares = null,
.portfolio_price = v.price,
.brokerage_price = null,
.portfolio_value = mv,
.brokerage_value = null,
.shares_delta = null,
.value_delta = null,
.is_cash = false,
.is_option = false,
.only_in_brokerage = false,
.only_in_portfolio = true,
});
}
// Portfolio-only CDs and options
for (portfolio.lots) |lot| {
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, pa)) continue;
if (!lot.isOpen(as_of)) continue;
if (lot.security_type != .cd and lot.security_type != .option) continue;
if (matched_symbols.contains(lot.symbol)) continue;
try matched_symbols.put(lot.symbol, {});
var pf_shares: f64 = 0;
var pf_value: f64 = 0;
var pf_price: ?f64 = null;
var is_cd = false;
// Aggregate all lots with same symbol in this account
for (portfolio.lots) |lot2| {
const la2 = lot2.account orelse continue;
if (!std.mem.eql(u8, la2, pa)) continue;
if (!lot2.isOpen(as_of)) continue;
if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue;
switch (lot2.security_type) {
.cd => {
pf_shares += lot2.shares;
pf_value += lot2.shares;
pf_price = 1.0;
is_cd = true;
},
.option => {
pf_shares += lot2.shares;
pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier;
pf_price = lot2.open_price * lot2.multiplier;
},
else => {},
}
}
if (pf_value != 0 or pf_shares != 0) {
portfolio_total += pf_value;
has_discrepancies = true;
try comparisons.append(allocator, .{
.symbol = lot.symbol,
.portfolio_shares = pf_shares,
.brokerage_shares = null,
.portfolio_price = pf_price,
.brokerage_price = null,
.portfolio_value = pf_value,
.brokerage_value = null,
.shares_delta = null,
.value_delta = null,
.is_cash = is_cd,
.is_option = !is_cd,
.only_in_brokerage = false,
.only_in_portfolio = true,
});
}
}
}
try results.append(allocator, .{
.account_name = portfolio_acct_name orelse "",
.brokerage_name = broker_name,
.account_number = acct_num,
.comparisons = try comparisons.toOwnedSlice(allocator),
.portfolio_total = portfolio_total,
.brokerage_total = brokerage_total,
.total_delta = brokerage_total - portfolio_total,
.option_value_delta = option_value_delta,
.has_discrepancies = has_discrepancies,
});
}
return results.toOwnedSlice(allocator);
}
// ── Ratio suggestions ────────────────────────────────────────
/// After displaying audit results, check for price_ratio positions where
/// the brokerage NAV implies a different ratio than what's configured.
/// Outputs actionable suggestions for portfolio.srf updates.
///
/// Normally only lots with `price_ratio != 1.0` get suggestions -
/// the typical case is an institutional share class where the
/// configured ratio needs to drift toward current retail-vs-
/// institutional NAV. Lots with `price_ratio == 1.0` usually have
/// nothing to adjust.
///
/// Exception: accounts flagged `direct_indexing::true` in
/// `accounts.srf`. These are proxy baskets whose tracking-error
/// drift is expressed by periodically nudging the ratio even
/// though the starting ratio is 1.0. For those accounts we still
/// emit a suggestion when brokerage and portfolio values disagree
/// - the suggested ratio is just `brokerage_NAV / retail_price`
/// applied against the existing lot share count, same formula as
/// the institutional-class case.
pub fn displayRatioSuggestions(
results: []const AccountComparison,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: ?analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
var has_header = false;
for (results) |acct| {
for (acct.comparisons) |cmp| {
// Skip unmatched, cash, and option rows
if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue;
if (cmp.is_cash or cmp.is_option) continue;
// Is this account flagged direct-indexing? Captured once
// per outer loop so the per-lot gate can skip the
// ratio == 1.0 check for flagged accounts.
const is_direct_indexing = if (account_map) |am|
am.isDirectIndexing(acct.account_name)
else
false;
// Find the portfolio lot(s) for this symbol with price_ratio != 1.0
// (or any ratio, for direct-indexing accounts).
for (portfolio.lots) |lot| {
if (lot.price_ratio == 1.0 and !is_direct_indexing) continue;
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue;
// Match by lot_symbol (CUSIP) or ticker against brokerage symbol
const lot_sym = lot.symbol;
const price_sym = lot.priceSymbol();
if (!std.mem.eql(u8, lot_sym, cmp.symbol) and
!std.mem.eql(u8, price_sym, cmp.symbol)) continue;
// Get the retail price from cache
const retail_price = prices.get(price_sym) orelse continue;
// Brokerage price is the institutional NAV per share
const inst_nav = cmp.brokerage_price orelse continue;
if (retail_price == 0) continue;
const current_ratio = lot.price_ratio;
const suggested_ratio = inst_nav / retail_price;
const drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0;
// Only suggest if drift is meaningful (> 0.01%)
if (current_ratio == suggested_ratio) break;
if (!has_header) {
try out.print("\n", .{});
try cli.printBold(out, color, " Ratio updates", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf)\n", .{});
has_header = true;
}
var cur_buf: [24]u8 = undefined;
var sug_buf: [24]u8 = undefined;
var drift_buf: [16]u8 = undefined;
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{current_ratio}) catch "?";
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?";
try out.print(" {s:<16} ", .{lot_sym});
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
try out.print(" ratio {s} -> ", .{cur_str});
try cli.printBold(out, color, "{s}", .{sug_str});
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
break; // One suggestion per symbol
}
}
}
if (has_header) try out.print("\n", .{});
}
// ── Display ─────────────────────────────────────────────────
pub fn displayResults(results: []const AccountComparison, 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", .{});
var total_portfolio: f64 = 0;
var total_brokerage: f64 = 0;
var total_option_delta: f64 = 0;
var discrepancy_count: usize = 0;
for (results) |acct| {
// Account header
if (acct.account_name.len > 0) {
try cli.printBold(out, color, " {s}", .{acct.account_name});
try cli.printFg(out, color, cli.CLR_MUTED, " ({s}, #{s})\n", .{ acct.brokerage_name, acct.account_number });
} else {
try cli.printBold(out, color, " {s} #{s}", .{ acct.brokerage_name, acct.account_number });
try cli.printFg(out, color, cli.CLR_WARNING, " (unmapped - add account_number to accounts.srf)\n", .{});
}
// Column headers
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{
"Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "",
});
for (acct.comparisons) |cmp| {
var pf_shares_buf: [16]u8 = undefined;
var br_shares_buf: [16]u8 = undefined;
var pf_price_buf: [16]u8 = undefined;
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);
const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch;
if (is_real_mismatch) discrepancy_count += 1;
// Format share strings
const pf_shares_str: []const u8 = if (cmp.is_cash)
(std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?")
else
std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?";
const br_shares_str: []const u8 = if (cmp.is_cash)
(if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--")
else if (cmp.brokerage_shares) |s|
(std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?")
else
"--";
const pf_price_str: []const u8 = if (cmp.is_cash)
""
else if (cmp.portfolio_price) |p|
(std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?")
else
"--";
const br_price_str: []const u8 = if (cmp.is_cash)
""
else if (cmp.brokerage_price) |p|
(std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?")
else
"--";
// Determine PF Shares color (relative to brokerage)
const shares_color: enum { normal, positive, negative, warning } = blk: {
if (cmp.only_in_portfolio) break :blk .warning;
if (cmp.is_cash and is_cash_mismatch) {
break :blk if (cmp.value_delta.? > 0) .negative else .positive;
}
if (shares_ok) break :blk .normal;
if (cmp.shares_delta) |d| {
break :blk if (d > 0) .negative else .positive;
}
break :blk .normal;
};
// Determine status text
var status_buf: [64]u8 = undefined;
const status: []const u8 = blk: {
if (cmp.only_in_brokerage) break :blk "Brokerage only";
if (cmp.only_in_portfolio) break :blk "Portfolio only";
if (is_cash_mismatch) {
if (cmp.value_delta) |d| {
const sign: []const u8 = if (d >= 0) "+" else "-";
break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch";
}
}
if (!shares_ok) {
if (cmp.shares_delta) |d| {
const sign: []const u8 = if (d > 0) "+" else "";
break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch";
}
}
// Options: shares match is sufficient - value delta is expected
// (cost basis vs mark-to-market) and not actionable
if (cmp.is_option) break :blk "Option";
// Shares match - show value delta (stale price) if any, muted
if (cmp.value_delta) |d| {
if (@abs(d) >= value_tolerance) {
const sign: []const u8 = if (d >= 0) "+" else "-";
break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "";
}
}
break :blk "";
};
// Status color: real mismatches in warning, stale values muted, ok is blank
const status_color: enum { warning, muted, none } = blk: {
if (is_real_mismatch) break :blk .warning;
if (status.len > 0) break :blk .muted;
break :blk .none;
};
// Print symbol
try out.print(" {s:<24} ", .{cmp.symbol});
// Print PF Shares with color
switch (shares_color) {
.positive => try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{pf_shares_str}),
.negative => try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s:>12}", .{pf_shares_str}),
.warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s:>12}", .{pf_shares_str}),
.normal => try out.print("{s:>12}", .{pf_shares_str}),
}
// Print BR Shares, prices
try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str });
// Print status
switch (status_color) {
.warning => try cli.printFg(out, color, cli.CLR_WARNING, "{s}\n", .{status}),
.muted => try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{status}),
.none => try out.print("{s}\n", .{status}),
}
}
// Account totals
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {f} {f} ", .{
"", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10),
});
const adj_delta = acct.total_delta - acct.option_value_delta;
if (@abs(adj_delta) < 1.0) {
// no delta text needed
} else {
const sign: []const u8 = if (adj_delta >= 0) "+" else "-";
const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) });
}
if (@abs(acct.option_value_delta) >= 1.0) {
const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-";
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;
}
// Grand totals
const grand_delta = total_brokerage - total_portfolio;
const grand_adj_delta = grand_delta - total_option_delta;
try cli.printBold(out, color, " Total: portfolio {f} brokerage {f}", .{
Money.from(total_portfolio),
Money.from(total_brokerage),
});
if (@abs(grand_adj_delta) < 1.0) {
// no delta text needed
} else {
const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-";
const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) });
}
if (@abs(total_option_delta) >= 1.0) {
const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) });
}
try out.print("\n", .{});
if (discrepancy_count > 0) {
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") });
}
try out.print("\n", .{});
}
/// Check if any account comparison results have discrepancies.
pub fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
for (results) |r| {
if (r.has_discrepancies) return true;
}
return false;
}
// ── Portfolio accounts absent from the export ────────────────
/// A portfolio account that maps to the institution under audit and
/// still holds open lots as-of, but whose account number never
/// appeared in the brokerage export. Surfaced as an advisory so an
/// account that was dropped from the download (or simply never
/// exported) doesn't reconcile silently. See `findAbsentAccounts`.
pub const AbsentAccount = struct {
/// Portfolio account name. Borrows from the account map.
account_name: []const u8,
/// Mapped account number. Borrows from the account map.
account_number: []const u8,
/// Current value of the account's open holdings as-of, for context.
portfolio_total: f64,
};
/// Collect the account numbers carried by a set of comparison
/// results. Monomorphized over the known result types that expose an
/// `account_number` field (`AccountComparison`, `SchwabAccountComparison`)
/// so the same membership input feeds `findAbsentAccounts` regardless
/// of which reconciler produced the results. Caller owns the slice;
/// the elements borrow from `results`.
pub fn presentNumbers(allocator: std.mem.Allocator, comptime T: type, results: []const T) ![][]const u8 {
var nums: std.ArrayList([]const u8) = .empty;
errdefer nums.deinit(allocator);
for (results) |r| try nums.append(allocator, r.account_number);
return nums.toOwnedSlice(allocator);
}
/// Find portfolio accounts mapped to `institution` that still hold
/// open lots as-of but whose account number is absent from
/// `present_numbers` (the numbers that appeared in the brokerage
/// export).
///
/// This closes a long-standing asymmetry: `compareAccounts` and
/// `compareSchwabSummary` walk export -> portfolio only, so an account
/// you hold that the export dropped (forgotten in the download, or
/// silently removed by the broker) reconciles to nothing and the
/// audit says nothing. Walking the other direction here surfaces it.
///
/// Gating: only entries whose `institution` matches are considered, so
/// a Fidelity export never flags Schwab accounts. Suppression:
/// fully-closed / zero-balance accounts (no open lots as-of) are
/// skipped - a dropped account is only actionable if you still hold
/// something in it. Entries with no `account_number` are skipped too:
/// without a number they can't be matched to an export row anyway.
///
/// Caller owns the returned slice. The string fields borrow from
/// `account_map`, which must outlive the result.
pub fn findAbsentAccounts(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
account_map: analysis.AccountMap,
institution: []const u8,
present_numbers: []const []const u8,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]AbsentAccount {
var results: std.ArrayList(AbsentAccount) = .empty;
errdefer results.deinit(allocator);
for (account_map.entries) |e| {
const inst = e.institution orelse continue;
if (!std.mem.eql(u8, inst, institution)) continue;
const num = e.account_number orelse continue;
// Present in the export? The export -> portfolio pass covered it.
var present = false;
for (present_numbers) |pn| {
if (std.mem.eql(u8, pn, num)) {
present = true;
break;
}
}
if (present) continue;
// Nothing held as-of -> nothing to reconcile. Suppress.
if (!portfolio.hasOpenLotsForAccount(as_of, e.account)) continue;
try results.append(allocator, .{
.account_name = e.account,
.account_number = num,
.portfolio_total = portfolio.totalForAccount(as_of, allocator, e.account, prices),
});
}
return results.toOwnedSlice(allocator);
}
/// Render the "portfolio accounts not found in this export" 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 {
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_MUTED, " (stale, or not exported?)\n", .{});
for (absent) |a| {
try out.print(" ", .{});
try cli.printFg(out, color, cli.CLR_WARNING, "{s}", .{a.account_name});
try cli.printFg(out, color, cli.CLR_MUTED, " (#{s})", .{a.account_number});
try out.print(" {f}\n", .{Money.from(a.portfolio_total)});
}
try out.print("\n", .{});
}
// ── Tests ────────────────────────────────────────────────────
test "consolidateBySymbol: distinct symbols pass through unchanged" {
const allocator = std.testing.allocator;
const rows = [_]BrokeragePosition{
.{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "", .quantity = 39, .current_value = 10300, .cost_basis = 10000, .is_cash = false },
.{ .account_number = "A", .account_name = "Acct", .symbol = "QTUM", .description = "", .quantity = 100, .current_value = 14000, .cost_basis = 13000, .is_cash = false },
};
var out = try consolidateBySymbol(allocator, &rows);
defer out.deinit(allocator);
try std.testing.expectEqual(@as(usize, 2), out.items.len);
try std.testing.expectEqualStrings("AMZN", out.items[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 39), out.items[0].quantity.?, 0.01);
try std.testing.expectEqualStrings("QTUM", out.items[1].symbol);
}
test "consolidateBySymbol: same-symbol rows aggregate quantity and value" {
// Reproduces the Fidelity Cash + Margin double-row scenario
// (newly-credited shares pre-settlement live in the cash
// sub-account; older settled shares live in the margin
// sub-account; both rows share the ticker). Both rows are
// AMZN in the same account; consolidation must sum to one
// entry of 40 shares total.
const allocator = std.testing.allocator;
const rows = [_]BrokeragePosition{
.{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "Cash row", .quantity = 39, .current_value = 10301.46, .cost_basis = 10244.55, .is_cash = false },
.{ .account_number = "A", .account_name = "Acct", .symbol = "AMZN", .description = "Margin row", .quantity = 1, .current_value = 264.14, .cost_basis = null, .is_cash = false },
};
var out = try consolidateBySymbol(allocator, &rows);
defer out.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), out.items.len);
try std.testing.expectEqualStrings("AMZN", out.items[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 40), out.items[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10565.60), out.items[0].current_value.?, 0.01);
// cost_basis was null on the margin row but present on cash row;
// null + value = value preserves the cash row's basis.
try std.testing.expectApproxEqAbs(@as(f64, 10244.55), out.items[0].cost_basis.?, 0.01);
try std.testing.expect(!out.items[0].is_cash);
}
test "consolidateBySymbol: null quantities collapse to null sum" {
// Two cash rows for the same money-market symbol - Fidelity reports
// these with quantity null and a dollar value. Sum the values, leave
// quantity null.
const allocator = std.testing.allocator;
const rows = [_]BrokeragePosition{
.{ .account_number = "A", .account_name = "Acct", .symbol = "FZFXX", .description = "", .quantity = null, .current_value = 100, .cost_basis = null, .is_cash = true },
.{ .account_number = "A", .account_name = "Acct", .symbol = "FZFXX", .description = "", .quantity = null, .current_value = 50, .cost_basis = null, .is_cash = true },
};
var out = try consolidateBySymbol(allocator, &rows);
defer out.deinit(allocator);
try std.testing.expectEqual(@as(usize, 1), out.items.len);
try std.testing.expectEqual(@as(?f64, null), out.items[0].quantity);
try std.testing.expectApproxEqAbs(@as(f64, 150), out.items[0].current_value.?, 0.01);
try std.testing.expect(out.items[0].is_cash);
}
test "consolidateBySymbol: empty input returns empty" {
const allocator = std.testing.allocator;
const rows = [_]BrokeragePosition{};
var out = try consolidateBySymbol(allocator, &rows);
defer out.deinit(allocator);
try std.testing.expectEqual(@as(usize, 0), out.items.len);
}
// ── resolvePositionValue ──────────────────────────────────────
//
// Pins the audit-side price-provenance rule: live-from-cache prices
// get price_ratio applied; avg_cost-fallback prices do not. This
// closes the latent bug where institutional-share-class positions
// (price_ratio != 1.0) that missed the cache would have their value
// over-reported by the ratio factor.
test "resolvePositionValue: live cache hit applies price_ratio" {
const allocator = std.testing.allocator;
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTTHX", 27.78); // retail-class close
const pos: zfin.Position = .{
.symbol = "VTTHX",
.lot_symbol = "VTTHX",
.shares = 100,
.avg_cost = 106.18,
.total_cost = 10618,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = "401k",
.price_ratio = 5.185,
};
const v = resolvePositionValue(pos, prices);
// price_ratio applied: 27.78 * 5.185 = 144.04
try std.testing.expectApproxEqAbs(@as(f64, 144.04), v.price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 14403.93), v.value, 0.01);
}
test "resolvePositionValue: avg_cost fallback skips price_ratio" {
const allocator = std.testing.allocator;
// Empty prices map - simulate cache miss for VTTHX.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const pos: zfin.Position = .{
.symbol = "VTTHX",
.lot_symbol = "VTTHX",
.shares = 100,
.avg_cost = 106.18, // already institutional-class terms
.total_cost = 10618,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = "401k",
.price_ratio = 5.185,
};
const v = resolvePositionValue(pos, prices);
// Pre-fix behavior would have multiplied: 106.18 * 5.185 = 550.55.
// Correct behavior: avg_cost is already in lot share-class terms.
try std.testing.expectApproxEqAbs(@as(f64, 106.18), v.price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10618.0), v.value, 0.01);
}
test "resolvePositionValue: ratio-1.0 position unaffected by provenance" {
// Sanity: when price_ratio == 1.0, the bug never fired. Both paths
// should give the same answer.
const allocator = std.testing.allocator;
var prices_hit = std.StringHashMap(f64).init(allocator);
defer prices_hit.deinit();
try prices_hit.put("AAPL", 200.0);
var prices_miss = std.StringHashMap(f64).init(allocator);
defer prices_miss.deinit();
const pos: zfin.Position = .{
.symbol = "AAPL",
.lot_symbol = "AAPL",
.shares = 10,
.avg_cost = 150.0,
.total_cost = 1500,
.open_lots = 1,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = "Roth",
};
const hit = resolvePositionValue(pos, prices_hit);
const miss = resolvePositionValue(pos, prices_miss);
try std.testing.expectApproxEqAbs(@as(f64, 200.0), hit.price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), hit.value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 150.0), miss.price, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), miss.value, 0.01);
}
test "option delta tracking in compareAccounts" {
const allocator = std.testing.allocator;
// Build a minimal portfolio with an option lot
var lots = [_]portfolio_mod.Lot{
.{
.symbol = "MSFT 05/15/2026 400.00 C",
.security_type = .option,
.underlying = "MSFT",
.strike = 400.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 5, 15),
.shares = -2,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 6.68,
.multiplier = 100,
.account = "Sample IRA",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
// Brokerage shows the option at different (mark-to-market) value
var brokerage = [_]BrokeragePosition{
.{
.account_number = "1234",
.account_name = "SCHWAB 1234",
.symbol = "MSFT 05/15/2026 400.00 C",
.description = "MSFT CALL",
.quantity = -2,
.current_value = -6511.20,
.cost_basis = -1336.0,
.is_cash = false,
},
};
// Account map: map schwab account 1234 -> portfolio "Sample IRA"
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Sample IRA",
.tax_type = .roth,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 5, 8));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
const acct = results[0];
// Option should be matched, with option_value_delta tracking the difference
try std.testing.expect(@abs(acct.option_value_delta) > 1.0);
// Option value mismatch should NOT set has_discrepancies
try std.testing.expect(!acct.has_discrepancies);
// The comparison should be flagged as is_option
var found_option = false;
for (acct.comparisons) |cmp| {
if (cmp.is_option) {
found_option = true;
// Shares should match (-2 vs -2)
if (cmp.shares_delta) |d| {
try std.testing.expect(@abs(d) < 0.01);
}
}
}
try std.testing.expect(found_option);
}
test "compareAccounts: sub-dollar cash drift is flagged (cash matches to the penny)" {
const allocator = std.testing.allocator;
// Portfolio cash $38.75; Fidelity reports $38.97 - a $0.22
// money-market dividend accrual. It's below the $1 securities
// tolerance, but cash carries no NAV rounding, so it must match to
// the penny rather than be silently swallowed.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "FDRXX", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDRXX", .description = "HELD IN MONEY MARKET", .quantity = null, .current_value = 38.97, .cost_basis = null, .is_cash = true },
};
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expect(results[0].has_discrepancies);
var found_cash = false;
for (results[0].comparisons) |cmp| {
if (cmp.is_cash) {
found_cash = true;
try std.testing.expectApproxEqAbs(@as(f64, 0.22), cmp.value_delta.?, 0.001);
}
}
try std.testing.expect(found_cash);
}
test "hasAccountDiscrepancies" {
const clean = [_]AccountComparison{.{
.account_name = "Acct",
.brokerage_name = "Schwab",
.account_number = "123",
.comparisons = &.{},
.portfolio_total = 1000,
.brokerage_total = 1000,
.total_delta = 0,
.option_value_delta = 0,
.has_discrepancies = false,
}};
try std.testing.expect(!hasAccountDiscrepancies(&clean));
const dirty = [_]AccountComparison{.{
.account_name = "Acct",
.brokerage_name = "Schwab",
.account_number = "123",
.comparisons = &.{},
.portfolio_total = 1000,
.brokerage_total = 1100,
.total_delta = 100,
.option_value_delta = 0,
.has_discrepancies = true,
}};
try std.testing.expect(hasAccountDiscrepancies(&dirty));
}
// ── compareAccounts: branch coverage ─────────────────────────
//
// The two pre-existing compareAccounts tests cover option-delta
// tracking and sub-dollar cash drift. These pin the remaining
// structural branches: the unmapped-account path, the portfolio-only
// passes (stock, CD, option), and the CD-matched-to-broker-row path.
test "compareAccounts: unmapped brokerage account is reported brokerage-only" {
const allocator = std.testing.allocator;
// account_map has no entry for account "9999" -> findByInstitutionAccount
// returns null -> the portfolio_acct_name == null branch fires.
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "9999", .account_name = "FIDELITY 9999", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
};
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 6, 19));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
// Unmapped -> account_name resolves to "" via `orelse`.
try std.testing.expectEqualStrings("", results[0].account_name);
try std.testing.expect(results[0].has_discrepancies);
try std.testing.expectEqual(@as(usize, 1), results[0].comparisons.len);
const cmp = results[0].comparisons[0];
try std.testing.expect(cmp.only_in_brokerage);
try std.testing.expectEqualStrings("AAPL", cmp.symbol);
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].brokerage_total, 0.01);
// brokerage-only row carries a derived per-share price (value/qty).
try std.testing.expectApproxEqAbs(@as(f64, 200), cmp.brokerage_price.?, 0.01);
}
test "compareAccounts: portfolio-only stock position is flagged only_in_portfolio" {
const allocator = std.testing.allocator;
// Two stocks in the account; the broker export lists only AAPL,
// so MSFT must surface as portfolio-only.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
.{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
};
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
try prices.put("MSFT", 320.0);
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 6, 19));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
var found_msft_only = false;
for (results[0].comparisons) |cmp| {
if (std.mem.eql(u8, cmp.symbol, "MSFT")) {
try std.testing.expect(cmp.only_in_portfolio);
try std.testing.expect(!cmp.only_in_brokerage);
found_msft_only = true;
}
}
try std.testing.expect(found_msft_only);
try std.testing.expect(results[0].has_discrepancies);
}
test "compareAccounts: portfolio-only CD and option lots are flagged" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
// Matched stock so the account gets processed at all.
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .account = "Sample IRA" },
// CD not present in the broker export -> portfolio-only CD path.
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
// Option not present in the broker export -> portfolio-only option path.
.{ .symbol = "AMZN 05/15/2026 220.00 C", .security_type = .option, .underlying = "AMZN", .strike = 220, .option_type = .call, .maturity_date = Date.fromYmd(2026, 5, 15), .shares = -2, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 8.75, .multiplier = 100, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
};
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 1));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
var found_cd = false;
var found_opt = false;
for (results[0].comparisons) |cmp| {
if (std.mem.eql(u8, cmp.symbol, "CD-1234")) {
try std.testing.expect(cmp.only_in_portfolio);
try std.testing.expect(cmp.is_cash); // CDs render as cash-class rows
try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01);
found_cd = true;
}
if (std.mem.eql(u8, cmp.symbol, "AMZN 05/15/2026 220.00 C")) {
try std.testing.expect(cmp.only_in_portfolio);
try std.testing.expect(cmp.is_option);
// |-2| * 8.75 * 100 = 1750
try std.testing.expectApproxEqAbs(@as(f64, 1750), cmp.portfolio_value, 0.01);
found_opt = true;
}
}
try std.testing.expect(found_cd);
try std.testing.expect(found_opt);
}
test "compareAccounts: a broker CD row matches a portfolio CD lot" {
const allocator = std.testing.allocator;
// CD present on both sides with identical value -> exercises the
// `.cd` arm of the lot-match switch and lands on no discrepancy.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "CD-1234", .security_type = .cd, .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var brokerage = [_]BrokeragePosition{
.{ .account_number = "1234", .account_name = "SCHWAB 1234", .symbol = "CD-1234", .description = "BANK CD", .quantity = 10000, .current_value = 10000, .cost_basis = 10000, .is_cash = false },
};
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "schwab", prices, Date.fromYmd(2026, 3, 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(@as(usize, 1), results[0].comparisons.len);
const cmp = results[0].comparisons[0];
try std.testing.expectEqualStrings("CD-1234", cmp.symbol);
try std.testing.expectApproxEqAbs(@as(f64, 10000), cmp.portfolio_value, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), cmp.portfolio_price.?, 0.01);
try std.testing.expect(!results[0].has_discrepancies);
}
// ── displayResults rendering ─────────────────────────────────
test "displayResults: renders every row classification and the totals block" {
const allocator = std.testing.allocator;
_ = allocator;
// One mapped account exercising each status branch, plus one
// unmapped account exercising the unmapped header + only_in_* rows.
const ira_cmps = [_]SymbolComparison{
// matched, clean -> blank status, normal color
.{ .symbol = "AAPL", .portfolio_shares = 10, .brokerage_shares = 10, .portfolio_price = 200, .brokerage_price = 200, .portfolio_value = 2000, .brokerage_value = 2000, .shares_delta = 0, .value_delta = 0, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// share mismatch -> "Shares +2.000"
.{ .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 },
// share mismatch, broker shows FEWER shares -> negative delta, "positive" color arm
.{ .symbol = "NVDA", .portfolio_shares = 10, .brokerage_shares = 8, .portfolio_price = 120, .brokerage_price = 120, .portfolio_value = 1200, .brokerage_value = 960, .shares_delta = -2, .value_delta = -240, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// cash mismatch -> "Cash +$0.25"
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 38.75, .brokerage_value = 39.00, .shares_delta = null, .value_delta = 0.25, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
// option -> "Option"
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
// shares match, stale value -> "Value +$50.00" (muted)
.{ .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 unmapped_cmps = [_]SymbolComparison{
.{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
.{ .symbol = "GOOG", .portfolio_shares = 4, .brokerage_shares = null, .portfolio_price = 200, .brokerage_price = null, .portfolio_value = 800, .brokerage_value = null, .shares_delta = null, .value_delta = null, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = true },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &ira_cmps, .portfolio_total = 16928.75, .brokerage_total = 17479.0, .total_delta = 550.25, .option_value_delta = 1500, .has_discrepancies = true },
.{ .account_name = "", .brokerage_name = "FIDELITY", .account_number = "9999", .comparisons = &unmapped_cmps, .portfolio_total = 800, .brokerage_total = 600, .total_delta = -200, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, false, &w);
const out = w.buffered();
// Header + account identity
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Audit") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample IRA") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "SCHWAB") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1234") != null);
// Unmapped-account header branch
try std.testing.expect(std.mem.indexOf(u8, out, "unmapped") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "FIDELITY") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "9999") != null);
// Per-row status strings
try std.testing.expect(std.mem.indexOf(u8, out, "Shares +2.000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Shares -2.000") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Option") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Brokerage only") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio only") != null);
// Totals block
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "options") != null);
// 5 real mismatches (2 share, cash, brokerage-only, portfolio-only) -> plural
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
}
test "displayResults: color=true emits ANSI and singular mismatch label" {
const cmps = [_]SymbolComparison{
.{ .symbol = "GOOG", .portfolio_shares = 4, .brokerage_shares = null, .portfolio_price = 200, .brokerage_price = null, .portfolio_value = 800, .brokerage_value = null, .shares_delta = null, .value_delta = null, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = true },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample Roth", .brokerage_name = "SCHWAB", .account_number = "5678", .comparisons = &cmps, .portfolio_total = 800, .brokerage_total = 0, .total_delta = -800, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayResults(&results, true, &w);
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, "\x1b[") != null); // ANSI present
// exactly one real mismatch -> singular label
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch to investigate") != null);
}
// ── displayRatioSuggestions ──────────────────────────────────
test "displayRatioSuggestions: emits a suggestion when broker NAV drifts from configured ratio" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTHRX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 30, .account = "Sample IRA", .price_ratio = 5.0 },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTHRX", 5.0); // retail close
const cmps = [_]SymbolComparison{
// brokerage_price (inst NAV) 30 / retail 5 = suggested ratio 6, vs configured 5
.{ .symbol = "VTHRX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 25.0, .brokerage_price = 30.0, .portfolio_value = 2500, .brokerage_value = 3000, .shares_delta = 0, .value_delta = 500, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 2500, .brokerage_total = 3000, .total_delta = 500, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayRatioSuggestions(&results, portfolio, prices, null, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "VTHRX") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 5 -> ") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "6") != null); // suggested ratio
}
test "displayRatioSuggestions: direct-indexing account suggests even at ratio 1.0" {
const allocator = std.testing.allocator;
// price_ratio == 1.0 would normally be skipped, but the account is
// flagged direct_indexing -> the ratio==1.0 skip is bypassed.
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "5678", .direct_indexing = true },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
const cmps = [_]SymbolComparison{
.{ .symbol = "SPY", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 500, .brokerage_price = 510.0, .portfolio_value = 50000, .brokerage_value = 51000, .shares_delta = 0, .value_delta = 1000, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample Brokerage", .brokerage_name = "SCHWAB", .account_number = "5678", .comparisons = &cmps, .portfolio_total = 50000, .brokerage_total = 51000, .total_delta = 1000, .option_value_delta = 0, .has_discrepancies = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
const out = w.buffered();
// suggested = 510/500 = 1.02, configured 1.0 -> drift suggestion emitted
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
}
test "displayRatioSuggestions: cash/option/only rows produce no output" {
const allocator = std.testing.allocator;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const cmps = [_]SymbolComparison{
.{ .symbol = "FDRXX", .portfolio_shares = 0, .brokerage_shares = null, .portfolio_price = null, .brokerage_price = null, .portfolio_value = 100, .brokerage_value = 100, .shares_delta = null, .value_delta = 0, .is_cash = true, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
.{ .symbol = "AMZN C", .portfolio_shares = -2, .brokerage_shares = -2, .portfolio_price = 875, .brokerage_price = 1625, .portfolio_value = 1750, .brokerage_value = 3250, .shares_delta = 0, .value_delta = 1500, .is_cash = false, .is_option = true, .only_in_brokerage = false, .only_in_portfolio = false },
.{ .symbol = "TSLA", .portfolio_shares = 0, .brokerage_shares = 3, .portfolio_price = null, .brokerage_price = 200, .portfolio_value = 0, .brokerage_value = 600, .shares_delta = 3, .value_delta = 600, .is_cash = false, .is_option = false, .only_in_brokerage = true, .only_in_portfolio = false },
};
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "SCHWAB", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 1850, .brokerage_total = 3950, .total_delta = 2100, .option_value_delta = 1500, .has_discrepancies = true },
};
var buf: [1024]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displayRatioSuggestions(&results, portfolio, prices, null, false, &w);
// No qualifying (matched, non-cash, non-option) rows -> header never prints.
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
// ── Absent-account detection ─────────────────────────────────
test "presentNumbers: collects account_number from each result" {
const allocator = std.testing.allocator;
const results = [_]AccountComparison{
.{ .account_name = "Sample IRA", .brokerage_name = "Fid", .account_number = "1234", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
.{ .account_name = "Sample Brokerage", .brokerage_name = "Fid", .account_number = "5678", .comparisons = &.{}, .portfolio_total = 0, .brokerage_total = 0, .total_delta = 0, .option_value_delta = 0, .has_discrepancies = false },
};
const nums = try presentNumbers(allocator, AccountComparison, &results);
defer allocator.free(nums);
try std.testing.expectEqual(@as(usize, 2), nums.len);
try std.testing.expectEqualStrings("1234", nums[0]);
try std.testing.expectEqualStrings("5678", nums[1]);
}
test "findAbsentAccounts: flags held account missing from export; honors gating + closed-account suppression" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 19);
var lots = [_]portfolio_mod.Lot{
// fidelity #1234 -> held, absent from export -> SHOULD flag.
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
// fidelity #5678 -> held, present in export -> handled by main pass.
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Sample Brokerage" },
// fidelity #3456 -> only a closed lot, absent -> suppressed.
.{ .symbol = "MSFT", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 350.0, .account = "Sample Roth" },
// schwab #9012 -> held, absent, but wrong institution for a Fidelity audit.
.{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "5678" },
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "fidelity", .account_number = "3456" },
.{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
// institution set but no account number -> can't match an export row -> skipped.
.{ .account = "Sample HSA", .tax_type = .hsa, .institution = "fidelity", .account_number = null },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("VTI", 210.0);
// The Fidelity export contained only account #5678.
const present = [_][]const u8{"5678"};
const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of);
defer allocator.free(absent);
try std.testing.expectEqual(@as(usize, 1), absent.len);
try std.testing.expectEqualStrings("Sample IRA", absent[0].account_name);
try std.testing.expectEqualStrings("1234", absent[0].account_number);
try std.testing.expectApproxEqAbs(@as(f64, 2100.0), absent[0].portfolio_total, 0.01);
}
test "findAbsentAccounts: gating flags only the audited institution" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 19);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
.{ .symbol = "NVDA", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400.0, .account = "Schwab Trust" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
.{ .account = "Schwab Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "9012" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
// Auditing a Schwab export that matched no known account number:
// only the Schwab account surfaces; the Fidelity account is gated out.
const present = [_][]const u8{};
const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "schwab", &present, prices, as_of);
defer allocator.free(absent);
try std.testing.expectEqual(@as(usize, 1), absent.len);
try std.testing.expectEqualStrings("Schwab Trust", absent[0].account_name);
try std.testing.expectEqualStrings("9012", absent[0].account_number);
}
test "findAbsentAccounts: no absent accounts when export covers every held account" {
const allocator = std.testing.allocator;
const as_of = Date.fromYmd(2026, 6, 19);
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Sample IRA" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "fidelity", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const present = [_][]const u8{"1234"};
const absent = try findAbsentAccounts(allocator, portfolio, acct_map, "fidelity", &present, prices, as_of);
defer allocator.free(absent);
try std.testing.expectEqual(@as(usize, 0), absent.len);
}
test "displayAbsentAccounts: silent when empty, renders names + totals otherwise" {
var buf: [1024]u8 = undefined;
// Empty -> no output.
{
var w = std.Io.Writer.fixed(&buf);
try displayAbsentAccounts(&.{}, false, &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
// Non-empty -> names, numbers, and a money total appear.
{
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, &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);
}
}