From a621f2932bfa79f8ddce94b581da541119760614 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 31 Aug 2026 08:19:48 -0700 Subject: [PATCH] introduce concept of uncounted flows to surface specific situations --- src/commands/compare.zig | 101 +++++++++++++++++++++++++++++++++ src/commands/contributions.zig | 65 ++++++++++++++++++++- src/views/compare.zig | 16 +++++- 3 files changed, 180 insertions(+), 2 deletions(-) diff --git a/src/commands/compare.zig b/src/commands/compare.zig index a8684ad..9e40cb7 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -609,6 +609,8 @@ fn renderFromParts( cv.attribution = .{ .contributions = a.total(), .gains = cv.liquid.delta - a.total(), + .uncounted_in = a.uncounted_in, + .uncounted_out = a.uncounted_out, }; } @@ -865,6 +867,30 @@ fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attributi try cli.printGainLoss(out, color, attribution.gains, "{f}\n", .{Money.from(attribution.gains).signed()}); try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Cash contributions:"}); try cli.printGainLoss(out, color, attribution.contributions, "{f}\n", .{Money.from(attribution.contributions).signed()}); + + // Uncounted movement, when there is any. + // + // `gains` above is a RESIDUAL (`delta - contributions`), so anything the + // classifier declines to count is reported as market performance. Raw + // cash-balance changes and share reductions are both excluded by design - see + // `AttributionSummary.uncounted_in` - and on a week where they do not cancel, + // that error lands silently in the gains figure. + // + // Printed as two gross figures rather than one net, because the net hides the + // scale: one real week netted to -$31.82 out of $25k moving each way, and + // "-$31.82" would have said nothing useful about it. + // + // Suppressed entirely on a clean week so the common case stays two lines. + if (attribution.hasUncounted()) { + try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Uncounted in/out:"}); + try cli.printGainLoss(out, color, attribution.uncounted_in, "{f}", .{Money.from(attribution.uncounted_in).signed()}); + try cli.printFg(out, color, cli.CLR_MUTED, " / ", .{}); + try cli.printGainLoss(out, color, attribution.uncounted_out, "{f}", .{Money.from(attribution.uncounted_out).signed()}); + const net = attribution.uncounted_in + attribution.uncounted_out; + try cli.printFg(out, color, cli.CLR_MUTED, " (net ", .{}); + try cli.printGainLoss(out, color, net, "{f}", .{Money.from(net).signed()}); + try cli.printFg(out, color, cli.CLR_MUTED, "; inside `Investment gains` - see `zfin contributions`)\n", .{}); + } } fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void { @@ -1187,6 +1213,81 @@ test "renderCompare: attribution line when attribution is set" { try testing.expect(std.mem.indexOf(u8, out, "+$7,512.02") != null); // The old `Attribution:` prefix is gone. try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == null); + + // A clean week has nothing uncounted, so the third line stays off and the + // common case remains two rows. + try testing.expect(std.mem.indexOf(u8, out, "Uncounted") == null); +} + +test "renderCompare: uncounted flows are surfaced gross, both directions" { + // The real week that motivated this. `gains` is a residual, so a $25k cash + // increase and a $25k share reduction - both deliberately uncounted - were + // folded into "Investment gains: +$173.85" with nothing to indicate it. They + // happened to be two legs of one internal move and nearly cancelled; an + // unmatched leg of that size would have read as market performance. + const cv = view.CompareView{ + .then_date = Date.fromYmd(2026, 8, 21), + .now_date = Date.fromYmd(2026, 8, 30), + .days_between = 9, + .now_is_live = true, + .liquid = view.buildTotalsRow(8_986_340.92, 8_987_832.16), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + .attribution = .{ + .contributions = 1_317.40, + .gains = 173.85, + .uncounted_in = 25_079.42, + .uncounted_out = -25_111.24, + }, + }; + + var buf: [4096]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + try renderCompare(&stream, false, cv, null); + const out = stream.buffered(); + + // Gross both ways, because the net alone conceals the scale. + try testing.expect(std.mem.indexOf(u8, out, "+$25,079.42") != null); + try testing.expect(std.mem.indexOf(u8, out, "-$25,111.24") != null); + // And the net, so a cancelling pair is visibly a cancelling pair. + try testing.expect(std.mem.indexOf(u8, out, "-$31.82") != null); + // Says where the money currently sits, so the residual is not read as clean. + try testing.expect(std.mem.indexOf(u8, out, "Investment gains") != null); + try testing.expect(std.mem.indexOf(u8, out, "zfin contributions") != null); +} + +test "renderCompare: a one-directional uncounted flow still reports" { + // The dangerous shape: nothing to cancel against, so the full amount is inside + // `gains`. Must not be suppressed just because one side is zero. + const cv = view.CompareView{ + .then_date = Date.fromYmd(2026, 8, 21), + .now_date = Date.fromYmd(2026, 8, 28), + .days_between = 7, + .now_is_live = false, + .liquid = view.buildTotalsRow(1_000_000, 1_020_000), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + .attribution = .{ + .contributions = 0, + .gains = 20_000, + .uncounted_in = 18_000, + .uncounted_out = 0, + }, + }; + + var buf: [4096]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + try renderCompare(&stream, false, cv, null); + const out = stream.buffered(); + + try testing.expect(std.mem.indexOf(u8, out, "Uncounted") != null); + try testing.expect(std.mem.indexOf(u8, out, "+$18,000.00") != null); + // Net equals the single leg; 90% of the reported "gains" is unexplained flow. + try testing.expect(std.mem.indexOf(u8, out, "net +$18,000.00") != null); } test "renderCompare: no attribution line when attribution is null" { diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 5e42d5b..c648110 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -927,10 +927,43 @@ pub const AttributionSummary = struct { /// contribution-vs-DRIP cases, treated as DRIP here to avoid /// double-counting with cash contributions). drip: f64, + /// Gross value of movements this pipeline deliberately does NOT count, split by + /// direction. Reported, never added to `total()`. + /// + /// ## Why these are surfaced at all + /// + /// `compare` derives market performance as a RESIDUAL: `gains = delta - + /// contributions`. That makes every classification gap indistinguishable from + /// market movement. Raw cash-balance changes are noise by default (interest, + /// DRIP legs, settlement sweeps), and share reductions are treated as a funding + /// source rather than an outflow - both defensible, and both invisible once + /// folded into a residual. + /// + /// On one real week that hid a $25,079.42 cash increase against a $25,111.24 + /// share reduction. They were two legs of one internal move and netted to + /// -$31.82, so `gains` was very nearly right - but nothing on screen said so, + /// and an unmatched leg of that size would have been reported as market + /// performance without comment. + /// + /// ## Why gross rather than net + /// + /// A single net figure would have read "-$31.82" for that week: accurate, and + /// useless. It conceals that $25k moved. Two gross figures say "$25k came in, + /// $25k went out, they cancel", which is the fact worth knowing. + uncounted_in: f64 = 0, + /// Gross uncounted outflows, as a NEGATIVE number so the sign carries meaning + /// at the callsite without a naming convention to remember. + uncounted_out: f64 = 0, pub fn total(self: AttributionSummary) f64 { return self.new_contributions + self.drip; } + + /// True when there is any uncounted movement worth showing. A cent threshold, + /// because float noise on a quiet week should not produce a line. + pub fn hasUncounted(self: AttributionSummary) bool { + return @abs(self.uncounted_in) >= 0.005 or @abs(self.uncounted_out) >= 0.005; + } }; /// Run the contributions pipeline over a commit window and return the @@ -1165,10 +1198,35 @@ 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. else => {}, }; // Cash-dest transfer attribution is already removed by `attributedValue()` on @@ -1176,7 +1234,12 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary { // `transfer_attributed`, so a fully-attributed cash Change contributes zero // here without any separate per-account subtraction. - return .{ .new_contributions = new_contributions, .drip = drip }; + return .{ + .new_contributions = new_contributions, + .drip = drip, + .uncounted_in = uncounted_in, + .uncounted_out = uncounted_out, + }; } // ── Git discovery / invocation ─────────────────────────────── diff --git a/src/views/compare.zig b/src/views/compare.zig index 392242e..a04ba1f 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -175,8 +175,22 @@ pub const Attribution = struct { /// `zfin contributions` reports as "money in"). contributions: f64, /// `TotalsRow.delta - contributions`. The residual - what the - /// market actually did. + /// market actually did, PLUS anything the classifier declined to + /// count. See `uncounted_in` / `uncounted_out`. gains: f64, + /// Gross movement the contributions pipeline deliberately excluded, split by + /// direction (`out` is negative). Present so the residual above can be read + /// with its error bars visible: raw cash-balance changes and share reductions + /// are excluded by design, and folding them into `gains` unannounced makes a + /// classification gap look like market performance. + /// + /// Both zero on a clean week, in which case the renderer omits the line. + uncounted_in: f64 = 0, + uncounted_out: f64 = 0, + + pub fn hasUncounted(self: Attribution) bool { + return @abs(self.uncounted_in) >= 0.005 or @abs(self.uncounted_out) >= 0.005; + } }; /// Complete compare view. `symbols` is caller-owned; call `deinit()`.