diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 5aaf749..efbd5a7 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -521,14 +521,17 @@ pub fn compareSchwabSummary( fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void { try cli.setBold(out, color); - try out.print("\nSchwab Account Audit (summary)\n", .{}); + try out.print("\nSchwab Account Audit", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" (brokerage is source of truth)\n", .{}); try cli.reset(out, color); try out.print("========================================\n\n", .{}); // Column headers try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:<24} {s:>14} {s:>14} {s:>14} {s:>14} {s}\n", .{ - "Account", "PF Cash", "BR Cash", "PF Total", "BR Total", "Status", + try out.print(" {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{ + "Account", "PF Cash", "BR Cash", "PF Total", "BR Total", }); try cli.reset(out, color); @@ -553,49 +556,70 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o else "--"; - var status_buf: [64]u8 = undefined; - const status: []const u8 = blk: { - if (r.account_name.len == 0) break :blk "Unmapped"; + const cash_ok = if (r.cash_delta) |d| @abs(d) < 1.0 else true; + const total_ok = if (r.total_delta) |d| @abs(d) < 1.0 else true; + const is_unmapped = r.account_name.len == 0; + const is_real_mismatch = !cash_ok or is_unmapped; - const cash_ok = if (r.cash_delta) |d| @abs(d) < 1.0 else true; - const total_ok = if (r.total_delta) |d| @abs(d) < 1.0 else true; + if (is_real_mismatch) discrepancy_count += 1; - if (cash_ok and total_ok) break :blk "ok"; - - if (!cash_ok) { - if (r.cash_delta) |d| { - var delta_buf: [24]u8 = undefined; - const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "Cash mismatch"; - } - } - if (!total_ok) { - if (r.total_delta) |d| { - var delta_buf: [24]u8 = undefined; - const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Total {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "Total mismatch"; - } - } - break :blk "Mismatch"; - }; - - const is_ok = std.mem.eql(u8, status, "ok"); - if (!is_ok) discrepancy_count += 1; - - try out.print(" {s:<24} {s:>14} {s:>14} {s:>14} {s:>14} ", .{ - label, - fmt.fmtMoneyAbs(&pf_cash_buf, r.portfolio_cash), - br_cash_str, - fmt.fmtMoneyAbs(&pf_total_buf, r.portfolio_total), - br_total_str, - }); - if (is_ok) { - try cli.setFg(out, color, cli.CLR_POSITIVE); - } else { + // Account label + try out.print(" ", .{}); + if (is_unmapped) { try cli.setFg(out, color, cli.CLR_WARNING); } - try out.print("{s}\n", .{status}); - try cli.reset(out, color); + try out.print("{s:<24}", .{label}); + if (is_unmapped) try cli.reset(out, color); + + // PF Cash — colored if mismatched (brokerage is truth) + try out.print(" ", .{}); + if (!cash_ok) { + if (r.cash_delta.? > 0) + try cli.setFg(out, color, cli.CLR_NEGATIVE) + else + try cli.setFg(out, color, cli.CLR_POSITIVE); + } + try out.print("{s:>14}", .{fmt.fmtMoneyAbs(&pf_cash_buf, r.portfolio_cash)}); + if (!cash_ok) try cli.reset(out, color); + + // BR Cash + try out.print(" {s:>14}", .{br_cash_str}); + + // PF Total — colored if not just stale prices + try out.print(" ", .{}); + if (!total_ok and !cash_ok) { + if (r.total_delta.? > 0) + try cli.setFg(out, color, cli.CLR_NEGATIVE) + else + try cli.setFg(out, color, cli.CLR_POSITIVE); + } + try out.print("{s:>14}", .{fmt.fmtMoneyAbs(&pf_total_buf, r.portfolio_total)}); + if (!total_ok and !cash_ok) try cli.reset(out, color); + + // BR Total + try out.print(" {s:>14}", .{br_total_str}); + + // Status + if (is_unmapped) { + try cli.setFg(out, color, cli.CLR_WARNING); + try out.print(" Unmapped", .{}); + try cli.reset(out, color); + } else if (!cash_ok) { + var delta_buf: [24]u8 = undefined; + const d = r.cash_delta.?; + const sign: []const u8 = if (d >= 0) "+" else "-"; + try cli.setFg(out, color, cli.CLR_WARNING); + try out.print(" Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }); + try cli.reset(out, color); + } else if (!total_ok) { + var delta_buf: [24]u8 = undefined; + const d = r.total_delta.?; + const sign: []const u8 = if (d >= 0) "+" else "-"; + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" Value {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }); + try cli.reset(out, color); + } + try out.print("\n", .{}); grand_pf += r.portfolio_total; if (r.schwab_total) |t| grand_br += t; @@ -616,23 +640,24 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o try cli.reset(out, color); if (@abs(grand_delta) < 1.0) { - try cli.setFg(out, color, cli.CLR_POSITIVE); - try out.print(" ok", .{}); + // no delta } else { - try cli.setFg(out, color, cli.CLR_WARNING); const sign: []const u8 = if (grand_delta >= 0) "+" else "-"; + if (grand_delta >= 0) { + try cli.setFg(out, color, cli.CLR_NEGATIVE); + } else { + try cli.setFg(out, color, cli.CLR_POSITIVE); + } try out.print(" delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&grand_delta_buf, @abs(grand_delta)) }); + try cli.reset(out, color); } - try cli.reset(out, color); try out.print("\n", .{}); if (discrepancy_count > 0) { try cli.setFg(out, color, cli.CLR_WARNING); - try out.print(" {d} discrepancies — drill down with: zfin audit --schwab \n", .{discrepancy_count}); - try cli.reset(out, color); - } else { - try cli.setFg(out, color, cli.CLR_POSITIVE); - try out.print(" All accounts match\n", .{}); + try out.print(" {d} {s} — drill down with: zfin audit --schwab \n", .{ + discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"), + }); try cli.reset(out, color); } try out.print("\n", .{}); @@ -848,7 +873,10 @@ pub fn compareAccounts( fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { try cli.setBold(out, color); - try out.print("\nPortfolio Audit\n", .{}); + try out.print("\nPortfolio Audit", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" (brokerage is source of truth)\n", .{}); try cli.reset(out, color); try out.print("========================================\n\n", .{}); @@ -875,7 +903,7 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. // Column headers try cli.setFg(out, color, cli.CLR_MUTED); try out.print(" {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} {s}\n", .{ - "Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "Status", + "Symbol", "PF Shares", "BR Shares", "PF Price", "BR Price", "", }); try cli.reset(out, color); @@ -885,42 +913,66 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. var pf_price_buf: [16]u8 = undefined; var br_price_buf: [16]u8 = undefined; - const pf_shares_str = if (cmp.is_cash) - "--" + // Classify this row + const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else !cmp.only_in_brokerage; + const is_cash_mismatch = cmp.is_cash and (if (cmp.value_delta) |d| @abs(d) >= 1.0 else false); + const is_real_mismatch = !shares_ok or cmp.only_in_brokerage or cmp.only_in_portfolio or is_cash_mismatch; + + if (is_real_mismatch) discrepancy_count += 1; + + // Format share strings + const pf_shares_str: []const u8 = if (cmp.is_cash) + (fmt.fmtMoneyAbs(&pf_shares_buf, cmp.portfolio_value)) else std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?"; - const br_shares_str = if (cmp.is_cash) - "--" + const br_shares_str: []const u8 = if (cmp.is_cash) + (if (cmp.brokerage_value) |v| fmt.fmtMoneyAbs(&br_shares_buf, v) else "--") else if (cmp.brokerage_shares) |s| (std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?") else "--"; const pf_price_str: []const u8 = if (cmp.is_cash) - "--" + "" else if (cmp.portfolio_price) |p| (std.fmt.bufPrint(&pf_price_buf, "{d:.2}", .{p}) catch "?") else "--"; const br_price_str: []const u8 = if (cmp.is_cash) - "--" + "" else if (cmp.brokerage_price) |p| (std.fmt.bufPrint(&br_price_buf, "{d:.2}", .{p}) catch "?") else "--"; - // Determine status + // Determine PF Shares color (relative to brokerage) + const shares_color: enum { normal, positive, negative, warning } = blk: { + if (cmp.only_in_portfolio) break :blk .warning; + if (cmp.is_cash and is_cash_mismatch) { + break :blk if (cmp.value_delta.? > 0) .negative else .positive; + } + if (shares_ok) break :blk .normal; + if (cmp.shares_delta) |d| { + break :blk if (d > 0) .negative else .positive; + } + break :blk .normal; + }; + + // Determine status text var status_buf: [64]u8 = undefined; const status: []const u8 = blk: { if (cmp.only_in_brokerage) break :blk "Brokerage only"; if (cmp.only_in_portfolio) break :blk "Portfolio only"; - const shares_ok = if (cmp.shares_delta) |d| @abs(d) < 0.01 else true; - const value_ok = if (cmp.value_delta) |d| @abs(d) < 1.0 else true; - - if (shares_ok and value_ok) break :blk "ok"; + if (is_cash_mismatch) { + if (cmp.value_delta) |d| { + var delta_buf: [24]u8 = undefined; + const sign: []const u8 = if (d >= 0) "+" else "-"; + break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "Cash mismatch"; + } + } if (!shares_ok) { if (cmp.shares_delta) |d| { @@ -928,31 +980,50 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. break :blk std.fmt.bufPrint(&status_buf, "Shares {s}{d:.3}", .{ sign, d }) catch "Shares mismatch"; } } - if (!value_ok) { - if (cmp.value_delta) |d| { + + // Shares match — show value delta (stale price) if any, muted + if (cmp.value_delta) |d| { + if (@abs(d) >= 1.0) { var delta_buf: [24]u8 = undefined; - const delta_str = fmt.fmtMoneyAbs(&delta_buf, @abs(d)); const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Value {s}{s}", .{ sign, delta_str }) catch "Value mismatch"; + break :blk std.fmt.bufPrint(&status_buf, "Value {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch ""; } } - break :blk "Mismatch"; + + break :blk ""; }; - // Color the status - const is_ok = std.mem.eql(u8, status, "ok"); - if (!is_ok) discrepancy_count += 1; + // Status color: real mismatches in warning, stale values muted, ok is blank + const status_color: enum { warning, muted, none } = blk: { + if (is_real_mismatch) break :blk .warning; + if (status.len > 0) break :blk .muted; + break :blk .none; + }; - try out.print(" {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} ", .{ - cmp.symbol, pf_shares_str, br_shares_str, pf_price_str, br_price_str, - }); - if (is_ok) { - try cli.setFg(out, color, cli.CLR_POSITIVE); - } else { - try cli.setFg(out, color, cli.CLR_WARNING); + // Print symbol + try out.print(" {s:<24} ", .{cmp.symbol}); + + // Print PF Shares with color + switch (shares_color) { + .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), + .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), + .warning => try cli.setFg(out, color, cli.CLR_WARNING), + .normal => {}, + } + try out.print("{s:>12}", .{pf_shares_str}); + if (shares_color != .normal) try cli.reset(out, color); + + // Print BR Shares, prices + try out.print(" {s:>12} {s:>10} {s:>10} ", .{ br_shares_str, pf_price_str, br_price_str }); + + // Print status + switch (status_color) { + .warning => try cli.setFg(out, color, cli.CLR_WARNING), + .muted => try cli.setFg(out, color, cli.CLR_MUTED), + .none => {}, } try out.print("{s}\n", .{status}); - try cli.reset(out, color); + if (status_color != .none) try cli.reset(out, color); } // Account totals @@ -965,15 +1036,19 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. }); try cli.reset(out, color); - if (@abs(acct.total_delta) < 1.0) { - try cli.setFg(out, color, cli.CLR_POSITIVE); - try out.print("ok\n", .{}); + const delta = acct.total_delta; + if (@abs(delta) < 1.0) { + try out.print("\n", .{}); } else { - try cli.setFg(out, color, cli.CLR_WARNING); - const sign: []const u8 = if (acct.total_delta >= 0) "+" else "-"; - try out.print("Delta {s}{s}\n", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(acct.total_delta)) }); + const sign: []const u8 = if (delta >= 0) "+" else "-"; + if (delta >= 0) { + try cli.setFg(out, color, cli.CLR_NEGATIVE); + } else { + try cli.setFg(out, color, cli.CLR_POSITIVE); + } + try out.print("Delta {s}{s}\n", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(delta)) }); + try cli.reset(out, color); } - try cli.reset(out, color); try out.print("\n", .{}); total_portfolio += acct.portfolio_total; @@ -994,23 +1069,22 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. try cli.reset(out, color); if (@abs(grand_delta) < 1.0) { - try cli.setFg(out, color, cli.CLR_POSITIVE); - try out.print(" ok", .{}); + // no delta text needed } else { - try cli.setFg(out, color, cli.CLR_WARNING); const sign: []const u8 = if (grand_delta >= 0) "+" else "-"; + if (grand_delta >= 0) { + try cli.setFg(out, color, cli.CLR_NEGATIVE); + } else { + try cli.setFg(out, color, cli.CLR_POSITIVE); + } try out.print(" delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&grand_delta_buf, @abs(grand_delta)) }); + try cli.reset(out, color); } - try cli.reset(out, color); try out.print("\n", .{}); if (discrepancy_count > 0) { try cli.setFg(out, color, cli.CLR_WARNING); - try out.print(" {d} discrepancies found\n", .{discrepancy_count}); - try cli.reset(out, color); - } else { - try cli.setFg(out, color, cli.CLR_POSITIVE); - try out.print(" All positions match\n", .{}); + try out.print(" {d} {s} to investigate\n", .{ discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches") }); try cli.reset(out, color); } try out.print("\n", .{});