initial commit projections TUI tab

This commit is contained in:
Emil Lerch 2026-04-28 10:30:13 -07:00
parent 6991e31bdd
commit c0b980146c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 991 additions and 174 deletions

13
TODO.md
View file

@ -1,5 +1,18 @@
# Future Work
## Projections: future enhancements
- Configurable return cap per position (default: none; cap outliers like NVDA)
- Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
- Life events in `projections.srf`: Social Security income, college costs, pensions
- "Not Retired Yet" mode: contributions until retirement date
- Multiple spending models (Bernicke, percentage-of-remaining)
- Configurable benchmark symbols (currently hardcoded SPY + AGG)
- Age-based horizons from birthdates in projections.srf
- Weekly tracking history (portfolio value + retirement date time series)
- Kitty graphics percentile band chart (currently braille only)
- Unclassified position handling in allocation split (warn user)
## Analysis account/asset-class total mismatch
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly

View file

@ -285,6 +285,72 @@ pub fn formatReturn(buf: []u8, value: f64) []const u8 {
return std.fmt.bufPrint(buf, "{d:.2}%", .{value * 100.0}) catch "??%";
}
/// Compute 1-week return from candle data: (latest_close / close_7_days_ago) - 1.
/// Candles must be sorted by date ascending.
pub fn weekReturn(candles: []const Candle) ?f64 {
if (candles.len < 2) return null;
const latest = candles[candles.len - 1];
const target_date = latest.date.addDays(-7);
// Linear scan backward (at most ~10 steps for daily candles)
var i: usize = candles.len - 2;
while (true) {
if (candles[i].date.days <= target_date.days) {
if (candles[i].close == 0) return null;
return (latest.close / candles[i].close) - 1.0;
}
if (i == 0) break;
i -= 1;
}
return null;
}
test "weekReturn less than 2 candles" {
const c = [_]Candle{.{ .date = Date.fromYmd(2024, 1, 10), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 }};
try std.testing.expect(weekReturn(&c) == null);
try std.testing.expect(weekReturn(&[_]Candle{}) == null);
}
test "weekReturn simple positive" {
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 1), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 },
.{ .date = Date.fromYmd(2024, 1, 8), .open = 110, .high = 110, .low = 110, .close = 110, .adj_close = 110, .volume = 0 },
};
const r = weekReturn(&candles).?;
try std.testing.expectApproxEqAbs(@as(f64, 0.10), r, 0.001);
}
test "weekReturn negative" {
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 1), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 },
.{ .date = Date.fromYmd(2024, 1, 8), .open = 95, .high = 95, .low = 95, .close = 95, .adj_close = 95, .volume = 0 },
};
const r = weekReturn(&candles).?;
try std.testing.expectApproxEqAbs(@as(f64, -0.05), r, 0.001);
}
test "weekReturn snaps to nearest trading day" {
// Latest is Wednesday Jan 10. 7 days back is Wednesday Jan 3.
// But candles only have Mon Jan 1 and Fri Jan 5. Should snap to Fri Jan 5.
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 1), .open = 90, .high = 90, .low = 90, .close = 90, .adj_close = 90, .volume = 0 },
.{ .date = Date.fromYmd(2024, 1, 5), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 },
.{ .date = Date.fromYmd(2024, 1, 10), .open = 105, .high = 105, .low = 105, .close = 105, .adj_close = 105, .volume = 0 },
};
// Target: Jan 10 - 7 = Jan 3. Nearest at-or-before is Jan 1 (not Jan 5 which is after Jan 3)
const r = weekReturn(&candles).?;
// 105/90 - 1 = 0.1667
try std.testing.expectApproxEqAbs(@as(f64, 0.1667), r, 0.001);
}
test "weekReturn zero close returns null" {
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 1), .open = 0, .high = 0, .low = 0, .close = 0, .adj_close = 0, .volume = 0 },
.{ .date = Date.fromYmd(2024, 1, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 0 },
};
try std.testing.expect(weekReturn(&candles) == null);
}
test "total return simple" {
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 },

View file

@ -10,6 +10,7 @@
/// - Success rate for a given spending level
/// - Percentile bands of portfolio value at each year (for charting)
const std = @import("std");
const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig");
const srf = @import("srf");
@ -297,16 +298,35 @@ fn successRate(
/// the portfolio survives `horizon` years in at least `confidence` fraction
/// of all historical cycles.
///
/// Uses binary search with $1 precision.
/// Uses binary search with $1 precision, seeded with a 4%-rule estimate
/// to narrow the search band (~10 iterations instead of ~23).
pub fn findSafeWithdrawal(
horizon: u16,
initial_value: f64,
stock_pct: f64,
confidence: f64,
) WithdrawalResult {
// Binary search bounds: $0 to the full portfolio value
var lo: f64 = 0;
var hi: f64 = initial_value;
// Seed from the 4% rule, adjusted for horizon and confidence.
// Base ~4% for 30yr/95%. Shorter horizons allow more; longer less.
// Higher confidence requires less.
const base_rate = 0.04;
const horizon_adj = 30.0 / @as(f64, @floatFromInt(horizon)); // >1 for short, <1 for long
const conf_adj = (1.0 - confidence) / 0.05; // 1.0 at 95%, 0.2 at 99%, 2.0 at 90%
const estimate = initial_value * base_rate * @sqrt(horizon_adj) * @sqrt(conf_adj);
// Search band: ±50% of estimate, clamped to [0, initial_value]
var lo: f64 = @max(estimate * 0.5, 0);
var hi: f64 = @min(estimate * 1.5, initial_value);
// Verify bounds bracket the answer; widen if not
if (successRate(horizon, initial_value, lo, stock_pct) < confidence) {
log.debug("findSafeWithdrawal: estimate too high, widening lo to 0 (horizon={d}, conf={d:.2})", .{ horizon, confidence });
lo = 0;
}
if (successRate(horizon, initial_value, hi, stock_pct) >= confidence) {
log.debug("findSafeWithdrawal: estimate too low, widening hi to portfolio value (horizon={d}, conf={d:.2})", .{ horizon, confidence });
hi = initial_value;
}
// Binary search to $1 precision
while (hi - lo > 1.0) {

View file

@ -393,7 +393,7 @@ fn renderBrailleChart(
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED);
try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false);
}
fn renderTable(

View file

@ -49,83 +49,24 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
};
defer pf_data.deinit(allocator);
// Load projections.srf config (sibling to portfolio file)
// Build projection context (loads config, metadata, computes everything)
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const proj_path = std.fmt.allocPrint(allocator, "{s}projections.srf", .{file_path[0..dir_end]}) catch null;
defer if (proj_path) |p| allocator.free(p);
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const proj_data = if (proj_path) |p| std.fs.cwd().readFileAlloc(allocator, p, 64 * 1024) catch null else null;
defer if (proj_data) |d| allocator.free(d);
const user_config = projections.parseProjectionsConfig(proj_data);
// Derive stock/bond allocation from portfolio using classification metadata.
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch null;
defer if (meta_path) |p| allocator.free(p);
const meta_data = if (meta_path) |p| std.fs.cwd().readFileAlloc(allocator, p, 1024 * 1024) catch null else null;
defer if (meta_data) |d| allocator.free(d);
var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d|
zfin.classification.parseClassificationFile(allocator, d) catch null
else
null;
defer if (cm_opt) |*cm| cm.deinit();
const allocs = pf_data.summary.allocations;
const total_value = pf_data.summary.total_value;
// Derive stock/bond split from classification metadata
const split = benchmark.deriveAllocationSplit(
allocs,
if (cm_opt) |cm| cm.entries else &.{},
total_value,
const ctx = try view.loadProjectionContext(
va,
file_path[0..dir_end],
pf_data.summary.allocations,
pf_data.summary.total_value,
portfolio.totalCash(),
portfolio.totalCdFaceValue(),
svc,
);
const stock_pct = split.stock_pct;
const bond_pct = split.bond_pct;
const sim_stock_pct = if (user_config.target_stock_pct) |t| t / 100.0 else stock_pct;
// Fetch benchmark candles (ensure they're cached)
_ = svc.getCandles(stock_benchmark) catch null;
_ = svc.getCandles(bond_benchmark) catch null;
const spy_candles = svc.getCachedCandles(stock_benchmark) orelse &.{};
defer if (spy_candles.len > 0) allocator.free(spy_candles);
const agg_candles = svc.getCachedCandles(bond_benchmark) orelse &.{};
defer if (agg_candles.len > 0) allocator.free(agg_candles);
// Compute benchmark trailing returns + week returns
const spy_trailing = performance.trailingReturns(spy_candles);
const agg_trailing = performance.trailingReturns(agg_candles);
const spy_week = weekReturn(spy_candles);
const agg_week = weekReturn(agg_candles);
// Build per-position trailing returns for portfolio weighted average
var pos_returns = std.ArrayList(benchmark.PositionReturn).empty;
defer pos_returns.deinit(allocator);
for (allocs) |a| {
const candles = pf_data.candle_map.get(a.symbol) orelse continue;
if (candles.len == 0) continue;
try pos_returns.append(allocator, .{
.symbol = a.symbol,
.weight = a.weight,
.returns = performance.trailingReturns(candles),
});
}
// Build benchmark comparison
const comparison = benchmark.buildComparison(
spy_trailing,
agg_trailing,
stock_pct,
bond_pct,
pos_returns.items,
spy_week,
agg_week,
);
// Render via view model
const horizons = ctx.config.getHorizons();
const confidence_levels = ctx.config.getConfidenceLevels();
const comparison = ctx.comparison;
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print("Projections ({s})\n", .{file_path});
@ -143,7 +84,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, stock_pct * 100),
view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, ctx.stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
@ -152,7 +93,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, bond_pct * 100),
view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,
@ -189,7 +130,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
// Target allocation note
{
var note_buf: [128]u8 = undefined;
if (view.fmtAllocationNote(&note_buf, user_config.target_stock_pct, stock_pct)) |note| {
if (view.fmtAllocationNote(&note_buf, ctx.config.target_stock_pct, ctx.stock_pct)) |note| {
try out.print("\n", .{});
try cli.setStyleIntent(out, color, note.style);
try out.print("{s}\n", .{note.text});
@ -197,49 +138,80 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
}
}
// Braille chart: median portfolio value
if (horizons.len > 0) {
const last_idx = horizons.len - 1;
if (ctx.data.bands[last_idx]) |b| {
if (b.len >= 2) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print("Median Portfolio Value ({d}-Year, 99% withdrawal)\n\n", .{horizons[last_idx]});
try cli.reset(out, color);
// Synthesize candles from median values
const candles = try va.alloc(zfin.Candle, b.len);
for (b, 0..) |bp, i| {
const v: f32 = @floatCast(bp.p50);
candles[i] = .{
.date = zfin.Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 365)),
.open = v,
.high = v,
.low = v,
.close = v,
.adj_close = v,
.volume = 0,
};
}
var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (br) |*chart| {
try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
// Year axis instead of date axis
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Now", .{});
const end_label_buf = try std.fmt.allocPrint(va, "{d}yr", .{horizons[last_idx]});
const pad = if (chart.n_cols > 3 + end_label_buf.len) chart.n_cols - 3 - end_label_buf.len else 0;
for (0..pad) |_| try out.print(" ", .{});
try out.print("{s}\n", .{end_label_buf});
try cli.reset(out, color);
}
}
}
}
// Terminal portfolio value
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print("Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{});
try cli.reset(out, color);
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)});
const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
for (p_labels, p_styles, 0..) |plabel, pstyle, pi| {
const row = try view.buildPercentileRow(va, plabel, pi, ctx.data.bands, pstyle);
try cli.setStyleIntent(out, color, row.style);
try out.print("{s}\n", .{row.text});
try cli.reset(out, color);
}
// Safe withdrawal table
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print("Safe Withdrawal (FIRECalc historical simulation)\n", .{});
try cli.reset(out, color);
const horizons = user_config.getHorizons();
const confidence_levels = user_config.getConfidenceLevels();
// Header row
try out.print("{s: <25}", .{""});
for (horizons) |h| {
var hbuf: [16]u8 = undefined;
try out.print("{s: >12}", .{view.fmtHorizonLabel(&hbuf, h)});
}
try out.print("\n", .{});
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
// One row per confidence level
for (confidence_levels) |conf| {
var lbuf: [25]u8 = undefined;
try out.print("{s: <25}", .{view.fmtConfidenceLabel(&lbuf, conf)});
for (horizons) |h| {
const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf);
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result);
try out.print("{s: >12}", .{cell.amount_text});
}
try out.print("\n", .{});
// Rate row
// Withdrawal rows
for (confidence_levels, 0..) |conf, ci| {
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
try out.print("{s}\n", .{wr_rows.amount.text});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s: <25}", .{""});
for (horizons) |h| {
const result = projections.findSafeWithdrawal(h, total_value, sim_stock_pct, conf);
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = view.fmtWithdrawalCell(&abuf, &rbuf, result);
try out.print("{s: >12}", .{cell.rate_text});
}
try out.print("{s}\n", .{wr_rows.rate.text});
try cli.reset(out, color);
try out.print("\n", .{});
}
try out.print("\n", .{});
@ -266,18 +238,3 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi
}
try cli.reset(out, color);
}
fn candleDate(c: zfin.Candle) zfin.Date {
return c.date;
}
/// Compute 1-week return from candle data.
fn weekReturn(candles: []const zfin.Candle) ?f64 {
if (candles.len < 2) return null;
const latest = candles[candles.len - 1];
const target_date = latest.date.addDays(-7);
const idx = valuation.indexAtOrBefore(zfin.Candle, candles, target_date, candleDate) orelse return null;
const start = candles[idx];
if (start.close == 0) return null;
return (latest.close / start.close) - 1.0;
}

