initial commit projections TUI tab

This commit is contained in:
Emil Lerch 2026-04-28 10:30:13 -07:00
parent 98bee5fba6
commit 33d5d7cbb4
11 changed files with 991 additions and 174 deletions

13
TODO.md
View file

@ -1,5 +1,18 @@
# Future Work # 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 ## Analysis account/asset-class total mismatch
The "By Account" and "By Tax Type" sections in the analysis command sum to slightly 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 "??%"; 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" { test "total return simple" {
const candles = [_]Candle{ const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 }, .{ .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 /// - Success rate for a given spending level
/// - Percentile bands of portfolio value at each year (for charting) /// - Percentile bands of portfolio value at each year (for charting)
const std = @import("std"); const std = @import("std");
const log = std.log.scoped(.projections);
const shiller = @import("../data/shiller.zig"); const shiller = @import("../data/shiller.zig");
const srf = @import("srf"); const srf = @import("srf");
@ -297,16 +298,35 @@ fn successRate(
/// the portfolio survives `horizon` years in at least `confidence` fraction /// the portfolio survives `horizon` years in at least `confidence` fraction
/// of all historical cycles. /// 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( pub fn findSafeWithdrawal(
horizon: u16, horizon: u16,
initial_value: f64, initial_value: f64,
stock_pct: f64, stock_pct: f64,
confidence: f64, confidence: f64,
) WithdrawalResult { ) WithdrawalResult {
// Binary search bounds: $0 to the full portfolio value // Seed from the 4% rule, adjusted for horizon and confidence.
var lo: f64 = 0; // Base ~4% for 30yr/95%. Shorter horizons allow more; longer less.
var hi: f64 = initial_value; // 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 // Binary search to $1 precision
while (hi - lo > 1.0) { 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; var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer chart.deinit(allocator); 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( 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); 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 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; var arena_state = std.heap.ArenaAllocator.init(allocator);
defer if (proj_path) |p| allocator.free(p); 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; const ctx = try view.loadProjectionContext(
defer if (proj_data) |d| allocator.free(d); va,
file_path[0..dir_end],
const user_config = projections.parseProjectionsConfig(proj_data); pf_data.summary.allocations,
pf_data.summary.total_value,
// 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,
portfolio.totalCash(), portfolio.totalCash(),
portfolio.totalCdFaceValue(), portfolio.totalCdFaceValue(),
svc,
); );
const horizons = ctx.config.getHorizons();
const stock_pct = split.stock_pct; const confidence_levels = ctx.config.getConfidenceLevels();
const bond_pct = split.bond_pct; const comparison = ctx.comparison;
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
try out.print("\n", .{}); try out.print("\n", .{});
try cli.setBold(out, color); try cli.setBold(out, color);
try out.print("Projections ({s})\n", .{file_path}); 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_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined; var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow( 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, comparison.stock_returns,
&spy_bufs, &spy_bufs,
false, 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_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined; var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow( 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, comparison.bond_returns,
&agg_bufs, &agg_bufs,
false, false,
@ -189,7 +130,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
// Target allocation note // Target allocation note
{ {
var note_buf: [128]u8 = undefined; 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 out.print("\n", .{});
try cli.setStyleIntent(out, color, note.style); try cli.setStyleIntent(out, color, note.style);
try out.print("{s}\n", .{note.text}); 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 // Safe withdrawal table
try out.print("\n", .{}); try out.print("\n", .{});
try cli.setBold(out, color); try cli.setBold(out, color);
try out.print("Safe Withdrawal (FIRECalc historical simulation)\n", .{}); try out.print("Safe Withdrawal (FIRECalc historical simulation)\n", .{});
try cli.reset(out, color); try cli.reset(out, color);
const horizons = user_config.getHorizons();
const confidence_levels = user_config.getConfidenceLevels();
// Header row // Header row
try out.print("{s: <25}", .{""}); try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
for (horizons) |h| {
var hbuf: [16]u8 = undefined;
try out.print("{s: >12}", .{view.fmtHorizonLabel(&hbuf, h)});
}
try out.print("\n", .{});
// One row per confidence level // Withdrawal rows
for (confidence_levels) |conf| { for (confidence_levels, 0..) |conf, ci| {
var lbuf: [25]u8 = undefined; const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
try out.print("{s: <25}", .{view.fmtConfidenceLabel(&lbuf, conf)}); try out.print("{s}\n", .{wr_rows.amount.text});
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
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s: <25}", .{""}); try out.print("{s}\n", .{wr_rows.rate.text});
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 cli.reset(out, color); try cli.reset(out, color);
try out.print("\n", .{});
} }
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); 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; var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (chart) |*ch| { if (chart) |*ch| {
defer ch.deinit(allocator); 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 // Price labels
var result: BrailleChart = undefined; 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; 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; result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width); 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. /// 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( pub fn writeBrailleAnsi(
out: *std.Io.Writer, out: *std.Io.Writer,
chart: *const BrailleChart, chart: *const BrailleChart,
use_color: bool, use_color: bool,
muted_color: [3]u8, muted_color: [3]u8,
skip_date_axis: bool,
) !void { ) !void {
var last_r: u8 = 0; var last_r: u8 = 0;
var last_g: u8 = 0; var last_g: u8 = 0;
@ -879,23 +883,24 @@ pub fn writeBrailleAnsi(
} }
// Date axis below chart // Date axis below chart
var start_buf: [7]u8 = undefined; if (!skip_date_axis) {
var end_buf: [7]u8 = undefined; var start_buf: [7]u8 = undefined;
const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf); var end_buf: [7]u8 = undefined;
const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf); 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] }); 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(" "); // match leading indent
try out.writeAll(start_label); try out.writeAll(start_label);
// Fill gap between start and end labels const total_width = chart.n_cols;
const total_width = chart.n_cols; if (total_width > start_label.len + end_label.len) {
if (total_width > start_label.len + end_label.len) { const gap = total_width - start_label.len - end_label.len;
const gap = total_width - start_label.len - end_label.len; for (0..gap) |_| try out.writeAll(" ");
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) // 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 earnings_tab = @import("tui/earnings_tab.zig");
const analysis_tab = @import("tui/analysis_tab.zig"); const analysis_tab = @import("tui/analysis_tab.zig");
const history_tab = @import("tui/history_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 history_io = @import("history.zig");
const timeline = @import("analytics/timeline.zig"); const timeline = @import("analytics/timeline.zig");
@ -79,6 +80,7 @@ pub const Tab = enum {
earnings, earnings,
analysis, analysis,
history, history,
projections,
fn label(self: Tab) []const u8 { fn label(self: Tab) []const u8 {
return switch (self) { return switch (self) {
@ -89,11 +91,12 @@ pub const Tab = enum {
.earnings => " 5:Earnings ", .earnings => " 5:Earnings ",
.analysis => " 6:Analysis ", .analysis => " 6:Analysis ",
.history => " 7:History ", .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 { pub const InputMode = enum {
normal, normal,
@ -378,6 +381,13 @@ pub const App = struct {
history_loaded: bool = false, history_loaded: bool = false,
history_disabled: bool = false, // true when no portfolio path (history requires it) history_disabled: bool = false, // true when no portfolio path (history requires it)
history_timeline: ?history_io.LoadedTimeline = null, 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 // Default to `.liquid` that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by // day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view. // liquid anyway, so "show me liquid" is the headline view.
@ -925,7 +935,7 @@ pub const App = struct {
ctx.queueRefresh() catch {}; ctx.queueRefresh() catch {};
return ctx.consumeAndRedraw(); 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); const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
if (idx < tabs.len) { if (idx < tabs.len) {
const target = tabs[idx]; const target = tabs[idx];
@ -1364,7 +1374,7 @@ pub const App = struct {
.options => { .options => {
self.svc.invalidate(self.symbol, .options); self.svc.invalidate(self.symbol, .options);
}, },
.portfolio, .analysis, .history => {}, .portfolio, .analysis, .history, .projections => {},
} }
} }
switch (self.active_tab) { switch (self.active_tab) {
@ -1400,6 +1410,10 @@ pub const App = struct {
self.history_loaded = false; self.history_loaded = false;
history_tab.freeLoaded(self); history_tab.freeLoaded(self);
}, },
.projections => {
self.projections_loaded = false;
projections_tab.freeLoaded(self);
},
} }
self.loadTabData(); self.loadTabData();
@ -1444,6 +1458,10 @@ pub const App = struct {
if (self.history_disabled) return; if (self.history_disabled) return;
if (!self.history_loaded) history_tab.loadData(self); 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.classification_map) |*cm| cm.deinit();
if (self.account_map) |*am| am.deinit(); if (self.account_map) |*am| am.deinit();
history_tab.freeLoaded(self); history_tab.freeLoaded(self);
projections_tab.freeLoaded(self);
self.chart.freeCache(self.allocator); // Free cached indicators self.chart.freeCache(self.allocator); // Free cached indicators
} }
@ -1626,7 +1645,8 @@ pub const App = struct {
fn isTabDisabled(self: *App, t: Tab) bool { fn isTabDisabled(self: *App, t: Tab) bool {
return (t == .earnings and self.earnings_disabled) or return (t == .earnings and self.earnings_disabled) or
(t == .analysis and self.analysis_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 { 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)), .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)), .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)), .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; _ = earnings_tab;
_ = analysis_tab; _ = analysis_tab;
_ = history_tab; _ = history_tab;
_ = projections_tab;
} }
/// Entry point for the interactive TUI. /// Entry point for the interactive TUI.

View file

@ -14,6 +14,7 @@ pub const Action = enum {
tab_5, tab_5,
tab_6, tab_6,
tab_7, tab_7,
tab_8,
scroll_down, scroll_down,
scroll_up, scroll_up,
scroll_top, scroll_top,
@ -98,6 +99,7 @@ const default_bindings = [_]Binding{
.{ .action = .tab_5, .key = .{ .codepoint = '5' } }, .{ .action = .tab_5, .key = .{ .codepoint = '5' } },
.{ .action = .tab_6, .key = .{ .codepoint = '6' } }, .{ .action = .tab_6, .key = .{ .codepoint = '6' } },
.{ .action = .tab_7, .key = .{ .codepoint = '7' } }, .{ .action = .tab_7, .key = .{ .codepoint = '7' } },
.{ .action = .tab_8, .key = .{ .codepoint = '8' } },
.{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } },
.{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } },
.{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, .{ .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 performance = @import("../analytics/performance.zig");
const benchmark = @import("../analytics/benchmark.zig"); const benchmark = @import("../analytics/benchmark.zig");
const projections = @import("../analytics/projections.zig"); const projections = @import("../analytics/projections.zig");
const valuation = @import("../analytics/valuation.zig");
const zfin = @import("../root.zig");
pub const StyleIntent = fmt.StyleIntent; pub const StyleIntent = fmt.StyleIntent;
@ -20,6 +22,7 @@ pub const col_10y = 10;
pub const col_week = 9; pub const col_week = 9;
pub const withdrawal_label_width = 25; pub const withdrawal_label_width = 25;
pub const withdrawal_col_width = 12; pub const withdrawal_col_width = 12;
pub const terminal_col_width = 18;
// Return row formatting // Return row formatting
@ -52,13 +55,7 @@ pub const ReturnRow = struct {
}; };
/// Build a return row from a ReturnsByPeriod and a label. /// 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 .{ return .{
.label = label, .label = label,
.one_year = fmtReturnCell(&bufs[0], returns.one_year), .one_year = fmtReturnCell(&bufs[0], returns.one_year),
@ -80,6 +77,7 @@ pub const WithdrawalCell = struct {
/// Format a safe withdrawal result into display strings. /// Format a safe withdrawal result into display strings.
/// Caller owns both buffers (at least 24 bytes each). /// 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 { pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell {
const money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount); const money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount);
// Strip trailing ".00" for clean display // 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] money_str[0 .. money_str.len - 3]
else else
money_str; money_str;
const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%"; 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"). /// 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 target = target_stock_pct orelse return null;
const current = current_stock_pct * 100; const current = current_stock_pct * 100;
const drift = @abs(current - target); 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) 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)", .{ std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{
target, 100.0 - target, current, 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}%)", .{ std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{
target, 100.0 - target, current, target, 100.0 - target, current,
}) catch return null; }) catch return null;
return .{ .text = text, .style = style }; 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 { pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 {
return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol; return std.fmt.bufPrint(buf, "{s} ({d:.1}% 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 // Tests
test "fmtReturnCell positive" { test "fmtReturnCell positive" {
@ -233,3 +474,129 @@ test "buildReturnRow" {
try std.testing.expect(row.five_year.style == .muted); try std.testing.expect(row.five_year.style == .muted);
try std.testing.expect(row.bold == false); 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);
}