diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index b4ac84e..f703cad 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -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, }); } diff --git a/src/commands/audit.zig b/src/commands/audit.zig index a60fcf4..e30c548 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -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 ] audit [options] - \\ - \\ --fidelity Fidelity positions CSV export - \\ --schwab 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); +} diff --git a/src/main.zig b/src/main.zig index fbcda28..4bb3d2b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 Manual price staleness threshold (default: 3) \\ --fidelity Fidelity positions CSV export (download from "All accounts" positions tab) \\ --schwab Schwab per-account positions CSV export \\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)