diff --git a/docs/reference/config/projections-srf.md b/docs/reference/config/projections-srf.md index 4ce841c..edeac8e 100644 --- a/docs/reference/config/projections-srf.md +++ b/docs/reference/config/projections-srf.md @@ -59,6 +59,17 @@ leg's trailing returns, the bond leg's, a blend weighted by your actual equity/fixed-income split, and your portfolio's own weighted return. The two legs default to `SPY` and `AGG`: +The blend is **renormalized** to the weight actually present. Your +equity and fixed-income fractions do not sum to 1 -- the remainder is +cash, CDs, options, and anything with no `metadata.srf` row -- so a raw +`stock*w1 + bond*w2` would describe a benchmark holding a zero-return +sleeve, and would not be comparable against the portfolio row (which is +renormalized). Both rows therefore describe invested assets only. + +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. + ``` type::config,benchmark_stock::SPYM type::config,benchmark_bond::BND diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig index b57e0a9..14f00e3 100644 --- a/src/analytics/benchmark.zig +++ b/src/analytics/benchmark.zig @@ -209,7 +209,8 @@ pub fn toReturnsByPeriod(tr: TrailingReturns) ReturnsByPeriod { }; } -/// Compute the weighted average of two ReturnsByPeriod values. +/// Weighted average of two `ReturnsByPeriod`, renormalized to the weight actually +/// present. See `blendOptional`. fn blendReturns(a: ReturnsByPeriod, a_weight: f64, b: ReturnsByPeriod, b_weight: f64) ReturnsByPeriod { return .{ .one_year = blendOptional(a.one_year, a_weight, b.one_year, b_weight), @@ -220,11 +221,39 @@ fn blendReturns(a: ReturnsByPeriod, a_weight: f64, b: ReturnsByPeriod, b_weight: }; } +/// Blend two optional returns by weight, RENORMALIZED by the weight that +/// contributed. +/// +/// The renormalization is the whole point and it used to be missing. `a_weight` +/// and `b_weight` are the portfolio's own stock and bond fractions, which do NOT +/// sum to 1: the remainder is cash, CDs, options, `other`, and anything with no +/// `metadata.srf` row (see `deriveAllocationSplit`). On a real portfolio that +/// remainder was 4.7%, so `stock*0.851 + bond*0.102` described a benchmark holding +/// a 4.7% sleeve returning exactly zero. The 1-year figure printed 18.91% where +/// the two-fund blend it claims to be is 19.84%. +/// +/// It also made the table internally inconsistent, which is how it was found: +/// `portfolioWeightedReturns` DOES renormalize (see the comment there), so +/// "Benchmark" carried a cash drag that "Your Portfolio" did not, and every +/// comparison between the two rows was biased in the portfolio's favour by the +/// same 4.7%. Now both rows describe invested assets only and are comparable. +/// +/// The single-sided branches were worse than the two-sided one: with no bond data +/// at all, the "Benchmark" row was `stock * 0.851` - not a benchmark of anything. +/// Renormalizing collapses that to just `stock`, which is the honest answer when +/// the bond leg is unavailable. +/// +/// Callers wanting the un-renormalized figure want a different thing (a +/// total-portfolio return including a cash drag), and that needs a declared cash +/// return rather than an implicit zero. Not modelled here. fn blendOptional(a: ?f64, a_w: f64, b: ?f64, b_w: f64) ?f64 { - if (a != null and b != null) - return a.? * a_w + b.? * b_w; - if (a != null) return a.? * a_w; - if (b != null) return b.? * b_w; + if (a != null and b != null) { + const w = a_w + b_w; + if (w <= 0) return null; + return (a.? * a_w + b.? * b_w) / w; + } + if (a != null) return if (a_w > 0) a.? else null; + if (b != null) return if (b_w > 0) b.? else null; return null; } @@ -395,14 +424,29 @@ test "blendReturns weighted average" { try std.testing.expectApproxEqAbs(@as(f64, 0.1225), result.three_year.?, 0.0001); } -test "blendReturns handles null" { +test "blendReturns with one leg missing reports the surviving leg" { const a = ReturnsByPeriod{ .one_year = 0.20, .three_year = null }; const b = ReturnsByPeriod{ .one_year = null, .three_year = 0.04 }; const result = blendReturns(a, 0.75, b, 0.25); - // Only a has 1Y: 0.20 * 0.75 = 0.15 - try std.testing.expectApproxEqAbs(@as(f64, 0.15), result.one_year.?, 0.0001); - // Only b has 3Y: 0.04 * 0.25 = 0.01 - try std.testing.expectApproxEqAbs(@as(f64, 0.01), result.three_year.?, 0.0001); + + // This test used to assert 0.15 and 0.01 - the surviving leg scaled by its own + // weight. That figure is neither the leg's return nor a blend of anything: with + // no bond data there is nothing to blend WITH, so 75% of the stock return + // describes no portfolio. It also made "Benchmark" quietly understate itself by + // the missing leg's weight whenever one series was unavailable. + // + // Renormalizing gives the only defensible answer - the leg that exists. + try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.one_year.?, 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, 0.04), result.three_year.?, 0.0001); + + // Neither leg present stays null rather than becoming zero. + const empty = blendReturns(.{}, 0.75, .{}, 0.25); + try std.testing.expect(empty.one_year == null); + + // A zero-weight leg cannot carry the blend either: weight 0 means "not part of + // this portfolio", which is different from "return of 0%". + const zero_w = blendReturns(a, 0, .{}, 0); + try std.testing.expect(zero_w.one_year == null); } test "portfolioWeightedReturns basic" { @@ -619,6 +663,48 @@ test "conservativeWeightedReturn single position single period" { try std.testing.expectApproxEqAbs(@as(f64, 0.125), result, 0.001); } +test "buildComparison renormalizes the benchmark blend to the weight present" { + // The regression. Weights are the portfolio's OWN stock/bond fractions and do + // not sum to 1 - the remainder is cash, CDs, options and unclassified + // holdings. Every existing test here happens to use weights summing to exactly + // 1.0, where renormalized and un-renormalized agree, which is why this went + // unnoticed. + // + // Figures are the real portfolio's: 85.1% stock, 10.2% bond, SPYM 1Y 21.92%, + // AGG 1Y 2.47%. + const stock_tr = TrailingReturns{ .one_year = makePR(0.2192, 0.2192), .week = -0.0138 }; + const bond_tr = TrailingReturns{ .one_year = makePR(0.0247, 0.0247), .week = -0.0013 }; + const positions = [_]PositionReturn{}; + const result = buildComparison(stock_tr, bond_tr, 0.851, 0.102, &positions, null); + + // Un-renormalized this printed 0.851*0.2192 + 0.102*0.0247 = 0.18906 -> 18.91%, + // i.e. a two-fund blend carrying a 4.7% sleeve returning zero. The blend it + // claims to be is 0.18906 / 0.953 = 0.19839. + try std.testing.expectApproxEqAbs(@as(f64, 0.19839), result.benchmark_returns.one_year.?, 0.0001); + try std.testing.expect(result.benchmark_returns.one_year.? > 0.18906); + + // Same for Week: -0.0138*0.851 + -0.0013*0.102 = -0.0118776 -> /0.953. + try std.testing.expectApproxEqAbs(@as(f64, -0.012463), result.benchmark_returns.week.?, 0.0001); + + // A renormalized blend must sit BETWEEN its two legs. The old form did not: + // 18.91% was below AGG-weighted-plus-SPYM-weighted precisely because the + // missing weight dragged it down. + try std.testing.expect(result.benchmark_returns.one_year.? < 0.2192); + try std.testing.expect(result.benchmark_returns.one_year.? > 0.0247); +} + +test "buildComparison with only one benchmark leg reports that leg, not a fraction of it" { + // With no bond data the old code returned `stock * 0.851` - 85% of SPY's + // return, presented as "Benchmark". Not a benchmark of anything. + const stock_tr = TrailingReturns{ .one_year = makePR(0.20, 0.20) }; + const bond_tr = TrailingReturns{}; // no data at all + const positions = [_]PositionReturn{}; + const result = buildComparison(stock_tr, bond_tr, 0.851, 0.102, &positions, null); + + try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.benchmark_returns.one_year.?, 0.0001); + try std.testing.expect(result.benchmark_returns.week == null); +} + test "buildComparison with week returns" { // Week returns now flow through `TrailingReturns.week` rather // than separate parameters - `performance.totalReturns` diff --git a/src/commands/history.zig b/src/commands/history.zig index 3f2a7e2..6090783 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -490,7 +490,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) // snapshot-to-snapshot Liquid deltas (or whichever metric is // focused) - they include contributions, withdrawals, and // weight drift, distinct from the `projections` benchmark - // table which reports price-only weighted returns and so will + // table which reports weighted total returns and so will // disagree on weeks with significant cash movement or // rebalancing. try cli.printFg(out, color, cli.CLR_MUTED, " (snapshot-to-snapshot Δ; includes contributions, withdrawals, weight drift)\n", .{}); diff --git a/src/commands/projections.zig b/src/commands/projections.zig index c2fe542..8d31b67 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -841,15 +841,52 @@ pub fn runBands( // comparing these numbers against the `history` tab's window // table doesn't get tripped up by the (legitimate) // disagreement: this table reports each row as a weighted - // price-only return per period (per-symbol price change × - // current weight, summed) so SPY/AGG/Benchmark/Your - // Portfolio rows are apples-to-apples; history's window - // table reports snapshot-to-snapshot Liquid value deltas - // that include contributions, withdrawals, and weight drift. + // return per period (per-symbol return × current weight, + // summed) so SPY/AGG/Benchmark/Your Portfolio rows are + // apples-to-apples; history's window table reports + // snapshot-to-snapshot Liquid value deltas that include + // contributions, withdrawals, and weight drift. + // + // "total return", not "price-only", and the distinction is not + // cosmetic. The year columns come from `performance.totalReturns`, + // which is dividend-reinvested - see the comment in + // `views/projections.zig` recording why: calling + // `performance.trailingReturns` directly used the adj_close series + // alone, so a stale adjustment basis understated the benchmark, the + // portfolio-weighted return, AND `conservative_return`, which drives + // the Monte Carlo. This title was never updated after that fix and + // spent a while contradicting three of its own five columns. + // + // Week is genuinely price-only: `performance.weekReturn` uses raw + // `close`. Left that way deliberately - dividends over seven days are + // negligible, and reconstructing them would add a dividend lookup to + // the one column nobody annualizes. Called out here so the mismatch + // is documented rather than discovered. try cli.setBold(out, color); - try out.print("Benchmark comparison (price-only weighted return)\n", .{}); + if (resolution) |r| { + try out.print("Benchmark comparison as of {f} (total return, weighted; Week is price-only)\n", .{r.actual}); + } else { + try out.print("Benchmark comparison (total return, weighted; Week is price-only)\n", .{}); + } try cli.reset(out, color); + // Under `--as-of`, say out loud which week the Week column covers. + // + // This exists because of a specific hour lost to it. `--as-of 1W` truncates the + // candle series at the nearest snapshot a week back, so every column - Week + // included - ends THERE, not today. Read beside a `compare` run covering the + // current week, the two disagree by a whole week's market movement and look + // like a bug in one of them. They were both right: SPYM returned -1.38% in the + // week ending 2026-08-21 and +0.49% in the week ending 2026-08-28. + // + // The header above already carried the as-of date, but a table pasted into an + // email loses it, and the reader is then holding two contradictory Week figures + // with nothing on screen to reconcile them. So the date goes on the section + // itself, and the window gets stated explicitly rather than implied. + if (resolution) |r| { + try cli.printFg(out, color, cli.CLR_MUTED, "(every column ends {f}; Week is the 7 days ENDING then, not the current week)\n", .{r.actual}); + } + // Header row try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}\n", .{ "", "1 Year", "3 Year", "5 Year", "10 Year", "Week", diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 70048c6..37d669d 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -1124,7 +1124,9 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines: const stock_pct = pctx.stock_pct; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = " Benchmark Comparison (price-only weighted return)", .style = th.headerStyle() }); + // See `commands/projections.zig` for why this says total return: the year + // columns are dividend-reinvested, only Week is price-only. + try lines.append(arena, .{ .text = " Benchmark Comparison (total return, weighted; Week is price-only)", .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Column headers @@ -1872,7 +1874,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style // Header try lines.append(arena, .{ - .text = " Benchmark Comparison (price-only weighted return)", + .text = " Benchmark Comparison (total return, weighted; Week is price-only)", .style = th.headerStyle(), }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });