diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index c648110..564125d 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -1178,6 +1178,60 @@ fn collectUnmatchedLargeLots( return out.toOwnedSlice(arena); } +/// The kinds that carry real value but are deliberately left OUT of attribution - +/// and the only place that list lives. +/// +/// Two consumers read it and they must never disagree: `compare`'s +/// "Uncounted in/out" headline and this report's own "Uncounted flows" total. +/// They would drift if each carried its own list, because these six kinds are +/// scattered across FOUR different print sections below (Cash deltas, Internal +/// purchases, Flagged for review, Lot edits) and nothing about the layout hints +/// that they belong to one bucket. That scattering is exactly why the headline +/// number was untraceable: every row was on screen and no total was. +/// +/// `cash_delta` is the raw-balance-change bucket (an opted-in account's positive +/// delta is reclassified to `cash_contribution` at diff time and counted as a +/// contribution, so it cannot be double-counted here). The share-reduction kinds +/// are the other side of the same coin: `attributedValue()` pins them to 0 on the +/// grounds that they are a funding source rather than an outflow, which is right +/// for attribution and still worth SEEING. +/// +/// Deliberately NOT here: +/// - `cd_matured` / `cd_removed_early` have their own section and always pair +/// with a cash increase in the same account, so counting both legs would +/// double-report one internal move. +/// - `transfer_in` / `transfer_out` / `unmatched_transfer` are DECLARED internal +/// moves (`transaction_log.srf`). The point of the total is UNdeclared +/// movement; a declared transfer is not a surprise. +/// - `price_only` carries no share change, so its value is zero anyway. +fn isUncountedKind(k: ChangeKind) bool { + return switch (k) { + .cash_delta, .drip_negative, .lot_removed, .position_closed, .lot_edited, .flagged => true, + else => false, + }; +} + +/// Gross in/out split of the uncounted flows, signed the way `compare` prints +/// them: `out` accumulates negatives, so `in + out` is the net and needs no +/// further sign juggling at either call site. +/// +/// Uses `value()`, not `attributedValue()` - the whole point is the value that +/// attribution threw away. Note this is a DIFFERENT basis from the figure +/// `printCollapsedSales` shows for a sale, which uses `face_value` (what the +/// trade realized) and can legitimately differ from a current-price mark. That +/// is why only the Cash deltas section gets a per-section total below: there the +/// two bases coincide, so the section total and this one cannot disagree. +fn uncountedTotals(changes: []const Change) struct { in: f64, out: f64 } { + var in: f64 = 0; + var out: f64 = 0; + for (changes) |c| { + if (!isUncountedKind(c.kind)) continue; + const v = c.value(); + if (v >= 0) in += v else out += v; + } + return .{ .in = in, .out = out }; +} + fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // Aggregate. Classification logic matches the full-report sections: @@ -1198,37 +1252,15 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // `partial_transfer_in` -> residual only (attributedValue()). var new_contributions: f64 = 0; var drip: f64 = 0; - var uncounted_in: f64 = 0; - var uncounted_out: f64 = 0; for (ctx.report.changes) |c| switch (c.kind) { .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), .partial_transfer_in => new_contributions += c.attributedValue(), - - // Uncounted, and now reported rather than silently dropped. See - // `AttributionSummary.uncounted_in`. - // - // `cash_delta` is the raw-balance-change bucket (an opted-in account's - // positive delta is reclassified to `cash_contribution` at diff time and - // counted above, so it cannot be double-counted here). The share-reduction - // kinds are the other side of the same coin: `attributedValue()` pins them - // to 0 on the grounds that they are a funding source rather than an outflow, - // which is right for attribution and still worth SEEING. - .cash_delta, .drip_negative, .lot_removed, .position_closed, .lot_edited, .flagged => { - const v = c.value(); - if (v >= 0) uncounted_in += v else uncounted_out += v; - }, - - // Deliberately NOT in the uncounted totals: - // - `cd_matured` / `cd_removed_early` have their own report section and - // always pair with a cash increase in the same account, so counting both - // legs would double-report one internal move. - // - `transfer_in` / `transfer_out` / `unmatched_transfer` are DECLARED - // internal moves (`transaction_log.srf`). The point of this line is - // UNdeclared movement; a declared transfer is not a surprise. - // - `price_only` carries no share change, so its value is zero anyway. + // The uncounted kinds fall through to `uncountedTotals` below, which owns + // that list. Everything else contributes nothing. else => {}, }; + const unc = uncountedTotals(ctx.report.changes); // Cash-dest transfer attribution is already removed by `attributedValue()` on // the per-Change side: `matchCashDestination` accumulates into // `transfer_attributed`, so a fully-attributed cash Change contributes zero @@ -1237,8 +1269,8 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary { return .{ .new_contributions = new_contributions, .drip = drip, - .uncounted_in = uncounted_in, - .uncounted_out = uncounted_out, + .uncounted_in = unc.in, + .uncounted_out = unc.out, }; } @@ -3124,16 +3156,25 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co try out.writeAll("\n"); // ── Section: Cash deltas ── + // + // Totalled, unlike the other uncounted sections, because this is the one + // that usually carries the bulk of `compare`'s "Uncounted in" and the one a + // reader ends up adding by hand. Safe to total here because these rows print + // `value()`, the same basis `uncountedTotals` uses - see the note there for + // why the sale sections are left alone. try printSection(out, "Cash deltas (raw balance changes)", color, h_color); any = false; + var cash_delta_total: f64 = 0; for (report.changes) |c| switch (c.kind) { .cash_delta => { any = true; + cash_delta_total += c.value(); try printCashDeltaLine(out, c, report, color); }, else => {}, }; if (!any) try printNone(out, color, mut_color); + if (any) try printTotalLine(out, "Total", cash_delta_total, color, h_color); try out.writeAll("\n"); // ── Section: Internal purchases (internally funded - not counted) ── @@ -3236,7 +3277,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, - " {s: <12} {s: <24} (strict key broke, shares unchanged)\n", + " {s:<14}{s:<30} (strict key broke, shares unchanged)\n", .{ c.symbol, c.account }, ) catch " (lot edit)\n"; try cli.printFg(out, color, mut_color, "{s}", .{msg}); @@ -3303,7 +3344,10 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co total_cd_int += cd_int; const acct_label = if (acct.len == 0) "(no account)" else acct; - try out.print(" {s:<28}", .{acct_label}); + // 30 to match the per-change rows above. `Fidelity Emil 401(k) Roth BL` + // is 28 characters, so 28 left zero gutter and the next field butted + // straight up against the name. + try out.print(" {s:<30}", .{acct_label}); try printSummaryCell(out, " new", t.new_money, color); try printSummaryCell(out, " drip", t.drip_confirmed, color); try printSummaryCell(out, " rollup", t.rollup, color); @@ -3326,6 +3370,57 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co // even though it originated inside the portfolio. const grand = total_new + total_drip + total_rollup + total_cd_int; try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)}); + + // The figures `compare` prints as "Uncounted in/out", restated here because + // that line says "see `zfin contributions`" and until now nothing in this + // report added up to them. Same helper, so they are the same numbers by + // construction rather than by hope. Named sections so the rows behind each + // side can actually be found. + const unc = uncountedTotals(report.changes); + if (@abs(unc.in) >= 0.005 or @abs(unc.out) >= 0.005) { + try out.writeAll("\n"); + try cli.printFg(out, color, h_color, "Uncounted flows (inside `Investment gains`, not attributed)\n", .{}); + try out.print(" In: {f}\n", .{Money.from(unc.in).signed()}); + try out.print(" Out: {f}\n", .{Money.from(unc.out).signed()}); + try cli.printFg(out, color, h_color, " Net: {f}\n", .{Money.from(unc.in + unc.out).signed()}); + + // Name only the sections that actually contributed. Listing all four + // unconditionally would point at headings that were never printed + // (Flagged and Lot edits are both conditional), and it would put their + // titles on screen even when the sections are absent - which is not just + // untidy, it defeats any "this section did not appear" check. + var listed: usize = 0; + for ([_]struct { name: []const u8, hit: bool }{ + .{ .name = "Cash deltas", .hit = anyUncountedIn(report.changes, .cash_delta_group) }, + .{ .name = "Internal purchases", .hit = anyUncountedIn(report.changes, .sale_group) }, + .{ .name = "Flagged for review", .hit = anyUncountedIn(report.changes, .flagged_group) }, + }) |g| { + if (!g.hit) continue; + try cli.printFg(out, color, mut_color, "{s}{s}", .{ if (listed == 0) " from: " else ", ", g.name }); + listed += 1; + } + if (listed > 0) try out.writeAll("\n"); + } +} + +/// Which print section a given uncounted change shows up under. Used only to +/// caption the total; `Lot edits` is deliberately absent because `lot_edited` +/// values to zero, so it can never move the figure it would be credited for. +const UncountedGroup = enum { cash_delta_group, sale_group, flagged_group }; + +fn anyUncountedIn(changes: []const Change, group: UncountedGroup) bool { + for (changes) |c| { + if (!isUncountedKind(c.kind)) continue; + if (@abs(c.value()) < 0.005) continue; + const g: UncountedGroup = switch (c.kind) { + .cash_delta => .cash_delta_group, + .position_closed, .lot_removed, .drip_negative => if (isSaleKind(c)) .sale_group else .flagged_group, + .flagged => .flagged_group, + else => continue, + }; + if (g == group) return true; + } + return false; } fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void { @@ -3350,7 +3445,7 @@ fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !voi const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?"; const acct = if (c.account.len == 0) "(no account)" else c.account; - try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct }); + try out.print(" {s:<14}{s:<30}", .{ c.symbol, acct }); if (c.security_type == .cash) { try cli.printFg(out, color, pos, " {s}", .{val_str}); } else { @@ -3369,7 +3464,7 @@ fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bo .cd_removed_early => "removed EARLY", else => "removed", }; - try out.print(" {s:<14}{s:<24} {s:<16} face {f} maturity {s}\n", .{ + try out.print(" {s:<14}{s:<30} {s:<16} face {f} maturity {s}\n", .{ c.symbol, acct, verb, @@ -3377,7 +3472,7 @@ fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bo mat_str, }); if (implied_interest) |i| { - try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {f}\n", .{ "", "", Money.from(i) }); + try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<30} implied interest: {f}\n", .{ "", "", Money.from(i) }); } } @@ -3385,7 +3480,7 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col const v = c.value(); const acct = if (c.account.len == 0) "(no account)" else c.account; const sign = if (v >= 0) "+" else "-"; - try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct }); + try out.print(" {s:<14}{s:<30} cash ", .{ c.symbol, acct }); try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) }); // Hint if a CD matured in the same account. @@ -3400,7 +3495,7 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { const acct = if (c.account.len == 0) "(no account)" else c.account; - try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {f} -> {f}\n", .{ + try cli.printFg(out, color, muted, " {s:<14}{s:<30} price {f} -> {f}\n", .{ c.symbol, acct, Money.from(c.old_price), @@ -3413,15 +3508,15 @@ fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !v try cli.setFg(out, color, warn); switch (c.kind) { .flagged => { - try out.print(" {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" }); + try out.print(" {s:<14}{s:<30} {s}", .{ c.symbol, acct, c.detail orelse "edited" }); }, .lot_removed => { - try out.print(" {s:<14}{s:<24} {s} lot removed (face {f})", .{ + try out.print(" {s:<14}{s:<30} {s} lot removed (face {f})", .{ c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value), }); }, .drip_negative => { - try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({f})", .{ + try out.print(" {s:<14}{s:<30} shares decreased on existing lot ({f})", .{ c.symbol, acct, Money.from(@abs(c.value())), }); }, @@ -3542,7 +3637,7 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3 const lot_value = c.value(); const sym = if (c.symbol.len > 0) c.symbol else "cash"; - try out.print(" {s:<14}{s:<24}", .{ sym, acct }); + try out.print(" {s:<14}{s:<30}", .{ sym, acct }); try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); try cli.printFg( out, @@ -3564,7 +3659,7 @@ fn printCashFundedResidualLine(out: *std.Io.Writer, c: Change, color: bool, pos: const lot_value = c.value(); const sym = if (c.symbol.len > 0) c.symbol else "cash"; - try out.print(" {s:<14}{s:<24}", .{ sym, acct }); + try out.print(" {s:<14}{s:<30}", .{ sym, acct }); try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); try cli.printFg( out, @@ -3629,9 +3724,9 @@ fn printCollapsedSales(out: *std.Io.Writer, report: *const Report, color: bool, // current-price proxy, so say which one the reader is looking at. const basis: []const u8 = if (closed_in_place) "at close" else "at mark"; if (lots == 1) { - try out.print(" {s:<14}{s:<24} sold {s} ({f})", .{ c.symbol, acct, basis, Money.from(proceeds) }); + try out.print(" {s:<14}{s:<30} sold {s} ({f})", .{ c.symbol, acct, basis, Money.from(proceeds) }); } else { - try out.print(" {s:<14}{s:<24} sold {d} lots {s} ({f})", .{ c.symbol, acct, lots, basis, Money.from(proceeds) }); + try out.print(" {s:<14}{s:<30} sold {d} lots {s} ({f})", .{ c.symbol, acct, lots, basis, Money.from(proceeds) }); } try cli.reset(out, color); try out.writeAll("\n"); @@ -3649,7 +3744,7 @@ fn printInternalPurchaseLine(out: *std.Io.Writer, c: Change, color: bool, muted: const lot_value = c.value(); try cli.setFg(out, color, muted); - try out.print(" {s:<14}{s:<24} {f} from existing cash", .{ sym, acct, Money.from(c.internal_funded) }); + try out.print(" {s:<14}{s:<30} {f} from existing cash", .{ sym, acct, Money.from(c.internal_funded) }); if (c.internal_funded + 0.005 < lot_value) { try out.print(" (of {f} lot)", .{Money.from(lot_value)}); }