zfin/src/views/projections.zig

206 lines
7.4 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");
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;
// ── 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.
/// 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 {
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).
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 ─────────────────────────────────────────
/// Format the target allocation note line.
/// Returns null if no target is configured.
pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?[]const u8 {
const target = target_stock_pct orelse return null;
const current = current_stock_pct * 100;
const diff = current - target;
if (@abs(diff) < 2.0) {
return 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 null;
}
return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{
target, 100.0 - target, current,
}) catch null;
}
/// Format the stock benchmark label with weight (e.g. "SPY (83.8% 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;
}
// ── 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(std.mem.indexOf(u8, note.?, "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(std.mem.indexOf(u8, note.?, "on target") == null);
}
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);
}