602 lines
23 KiB
Zig
602 lines
23 KiB
Zig
/// Renderer-agnostic view model for the projections display.
|
||
///
|
||
/// Produces pre-formatted text and `StyleIntent` values that both CLI
|
||
/// and TUI renderers can consume through thin style-mapping adapters.
|
||
const std = @import("std");
|
||
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;
|
||
|
||
// ── Layout constants (shared by CLI and TUI) ──────────────────
|
||
|
||
pub const label_width = 32;
|
||
pub const col_1y = 8;
|
||
pub const col_3y = 9;
|
||
pub const col_5y = 9;
|
||
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 ──────────────────────────────────────
|
||
|
||
/// A single cell in the returns table: formatted text + style.
|
||
pub const ReturnCell = struct {
|
||
text: []const u8,
|
||
style: StyleIntent,
|
||
};
|
||
|
||
/// Format a return value into a buffer, returning the styled cell.
|
||
pub fn fmtReturnCell(buf: []u8, value: ?f64) ReturnCell {
|
||
if (value) |v| {
|
||
return .{
|
||
.text = performance.formatReturn(buf, v),
|
||
.style = if (v >= 0) .positive else .negative,
|
||
};
|
||
}
|
||
return .{ .text = "--", .style = .muted };
|
||
}
|
||
|
||
/// A complete row in the benchmark comparison table.
|
||
pub const ReturnRow = struct {
|
||
label: []const u8,
|
||
one_year: ReturnCell,
|
||
three_year: ReturnCell,
|
||
five_year: ReturnCell,
|
||
ten_year: ReturnCell,
|
||
week: ReturnCell,
|
||
bold: bool = false,
|
||
};
|
||
|
||
/// Build a return row from a ReturnsByPeriod and a label.
|
||
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),
|
||
.three_year = fmtReturnCell(&bufs[1], returns.three_year),
|
||
.five_year = fmtReturnCell(&bufs[2], returns.five_year),
|
||
.ten_year = fmtReturnCell(&bufs[3], returns.ten_year),
|
||
.week = fmtReturnCell(&bufs[4], returns.week),
|
||
.bold = bold,
|
||
};
|
||
}
|
||
|
||
// ── Safe withdrawal formatting ─────────────────────────────────
|
||
|
||
/// A single cell in the withdrawal table.
|
||
pub const WithdrawalCell = struct {
|
||
amount_text: []const u8,
|
||
rate_text: []const u8,
|
||
};
|
||
|
||
/// 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
|
||
const clean_amount = if (std.mem.endsWith(u8, money_str, ".00"))
|
||
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 };
|
||
}
|
||
|
||
/// Format a confidence level label (e.g. "99% safe withdrawal").
|
||
pub fn fmtConfidenceLabel(buf: []u8, confidence: f64) []const u8 {
|
||
return std.fmt.bufPrint(buf, "{d:.0}% safe withdrawal", .{confidence * 100}) catch "??";
|
||
}
|
||
|
||
/// Format a horizon column header (e.g. "30 Year").
|
||
pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 {
|
||
return std.fmt.bufPrint(buf, "{d} Year", .{horizon}) catch "??";
|
||
}
|
||
|
||
// ── Allocation summary ─────────────────────────────────────────
|
||
|
||
/// Result of formatting the allocation note.
|
||
pub const AllocationNote = struct {
|
||
text: []const u8,
|
||
style: StyleIntent,
|
||
};
|
||
|
||
/// Format the target allocation note line with drift-aware styling.
|
||
/// Returns null if no target is configured.
|
||
///
|
||
/// Drift thresholds:
|
||
/// - Within 2%: "on target" (muted)
|
||
/// - 2–5% off: warning
|
||
/// - Over 5% off: negative
|
||
pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote {
|
||
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 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,
|
||
}) catch return null
|
||
else
|
||
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.
|
||
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" {
|
||
var buf: [16]u8 = undefined;
|
||
const cell = fmtReturnCell(&buf, 0.1234);
|
||
try std.testing.expect(cell.style == .positive);
|
||
try std.testing.expect(cell.text.len > 0);
|
||
}
|
||
|
||
test "fmtReturnCell negative" {
|
||
var buf: [16]u8 = undefined;
|
||
const cell = fmtReturnCell(&buf, -0.05);
|
||
try std.testing.expect(cell.style == .negative);
|
||
}
|
||
|
||
test "fmtReturnCell null" {
|
||
var buf: [16]u8 = undefined;
|
||
const cell = fmtReturnCell(&buf, null);
|
||
try std.testing.expect(cell.style == .muted);
|
||
try std.testing.expectEqualStrings("--", cell.text);
|
||
}
|
||
|
||
test "fmtWithdrawalCell strips .00" {
|
||
var abuf: [24]u8 = undefined;
|
||
var rbuf: [16]u8 = undefined;
|
||
const cell = fmtWithdrawalCell(&abuf, &rbuf, .{
|
||
.confidence = 0.99,
|
||
.annual_amount = 305000,
|
||
.withdrawal_rate = 0.0366,
|
||
});
|
||
try std.testing.expect(!std.mem.endsWith(u8, cell.amount_text, ".00"));
|
||
try std.testing.expect(std.mem.indexOf(u8, cell.rate_text, "3.66") != null);
|
||
}
|
||
|
||
test "fmtAllocationNote on target" {
|
||
var buf: [128]u8 = undefined;
|
||
const note = fmtAllocationNote(&buf, 77, 0.768);
|
||
try std.testing.expect(note != null);
|
||
try std.testing.expect(note.?.style == .muted);
|
||
try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") != null);
|
||
}
|
||
|
||
test "fmtAllocationNote off target" {
|
||
var buf: [128]u8 = undefined;
|
||
const note = fmtAllocationNote(&buf, 77, 0.85);
|
||
try std.testing.expect(note != null);
|
||
try std.testing.expect(note.?.style == .negative); // >5% drift
|
||
try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") == null);
|
||
}
|
||
|
||
test "fmtAllocationNote warning range" {
|
||
var buf: [128]u8 = undefined;
|
||
const note = fmtAllocationNote(&buf, 77, 0.80);
|
||
try std.testing.expect(note != null);
|
||
try std.testing.expect(note.?.style == .warning); // 3% drift, in 2-5% range
|
||
}
|
||
|
||
test "fmtAllocationNote no target" {
|
||
var buf: [128]u8 = undefined;
|
||
try std.testing.expect(fmtAllocationNote(&buf, null, 0.75) == null);
|
||
}
|
||
|
||
test "fmtBenchmarkLabel" {
|
||
var buf: [32]u8 = undefined;
|
||
const label = fmtBenchmarkLabel(&buf, "SPY", 83.8);
|
||
try std.testing.expect(std.mem.indexOf(u8, label, "SPY") != null);
|
||
try std.testing.expect(std.mem.indexOf(u8, label, "83.8") != null);
|
||
}
|
||
|
||
test "buildReturnRow" {
|
||
var bufs: [5][16]u8 = undefined;
|
||
const returns = benchmark.ReturnsByPeriod{
|
||
.one_year = 0.15,
|
||
.three_year = -0.02,
|
||
.five_year = null,
|
||
};
|
||
const row = buildReturnRow("Test", returns, &bufs, false);
|
||
try std.testing.expectEqualStrings("Test", row.label);
|
||
try std.testing.expect(row.one_year.style == .positive);
|
||
try std.testing.expect(row.three_year.style == .negative);
|
||
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);
|
||
}
|