380 lines
16 KiB
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);
|
|
}
|