migrate audit to new cli framework
This commit is contained in:
parent
177016a153
commit
f409c7c180
2 changed files with 129 additions and 24 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const zfin = @import("../root.zig");
|
const zfin = @import("../root.zig");
|
||||||
const cli = @import("common.zig");
|
const cli = @import("common.zig");
|
||||||
|
const framework = @import("framework.zig");
|
||||||
const fmt = cli.fmt;
|
const fmt = cli.fmt;
|
||||||
const Money = @import("../Money.zig");
|
const Money = @import("../Money.zig");
|
||||||
const analysis = @import("../analytics/analysis.zig");
|
const analysis = @import("../analytics/analysis.zig");
|
||||||
|
|
@ -2241,30 +2242,86 @@ fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
|
||||||
|
|
||||||
// ── CLI entry point ─────────────────────────────────────────
|
// ── 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 {
|
pub const ParsedArgs = struct {
|
||||||
var fidelity_csv: ?[]const u8 = null;
|
fidelity_csv: ?[]const u8 = null,
|
||||||
var schwab_csv: ?[]const u8 = null;
|
schwab_csv: ?[]const u8 = null,
|
||||||
var schwab_summary = false;
|
schwab_summary: bool = false,
|
||||||
var verbose = false;
|
verbose: bool = false,
|
||||||
var stale_days: u32 = default_stale_days;
|
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;
|
var i: usize = 0;
|
||||||
while (i < args.len) : (i += 1) {
|
while (i < cmd_args.len) : (i += 1) {
|
||||||
if (std.mem.eql(u8, args[i], "--fidelity") and i + 1 < args.len) {
|
if (std.mem.eql(u8, cmd_args[i], "--fidelity") and i + 1 < cmd_args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
fidelity_csv = args[i];
|
parsed.fidelity_csv = cmd_args[i];
|
||||||
} else if (std.mem.eql(u8, args[i], "--schwab") and i + 1 < args.len) {
|
} else if (std.mem.eql(u8, cmd_args[i], "--schwab") and i + 1 < cmd_args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
schwab_csv = args[i];
|
parsed.schwab_csv = cmd_args[i];
|
||||||
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
|
} else if (std.mem.eql(u8, cmd_args[i], "--schwab-summary")) {
|
||||||
schwab_summary = true;
|
parsed.schwab_summary = true;
|
||||||
} else if (std.mem.eql(u8, args[i], "--verbose")) {
|
} else if (std.mem.eql(u8, cmd_args[i], "--verbose")) {
|
||||||
verbose = true;
|
parsed.verbose = true;
|
||||||
} else if (std.mem.eql(u8, args[i], "--stale-days") and i + 1 < args.len) {
|
} else if (std.mem.eql(u8, cmd_args[i], "--stale-days") and i + 1 < cmd_args.len) {
|
||||||
i += 1;
|
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
|
// Flagless mode: run portfolio hygiene check
|
||||||
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
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);
|
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");
|
try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n");
|
||||||
return;
|
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 {
|
defer {
|
||||||
for (results) |r| allocator.free(r.comparisons);
|
for (results) |r| allocator.free(r.comparisons);
|
||||||
allocator.free(results);
|
allocator.free(results);
|
||||||
|
|
@ -2405,6 +2462,58 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, por
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── 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" {
|
test "parseDollarAmount" {
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
|
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);
|
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")) {
|
if (std.mem.eql(u8, command, "portfolio")) {
|
||||||
// Parse --refresh flag; reject any other token (including old
|
// Parse --refresh flag; reject any other token (including old
|
||||||
// positional FILE, which is now a global -p).
|
// 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")) {
|
} else if (std.mem.eql(u8, command, "projections")) {
|
||||||
var events_enabled = true;
|
var events_enabled = true;
|
||||||
var as_of: ?zfin.Date = null;
|
var as_of: ?zfin.Date = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue