zfin/src/commands/projections.zig

1915 lines
79 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 framework = @import("framework.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");
const imported = @import("../data/imported_values.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
/// Tagged-union args for the four projection sub-modes. Mutually-
/// exclusive flag combos (--convergence with --vs, --real with
/// non-backtest, etc.) are rejected at parse time so each variant
/// here is a self-contained mode.
pub const ParsedArgs = union(enum) {
/// Default: percentile bands view. `as_of` selects the snapshot
/// (or today for live). `overlay_actuals` plots realized
/// trajectory on top.
bands: BandsArgs,
/// `--vs <DATE>`: side-by-side compare of two projections.
compare: CompareArgs,
/// `--convergence`: plot the spreadsheet's predicted retirement
/// date over time. No knobs.
convergence,
/// `--return-backtest [--real]`: plot expected_return vs realized
/// forward-CAGR.
return_backtest: struct { real: bool },
};
pub const BandsArgs = struct {
events_enabled: bool = true,
/// `null` means live (today). Non-null = historical snapshot.
as_of: ?Date = null,
overlay_actuals: bool = false,
/// When set, render the percentile-band chart (with optional
/// overlay) as a PNG to this path and exit. No text output.
/// Only supported in the default bands mode; --convergence,
/// --return-backtest, and --vs reject the flag at parse time.
export_chart: ?[]const u8 = null,
};
pub const CompareArgs = struct {
events_enabled: bool = true,
vs_date: Date,
/// "Now" side. Null = today (live); non-null = the `--as-of`
/// date the user paired with `--vs`.
as_of: ?Date = null,
};
pub const meta: framework.Meta = .{
.name = "projections",
.group = .portfolio,
.synopsis = "Retirement projections, benchmark comparison, percentile bands",
.help =
\\Usage: zfin projections [opts]
\\
\\Default mode: percentile-bands view of the portfolio's
\\projected value over the configured horizon (`projections.srf`),
\\plus benchmark comparison (SPY/AGG) and safe-withdrawal
\\dollars at multiple horizons / confidence levels.
\\
\\Three alternate sub-modes (mutually exclusive):
\\ --vs <DATE> Side-by-side compare with a
\\ historical snapshot's projection.
\\ --convergence Plot the model's predicted
\\ retirement date over time as data
\\ accumulated.
\\ --return-backtest Plot expected_return claim over
\\ time alongside realized forward
\\ CAGR. Pair with `--real` for
\\ CPI-adjusted dollars.
\\
\\Options:
\\ --no-events Exclude life events from the
\\ simulation (baseline view).
\\ --as-of <DATE> Compute against a historical
\\ snapshot. Auto-snaps to the
\\ nearest-earlier snapshot.
\\ --overlay-actuals Plot realized portfolio trajectory
\\ from --as-of through today on top
\\ of the percentile bands. Requires
\\ --as-of. Ignored under --vs.
\\ --vs <DATE> (see above)
\\ --convergence (see above)
\\ --return-backtest (see above)
\\ --real With --return-backtest, render in
\\ CPI-adjusted dollars.
\\ --export-chart <PATH> Render the percentile-band chart
\\ (with optional overlay if
\\ --overlay-actuals is set) as a PNG
\\ to PATH (1920x1080) and exit. Only
\\ valid in the default bands mode.
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, MutuallyExclusive, NoSnapshot, PortfolioLoadFailed },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
const io = ctx.io;
const today = ctx.today;
var events_enabled = true;
var as_of: ?Date = null;
var vs_date: ?Date = null;
var overlay_actuals = false;
var convergence = false;
var return_backtest = false;
var real_mode = false;
var export_chart: ?[]const u8 = null;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
if (std.mem.eql(u8, a, "--no-events")) {
events_enabled = false;
} else if (std.mem.eql(u8, a, "--overlay-actuals")) {
overlay_actuals = true;
} else if (std.mem.eql(u8, a, "--convergence")) {
convergence = true;
} else if (std.mem.eql(u8, a, "--return-backtest")) {
return_backtest = true;
} else if (std.mem.eql(u8, a, "--real")) {
real_mode = true;
} else if (std.mem.eql(u8, a, "--export-chart")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n");
return error.MissingFlagValue;
}
export_chart = cmd_args[i + 1];
i += 1;
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
return error.MissingFlagValue;
}
const value = cmd_args[i + 1];
const parsed_date = cli.parseAsOfDate(value, today) catch |err| {
var buf: [256]u8 = undefined;
const msg = cli.fmtAsOfParseError(&buf, value, err);
try cli.stderrPrint(io, msg);
try cli.stderrPrint(io, "\n");
return error.InvalidFlagValue;
};
if (parsed_date) |d| {
if (d.days > today.days) {
try cli.stderrPrint(io, "Error: date is in the future.\n");
return error.InvalidFlagValue;
}
if (std.mem.eql(u8, a, "--as-of")) {
as_of = d;
} else {
vs_date = d;
}
}
// null (= "live") is ignored — leaves flag unset, same
// as not passing the flag at all.
i += 1;
} else {
try cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\n");
return error.UnexpectedArg;
}
}
// Mutually-exclusive view flags. Forecast-evaluation flags
// (`--convergence`, `--return-backtest`) replace the default
// bands view entirely; combining them with each other,
// `--vs`, or `--overlay-actuals` is rejected.
if (convergence and return_backtest) {
try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
return error.MutuallyExclusive;
}
if ((convergence or return_backtest) and vs_date != null) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
return error.MutuallyExclusive;
}
if ((convergence or return_backtest) and overlay_actuals) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
return error.MutuallyExclusive;
}
if (real_mode and !return_backtest) {
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive;
}
// Chart export only meaningful in default bands mode. The
// forecast-evaluation views (convergence, return-backtest)
// render via `forecast_chart.zig` which doesn't have a PNG
// export path yet; --vs is text-only with no chart at all.
if (export_chart != null and (convergence or return_backtest or vs_date != null)) {
try cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n");
return error.MutuallyExclusive;
}
if (convergence) return ParsedArgs{ .convergence = {} };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode } };
if (vs_date) |d| {
return ParsedArgs{ .compare = .{
.events_enabled = events_enabled,
.vs_date = d,
.as_of = as_of,
} };
}
return ParsedArgs{ .bands = .{
.events_enabled = events_enabled,
.as_of = as_of,
.overlay_actuals = overlay_actuals,
.export_chart = export_chart,
} };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const today = ctx.today;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const file_path = pf.path;
switch (parsed) {
.convergence => try runConvergence(io, allocator, file_path, color, out),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, color, out),
.compare => |args| {
const svc = ctx.svc orelse return error.MissingDataService;
try runCompare(
io,
allocator,
svc,
file_path,
.{
.events_enabled = args.events_enabled,
.vs_date = args.vs_date,
.now_date = args.as_of orelse today,
.now_from_snapshot = args.as_of != null,
.refresh = ctx.globals.refresh_policy,
},
color,
out,
);
},
.bands => |args| {
const svc = ctx.svc orelse return error.MissingDataService;
try runBands(
io,
allocator,
svc,
file_path,
.{
.events_enabled = args.events_enabled,
.as_of = args.as_of orelse today,
.from_snapshot = args.as_of != null,
.today = today,
.overlay_actuals = args.overlay_actuals,
.refresh = ctx.globals.refresh_policy,
.export_chart = args.export_chart,
},
color,
out,
);
},
}
}
/// 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 percentile-bands projection (the default mode).
///
/// `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.
/// Per-call configuration for `runBands`. Bundled because the
/// call already had nine context-plus-config parameters and adding
/// `refresh` would push it past the readable-positional threshold.
/// Same rationale as `KeyComparisonOptions` — see its doc-block.
pub const BandsOptions = struct {
/// Whether simulated lifecycle events (RMDs, lump-sum
/// withdrawals, Social Security) are baked into the
/// projection.
events_enabled: bool,
/// Reference date for the projection. When `from_snapshot`
/// is false this also doubles as "today" for cash/CD
/// computation paths that resolve from the live portfolio.
as_of: Date,
/// True when `as_of` came from `--as-of`. Selects the
/// historical-snapshot resolution path; otherwise the
/// live-portfolio path is used.
from_snapshot: bool,
/// The actual current calendar day. Used for the live-side
/// composition and cash totals when `from_snapshot` is
/// false. May equal `as_of` in the live case.
today: Date,
/// Whether to overlay an actual-history series on the
/// projection percentile bands.
overlay_actuals: bool,
/// Refresh policy threaded through the live "now" price
/// load. Has no effect when `from_snapshot = true`.
refresh: framework.RefreshPolicy = .auto,
/// When set, render the percentile-band chart (with optional
/// overlay) to a PNG at this path and exit before any text
/// output renders. See `chart_export.exportProjectionChart`.
export_chart: ?[]const u8 = null,
};
pub fn runBands(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
opts: BandsOptions,
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 (opts.from_snapshot) {
resolution = resolveAsOfSnapshot(io, va, file_path, opts.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,
opts.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.loadPortfolioFromFile(io, allocator, file_path, opts.today) orelse return;
const lp = &live_loaded.?;
// Route through the shared loader so `--refresh-data`
// propagates here. Pre-bundle this was a silent
// cache-only loop; behavior change is intentional.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
{
var load_result = cli.loadPortfolioPrices(io, svc, lp.syms, &.{}, opts.refresh, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.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(opts.today),
lp.portfolio.totalCdFaceValue(opts.today),
resolution.?.liquid,
resolution.?.actual,
svc,
opts.events_enabled,
);
}
} else {
live_loaded = cli.loadPortfolioFromFile(io, allocator, file_path, opts.as_of) orelse return;
const lp = &live_loaded.?;
// Route through the shared loader so `--refresh-data`
// propagates here. Pre-bundle this was a silent
// cache-only loop; behavior change is intentional.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
{
var load_result = cli.loadPortfolioPrices(io, svc, lp.syms, &.{}, opts.refresh, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.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(opts.as_of),
lp.portfolio.totalCdFaceValue(opts.as_of),
svc,
opts.events_enabled,
opts.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 (opts.overlay_actuals) {
if (!opts.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, opts.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;
};
}
}
// ── PNG export short-circuit ─────────────────────────────────
//
// When --export-chart is set, render the percentile-band chart
// (with overlay if loaded) to the requested PNG path and exit
// before any text output. Uses the longest configured horizon —
// matching what the TUI shows by default.
if (opts.export_chart) |export_path| {
const horizons_ec = ctx.config.getHorizons();
if (horizons_ec.len == 0) {
try cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n");
return;
}
const last_idx_ec = horizons_ec.len - 1;
const bands_ec = ctx.data.bands[last_idx_ec] orelse {
try cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
return;
};
// Translate the view-layer overlay points (if any) into the
// chart-module's ActualsPoint shape. Same conversion the TUI
// does in `projections_tab.drawWithKittyChart`.
var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
defer if (overlay_buf) |ob| va.free(ob);
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
for (ov.points, 0..) |p, i| {
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
}
overlay_buf = buf;
break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
.points = buf,
.today_years = ov.today_years,
};
};
chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) {
error.InsufficientData => {
try cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
return;
},
else => {
try cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
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", .{});
// Section title. Includes a methodology note so a reader
// comparing these numbers against the `history` tab's window
// table doesn't get tripped up by the (legitimate)
// disagreement: this table reports each row as a weighted
// price-only return per period (per-symbol price change ×
// current weight, summed) so SPY/AGG/Benchmark/Your
// Portfolio rows are apples-to-apples; history's window
// table reports snapshot-to-snapshot Liquid value deltas
// that include contributions, withdrawals, and weight drift.
try cli.setBold(out, color);
try out.print("Benchmark comparison (price-only weighted return)\n", .{});
try cli.reset(out, color);
// 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, ctx.config.benchmark_stock, 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, ctx.config.benchmark_bond, 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, opts.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 (opts.overlay_actuals and opts.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 opts.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,
/// Longest configured horizon, in years. Used for the "this is
/// at the {N}-year horizon" caption above the comparison block
/// so the reader doesn't have to remember which horizon
/// `swr_99` refers to. Comes from `ctx.config.getHorizons()`.
horizon_years: u16,
};
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,
.horizon_years = horizons[longest],
};
}
/// 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.
///
/// Imported-only resolutions (where the requested date predates any
/// real snapshot but is covered by `imported_values.srf`) are NOT
/// supported here: the imported-only path needs live-portfolio
/// composition plumbed through additional outparams that this helper
/// doesn't expose. Callers that hit this case get `error.NoSnapshot`
/// after a clear stderr message, mirroring the user-visible behavior
/// of "no snapshot at that date." See the `--vs` follow-up TODO for
/// the parity work that would make this branch fully supported.
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;
if (resolution_out.source != .snapshot) {
// Imported-only resolution: no snapshot file exists at the
// resolved date, so `loadSnapshotAt` would crash with
// FileNotFound. Bail with a clear message instead.
try cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n");
return error.NoSnapshot;
}
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,
opts: KeyComparisonOptions,
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, opts) 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
opts.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, result.events_enabled);
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", .{});
}
// ── Forecast-vs-actual evaluation views ──────────────────────
/// Horizons used by `--return-backtest`. 1y/3y/5y per the
/// V1 spec; 10y is a polish-pass addition.
const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
/// `zfin projections --convergence` entry point. Renders a
/// summary table of `(observation_date, projected_date,
/// years_until)` from the imported spreadsheet history. The CLI
/// is intentionally table-based — the high-fidelity chart lives
/// on the TUI projections tab.
///
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
/// loaded via `data.imported_values.loadImportedValues`. Missing
/// file is treated as "no historical data," not an error: the
/// command emits a one-line note and returns successfully.
///
/// Caveat (per spec): this view shows whether the model was
/// directionally honest about retirement timing. It does NOT
/// validate the SWR claim itself — that's a 30-year claim we
/// can't validate within either of our lifetimes.
pub fn runConvergence(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
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 hist_dir = try history.deriveHistoryDir(va, file_path);
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
const points = try forecast.convergencePoints(va, iv.points);
const lines = try view.convergenceLines(va, points);
try renderForecastLines(out, color, lines);
}
/// `zfin projections --return-backtest [--real]` entry point.
/// Renders a summary table comparing the spreadsheet's
/// `expected_return` claim to realized 1y/3y/5y forward CAGR
/// for each anchor row.
///
/// When `real_mode` is true, the realized CAGR is computed
/// against inflation-deflated `liquid` values (Shiller annual
/// CPI). The expected_return column is left as-is (it's a return
/// rate, not a level — but it's a nominal return as captured by
/// the source spreadsheet, which means real-mode is comparing
/// nominal-claim against real-realized; useful but watch the
/// caveat in the output).
pub fn runReturnBacktest(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
real_mode: 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 hist_dir = try history.deriveHistoryDir(va, file_path);
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
// Build the YearCpi slice from Shiller's annual_returns. Same
// shape `milestones.deflate` accepts.
var cpi_list: std.ArrayList(milestones.YearCpi) = .empty;
defer cpi_list.deinit(va);
for (shiller.annual_returns) |yr| {
try cpi_list.append(va, .{ .year = yr.year, .cpi = yr.cpi_inflation });
}
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
const anchors = try forecast.pivotByAnchor(va, rows);
const lines = try view.backtestLines(va, anchors, real_mode);
try renderForecastLines(out, color, lines);
}
/// Emit `view.ForecastLine`s through the CLI's ANSI styling
/// helpers. Shared by `runConvergence` and `runReturnBacktest` so
/// the bold/intent → ANSI mapping lives in exactly one place.
fn renderForecastLines(
out: *std.Io.Writer,
color: bool,
lines: []const view.ForecastLine,
) !void {
for (lines) |ln| {
if (ln.bold) try cli.setBold(out, color);
try cli.setStyleIntent(out, color, ln.intent);
try out.print("{s}\n", .{ln.text});
try cli.reset(out, color);
}
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,
/// Whether simulated lifecycle events (RMDs, lump-sum
/// withdrawals, Social Security) were included in the
/// projection. Captured here so the comparison-row caption
/// can tell the reader what assumptions are baked in.
events_enabled: bool,
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);
}
};
/// Per-call configuration for `computeKeyComparison`. Bundled into
/// a struct because the call already had eight context-plus-config
/// parameters and adding `refresh` would push it to ten — past the
/// point where positional args are readable. Required fields have
/// no defaults; optional knobs (currently just `refresh`) carry
/// sensible defaults so most callers can leave them out.
pub const KeyComparisonOptions = struct {
/// Whether simulated lifecycle events (RMDs, lump-sum
/// withdrawals, Social Security) are baked into the
/// projection. The "then" and "now" sides both honor this.
events_enabled: bool,
/// The earlier date — historical snapshot resolution.
vs_date: Date,
/// The later date — either live or another snapshot,
/// controlled by `now_from_snapshot`.
now_date: Date,
/// When true, both sides resolve from snapshots. When
/// false, the "now" side loads the live portfolio and
/// fetches current prices.
now_from_snapshot: bool,
/// Refresh policy threaded through the live "now" price
/// load. Has no effect when `now_from_snapshot = true`
/// (snapshots don't fetch prices).
refresh: framework.RefreshPolicy = .auto,
};
/// Compute the "then" vs "now" key metrics for `--vs` and the
/// `compare --projections` embedded block.
///
/// Output-equivalence invariant: this function MUST produce
/// identical `KeyMetrics` to what standalone `projections` /
/// `projections --as-of` produce for the same dates. Both paths
/// resolve the same way:
///
/// - `then` (snapshot): `loadAsOfContext` →
/// `view.loadProjectionContextAsOf(...)` is the same call
/// standalone `projections --as-of` makes at line ~110.
/// - `now` (live): the `cli.loadPortfolio` →
/// `cli.buildPortfolioData` → `view.loadProjectionContext`
/// pipeline below mirrors standalone `projections` (no flags)
/// at lines ~167-202.
///
/// If you change inputs to either of these loaders, change them
/// in BOTH places. A drift between this path and standalone
/// projections will show as `compare --projections` reporting
/// different numbers from what `projections --as-of <DATE>` and
/// `projections` print, which has bitten us before.
pub fn computeKeyComparison(
io: std.Io,
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
opts: KeyComparisonOptions,
) !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,
opts.events_enabled,
opts.vs_date,
&then_resolution,
&then_snap,
);
// Now side — either another snapshot or the live portfolio.
if (opts.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,
opts.events_enabled,
opts.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,
.events_enabled = opts.events_enabled,
.retained_then = then_snap,
.retained_now = now_snap,
.retained_allocator = allocator,
};
}
// Live "now" side — mirrors `run()`'s live path.
var loaded = cli.loadPortfolioFromFile(io, allocator, file_path, opts.now_date) orelse {
then_snap.deinit(allocator);
return error.PortfolioLoadFailed;
};
defer loaded.deinit(allocator);
// Route through the shared loader so `--refresh-data` propagates
// here too. Pre-bundle this was a silent cache-only loop, which
// diverged from every other multi-symbol command. Watchlist syms
// aren't relevant for projections so we pass an empty slice.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
{
var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, opts.refresh, false);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
}
var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, opts.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(opts.now_date),
loaded.portfolio.totalCdFaceValue(opts.now_date),
svc,
opts.events_enabled,
opts.now_date,
);
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.resolution = then_resolution,
.now_resolution = null,
.events_enabled = opts.events_enabled,
.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`).
/// Render the three "then → now" comparison rows (projected return,
/// SWR @99% dollars, SWR @99% rate) for the `--vs` and
/// `compare --projections` outputs.
///
/// The leading caption ("{N}-year horizon, lifecycle events
/// {included|excluded}") tells the reader which assumptions are
/// baked in: lots of numbers crowd the report, and "Safe
/// withdrawal @99%" alone is ambiguous about which horizon's SWR
/// is being shown (the TUI's full table renders both 30-year and
/// 43-year columns, but this compact view only shows the longest
/// horizon). Surface the context so a reader scanning the
/// numbers doesn't have to remember.
pub fn renderKeyComparisonRows(
out: *std.Io.Writer,
color: bool,
then: KeyMetrics,
now: KeyMetrics,
events_enabled: bool,
) !void {
// `then` and `now` are computed against the same projections.srf
// (REPORT.md §4 — the "then" side reuses today's config), so
// their horizons agree. Use whichever side is convenient.
const events_label: []const u8 = if (events_enabled) "included" else "excluded";
try cli.printFg(out, color, cli.CLR_MUTED, " ({d}-year horizon, lifecycle events {s})\n", .{ now.horizon_years, events_label });
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 ────────────────────────────────────────────────────
const testing = std.testing;
fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
var ctx: framework.RunCtx = .{
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
.globals = .{},
.today = today,
.now_s = 0,
.color = false,
.out = undefined,
};
return parseArgs(&ctx, args);
}
test "parseArgs: empty → bands variant with defaults" {
const today = Date.fromYmd(2026, 5, 9);
const parsed = try parseArgsForTest(today, &.{});
switch (parsed) {
.bands => |b| {
try testing.expect(b.events_enabled);
try testing.expect(b.as_of == null);
try testing.expect(!b.overlay_actuals);
},
else => try testing.expect(false),
}
}
test "parseArgs: --as-of populates bands.as_of" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--as-of", "2026-04-01" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.bands => |b| try testing.expect(b.as_of.?.eql(Date.fromYmd(2026, 4, 1))),
else => try testing.expect(false),
}
}
test "parseArgs: --no-events disables events on bands" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--no-events"};
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.bands => |b| try testing.expect(!b.events_enabled),
else => try testing.expect(false),
}
}
test "parseArgs: --vs produces compare variant" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--vs", "2026-04-01" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.compare => |c| try testing.expect(c.vs_date.eql(Date.fromYmd(2026, 4, 1))),
else => try testing.expect(false),
}
}
test "parseArgs: --vs + --as-of carries both into compare variant" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--vs", "2026-03-01", "--as-of", "2026-04-01" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.compare => |c| {
try testing.expect(c.vs_date.eql(Date.fromYmd(2026, 3, 1)));
try testing.expect(c.as_of.?.eql(Date.fromYmd(2026, 4, 1)));
},
else => try testing.expect(false),
}
}
test "parseArgs: --convergence produces convergence variant" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--convergence"};
const parsed = try parseArgsForTest(today, &args);
try testing.expectEqual(std.meta.Tag(ParsedArgs).convergence, std.meta.activeTag(parsed));
}
test "parseArgs: --return-backtest produces return_backtest variant" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--return-backtest"};
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.return_backtest => |rb| try testing.expect(!rb.real),
else => try testing.expect(false),
}
}
test "parseArgs: --return-backtest --real" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--return-backtest", "--real" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.return_backtest => |rb| try testing.expect(rb.real),
else => try testing.expect(false),
}
}
test "parseArgs: --convergence + --return-backtest mutually exclusive" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--convergence", "--return-backtest" };
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}
test "parseArgs: --convergence + --vs rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--convergence", "--vs", "2026-04-01" };
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}
test "parseArgs: --real without --return-backtest rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--real"};
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}
test "parseArgs: future --as-of rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--as-of", "2027-01-01" };
try testing.expectError(error.InvalidFlagValue, parseArgsForTest(today, &args));
}
test "parseArgs: unknown flag errors" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--bogus"};
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}
test "parseArgs: --overlay-actuals carries into bands" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--as-of", "2026-04-01", "--overlay-actuals" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.bands => |b| try testing.expect(b.overlay_actuals),
else => try testing.expect(false),
}
}
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 runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = 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 runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = 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 runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = 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);
}