migrate audit to new cli framework
This commit is contained in:
parent
b8e6732df1
commit
e3dbd429df
2 changed files with 129 additions and 24 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue