/// 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 `: 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 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 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 (see above) \\ --convergence (see above) \\ --return-backtest (see above) \\ --real With --return-backtest, render in \\ CPI-adjusted dollars. \\ --export-chart 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 ` // 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(¬e_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 ` 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 ` 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: `/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 ` 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); }