//! `zfin audit` command dispatcher. //! //! Thin entry point: argument parsing plus routing to one of the //! per-responsibility modules in the `audit/` directory: //! //! - `audit/hygiene.zig` - flagless portfolio hygiene check (no flags) //! - `audit/fidelity.zig` - `--fidelity` positions-CSV reconciler //! - `audit/schwab.zig` - `--schwab` positions-CSV + `--schwab-summary` //! reconcilers //! - `audit/common.zig` - shared comparison types + per-account display //! //! This file sits beside its `audit/` directory (the `tui.zig` + //! `tui/` convention), not as a `mod.zig` inside it. //! //! The split keeps each broker's reconcile/display logic in its own //! file so adding a broker is a one-file add next to the others, and //! so a future `zfin doctor` can reuse the hygiene check without //! pulling in the reconciliation surface. const std = @import("std"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const common = @import("audit/common.zig"); const fidelity = @import("audit/fidelity.zig"); const schwab = @import("audit/schwab.zig"); const hygiene = @import("audit/hygiene.zig"); // ── 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 = hygiene.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 Manual price staleness threshold (default 3) \\ --fidelity Fidelity positions CSV export \\ ("All accounts" -> Positions tab -> Download) \\ --schwab 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, .user_errors = error{ UnexpectedArg, EmptyFile, NoAccountsFound, UnexpectedHeader, MissingFlagValue }, }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { var parsed: ParsedArgs = .{}; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { const a = cmd_args[i]; if (std.mem.eql(u8, a, "--fidelity")) { parsed.fidelity_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); } else if (std.mem.eql(u8, a, "--schwab")) { parsed.schwab_csv = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); } else if (std.mem.eql(u8, a, "--schwab-summary")) { parsed.schwab_summary = true; } else if (std.mem.eql(u8, a, "--verbose")) { parsed.verbose = true; } else if (std.mem.eql(u8, a, "--stale-days")) { const v = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); // Reject a non-integer value loudly instead of silently // falling back to the default; a typo'd threshold that // silently reverts to 3 is a foot-gun. parsed.stale_days = std.fmt.parseInt(u32, v, 10) catch { cli.stderrPrint(ctx.io, "Error: --stale-days requires a non-negative integer, got: "); cli.stderrPrint(ctx.io, v); cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; }; } else { // Unknown flag or stray positional: reject explicitly // rather than silently ignoring it (which previously let // `audit --bogus` run flagless hygiene mode with no error). cli.stderrPrint(ctx.io, "Error: unknown argument to 'audit': "); cli.stderrPrint(ctx.io, a); cli.stderrPrint(ctx.io, "\nRun 'zfin audit --help' for usage.\n"); return error.UnexpectedArg; } } 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 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 (single-file // semantics - git blame, commit SHAs, etc.). Resolve paths // just to find the anchor; we don't need the merged view. if (fidelity_csv == null and schwab_csv == null and !schwab_summary) { const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); return hygiene.runHygieneCheck(io, allocator, ctx.environ_map, svc, pf.path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out); } // Reconciliation modes (--fidelity / --schwab / --schwab-summary): // load the union of all portfolio files so the comparison sees // every lot the user holds, even if they're split across multiple // portfolio_*.srf files. var loaded = cli.loadPortfolio(ctx, as_of) orelse return; defer loaded.deinit(allocator); const portfolio = loaded.portfolio; const portfolio_path = loaded.anchor(); // Load accounts.srf var account_map = svc.loadAccountMap(allocator, portfolio_path) orelse { 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, &.{}, ctx.globals.refresh_policy, 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) { 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 { cli.stderrPrint(io, "Error: Cannot read stdin\n"); return; }; defer allocator.free(stdin_data); const results = schwab.reconcileSummary(allocator, portfolio, stdin_data, account_map, prices, as_of) catch |err| switch (err) { error.OutOfMemory => return err, else => { cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); return; }, }; defer allocator.free(results); try schwab.displaySchwabResults(results, color, out); try schwab.displaySchwabSummaryRatioSuggestions(results, portfolio, prices, account_map, color, out); const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results); defer allocator.free(present); const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); defer allocator.free(absent); try common.displayAbsentAccounts(absent, 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"; cli.stderrPrint(io, msg); return; }; defer allocator.free(csv_data); const results = fidelity.reconcile(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) { error.OutOfMemory => return err, else => { cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); return; }, }; defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } try common.displayResults(results, color, out); try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); const present = try common.presentNumbers(allocator, common.AccountComparison, results); defer allocator.free(present); const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "fidelity", present, prices, as_of); defer allocator.free(absent); try common.displayAbsentAccounts(absent, 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"; cli.stderrPrint(io, msg); return; }; defer allocator.free(csv_data); const results = schwab.reconcileCsv(allocator, portfolio, csv_data, account_map, prices, as_of) catch |err| switch (err) { error.OutOfMemory => return err, else => { cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); return; }, }; defer { for (results) |r| allocator.free(r.comparisons); allocator.free(results); } try common.displayResults(results, color, out); try common.displayRatioSuggestions(results, portfolio, prices, account_map, color, out); const present = try common.presentNumbers(allocator, common.AccountComparison, results); defer allocator.free(present); const absent = try common.findAbsentAccounts(allocator, portfolio, account_map, "schwab", present, prices, as_of); defer allocator.free(absent); try common.displayAbsentAccounts(absent, 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(hygiene.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 "parseArgs: unknown flag is rejected (not silently ignored)" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--bogus"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "parseArgs: stray positional is rejected" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"AAPL"}; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "parseArgs: --fidelity without a value is rejected (no silent mode switch)" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--fidelity"}; try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); } test "parseArgs: --fidelity followed by a flag does not swallow the flag" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "--fidelity", "--verbose" }; try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); } test "parseArgs: --schwab without a value is rejected" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--schwab"}; try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); } test "parseArgs: --stale-days with a non-integer value is rejected (not a silent default)" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "--stale-days", "abc" }; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "parseArgs: combined valid flags parse together" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv", "--verbose", "--stale-days", "7" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?); try std.testing.expect(parsed.verbose); try std.testing.expectEqual(@as(u32, 7), parsed.stale_days); }