master audit command to do all the things
This commit is contained in:
parent
4df334896c
commit
a2271e3582
3 changed files with 986 additions and 9 deletions
|
|
@ -41,6 +41,35 @@ pub const AccountTaxEntry = struct {
|
|||
tax_type: TaxType,
|
||||
institution: ?[]const u8 = null,
|
||||
account_number: ?[]const u8 = null,
|
||||
update_cadence: UpdateCadence = .weekly,
|
||||
};
|
||||
|
||||
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
|
||||
/// Default is `weekly` (fail-open: every account nags until explicitly silenced).
|
||||
pub const UpdateCadence = enum {
|
||||
weekly,
|
||||
monthly,
|
||||
quarterly,
|
||||
none,
|
||||
|
||||
/// Number of calendar days before an account is considered overdue.
|
||||
pub fn thresholdDays(self: UpdateCadence) ?u32 {
|
||||
return switch (self) {
|
||||
.weekly => 7,
|
||||
.monthly => 30,
|
||||
.quarterly => 90,
|
||||
.none => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn label(self: UpdateCadence) []const u8 {
|
||||
return switch (self) {
|
||||
.weekly => "weekly",
|
||||
.monthly => "monthly",
|
||||
.quarterly => "quarterly",
|
||||
.none => "none",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Parsed account metadata.
|
||||
|
|
@ -117,6 +146,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
|||
.tax_type = entry.tax_type,
|
||||
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
|
||||
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
|
||||
.update_cadence = entry.update_cadence,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1400,12 +1400,664 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
|||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
// ── Hygiene check (flagless audit) ──────────────────────────
|
||||
|
||||
/// Constants for hygiene check behavior. Kept as named constants for
|
||||
/// easy future tuning.
|
||||
const audit_file_max_age_hours = 24;
|
||||
const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
|
||||
const default_stale_days: u32 = 3;
|
||||
const stale_warning_multiplier: u32 = 2; // yellow → red at 2× threshold
|
||||
|
||||
/// Type of a discovered brokerage file.
|
||||
const BrokerFileKind = enum {
|
||||
fidelity_csv,
|
||||
schwab_csv,
|
||||
schwab_summary,
|
||||
};
|
||||
|
||||
/// A discovered brokerage file ready for reconciliation.
|
||||
const DiscoveredFile = struct {
|
||||
path: []const u8,
|
||||
kind: BrokerFileKind,
|
||||
dir_label: []const u8, // e.g. "audit/" or "$ZFIN_AUDIT_FILES"
|
||||
};
|
||||
|
||||
/// Detect the brokerage type from file contents by inspecting the first few lines.
|
||||
fn detectBrokerFileKind(data: []const u8) ?BrokerFileKind {
|
||||
// Strip optional UTF-8 BOM
|
||||
const content = if (data.len >= 3 and data[0] == 0xEF and data[1] == 0xBB and data[2] == 0xBF)
|
||||
data[3..]
|
||||
else
|
||||
data;
|
||||
|
||||
// Fidelity CSV: first line starts with "Account Number" or "Account Name"
|
||||
if (std.mem.startsWith(u8, content, "Account Number") or
|
||||
std.mem.startsWith(u8, content, "Account Name"))
|
||||
return .fidelity_csv;
|
||||
|
||||
// Schwab per-account CSV: starts with a quoted title line like "Positions for ..."
|
||||
if (std.mem.startsWith(u8, content, "\"Positions for")) return .schwab_csv;
|
||||
|
||||
// Schwab summary: contains "Account number ending in" pattern
|
||||
const peek = content[0..@min(content.len, 4096)];
|
||||
if (std.mem.indexOf(u8, peek, "Account number ending in") != null) return .schwab_summary;
|
||||
// Also match by account type labels + dollar amounts
|
||||
if ((std.mem.indexOf(u8, peek, "Brokerage") != null or
|
||||
std.mem.indexOf(u8, peek, "Roth IRA") != null or
|
||||
std.mem.indexOf(u8, peek, "Traditional IRA") != null or
|
||||
std.mem.indexOf(u8, peek, "Rollover IRA") != null) and
|
||||
std.mem.indexOf(u8, peek, "$") != null)
|
||||
{
|
||||
return .schwab_summary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Discover brokerage files in a directory. Filters by recency (< 24h)
|
||||
/// and applies size limits for non-CSV files.
|
||||
fn discoverBrokerFiles(
|
||||
allocator: std.mem.Allocator,
|
||||
dir_path: []const u8,
|
||||
dir_label: []const u8,
|
||||
) ![]DiscoveredFile {
|
||||
var results = std.ArrayList(DiscoveredFile).empty;
|
||||
defer results.deinit(allocator);
|
||||
|
||||
var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch return try results.toOwnedSlice(allocator);
|
||||
defer dir.close();
|
||||
|
||||
const now_ts = std.time.timestamp();
|
||||
const max_age_s: i128 = audit_file_max_age_hours * 3600;
|
||||
|
||||
var it = dir.iterate();
|
||||
while (try it.next()) |entry| {
|
||||
if (entry.kind != .file) continue;
|
||||
|
||||
// Check file modification time
|
||||
const stat = dir.statFile(entry.name) catch continue;
|
||||
const mtime_s: i128 = @divFloor(stat.mtime, std.time.ns_per_s);
|
||||
const age_s = now_ts - mtime_s;
|
||||
if (age_s > max_age_s) continue;
|
||||
|
||||
// Check if it's a CSV (no size limit) or non-CSV (size limit applies)
|
||||
const is_csv = std.mem.endsWith(u8, entry.name, ".csv") or std.mem.endsWith(u8, entry.name, ".CSV");
|
||||
if (!is_csv and stat.size > audit_file_max_size_non_csv) continue;
|
||||
|
||||
// Read and detect content type
|
||||
const data = dir.readFileAlloc(allocator, entry.name, 10 * 1024 * 1024) catch continue;
|
||||
defer allocator.free(data);
|
||||
|
||||
const kind = detectBrokerFileKind(data) orelse continue;
|
||||
const full_path = std.fs.path.join(allocator, &.{ dir_path, entry.name }) catch continue;
|
||||
try results.append(allocator, .{
|
||||
.path = full_path,
|
||||
.kind = kind,
|
||||
.dir_label = dir_label,
|
||||
});
|
||||
}
|
||||
|
||||
return results.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Compute which accounts have been modified between two parsed portfolios.
|
||||
/// Returns a set of account names that have any lot-level differences.
|
||||
/// Compares by serializing each lot to a canonical string per account,
|
||||
/// sorting, and checking for equality. Simple and robust -- any field
|
||||
/// change in a lot produces a different string.
|
||||
fn findModifiedAccounts(
|
||||
allocator: std.mem.Allocator,
|
||||
old_portfolio: zfin.Portfolio,
|
||||
new_portfolio: zfin.Portfolio,
|
||||
) !std.StringHashMap(void) {
|
||||
var modified = std.StringHashMap(void).init(allocator);
|
||||
errdefer modified.deinit();
|
||||
|
||||
// Collect serialized lot strings grouped by account
|
||||
var old_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
|
||||
defer {
|
||||
var it = old_accts.valueIterator();
|
||||
while (it.next()) |v| {
|
||||
for (v.items) |s| allocator.free(s);
|
||||
v.deinit(allocator);
|
||||
}
|
||||
old_accts.deinit();
|
||||
}
|
||||
var new_accts = std.StringHashMap(std.ArrayList([]const u8)).init(allocator);
|
||||
defer {
|
||||
var it = new_accts.valueIterator();
|
||||
while (it.next()) |v| {
|
||||
for (v.items) |s| allocator.free(s);
|
||||
v.deinit(allocator);
|
||||
}
|
||||
new_accts.deinit();
|
||||
}
|
||||
|
||||
for (old_portfolio.lots) |lot| {
|
||||
const acct = lot.account orelse continue;
|
||||
const entry = try old_accts.getOrPut(acct);
|
||||
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
|
||||
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
|
||||
}
|
||||
for (new_portfolio.lots) |lot| {
|
||||
const acct = lot.account orelse continue;
|
||||
const entry = try new_accts.getOrPut(acct);
|
||||
if (!entry.found_existing) entry.value_ptr.* = std.ArrayList([]const u8).empty;
|
||||
try entry.value_ptr.append(allocator, try lotToString(allocator, lot));
|
||||
}
|
||||
|
||||
// Compare per account: sort both lists, then check equality
|
||||
var all = std.StringHashMap(void).init(allocator);
|
||||
defer all.deinit();
|
||||
{
|
||||
var it = old_accts.keyIterator();
|
||||
while (it.next()) |k| try all.put(k.*, {});
|
||||
}
|
||||
{
|
||||
var it = new_accts.keyIterator();
|
||||
while (it.next()) |k| try all.put(k.*, {});
|
||||
}
|
||||
|
||||
var acct_it = all.keyIterator();
|
||||
while (acct_it.next()) |acct_key| {
|
||||
const acct = acct_key.*;
|
||||
const old_ptr = old_accts.getPtr(acct);
|
||||
const new_ptr = new_accts.getPtr(acct);
|
||||
const old_len = if (old_ptr) |p| p.items.len else 0;
|
||||
const new_len = if (new_ptr) |p| p.items.len else 0;
|
||||
|
||||
if (old_len != new_len) {
|
||||
try modified.put(acct, {});
|
||||
continue;
|
||||
}
|
||||
if (old_len == 0) continue;
|
||||
|
||||
const old_items = old_ptr.?.items;
|
||||
const new_items = new_ptr.?.items;
|
||||
std.mem.sort([]const u8, old_items, {}, strLessThan);
|
||||
std.mem.sort([]const u8, new_items, {}, strLessThan);
|
||||
|
||||
var differs = false;
|
||||
for (old_items, new_items) |a, b| {
|
||||
if (!std.mem.eql(u8, a, b)) {
|
||||
differs = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (differs) try modified.put(acct, {});
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
fn strLessThan(_: void, a: []const u8, b: []const u8) bool {
|
||||
return std.mem.order(u8, a, b) == .lt;
|
||||
}
|
||||
|
||||
const srf = @import("srf");
|
||||
|
||||
/// Serialize a lot to a canonical SRF string for comparison.
|
||||
/// Uses the SRF serializer with comptime reflection, so any new
|
||||
/// field added to Lot is automatically included.
|
||||
fn lotToString(allocator: std.mem.Allocator, lot: portfolio_mod.Lot) ![]const u8 {
|
||||
const lots = [_]portfolio_mod.Lot{lot};
|
||||
return std.fmt.allocPrint(allocator, "{f}", .{srf.fmtFrom(portfolio_mod.Lot, allocator, &lots, .{ .emit_directives = false })});
|
||||
}
|
||||
|
||||
/// Staleness color based on age vs threshold.
|
||||
/// Returns CLR_MUTED for within threshold, warning for 1-2x, negative for >2x.
|
||||
fn stalenessColor(age_days: i32, threshold: u32) [3]u8 {
|
||||
const t: i32 = @intCast(threshold);
|
||||
if (age_days <= t) return cli.CLR_MUTED;
|
||||
if (age_days <= t * @as(i32, stale_warning_multiplier)) return cli.CLR_WARNING;
|
||||
return cli.CLR_NEGATIVE;
|
||||
}
|
||||
|
||||
/// Run the flagless portfolio hygiene check.
|
||||
fn runHygieneCheck(
|
||||
allocator: std.mem.Allocator,
|
||||
svc: *zfin.DataService,
|
||||
portfolio_path: []const u8,
|
||||
stale_days: u32,
|
||||
verbose: bool,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
// Load portfolio
|
||||
const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch {
|
||||
try cli.stderrPrint("Error: Cannot read portfolio file\n");
|
||||
return;
|
||||
};
|
||||
defer allocator.free(pf_data);
|
||||
|
||||
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
||||
try cli.stderrPrint("Error: Cannot parse portfolio file\n");
|
||||
return;
|
||||
};
|
||||
defer portfolio.deinit();
|
||||
|
||||
// Load accounts.srf
|
||||
var account_map = svc.loadAccountMap(portfolio_path) orelse {
|
||||
try cli.stderrPrint("Error: Cannot read/parse accounts.srf (needed for account mapping)\n");
|
||||
return;
|
||||
};
|
||||
defer account_map.deinit();
|
||||
|
||||
try cli.setBold(out, color);
|
||||
try out.print(" Portfolio hygiene\n", .{});
|
||||
try cli.reset(out, color);
|
||||
|
||||
// ── Section 1: Stale manual prices ──
|
||||
|
||||
const today = fmt.todayDate();
|
||||
var stale_count: usize = 0;
|
||||
|
||||
// Collect and display stale manual prices
|
||||
{
|
||||
var header_shown = false;
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.price == null) continue;
|
||||
const pd = lot.price_date orelse continue;
|
||||
const age_days = today.days - pd.days;
|
||||
const threshold: i32 = @intCast(stale_days);
|
||||
if (age_days <= threshold) continue;
|
||||
|
||||
if (!header_shown) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" Stale manual prices (>{d} days — --stale-days to configure)\n", .{stale_days});
|
||||
try cli.reset(out, color);
|
||||
header_shown = true;
|
||||
}
|
||||
|
||||
stale_count += 1;
|
||||
var date_buf: [10]u8 = undefined;
|
||||
const date_str = pd.format(&date_buf);
|
||||
const note_display = lot.note orelse "";
|
||||
var price_buf: [24]u8 = undefined;
|
||||
const price_str = fmt.fmtMoneyAbs(&price_buf, lot.price.?);
|
||||
|
||||
try out.print(" {s:<16} {s:<16} {s:>10} {s} ", .{
|
||||
lot.symbol,
|
||||
note_display,
|
||||
price_str,
|
||||
date_str,
|
||||
});
|
||||
const clr = stalenessColor(age_days, stale_days);
|
||||
try cli.setFg(out, color, clr);
|
||||
try out.print("({d} days)\n", .{@as(u32, @intCast(age_days))});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
if (!header_shown) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" Stale manual prices (>{d} days)\n", .{stale_days});
|
||||
try cli.reset(out, color);
|
||||
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||
try out.print(" (none)\n", .{});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 2: Account cadence check ──
|
||||
{
|
||||
// Try to get committed version via git
|
||||
const git = @import("../git.zig");
|
||||
const repo_info: ?git.RepoInfo = git.findRepo(allocator, portfolio_path) catch null;
|
||||
defer if (repo_info) |ri| {
|
||||
allocator.free(ri.root);
|
||||
allocator.free(ri.rel_path);
|
||||
};
|
||||
|
||||
// Parse committed portfolio for diff (working copy vs HEAD)
|
||||
var committed_portfolio: ?zfin.Portfolio = null;
|
||||
defer if (committed_portfolio) |*cp| cp.deinit();
|
||||
|
||||
var committed_data: ?[]const u8 = null;
|
||||
defer if (committed_data) |d| allocator.free(d);
|
||||
|
||||
if (repo_info) |ri| {
|
||||
committed_data = git.show(allocator, ri.root, "HEAD", ri.rel_path) catch null;
|
||||
if (committed_data) |cd| {
|
||||
committed_portfolio = zfin.cache.deserializePortfolio(allocator, cd) catch null;
|
||||
}
|
||||
}
|
||||
|
||||
// Find accounts modified in working copy (uncommitted changes)
|
||||
var working_copy_modified = std.StringHashMap(void).init(allocator);
|
||||
defer working_copy_modified.deinit();
|
||||
|
||||
if (committed_portfolio) |cp| {
|
||||
working_copy_modified = findModifiedAccounts(allocator, cp, portfolio) catch std.StringHashMap(void).init(allocator);
|
||||
}
|
||||
|
||||
// Collect all unique account names from working copy portfolio
|
||||
// (these pointers are stable for the lifetime of the function)
|
||||
var all_accounts = std.StringHashMap(void).init(allocator);
|
||||
defer all_accounts.deinit();
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.account) |acct| {
|
||||
all_accounts.put(acct, {}) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
// Find last update time for each account via git history.
|
||||
// Walk commits newest-to-oldest, diffing adjacent pairs to find
|
||||
// which accounts changed. Use working-copy account names as keys
|
||||
// (stable lifetime) rather than historical portfolio strings.
|
||||
// Only walk back far enough to hit red status (2× max cadence).
|
||||
var last_update_ts = std.StringHashMap(i64).init(allocator);
|
||||
defer last_update_ts.deinit();
|
||||
|
||||
if (repo_info) |ri| {
|
||||
// Compute the furthest we need to look back: 2× the max cadence
|
||||
var max_threshold: u32 = 14; // 2× weekly default
|
||||
for (account_map.entries) |entry| {
|
||||
if (entry.update_cadence.thresholdDays()) |td| {
|
||||
const red = td * stale_warning_multiplier;
|
||||
if (red > max_threshold) max_threshold = red;
|
||||
}
|
||||
}
|
||||
var since_buf: [32]u8 = undefined;
|
||||
const since = std.fmt.bufPrint(&since_buf, "{d} days ago", .{max_threshold}) catch "30 days ago";
|
||||
|
||||
const commits = git.listCommitsTouching(allocator, ri.root, ri.rel_path, since) catch &.{};
|
||||
defer git.freeCommitTouches(allocator, commits);
|
||||
|
||||
var prev_data: ?[]const u8 = null;
|
||||
defer if (prev_data) |pd| allocator.free(pd);
|
||||
|
||||
for (commits, 0..) |ct, ci| {
|
||||
// Stop early if every account already has a timestamp
|
||||
if (last_update_ts.count() >= all_accounts.count()) break;
|
||||
|
||||
const rev_data = git.show(allocator, ri.root, ct.commit, ri.rel_path) catch continue;
|
||||
|
||||
if (ci > 0) {
|
||||
if (prev_data) |pd| {
|
||||
// rev_data is older, pd is newer (commits are newest-first)
|
||||
var old_pf = zfin.cache.deserializePortfolio(allocator, rev_data) catch {
|
||||
allocator.free(rev_data);
|
||||
continue;
|
||||
};
|
||||
defer old_pf.deinit();
|
||||
var new_pf = zfin.cache.deserializePortfolio(allocator, pd) catch continue;
|
||||
defer new_pf.deinit();
|
||||
|
||||
var mods = findModifiedAccounts(allocator, old_pf, new_pf) catch continue;
|
||||
defer mods.deinit();
|
||||
|
||||
// The newer commit's timestamp is when these accounts were updated
|
||||
const update_ts = commits[ci - 1].timestamp;
|
||||
|
||||
// Match against stable working-copy account names
|
||||
var acct_iter = all_accounts.keyIterator();
|
||||
while (acct_iter.next()) |stable_name| {
|
||||
if (last_update_ts.contains(stable_name.*)) continue;
|
||||
if (mods.contains(stable_name.*)) {
|
||||
last_update_ts.put(stable_name.*, update_ts) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prev_data) |pd| allocator.free(pd);
|
||||
prev_data = rev_data;
|
||||
}
|
||||
}
|
||||
|
||||
// Display overdue accounts
|
||||
var overdue_header_shown = false;
|
||||
var updated_accounts = std.ArrayList([]const u8).empty;
|
||||
defer updated_accounts.deinit(allocator);
|
||||
|
||||
// Check accounts updated in working copy
|
||||
var wc_it = working_copy_modified.keyIterator();
|
||||
while (wc_it.next()) |key| {
|
||||
try updated_accounts.append(allocator, key.*);
|
||||
}
|
||||
|
||||
// Check overdue accounts
|
||||
var acct_it = all_accounts.keyIterator();
|
||||
while (acct_it.next()) |acct_key| {
|
||||
const acct_name = acct_key.*;
|
||||
|
||||
// Skip if already updated in working copy
|
||||
if (working_copy_modified.contains(acct_name)) continue;
|
||||
|
||||
// Look up cadence from accounts.srf
|
||||
var cadence = analysis.UpdateCadence.weekly; // default
|
||||
for (account_map.entries) |entry| {
|
||||
if (std.mem.eql(u8, entry.account, acct_name)) {
|
||||
cadence = entry.update_cadence;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const threshold_days = cadence.thresholdDays() orelse continue; // skip 'none'
|
||||
|
||||
// Find last update time
|
||||
const now_ts = std.time.timestamp();
|
||||
var age_days: ?i32 = null;
|
||||
if (last_update_ts.get(acct_name)) |ts| {
|
||||
const age_s = now_ts - ts;
|
||||
age_days = @intCast(@divFloor(age_s, std.time.s_per_day));
|
||||
}
|
||||
|
||||
// If we have no git history for this account, it's definitely overdue
|
||||
const days = age_days orelse @as(i32, @intCast(threshold_days + 1));
|
||||
if (days <= @as(i32, @intCast(threshold_days))) continue;
|
||||
|
||||
if (!overdue_header_shown) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" Accounts overdue for update (weekly default — set update_cadence in accounts.srf)\n", .{});
|
||||
try cli.reset(out, color);
|
||||
overdue_header_shown = true;
|
||||
}
|
||||
|
||||
try out.print(" {s:<32} {s:<10}", .{ acct_name, cadence.label() });
|
||||
if (age_days) |ad| {
|
||||
const clr = stalenessColor(ad, threshold_days);
|
||||
try cli.setFg(out, color, clr);
|
||||
try out.print("last updated {d} days ago\n", .{@as(u32, @intCast(ad))});
|
||||
} else {
|
||||
try cli.setFg(out, color, cli.CLR_NEGATIVE);
|
||||
try out.print("no update history found\n", .{});
|
||||
}
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
|
||||
// Display accounts updated in working copy
|
||||
if (updated_accounts.items.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" Accounts updated (working copy)\n", .{});
|
||||
try cli.reset(out, color);
|
||||
for (updated_accounts.items) |acct| {
|
||||
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||
try out.print(" {s}\n", .{acct});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 3: Discover brokerage files ──
|
||||
|
||||
// Resolve audit directories
|
||||
const portfolio_dir = std.fs.path.dirnamePosix(portfolio_path) orelse ".";
|
||||
|
||||
var all_files = std.ArrayList(DiscoveredFile).empty;
|
||||
defer {
|
||||
for (all_files.items) |f| allocator.free(f.path);
|
||||
all_files.deinit(allocator);
|
||||
}
|
||||
|
||||
// Check $ZFIN_AUDIT_FILES first
|
||||
const env_audit_dir = std.posix.getenv("ZFIN_AUDIT_FILES");
|
||||
if (env_audit_dir) |edir| {
|
||||
const env_files = try discoverBrokerFiles(allocator, edir, "$ZFIN_AUDIT_FILES");
|
||||
defer allocator.free(env_files);
|
||||
for (env_files) |f| try all_files.append(allocator, f);
|
||||
}
|
||||
|
||||
// Then check {portfolio_dir}/audit/
|
||||
const default_audit_dir = std.fs.path.join(allocator, &.{ portfolio_dir, "audit" }) catch null;
|
||||
defer if (default_audit_dir) |d| allocator.free(d);
|
||||
|
||||
if (default_audit_dir) |adir| {
|
||||
const dir_files = try discoverBrokerFiles(allocator, adir, "audit/");
|
||||
defer allocator.free(dir_files);
|
||||
for (dir_files) |f| try all_files.append(allocator, f);
|
||||
}
|
||||
|
||||
// Display discovered files
|
||||
if (all_files.items.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" Brokerage files (last {d} hours)\n", .{audit_file_max_age_hours});
|
||||
try cli.reset(out, color);
|
||||
for (all_files.items) |f| {
|
||||
const kind_label: []const u8 = switch (f.kind) {
|
||||
.fidelity_csv => "fidelity",
|
||||
.schwab_csv => "schwab csv",
|
||||
.schwab_summary => "schwab summary",
|
||||
};
|
||||
try out.print(" {s:<52} {s}\n", .{ f.path, kind_label });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section 4: Auto-reconcile discovered files ──
|
||||
|
||||
if (all_files.items.len > 0) {
|
||||
// Build prices map (shared by all reconciliations)
|
||||
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(svc, pos_syms, &.{}, false, color);
|
||||
defer load_result.deinit();
|
||||
var pit = load_result.prices.iterator();
|
||||
while (pit.next()) |entry| {
|
||||
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
|
||||
}
|
||||
}
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.price) |p| {
|
||||
if (!prices.contains(lot.priceSymbol())) {
|
||||
try prices.put(lot.priceSymbol(), lot.effectivePrice(p, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try out.print("\n", .{});
|
||||
try cli.setBold(out, color);
|
||||
try out.print(" Reconciliation\n", .{});
|
||||
try cli.reset(out, color);
|
||||
|
||||
for (all_files.items) |f| {
|
||||
const file_data = std.fs.cwd().readFileAlloc(allocator, f.path, 10 * 1024 * 1024) catch continue;
|
||||
defer allocator.free(file_data);
|
||||
|
||||
switch (f.kind) {
|
||||
.schwab_summary => {
|
||||
const schwab_accounts = parseSchwabSummary(allocator, file_data) catch continue;
|
||||
defer allocator.free(schwab_accounts);
|
||||
|
||||
const results = compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices) catch continue;
|
||||
defer allocator.free(results);
|
||||
|
||||
if (verbose or hasSchwabDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try displaySchwabResults(results, color, out);
|
||||
} else {
|
||||
var acct_count: usize = 0;
|
||||
for (results) |r| {
|
||||
if (r.account_name.len > 0) acct_count += 1;
|
||||
}
|
||||
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||
try out.print(" schwab summary: {d} accounts, no discrepancies\n", .{acct_count});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
},
|
||||
.fidelity_csv => {
|
||||
const brokerage_positions = parseFidelityCsv(allocator, file_data) catch continue;
|
||||
defer allocator.free(brokerage_positions);
|
||||
|
||||
const results = compareAccounts(allocator, portfolio, brokerage_positions, account_map, "fidelity", prices) catch continue;
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
if (verbose or hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try displayResults(results, color, out);
|
||||
try displayRatioSuggestions(results, portfolio, prices, color, out);
|
||||
} else {
|
||||
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||
try out.print(" fidelity: {d} accounts, no discrepancies\n", .{results.len});
|
||||
try cli.reset(out, color);
|
||||
// Always show ratio suggestions even in compact mode
|
||||
try displayRatioSuggestions(results, portfolio, prices, color, out);
|
||||
}
|
||||
},
|
||||
.schwab_csv => {
|
||||
const parsed = parseSchwabCsv(allocator, file_data) catch continue;
|
||||
defer allocator.free(parsed.positions);
|
||||
|
||||
const results = compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices) catch continue;
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
if (verbose or hasAccountDiscrepancies(results)) {
|
||||
try out.print("\n", .{});
|
||||
try displayResults(results, color, out);
|
||||
try displayRatioSuggestions(results, portfolio, prices, color, out);
|
||||
} else {
|
||||
try cli.setFg(out, color, cli.CLR_POSITIVE);
|
||||
try out.print(" schwab: {d} accounts, no discrepancies\n", .{results.len});
|
||||
try cli.reset(out, color);
|
||||
try displayRatioSuggestions(results, portfolio, prices, color, out);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Check if any Schwab summary results have discrepancies.
|
||||
fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||||
for (results) |r| {
|
||||
if (r.has_discrepancy) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if any account comparison results have discrepancies.
|
||||
fn hasAccountDiscrepancies(results: []const AccountComparison) bool {
|
||||
for (results) |r| {
|
||||
if (r.has_discrepancies) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── CLI entry point ─────────────────────────────────────────
|
||||
|
||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, 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;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < args.len) : (i += 1) {
|
||||
|
|
@ -1417,19 +2069,17 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path:
|
|||
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) {
|
||||
i += 1;
|
||||
stale_days = std.fmt.parseInt(u32, args[i], 10) catch default_stale_days;
|
||||
}
|
||||
}
|
||||
|
||||
// Flagless mode: run portfolio hygiene check
|
||||
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
||||
try cli.stderrPrint(
|
||||
\\Usage: zfin [-p <portfolio.srf>] audit [options]
|
||||
\\
|
||||
\\ --fidelity <csv> Fidelity positions CSV export
|
||||
\\ --schwab <csv> Schwab per-account positions CSV export
|
||||
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
|
||||
\\
|
||||
);
|
||||
return;
|
||||
return runHygieneCheck(allocator, svc, portfolio_path, stale_days, verbose, color, out);
|
||||
}
|
||||
|
||||
// Load portfolio
|
||||
|
|
@ -2045,3 +2695,297 @@ test "option delta tracking in compareAccounts" {
|
|||
}
|
||||
try std.testing.expect(found_option);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: fidelity csv" {
|
||||
const fidelity_header = "Account Number,Account Name,Symbol,Description";
|
||||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_header).?);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: fidelity csv with BOM" {
|
||||
const fidelity_bom = "\xEF\xBB\xBFAccount Number,Account Name,Symbol";
|
||||
try std.testing.expectEqual(BrokerFileKind.fidelity_csv, detectBrokerFileKind(fidelity_bom).?);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: schwab csv" {
|
||||
const schwab_header = "\"Positions for account Roth IRA ...716 as of\"";
|
||||
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(schwab_header).?);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: schwab summary" {
|
||||
const schwab_summary_data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00";
|
||||
try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(schwab_summary_data).?);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: unknown file" {
|
||||
const random_data = "This is just some random text that doesn't match any pattern";
|
||||
try std.testing.expect(detectBrokerFileKind(random_data) == null);
|
||||
}
|
||||
|
||||
test "stalenessColor: within threshold" {
|
||||
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(2, 3));
|
||||
try std.testing.expectEqual(cli.CLR_MUTED, stalenessColor(3, 3));
|
||||
}
|
||||
|
||||
test "stalenessColor: warning zone (1-2x threshold)" {
|
||||
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(4, 3));
|
||||
try std.testing.expectEqual(cli.CLR_WARNING, stalenessColor(6, 3));
|
||||
}
|
||||
|
||||
test "stalenessColor: critical zone (>2x threshold)" {
|
||||
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(7, 3));
|
||||
try std.testing.expectEqual(cli.CLR_NEGATIVE, stalenessColor(30, 3));
|
||||
}
|
||||
|
||||
test "UpdateCadence thresholdDays" {
|
||||
try std.testing.expectEqual(@as(?u32, 7), analysis.UpdateCadence.weekly.thresholdDays());
|
||||
try std.testing.expectEqual(@as(?u32, 30), analysis.UpdateCadence.monthly.thresholdDays());
|
||||
try std.testing.expectEqual(@as(?u32, 90), analysis.UpdateCadence.quarterly.thresholdDays());
|
||||
try std.testing.expect(analysis.UpdateCadence.none.thresholdDays() == null);
|
||||
}
|
||||
|
||||
test "findModifiedAccounts: detects share changes" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var old_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" },
|
||||
};
|
||||
var new_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 110, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" }, // shares changed
|
||||
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "Acct B" }, // unchanged
|
||||
};
|
||||
|
||||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||||
|
||||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||||
defer modified.deinit();
|
||||
|
||||
try std.testing.expect(modified.contains("Acct A"));
|
||||
try std.testing.expect(!modified.contains("Acct B"));
|
||||
}
|
||||
|
||||
test "findModifiedAccounts: detects new lots" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var old_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
};
|
||||
var new_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
.{ .symbol = "VTI", .shares = 200, .open_date = Date.fromYmd(2025, 3, 1), .open_price = 200.0, .account = "Acct A" },
|
||||
};
|
||||
|
||||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||||
|
||||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||||
defer modified.deinit();
|
||||
|
||||
try std.testing.expect(modified.contains("Acct A"));
|
||||
}
|
||||
|
||||
test "findModifiedAccounts: detects price changes" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var old_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 161.71, .price_date = Date.fromYmd(2026, 4, 9) },
|
||||
};
|
||||
var new_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "NON40OR52", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 97.24, .account = "401k", .price = 169.07, .price_date = Date.fromYmd(2026, 4, 18) },
|
||||
};
|
||||
|
||||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||||
|
||||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||||
defer modified.deinit();
|
||||
|
||||
try std.testing.expect(modified.contains("401k"));
|
||||
}
|
||||
|
||||
test "findModifiedAccounts: detects removed lots" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var old_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
.{ .symbol = "VTI", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200.0, .account = "Acct A" },
|
||||
};
|
||||
var new_lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
// VTI removed
|
||||
};
|
||||
|
||||
const old_pf = portfolio_mod.Portfolio{ .lots = &old_lots, .allocator = allocator };
|
||||
const new_pf = portfolio_mod.Portfolio{ .lots = &new_lots, .allocator = allocator };
|
||||
|
||||
var modified = try findModifiedAccounts(allocator, old_pf, new_pf);
|
||||
defer modified.deinit();
|
||||
|
||||
try std.testing.expect(modified.contains("Acct A"));
|
||||
}
|
||||
|
||||
test "findModifiedAccounts: no changes" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
||||
};
|
||||
|
||||
const pf = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var modified = try findModifiedAccounts(allocator, pf, pf);
|
||||
defer modified.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(u32, 0), modified.count());
|
||||
}
|
||||
|
||||
test "hasAccountDiscrepancies" {
|
||||
const clean = [_]AccountComparison{.{
|
||||
.account_name = "Acct",
|
||||
.brokerage_name = "Schwab",
|
||||
.account_number = "123",
|
||||
.comparisons = &.{},
|
||||
.portfolio_total = 1000,
|
||||
.brokerage_total = 1000,
|
||||
.total_delta = 0,
|
||||
.option_value_delta = 0,
|
||||
.has_discrepancies = false,
|
||||
}};
|
||||
try std.testing.expect(!hasAccountDiscrepancies(&clean));
|
||||
|
||||
const dirty = [_]AccountComparison{.{
|
||||
.account_name = "Acct",
|
||||
.brokerage_name = "Schwab",
|
||||
.account_number = "123",
|
||||
.comparisons = &.{},
|
||||
.portfolio_total = 1000,
|
||||
.brokerage_total = 1100,
|
||||
.total_delta = 100,
|
||||
.option_value_delta = 0,
|
||||
.has_discrepancies = true,
|
||||
}};
|
||||
try std.testing.expect(hasAccountDiscrepancies(&dirty));
|
||||
}
|
||||
|
||||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "716",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 100,
|
||||
.cash_delta = 0,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5000,
|
||||
.total_delta = 0,
|
||||
.has_discrepancy = false,
|
||||
}};
|
||||
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
|
||||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "716",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
.cash_delta = 100,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5100,
|
||||
.total_delta = 100,
|
||||
.has_discrepancy = true,
|
||||
}};
|
||||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: schwab csv with Positions header" {
|
||||
const data = "\"Positions for account Brokerage ...1234 as of 11:31 AM ET, 2026/04/25\"\n\nSymbol,Description,Quantity";
|
||||
try std.testing.expectEqual(BrokerFileKind.schwab_csv, detectBrokerFileKind(data).?);
|
||||
}
|
||||
|
||||
test "detectBrokerFileKind: schwab summary with Roth IRA" {
|
||||
const data = "Roth IRA ...716\nSome text\n$50,000.00\n";
|
||||
try std.testing.expectEqual(BrokerFileKind.schwab_summary, detectBrokerFileKind(data).?);
|
||||
}
|
||||
|
||||
test "UpdateCadence label" {
|
||||
try std.testing.expectEqualStrings("weekly", analysis.UpdateCadence.weekly.label());
|
||||
try std.testing.expectEqualStrings("monthly", analysis.UpdateCadence.monthly.label());
|
||||
try std.testing.expectEqualStrings("quarterly", analysis.UpdateCadence.quarterly.label());
|
||||
try std.testing.expectEqualStrings("none", analysis.UpdateCadence.none.label());
|
||||
}
|
||||
|
||||
test "discoverBrokerFiles: finds files in temp directory" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Create a temp directory with test files
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
// Write a fidelity CSV
|
||||
tmp.dir.writeFile(.{
|
||||
.sub_path = "fidelity.csv",
|
||||
.data = "Account Number,Account Name,Symbol,Description,Quantity,Last Price,Current Value\nZ123,Test,AAPL,Apple,100,200,20000\n",
|
||||
}) catch unreachable;
|
||||
|
||||
// Write a schwab summary (non-CSV)
|
||||
tmp.dir.writeFile(.{
|
||||
.sub_path = "schwab.txt",
|
||||
.data = "Brokerage ...1234\nAccount number ending in 1234\n$500,000.00\n",
|
||||
}) catch unreachable;
|
||||
|
||||
// Write a random non-matching file
|
||||
tmp.dir.writeFile(.{
|
||||
.sub_path = "notes.txt",
|
||||
.data = "Just some random notes",
|
||||
}) catch unreachable;
|
||||
|
||||
// Get the temp dir path
|
||||
const tmp_path = tmp.dir.realpathAlloc(allocator, ".") catch unreachable;
|
||||
defer allocator.free(tmp_path);
|
||||
|
||||
const files = try discoverBrokerFiles(allocator, tmp_path, "test/");
|
||||
defer {
|
||||
for (files) |f| allocator.free(f.path);
|
||||
allocator.free(files);
|
||||
}
|
||||
|
||||
// Should find fidelity CSV and schwab summary, but not notes.txt
|
||||
try std.testing.expectEqual(@as(usize, 2), files.len);
|
||||
|
||||
var found_fidelity = false;
|
||||
var found_schwab = false;
|
||||
for (files) |f| {
|
||||
switch (f.kind) {
|
||||
.fidelity_csv => found_fidelity = true,
|
||||
.schwab_summary => found_schwab = true,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
try std.testing.expect(found_fidelity);
|
||||
try std.testing.expect(found_schwab);
|
||||
}
|
||||
|
||||
test "discoverBrokerFiles: empty directory returns empty" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
|
||||
const tmp_path = tmp.dir.realpathAlloc(allocator, ".") catch unreachable;
|
||||
defer allocator.free(tmp_path);
|
||||
|
||||
const files = try discoverBrokerFiles(allocator, tmp_path, "test/");
|
||||
defer allocator.free(files);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), files.len);
|
||||
}
|
||||
|
||||
test "discoverBrokerFiles: nonexistent directory returns empty" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
const files = try discoverBrokerFiles(allocator, "/nonexistent/path/audit", "test/");
|
||||
defer allocator.free(files);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), files.len);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ const usage =
|
|||
\\ --rebuild-rollup (Re)write history/rollup.srf and exit
|
||||
\\
|
||||
\\Audit command options:
|
||||
\\ (no flags) Portfolio hygiene check + auto-reconcile discovered files
|
||||
\\ --verbose Show full reconciliation output even when clean
|
||||
\\ --stale-days <N> Manual price staleness threshold (default: 3)
|
||||
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||||
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue