zfin/src/commands/projections.zig

1221 lines
49 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.

/// CLI `projections` command: retirement projections and benchmark comparison.
///
/// Produces:
/// - Benchmark comparison table (SPY/AGG vs portfolio weighted returns)
/// - Conservative weighted return estimate
/// - Safe withdrawal amounts at multiple horizons and confidence levels
///
/// When `as_of` is non-null, the same output is produced against a
/// historical snapshot instead of the live portfolio. See
/// `src/views/projections.zig:loadProjectionContextAsOf`.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
const Date = zfin.Date;
const Money = @import("../Money.zig");
const performance = @import("../analytics/performance.zig");
const projections = @import("../analytics/projections.zig");
const benchmark = @import("../analytics/benchmark.zig");
const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig");
/// Hardcoded benchmark symbols (configurable in a future version).
const stock_benchmark = "SPY";
const bond_benchmark = "AGG";
/// How an as-of date resolved against the history directory. The CLI
/// uses this to render a single header that tells the user what
/// actually got loaded (exact hit, nearest-earlier, or straight-up
/// "no snapshot available").
const AsOfResolution = struct {
/// The requested date, as parsed by the caller.
requested: Date,
/// The date that was actually loaded. Differs from `requested`
/// when we auto-snapped to the nearest-earlier data point.
actual: Date,
/// Whether the resolution landed on a native snapshot or an
/// `imported_values.srf` row. Snapshot wins when both are
/// available within the at-or-before window.
source: history.AsOfSourceKind = .snapshot,
/// Liquid total at `actual`. Filled when `source == .imported`
/// (read directly from the imported_values row); zero otherwise
/// — the snapshot path reads its totals from the loaded snapshot.
liquid: f64 = 0,
};
/// Run projections.
///
/// `as_of` is the reference date for ages, horizons, and snapshot
/// windows. `from_snapshot` selects the data source:
/// - `false`: live mode. Load `file_path` directly. Caller passes
/// today as `as_of`.
/// - `true`: historical mode. Load the snapshot at-or-before
/// `as_of` from the history dir.
pub fn run(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
events_enabled: bool,
as_of: Date,
from_snapshot: bool,
today: Date,
overlay_actuals: bool,
color: bool,
out: *std.Io.Writer,
) !void {
// Single arena for all view/render allocations. Same lifetime
// regardless of live vs. as-of path.
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
// portfolio_dir is the directory component of file_path, ending
// in a separator (for the downstream `{s}projections.srf` join).
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const portfolio_dir = file_path[0..dir_end];
// Build the context via either the live or as-of pipeline. Both
// produce a `ProjectionContext`; from that point on rendering is
// identical.
var ctx: view.ProjectionContext = undefined;
var resolution: ?AsOfResolution = null;
// Snapshot must outlive the context when on the as-of path because
// `ctx.allocations` borrow their symbol strings from the snapshot's
// backing buffer. Keep this declared at the outer scope so the
// defer runs at the end of `run`.
var snap_bundle: ?history.LoadedSnapshot = null;
defer if (snap_bundle) |*s| s.deinit(allocator);
// Live-portfolio bundle. Loaded for the live (no-as-of) path
// AND for the imported-only as-of path (which uses today's
// composition scaled to the imported liquid total).
var live_loaded: ?cli.LoadedPortfolio = null;
defer if (live_loaded) |*l| l.deinit(allocator);
var live_pf_data: ?cli.PortfolioData = null;
defer if (live_pf_data) |*p| p.deinit(allocator);
if (from_snapshot) {
resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) {
error.NoSnapshot => return,
else => return err,
};
if (resolution.?.source == .snapshot) {
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual);
ctx = try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_bundle.?.snap,
resolution.?.actual,
svc,
events_enabled,
);
} else {
// Imported-only as-of: need today's portfolio composition
// (allocations + cash/CD totals) to scale to the
// imported liquid value.
//
// Today's `as_of` parameter is being repurposed here as
// the historical reference date for the simulation; for
// the composition we ALWAYS want today's mix (the only
// composition we know). Pass `today` for the cash/CD
// computation.
live_loaded = cli.loadPortfolio(io, allocator, file_path, today) orelse return;
const lp = &live_loaded.?;
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
for (lp.positions) |pos| {
if (pos.shares <= 0) continue;
if (svc.getCachedCandles(pos.symbol)) |cs| {
defer cs.deinit();
if (cs.data.len > 0) {
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close);
}
}
}
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, today) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n");
return;
},
else => return err,
};
ctx = try view.loadProjectionContextFromImported(
io,
va,
portfolio_dir,
live_pf_data.?.summary.allocations,
live_pf_data.?.summary.total_value,
lp.portfolio.totalCash(today),
lp.portfolio.totalCdFaceValue(today),
resolution.?.liquid,
resolution.?.actual,
svc,
events_enabled,
);
}
} else {
live_loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
const lp = &live_loaded.?;
// Prices from cache — matches pre-as-of behavior exactly.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
for (lp.positions) |pos| {
if (pos.shares <= 0) continue;
if (svc.getCachedCandles(pos.symbol)) |cs| {
defer cs.deinit();
if (cs.data.len > 0) {
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close);
}
}
}
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
};
ctx = try view.loadProjectionContext(
io,
va,
portfolio_dir,
live_pf_data.?.summary.allocations,
live_pf_data.?.summary.total_value,
lp.portfolio.totalCash(as_of),
lp.portfolio.totalCdFaceValue(as_of),
svc,
events_enabled,
as_of,
);
}
// ── Optional actuals overlay ────────────────────────────────
//
// Requires `--as-of` (an as-of-resolved snapshot date). When the
// user passes `--overlay-actuals` without `--as-of`, warn and
// continue without the overlay; the overlay against today-as-now
// is meaningless because the future hasn't happened yet.
if (overlay_actuals) {
if (!from_snapshot) {
try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n");
} else if (resolution) |r| {
ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, today) catch |err| blk: {
// Non-fatal — the projection still renders without
// the overlay. Surface the error so the user can fix
// their history dir but don't block the report.
var buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n";
try cli.stderrPrint(io, msg);
break :blk null;
};
}
}
const horizons = ctx.config.getHorizons();
const confidence_levels = ctx.config.getConfidenceLevels();
const comparison = ctx.comparison;
try out.print("\n", .{});
if (resolution) |r| {
const source_label: []const u8 = switch (r.source) {
.snapshot => "snapshot",
.imported => "imported value",
};
try cli.printBold(out, color, "Projections (as of {f}, {s})\n", .{ r.actual, source_label });
} else {
try cli.printBold(out, color, "Projections ({s})\n", .{file_path});
}
try out.print("========================================\n", .{});
// If auto-snapped, print a muted note so the user knows the
// requested date wasn't an exact hit. The wording reflects the
// resolution source — "nearest snapshot" vs "nearest imported
// value" — so the user knows which file to update for finer
// granularity.
if (resolution) |r| {
if (r.actual.days != r.requested.days) {
const diff = r.requested.days - r.actual.days;
const nearest_label: []const u8 = switch (r.source) {
.snapshot => "nearest snapshot",
.imported => "nearest imported value",
};
try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f}; {s}: {f}, {d} day{s} earlier)\n", .{
r.requested,
nearest_label,
r.actual,
diff,
fmt.dayPlural(diff),
});
}
if (r.source == .imported) {
try cli.printFg(out, color, cli.CLR_MUTED, "(bands use today's allocation scaled to the imported liquid total)\n", .{});
}
}
try out.print("\n", .{});
// Header row
try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}\n", .{
"", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
});
// Build return rows via view model
var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, ctx.stock_pct * 100),
comparison.stock_returns,
&spy_bufs,
false,
);
var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, ctx.bond_pct * 100),
comparison.bond_returns,
&agg_bufs,
false,
);
var bench_bufs: [5][16]u8 = undefined;
const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
var port_bufs: [5][16]u8 = undefined;
const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);
const rows = [_]view.ReturnRow{ spy_row, agg_row, bench_row };
for (rows) |row| {
if (row.bold) try cli.setBold(out, color);
try writeReturnRow(out, color, row);
if (row.bold) try cli.reset(out, color);
}
try out.print("\n", .{});
try cli.setBold(out, color);
try writeReturnRow(out, color, port_row);
try cli.reset(out, color);
// Projected return (conservative estimate from benchmark analytics)
{
var buf: [16]u8 = undefined;
const cell = view.fmtReturnCell(&buf, comparison.conservative_return);
try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}\n", .{ "Projected return", cell.text });
}
// Target allocation note
{
var note_buf: [128]u8 = undefined;
if (view.fmtAllocationNote(&note_buf, ctx.config.target_stock_pct, ctx.stock_pct)) |note| {
try out.print("\n", .{});
try cli.printIntent(out, color, note.style, "{s}\n", .{note.text});
}
}
// ── Accumulation phase / Earliest retirement blocks ──────────
try renderAccumulationBlock(out, color, va, ctx);
try renderEarliestBlock(out, color, va, ctx, as_of);
// ── Braille chart: median portfolio value ─────────────────────
if (horizons.len > 0) {
const last_idx = horizons.len - 1;
if (ctx.data.bands[last_idx]) |b| {
if (b.len >= 2) {
try out.print("\n", .{});
try cli.printBold(out, color, "Median Portfolio Value ({d}-Year, 99% withdrawal)\n\n", .{horizons[last_idx]});
// Synthesize candles from median values
const candles = try va.alloc(zfin.Candle, b.len);
for (b, 0..) |bp, i| {
const v: f32 = @floatCast(bp.p50);
candles[i] = .{
.date = zfin.Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 365)),
.open = v,
.high = v,
.low = v,
.close = v,
.adj_close = v,
.volume = 0,
};
}
var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (br) |*chart| {
try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
// Year axis instead of date axis
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Now", .{});
const end_label_buf = try std.fmt.allocPrint(va, "{d}yr", .{horizons[last_idx]});
const pad = if (chart.n_cols > 3 + end_label_buf.len) chart.n_cols - 3 - end_label_buf.len else 0;
for (0..pad) |_| try out.print(" ", .{});
try out.print("{s}\n", .{end_label_buf});
try cli.reset(out, color);
}
}
}
}
// Overlay-actuals tip: the CLI's braille chart is single-series,
// so the actuals overlay only renders in the TUI. Print a short
// pointer so the user knows where to find it. (We do NOT gate on
// ctx.overlay_actuals being non-null — even when the overlay was
// requested but had no data, the user benefits from the tip.)
if (overlay_actuals and from_snapshot) {
try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{});
}
// ── Terminal portfolio value ─────────────────────────────────
try out.print("\n", .{});
try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{});
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)});
const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
for (p_labels, p_styles, 0..) |plabel, pstyle, pi| {
const row = try view.buildPercentileRow(va, plabel, pi, ctx.data.bands, pstyle);
try cli.printIntent(out, color, row.style, "{s}\n", .{row.text});
}
// ── Safe withdrawal table ──────────────────────────────────
try out.print("\n", .{});
try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{});
// Header row
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
// Withdrawal rows
for (confidence_levels, 0..) |conf, ci| {
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
try out.print("{s}\n", .{wr_rows.amount.text});
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
}
// Life events summary — both as-of and live modes resolve ages
// against the reference date (`resolution.actual` if a snapshot
// was loaded, otherwise `as_of` directly).
{
const events = ctx.config.getEvents();
if (events.len > 0) {
const ages_ref_date = if (resolution) |r| r.actual else as_of;
const ages = ctx.config.currentAges(ages_ref_date);
try out.print("\n", .{});
try cli.printBold(out, color, "Life Events\n", .{});
for (events) |*ev| {
const line = try view.fmtEventLine(va, ev, &ages);
try cli.printIntent(out, color, line.style, "{s}\n", .{line.text});
}
}
}
try out.print("\n", .{});
}
/// Key numbers extracted from a fully-built `ProjectionContext` for
/// email-header headline comparison. Bundles what `zfin compare`'s
/// attribution line needs alongside what the weekly review email's
/// "Projected Return" and "1st Year Withdrawal" rows cite.
pub const KeyMetrics = struct {
/// The "conservative" trailing-returns estimate (MIN 3Y/5Y/10Y
/// per position, weighted). Rendered under the label
/// "Projected return" — matches the email's column header.
projected_return: f64,
/// Safe withdrawal amount at longest horizon × 99% confidence.
/// This is the "retirement now, 1st year withdrawal" number the
/// email cites.
swr_99: f64,
/// `swr_99 / total_value`. Rendered as a percent alongside the
/// dollar amount for comparison sanity.
swr_99_rate: f64,
};
fn extractKeyMetrics(ctx: view.ProjectionContext) KeyMetrics {
const horizons = ctx.config.getHorizons();
const longest = horizons.len - 1;
const swr = ctx.data.withdrawals[ctx.data.ci_99 * horizons.len + longest];
const rate = if (ctx.total_value > 0) swr.annual_amount / ctx.total_value else 0.0;
return .{
.projected_return = ctx.comparison.conservative_return,
.swr_99 = swr.annual_amount,
.swr_99_rate = rate,
};
}
/// Build a `ProjectionContext` against a historical snapshot date.
///
/// Caller owns `snap_bundle_out.*` on success — it must outlive the
/// returned context because allocations borrow symbol strings from
/// the snapshot's backing buffer.
fn loadAsOfContext(
io: std.Io,
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
portfolio_dir: []const u8,
events_enabled: bool,
requested_date: Date,
resolution_out: *AsOfResolution,
snap_bundle_out: *history.LoadedSnapshot,
) !view.ProjectionContext {
resolution_out.* = resolveAsOfSnapshot(io, va, file_path, requested_date) catch |err| return err;
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_bundle_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution_out.actual);
return try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_bundle_out.snap,
resolution_out.actual,
svc,
events_enabled,
);
}
/// `--vs <DATE>` entry point: compare two projections side-by-side
/// with deltas. The "then" side is always a historical snapshot at
/// `vs_date`; the "now" side is either another historical snapshot
/// (when `now_from_snapshot` is true) or the live portfolio at
/// `now_date`.
///
/// Target audience is the weekly review email's header — the
/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns.
/// For the full benchmark table / SWR grid / percentile bands, run
/// `zfin projections` and `zfin projections --as-of <DATE>` separately.
pub fn runCompare(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
events_enabled: bool,
vs_date: Date,
now_date: Date,
now_from_snapshot: bool,
color: bool,
out: *std.Io.Writer,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const result = computeKeyComparison(io, allocator, va, svc, file_path, events_enabled, vs_date, now_date, now_from_snapshot) catch |err| switch (err) {
error.NoSnapshot, error.PortfolioLoadFailed => return,
else => return err,
};
defer result.cleanup();
try out.print("\n", .{});
var then_buf: [10]u8 = undefined;
var now_buf: [10]u8 = undefined;
const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{result.resolution.actual}) catch "????-??-??";
const now_str = if (result.now_resolution) |nr| (std.fmt.bufPrint(&now_buf, "{f}", .{nr.actual}) catch "????-??-??") else "today";
const days_between = if (result.now_resolution) |nr|
nr.actual.days - result.resolution.actual.days
else
now_date.days - result.resolution.actual.days;
try cli.printBold(out, color, "Projections comparison: {s} → {s} ({d} day{s})\n", .{
then_str,
now_str,
days_between,
if (days_between == 1) "" else "s",
});
// Snap notes for either endpoint, if applicable.
if (result.resolution.actual.days != result.resolution.requested.days) {
const diff = result.resolution.requested.days - result.resolution.actual.days;
try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
result.resolution.requested,
then_str,
diff,
if (diff == 1) "" else "s",
});
}
if (result.now_resolution) |nr| {
if (nr.actual.days != nr.requested.days) {
const diff = nr.requested.days - nr.actual.days;
try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
nr.requested,
now_str,
diff,
if (diff == 1) "" else "s",
});
}
}
try out.print("\n", .{});
try renderKeyComparisonRows(out, color, result.then, result.now);
try cli.printFg(out, color, cli.CLR_MUTED, "\nFor the full benchmark + SWR tables run `zfin projections --as-of {s}` and `zfin projections{s}`.\n", .{
then_str,
if (result.now_resolution) |_| (try std.fmt.allocPrint(va, " --as-of {s}", .{now_str})) else "",
});
try out.print("\n", .{});
}
/// Shared key-metrics comparison used by both `projections --vs` and
/// `compare --projections`. Returns `then`/`now` metrics ready for
/// rendering, plus the snapshot resolutions for header rendering.
/// Caller must invoke `cleanup()` to release retained snapshots.
///
/// When `now_from_snapshot` is false (live mode), only `retained_then`
/// is populated. When true, both snapshots are retained and must be
/// cleaned up via `cleanup()`.
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
/// Resolution of the "then" snapshot. Always present.
resolution: AsOfResolution,
/// Resolution of the "now" snapshot. Null when now is live.
now_resolution: ?AsOfResolution,
retained_then: history.LoadedSnapshot,
retained_now: ?history.LoadedSnapshot,
retained_allocator: std.mem.Allocator,
pub fn cleanup(self: KeyComparisonResult) void {
var mut = self;
mut.retained_then.deinit(self.retained_allocator);
if (mut.retained_now) |*s| s.deinit(self.retained_allocator);
}
};
pub fn computeKeyComparison(
io: std.Io,
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
events_enabled: bool,
vs_date: Date,
now_date: Date,
now_from_snapshot: bool,
) !KeyComparisonResult {
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const portfolio_dir = file_path[0..dir_end];
// Load "then" snapshot first. If it doesn't exist we bail before
// doing the (more expensive) "now" side.
var then_resolution: AsOfResolution = undefined;
var then_snap: history.LoadedSnapshot = undefined;
const then_ctx = try loadAsOfContext(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
events_enabled,
vs_date,
&then_resolution,
&then_snap,
);
// Now side — either another snapshot or the live portfolio.
if (now_from_snapshot) {
var now_resolution: AsOfResolution = undefined;
var now_snap: history.LoadedSnapshot = undefined;
const now_ctx = loadAsOfContext(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
events_enabled,
now_date,
&now_resolution,
&now_snap,
) catch |err| {
then_snap.deinit(allocator);
return err;
};
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.resolution = then_resolution,
.now_resolution = now_resolution,
.retained_then = then_snap,
.retained_now = now_snap,
.retained_allocator = allocator,
};
}
// Live "now" side — mirrors `run()`'s live path.
var loaded = cli.loadPortfolio(io, allocator, file_path, now_date) orelse {
then_snap.deinit(allocator);
return error.PortfolioLoadFailed;
};
defer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
for (loaded.positions) |pos| {
if (pos.shares <= 0) continue;
if (svc.getCachedCandles(pos.symbol)) |cs| {
defer cs.deinit();
if (cs.data.len > 0) {
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close);
}
}
}
var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, now_date) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
then_snap.deinit(allocator);
try cli.stderrPrint(io, "Error computing portfolio summary.\n");
return error.PortfolioLoadFailed;
},
else => {
then_snap.deinit(allocator);
return err;
},
};
defer pf_data.deinit(allocator);
const now_ctx = try view.loadProjectionContext(
io,
va,
portfolio_dir,
pf_data.summary.allocations,
pf_data.summary.total_value,
loaded.portfolio.totalCash(now_date),
loaded.portfolio.totalCdFaceValue(now_date),
svc,
events_enabled,
now_date,
);
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.resolution = then_resolution,
.now_resolution = null,
.retained_then = then_snap,
.retained_now = null,
.retained_allocator = allocator,
};
}
/// Render the three comparison rows (projected return, SWR @99%, SWR
/// rate). Shared between `projections --vs` and any other caller that
/// wants to embed the same block (e.g. `compare --projections`).
pub fn renderKeyComparisonRows(
out: *std.Io.Writer,
color: bool,
then: KeyMetrics,
now: KeyMetrics,
) !void {
try renderCompareRowPct(out, color, "Projected return:", then.projected_return, now.projected_return);
try renderCompareRowMoney(out, color, "Safe withdrawal @99%:", then.swr_99, now.swr_99);
try renderCompareRowPct(out, color, " (as % of total)", then.swr_99_rate, now.swr_99_rate);
}
/// Render a "label: then → now Δ" row for percentage values.
fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
const delta = now_val - then_val;
var then_buf: [16]u8 = undefined;
var now_buf: [16]u8 = undefined;
var delta_buf: [16]u8 = undefined;
const then_str = std.fmt.bufPrint(&then_buf, "{d:.2}%", .{then_val * 100.0}) catch "?";
const now_str = std.fmt.bufPrint(&now_buf, "{d:.2}%", .{now_val * 100.0}) catch "?";
const delta_str = std.fmt.bufPrint(&delta_buf, "{s}{d:.2}%", .{ if (delta >= 0) "+" else "", delta * 100.0 }) catch "?";
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str });
try cli.printGainLoss(out, color, delta, "{s: >10}\n", .{delta_str});
}
/// Render a "label: then → now Δ" row for money values.
fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void {
const delta = now_val - then_val;
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label});
try cli.printFg(out, color, cli.CLR_MUTED, "{f} → {f} ", .{
Money.from(then_val).padRight(10),
Money.from(now_val).padRight(10),
});
try cli.printGainLoss(out, color, delta, "{f}\n", .{Money.from(delta).signed().padRight(12)});
}
/// Resolve the user's requested as-of date against the history
/// directory, accepting either a native snapshot or an
/// `imported_values.srf` row.
///
/// Thin adapter over `cli.resolveAsOfOrExplain` — the shared CLI
/// helper owns the exact-then-fallback resolution and the stderr
/// messaging. This wrapper just maps the error set to
/// `error.NoSnapshot` (projections-specific) and packs the source +
/// liquid fields into the local `AsOfResolution` shape.
///
/// Arena-allocates the intermediate `hist_dir` + filename strings;
/// pass a short-lived arena as `va`.
fn resolveAsOfSnapshot(
io: std.Io,
va: std.mem.Allocator,
file_path: []const u8,
requested: Date,
) !AsOfResolution {
const hist_dir = try history.deriveHistoryDir(va, file_path);
const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) {
error.NoDataAtOrBefore => return error.NoSnapshot,
else => |e| {
try cli.stderrPrint(io, "Error resolving as-of: ");
try cli.stderrPrint(io, @errorName(e));
try cli.stderrPrint(io, "\n");
return error.NoSnapshot;
},
};
return .{
.requested = resolved.requested,
.actual = resolved.actual,
.source = resolved.source,
.liquid = resolved.liquid,
};
}
/// Load the merged history timeline (snapshots + imported_values),
/// filter to the [`as_of`, `today`] range, and produce an overlay
/// section. Caller passes an arena allocator so all intermediate
/// allocations are freed at the end of the request.
///
/// Returns null on a missing/empty history dir — that's a soft
/// failure (no overlay rendered, projection still works).
fn loadOverlayActuals(
io: std.Io,
arena: std.mem.Allocator,
file_path: []const u8,
as_of: Date,
today: Date,
) !?view.OverlayActualsSection {
var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) {
// Missing/unreadable history dir → no overlay, no error.
error.FileNotFound, error.NotDir, error.AccessDenied => return null,
else => return err,
};
defer loaded.deinit();
if (loaded.series.points.len == 0) return null;
return try view.buildOverlayActuals(arena, loaded.series.points, as_of, today);
}
/// Write a return row using the view model, applying StyleIntent colors.
fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void {
try out.print("{s: <32}", .{row.label});
try writeCell(out, color, row.one_year, 8);
try writeCell(out, color, row.three_year, 9);
try writeCell(out, color, row.five_year, 9);
try writeCell(out, color, row.ten_year, 10);
try writeCell(out, color, row.week, 9);
try out.print("\n", .{});
}
fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void {
switch (width) {
8 => try cli.printIntent(out, color, cell.style, "{s: >8}", .{cell.text}),
9 => try cli.printIntent(out, color, cell.style, "{s: >9}", .{cell.text}),
10 => try cli.printIntent(out, color, cell.style, "{s: >10}", .{cell.text}),
else => try cli.printIntent(out, color, cell.style, "{s}", .{cell.text}),
}
}
/// Render the "Accumulation phase" block (driven by the user's
/// target retirement date — `retirement_age` / `retirement_at` —
/// or by the promoted cell from the earliest-retirement search when
/// only `target_spending` is configured).
///
/// Always emits the "Years until possible retirement" line — including
/// `none` for the already-retired case, where the entire block reduces
/// to that single line. When a retirement date is configured, the
/// median portfolio at retirement and the p10p90 range follow,
/// computed from the longest-horizon percentile bands.
fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext) !void {
try out.print("\n", .{});
try cli.printBold(out, color, "Accumulation phase:\n", .{});
var line_buf: [128]u8 = undefined;
const parts = view.splitRetirementLine(&line_buf, ctx.retirement, &ctx.config);
// Label stays neutral; only the value (date / "not feasible" /
// "none") carries a style. This keeps "not feasible" loud
// without making the entire row scream red.
try out.print(" {s}", .{parts.label_text});
try cli.printIntent(out, color, parts.value_style, "{s}\n", .{parts.value_text});
// Contribution line — suppressed when both contribution and
// accumulation are zero.
if (try view.fmtContributionLine(va, ctx.config.annual_contribution, ctx.config.contribution_inflation_adjusted, ctx.retirement.accumulation_years)) |contrib| {
try out.print(" {s}\n", .{contrib});
}
// Accumulation-phase stats: median + p10-p90 range at the
// retirement boundary.
if (ctx.accumulation) |acc| {
try out.print(" Median portfolio at retirement: {f}\n", .{Money.from(acc.median_at_retirement).trim()});
try out.print(" Range (10th\u{2013}90th percentile): {f} to {f}\n", .{
Money.from(acc.p10_at_retirement).trim(),
Money.from(acc.p90_at_retirement).trim(),
});
}
}
/// Render the "Earliest retirement" block (driven by the user's
/// target spending — `target_spending`).
///
/// Renders a grid of (confidence × horizon) cells, each showing the
/// earliest retirement date that sustains the target spending at that
/// confidence over that distribution horizon, or "—" when not feasible
/// within `max_accumulation_years`.
fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, ctx: view.ProjectionContext, as_of: zfin.Date) !void {
const earliest = ctx.earliest orelse return;
const target = ctx.config.target_spending orelse return;
try out.print("\n", .{});
const adj: []const u8 = if (ctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal";
try cli.printBold(out, color, "Earliest retirement (target spending: {f}/yr {s})\n", .{ Money.from(target).trim(), adj });
const horizons = ctx.config.getHorizons();
const confs = ctx.config.getConfidenceLevels();
// Header row: blank label space + horizon column headers.
const cell_width: usize = 14;
const label_width: usize = 25;
{
var hdr: std.ArrayListUnmanaged(u8) = .empty;
try hdr.appendNTimes(va, ' ', label_width);
for (horizons) |h| {
var hbuf: [16]u8 = undefined;
const hlabel = view.fmtHorizonLabel(&hbuf, h);
try hdr.appendNTimes(va, ' ', cell_width -| hlabel.len);
try hdr.appendSlice(va, hlabel);
}
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{hdr.items});
}
for (confs, 0..) |conf, ci| {
const row = try view.buildEarliestRow(va, conf, horizons, earliest, ci, as_of);
// Label
try out.print(" {s}", .{row.label_text});
// Pad label to label_width (we wrote 2 leading spaces + label, so pad to label_width - 2).
const pad = if (label_width > 2 + row.label_text.len) label_width - 2 - row.label_text.len else 0;
for (0..pad) |_| try out.print(" ", .{});
// Cells
for (row.cells) |cell| {
const cellpad = if (cell_width > cell.text.len) cell_width - cell.text.len else 0;
for (0..cellpad) |_| try out.print(" ", .{});
try cli.printIntent(out, color, cell.style, "{s}", .{cell.text});
}
try out.print("\n", .{});
}
}
// ── Tests ────────────────────────────────────────────────────
//
// The projections simulation and rendering are covered by the
// view-model tests in `src/views/projections.zig` and the analytics
// tests in `src/analytics/`. These tests focus on the CLI-surface
// behaviour that `run` is responsible for: as-of snapshot resolution,
// exact/nearest/miss branching, and error reporting.
const testing = std.testing;
const snapshot_model = @import("../models/snapshot.zig");
const snapshot = @import("snapshot.zig");
fn makeTestSvc() zfin.DataService {
const config = zfin.Config{ .cache_dir = "/tmp" };
return zfin.DataService.init(std.testing.io, testing.allocator, config);
}
fn writeFixtureSnapshot(
io: std.Io,
dir: std.Io.Dir,
allocator: std.mem.Allocator,
filename: []const u8,
as_of: Date,
liquid: f64,
) !void {
const lots = [_]snapshot_model.LotRow{
.{
.kind = "lot",
.symbol = "VTI",
.lot_symbol = "VTI",
.account = "Roth",
.security_type = "Stock",
.shares = 100,
.open_price = 200,
.cost_basis = 20_000,
.value = liquid,
.price = liquid / 100,
.quote_date = as_of,
},
};
const totals = [_]snapshot_model.TotalRow{
.{ .kind = "total", .scope = "net_worth", .value = liquid },
.{ .kind = "total", .scope = "liquid", .value = liquid },
.{ .kind = "total", .scope = "illiquid", .value = 0 },
};
const snap: snapshot_model.Snapshot = .{
.meta = .{
.kind = "meta",
.snapshot_version = 1,
.as_of_date = as_of,
.captured_at = 1_745_222_400,
.zfin_version = "test",
.stale_count = 0,
},
.totals = @constCast(&totals),
.tax_types = &.{},
.accounts = &.{},
.lots = @constCast(&lots),
};
const rendered = try snapshot.renderSnapshot(allocator, snap);
defer allocator.free(rendered);
try dir.writeFile(io, .{ .sub_path = filename, .data = rendered });
}
/// Build a portfolio path inside `tmp` and return the joined string.
/// Caller owns the returned buffer.
fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 {
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
}
test "resolveAsOfSnapshot: exact match returns actual == requested" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
const d = Date.fromYmd(2026, 3, 13);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const res = try resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, d);
try testing.expect(res.actual.eql(d));
try testing.expect(res.requested.eql(d));
}
test "resolveAsOfSnapshot: no exact match snaps to earlier" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
const earlier = Date.fromYmd(2026, 3, 12);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-12-portfolio.srf", earlier, 1_000_000);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const requested = Date.fromYmd(2026, 3, 13);
const res = try resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested);
try testing.expect(res.actual.eql(earlier));
try testing.expect(res.requested.eql(requested));
try testing.expect(!res.actual.eql(res.requested));
}
test "resolveAsOfSnapshot: no earlier snapshot returns NoSnapshot" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
// Only a later snapshot exists — can't satisfy an earlier request.
const later = Date.fromYmd(2026, 4, 1);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const requested = Date.fromYmd(2026, 3, 13);
const result = resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested);
try testing.expectError(error.NoSnapshot, result);
}
test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(io, "history");
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const requested = Date.fromYmd(2026, 3, 13);
const result = resolveAsOfSnapshot(std.testing.io, arena.allocator(), pf, requested);
try testing.expectError(error.NoSnapshot, result);
}
test "run: as_of with no snapshots returns without error (stderr-only)" {
const io = std.testing.io;
// No history dir at all. `run` prints a stderr hint via
// `resolveAsOfSnapshot` and returns — should NOT propagate the
// error to the caller (exit code stays 0 from the CLI dispatch).
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
const d = Date.fromYmd(2026, 3, 13);
try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream);
// No body output because the resolution failed — the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in
// `stream`. This guarantees the error-path returns cleanly.
const out = stream.buffered();
try testing.expectEqual(@as(usize, 0), out.len);
}
test "run: as_of with matching snapshot produces body output" {
const io = std.testing.io;
// End-to-end smoke test. With no cached candles, benchmark rows
// will be `--` and portfolio returns will be empty, but the
// rendering pipeline should still produce a complete header +
// tables without panicking.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
const d = Date.fromYmd(2026, 3, 13);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream);
const out = stream.buffered();
// Header should call out the as-of date explicitly.
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-13") != null);
// Benchmark + withdrawal tables still render even with missing candles.
try testing.expect(std.mem.indexOf(u8, out, "Safe Withdrawal") != null);
try testing.expect(std.mem.indexOf(u8, out, "Terminal Portfolio Value") != null);
}
test "run: as_of auto-snap surfaces muted 'nearest' note" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
const actual = Date.fromYmd(2026, 3, 12);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2026-03-12-portfolio.srf", actual, 1_000_000);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
const requested = Date.fromYmd(2026, 3, 13);
try run(io, testing.allocator, &svc, pf, false, requested, true, requested, false, false, &stream);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null);
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null);
// 1 day earlier → singular "day", not "days"
try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
}
test "renderCompareRowPct: positive delta renders with + sign" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderCompareRowPct(&w, false, "Stocks", 0.50, 0.65);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Stocks") != null);
try testing.expect(std.mem.indexOf(u8, out, "50.00%") != null);
try testing.expect(std.mem.indexOf(u8, out, "65.00%") != null);
try testing.expect(std.mem.indexOf(u8, out, "+15.00%") != null);
}
test "renderCompareRowPct: negative delta has no + sign" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderCompareRowPct(&w, false, "Bonds", 0.40, 0.30);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "40.00%") != null);
try testing.expect(std.mem.indexOf(u8, out, "30.00%") != null);
try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null);
}
test "renderCompareRowMoney: positive delta" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderCompareRowMoney(&w, false, "Net Worth", 100_000, 110_000);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Net Worth") != null);
try testing.expect(std.mem.indexOf(u8, out, "$100,000") != null);
try testing.expect(std.mem.indexOf(u8, out, "$110,000") != null);
}
test "renderCompareRowMoney: zero delta" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderCompareRowMoney(&w, false, "Cash", 50_000, 50_000);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
try testing.expect(std.mem.indexOf(u8, out, "$50,000") != null);
}
test "renderCompareRowPct: no ANSI when color=false" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderCompareRowPct(&w, false, "X", 0.1, 0.2);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}