206 lines
7.4 KiB
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);
|
|
}
|