Compare commits

...

3 commits

Author SHA1 Message Date
23fd4e3b63
alignment fixes in contributions
All checks were successful
Generic zig build / build (push) Successful in 5m15s
Generic zig build / publish-macos (push) Successful in 12s
Generic zig build / deploy (push) Successful in 20s
2026-09-05 11:43:30 -07:00
cee557d55a
more detail in contributions 2026-09-05 11:32:16 -07:00
eae4299ad3
fix wording on benchmarks 2026-09-05 11:27:54 -07:00
5 changed files with 309 additions and 75 deletions

View file

@ -66,6 +66,15 @@ cash, CDs, options, and anything with no `metadata.srf` row -- so a raw
sleeve, and would not be comparable against the portfolio row (which is
renormalized). Both rows therefore describe invested assets only.
The row labels say so, and they are renormalized to match: a leg reads
`SPYM (89.4% of benchmark)`, not its raw portfolio weight, so that
multiplying the printed weights by the printed returns reproduces the
printed blend. The blend row names the share of the portfolio it covers,
`Benchmark (94.9% of portfolio)`, and omits that clause entirely once
coverage reaches 99.5%. A leg's raw portfolio weight is still recoverable
as `of benchmark x of portfolio` -- here `0.894 x 0.949 = 84.9%`, which
is the figure the `Target allocation` line reports.
The year columns are **total return** (dividend-reinvested). The `Week`
column is price-only, because dividends over seven days are negligible;
the table header says so.

View file

