projections: suppress output of percentage data when accumulation mode exists
This commit is contained in:
parent
de19e3e760
commit
a9b5b8fe19
4 changed files with 189 additions and 44 deletions
12
TODO.md
12
TODO.md
|
|
@ -10,18 +10,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
||||||
- **Configurable return cap per position — priority MEDIUM.**
|
- **Configurable return cap per position — priority MEDIUM.**
|
||||||
Default: none; cap outliers like NVDA. Should route through
|
Default: none; cap outliers like NVDA. Should route through
|
||||||
`projections.srf` cleanly.
|
`projections.srf` cleanly.
|
||||||
- **Accumulation-mode SWR rate column is misleading — priority LOW.**
|
|
||||||
When `retirement_age`/`retirement_at` is configured, the "Safe
|
|
||||||
Withdrawal" table's % column divides the SWR amount by the
|
|
||||||
CURRENT portfolio value, not the post-accumulation portfolio
|
|
||||||
value. The dollar amount is correct (it's the safe spending in
|
|
||||||
retirement, given the projected accumulation), but the % rate
|
|
||||||
comes out absurdly high (e.g., 22% of today's portfolio). The
|
|
||||||
Accumulation phase block already shows the median portfolio at
|
|
||||||
retirement, so the user can compute the real rate themselves —
|
|
||||||
but the SWR table's rate column should ideally divide by the
|
|
||||||
median post-accumulation value, or be suppressed when accumulation
|
|
||||||
is active. Decide which.
|
|
||||||
- **Chart vertical line at retirement boundary — priority LOW.**
|
- **Chart vertical line at retirement boundary — priority LOW.**
|
||||||
The accumulation-phase spec called this "mandatory" but it was
|
The accumulation-phase spec called this "mandatory" but it was
|
||||||
explicitly deferred during implementation. The chart currently
|
explicitly deferred during implementation. The chart currently
|
||||||
|
|
|
||||||
|
|
@ -790,11 +790,20 @@ pub fn runBands(
|
||||||
// Header row
|
// Header row
|
||||||
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
|
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
|
||||||
|
|
||||||
// Withdrawal rows
|
// Withdrawal rows. When an accumulation phase is active the
|
||||||
|
// per-row % rate is suppressed (it would divide today's-dollars
|
||||||
|
// retirement spending by today's portfolio); a footnote explains
|
||||||
|
// and points at the Accumulation phase block.
|
||||||
|
const swr_rate_note = view.swrRateNote(ctx.retirement.accumulation_years);
|
||||||
for (confidence_levels, 0..) |conf, ci| {
|
for (confidence_levels, 0..) |conf, ci| {
|
||||||
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
|
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
|
||||||
try out.print("{s}\n", .{wr_rows.amount.text});
|
try out.print("{s}\n", .{wr_rows.amount.text});
|
||||||
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
|
if (swr_rate_note == null) {
|
||||||
|
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (swr_rate_note) |note| {
|
||||||
|
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Life events summary — both as-of and live modes resolve ages
|
// Life events summary — both as-of and live modes resolve ages
|
||||||
|
|
|
||||||
|
|
@ -1223,6 +1223,28 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe withdrawal table
|
// Safe withdrawal table
|
||||||
|
try appendSwrTable(lines, arena, th, pctx);
|
||||||
|
|
||||||
|
// Life events summary
|
||||||
|
try appendEventSummary(lines, app.today, arena, th, pctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append the "Safe Withdrawal" table (header + per-confidence
|
||||||
|
/// amount/rate rows) to a styled-lines list. Shared by the chart and
|
||||||
|
/// no-chart render paths so they can't drift.
|
||||||
|
///
|
||||||
|
/// During an accumulation phase the per-row % rate is suppressed and
|
||||||
|
/// replaced with a single footnote: dividing today's-dollars
|
||||||
|
/// retirement spending by today's portfolio yields a misleading rate.
|
||||||
|
/// See `view.swrRateNote`.
|
||||||
|
fn appendSwrTable(
|
||||||
|
lines: *std.ArrayListUnmanaged(StyledLine),
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
th: theme.Theme,
|
||||||
|
pctx: view.ProjectionContext,
|
||||||
|
) !void {
|
||||||
|
const horizons = pctx.config.getHorizons();
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
try lines.append(arena, .{
|
try lines.append(arena, .{
|
||||||
.text = " Safe Withdrawal (FIRECalc historical simulation)",
|
.text = " Safe Withdrawal (FIRECalc historical simulation)",
|
||||||
|
|
@ -1236,21 +1258,27 @@ fn buildFooterSection(app: *App, arena: std.mem.Allocator, lines: *std.ArrayList
|
||||||
});
|
});
|
||||||
|
|
||||||
const cached_wr = pctx.data.withdrawals;
|
const cached_wr = pctx.data.withdrawals;
|
||||||
const confidence_levels = config.getConfidenceLevels();
|
const confidence_levels = pctx.config.getConfidenceLevels();
|
||||||
|
const rate_note = view.swrRateNote(pctx.retirement.accumulation_years);
|
||||||
for (confidence_levels, 0..) |conf, ci| {
|
for (confidence_levels, 0..) |conf, ci| {
|
||||||
const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci);
|
const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci);
|
||||||
try lines.append(arena, .{
|
try lines.append(arena, .{
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{rows.amount.text}),
|
.text = try std.fmt.allocPrint(arena, " {s}", .{rows.amount.text}),
|
||||||
.style = th.contentStyle(),
|
.style = th.contentStyle(),
|
||||||
});
|
});
|
||||||
|
if (rate_note == null) {
|
||||||
|
try lines.append(arena, .{
|
||||||
|
.text = try std.fmt.allocPrint(arena, " {s}", .{rows.rate.text}),
|
||||||
|
.style = th.mutedStyle(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rate_note) |note| {
|
||||||
try lines.append(arena, .{
|
try lines.append(arena, .{
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{rows.rate.text}),
|
.text = try std.fmt.allocPrint(arena, " {s}", .{note}),
|
||||||
.style = th.mutedStyle(),
|
.style = th.mutedStyle(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Life events summary
|
|
||||||
try appendEventSummary(lines, app.today, arena, th, pctx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Date, arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {
|
fn appendEventSummary(lines: *std.ArrayListUnmanaged(StyledLine), as_of: zfin.Date, arena: std.mem.Allocator, th: theme.Theme, pctx: view.ProjectionContext) !void {
|
||||||
|
|
@ -2065,31 +2093,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safe withdrawal table
|
// Safe withdrawal table
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try appendSwrTable(&lines, arena, th, ctx);
|
||||||
try lines.append(arena, .{
|
|
||||||
.text = " Safe Withdrawal (FIRECalc historical simulation)",
|
|
||||||
.style = th.headerStyle(),
|
|
||||||
});
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
||||||
|
|
||||||
try lines.append(arena, .{
|
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.withdrawal_col_width)}),
|
|
||||||
.style = th.headerStyle(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const cached_wr = ctx.data.withdrawals;
|
|
||||||
const confidence_levels = config.getConfidenceLevels();
|
|
||||||
for (confidence_levels, 0..) |conf, ci| {
|
|
||||||
const rows = try view.buildWithdrawalRows(arena, conf, horizons, cached_wr, ci);
|
|
||||||
try lines.append(arena, .{
|
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{rows.amount.text}),
|
|
||||||
.style = th.contentStyle(),
|
|
||||||
});
|
|
||||||
try lines.append(arena, .{
|
|
||||||
.text = try std.fmt.allocPrint(arena, " {s}", .{rows.rate.text}),
|
|
||||||
.style = th.mutedStyle(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Life events summary (at the bottom)
|
// Life events summary (at the bottom)
|
||||||
try appendEventSummary(&lines, app.today, arena, th, ctx);
|
try appendEventSummary(&lines, app.today, arena, th, ctx);
|
||||||
|
|
@ -2196,3 +2200,107 @@ test "formatOverlayUnavailable: respects rebound as-of-input key" {
|
||||||
const msg = try formatOverlayUnavailable(&buf, "ctrl+d");
|
const msg = try formatOverlayUnavailable(&buf, "ctrl+d");
|
||||||
try testing.expectEqualStrings("Overlay only available with --as-of (press ctrl+d to set)", msg);
|
try testing.expectEqualStrings("Overlay only available with --as-of (press ctrl+d to set)", msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a `ProjectionContext` for the SWR-table tests. Runs the real
|
||||||
|
/// (network-free) historical simulation via `view.buildProjectionContext`
|
||||||
|
/// so `data.withdrawals` is populated. `retirement_at == null` yields a
|
||||||
|
/// distribution-only context (accumulation_years == 0); a future date
|
||||||
|
/// yields an accumulation phase.
|
||||||
|
fn buildSwrTestCtx(
|
||||||
|
arena: std.mem.Allocator,
|
||||||
|
retirement_at: ?zfin.Date,
|
||||||
|
annual_contribution: f64,
|
||||||
|
as_of: zfin.Date,
|
||||||
|
) !view.ProjectionContext {
|
||||||
|
const benchmark = @import("../analytics/benchmark.zig");
|
||||||
|
const projections = @import("../analytics/projections.zig");
|
||||||
|
var config: projections.UserConfig = .{};
|
||||||
|
config.retirement_at = retirement_at;
|
||||||
|
config.annual_contribution = annual_contribution;
|
||||||
|
const comparison: benchmark.BenchmarkComparison = .{
|
||||||
|
.stock_returns = .{},
|
||||||
|
.bond_returns = .{},
|
||||||
|
.benchmark_returns = .{},
|
||||||
|
.portfolio_returns = .{},
|
||||||
|
.conservative_return = 0.07,
|
||||||
|
.stock_pct = 0.75,
|
||||||
|
.bond_pct = 0.25,
|
||||||
|
};
|
||||||
|
return view.buildProjectionContext(arena, config, comparison, 0.75, 0.25, 1_000_000, &.{}, as_of);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tallies of the line kinds `appendSwrTable` emits, used by the SWR
|
||||||
|
/// table tests. Each emitted line is exactly one kind:
|
||||||
|
/// - `header`: the "Safe Withdrawal (...)" title line.
|
||||||
|
/// - `amount`: a "NN% safe withdrawal $X $Y ..." row.
|
||||||
|
/// - `rate`: a bare numeric percentage row (rendered only when
|
||||||
|
/// there is no accumulation phase).
|
||||||
|
/// - `footnote`: the "% rate omitted during accumulation ..." note.
|
||||||
|
const SwrLineCounts = struct {
|
||||||
|
headers: usize = 0,
|
||||||
|
amounts: usize = 0,
|
||||||
|
rates: usize = 0,
|
||||||
|
footnotes: usize = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn classifySwrLines(lines: []const StyledLine) SwrLineCounts {
|
||||||
|
var c: SwrLineCounts = .{};
|
||||||
|
for (lines) |line| {
|
||||||
|
if (std.mem.indexOf(u8, line.text, "Safe Withdrawal") != null) {
|
||||||
|
// Title line ("Safe Withdrawal (FIRECalc ...)").
|
||||||
|
c.headers += 1;
|
||||||
|
} else if (std.mem.indexOf(u8, line.text, "omitted") != null) {
|
||||||
|
// Suppression footnote.
|
||||||
|
c.footnotes += 1;
|
||||||
|
} else if (std.mem.indexOf(u8, line.text, "safe withdrawal") != null) {
|
||||||
|
// Amount row: the "NN% safe withdrawal" label carries a
|
||||||
|
// '%', so it must be matched before the bare-'%' rate case.
|
||||||
|
c.amounts += 1;
|
||||||
|
} else if (std.mem.indexOfScalar(u8, line.text, '%') != null) {
|
||||||
|
// Numeric percentage row.
|
||||||
|
c.rates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "appendSwrTable: distribution-only renders a rate row per confidence, no footnote" {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
const ctx = try buildSwrTestCtx(a, null, 0, zfin.Date.fromYmd(2026, 5, 12));
|
||||||
|
try testing.expectEqual(@as(u16, 0), ctx.retirement.accumulation_years);
|
||||||
|
|
||||||
|
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||||
|
try appendSwrTable(&lines, a, theme.default_theme, ctx);
|
||||||
|
|
||||||
|
const n_conf = ctx.config.getConfidenceLevels().len;
|
||||||
|
const counts = classifySwrLines(lines.items);
|
||||||
|
try testing.expectEqual(@as(usize, 1), counts.headers);
|
||||||
|
try testing.expectEqual(@as(usize, 0), counts.footnotes);
|
||||||
|
// One amount row and one rate row per confidence level.
|
||||||
|
try testing.expectEqual(n_conf, counts.amounts);
|
||||||
|
try testing.expectEqual(n_conf, counts.rates);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "appendSwrTable: accumulation suppresses rate rows and adds one footnote" {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const a = arena.allocator();
|
||||||
|
|
||||||
|
// Retire 10 years out with contributions -> non-zero accumulation.
|
||||||
|
const ctx = try buildSwrTestCtx(a, zfin.Date.fromYmd(2036, 7, 1), 50_000, zfin.Date.fromYmd(2026, 7, 1));
|
||||||
|
try testing.expect(ctx.retirement.accumulation_years > 0);
|
||||||
|
|
||||||
|
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
|
||||||
|
try appendSwrTable(&lines, a, theme.default_theme, ctx);
|
||||||
|
|
||||||
|
const n_conf = ctx.config.getConfidenceLevels().len;
|
||||||
|
const counts = classifySwrLines(lines.items);
|
||||||
|
// Amounts still render; the per-row rate is gone, replaced by a
|
||||||
|
// single footnote.
|
||||||
|
try testing.expectEqual(n_conf, counts.amounts);
|
||||||
|
try testing.expectEqual(@as(usize, 0), counts.rates);
|
||||||
|
try testing.expectEqual(@as(usize, 1), counts.footnotes);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1067,6 +1067,34 @@ pub fn buildWithdrawalRows(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Footnote shown beneath the Safe Withdrawal table when the
|
||||||
|
/// projection includes an accumulation phase. Returns `null` (render
|
||||||
|
/// the rate rows normally) when `accumulation_years == 0`.
|
||||||
|
///
|
||||||
|
/// Why the rate is suppressed during accumulation: `WithdrawalResult.
|
||||||
|
/// withdrawal_rate` is `annual_amount / initial_value`, and
|
||||||
|
/// `initial_value` is the *current* portfolio. With an accumulation
|
||||||
|
/// phase the `annual_amount` is the safe spending sized for the
|
||||||
|
/// projected *post-accumulation* portfolio, so dividing it by today's
|
||||||
|
/// (much smaller) portfolio yields an absurdly high "rate" (e.g. 22%
|
||||||
|
/// of today's value). That is the bug this note resolves.
|
||||||
|
///
|
||||||
|
/// We suppress rather than "divide by the median post-accumulation
|
||||||
|
/// value" because the units don't line up: `annual_amount` is in
|
||||||
|
/// today's dollars (spending is CPI-adjusted from today; see
|
||||||
|
/// `searchSafeWithdrawal`), while the bands' `median_at_retirement`
|
||||||
|
/// is nominal future dollars. A naive `annual_amount /
|
||||||
|
/// median_at_retirement` would be off by the accumulation-period
|
||||||
|
/// inflation factor, misleading in the opposite direction. A
|
||||||
|
/// unit-correct rate needs the boundary inflation factor threaded
|
||||||
|
/// through the simulation, which isn't worth it for a cosmetic
|
||||||
|
/// column. The "Accumulation phase" block already reports the
|
||||||
|
/// projected portfolio at retirement, so we point the reader there.
|
||||||
|
pub fn swrRateNote(accumulation_years: u16) ?[]const u8 {
|
||||||
|
if (accumulation_years == 0) return null;
|
||||||
|
return "% rate omitted during accumulation (would divide retirement spending by today's portfolio). See Accumulation phase above.";
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a percentile row (p10/p50/p90) across horizons.
|
/// Build a percentile row (p10/p50/p90) across horizons.
|
||||||
pub fn buildPercentileRow(
|
pub fn buildPercentileRow(
|
||||||
arena: std.mem.Allocator,
|
arena: std.mem.Allocator,
|
||||||
|
|
@ -1634,6 +1662,18 @@ test "buildWithdrawalRows produces amount and rate" {
|
||||||
try std.testing.expect(rows.rate.style == .muted);
|
try std.testing.expect(rows.rate.style == .muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "swrRateNote: null without accumulation, present with accumulation" {
|
||||||
|
// Distribution-only (already retired): the rate is a correct
|
||||||
|
// withdrawal rate against the current portfolio, so render it.
|
||||||
|
try std.testing.expect(swrRateNote(0) == null);
|
||||||
|
|
||||||
|
// With an accumulation phase the rate would divide today's-dollars
|
||||||
|
// retirement spending by today's portfolio; suppress and explain.
|
||||||
|
const note = swrRateNote(12) orelse return error.TestUnexpectedNull;
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, note, "omitted") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, note, "Accumulation phase") != null);
|
||||||
|
}
|
||||||
|
|
||||||
test "buildPercentileRow extracts correct percentile" {
|
test "buildPercentileRow extracts correct percentile" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue