initial commit projections TUI tab
This commit is contained in:
parent
6991e31bdd
commit
c0b980146c
11 changed files with 991 additions and 174 deletions
13
TODO.md
13
TODO.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(¬e_buf, user_config.target_stock_pct, stock_pct)) |note| {
|
||||
if (view.fmtAllocationNote(¬e_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────────────
|
||||
|
|
|
|||
30
src/tui.zig
30
src/tui.zig
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
365
src/tui/projections_tab.zig
Normal 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(¬e_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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue