fix wording on benchmarks

This commit is contained in:
Emil Lerch 2026-09-05 11:27:54 -07:00
parent 6bd063d51f
commit eae4299ad3
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 97 additions and 20 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

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