introduce concept of uncounted flows to surface specific situations
Some checks failed
Generic zig build / build (push) Failing after 49s
Generic zig build / deploy (push) Has been skipped
Generic zig build / publish-macos (push) Has been skipped

This commit is contained in:
Emil Lerch 2026-08-31 08:19:48 -07:00
parent 3f9cb33be0
commit a621f2932b
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 180 additions and 2 deletions

View file

@ -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" {

View file

@ -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

View file

@ -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()`.