zfin/src/commands/audit.zig

380 lines
16 KiB
Zig

//! `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 <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,
.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);
}