master audit command to do all the things
All checks were successful
Generic zig build / build (push) Successful in 2m0s
Generic zig build / deploy (push) Successful in 15s

This commit is contained in:
Emil Lerch 2026-04-25 14:58:37 -07:00
parent 4df334896c
commit a2271e3582
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 986 additions and 9 deletions

View file

@ -41,6 +41,35 @@ pub const AccountTaxEntry = struct {
tax_type: TaxType,
institution: ?[]const u8 = null,
account_number: ?[]const u8 = null,
update_cadence: UpdateCadence = .weekly,
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
pub const UpdateCadence = enum {
weekly,
monthly,
quarterly,
none,
/// Number of calendar days before an account is considered overdue.
pub fn thresholdDays(self: UpdateCadence) ?u32 {
return switch (self) {
.weekly => 7,
.monthly => 30,
.quarterly => 90,
.none => null,
};
}
pub fn label(self: UpdateCadence) []const u8 {
return switch (self) {
.weekly => "weekly",
.monthly => "monthly",
.quarterly => "quarterly",
.none => "none",
};
}
};
/// Parsed account metadata.
@ -117,6 +146,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
.tax_type = entry.tax_type,
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
.update_cadence = entry.update_cadence,
});
}

View file

@ -1400,12 +1400,664 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
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
/// 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(
allocator: std.mem.Allocator,
dir_path: []const u8,
dir_label: []const u8,
) ![]DiscoveredFile {
var results = std.ArrayList(DiscoveredFile).empty;
defer results.deinit(allocator);
var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator);
defer dir.close();
const now_ts = std.time.timestamp();
const max_age_s: i128 = audit_file_max_age_hours * 3600;
var it = dir.iterate();
while (try it.next()) |entry| {
if (entry.kind != .file) continue;
// Check file modification time
const stat = dir.statFile(entry.name) catch continue;
const mtime_s: i128 = @divFloor(stat.mtime, std.time.ns_per_s);
const age_s = now_ts - 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(allocator, entry.name, 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;
}
/// Run the flagless portfolio hygiene check.
fn runHygieneCheck(
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
stale_days: u32,
verbose: bool,
color: bool,
out: *std.Io.Writer,
) !void {
// Load portfolio
const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint("Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint("Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
return;
};
defer account_map.deinit();
try cli.setBold(out, color);
try out.print(" Portfolio hygiene\n", .{});
try cli.reset(out, color);
// Section 1: Stale manual prices
const today = fmt.todayDate();
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 = today.days - pd.days;
const threshold: i32 = @intCast(stale_days);
if (age_days <= threshold) continue;
if (!header_shown) {
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
try cli.reset(out, color);
header_shown = true;
}
stale_count += 1;
var date_buf: [10]u8 = undefined;
const date_str = pd.format(&date_buf);
const note_display = lot.note orelse "";
var price_buf: [24]u8 = undefined;
const price_str = fmt.fmtMoneyAbs(&price_buf, lot.price.?);
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.setFg(out, color, clr);
try out.print("({d} days)\n", .{@as(u32, @intCast(age_days))});
try cli.reset(out, color);
}
if (!header_shown) {
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Stale manual prices (>{d} days)\n", .{stale_days});
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" (none)\n", .{});
try cli.reset(out, color);
}
}
// Section 2: Account cadence check
{
// Try to get committed version via git
const git = @import("../git.zig");
const repo_info: ?git.RepoInfo = git.findRepo(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(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(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(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
const now_ts = std.time.timestamp();
var age_days: ?i32 = null;
if (last_update_ts.get(acct_name)) |ts| {
const age_s = now_ts - 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.setFg(out, color, cli.CLR_MUTED);
try out.print(" Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{});
try cli.reset(out, color);
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.setFg(out, color, clr);
try out.print("last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
} else {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
try out.print("no update history found\n", .{});
}
try cli.reset(out, color);
}
// Display accounts updated in working copy
if (updated_accounts.items.len > 0) {
try out.print("\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Accounts updated (working copy)\n", .{});
try cli.reset(out, color);
for (updated_accounts.items) |acct| {
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" {s}\n", .{acct});
try cli.reset(out, color);
}
}
}
// 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 = std.posix.getenv("ZFIN_AUDIT_FILES");
if (env_audit_dir) |edir| {
const env_files = try discoverBrokerFiles(allocator, edir, "$ZFIN_AUDIT_FILES");
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(allocator, adir, "audit/");
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.setFg(out, color, cli.CLR_MUTED);
try out.print(" Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
try cli.reset(out, color);
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(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.setBold(out, color);
try out.print(" Reconciliation\n", .{});
try cli.reset(out, color);
for (all_files.items) |f| {
const file_data = std.fs.cwd().readFileAlloc(allocator, f.path, 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) catch continue;
defer allocator.free(results);
if (verbose or hasSchwabDiscrepancies(results)) {
try out.print("\n", .{});
try displaySchwabResults(results, color, out);
} else {
var acct_count: usize = 0;
for (results) |r| {
if (r.account_name.len > 0) acct_count += 1;
}
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
try cli.reset(out, color);
}
},
.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) 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, color, out);
} else {
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" fidelity: {d} accounts, no discrepancies\n", .{results.len});
try cli.reset(out, color);
// Always show ratio suggestions even in compact mode
try displayRatioSuggestions(results, portfolio, prices, 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) 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, color, out);
} else {
try cli.setFg(out, color, cli.CLR_POSITIVE);
try out.print(" schwab: {d} accounts, no discrepancies\n", .{results.len});
try cli.reset(out, color);
try displayRatioSuggestions(results, portfolio, prices, color, out);
}
},
}
}
}
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 fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
var fidelity_csv: ?[]const u8 = null;
var schwab_csv: ?[]const u8 = null;
var schwab_summary = false;
var verbose = false;
var stale_days: u32 = default_stale_days;
var i: usize = 0;
while (i < args.len) : (i += 1) {
@ -1417,19 +2069,17 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path:
schwab_csv = args[i];
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
schwab_summary = true;
} else if (std.mem.eql(u8, args[i], "--verbose")) {
verbose = true;
} else if (std.mem.eql(u8, args[i], "--stale-days") and i + 1 < args.len) {
i += 1;
stale_days = std.fmt.parseInt(u32, args[i], 10) catch default_stale_days;
}
}
// Flagless mode: run portfolio hygiene check
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
try cli.stderrPrint(
\\Usage: zfin [-p <portfolio.srf>] audit [options]
\\
\\ --fidelity <csv> Fidelity positions CSV export
\\ --schwab <csv> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
\\
);
return;
return runHygieneCheck(allocator, svc, portfolio_path, stale_days, verbose, color, out);
}
// Load portfolio
@ -2045,3 +2695,297 @@ test "option delta tracking in compareAccounts" {
}
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 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(.{
.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(.{
.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(.{
.sub_path = "notes.txt",
.data = "Just some random notes",
}) catch unreachable;
// Get the temp dir path
const tmp_path = tmp.dir.realpathAlloc(allocator, ".") catch unreachable;
defer allocator.free(tmp_path);
const files = try discoverBrokerFiles(allocator, tmp_path, "test/");
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 allocator = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const tmp_path = tmp.dir.realpathAlloc(allocator, ".") catch unreachable;
defer allocator.free(tmp_path);
const files = try discoverBrokerFiles(allocator, tmp_path, "test/");
defer allocator.free(files);
try std.testing.expectEqual(@as(usize, 0), files.len);
}
test "discoverBrokerFiles: nonexistent directory returns empty" {
const allocator = std.testing.allocator;
const files = try discoverBrokerFiles(allocator, "/nonexistent/path/audit", "test/");
defer allocator.free(files);
try std.testing.expectEqual(@as(usize, 0), files.len);
}

View file

@ -56,6 +56,9 @@ const usage =
\\ --rebuild-rollup (Re)write history/rollup.srf and exit
\\
\\Audit command options:
\\ (no flags) Portfolio hygiene check + auto-reconcile discovered files
\\ --verbose Show full reconciliation output even when clean
\\ --stale-days <N> Manual price staleness threshold (default: 3)
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
\\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)