View file

@ -100,7 +100,7 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (chart) |*ch| {
defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED);
try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false);
}
}

View file

@ -765,9 +765,11 @@ pub fn computeBrailleChart(
// Price labels
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "${d:.0}", .{max_price}) catch "";
var max_tmp: [24]u8 = undefined;
var min_tmp: [24]u8 = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "{s}", .{fmtMoneyAbs(&max_tmp, max_price)}) catch "";
result.max_label_len = max_str.len;
const min_str = std.fmt.bufPrint(&result.min_label, "${d:.0}", .{min_price}) catch "";
const min_str = std.fmt.bufPrint(&result.min_label, "{s}", .{fmtMoneyAbs(&min_tmp, min_price)}) catch "";
result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width);
@ -826,12 +828,14 @@ pub fn computeBrailleChart(
}
/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output.
/// Used by the CLI for terminal output. Set `skip_date_axis` to
/// provide a custom x-axis (e.g. year labels instead of dates).
pub fn writeBrailleAnsi(
out: *std.Io.Writer,
chart: *const BrailleChart,
use_color: bool,
muted_color: [3]u8,
skip_date_axis: bool,
) !void {
var last_r: u8 = 0;
var last_g: u8 = 0;
@ -879,23 +883,24 @@ pub fn writeBrailleAnsi(
}
// Date axis below chart
var start_buf: [7]u8 = undefined;
var end_buf: [7]u8 = undefined;
const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf);
const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf);
if (!skip_date_axis) {
var start_buf: [7]u8 = undefined;
var end_buf: [7]u8 = undefined;
const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf);
const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf);
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
// Fill gap between start and end labels
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
// ANSI color helpers (for CLI)

View file

@ -14,6 +14,7 @@ const options_tab = @import("tui/options_tab.zig");
const earnings_tab = @import("tui/earnings_tab.zig");
const analysis_tab = @import("tui/analysis_tab.zig");
const history_tab = @import("tui/history_tab.zig");
const projections_tab = @import("tui/projections_tab.zig");
const history_io = @import("history.zig");
const timeline = @import("analytics/timeline.zig");
@ -79,6 +80,7 @@ pub const Tab = enum {
earnings,
analysis,
history,
projections,
fn label(self: Tab) []const u8 {
return switch (self) {
@ -89,11 +91,12 @@ pub const Tab = enum {
.earnings => " 5:Earnings ",
.analysis => " 6:Analysis ",
.history => " 7:History ",
.projections => " 8:Projections ",
};
}
};
const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis, .history };
const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis, .history, .projections };
pub const InputMode = enum {
normal,
@ -378,6 +381,13 @@ pub const App = struct {
history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it)
history_timeline: ?history_io.LoadedTimeline = null,
// Projections tab state
projections_loaded: bool = false,
projections_disabled: bool = false,
projections_config: @import("analytics/projections.zig").UserConfig = .{},
projections_ctx: ?@import("views/projections.zig").ProjectionContext = null,
projections_horizon_idx: usize = 0,
// Default to `.liquid` that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view.
@ -925,7 +935,7 @@ pub const App = struct {
ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw();
},
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7 => {
.tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => {
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
if (idx < tabs.len) {
const target = tabs[idx];
@ -1364,7 +1374,7 @@ pub const App = struct {
.options => {
self.svc.invalidate(self.symbol, .options);
},
.portfolio, .analysis, .history => {},
.portfolio, .analysis, .history, .projections => {},
}
}
switch (self.active_tab) {
@ -1400,6 +1410,10 @@ pub const App = struct {
self.history_loaded = false;
history_tab.freeLoaded(self);
},
.projections => {
self.projections_loaded = false;
projections_tab.freeLoaded(self);
},
}
self.loadTabData();
@ -1444,6 +1458,10 @@ pub const App = struct {
if (self.history_disabled) return;
if (!self.history_loaded) history_tab.loadData(self);
},
.projections => {
if (self.projections_disabled) return;
if (!self.projections_loaded) projections_tab.loadData(self);
},
}
}
@ -1548,6 +1566,7 @@ pub const App = struct {
if (self.classification_map) |*cm| cm.deinit();
if (self.account_map) |*am| am.deinit();
history_tab.freeLoaded(self);
projections_tab.freeLoaded(self);
self.chart.freeCache(self.allocator); // Free cached indicators
}
@ -1626,7 +1645,8 @@ pub const App = struct {
fn isTabDisabled(self: *App, t: Tab) bool {
return (t == .earnings and self.earnings_disabled) or
(t == .analysis and self.analysis_disabled) or
(t == .history and self.history_disabled);
(t == .history and self.history_disabled) or
(t == .projections and self.projections_disabled);
}
fn isSymbolSelected(self: *App) bool {
@ -1657,6 +1677,7 @@ pub const App = struct {
.earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)),
.analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)),
.history => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHistoryStyledLines(ctx.arena)),
.projections => try self.drawStyledContent(ctx.arena, buf, width, height, try projections_tab.buildStyledLines(self, ctx.arena)),
}
}
@ -2038,6 +2059,7 @@ comptime {
_ = earnings_tab;
_ = analysis_tab;
_ = history_tab;
_ = projections_tab;
}
/// Entry point for the interactive TUI.

View file

@ -14,6 +14,7 @@ pub const Action = enum {
tab_5,
tab_6,
tab_7,
tab_8,
scroll_down,
scroll_up,
scroll_top,
@ -98,6 +99,7 @@ const default_bindings = [_]Binding{
.{ .action = .tab_5, .key = .{ .codepoint = '5' } },
.{ .action = .tab_6, .key = .{ .codepoint = '6' } },
.{ .action = .tab_7, .key = .{ .codepoint = '7' } },
.{ .action = .tab_8, .key = .{ .codepoint = '8' } },
.{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } },
.{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } },
.{ .action = .scroll_top, .key = .{ .codepoint = 'g' } },

365
src/tui/projections_tab.zig Normal file
View file

