normalize benchmark so cash does not artificially drag the return
This commit is contained in:
parent
770ceb8777
commit
efb4d57c23
5 changed files with 155 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue