2510 lines
102 KiB
Zig
2510 lines
102 KiB
Zig
/// 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(¬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 <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);
|
||
}
|