@ -0,0 +1,365 @@
//! TUI projections tab retirement projections and benchmark comparison.
//!
//! Layout (top-to-bottom):
//! 1. Benchmark comparison table (SPY/AGG/Benchmark/Your Portfolio)
//! 2. Conservative estimate + target allocation note
//! 3. Braille chart of portfolio value percentile bands (median line)
//! 4. Terminal portfolio value table (p10/p50/p90)
//! 5. Safe withdrawal table at multiple confidence levels
//!
//! Consumes `src/analytics/projections.zig` (simulation engine),
//! `src/analytics/benchmark.zig` (weighted returns), and
//! `src/views/projections.zig` (view model).
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projections = @import("../analytics/projections.zig");
const benchmark = @import("../analytics/benchmark.zig");
const performance = @import("../analytics/performance.zig");
const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(app: *App) void {
app.projections_loaded = true;
freeLoaded(app);
const portfolio_path = app.portfolio_path orelse {
app.setStatus("Projections tab requires a loaded portfolio");
return;
};
const summary = app.portfolio_summary orelse {
app.setStatus("No portfolio summary — visit Portfolio tab first");
return;
};
const portfolio = app.portfolio orelse return;
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const ctx = view.loadProjectionContext(
app.allocator,
portfolio_path[0..dir_end],
summary.allocations,
summary.total_value,
portfolio.totalCash(),
portfolio.totalCdFaceValue(),
app.svc,
) catch {
app.setStatus("Failed to compute projections");
return;
};
app.projections_ctx = ctx;
}
pub fn freeLoaded(app: *App) void {
if (app.projections_ctx) |ctx| {
app.allocator.free(ctx.data.withdrawals);
for (ctx.data.bands) |b| {
if (b) |slice| app.allocator.free(slice);
}
app.allocator.free(ctx.data.bands);
}
app.projections_ctx = null;
}
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayListUnmanaged(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const ctx = app.projections_ctx orelse {
try lines.append(arena, .{ .text = " No projection data. Ensure portfolio is loaded.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
const comparison = ctx.comparison;
const config = ctx.config;
const stock_pct = ctx.stock_pct;
// Header
try lines.append(arena, .{
.text = " Benchmark Comparison",
.style = th.headerStyle(),
});
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Column headers (accent color to match other tabs)
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{
"", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
}),
.style = th.headerStyle(),
});
// Return rows
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, "SPY", stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
);
try appendReturnRow(&lines, arena, th, spy_row);
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, "AGG", ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,
);
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);
try appendReturnRow(&lines, arena, th, bench_row);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
var port_bufs: [5][16]u8 = undefined;
const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);
try appendReturnRow(&lines, arena, th, port_row);
// Conservative estimate
{
var buf: [16]u8 = undefined;
const cell = view.fmtReturnCell(&buf, comparison.conservative_return);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}", .{ "Conservative estimate", cell.text }),
.style = th.mutedStyle(),
});
}
// Target allocation note
{
var note_buf: [128]u8 = undefined;
if (view.fmtAllocationNote(&note_buf, config.target_stock_pct, stock_pct)) |note| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{note.text}),
.style = th.styleFor(note.style),
});
}
}
// Braille chart: median portfolio value over the longest horizon
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const horizons = config.getHorizons();
if (horizons.len > 0) {
const last_idx = horizons.len - 1;
if (ctx.data.bands[last_idx]) |bands| {
if (bands.len >= 2) {
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " Median Portfolio Value ({d}-Year, 99% withdrawal)", .{horizons[last_idx]}),
.style = th.headerStyle(),
});
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Synthesize candles from median values
const candles = try arena.alloc(zfin.Candle, bands.len);
for (bands, 0..) |bp, i| {
const v: f32 = @floatCast(bp.p50);
candles[i] = .{
.date = zfin.Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 365)),
.open = v,
.high = v,
.low = v,
.close = v,
.adj_close = v,
.volume = 0,
};
}
// Compute braille chart with wider dimensions
const chart_width: usize = 80;
const chart_height: usize = 12;
var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
if (br) |*chart| {
const bg = th.bg;
const muted_fg = theme.Theme.vcolor(th.text_muted);
const bg_v = theme.Theme.vcolor(bg);
for (0..chart.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, chart.n_cols + 20);
const styles = try arena.alloc(vaxis.Style, chart.n_cols + 20);
var gpos: usize = 0;
// 2 leading spaces
graphemes[gpos] = " ";
styles[gpos] = .{ .fg = muted_fg, .bg = bg_v };
gpos += 1;
graphemes[gpos] = " ";
styles[gpos] = styles[0];
gpos += 1;
// Chart columns
for (0..chart.n_cols) |col| {
const pat = chart.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pat);
if (pat != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(chart.col_colors[col]), .bg = bg_v };
} else {
styles[gpos] = .{ .fg = bg_v, .bg = bg_v };
}
gpos += 1;
}
// Right-side price labels
if (row == 0 or row == chart.chart_height - 1) {
const lbl = if (row == 0) chart.maxLabel() else chart.minLabel();
const lbl_full = try std.fmt.allocPrint(arena, " {s}", .{lbl});
for (lbl_full) |ch| {
if (gpos < graphemes.len) {
graphemes[gpos] = tui.glyph(ch);
styles[gpos] = .{ .fg = muted_fg, .bg = bg_v };
gpos += 1;
}
}
}
try lines.append(arena, .{
.text = "",
.style = .{ .fg = theme.Theme.vcolor(th.text), .bg = bg_v },
.graphemes = graphemes[0..gpos],
.cell_styles = styles[0..gpos],
});
}
// Year axis: "Now" on left, "{horizon}yr" on right
{
const axis_graphemes = try arena.alloc([]const u8, chart.n_cols + 20);
const axis_styles = try arena.alloc(vaxis.Style, chart.n_cols + 20);
const muted_style = vaxis.Style{ .fg = muted_fg, .bg = bg_v };
var apos: usize = 0;
// " Now"
for (" Now") |ch| {
axis_graphemes[apos] = tui.glyph(ch);
axis_styles[apos] = muted_style;
apos += 1;
}
// Padding to right-align the end label
const end_label = try std.fmt.allocPrint(arena, "{d}yr", .{horizons[last_idx]});
const pad = if (chart.n_cols + 2 > 3 + end_label.len) chart.n_cols + 2 - 3 - end_label.len else 0;
for (0..pad) |_| {
axis_graphemes[apos] = " ";
axis_styles[apos] = muted_style;
apos += 1;
}
for (end_label) |ch| {
if (apos < axis_graphemes.len) {
axis_graphemes[apos] = tui.glyph(ch);
axis_styles[apos] = muted_style;
apos += 1;
}
}
try lines.append(arena, .{
.text = "",
.style = muted_style,
.graphemes = axis_graphemes[0..apos],
.cell_styles = axis_styles[0..apos],
});
}
}
}
}
}
// Portfolio value at end of horizon (nominal, using 99% withdrawal)
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{
.text = " Terminal Portfolio Value (nominal, at 99% withdrawal rate)",
.style = th.headerStyle(),
});
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Column header
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{try view.buildHeaderRow(arena, horizons, view.terminal_col_width)}),
.style = th.headerStyle(),
});
// Percentile rows
{
const all_bands = ctx.data.bands;
const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
for (p_labels, p_styles, 0..) |plabel, pstyle, pi| {
const row = try view.buildPercentileRow(arena, plabel, pi, all_bands, pstyle);
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s}", .{row.text}),
.style = th.styleFor(row.style),
});
}
}
// Safe withdrawal table
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
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(),
});
}
return lines.toOwnedSlice(arena);
}
// Helpers
fn appendReturnRow(
lines: *std.ArrayListUnmanaged(StyledLine),
arena: std.mem.Allocator,
th: theme.Theme,
row: view.ReturnRow,
) !void {
// SPY/AGG (not bold) in muted; Benchmark/Portfolio (bold) in content style.
const style = if (row.bold) th.contentStyle() else th.mutedStyle();
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s: <30}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}", .{
row.label,
row.one_year.text,
row.three_year.text,
row.five_year.text,
row.ten_year.text,
row.week.text,
}),
.style = style,
});
}

View file

