migrate audit to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:44:43 -07:00
parent b8e6732df1
commit e3dbd429df
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 129 additions and 24 deletions

View file

@ -1,6 +1,7 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const analysis = @import("../analytics/analysis.zig");
@ -2241,30 +2242,86 @@ fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
// CLI entry point
pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, as_of: Date, now_s: i64, 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;
pub const ParsedArgs = struct {
fidelity_csv: ?[]const u8 = null,
schwab_csv: ?[]const u8 = null,
schwab_summary: bool = false,
verbose: bool = false,
stale_days: u32 = default_stale_days,
};
pub const meta = struct {
pub const name: []const u8 = "audit";
pub const group: framework.Group = .hygiene;
pub const synopsis: []const u8 = "Reconcile portfolio against brokerage exports + portfolio hygiene check";
pub const help: []const u8 =
\\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
\\
;
};
comptime {
framework.validateCommandModule(@This());
}
pub fn parseArgs(_: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
var i: usize = 0;
while (i < args.len) : (i += 1) {
if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) {
while (i < cmd_args.len) : (i += 1) {
if (std.mem.eql(u8, cmd_args[i], "--fidelity") and i + 1 < cmd_args.len) {
i += 1;
fidelity_csv = args[i];
} else if (std.mem.eql(u8, args[i], "--schwab") and i + 1 < args.len) {
parsed.fidelity_csv = cmd_args[i];
} else if (std.mem.eql(u8, cmd_args[i], "--schwab") and i + 1 < cmd_args.len) {
i += 1;
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) {
parsed.schwab_csv = cmd_args[i];
} else if (std.mem.eql(u8, cmd_args[i], "--schwab-summary")) {
parsed.schwab_summary = true;
} else if (std.mem.eql(u8, cmd_args[i], "--verbose")) {
parsed.verbose = true;
} else if (std.mem.eql(u8, cmd_args[i], "--stale-days") and i + 1 < cmd_args.len) {
i += 1;
stale_days = std.fmt.parseInt(u32, args[i], 10) catch default_stale_days;
parsed.stale_days = std.fmt.parseInt(u32, cmd_args[i], 10) catch default_stale_days;
}
}
return parsed;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
const now_s = ctx.now_s;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const portfolio_path = pf.path;
const fidelity_csv = parsed.fidelity_csv;
const schwab_csv = parsed.schwab_csv;
const schwab_summary = parsed.schwab_summary;
const verbose = parsed.verbose;
const stale_days = parsed.stale_days;
// Flagless mode: run portfolio hygiene check
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
@ -2386,13 +2443,13 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, por
};
defer allocator.free(csv_data);
const parsed = parseSchwabCsv(allocator, csv_data) catch {
const csv = parseSchwabCsv(allocator, csv_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
return;
};
defer allocator.free(parsed.positions);
defer allocator.free(csv.positions);
const results = try compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
const results = try compareAccounts(allocator, portfolio, csv.positions, account_map, "schwab", prices, as_of);
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
@ -2405,6 +2462,58 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, por
// Tests
test "parseArgs: defaults" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.fidelity_csv == null);
try std.testing.expect(parsed.schwab_csv == null);
try std.testing.expect(!parsed.schwab_summary);
try std.testing.expect(!parsed.verbose);
try std.testing.expectEqual(default_stale_days, parsed.stale_days);
}
test "parseArgs: --fidelity captures CSV path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--fidelity", "/tmp/fid.csv" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("/tmp/fid.csv", parsed.fidelity_csv.?);
}
test "parseArgs: --schwab captures CSV path" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--schwab", "/tmp/sch.csv" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqualStrings("/tmp/sch.csv", parsed.schwab_csv.?);
}
test "parseArgs: --schwab-summary boolean" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--schwab-summary"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.schwab_summary);
}
test "parseArgs: --verbose boolean" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"--verbose"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.verbose);
}
test "parseArgs: --stale-days parses integer" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--stale-days", "5" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
}
test "parseDollarAmount" {
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);

View file

@ -480,10 +480,6 @@ fn runCli(init: std.process.Init) !u8 {
if (std.mem.eql(u8, command, "portfolio")) {
// Parse --refresh flag; reject any other token (including old
// positional FILE, which is now a global -p).
} else if (std.mem.eql(u8, command, "audit")) {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.audit.run(io, allocator, &svc, pf.path, cmd_args, today, now_s, color, out);
} else if (std.mem.eql(u8, command, "projections")) {
var events_enabled = true;
var as_of: ?zfin.Date = null;