diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md index edeac8e..b2afc24 100644 --- a/docs/reference/config/projections-srf.md +++ b/docs/reference/config/projections-srf.md @@ -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. diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 8d31b67..f647488 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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); diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 37d669d..a2ca268 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -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() }); diff --git a/src/views/projections.zig b/src/views/projections.zig index c402e53..751bfdc 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -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) ────────