235 lines
8.2 KiB
Zig
235 lines
8.2 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 ─────────────────────────────────────────
|
||
|
||
/// 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 (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(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);
|
||
}
|