zfin/src/commands/audit.zig

3700 lines
151 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const analysis = @import("../analytics/analysis.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const contributions = @import("contributions.zig");
// ── Brokerage position (normalized from any source) ─────────
/// A single position row from a brokerage export, normalized to a common format.
pub const BrokeragePosition = struct {
account_number: []const u8,
account_name: []const u8,
symbol: []const u8,
description: []const u8,
quantity: ?f64,
current_value: ?f64,
cost_basis: ?f64,
is_cash: bool,
};
/// Parsed brokerage data grouped by account.
pub const BrokerageAccount = struct {
account_number: []const u8,
account_name: []const u8,
portfolio_account: ?[]const u8,
positions: []const BrokeragePosition,
total_value: f64,
cash_value: f64,
};
// ── Fidelity CSV parser ─────────────────────────────────────
//
// Limitations of this CSV parser:
//
// 1. NOT a general-purpose CSV parser. It handles the specific format
// exported by Fidelity's "Download Positions" feature.
//
// 2. Does NOT handle quoted fields containing commas. Fidelity's export
// does not quote fields with commas in practice (description fields
// use spaces, not commas), but a truly compliant RFC 4180 parser would
// need to handle "field,with,commas" as a single value.
//
// 3. Does NOT handle escaped quotes ("" inside quoted fields).
//
// 4. Does NOT handle multi-line values (newlines inside quoted fields).
//
// 5. Assumes UTF-8 with optional BOM (which Fidelity includes).
//
// 6. Stops parsing at the first blank line, which separates position data
// from the Fidelity legal disclaimer footer.
//
// 7. Hardcodes the expected column layout. If Fidelity changes the CSV
// format (adds/removes/reorders columns), this parser will break.
// The header row is validated to catch this.
//
// 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of
// $, commas, and leading +/- signs. Negative values wrapped in
// parentheses are NOT handled (Fidelity uses -$X.XX format).
//
// 9. Money market rows (symbol ending in **) are treated as cash.
//
// For a production-grade CSV parser, consider a library that handles
// RFC 4180 fully (quoted fields, escaping, multi-line values).
const fidelity_expected_columns = 16;
/// Column indices in the Fidelity CSV export.
/// Based on: Account Number, Account Name, Symbol, Description, Quantity,
/// Last Price, Last Price Change, Current Value, Today's Gain/Loss Dollar,
/// Today's Gain/Loss Percent, Total Gain/Loss Dollar, Total Gain/Loss Percent,
/// Percent Of Account, Cost Basis Total, Average Cost Basis, Type
const FidelityCol = struct {
const account_number = 0;
const account_name = 1;
const symbol = 2;
const description = 3;
const quantity = 4;
const last_price = 5;
const current_value = 7;
const cost_basis_total = 13;
const avg_cost_basis = 14;
const type_col = 15;
};
/// Parse a Fidelity CSV positions export into BrokeragePosition slices.
/// All string fields in the returned positions are slices into `data`,
/// so the caller must keep `data` alive for as long as the positions are used.
/// Only the returned slice itself is heap-allocated (caller must free it).
pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator);
// Skip UTF-8 BOM if present
var content = data;
if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) {
content = content[3..];
}
var lines = std.mem.splitScalar(u8, content, '\n');
// Validate header row
const header_line = lines.next() orelse return error.EmptyFile;
const header_trimmed = std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' });
if (header_trimmed.len == 0) return error.EmptyFile;
if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) {
return error.UnexpectedHeader;
}
// Parse data rows
while (lines.next()) |line| {
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
if (trimmed.len == 0) break;
// Skip lines starting with " (disclaimer text)
if (trimmed[0] == '"') break;
var col_iter = std.mem.splitScalar(u8, trimmed, ',');
var cols: [fidelity_expected_columns][]const u8 = undefined;
var col_count: usize = 0;
while (col_iter.next()) |col| {
if (col_count < fidelity_expected_columns) {
cols[col_count] = col;
col_count += 1;
}
}
if (col_count < fidelity_expected_columns) continue;
const symbol_raw = std.mem.trim(u8, cols[FidelityCol.symbol], &.{ ' ', '"' });
if (symbol_raw.len == 0) continue;
// Strip ** suffix from money market symbols for display
const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**"))
symbol_raw[0 .. symbol_raw.len - 2]
else
symbol_raw;
// Classify as cash if any of:
// - Fidelity's ** suffix marks a money-market position
// - The symbol appears in zfin's canonical money-market list
// (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these)
// - price and cost both equal exactly $1.00, the catch-all for
// fixed-NAV instruments that we don't have in the list yet.
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
portfolio_mod.isMoneyMarketSymbol(symbol_clean) or
isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]);
try positions.append(allocator, .{
.account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }),
.account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }),
.symbol = symbol_clean,
.description = std.mem.trim(u8, cols[FidelityCol.description], &.{ ' ', '"' }),
.quantity = if (is_cash) null else parseDollarAmount(cols[FidelityCol.quantity]),
.current_value = parseDollarAmount(cols[FidelityCol.current_value]),
.cost_basis = if (is_cash) null else parseDollarAmount(cols[FidelityCol.cost_basis_total]),
.is_cash = is_cash,
});
}
return positions.toOwnedSlice(allocator);
}
/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values.
fn parseDollarAmount(raw: []const u8) ?f64 {
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' });
if (trimmed.len == 0) return null;
// Strip leading +/- and $, remove commas
var buf: [64]u8 = undefined;
var pos: usize = 0;
var negative = false;
for (trimmed) |c| {
if (c == '-') {
negative = true;
} else if (c == '$' or c == '+' or c == ',') {
continue;
} else {
if (pos >= buf.len) return null;
buf[pos] = c;
pos += 1;
}
}
if (pos == 0) return null;
const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null;
return if (negative) -val else val;
}
/// Returns true when both the last price and average cost basis parse to exactly $1.00,
/// indicating a money-market or cash-equivalent position (e.g. FDRXX).
fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool {
const price = parseDollarAmount(price_raw) orelse return false;
const cost = parseDollarAmount(cost_raw) orelse return false;
return price == 1.0 and cost == 1.0;
}
const Date = @import("../Date.zig");
/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a
/// portfolio lot by comparing parsed components against the lot's structured
/// fields (underlying, maturity_date, option_type, strike).
///
/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}
/// The underlying length is variable, so we scan for the first position
/// where 6 consecutive digits encode a valid date.
fn fidelityOptionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool {
if (lot.security_type != .option) return false;
// Strip leading dash (short indicator)
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
if (sym.len < 9) return false;
// Scan for the date boundary: first position where 6 consecutive digits
// form a valid YYMMDD (and the character before is a letter).
var i: usize = 1; // underlying is at least 1 char
while (i + 7 < sym.len) : (i += 1) {
// All 6 chars must be digits
if (!std.ascii.isDigit(sym[i]) or
!std.ascii.isDigit(sym[i + 1]) or
!std.ascii.isDigit(sym[i + 2]) or
!std.ascii.isDigit(sym[i + 3]) or
!std.ascii.isDigit(sym[i + 4]) or
!std.ascii.isDigit(sym[i + 5]))
continue;
// Character after the 6 digits must be C or P
const type_char = sym[i + 6];
if (type_char != 'C' and type_char != 'P') continue;
// Parse date components
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
const year = 2000 + yy;
// Parse components
const underlying = sym[0..i];
const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call;
const strike_str = sym[i + 7 ..];
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
const date = Date.fromYmd(year, mm, dd);
// Match against lot fields
const lot_underlying = lot.underlying orelse return false;
const lot_maturity = lot.maturity_date orelse return false;
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
if (!lot_maturity.eql(date)) return false;
if (option_type != lot.option_type) return false;
if (lot.strike) |ls| {
if (@abs(ls - strike) > 0.01) return false;
} else return false;
return true;
}
return false;
}
// ── Schwab CSV parser ───────────────────────────────────────
//
// Parses the per-account positions CSV exported from Schwab's website
// (Accounts → Positions → Export for a single account).
//
// Limitations:
//
// 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
// format where every field is double-quoted.
//
// 2. Handles simple quoted fields ("value") but does NOT handle escaped
// quotes ("value with ""quotes"" inside") or multi-line quoted fields.
// Schwab's export does not use these in practice.
//
// 3. The account number and name are extracted from the title line:
// "Positions for account <NAME> ...<NUM> as of ..."
//
// 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
// The row with symbol "Positions Total" is skipped.
//
// 5. Hardcodes the expected column layout. If Schwab changes the CSV
// format, this parser will break. The header row is not validated
// beyond being skipped.
const schwab_expected_columns = 17;
const SchwabCol = struct {
const symbol = 0;
const price = 4;
const quantity = 5;
const market_value = 8;
const cost_basis = 9;
const asset_type = 16;
};
/// Split a Schwab CSV line on commas, stripping surrounding quotes from each field.
/// Returns the number of columns parsed. Fields are slices into the input line.
fn splitSchwabCsvLine(line: []const u8, cols: *[schwab_expected_columns][]const u8) usize {
var col_count: usize = 0;
var pos: usize = 0;
while (pos < line.len and col_count < schwab_expected_columns) {
if (line[pos] == '"') {
// Quoted field: find closing quote
const start = pos + 1;
pos = start;
while (pos < line.len and line[pos] != '"') : (pos += 1) {}
cols[col_count] = line[start..pos];
col_count += 1;
if (pos < line.len) pos += 1; // skip closing quote
if (pos < line.len and line[pos] == ',') pos += 1; // skip comma
} else if (line[pos] == ',') {
cols[col_count] = "";
col_count += 1;
pos += 1;
} else {
// Unquoted field
const start = pos;
while (pos < line.len and line[pos] != ',') : (pos += 1) {}
cols[col_count] = line[start..pos];
col_count += 1;
if (pos < line.len) pos += 1; // skip comma
}
}
return col_count;
}
/// Extract account name and number from Schwab title line.
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
/// Returns {name, number} or null if the line doesn't match.
fn parseSchwabTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
const prefix = "Positions for account ";
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
const rest = stripped[prefix.len..];
// Find "..." which separates name from account number
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
// Account number: after "..." until " as of" or end
const after_dots = rest[dots_idx + 3 ..];
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
return .{ .name = name, .number = number };
}
pub fn parseSchwabCsv(allocator: std.mem.Allocator, data: []const u8) !struct { positions: []BrokeragePosition, account_name: []const u8, account_number: []const u8 } {
var positions = std.ArrayList(BrokeragePosition).empty;
errdefer positions.deinit(allocator);
var lines = std.mem.splitScalar(u8, data, '\n');
// Line 1: title with account name and number
const title_line = lines.next() orelse return error.EmptyFile;
const title = parseSchwabTitle(title_line) orelse return error.UnexpectedHeader;
// Line 2: blank (skip)
_ = lines.next();
// Line 3: header row (skip)
_ = lines.next();
// Data rows
while (lines.next()) |line| {
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
if (trimmed.len == 0) continue;
var cols: [schwab_expected_columns][]const u8 = undefined;
const col_count = splitSchwabCsvLine(trimmed, &cols);
if (col_count < schwab_expected_columns) continue;
const symbol = cols[SchwabCol.symbol];
if (symbol.len == 0) continue;
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
// "Cash & Cash Investments" is Schwab's aggregate cash line.
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
// with their real ticker and price — treat those as cash too so
// the reconciliation matches what brokerage users think of as
// "cash" in the account.
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
portfolio_mod.isMoneyMarketSymbol(symbol);
try positions.append(allocator, .{
.account_number = title.number,
.account_name = title.name,
.symbol = symbol,
.description = if (col_count > 1) cols[1] else "",
.quantity = if (is_cash) null else parseDollarAmount(cols[SchwabCol.quantity]),
.current_value = parseDollarAmount(cols[SchwabCol.market_value]),
.cost_basis = if (is_cash) null else parseDollarAmount(cols[SchwabCol.cost_basis]),
.is_cash = is_cash,
});
}
return .{
.positions = try positions.toOwnedSlice(allocator),
.account_name = title.name,
.account_number = title.number,
};
}
// ── Schwab summary parser ───────────────────────────────────
//
// Parses the account summary paste from Schwab's web interface.
// The expected format is repeating blocks of 2-3 lines per account:
//
// Account Name
// Account number ending in NNN ...NNN
// Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
//
// Limitations:
//
// 1. NOT a CSV parser — parses freeform text pasted from the Schwab UI.
//
// 2. Identifies account blocks by the "Account number ending in" line.
// The account name is the non-empty line immediately before it.
//
// 3. The values line (cash, total, change, pct) is identified by finding
// dollar amounts. It tolerates missing or extra fields — it looks for
// the first two dollar amounts as cash and total value.
//
// 4. Skips summary lines like "Investment Total", "Day Change Total",
// and "Day Change Percent Total" which appear at the end of the paste.
//
// 5. Tolerant of partial pastes: if the user copies headers once but
// not on subsequent pastes, or includes extra blank lines, the parser
// still finds account blocks by the "Account number ending in" anchor.
//
// 6. The account number is extracted from "...NNN" at the end of the
// account number line (the last whitespace-separated token).
/// Account-level summary from a Schwab paste (no per-position detail).
pub const SchwabAccountSummary = struct {
account_name: []const u8,
account_number: []const u8,
cash: ?f64,
total_value: ?f64,
};
/// Parse Schwab account summary from pasted text.
/// All string fields in the returned summaries are slices into `data`.
/// Only the returned slice itself is heap-allocated (caller must free it).
pub fn parseSchwabSummary(allocator: std.mem.Allocator, data: []const u8) ![]SchwabAccountSummary {
var accounts = std.ArrayList(SchwabAccountSummary).empty;
errdefer accounts.deinit(allocator);
// Collect all lines, trimmed
var all_lines = std.ArrayList([]const u8).empty;
defer all_lines.deinit(allocator);
var line_iter = std.mem.splitScalar(u8, data, '\n');
while (line_iter.next()) |line| {
const trimmed = std.mem.trim(u8, line, &.{ '\r', ' ', '\t' });
try all_lines.append(allocator, trimmed);
}
const lines = all_lines.items;
// Scan for "Account number ending in" anchors
for (lines, 0..) |line, i| {
if (!std.mem.startsWith(u8, line, "Account number ending in")) continue;
// Extract account number: last token on the line (e.g. "...901" -> "901")
var acct_num: []const u8 = "";
var tok_iter = std.mem.tokenizeAny(u8, line, &.{ ' ', '\t' });
while (tok_iter.next()) |tok| {
acct_num = tok;
}
// Strip leading dots
while (acct_num.len > 0 and acct_num[0] == '.') {
acct_num = acct_num[1..];
}
// Account name: nearest non-empty line before the anchor
var acct_name: []const u8 = "";
if (i > 0) {
var j: usize = i - 1;
while (true) {
if (lines[j].len > 0 and
!std.mem.startsWith(u8, lines[j], "Account number") and
!std.mem.startsWith(u8, lines[j], "Investment Total") and
!std.mem.startsWith(u8, lines[j], "Day Change"))
{
acct_name = lines[j];
break;
}
if (j == 0) break;
j -= 1;
}
}
// Values line: look at lines after the anchor for dollar amounts.
// The format is "Type XXX $CASH $TOTAL +$CHANGE +PCT%"
// We want the first two dollar amounts (cash and total).
var cash: ?f64 = null;
var total: ?f64 = null;
if (i + 1 < lines.len) {
var dollar_values = std.ArrayList(f64).empty;
defer dollar_values.deinit(allocator);
var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' });
while (val_iter.next()) |tok| {
if (parseDollarAmount(tok)) |v| {
dollar_values.append(allocator, v) catch {};
}
}
if (dollar_values.items.len >= 2) {
cash = dollar_values.items[0];
total = dollar_values.items[1];
} else if (dollar_values.items.len == 1) {
total = dollar_values.items[0];
}
}
try accounts.append(allocator, .{
.account_name = acct_name,
.account_number = acct_num,
.cash = cash,
.total_value = total,
});
}
if (accounts.items.len == 0) return error.NoAccountsFound;
return accounts.toOwnedSlice(allocator);
}
/// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct {
account_name: []const u8,
schwab_name: []const u8,
account_number: []const u8,
portfolio_cash: f64,
schwab_cash: ?f64,
cash_delta: ?f64,
portfolio_total: f64,
schwab_total: ?f64,
total_delta: ?f64,
has_discrepancy: bool,
};
/// 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),
};
}
/// Compare Schwab summary against portfolio.srf account totals.
pub fn compareSchwabSummary(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
schwab_accounts: []const SchwabAccountSummary,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]SchwabAccountComparison {
var results = std.ArrayList(SchwabAccountComparison).empty;
errdefer results.deinit(allocator);
for (schwab_accounts) |sa| {
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
var pf_cash: f64 = 0;
var pf_total: f64 = 0;
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
}
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
const total_delta = if (sa.total_value) |st| st - pf_total else null;
const cash_ok = if (cash_delta) |d| @abs(d) < 1.0 else true;
const total_ok = if (total_delta) |d| @abs(d) < 1.0 else true;
try results.append(allocator, .{
.account_name = portfolio_acct orelse "",
.schwab_name = sa.account_name,
.account_number = sa.account_number,
.portfolio_cash = pf_cash,
.schwab_cash = sa.cash,
.cash_delta = cash_delta,
.portfolio_total = pf_total,
.schwab_total = sa.total_value,
.total_delta = total_delta,
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
});
}
return results.toOwnedSlice(allocator);
}
fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
try out.print("========================================\n\n", .{});
// Column headers
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
});
var grand_pf: f64 = 0;
var grand_br: f64 = 0;
var discrepancy_count: usize = 0;
for (results) |r| {
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
var br_cash_buf: [24]u8 = undefined;
var br_total_buf: [24]u8 = undefined;
const br_cash_str = if (r.schwab_cash) |c|
std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
else
"--";
const br_total_str = if (r.schwab_total) |t|
std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
else
"--";
const cash_ok = if (r.cash_delta) |d| @abs(d) < 1.0 else true;
const total_ok = if (r.total_delta) |d| @abs(d) < 1.0 else true;
const is_unmapped = r.account_name.len == 0;
const is_real_mismatch = !cash_ok or is_unmapped;
if (is_real_mismatch) discrepancy_count += 1;
// Account label
try out.print(" ", .{});
if (is_unmapped) {
try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
} else {
try out.print("{s:<24}", .{label});
}
// PF Cash — colored if mismatched (brokerage is truth)
try out.print(" ", .{});
if (!cash_ok) {
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
} else {
try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
}
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total — colored if not just stale prices
try out.print(" ", .{});
if (!total_ok and !cash_ok) {
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
} else {
try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
}
// BR Total
try out.print(" {s:>14}", .{br_total_str});
// Status
if (is_unmapped) {
try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
} else if (!cash_ok) {
const d = r.cash_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!total_ok) {
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
}
try out.print("\n", .{});
grand_pf += r.portfolio_total;
if (r.schwab_total) |t| grand_br += t;
}
// Grand totals
try out.print("\n", .{});
const grand_delta = grand_br - grand_pf;
try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{
Money.from(grand_pf),
Money.from(grand_br),
});
if (@abs(grand_delta) < 1.0) {
// no delta
} else {
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
}
try out.print("\n", .{});
if (discrepancy_count > 0) {
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
});
}
try out.print("\n", .{});
}
// ── 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
// (Fidelity uses compact OCC format like "-AMZN260515C220"
// while portfolio uses "AMZN 05/15/2026 220.00 C")
if (!std.mem.eql(u8, lot.symbol, bp.symbol) and
!fidelityOptionMatchesLot(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;
const value_match = if (value_delta) |d| @abs(d) < 1.0 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.
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", .{});
}
/// Direct-indexing ratio suggestions from Schwab-summary data.
///
/// The Schwab summary path only gives us per-account totals, not
/// per-symbol detail. For a direct-indexing account with exactly one
/// stock lot (the common case — the account is the proxy basket,
/// tracked as a single benchmark lot), we can still emit a ratio
/// suggestion from the account-level `total_delta`:
///
/// current_stock_value = portfolio_total - portfolio_cash
/// target_stock_value = current_stock_value + total_delta
/// suggested_ratio = target_stock_value / (shares × price)
///
/// Where `price` is `shares × current_cached_price`. The math
/// assumes the full account delta lands on the single tracked lot,
/// which is the semantics of a direct-indexing proxy.
///
/// Skips accounts with more than one stock lot (can't allocate the
/// delta) or zero stock lots (nothing to adjust).
fn displaySchwabSummaryRatioSuggestions(
results: []const SchwabAccountComparison,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: ?analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
const am = account_map orelse return;
var has_header = false;
for (results) |r| {
if (r.account_name.len == 0) continue;
if (!am.isDirectIndexing(r.account_name)) continue;
const total_delta = r.total_delta orelse continue;
if (@abs(total_delta) < 0.01) continue;
// Find the single stock lot for this account.
var stock_lot: ?zfin.Lot = null;
var stock_lot_count: usize = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
stock_lot = lot;
stock_lot_count += 1;
}
if (stock_lot_count != 1) continue;
const lot = stock_lot.?;
const price_sym = lot.priceSymbol();
const retail_price = prices.get(price_sym) orelse continue;
if (retail_price == 0) continue;
if (lot.shares == 0) continue;
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
if (current_stock_value == 0) continue;
const target_stock_value = current_stock_value + total_delta;
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
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; direct-indexing accounts)\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}", .{lot.price_ratio}) catch "?";
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";
try out.print(" {s:<16} ", .{lot.symbol});
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});
}
if (has_header) try out.print("\n", .{});
}
// ── Display ─────────────────────────────────────────────────
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) >= 1.0 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) >= 1.0) {
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", .{});
}
// ── Hygiene check (flagless audit) ──────────────────────────
/// Constants for hygiene check behavior. Kept as named constants for
/// easy future tuning.
const audit_file_max_age_hours = 24;
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
const default_stale_days: u32 = 3;
const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
/// new_cash / new_cd / cash_contribution) gets flagged in the
/// "Large new lots — confirm source" hygiene section. Below this
/// threshold new lots pass silently — the audit's goal is to catch
/// unconfirmed six-figure movements, not flag every payroll
/// contribution.
///
/// $10k is a judgment call: high enough to ignore routine payroll
/// ESPP accruals and $1-$2k weekly deposits, low enough to surface
/// a typical IRA contribution or a genuine transfer. Tunable here,
/// per the plan's "revisit if the threshold proves wrong" note in
/// TODO.md.
const audit_large_lot_threshold: f64 = 10_000.0;
/// Type of a discovered brokerage file.
const BrokerFileKind = enum {
fidelity_csv,
schwab_csv,
schwab_summary,
};
/// A discovered brokerage file ready for reconciliation.
const DiscoveredFile = struct {
path: []const u8,
kind: BrokerFileKind,
dir_label: []const u8, // e.g. "audit/" or "$ZFIN_AUDIT_FILES"
};
/// Detect the brokerage type from file contents by inspecting the first few lines.
fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
// Strip optional UTF-8 BOM
const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF)
data[3..]
else
data;
// Fidelity CSV: first line starts with "Account Number" or "Account Name"
if (std.mem.startsWith(u8, content, "Account Number") or
std.mem.startsWith(u8, content, "Account Name"))
return .fidelity_csv;
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
// Schwab summary: contains "Account number ending in" pattern
const peek = content[0..@min(content.len, 4096)];
if (std.mem.indexOf(u8, peek, "Account number ending in") != null) return .schwab_summary;
// Also match by account type labels + dollar amounts
if ((std.mem.indexOf(u8, peek, "Brokerage") != null or
std.mem.indexOf(u8, peek, "Roth IRA") != null or
std.mem.indexOf(u8, peek, "Traditional IRA") != null or
std.mem.indexOf(u8, peek, "Rollover IRA") != null) and
std.mem.indexOf(u8, peek, "$") != null)
{
return .schwab_summary;
}
return null;
}
/// Discover brokerage files in a directory. Filters by recency (< 24h)
/// and applies size limits for non-CSV files.
fn discoverBrokerFiles(
io: std.Io,
allocator: std.mem.Allocator,
dir_path: []const u8,
dir_label: []const u8,
now_s: i64,
) ![]DiscoveredFile {
var results = std.ArrayList(DiscoveredFile).empty;
defer results.deinit(allocator);
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator);
defer dir.close(io);
const max_age_s: i128 = audit_file_max_age_hours * 3600;
var it = dir.iterate();
while (try it.next(io)) |entry| {
if (entry.kind != .file) continue;
// Check file modification time
const stat = dir.statFile(io, entry.name, .{}) catch continue;
const mtime_s: i128 = @divFloor(stat.mtime.nanoseconds, std.time.ns_per_s);
const age_s = now_s - mtime_s;
if (age_s > max_age_s) continue;
// Check if it's a CSV (no size limit) or non-CSV (size limit applies)
const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV");
if (!is_csv and stat.size > audit_file_max_size_non_csv) continue;
// Read and detect content type
const data = dir.readFileAlloc(io, entry.name, allocator, .limited(10 * 1024 * 1024)) catch continue;
defer allocator.free(data);
const kind = detectBrokerFileKind(data) orelse continue;
const full_path = std.fs.path.join(allocator, &.{ dir_path, entry.name }) catch continue;
try results.append(allocator, .{
.path = full_path,
.kind = kind,
.dir_label = dir_label,
});
}
return results.toOwnedSlice(allocator);
}
/// Compute which accounts have been modified between two parsed portfolios.
/// Returns a set of account names that have any lot-level differences.
/// Compares by serializing each lot to a canonical string per account,
/// sorting, and checking for equality. Simple and robust -- any field
/// change in a lot produces a different string.
fn findModifiedAccounts(
allocator: std.mem.Allocator,
old_portfolio: zfin.Portfolio,
new_portfolio: zfin.Portfolio,
) !std.StringHashMap(void) {
var modified = std.StringHashMap(void).init(allocator);
errdefer modified.deinit();
// Collect serialized lot strings grouped by account
var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
defer {
var it = old_accts.valueIterator();
while (it.next()) |v| {
for (v.items) |s| allocator.free(s);
v.deinit(allocator);
}
old_accts.deinit();
}
var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
defer {
var it = new_accts.valueIterator();
while (it.next()) |v| {
for (v.items) |s| allocator.free(s);
v.deinit(allocator);
}
new_accts.deinit();
}
for (old_portfolio.lots) |lot| {
const acct = lot.account orelse continue;
const entry = try old_accts.getOrPut(acct);
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
}
for (new_portfolio.lots) |lot| {
const acct = lot.account orelse continue;
const entry = try new_accts.getOrPut(acct);
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
}
// Compare per account: sort both lists, then check equality
var all = std.StringHashMap(void).init(allocator);
defer all.deinit();
{
var it = old_accts.keyIterator();
while (it.next()) |k| try all.put(k.*, {});
}
{
var it = new_accts.keyIterator();
while (it.next()) |k| try all.put(k.*, {});
}
var acct_it = all.keyIterator();
while (acct_it.next()) |acct_key| {
const acct = acct_key.*;
const old_ptr = old_accts.getPtr(acct);
const new_ptr = new_accts.getPtr(acct);
const old_len = if (old_ptr) |p| p.items.len else 0;
const new_len = if (new_ptr) |p| p.items.len else 0;
if (old_len != new_len) {
try modified.put(acct, {});
continue;
}
if (old_len == 0) continue;
const old_items = old_ptr.?.items;
const new_items = new_ptr.?.items;
std.mem.sort([]const u8, old_items, {}, strLessThan);
std.mem.sort([]const u8, new_items, {}, strLessThan);
var differs = false;
for (old_items, new_items) |a, b| {
if (!std.mem.eql(u8, a, b)) {
differs = true;
break;
}
}
if (differs) try modified.put(acct, {});
}
return modified;
}
fn strLessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
const srf = @import("srf");
/// Serialize a lot to a canonical SRF string for comparison.
/// Uses the SRF serializer with comptime reflection, so any new
/// field added to Lot is automatically included.
fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
const lots = [_]portfolio_mod.Lot{lot};
return std.fmt.allocPrint(allocator, "{f}", .{srf.fmtFrom(portfolio_mod.Lot, allocator, &lots, .{ .emit_directives = false })});
}
/// Staleness color based on age vs threshold.
/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x.
fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
const t: i32 = @intCast(threshold);
if (age_days <= t) return cli.CLR_MUTED;
if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING;
return cli.CLR_NEGATIVE;
}
/// Render one unmatched large-lot warning. Formats the line the
/// user needs to paste into `transaction_log.srf` if the lot was
/// an internal movement rather than a real external contribution.
/// Leaves `from::<SOURCE>` as a placeholder — the audit doesn't
/// know which account the money came from.
///
/// Stock / CD destinations use `dest_lot::SYMBOL@OPEN_DATE`; cash
/// (or cash_contribution) destinations use `dest_lot::cash`. The
/// template always uses `type::cash` since `type::in_kind` is
/// rejected downstream in v1.
fn printLargeLotWarning(
out: *std.Io.Writer,
lot: contributions.UnmatchedLargeLot,
color: bool,
) !void {
var val_buf: [32]u8 = undefined;
var date_buf: [10]u8 = undefined;
const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?";
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??";
const kind_label: []const u8 = switch (lot.security_type) {
.stock => "STOCK",
.cash => "CASH",
.cd => "CD",
.option => "OPTION",
else => "LOT",
};
const sym_for_display = if (lot.symbol.len > 0) lot.symbol else "cash";
try out.print(
" {s}: new {s} lot {s} ",
.{ lot.account, kind_label, sym_for_display },
);
try cli.printFg(out, color, cli.CLR_POSITIVE, "+{s}", .{value_str});
try out.print(" on {s}\n", .{date_str});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an external contribution: no action needed.\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " If this was an internal transfer, add to transaction_log.srf:\n", .{});
// Amount formatted as a whole-dollar number for the `num:`
// encoding; precise-to-the-cent values are rare in practice
// and callers can edit the template if needed.
const amount_int: i64 = @intFromFloat(@round(lot.value));
if (lot.security_type == .cash) {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::cash\n",
.{ date_str, amount_int, lot.account },
);
} else {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" transfer::{s},type::cash,amount:num:{d},from::<SOURCE>,to::{s},dest_lot::{s}@{s}\n",
.{ date_str, amount_int, lot.account, lot.symbol, date_str },
);
}
}
/// Run the flagless portfolio hygiene check.
fn runHygieneCheck(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
stale_days: u32,
verbose: bool,
as_of: Date,
now_s: i64,
color: bool,
out: *std.Io.Writer,
) !void {
// Load portfolio
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
return;
};
defer account_map.deinit();
try cli.printBold(out, color, " Portfolio hygiene\n", .{});
// ── Section 1: Stale manual prices ──
var stale_count: usize = 0;
// Collect and display stale manual prices
{
var header_shown = false;
for (portfolio.lots) |lot| {
if (lot.price == null) continue;
const pd = lot.price_date orelse continue;
const age_days = as_of.days - pd.days;
const threshold: i32 = @intCast(stale_days);
if (age_days <= threshold) continue;
if (!header_shown) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
header_shown = true;
}
stale_count += 1;
var date_buf: [10]u8 = undefined;
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??";
const note_display = lot.note orelse "";
var price_buf: [24]u8 = undefined;
const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(lot.price.?)}) catch "$?";
try out.print(" {s:<16} {s:<16} {s:>10} {s} ", .{
lot.symbol,
note_display,
price_str,
date_str,
});
const clr = stalenessColor(age_days, stale_days);
try cli.printFg(out, color, clr, "({d} days)\n", .{@as(u32, @intCast(age_days))});
}
if (!header_shown) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Stale manual prices (>{d} days)\n", .{stale_days});
try cli.printFg(out, color, cli.CLR_POSITIVE, " (none)\n", .{});
}
}
// ── Section 2: Account cadence check ──
{
// Try to get committed version via git
const git = @import("../git.zig");
const repo_info: ?git.RepoInfo = git.findRepo(io, allocator, portfolio_path) catch null;
defer if (repo_info) |ri| {
allocator.free(ri.root);
allocator.free(ri.rel_path);
};
// Parse committed portfolio for diff (working copy vs HEAD)
var committed_portfolio: ?zfin.Portfolio = null;
defer if (committed_portfolio) |*cp| cp.deinit();
var committed_data: ?[]const u8 = null;
defer if (committed_data) |d| allocator.free(d);
if (repo_info) |ri| {
committed_data = git.show(io, allocator, ri.root, "HEAD", ri.rel_path) catch null;
if (committed_data) |cd| {
committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null;
}
}
// Find accounts modified in working copy (uncommitted changes)
var working_copy_modified = std.StringHashMap(void).init(allocator);
defer working_copy_modified.deinit();
if (committed_portfolio) |cp| {
working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator);
}
// Collect all unique account names from working copy portfolio
// (these pointers are stable for the lifetime of the function)
var all_accounts = std.StringHashMap(void).init(allocator);
defer all_accounts.deinit();
for (portfolio.lots) |lot| {
if (lot.account) |acct| {
all_accounts.put(acct, {}) catch {};
}
}
// Find last update time for each account via git history.
// Walk commits newest-to-oldest, diffing adjacent pairs to find
// which accounts changed. Use working-copy account names as keys
// (stable lifetime) rather than historical portfolio strings.
// Only walk back far enough to hit red status (2× max cadence).
var last_update_ts = std.StringHashMap(i64).init(allocator);
defer last_update_ts.deinit();
if (repo_info) |ri| {
// Compute the furthest we need to look back: 2× the max cadence
var max_threshold: u32 = 14; // 2× weekly default
for (account_map.entries) |entry| {
if (entry.update_cadence.thresholdDays()) |td| {
const red = td * stale_warning_multiplier;
if (red > max_threshold) max_threshold = red;
}
}
var since_buf: [32]u8 = undefined;
const since = std.fmt.bufPrint(&since_buf, "{d} days ago", .{max_threshold}) catch "30 days ago";
const commits = git.listCommitsTouching(io, allocator, ri.root, ri.rel_path, since) catch &.{};
defer git.freeCommitTouches(allocator, commits);
var prev_data: ?[]const u8 = null;
defer if (prev_data) |pd| allocator.free(pd);
for (commits, 0..) |ct, ci| {
// Stop early if every account already has a timestamp
if (last_update_ts.count() >= all_accounts.count()) break;
const rev_data = git.show(io, allocator, ri.root, ct.commit, ri.rel_path) catch continue;
if (ci > 0) {
if (prev_data) |pd| {
// rev_data is older, pd is newer (commits are newest-first)
var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch {
allocator.free(rev_data);
continue;
};
defer old_pf.deinit();
var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch continue;
defer new_pf.deinit();
var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch continue;
defer mods.deinit();
// The newer commit's timestamp is when these accounts were updated
const update_ts = commits[ci - 1].timestamp;
// Match against stable working-copy account names
var acct_iter = all_accounts.keyIterator();
while (acct_iter.next()) |stable_name| {
if (last_update_ts.contains(stable_name.*)) continue;
if (mods.contains(stable_name.*)) {
last_update_ts.put(stable_name.*, update_ts) catch {};
}
}
}
}
if (prev_data) |pd| allocator.free(pd);
prev_data = rev_data;
}
}
// Display overdue accounts
var overdue_header_shown = false;
var updated_accounts = std.ArrayList([]const u8).empty;
defer updated_accounts.deinit(allocator);
// Check accounts updated in working copy
var wc_it = working_copy_modified.keyIterator();
while (wc_it.next()) |key| {
try updated_accounts.append(allocator, key.*);
}
// Check overdue accounts
var acct_it = all_accounts.keyIterator();
while (acct_it.next()) |acct_key| {
const acct_name = acct_key.*;
// Skip if already updated in working copy
if (working_copy_modified.contains(acct_name)) continue;
// Look up cadence from accounts.srf
var cadence = analysis.UpdateCadence.weekly; // default
for (account_map.entries) |entry| {
if (std.mem.eql(u8, entry.account, acct_name)) {
cadence = entry.update_cadence;
break;
}
}
const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none'
// Find last update time
var age_days: ?i32 = null;
if (last_update_ts.get(acct_name)) |ts| {
const age_s = now_s - ts;
age_days = @intCast(@divFloor(age_s, std.time.s_per_day));
}
// If we have no git history for this account, it's definitely overdue
const days = age_days orelse @as(i32, @intCast(threshold_days + 1));
if (days <= @as(i32, @intCast(threshold_days))) continue;
if (!overdue_header_shown) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{});
overdue_header_shown = true;
}
try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() });
if (age_days) |ad| {
const clr = stalenessColor(ad, threshold_days);
try cli.printFg(out, color, clr, "last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
} else {
try cli.printFg(out, color, cli.CLR_NEGATIVE, "no update history found\n", .{});
}
}
// Display accounts updated in working copy
if (updated_accounts.items.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Accounts updated (working copy)\n", .{});
for (updated_accounts.items) |acct| {
try cli.printFg(out, color, cli.CLR_POSITIVE, " {s}\n", .{acct});
}
}
}
// ── Section 3: Discover brokerage files ──
// Resolve audit directories
const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";
var all_files = std.ArrayList(DiscoveredFile).empty;
defer {
for (all_files.items) |f| allocator.free(f.path);
all_files.deinit(allocator);
}
// Check $ZFIN_AUDIT_FILES first
const env_audit_dir = if (svc.config.environ_map) |em| em.get("ZFIN_AUDIT_FILES") else null;
if (env_audit_dir) |edir| {
const env_files = try discoverBrokerFiles(io, allocator, edir, "$ZFIN_AUDIT_FILES", now_s);
defer allocator.free(env_files);
for (env_files) |f| try all_files.append(allocator, f);
}
// Then check {portfolio_dir}/audit/
const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null;
defer if (default_audit_dir) |d| allocator.free(d);
if (default_audit_dir) |adir| {
const dir_files = try discoverBrokerFiles(io, allocator, adir, "audit/", now_s);
defer allocator.free(dir_files);
for (dir_files) |f| try all_files.append(allocator, f);
}
// Display discovered files
if (all_files.items.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
for (all_files.items) |f| {
const kind_label: []const u8 = switch (f.kind) {
.fidelity_csv => "fidelity",
.schwab_csv => "schwab csv",
.schwab_summary => "schwab summary",
};
try out.print(" {s:<52} {s}\n", .{ f.path, kind_label });
}
}
// ── Section 4: Auto-reconcile discovered files ──
if (all_files.items.len > 0) {
// Build prices map (shared by all reconciliations)
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
{
const pos_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(pos_syms);
if (pos_syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, false, color);
defer load_result.deinit();
var pit = load_result.prices.iterator();
while (pit.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
}
}
}
}
try out.print("\n", .{});
try cli.printBold(out, color, " Reconciliation\n", .{});
for (all_files.items) |f| {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, f.path, allocator, .limited(10 * 1024 * 1024)) catch continue;
defer allocator.free(file_data);
switch (f.kind) {
.schwab_summary => {
const schwab_accounts = parseSchwabSummary(allocator, file_data) catch continue;
defer allocator.free(schwab_accounts);
const results = compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of) catch continue;
defer allocator.free(results);
if (verbose or hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try displaySchwabResults(results, color, out);
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
} else {
var acct_count: usize = 0;
for (results) |r| {
if (r.account_name.len > 0) acct_count += 1;
}
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
// Always show ratio suggestions even in compact
// mode — direct-indexing drift may cause a
// non-zero delta that still deserves a nudge.
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
},
.fidelity_csv => {
const brokerage_positions = parseFidelityCsv(allocator, file_data) catch continue;
defer allocator.free(brokerage_positions);
const results = compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of) catch continue;
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
if (verbose or hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " fidelity: {d} accounts, no discrepancies\n", .{results.len});
// Always show ratio suggestions even in compact mode
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
},
.schwab_csv => {
const parsed = parseSchwabCsv(allocator, file_data) catch continue;
defer allocator.free(parsed.positions);
const results = compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of) catch continue;
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
if (verbose or hasAccountDiscrepancies(results)) {
try out.print("\n", .{});
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
} else {
try cli.printFg(out, color, cli.CLR_POSITIVE, " schwab: {d} accounts, no discrepancies\n", .{results.len});
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
},
}
}
}
// ── Section 5: Large new lots — confirm source ──
//
// Cross-check any new_* Change with value >= threshold against
// `transaction_log.srf` (via the shared contributions pipeline).
// Surfaces lots that look like significant external contributions
// OR unrecorded internal transfers — nudges the user to either
// confirm or add a transfer record.
//
// Silent when every large lot matched a transfer record, when
// there are no new lots at all, or when the pipeline can't run
// (not in a git repo). Threshold is a judgment call; see
// `audit_large_lot_threshold`.
if (contributions.findUnmatchedLargeLots(io, allocator, svc, portfolio_path, audit_large_lot_threshold, as_of, color)) |found| {
var found_mut = found;
defer found_mut.deinit();
if (found_mut.lots.len > 0) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Large new lots — confirm source\n", .{});
for (found_mut.lots) |lot| {
try printLargeLotWarning(out, lot, color);
}
}
}
try out.print("\n", .{});
}
/// Check if any Schwab summary results have discrepancies.
fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
for (results) |r| {
if (r.has_discrepancy) return true;
}
return false;
}
/// Check if any account comparison results have discrepancies.
fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
for (results) |r| {
if (r.has_discrepancies) return true;
}
return false;
}
// ── CLI entry point ─────────────────────────────────────────
pub const ParsedArgs = struct {
fidelity_csv: ?[]const u8 = null,
schwab_csv: ?[]const u8 = null,
schwab_summary: bool = false,
verbose: bool = false,
stale_days: u32 = default_stale_days,
};
pub const meta: framework.Meta = .{
.name = "audit",
.group = .hygiene,
.synopsis = "Reconcile portfolio against brokerage exports + portfolio hygiene check",
.help =
\\Usage: zfin audit [opts]
\\
\\Two modes in one command:
\\
\\ Flagless: run the portfolio hygiene check — surfaces stale
\\ manual prices, account-cadence violations, and brokerage-file
\\ candidates discovered automatically.
\\
\\ With brokerage flags: reconcile the portfolio against the
\\ given export and report discrepancies.
\\
\\Options:
\\ --verbose Show full reconciliation output even when clean
\\ --stale-days <N> Manual price staleness threshold (default 3)
\\ --fidelity <CSV> Fidelity positions CSV export
\\ ("All accounts" → Positions tab → Download)
\\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary; copy from accounts
\\ summary page, paste to stdin, then ^D
\\
,
.uppercase_first_arg = false,
};
comptime {
framework.validateCommandModule(@This());
}
pub fn parseArgs(_: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
if (std.mem.eql(u8, cmd_args[i], "--fidelity") and i + 1 < cmd_args.len) {
i += 1;
parsed.fidelity_csv = cmd_args[i];
} else if (std.mem.eql(u8, cmd_args[i], "--schwab") and i + 1 < cmd_args.len) {
i += 1;
parsed.schwab_csv = cmd_args[i];
} else if (std.mem.eql(u8, cmd_args[i], "--schwab-summary")) {
parsed.schwab_summary = true;
} else if (std.mem.eql(u8, cmd_args[i], "--verbose")) {
parsed.verbose = true;
} else if (std.mem.eql(u8, cmd_args[i], "--stale-days") and i + 1 < cmd_args.len) {
i += 1;
parsed.stale_days = std.fmt.parseInt(u32, cmd_args[i], 10) catch default_stale_days;
}
}
return parsed;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
const now_s = ctx.now_s;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const portfolio_path = pf.path;
const fidelity_csv = parsed.fidelity_csv;
const schwab_csv = parsed.schwab_csv;
const schwab_summary = parsed.schwab_summary;
const verbose = parsed.verbose;
const stale_days = parsed.stale_days;
// Flagless mode: run portfolio hygiene check
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
return runHygieneCheck(io, allocator, svc, portfolio_path, stale_days, verbose, as_of, now_s, color, out);
}
// Load portfolio
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
return;
};
defer account_map.deinit();
// Build prices map, shared by all audit modes.
//
// Route through `cli.loadPortfolioPrices` so the audit gets the same
// TTL-based cache refresh behavior `zfin portfolio` uses. Previously
// this read cached last-closes directly, which silently used stale
// data after long weekends / when the cache hadn't been refreshed.
// TTL-driven refetch keeps numbers current without forcing a full
// provider hit every run.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
{
const pos_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(pos_syms);
if (pos_syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, pos_syms, &.{}, false, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
// Manual `price::` overrides from portfolio.srf still win for lots
// that carry them (e.g. 401k CIT shares with no API coverage).
for (portfolio.lots) |lot| {
if (lot.price) |p| {
if (!prices.contains(lot.priceSymbol())) {
// Pre-multiply — see "Pricing model" in models/portfolio.zig.
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
}
}
}
}
// Schwab summary from stdin
if (schwab_summary) {
try cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n");
var stdin_reader_buf: [4096]u8 = undefined;
var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf);
const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read stdin\n");
return;
};
defer allocator.free(stdin_data);
const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n");
return;
};
defer allocator.free(schwab_accounts);
const results = try compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
defer allocator.free(results);
try displaySchwabResults(results, color, out);
try displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
// Fidelity CSV
if (fidelity_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
try cli.stderrPrint(io, msg);
return;
};
defer allocator.free(csv_data);
const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n");
return;
};
defer allocator.free(brokerage_positions);
const results = try compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices, as_of);
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
// Schwab per-account CSV
if (schwab_csv) |csv_path| {
const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n";
try cli.stderrPrint(io, msg);
return;
};
defer allocator.free(csv_data);
const csv = parseSchwabCsv(allocator, csv_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
return;
};
defer allocator.free(csv.positions);
const results = try compareAccounts(allocator, portfolio, csv.positions, account_map, "schwab", prices, as_of);
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try displayResults(results, color, out);
try displayRatioSuggestions(results, portfolio, prices, account_map, color, out);
}
}
// ── Tests ────────────────────────────────────────────────────
test "parseArgs: defaults" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.fidelity_csv == null);
try std.testing.expect(parsed.schwab_csv == null);
try std.testing.expect(!parsed.schwab_summary);
try std.testing.expect(!parsed.verbose);
try std.testing.expectEqual(default_stale_days, parsed.stale_days);
}
test "parseArgs: --fidelity captures CSV path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
}
test "parseArgs: --schwab captures CSV path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--schwab", "/tmp/sch.csv" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?);
}
test "parseArgs: --schwab-summary boolean" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--schwab-summary"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.schwab_summary);
}
test "parseArgs: --verbose boolean" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--verbose"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.verbose);
}
test "parseArgs: --stale-days parses integer" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--stale-days", "5" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
}
test "parseDollarAmount" {
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01);
try std.testing.expect(parseDollarAmount("") == null);
try std.testing.expect(parseDollarAmount(" ") == null);
try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001);
}
test "parseFidelityCsv basic" {
const csv =
"\xEF\xBB\xBF" ++ // BOM
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\r\n" ++
"Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++
"\r\n" ++
"\"Disclaimer text\"\r\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 2), positions.len);
// Cash position
try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
try std.testing.expect(positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01);
try std.testing.expect(positions[0].quantity == null);
// Stock position
try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
try std.testing.expect(!positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
}
test "parseFidelityCsv treats $1.00 price+cost as cash" {
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual,FDRXX,FID GOV CASH RESERVE,8500,$1.00,,$8500.00,,,,,10%,$8500.00,$1.00,Cash,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("FDRXX", positions[0].symbol);
try std.testing.expect(positions[0].is_cash);
try std.testing.expect(positions[0].quantity == null);
try std.testing.expectApproxEqAbs(@as(f64, 8500.00), positions[0].current_value.?, 0.01);
}
test "parseFidelityCsv stops at blank line" {
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual,AAPL,APPLE INC,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++
"\n" ++
"Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
}
test "parseFidelityCsv option row" {
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01);
}
test "parseFidelityCsv empty file" {
const allocator = std.testing.allocator;
const result = parseFidelityCsv(allocator, "");
try std.testing.expectError(error.EmptyFile, result);
}
test "parseFidelityCsv wrong header" {
const allocator = std.testing.allocator;
const result = parseFidelityCsv(allocator, "Wrong,Header,Format\n");
try std.testing.expectError(error.UnexpectedHeader, result);
}
test "parseFidelityCsv cash account type is not cash position" {
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
// This does NOT mean the security is a cash holding — only ** suffix means that.
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
const allocator = std.testing.allocator;
const positions = try parseFidelityCsv(allocator, csv);
defer allocator.free(positions);
try std.testing.expectEqual(@as(usize, 1), positions.len);
try std.testing.expectEqualStrings("QTUM", positions[0].symbol);
try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
}
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);
}
test "parseSchwabSummary basic" {
const data =
\\Emil Roth
\\Account number ending in 901 ...901
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\Inherited IRA
\\Account number ending in 503 ...503
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
;
const allocator = std.testing.allocator;
const accounts = try parseSchwabSummary(allocator, data);
defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 2), accounts.len);
try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name);
try std.testing.expectEqualStrings("901", accounts[0].account_number);
try std.testing.expectApproxEqAbs(@as(f64, 46.44), accounts[0].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01);
try std.testing.expectEqualStrings("Inherited IRA", accounts[1].account_name);
try std.testing.expectEqualStrings("503", accounts[1].account_number);
try std.testing.expectApproxEqAbs(@as(f64, 2461.82), accounts[1].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 0.01);
}
test "parseSchwabSummary tolerates missing headers and extra blank lines" {
const data =
\\
\\Joint trust
\\Account number ending in 716 ...716
\\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47%
\\
\\Tax Loss
\\Account number ending in 311 ...311
\\$4,654.15 $488,481.18 +$1,686.91 +0.35%
;
const allocator = std.testing.allocator;
const accounts = try parseSchwabSummary(allocator, data);
defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 2), accounts.len);
try std.testing.expectEqualStrings("Joint trust", accounts[0].account_name);
try std.testing.expectEqualStrings("716", accounts[0].account_number);
// Second account has no "Type" prefix — parser still finds dollar amounts
try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name);
try std.testing.expectApproxEqAbs(@as(f64, 4654.15), accounts[1].cash.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 0.01);
}
test "parseSchwabSummary skips summary footer" {
const data =
\\Mom
\\Account number ending in 152 ...152
\\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47%
\\Investment Total
\\$22,070.35
\\$4,338,116.38
\\Day Change Total
\\+$31,633.86
;
const allocator = std.testing.allocator;
const accounts = try parseSchwabSummary(allocator, data);
defer allocator.free(accounts);
try std.testing.expectEqual(@as(usize, 1), accounts.len);
try std.testing.expectEqualStrings("Mom", accounts[0].account_name);
}
test "parseSchwabSummary no accounts" {
const allocator = std.testing.allocator;
const result = parseSchwabSummary(allocator, "some random text\nno accounts here\n");
try std.testing.expectError(error.NoAccountsFound, result);
}
test "parseSchwabTitle" {
const t1 = parseSchwabTitle("\"Positions for account Joint trust ...716 as of 10:47 AM ET, 2026/04/10\"");
try std.testing.expect(t1 != null);
try std.testing.expectEqualStrings("Joint trust", t1.?.name);
try std.testing.expectEqualStrings("716", t1.?.number);
const t2 = parseSchwabTitle("\"Positions for account Emil IRA ...118 as of 3:00 PM ET, 2026/04/10\"");
try std.testing.expect(t2 != null);
try std.testing.expectEqualStrings("Emil IRA", t2.?.name);
try std.testing.expectEqualStrings("118", t2.?.number);
try std.testing.expect(parseSchwabTitle("some random text") == null);
}
test "splitSchwabCsvLine" {
var cols: [schwab_expected_columns][]const u8 = undefined;
const n = splitSchwabCsvLine("\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",", &cols);
try std.testing.expectEqual(@as(usize, 17), n);
try std.testing.expectEqualStrings("AMZN", cols[0]);
try std.testing.expectEqualStrings("AMAZON.COM INC", cols[1]);
try std.testing.expectEqualStrings("1,488", cols[5]);
try std.testing.expectEqualStrings("$355,941.50", cols[8]);
try std.testing.expectEqualStrings("Equity", cols[16]);
}
test "parseSchwabCsv basic" {
const csv =
"\"Positions for account Joint trust ...716 as of 10:47 AM ET, 2026/04/10\"\n" ++
"\n" ++
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n" ++
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
const allocator = std.testing.allocator;
const parsed = try parseSchwabCsv(allocator, csv);
defer allocator.free(parsed.positions);
try std.testing.expectEqualStrings("Joint trust", parsed.account_name);
try std.testing.expectEqualStrings("716", parsed.account_number);
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
// Stock position
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
try std.testing.expect(!parsed.positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
// Cash
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
try std.testing.expect(parsed.positions[1].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
try std.testing.expect(parsed.positions[1].quantity == null);
}
// ── 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 "fidelityOptionMatchesLot basic call" {
const lot = portfolio_mod.Lot{
.symbol = "AMZN 05/15/2026 220.00 C",
.security_type = .option,
.underlying = "AMZN",
.strike = 220.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 5, 15),
.shares = -3,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 8.75,
};
// Fidelity format with leading dash (short)
try std.testing.expect(fidelityOptionMatchesLot("-AMZN260515C220", lot));
// Without dash
try std.testing.expect(fidelityOptionMatchesLot("AMZN260515C220", lot));
// Wrong underlying
try std.testing.expect(!fidelityOptionMatchesLot("-MSFT260515C220", lot));
// Wrong date
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260615C220", lot));
// Wrong type
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515P220", lot));
// Wrong strike
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C230", lot));
// Non-option lot
const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C220", stock_lot));
}
test "fidelityOptionMatchesLot put option and decimal strike" {
const lot = portfolio_mod.Lot{
.symbol = "AAPL 06/20/2026 220.50 P",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.50,
.option_type = .put,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(fidelityOptionMatchesLot("-AAPL260620P220.50", lot));
try std.testing.expect(fidelityOptionMatchesLot("AAPL260620P220.50", lot));
// Call doesn't match put
try std.testing.expect(!fidelityOptionMatchesLot("-AAPL260620C220.50", lot));
}
test "fidelityOptionMatchesLot single-char underlying" {
const lot = portfolio_mod.Lot{
.symbol = "A 03/20/2026 150.00 C",
.security_type = .option,
.underlying = "A",
.strike = 150.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 3, 20),
.shares = -2,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 3.0,
};
try std.testing.expect(fidelityOptionMatchesLot("-A260320C150", lot));
try std.testing.expect(!fidelityOptionMatchesLot("-A260320P150", lot));
}
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 = "Emil 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 "Emil IRA"
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Emil 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 "detectBrokerFileKind: fidelity csv" {
const fidelity_header = "Account Number,Account Name,Symbol,Description";
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
}
test "detectBrokerFileKind: fidelity csv with BOM" {
const fidelity_bom = "\xEF\xBB\xBFAccount Number,Account Name,Symbol";
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?);
}
test "detectBrokerFileKind: schwab csv" {
const schwab_header = "\"Positions for account Roth IRA ...716 as of\"";
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?);
}
test "detectBrokerFileKind: schwab summary" {
const schwab_summary_data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00";
try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(schwab_summary_data).?);
}
test "detectBrokerFileKind: unknown file" {
const random_data = "This is just some random text that doesn't match any pattern";
try std.testing.expect(detectBrokerFileKind(random_data) == null);
}
test "stalenessColor: within threshold" {
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3));
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3));
}
test "stalenessColor: warning zone (1-2x threshold)" {
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3));
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3));
}
test "stalenessColor: critical zone (>2x threshold)" {
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3));
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3));
}
test "UpdateCadence thresholdDays" {
try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays());
try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays());
try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays());
try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null);
}
test "findModifiedAccounts: detects share changes" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("Acct A"));
try std.testing.expect(!modified.contains("Acct B"));
}
test "findModifiedAccounts: detects new lots" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
.{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" },
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("Acct A"));
}
test "findModifiedAccounts: detects price changes" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) },
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("401k"));
}
test "findModifiedAccounts: detects removed lots" {
const allocator = std.testing.allocator;
var old_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
.{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" },
};
var new_lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
// VTI removed
};
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
defer modified.deinit();
try std.testing.expect(modified.contains("Acct A"));
}
test "findModifiedAccounts: no changes" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
};
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var modified = try findModifiedAccounts(allocator, pf, pf);
defer modified.deinit();
try std.testing.expectEqual(@as(u32, 0), modified.count());
}
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));
}
test "hasSchwabDiscrepancies" {
const clean = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.account_number = "716",
.portfolio_cash = 100,
.schwab_cash = 100,
.cash_delta = 0,
.portfolio_total = 5000,
.schwab_total = 5000,
.total_delta = 0,
.has_discrepancy = false,
}};
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
const dirty = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.account_number = "716",
.portfolio_cash = 100,
.schwab_cash = 200,
.cash_delta = 100,
.portfolio_total = 5000,
.schwab_total = 5100,
.total_delta = 100,
.has_discrepancy = true,
}};
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
}
test "detectBrokerFileKind: schwab csv with Positions header" {
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
}
test "detectBrokerFileKind: schwab summary with Roth IRA" {
const data = "Roth IRA ...716\nSome text\n$50,000.00\n";
try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?);
}
test "UpdateCadence label" {
try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label());
try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label());
try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label());
try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label());
}
test "discoverBrokerFiles: finds files in temp directory" {
const io = std.testing.io;
const allocator = std.testing.allocator;
// Create a temp directory with test files
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Write a fidelity CSV
tmp.dir.writeFile(io, .{
.sub_path = "fidelity.csv",
.data = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value\nZ123,Test,AAPL,Apple,100,200,20000\n",
}) catch unreachable;
// Write a schwab summary (non-CSV)
tmp.dir.writeFile(io, .{
.sub_path = "schwab.txt",
.data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00\n",
}) catch unreachable;
// Write a random non-matching file
tmp.dir.writeFile(io, .{
.sub_path = "notes.txt",
.data = "Just some random notes",
}) catch unreachable;
// Get the temp dir path
const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable;
defer allocator.free(tmp_path);
// wall-clock required: test writes real files and verifies they're
// treated as fresh. A fixed synthetic `now_s` would drift relative
// to the file mtime and produce flaky results.
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s);
defer {
for (files) |f| allocator.free(f.path);
allocator.free(files);
}
// Should find fidelity CSV and schwab summary, but not notes.txt
try std.testing.expectEqual(@as(usize, 2), files.len);
var found_fidelity = false;
var found_schwab = false;
for (files) |f| {
switch (f.kind) {
.fidelity_csv => found_fidelity = true,
.schwab_summary => found_schwab = true,
else => {},
}
}
try std.testing.expect(found_fidelity);
try std.testing.expect(found_schwab);
}
test "discoverBrokerFiles: empty directory returns empty" {
const io = std.testing.io;
const allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const tmp_path = tmp.dir.realPathFileAlloc(io, ".", allocator) catch unreachable;
defer allocator.free(tmp_path);
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
const files = try discoverBrokerFiles(io, allocator, tmp_path, "test/", now_s);
defer allocator.free(files);
try std.testing.expectEqual(@as(usize, 0), files.len);
}
test "discoverBrokerFiles: nonexistent directory returns empty" {
const io = std.testing.io;
const allocator = std.testing.allocator;
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
const files = try discoverBrokerFiles(io, allocator, "/nonexistent/path/audit", "test/", now_s);
defer allocator.free(files);
try std.testing.expectEqual(@as(usize, 0), files.len);
}
test "printLargeLotWarning: cash destination emits dest_lot::cash template" {
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Acct A",
.symbol = "",
.security_type = .cash,
.value = 50_000.0,
.open_date = Date.fromYmd(2026, 5, 10),
};
try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes
const output = writer.buffered();
// Header line with account + value + date.
try std.testing.expect(std.mem.indexOf(u8, output, "Acct A: new CASH lot cash") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$50,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "on 2026-05-10") != null);
// Template line with the expected SRF shape.
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-10,type::cash,amount:num:50000,from::<SOURCE>,to::Acct A,dest_lot::cash") != null);
}
test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" {
var buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&buf);
const lot: contributions.UnmatchedLargeLot = .{
.account = "Acct B",
.symbol = "SYM",
.security_type = .stock,
.value = 25_000.0,
.open_date = Date.fromYmd(2026, 5, 3),
};
try printLargeLotWarning(&writer, lot, false);
const output = writer.buffered();
try std.testing.expect(std.mem.indexOf(u8, output, "Acct B: new STOCK lot SYM") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+$25,000.00") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
}
test "isUnitPriceCash: $1.00 + $1.00 returns true" {
try std.testing.expect(isUnitPriceCash("$1.00", "$1.00"));
try std.testing.expect(isUnitPriceCash("1.00", "1.00"));
try std.testing.expect(isUnitPriceCash("$1", "$1"));
}
test "isUnitPriceCash: non-$1 price returns false" {
try std.testing.expect(!isUnitPriceCash("$1.01", "$1.00"));
try std.testing.expect(!isUnitPriceCash("$1.00", "$1.01"));
try std.testing.expect(!isUnitPriceCash("$150.00", "$120.00"));
try std.testing.expect(!isUnitPriceCash("$0.99", "$1.00"));
}
test "isUnitPriceCash: unparseable inputs return false" {
try std.testing.expect(!isUnitPriceCash("", "$1.00"));
try std.testing.expect(!isUnitPriceCash("$1.00", ""));
try std.testing.expect(!isUnitPriceCash("N/A", "$1.00"));
}
test "strLessThan: orders strings lexicographically" {
try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
try std.testing.expect(!strLessThan({}, "AAPL", "AAPL"));
try std.testing.expect(strLessThan({}, "AAPL", "AAPLE"));
}
test "lotToString: stock lot includes symbol, shares, date" {
const allocator = std.testing.allocator;
const lot = portfolio_mod.Lot{
.symbol = "AAPL",
.shares = 100,
.open_date = Date.fromYmd(2024, 3, 15),
.open_price = 150.50,
};
const s = try lotToString(allocator, lot);
defer allocator.free(s);
try std.testing.expect(std.mem.indexOf(u8, s, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, s, "100") != null);
try std.testing.expect(std.mem.indexOf(u8, s, "2024-03-15") != null);
}
test "compareSchwabSummary: matching account → no discrepancy" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
// With AAPL price=200, total = 5000 + 10*200 = 7000.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Emil Brokerage",
},
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150,
.account = "Emil Brokerage",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]SchwabAccountSummary{
.{
.account_name = "Emil Brokerage",
.account_number = "1234",
.cash = 5000.0,
.total_value = 7000.0,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Emil Brokerage",
.tax_type = .taxable,
.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("AAPL", 200.0);
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Emil Brokerage", results[0].account_name);
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: cash mismatch → has_discrepancy true" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
// Portfolio cash = 5000, Schwab reports 5500 → $500 delta.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Brokerage",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]SchwabAccountSummary{
.{
.account_name = "Brokerage",
.account_number = "1234",
.cash = 5500.0,
.total_value = 5500.0,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Brokerage",
.tax_type = .taxable,
.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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
try std.testing.expect(results[0].has_discrepancy);
}
test "compareSchwabSummary: account_number with no match → empty account_name" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]portfolio_mod.Lot{};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]SchwabAccountSummary{
.{
.account_name = "Unknown Acct",
.account_number = "9999",
.cash = 1000.0,
.total_value = 1000.0,
},
};
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("", results[0].account_name);
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
// No portfolio match → cash and total are zero, schwab values become deltas
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
}
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "X",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
const schwab_accounts = [_]SchwabAccountSummary{
.{
.account_name = "X",
.account_number = "1234",
.cash = null,
.total_value = null,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "X",
.tax_type = .taxable,
.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 compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expect(results[0].cash_delta == null);
try std.testing.expect(results[0].total_delta == null);
// Null deltas are treated as "ok" (no discrepancy possible to assert).
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: today affects valuation of held assets" {
const allocator = std.testing.allocator;
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
// open), it's not held → portfolio_total excludes it. With
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 150,
.account = "Acct",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]SchwabAccountSummary{
.{
.account_name = "Acct",
.account_number = "1234",
.cash = 0,
.total_value = 2000,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Acct",
.tax_type = .taxable,
.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("AAPL", 200.0);
// Before open: portfolio holds nothing for this account.
{
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
defer allocator.free(results);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
}
// After open: portfolio holds 10 * 200 = 2000.
{
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
defer allocator.free(results);
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
// Matches schwab → no discrepancy.
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
try std.testing.expect(!results[0].has_discrepancy);
}
}