zfin/src/views/projections.zig

602 lines
23 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 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)
/// - 25% 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);
}