@ -7,6 +7,8 @@ const fmt = @import("../format.zig");
const performance = @import("../analytics/performance.zig");
const benchmark = @import("../analytics/benchmark.zig");
const projections = @import("../analytics/projections.zig");
const valuation = @import("../analytics/valuation.zig");
const zfin = @import("../root.zig");
pub const StyleIntent = fmt.StyleIntent;
@ -20,6 +22,7 @@ pub const col_10y = 10;
pub const col_week = 9;
pub const withdrawal_label_width = 25;
pub const withdrawal_col_width = 12;
pub const terminal_col_width = 18;
// Return row formatting
@ -52,13 +55,7 @@ pub const ReturnRow = struct {
};
/// Build a return row from a ReturnsByPeriod and a label.
/// Caller owns the buffers (5 buffers of at least 16 bytes each).
pub fn buildReturnRow(
label: []const u8,
returns: benchmark.ReturnsByPeriod,
bufs: *[5][16]u8,
bold: bool,
) ReturnRow {
pub fn buildReturnRow(label: []const u8, returns: benchmark.ReturnsByPeriod, bufs: *[5][16]u8, bold: bool) ReturnRow {
return .{
.label = label,
.one_year = fmtReturnCell(&bufs[0], returns.one_year),
@ -80,6 +77,7 @@ pub const WithdrawalCell = struct {
/// Format a safe withdrawal result into display strings.
/// Caller owns both buffers (at least 24 bytes each).
/// Strips trailing ".00" from whole-dollar amounts for clean display.
pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell {
const money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount);
// Strip trailing ".00" for clean display
@ -87,13 +85,8 @@ pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.W
money_str[0 .. money_str.len - 3]
else
money_str;
const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%";
return .{
.amount_text = clean_amount,
.rate_text = rate_str,
};
return .{ .amount_text = clean_amount, .rate_text = rate_str };
}
/// Format a confidence level label (e.g. "99% safe withdrawal").
@ -125,14 +118,7 @@ pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f
const target = target_stock_pct orelse return null;
const current = current_stock_pct * 100;
const drift = @abs(current - target);
const style: StyleIntent = if (drift < 2.0)
.muted
else if (drift < 5.0)
.warning
else
.negative;
const style: StyleIntent = if (drift < 2.0) .muted else if (drift < 5.0) .warning else .negative;
const text = if (drift < 2.0)
std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{
target, 100.0 - target, current,
@ -141,15 +127,270 @@ pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f
std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{
target, 100.0 - target, current,
}) catch return null;
return .{ .text = text, .style = style };
}
/// Format the stock benchmark label with weight (e.g. "SPY (83.8% weight)").
/// Format the stock benchmark label with weight.
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;
}
// Precomputed projection data (shared by CLI and TUI)
pub const ProjectionContext = struct {
comparison: benchmark.BenchmarkComparison,
config: projections.UserConfig,
data: ProjectionData,
stock_pct: f64,
bond_pct: f64,
total_value: f64,
};
pub const ProjectionData = struct {
withdrawals: []projections.WithdrawalResult,
bands: []?[]projections.YearPercentiles,
ci_99: usize,
};
pub fn computeProjectionData(
alloc: std.mem.Allocator,
horizons: []const u16,
confidence_levels: []const f64,
total_value: f64,
stock_pct: f64,
) !ProjectionData {
const num_results = horizons.len * confidence_levels.len;
const withdrawals = try alloc.alloc(projections.WithdrawalResult, num_results);
for (confidence_levels, 0..) |conf, ci| {
for (horizons, 0..) |h, hi| {
withdrawals[ci * horizons.len + hi] = projections.findSafeWithdrawal(h, total_value, stock_pct, conf);
}
}
const ci_99 = confidence_levels.len - 1;
const bands = try alloc.alloc(?[]projections.YearPercentiles, horizons.len);
for (horizons, 0..) |h, hi| {
bands[hi] = projections.computePercentileBands(
alloc,
h,
total_value,
withdrawals[ci_99 * horizons.len + hi].annual_amount,
stock_pct,
) catch null;
}
return .{ .withdrawals = withdrawals, .bands = bands, .ci_99 = ci_99 };
}
pub fn buildProjectionContext(
alloc: std.mem.Allocator,
config: projections.UserConfig,
comparison: benchmark.BenchmarkComparison,
stock_pct: f64,
bond_pct: f64,
total_value: f64,
) !ProjectionContext {
const sim_stock_pct = if (config.target_stock_pct) |t| t / 100.0 else stock_pct;
const data = try computeProjectionData(alloc, config.getHorizons(), config.getConfidenceLevels(), total_value, sim_stock_pct);
return .{
.comparison = comparison,
.config = config,
.data = data,
.stock_pct = stock_pct,
.bond_pct = bond_pct,
.total_value = total_value,
};
}
/// Load and compute a complete ProjectionContext from a portfolio path and service.
///
/// This is the single entry point for both CLI and TUI. It handles:
/// - Loading projections.srf and metadata.srf from the portfolio directory
/// - Deriving stock/bond allocation from classification metadata
/// - Computing benchmark trailing returns (SPY + AGG)
/// - Building per-position weighted trailing returns
/// - Running the FIRECalc simulation for all horizons and confidence levels
///
/// The caller provides the portfolio summary (allocations, total value, cash/CD)
/// and a DataService for candle access. All intermediate allocations use `alloc`.
pub fn loadProjectionContext(
alloc: std.mem.Allocator,
portfolio_dir: []const u8,
allocations: []const valuation.Allocation,
total_value: f64,
cash_value: f64,
cd_value: f64,
svc: *zfin.DataService,
) !ProjectionContext {
// Load projections.srf
const proj_path = try std.fmt.allocPrint(alloc, "{s}projections.srf", .{portfolio_dir});
defer alloc.free(proj_path);
const proj_data = std.fs.cwd().readFileAlloc(alloc, proj_path, 64 * 1024) catch null;
defer if (proj_data) |d| alloc.free(d);
const config = projections.parseProjectionsConfig(proj_data);
// Load metadata for classification
const meta_path = try std.fmt.allocPrint(alloc, "{s}metadata.srf", .{portfolio_dir});
defer alloc.free(meta_path);
const meta_data = std.fs.cwd().readFileAlloc(alloc, meta_path, 1024 * 1024) catch null;
defer if (meta_data) |d| alloc.free(d);
var cm_opt: ?zfin.classification.ClassificationMap = if (meta_data) |d|
zfin.classification.parseClassificationFile(alloc, d) catch null
else
null;
defer if (cm_opt) |*cm| cm.deinit();
// Derive stock/bond split
const split = benchmark.deriveAllocationSplit(
allocations,
if (cm_opt) |cm| cm.entries else &.{},
total_value,
cash_value,
cd_value,
);
// Fetch benchmark candles (checks cache first)
const spy_result = svc.getCandles("SPY") catch null;
const spy_candles = if (spy_result) |r| r.data else &.{};
defer if (spy_result) |r| alloc.free(r.data);
const agg_result = svc.getCandles("AGG") catch null;
const agg_candles = if (agg_result) |r| r.data else &.{};
defer if (agg_result) |r| alloc.free(r.data);
const spy_trailing = performance.trailingReturns(spy_candles);
const agg_trailing = performance.trailingReturns(agg_candles);
const spy_week = performance.weekReturn(spy_candles);
const agg_week = performance.weekReturn(agg_candles);
// Build per-position trailing returns
var pos_returns: std.ArrayListUnmanaged(benchmark.PositionReturn) = .empty;
defer pos_returns.deinit(alloc);
for (allocations) |a| {
const candles = svc.getCachedCandles(a.symbol) orelse continue;
defer alloc.free(candles);
if (candles.len > 0) {
try pos_returns.append(alloc, .{
.symbol = a.symbol,
.weight = a.weight,
.returns = performance.trailingReturns(candles),
});
}
}
const comparison = benchmark.buildComparison(
spy_trailing,
agg_trailing,
split.stock_pct,
split.bond_pct,
pos_returns.items,
spy_week,
agg_week,
);
return buildProjectionContext(alloc, config, comparison, split.stock_pct, split.bond_pct, total_value);
}
// Table row builders (shared by CLI and TUI)
/// A pre-formatted table row: label + right-aligned columns.
pub const TableRow = struct {
text: []const u8,
style: StyleIntent,
};
/// Build a column header row for a given set of horizons and column width.
pub fn buildHeaderRow(arena: std.mem.Allocator, horizons: []const u16, col_width: usize) ![]const u8 {
var row: std.ArrayListUnmanaged(u8) = .empty;
try row.appendNTimes(arena, ' ', withdrawal_label_width);
for (horizons) |h| {
var hbuf: [16]u8 = undefined;
const hlabel = fmtHorizonLabel(&hbuf, h);
try row.appendNTimes(arena, ' ', col_width -| hlabel.len);
try row.appendSlice(arena, hlabel);
}
return row.toOwnedSlice(arena);
}
/// Build withdrawal rows for one confidence level: amount row + rate row.
pub fn buildWithdrawalRows(
arena: std.mem.Allocator,
confidence: f64,
horizons: []const u16,
cached_results: []const projections.WithdrawalResult,
confidence_idx: usize,
) !struct { amount: TableRow, rate: TableRow } {
// Amount row
var amount_row: std.ArrayListUnmanaged(u8) = .empty;
var lbuf: [25]u8 = undefined;
const clabel = fmtConfidenceLabel(&lbuf, confidence);
try amount_row.appendSlice(arena, clabel);
try amount_row.appendNTimes(arena, ' ', withdrawal_label_width -| clabel.len);
for (horizons, 0..) |_, hi| {
const result = cached_results[confidence_idx * horizons.len + hi];
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = fmtWithdrawalCell(&abuf, &rbuf, result);
try amount_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.amount_text.len);
try amount_row.appendSlice(arena, cell.amount_text);
}
// Rate row
var rate_row: std.ArrayListUnmanaged(u8) = .empty;
try rate_row.appendNTimes(arena, ' ', withdrawal_label_width);
for (horizons, 0..) |_, hi| {
const result = cached_results[confidence_idx * horizons.len + hi];
var abuf: [24]u8 = undefined;
var rbuf: [16]u8 = undefined;
const cell = fmtWithdrawalCell(&abuf, &rbuf, result);
try rate_row.appendNTimes(arena, ' ', withdrawal_col_width -| cell.rate_text.len);
try rate_row.appendSlice(arena, cell.rate_text);
}
return .{
.amount = .{ .text = try amount_row.toOwnedSlice(arena), .style = .normal },
.rate = .{ .text = try rate_row.toOwnedSlice(arena), .style = .muted },
};
}
/// Build a percentile row (p10/p50/p90) across horizons.
pub fn buildPercentileRow(
arena: std.mem.Allocator,
label: []const u8,
percentile_idx: usize,
all_bands: []const ?[]const projections.YearPercentiles,
style: StyleIntent,
) !TableRow {
var row: std.ArrayListUnmanaged(u8) = .empty;
try row.appendSlice(arena, label);
try row.appendNTimes(arena, ' ', withdrawal_label_width -| label.len);
for (all_bands) |bands_opt| {
if (bands_opt) |bands| {
if (bands.len > 0) {
const last = bands[bands.len - 1];
const val = switch (percentile_idx) {
0 => last.p10,
1 => last.p50,
2 => last.p90,
else => 0,
};
var mbuf: [24]u8 = undefined;
const txt = fmt.fmtMoneyAbs(&mbuf, val);
try row.appendNTimes(arena, ' ', terminal_col_width -| txt.len);
try row.appendSlice(arena, txt);
} else {
try row.appendNTimes(arena, ' ', terminal_col_width - 2);
try row.appendSlice(arena, "--");
}
} else {
try row.appendNTimes(arena, ' ', terminal_col_width - 2);
try row.appendSlice(arena, "--");
}
}
return .{ .text = try row.toOwnedSlice(arena), .style = style };
}
// Tests
test "fmtReturnCell positive" {
@ -233,3 +474,129 @@ test "buildReturnRow" {
try std.testing.expect(row.five_year.style == .muted);
try std.testing.expect(row.bold == false);
}
test "buildHeaderRow formats horizons" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{ 30, 45 };
const result = try buildHeaderRow(a, &horizons, withdrawal_col_width);
try std.testing.expect(std.mem.indexOf(u8, result, "30 Year") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "45 Year") != null);
}
test "buildHeaderRow uses terminal column width" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{20};
const narrow = try buildHeaderRow(a, &horizons, withdrawal_col_width);
const wide = try buildHeaderRow(a, &horizons, terminal_col_width);
try std.testing.expect(wide.len > narrow.len);
}
test "buildWithdrawalRows produces amount and rate" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const horizons = [_]u16{ 30, 45 };
const results = [_]projections.WithdrawalResult{
.{ .confidence = 0.95, .annual_amount = 350000, .withdrawal_rate = 0.042 },
.{ .confidence = 0.95, .annual_amount = 310000, .withdrawal_rate = 0.037 },
};
const rows = try buildWithdrawalRows(a, 0.95, &horizons, &results, 0);
// Amount row should contain the dollar amounts
try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "350,000") != null);
try std.testing.expect(std.mem.indexOf(u8, rows.amount.text, "310,000") != null);
try std.testing.expect(rows.amount.style == .normal);
// Rate row should contain percentages
try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "4.20%") != null);
try std.testing.expect(std.mem.indexOf(u8, rows.rate.text, "3.70%") != null);
try std.testing.expect(rows.rate.style == .muted);
}
test "buildPercentileRow extracts correct percentile" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const bands = [_]projections.YearPercentiles{
.{ .year = 0, .p10 = 1000000, .p25 = 2000000, .p50 = 3000000, .p75 = 4000000, .p90 = 5000000 },
.{ .year = 30, .p10 = 5000000, .p25 = 10000000, .p50 = 20000000, .p75 = 30000000, .p90 = 50000000 },
};
const band_slice: []const projections.YearPercentiles = &bands;
const all_bands = [_]?[]const projections.YearPercentiles{band_slice};
// p10 (index 0)
const row_p10 = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted);
try std.testing.expect(std.mem.indexOf(u8, row_p10.text, "5,000,000") != null);
try std.testing.expect(row_p10.style == .muted);
// p50 (index 1)
const row_p50 = try buildPercentileRow(a, "Median", 1, &all_bands, .normal);
try std.testing.expect(std.mem.indexOf(u8, row_p50.text, "20,000,000") != null);
try std.testing.expect(row_p50.style == .normal);
// p90 (index 2)
const row_p90 = try buildPercentileRow(a, "Optimistic", 2, &all_bands, .muted);
try std.testing.expect(std.mem.indexOf(u8, row_p90.text, "50,000,000") != null);
}
test "buildPercentileRow handles null bands" {
const allocator = std.testing.allocator;
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const a = arena.allocator();
const all_bands = [_]?[]const projections.YearPercentiles{null};
const row = try buildPercentileRow(a, "Pessimistic", 0, &all_bands, .muted);
try std.testing.expect(std.mem.indexOf(u8, row.text, "--") != null);
}
test "computeProjectionData produces correct structure" {
const allocator = std.testing.allocator;
const horizons = [_]u16{ 20, 30 };
const conf = [_]f64{ 0.95, 0.99 };
const data = try computeProjectionData(allocator, &horizons, &conf, 1000000, 0.75);
defer {
allocator.free(data.withdrawals);
for (data.bands) |b| {
if (b) |slice| allocator.free(slice);
}
allocator.free(data.bands);
}
// 2 horizons × 2 confidence levels = 4 withdrawal results
try std.testing.expectEqual(@as(usize, 4), data.withdrawals.len);
// 2 bands (one per horizon)
try std.testing.expectEqual(@as(usize, 2), data.bands.len);
// 99% is the last confidence level
try std.testing.expectEqual(@as(usize, 1), data.ci_99);
// Withdrawal at 95% should be >= withdrawal at 99% (for same horizon)
try std.testing.expect(data.withdrawals[0].annual_amount >= data.withdrawals[2].annual_amount);
// Withdrawal at 20yr should be >= withdrawal at 30yr (for same confidence)
try std.testing.expect(data.withdrawals[0].annual_amount >= data.withdrawals[1].annual_amount);
}
test "fmtConfidenceLabel" {
var buf: [25]u8 = undefined;
const label = fmtConfidenceLabel(&buf, 0.99);
try std.testing.expect(std.mem.indexOf(u8, label, "99%") != null);
try std.testing.expect(std.mem.indexOf(u8, label, "withdrawal") != null);
}
test "fmtHorizonLabel" {
var buf: [16]u8 = undefined;
const label = fmtHorizonLabel(&buf, 30);
try std.testing.expectEqualStrings("30 Year", label);
}