@ -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)
@ -3233,13 +3274,10 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
try printSection(out, "Lot edits (same position, key rewritten - not counted)", color, h_color);
for (report.changes) |c| switch (c.kind) {
.lot_edited => {
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(
&buf,
" {s: <12} {s: <24} (strict key broke, shares unchanged)\n",
.{ c.symbol, c.account },
) catch " (lot edit)\n";
try cli.printFg(out, color, mut_color, "{s}", .{msg});
try cli.setFg(out, color, mut_color);
try writeRowPrefix(out, c.symbol, c.account);
try out.writeAll(" (strict key broke, shares unchanged)\n");
try cli.reset(out, color);
},
else => {},
};
@ -3303,7 +3341,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});
// Same width and the same gutter guarantee as the per-change rows, minus
// the symbol column this section has no use for.
try out.writeAll(" ");
try padTo(out, acct_label, acct_w);
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 +3367,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 {
@ -3337,6 +3429,66 @@ fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void {
try cli.printFg(out, color, muted, " (none)\n", .{});
}
/// Symbol column. 16 because `Engagement Ring` is 15 - illiquid assets carry
/// free-form names rather than tickers, so the old 14 was sized for a population
/// this report no longer only contains.
const sym_w = 16;
/// Account column. 30 because `Fidelity Emil 401(k) Roth BL` is 28.
const acct_w = 30;
/// Pad `s` into a `w`-wide field, always leaving at least one space behind it.
///
/// The guarantee is the point. `{s:<N}` OVERFLOWS instead of truncating, so a
/// single over-long value both shifted its own row right AND consumed the gutter,
/// welding itself to the next field. And no fixed `N` avoids it here: symbols in
/// this report run from a 3-character ticker to a 24-character option
/// (`AMZN 09/18/2026 280.00 C`), with hand-named illiquid assets in between. So
/// the widths above are chosen for the common case and this keeps the rare long
/// one readable - it loses its alignment, not its whitespace.
fn padTo(out: *std.Io.Writer, s: []const u8, w: usize) !void {
try out.writeAll(s);
try out.splatByteAll(' ', if (s.len >= w) 1 else w - s.len);
}
/// The ` symbol account ` prefix every per-change row opens with.
///
/// One function rather than a format-string literal repeated at fourteen call
/// sites. The literal is how the widths drifted out of step in the first place:
/// twelve rows said `{s:<14}{s:<24}`, the CD continuation said the same with
/// blank arguments, the lot-edit row said `{s: <12} {s: <24}`, and the account
/// summary said `{s:<28}` - four different answers to one question, and each new
/// long value found a different one of them.
///
/// Callers own the single space that follows, so content lands one column past
/// `acct_w`. Keep it: every section has to agree on that column or the report
/// shears between sections instead of within a row, which is harder to spot.
fn writeRowPrefix(out: *std.Io.Writer, symbol: []const u8, account: []const u8) !void {
try out.writeAll(" ");
try padTo(out, symbol, sym_w);
try padTo(out, account, acct_w);
}
test "padTo: pads short values to width and never welds a long one" {
var buf: [128]u8 = undefined;
// Short: padded to the column.
var w1 = std.Io.Writer.fixed(&buf);
try padTo(&w1, "CASH", 16);
try std.testing.expectEqualStrings("CASH ", w1.buffered());
// Exactly one under: still a gutter, and it is the last width that aligns.
var w2 = std.Io.Writer.fixed(&buf);
try padTo(&w2, "Engagement Ring", 16);
try std.testing.expectEqualStrings("Engagement Ring ", w2.buffered());
// Over: alignment is gone, whitespace is not. `{s:<16}` produced no space
// here at all, which ran the value into the next column.
var w3 = std.Io.Writer.fixed(&buf);
try padTo(&w3, "AMZN 09/18/2026 280.00 C", 16);
try std.testing.expectEqualStrings("AMZN 09/18/2026 280.00 C ", w3.buffered());
}
fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void {
try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) });
}
@ -3350,7 +3502,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 writeRowPrefix(out, c.symbol, acct);
if (c.security_type == .cash) {
try cli.printFg(out, color, pos, " {s}", .{val_str});
} else {
@ -3369,15 +3521,15 @@ 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", .{
c.symbol,
acct,
try writeRowPrefix(out, c.symbol, acct);
try out.print(" {s:<16} face {f} maturity {s}\n", .{
verb,
Money.from(c.face_value),
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 writeRowPrefix(out, "", "");
try cli.printFg(out, color, cli.CLR_POSITIVE, " implied interest: {f}\n", .{Money.from(i)});
}
}
@ -3385,7 +3537,8 @@ 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 writeRowPrefix(out, c.symbol, acct);
try out.writeAll(" cash ");
try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) });
// Hint if a CD matured in the same account.
@ -3400,9 +3553,9 @@ 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", .{
c.symbol,
acct,
try cli.setFg(out, color, muted);
try writeRowPrefix(out, c.symbol, acct);
try out.print(" price {f} -> {f}\n", .{
Money.from(c.old_price),
Money.from(c.new_price),
});
@ -3413,17 +3566,18 @@ 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 writeRowPrefix(out, c.symbol, acct);
try out.print(" {s}", .{c.detail orelse "edited"});
},
.lot_removed => {
try out.print(" {s:<14}{s:<24} {s} lot removed (face {f})", .{
c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value),
try writeRowPrefix(out, c.symbol, acct);
try out.print(" {s} lot removed (face {f})", .{
@tagName(c.security_type), Money.from(c.face_value),
});
},
.drip_negative => {
try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({f})", .{
c.symbol, acct, Money.from(@abs(c.value())),
});
try writeRowPrefix(out, c.symbol, acct);
try out.print(" shares decreased on existing lot ({f})", .{Money.from(@abs(c.value()))});
},
else => {},
}
@ -3542,7 +3696,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 writeRowPrefix(out, sym, acct);
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg(
out,
@ -3564,7 +3718,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 writeRowPrefix(out, sym, acct);
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg(
out,
@ -3629,9 +3783,11 @@ 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 writeRowPrefix(out, c.symbol, acct);
try out.print(" sold {s} ({f})", .{ 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 writeRowPrefix(out, c.symbol, acct);
try out.print(" sold {d} lots {s} ({f})", .{ lots, basis, Money.from(proceeds) });
}
try cli.reset(out, color);
try out.writeAll("\n");
@ -3649,7 +3805,8 @@ 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 writeRowPrefix(out, sym, acct);
try out.print(" {f} from existing cash", .{Money.from(c.internal_funded)});
if (c.internal_funded + 0.005 < lot_value) {
try out.print(" (of {f} lot)", .{Money.from(lot_value)});
}

View file

@ -892,27 +892,36 @@ pub fn runBands(
"", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
});
// Build return rows via view model
// Build return rows via view model. Weights are renormalized to the blend,
// matching what `comparison.*_returns` were actually computed from.
const bw = view.benchmarkWeights(ctx.stock_pct, ctx.bond_pct);
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
var spy_label_buf: [48]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmarkStock(), ctx.stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmarkStock(), bw.stock),
comparison.stock_returns,
&spy_bufs,
false,
);
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
var agg_label_buf: [48]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmarkBond(), ctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmarkBond(), bw.bond),
comparison.bond_returns,
&agg_bufs,
false,
);
var bench_bufs: [5][16]u8 = undefined;
const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
var bench_label_buf: [48]u8 = undefined;
const bench_row = view.buildReturnRow(
view.fmtBenchmarkAggregateLabel(&bench_label_buf, bw.covered),
comparison.benchmark_returns,
&bench_bufs,
true,
);
var port_bufs: [5][16]u8 = undefined;
const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);

View file

