fix wording on benchmarks
This commit is contained in:
parent
6bd063d51f
commit
eae4299ad3
4 changed files with 97 additions and 20 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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) ────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue