zfin/src/commands/projections.zig

2510 lines
102 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 view = @import("../views/projections.zig");
const history = @import("../history.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");
const projection_chart = @import("../charts/projection_chart.zig");
const projections = @import("../analytics/projections.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const compare_chart = @import("../charts/compare_chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.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. `export_chart` renders a PNG instead of the
/// text table.
convergence: struct { export_chart: ?[]const u8 = null },
/// `--return-backtest [--real]`: plot expected_return vs realized
/// forward-CAGR. `export_chart` renders a PNG instead of text.
return_backtest: struct { real: bool, export_chart: ?[]const u8 = null },
};
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,
/// When set, render the side-by-side comparison overlay as a PNG
/// to this path and exit. No text output.
export_chart: ?[]const u8 = 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 current mode's chart as a
\\ PNG to PATH (1920x1080) and exit.
\\ Works in all modes: the default bands
\\ view (with the overlay if
\\ --overlay-actuals is set), --convergence,
\\ --return-backtest, and --vs.
\\
\\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")) {
export_chart = try cli.requireFlagValue(io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) {
cli.stderrPrint(io, "Error: ");
cli.stderrPrint(io, a);
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);
cli.stderrPrint(io, msg);
cli.stderrPrint(io, "\n");
return error.InvalidFlagValue;
};
if (parsed_date) |d| {
if (d.days > today.days) {
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 {
cli.stderrPrint(io, "Error: unexpected argument to 'projections': ");
cli.stderrPrint(io, a);
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) {
cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
return error.MutuallyExclusive;
}
if ((convergence or return_backtest) and vs_date != null) {
cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
return error.MutuallyExclusive;
}
if ((convergence or return_backtest) and overlay_actuals) {
cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
return error.MutuallyExclusive;
}
if (real_mode and !return_backtest) {
cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return error.MutuallyExclusive;
}
// The actuals overlay plots the realized trajectory from `--as-of`
// through today; without an as-of anchor there's nothing to plot.
// Matches the TUI, which refuses the overlay without an as-of date.
// (Under `--vs` the overlay is ignored rather than required, so
// that combination is left alone.)
if (overlay_actuals and as_of == null and vs_date == null) {
cli.stderrPrint(io, "Error: --overlay-actuals requires --as-of.\n");
return error.MutuallyExclusive;
}
if (convergence) return ParsedArgs{ .convergence = .{ .export_chart = export_chart } };
if (return_backtest) return ParsedArgs{ .return_backtest = .{ .real = real_mode, .export_chart = export_chart } };
if (vs_date) |d| {
return ParsedArgs{ .compare = .{
.events_enabled = events_enabled,
.vs_date = d,
.as_of = as_of,
.export_chart = export_chart,
} };
}
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;
// Inline kitty charts when the terminal supports it (or `--chart
// kitty` forces it). No braille fallback for the projection-family
// charts - non-kitty terminals get table-only output. Shared by the
// bands, convergence, and return-backtest modes.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
switch (parsed) {
.convergence => |args| try runConvergence(io, allocator, file_path, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
.return_backtest => |args| try runReturnBacktest(io, allocator, file_path, args.real, args.export_chart, color, out, kitty_caps, ctx.chart_theme),
.compare => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load today's live composition only when it's
// actually needed: either the "now" side is live, or one
// of the endpoints resolves to an imported-only date (no
// native snapshot) and we must scale today's composition
// to its liquid total. `anyImportedOnly` is a disk-only
// probe - it spends no network/rate-limit budget - so the
// common snapshot-vs-snapshot compare still skips the
// live price fetch.
const now_is_live = args.as_of == null;
const need_live = now_is_live or
anyImportedOnly(io, allocator, file_path, args.vs_date, args.as_of orelse today);
var live: ?LiveData = null;
defer if (live) |*l| l.deinit(allocator);
if (need_live) {
live = try loadLiveData(ctx, today, color);
}
try runCompare(
ctx,
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,
.today = today,
.live = if (live) |*l| l else null,
},
kitty_caps,
args.export_chart,
);
},
.bands => |args| {
_ = ctx.svc orelse return error.MissingDataService;
// Pre-load live data unconditionally for the bands view.
// The live (no `--as-of`) path needs it for the simulation;
// the imported-only as-of branch (when `--as-of <DATE>`
// resolves to an imported value rather than a snapshot)
// needs today's allocations to scale to the imported
// total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator);
try runBands(
io,
allocator,
ctx.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,
.export_chart = args.export_chart,
.live = if (live) |*l| l else null,
.chart_theme = ctx.chart_theme,
},
color,
out,
kitty_caps,
);
},
}
}
/// 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.
/// Pre-loaded live-portfolio data used by `runBands` and
/// `computeKeyComparison`. The caller (typically `run()`) loads
/// this via `cli.loadPortfolio(ctx, today)` so the multi-file
/// union-merge path is always taken - matching what the TUI sees
/// and what every other CLI command sees.
///
/// Loading lives in the caller (not in `runBands` /
/// `computeKeyComparison`) so the helpers can't accidentally take
/// a single-file path. They consume what the caller passes in;
/// the union-merge contract is enforced at one site.
///
/// `prices` is retained alongside the summary so callers that
/// need per-symbol price lookups (e.g. `compare`'s LiveSide via
/// `compare_core.aggregateLiveStocks`) can borrow without
/// re-fetching.
///
/// Caller owns the fields and must call `deinit` after the helper
/// returns.
pub const LiveData = struct {
loaded: cli.LoadedPortfolio,
pf_data: cli.PortfolioData,
prices: std.StringHashMap(f64),
pub fn deinit(self: *LiveData, allocator: std.mem.Allocator) void {
self.prices.deinit();
self.pf_data.deinit(allocator);
self.loaded.deinit(allocator);
}
};
/// Load the live portfolio (multi-file union-merge), fetch prices
/// per the resolved refresh policy, and build the
/// summary/candle-map bundle. Mirrors what every other CLI command
/// does via `cli.loadPortfolio` + `cli.loadPortfolioPrices` +
/// `cli.buildPortfolioData`.
///
/// Returns `null` when the portfolio fails to load (the loader has
/// already printed a stderr message). On `error.NoAllocations` /
/// `error.SummaryFailed` the helper writes a friendly stderr line
/// and returns `null` so the caller can fall through cleanly.
pub fn loadLiveData(
ctx: *framework.RunCtx,
today: Date,
color: bool,
) !?LiveData {
const io = ctx.io;
const allocator = ctx.allocator;
const svc = ctx.svc orelse return error.MissingDataService;
var loaded = cli.loadPortfolio(ctx, today) orelse return null;
errdefer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator);
errdefer prices.deinit();
{
var load_result = cli.loadPortfolioPrices(io, svc, loaded.syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
const pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return null;
},
else => return err,
};
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
}
/// 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,
/// 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,
/// Pre-loaded live-portfolio data. Required when:
/// - `from_snapshot == false` (the live-portfolio path), OR
/// - `from_snapshot == true` AND the as-of resolution lands
/// on an `imported_values.srf` row (the imported-only
/// branch needs today's allocations to scale).
/// Snapshot-only as-of paths ignore this field. See
/// `LiveData` for the rationale.
live: ?*const LiveData = null,
/// Theme for the `--export-chart` PNG (resolved from `--theme`).
/// Defaults to the built-in theme; the inline kitty chart always
/// uses the default.
chart_theme: theme.Theme = theme.default_theme,
};
/// Build a `ProjectionContext` for an already-resolved as-of date,
/// dispatching to the native-snapshot or imported-only loader. This
/// is the single home for the snapshot-vs-imported branch shared by
/// `runBands` (the `--as-of` bands view) and `loadAsOfContext` (the
/// `--vs` / `compare --projections` key-metrics path) so the two
/// can't drift; see the output-equivalence note on
/// `computeKeyComparison`.
///
/// On the `.snapshot` branch `snap_out.*` receives the owned
/// `LoadedSnapshot`; the caller must keep it alive for as long as
/// the returned context is read (allocations borrow symbol strings
/// from the snapshot's backing buffer) and must `deinit` it.
///
/// On the `.imported` branch `snap_out.*` is set to `null` and
/// `live` MUST be non-null: today's composition is scaled to the
/// imported liquid total. The returned context borrows nothing from
/// `live` once this call returns (the scaled allocations are freed
/// inside `loadProjectionContextFromImported`). When `live` is null
/// the function prints a clear stderr line and returns
/// `error.NoLiveComposition`.
fn loadContextForResolution(
io: std.Io,
allocator: std.mem.Allocator,
va: std.mem.Allocator,
svc: *zfin.DataService,
file_path: []const u8,
portfolio_dir: []const u8,
resolution: AsOfResolution,
events_enabled: bool,
today: Date,
live: ?*const LiveData,
snap_out: *?history.LoadedSnapshot,
) !view.ProjectionContext {
if (resolution.source == .snapshot) {
const hist_dir = try history.deriveHistoryDir(va, file_path);
snap_out.* = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.actual);
return try view.loadProjectionContextAsOf(
io,
va,
portfolio_dir,
&snap_out.*.?.snap,
resolution.actual,
svc,
events_enabled,
);
}
// Imported-only resolution: no native snapshot at the resolved
// date. Reconstruct an approximate composition from today's live
// portfolio scaled to the imported liquid total. Requires the
// caller to have pre-loaded `live`.
snap_out.* = null;
const l = live orelse {
cli.stderrPrint(io, "Error: back-dating to an imported-only date needs today's portfolio composition to scale from, but no live portfolio was loaded.\n");
return error.NoLiveComposition;
};
return try view.loadProjectionContextFromImported(
io,
va,
portfolio_dir,
l.pf_data.summary.allocations,
l.pf_data.summary.total_value,
l.loaded.portfolio.totalCash(today),
l.loaded.portfolio.totalCdFaceValue(today),
resolution.liquid,
resolution.actual,
svc,
events_enabled,
);
}
/// Disk-only probe used by the `--vs` dispatch to decide whether it
/// must pre-load today's live composition before calling
/// `computeKeyComparison`. Returns true when either endpoint
/// resolves to an imported-only date (an `imported_values.srf` row
/// with no native snapshot at-or-before it), which is the only case
/// the live data is needed for on the snapshot "now" path.
///
/// Resolution is filesystem-only (history dir listing +
/// `imported_values.srf`); no network, no rate-limit budget. On any
/// resolve failure this returns false: `computeKeyComparison`
/// re-resolves and owns the user-facing error message, so a
/// false-negative here only skips the (then-unnecessary) pre-load.
pub fn anyImportedOnly(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
vs_date: Date,
now_date: Date,
) bool {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const hist_dir = history.deriveHistoryDir(va, file_path) catch return false;
const then_res = history.resolveAsOfDate(io, va, hist_dir, vs_date) catch return false;
if (then_res.source == .imported) return true;
const now_res = history.resolveAsOfDate(io, va, hist_dir, now_date) catch return false;
return now_res.source == .imported;
}
/// The chart-ready overlay input + band slice for the bands-mode
/// chart. Shared by the inline-kitty (`emitBandsKitty`) and PNG-export
/// paths so both translate the actuals overlay and frame it (zoom to
/// the overlay window) identically.
const OverlayChart = struct {
overlay: ?projection_chart.ActualsOverlay,
bands: []const projections.YearPercentiles,
};
/// Translate the context's actuals overlay into the chart module's
/// shape and pick the band slice to render: zoomed to the overlay
/// window (`view.overlayZoomBands`) when an overlay is present, else
/// the full `bands_ec`. Overlay points are allocated from the arena
/// `va`, so no explicit free is needed.
fn prepOverlayChart(
va: std.mem.Allocator,
ctx: *const view.ProjectionContext,
bands_ec: []const projections.YearPercentiles,
) OverlayChart {
const overlay: ?projection_chart.ActualsOverlay = blk: {
const ov = ctx.overlay_actuals orelse break :blk null;
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null;
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
break :blk .{ .points = buf, .today_years = ov.today_years };
};
return .{
.overlay = overlay,
.bands = view.overlayZoomBands(bands_ec, if (overlay) |ov| ov.today_years else null),
};
}
/// Pixel + cell dimensions for an inline projection-family chart at
/// the standard column width, derived from the terminal's cell size.
/// Shared by the bands, convergence, and return-backtest inline-kitty
/// paths so they all render at the same on-screen footprint.
const ProjChartDims = struct { width: u32, height: u32, cols: u16, rows: u16 };
fn projectionChartDims(caps: term_query.Caps) ProjChartDims {
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
return .{ .width = dims.width, .height = dims.height, .cols = cols, .rows = rows };
}
/// Render the percentile-band chart (longest horizon, with the actuals
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
/// wide and emit it inline. Returns `error.InsufficientData` when bands
/// aren't available so the caller can skip the chart - projections has
/// no braille fallback. All allocations come from the arena `va`.
fn emitBandsKitty(
io: std.Io,
va: std.mem.Allocator,
ctx: *const view.ProjectionContext,
caps: term_query.Caps,
th: theme.Theme,
out: *std.Io.Writer,
) !void {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return error.InsufficientData;
const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData;
// Translate the overlay and frame the band slice (zoomed to the
// overlay window) the same way the PNG-export path does.
const oc = prepOverlayChart(va, ctx, bands_ec);
const d = projectionChartDims(caps);
var rendered = try projection_chart.renderToSurface(io, va, oc.bands, d.width, d.height, th, oc.overlay, true, ctx.retirement.boundaryYear());
defer rendered.deinit(va);
const rgb = try rendered.extractRgb(va);
try term_graphics.placeInline(out, va, rgb, d.width, d.height, d.cols, d.rows);
}
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,
kitty_caps: ?term_query.Caps,
) !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.
// SAFETY: ctx is fully written by the live or as-of branch
// below before any read. Both branches assign `ctx.* = ...`
// before falling through to the rendering code.
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);
if (opts.from_snapshot) {
resolution = resolveAsOfSnapshot(io, va, file_path, opts.as_of) catch |err| switch (err) {
error.NoSnapshot => return,
else => return err,
};
ctx = loadContextForResolution(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
resolution.?,
opts.events_enabled,
opts.today,
opts.live,
&snap_bundle,
) catch |err| switch (err) {
// Imported-only date with no live composition to scale
// from; the helper already printed a clear message.
error.NoLiveComposition => return,
else => return err,
};
} else {
// Live path. Caller supplies pre-loaded data in `opts.live`.
const live = opts.live orelse {
cli.stderrPrint(io, "Error: live projections require pre-loaded `opts.live` data.\n");
return;
};
const lp = &live.loaded;
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) {
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";
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) {
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 {
cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n");
return;
};
// Translate the overlay and frame the band slice (zoomed to the
// overlay window) the same way the inline-kitty path does.
const oc = prepOverlayChart(va, &ctx, bands_ec);
chart_export.exportProjectionChart(io, allocator, oc.bands, oc.overlay, ctx.retirement.boundaryYear(), opts.chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n");
return;
},
else => {
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", .{});
// Headline percentile-band chart, inline via kitty graphics when
// supported (or forced). No braille fallback - non-kitty terminals
// keep the table-only view below.
if (kitty_caps) |kc| {
try out.print("\n", .{});
emitBandsKitty(io, va, &ctx, kc, opts.chart_theme, out) catch |err| switch (err) {
error.InsufficientData => {}, // no bands yet; fall through to the table
else => return err,
};
}
// 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 = braille.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (br) |*chart| {
try braille.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. When an accumulation phase is active the
// per-row % rate is suppressed (it would divide today's-dollars
// retirement spending by today's portfolio); a footnote explains
// and points at the Accumulation phase block.
const swr_rate_note = view.swrRateNote(ctx.retirement.accumulation_years);
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});
if (swr_rate_note == null) {
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
}
}
if (swr_rate_note) |note| {
try cli.printFg(out, color, cli.CLR_MUTED, " {s}\n", .{note});
}
// Spending trough: when a declining/rising spending model is
// active, surface the lowest-spending year in today's dollars.
// It accounts for expense life events (e.g. a late-life
// healthcare hump), so it can land mid-retirement rather than at
// the final year.
if (ctx.spending_trough) |trough| {
try cli.printFg(out, color, cli.CLR_MUTED, " Lowest spending: {f} in year {d} ({d}), today's dollars\n", .{
Money.from(trough.amount).whole(),
trough.years_from_now,
trough.date.year(),
});
}
// 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],
};
}
/// The longest-horizon percentile band envelope for a context, or
/// null when no horizon/bands are available. Used to retain the
/// `--vs` comparison overlay's two envelopes.
fn longestBands(ctx: view.ProjectionContext) ?[]const projections.YearPercentiles {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return null;
return ctx.data.bands[horizons.len - 1];
}
/// Build a `ProjectionContext` for the `--vs` / `compare --projections`
/// "then" or snapshot "now" side at `requested_date`.
///
/// Thin wrapper over `resolveAsOfSnapshot` + `loadContextForResolution`.
/// Handles both native snapshots and imported-only dates:
///
/// - Native snapshot: `snap_bundle_out.*` receives the owned
/// `LoadedSnapshot`. It must outlive the returned context
/// (allocations borrow symbol strings from the snapshot's
/// backing buffer) and the caller must `deinit` it.
/// - Imported-only (date covered by `imported_values.srf` with no
/// snapshot): `snap_bundle_out.*` is set to `null` and `live`
/// MUST be non-null; today's composition is scaled to the
/// imported liquid total. Without `live`, returns
/// `error.NoLiveComposition` after a clear stderr message.
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,
today: Date,
live: ?*const LiveData,
resolution_out: *AsOfResolution,
snap_bundle_out: *?history.LoadedSnapshot,
) !view.ProjectionContext {
resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date);
return loadContextForResolution(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
resolution_out.*,
events_enabled,
today,
live,
snap_bundle_out,
);
}
/// `--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(
ctx: *framework.RunCtx,
file_path: []const u8,
opts: KeyComparisonOptions,
kitty_caps: ?term_query.Caps,
export_chart: ?[]const u8,
) !void {
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const svc = ctx.svc orelse return error.MissingDataService;
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, error.NoLiveComposition => return,
else => return err,
};
defer result.cleanup();
// --export-chart: render the comparison overlay to a PNG and exit
// before any text output.
if (export_chart) |export_path| {
if (result.then_bands == null or result.now_bands == null) {
cli.stderrPrint(io, "Error: projection bands unavailable for one side; cannot export comparison chart.\n");
return;
}
chart_export.exportCompareChart(io, allocator, result.then_bands.?, result.now_bands.?, ctx.chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough projection data to render a comparison chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
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", .{});
// Inline comparison overlay above the table when supported. No
// braille fallback - non-kitty terminals get the table only.
if (kitty_caps) |kc| {
if (result.then_bands != null and result.now_bands != null) {
const d = projectionChartDims(kc);
if (compare_chart.renderCompareChart(io, va, result.then_bands.?, result.now_bands.?, d.width, d.height, ctx.chart_theme)) |cres| {
try term_graphics.placeInline(out, va, cres.rgb_data, d.width, d.height, d.cols, d.rows);
try out.print("\n", .{});
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
}
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,
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
chart_theme: theme.Theme,
) !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| {
cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
const points = try forecast.convergencePoints(va, iv.points);
// --export-chart: render the convergence chart to a PNG and exit
// before any text output.
if (export_chart) |export_path| {
chart_export.exportConvergenceChart(io, allocator, points, chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough convergence data to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
// Inline kitty chart above the table when supported. No braille
// fallback - non-kitty terminals get the table only. Too few points
// skips the chart and still renders the table below.
if (kitty_caps) |kc| {
const d = projectionChartDims(kc);
if (forecast_chart.renderConvergenceChart(io, va, points, d.width, d.height, chart_theme)) |result| {
try out.print("\n", .{});
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
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,
export_chart: ?[]const u8,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
chart_theme: theme.Theme,
) !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| {
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);
// --export-chart: render the back-test chart to a PNG and exit.
if (export_chart) |export_path| {
chart_export.exportBacktestChart(io, allocator, anchors, chart_theme, export_path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: not enough back-test data to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error: failed to write PNG.\n");
return err;
},
};
return;
}
// Inline kitty chart above the table when supported (see
// runConvergence). Too few anchors skips the chart; table still renders.
if (kitty_caps) |kc| {
const d = projectionChartDims(kc);
if (forecast_chart.renderBacktestChart(io, va, anchors, d.width, d.height, chart_theme)) |result| {
try out.print("\n", .{});
try term_graphics.placeInline(out, va, result.rgb_data, d.width, d.height, d.cols, d.rows);
} else |err| switch (err) {
error.InsufficientData => {},
else => return err,
}
}
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.
///
/// Each side retains its `LoadedSnapshot` only when that side
/// resolved to a native snapshot; an imported-only or live side
/// retains `null`. `cleanup()` releases whatever was retained.
pub const KeyComparisonResult = struct {
then: KeyMetrics,
now: KeyMetrics,
/// Longest-horizon percentile bands for each side, retained for
/// the `--vs` comparison overlay chart. Arena-lived (the caller's
/// `va`); null when a side produced no bands. Both aligned at year 0.
then_bands: ?[]const projections.YearPercentiles = null,
now_bands: ?[]const projections.YearPercentiles = null,
/// 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;
if (mut.retained_then) |*s| s.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 more knobs would push it past the readable
/// positional threshold.
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 or imported resolution.
vs_date: Date,
/// The later date - live, another snapshot, or imported,
/// controlled by `now_from_snapshot`.
now_date: Date,
/// When true, both sides resolve from the history dir (snapshot
/// or imported_values). When false, the "now" side uses the
/// live portfolio supplied via `live`.
now_from_snapshot: bool,
/// The actual current calendar day. Used to scale today's
/// composition when either side resolves to an imported-only
/// date (no native snapshot). Distinct from `now_date`, which
/// may be a back-dated `--as-of` value.
today: Date,
/// Pre-loaded live-portfolio data (today's composition).
/// REQUIRED when `now_from_snapshot == false` (it is the live
/// "now" side) AND whenever either side resolves to an
/// imported-only date (it supplies the composition scaled to
/// the imported liquid total). The caller (typically `run` or
/// `commands/compare.zig`'s `run`) loads this via
/// `loadLiveData(ctx, ...)` so the multi-file union-merge path
/// is always taken. See `LiveData`'s doc-comment for why the
/// load lives in the caller and not here.
live: ?*const LiveData = null,
};
/// 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.
// SAFETY: out-param populated by `loadAsOfContext` on success;
// on error we return before any read.
var then_resolution: AsOfResolution = undefined;
var then_snap: ?history.LoadedSnapshot = null;
const then_ctx = try loadAsOfContext(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
opts.events_enabled,
opts.vs_date,
opts.today,
opts.live,
&then_resolution,
&then_snap,
);
// Now side: another snapshot, an imported-only date, or the
// live portfolio.
if (opts.now_from_snapshot) {
// SAFETY: out-param populated by `loadAsOfContext`.
var now_resolution: AsOfResolution = undefined;
var now_snap: ?history.LoadedSnapshot = null;
const now_ctx = loadAsOfContext(
io,
allocator,
va,
svc,
file_path,
portfolio_dir,
opts.events_enabled,
opts.now_date,
opts.today,
opts.live,
&now_resolution,
&now_snap,
) catch |err| {
if (then_snap) |*s| s.deinit(allocator);
return err;
};
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(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. The caller pre-loads via `loadLiveData` and
// passes the result in `opts.live`.
const live = opts.live orelse {
if (then_snap) |*s| s.deinit(allocator);
cli.stderrPrint(io, "Error: live `now` side requires pre-loaded `opts.live`.\n");
return error.PortfolioLoadFailed;
};
const now_ctx = try view.loadProjectionContext(
io,
va,
portfolio_dir,
live.pf_data.summary.allocations,
live.pf_data.summary.total_value,
live.loaded.portfolio.totalCash(opts.now_date),
live.loaded.portfolio.totalCdFaceValue(opts.now_date),
svc,
opts.events_enabled,
opts.now_date,
);
return .{
.then = extractKeyMetrics(then_ctx),
.now = extractKeyMetrics(now_ctx),
.then_bands = longestBands(then_ctx),
.now_bands = longestBands(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| {
cli.stderrPrint(io, "Error resolving as-of: ");
cli.stderrPrint(io, @errorName(e));
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 p10-p90 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,
// SAFETY: parseArgs doesn't touch environ_map.
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
.globals = .{},
.today = today,
.now_s = 0,
.color = false,
// SAFETY: parseArgs doesn't write to out.
.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: --export-chart without a value is rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--export-chart"};
try testing.expectError(error.MissingFlagValue, parseArgsForTest(today, &args));
}
test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--export-chart", "--real" };
try testing.expectError(error.MissingFlagValue, 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),
}
}
test "parseArgs: --overlay-actuals without --as-of is rejected" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"--overlay-actuals"};
try testing.expectError(error.MutuallyExclusive, parseArgsForTest(today, &args));
}
test "projectionChartDims: standard column width, sane pixel/row footprint" {
const caps = term_query.Caps{ .kitty = true, .cell_w = 10, .cell_h = 20 };
const d = projectionChartDims(caps);
try testing.expectEqual(term_graphics.projection_cols, d.cols);
try testing.expect(d.rows > 0);
try testing.expect(d.width > 0 and d.height > 0);
}
test "parseArgs: --vs with --export-chart carries into compare" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--vs", "2024-01-01", "--export-chart", "out.png" };
const parsed = try parseArgsForTest(today, &args);
switch (parsed) {
.compare => |c| try testing.expect(c.export_chart != null),
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" });
}
/// Write a minimal live `portfolio.srf` (single VTI lot) into the
/// tmp dir root so `makeTestLiveData` has a today's-composition to
/// load and scale. Placeholder data only.
fn writeFixturePortfolio(io: std.Io, tmp: *std.testing.TmpDir) !void {
const data =
\\#!srfv1
\\symbol::VTI,shares:num:100,open_date::2020-01-15,open_price:num:200,account::Sample Roth
\\
;
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data });
}
/// Build a `LiveData` (today's composition) directly from a
/// `portfolio.srf` on disk plus a manual price map, mirroring what
/// `loadLiveData` does minus the `RunCtx`/network. Used by the
/// imported-only as-of tests, which need today's allocations to
/// scale to an imported liquid total.
fn makeTestLiveData(io: std.Io, svc: *zfin.DataService, pf_path: []const u8, today: Date) !LiveData {
const portfolio_loader = @import("../portfolio_loader.zig");
const allocator = testing.allocator;
var loaded = portfolio_loader.loadPortfolioFromPaths(io, allocator, &.{pf_path}, today) orelse return error.PortfolioLoadFailed;
errdefer loaded.deinit(allocator);
var prices = std.StringHashMap(f64).init(allocator);
errdefer prices.deinit();
try prices.put("VTI", 200.0);
const pf_data = try portfolio_loader.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, today);
return .{ .loaded = loaded, .pf_data = pf_data, .prices = prices };
}
/// Write a `history/imported_values.srf` with the given body into
/// `tmp` (creating the dir). Body is raw SRF lines.
fn writeFixtureImported(io: std.Io, tmp: *std.testing.TmpDir, body: []const u8) !void {
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
try hist_dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = body });
}
test "runBands: imported-only as_of scales today's composition and renders body" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
// imported_values.srf row with no native snapshot -> imported-only.
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
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 = Date.fromYmd(2016, 1, 4),
.from_snapshot = true,
.today = today,
.overlay_actuals = false,
.live = &ld,
}, false, &stream, null);
const out = stream.buffered();
// Header reflects the imported source, and the caveat explains
// the today's-allocation scaling approximation.
try testing.expect(std.mem.indexOf(u8, out, "as of 2016-01-04, imported value") != null);
try testing.expect(std.mem.indexOf(u8, out, "scaled to the imported liquid total") != null);
}
test "runBands: imported-only as_of without live data returns cleanly" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
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);
try runBands(io, testing.allocator, &svc, pf, .{
.events_enabled = false,
.as_of = Date.fromYmd(2016, 1, 4),
.from_snapshot = true,
.today = Date.fromYmd(2026, 3, 13),
.overlay_actuals = false,
.live = null,
}, false, &stream, null);
// The helper printed a clear stderr message (swallowed by
// cli.stderrPrint) and returned without body output.
try testing.expectEqual(@as(usize, 0), stream.buffered().len);
}
test "computeKeyComparison: imported-only then side with live now side" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
.events_enabled = false,
.vs_date = Date.fromYmd(2016, 1, 4),
.now_date = today,
.now_from_snapshot = false,
.today = today,
.live = &ld,
});
defer result.cleanup();
// "then" resolved imported-only: no snapshot retained on that
// side, and the live "now" side retains no resolution.
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
try testing.expect(result.retained_then == null);
try testing.expect(result.now_resolution == null);
}
test "computeKeyComparison: imported-only on both then and now sides" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try writeFixtureImported(io, &tmp,
\\#!srfv1
\\date::2016-01-04,liquid:num:1500000.00
\\date::2016-06-06,liquid:num:1600000.00
\\
);
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
try writeFixturePortfolio(io, &tmp);
const today = Date.fromYmd(2026, 3, 13);
var ld = try makeTestLiveData(io, &svc, pf, today);
defer ld.deinit(testing.allocator);
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const result = try computeKeyComparison(io, testing.allocator, arena.allocator(), &svc, pf, .{
.events_enabled = false,
.vs_date = Date.fromYmd(2016, 1, 4),
.now_date = Date.fromYmd(2016, 6, 6),
.now_from_snapshot = true,
.today = today,
.live = &ld,
});
defer result.cleanup();
try testing.expectEqual(history.AsOfSourceKind.imported, result.resolution.source);
try testing.expect(result.now_resolution != null);
try testing.expectEqual(history.AsOfSourceKind.imported, result.now_resolution.?.source);
try testing.expect(result.retained_then == null);
try testing.expect(result.retained_now == null);
}
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, null);
// 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, null);
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, null);
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);
}