@ -1137,11 +1137,13 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
.style = th.headerStyle(),
});
// Return rows
// Return rows. Weights renormalized to the blend - see `benchmarkWeights`.
const bw = view.benchmarkWeights(stock_pct, pctx.bond_pct);
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
var spy_label_buf: [48]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), bw.stock),
comparison.stock_returns,
&spy_bufs,
false,
@ -1149,9 +1151,9 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
try appendReturnRow(lines, arena, th, spy_row);
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
var agg_label_buf: [48]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), pctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), bw.bond),
comparison.bond_returns,
&agg_bufs,
false,
@ -1159,7 +1161,13 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
try appendReturnRow(lines, arena, th, agg_row);
var bench_bufs: [5][16]u8 = undefined;
const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
var bench_label_buf: [48]u8 = undefined;
const bench_row = view.buildReturnRow(
view.fmtBenchmarkAggregateLabel(&bench_label_buf, bw.covered),
comparison.benchmark_returns,
&bench_bufs,
true,
);
try appendReturnRow(lines, arena, th, bench_row);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -1887,11 +1895,13 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
.style = th.headerStyle(),
});
// Return rows
// Return rows. Weights renormalized to the blend - see `benchmarkWeights`.
const bw = view.benchmarkWeights(stock_pct, ctx.bond_pct);
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
var spy_label_buf: [48]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), bw.stock),
comparison.stock_returns,
&spy_bufs,
false,
@ -1899,9 +1909,9 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
try appendReturnRow(&lines, arena, th, spy_row);
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
var agg_label_buf: [48]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), ctx.bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), bw.bond),
comparison.bond_returns,
&agg_bufs,
false,
@ -1909,7 +1919,13 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
try appendReturnRow(&lines, arena, th, agg_row);
var bench_bufs: [5][16]u8 = undefined;
const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
var bench_label_buf: [48]u8 = undefined;
const bench_row = view.buildReturnRow(
view.fmtBenchmarkAggregateLabel(&bench_label_buf, bw.covered),
comparison.benchmark_returns,
&bench_bufs,
true,
);
try appendReturnRow(&lines, arena, th, bench_row);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

View file

@ -144,9 +144,52 @@ pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f
return .{ .text = text, .style = style };
}
/// Format the stock benchmark label with weight.
/// Benchmark weights as the blend actually uses them.
///
/// `deriveAllocationSplit` returns stock/bond as fractions of the WHOLE
/// portfolio, so they do not sum to 1 - the remainder is cash, CDs, options and
/// anything with no `metadata.srf` row. `blendReturns` then renormalizes to the
/// weight present (`benchmark.blendOptional`), which is what stops a cash sleeve
/// dragging the benchmark down against a portfolio return that excludes it too.
///
/// The labels have to agree with that arithmetic. Printing the raw 84.9%/10.0%
/// beside a row computed from 89.5%/10.5% invites the reader to multiply out and
/// get 17.14% where the row says 18.06%. Both numbers are correct and the
/// mismatch is entirely in the caption - the same trap that already cost an hour
/// on the `--as-of` table and is why `review` prints that table's date.
///
/// Nothing is lost by renormalizing the caption: the raw portfolio weight of
/// either leg is `stock/100 * covered`.
pub const BenchmarkWeights = struct {
/// Share of the BENCHMARK. Sums to 100 with `bond`.
stock: f64,
bond: f64,
/// Share of the PORTFOLIO the benchmark covers.
covered: f64,
};
pub fn benchmarkWeights(stock_pct: f64, bond_pct: f64) BenchmarkWeights {
const w = stock_pct + bond_pct;
if (w <= 0) return .{ .stock = 0, .bond = 0, .covered = 0 };
return .{
.stock = stock_pct / w * 100.0,
.bond = bond_pct / w * 100.0,
.covered = w * 100.0,
};
}
/// Format the benchmark leg label with its weight *within the benchmark*.
pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 {
return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol;
return std.fmt.bufPrint(buf, "{s} ({d:.1}% of benchmark)", .{ symbol, weight_pct }) catch symbol;
}
/// Label for the blended row. Discloses how much of the portfolio the blend
/// covers, but only when it is materially short of all of it - on a fully
/// classified stock/bond portfolio there is nothing to disclose and the bare
/// word is less noise.
pub fn fmtBenchmarkAggregateLabel(buf: []u8, covered_pct: f64) []const u8 {
if (covered_pct >= 99.5) return "Benchmark";
return std.fmt.bufPrint(buf, "Benchmark ({d:.1}% of portfolio)", .{covered_pct}) catch "Benchmark";
}
// Precomputed projection data (shared by CLI and TUI)