introduce concept of uncounted flows to surface specific situations
This commit is contained in:
parent
3f9cb33be0
commit
a621f2932b
3 changed files with 180 additions and 2 deletions
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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()`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue