611 lines
24 KiB
Zig
611 lines
24 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 fmt = cli.fmt;
|
|
const Date = zfin.Date;
|
|
const performance = @import("../analytics/performance.zig");
|
|
const projections = @import("../analytics/projections.zig");
|
|
const benchmark = @import("../analytics/benchmark.zig");
|
|
const valuation = @import("../analytics/valuation.zig");
|
|
const view = @import("../views/projections.zig");
|
|
const history = @import("../history.zig");
|
|
|
|
/// Hardcoded benchmark symbols (configurable in a future version).
|
|
const stock_benchmark = "SPY";
|
|
const bond_benchmark = "AGG";
|
|
|
|
/// How an as-of date resolved against the history directory. The CLI
|
|
/// uses this to render a single header that tells the user what
|
|
/// actually got loaded (exact hit, nearest-earlier, or straight-up
|
|
/// "no snapshot available").
|
|
const AsOfResolution = struct {
|
|
/// The requested date, as parsed by the caller.
|
|
requested: Date,
|
|
/// The date that was actually loaded. Differs from `requested`
|
|
/// when we auto-snapped to the nearest-earlier snapshot.
|
|
actual: Date,
|
|
};
|
|
|
|
pub fn run(
|
|
allocator: std.mem.Allocator,
|
|
svc: *zfin.DataService,
|
|
file_path: []const u8,
|
|
events_enabled: bool,
|
|
as_of: ?Date,
|
|
color: bool,
|
|
out: *std.Io.Writer,
|
|
) !void {
|
|
// Single arena for all view/render allocations. Same lifetime
|
|
// regardless of live vs. as-of path.
|
|
var arena_state = std.heap.ArenaAllocator.init(allocator);
|
|
defer arena_state.deinit();
|
|
const va = arena_state.allocator();
|
|
|
|
// portfolio_dir is the directory component of file_path, ending
|
|
// in a separator (for the downstream `{s}projections.srf` join).
|
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
const portfolio_dir = file_path[0..dir_end];
|
|
|
|
// Build the context via either the live or as-of pipeline. Both
|
|
// produce a `ProjectionContext`; from that point on rendering is
|
|
// identical.
|
|
var ctx: view.ProjectionContext = undefined;
|
|
var resolution: ?AsOfResolution = null;
|
|
// Snapshot must outlive the context when on the as-of path because
|
|
// `ctx.allocations` borrow their symbol strings from the snapshot's
|
|
// backing buffer. Keep this declared at the outer scope so the
|
|
// defer runs at the end of `run`.
|
|
var snap_bundle: ?history.LoadedSnapshot = null;
|
|
defer if (snap_bundle) |*s| s.deinit(allocator);
|
|
|
|
if (as_of) |requested_date| {
|
|
resolution = resolveAsOfSnapshot(va, file_path, requested_date) catch |err| switch (err) {
|
|
error.NoSnapshot => return,
|
|
else => return err,
|
|
};
|
|
const hist_dir = try history.deriveHistoryDir(va, file_path);
|
|
snap_bundle = try history.loadSnapshotAt(allocator, hist_dir, resolution.?.actual);
|
|
|
|
ctx = try view.loadProjectionContextAsOf(
|
|
va,
|
|
portfolio_dir,
|
|
&snap_bundle.?.snap,
|
|
resolution.?.actual,
|
|
svc,
|
|
events_enabled,
|
|
);
|
|
} else {
|
|
var loaded = cli.loadPortfolio(allocator, file_path) orelse return;
|
|
defer loaded.deinit(allocator);
|
|
const portfolio = loaded.portfolio;
|
|
const positions = loaded.positions;
|
|
const syms = loaded.syms;
|
|
|
|
// Prices from cache — matches pre-as-of behavior exactly.
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
for (positions) |pos| {
|
|
if (pos.shares <= 0) continue;
|
|
if (svc.getCachedCandles(pos.symbol)) |cs| {
|
|
defer cs.deinit();
|
|
if (cs.data.len > 0) {
|
|
try prices.put(pos.symbol, cs.data[cs.data.len - 1].close);
|
|
}
|
|
}
|
|
}
|
|
|
|
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) {
|
|
error.NoAllocations, error.SummaryFailed => {
|
|
try cli.stderrPrint("Error computing portfolio summary.\n");
|
|
return;
|
|
},
|
|
else => return err,
|
|
};
|
|
defer pf_data.deinit(allocator);
|
|
|
|
ctx = try view.loadProjectionContext(
|
|
va,
|
|
portfolio_dir,
|
|
pf_data.summary.allocations,
|
|
pf_data.summary.total_value,
|
|
portfolio.totalCash(),
|
|
portfolio.totalCdFaceValue(),
|
|
svc,
|
|
events_enabled,
|
|
);
|
|
}
|
|
|
|
const horizons = ctx.config.getHorizons();
|
|
const confidence_levels = ctx.config.getConfidenceLevels();
|
|
const comparison = ctx.comparison;
|
|
|
|
try out.print("\n", .{});
|
|
if (resolution) |r| {
|
|
var buf: [10]u8 = undefined;
|
|
try cli.printBold(out, color, "Projections (as of {s})\n", .{r.actual.format(&buf)});
|
|
} else {
|
|
try cli.printBold(out, color, "Projections ({s})\n", .{file_path});
|
|
}
|
|
try out.print("========================================\n", .{});
|
|
|
|
// If auto-snapped, print a muted note so the user knows the
|
|
// requested date wasn't an exact hit.
|
|
if (resolution) |r| {
|
|
if (r.actual.days != r.requested.days) {
|
|
const diff = r.requested.days - r.actual.days;
|
|
var req_buf: [10]u8 = undefined;
|
|
var act_buf: [10]u8 = undefined;
|
|
try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s}; nearest snapshot: {s}, {d} day{s} earlier)\n", .{
|
|
r.requested.format(&req_buf),
|
|
r.actual.format(&act_buf),
|
|
diff,
|
|
fmt.dayPlural(diff),
|
|
});
|
|
}
|
|
}
|
|
try out.print("\n", .{});
|
|
|
|
// Header row
|
|
try cli.printFg(out, color, cli.CLR_MUTED, "{s: <32}{s: >8}{s: >9}{s: >9}{s: >10}{s: >9}\n", .{
|
|
"", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
|
|
});
|
|
|
|
// Build return rows via view model
|
|
var spy_bufs: [5][16]u8 = undefined;
|
|
var spy_label_buf: [32]u8 = undefined;
|
|
const spy_row = view.buildReturnRow(
|
|
view.fmtBenchmarkLabel(&spy_label_buf, stock_benchmark, ctx.stock_pct * 100),
|
|
comparison.stock_returns,
|
|
&spy_bufs,
|
|
false,
|
|
);
|
|
|
|
var agg_bufs: [5][16]u8 = undefined;
|
|
var agg_label_buf: [32]u8 = undefined;
|
|
const agg_row = view.buildReturnRow(
|
|
view.fmtBenchmarkLabel(&agg_label_buf, bond_benchmark, ctx.bond_pct * 100),
|
|
comparison.bond_returns,
|
|
&agg_bufs,
|
|
false,
|
|
);
|
|
|
|
var bench_bufs: [5][16]u8 = undefined;
|
|
const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
|
|
|
|
var port_bufs: [5][16]u8 = undefined;
|
|
const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);
|
|
|
|
const rows = [_]view.ReturnRow{ spy_row, agg_row, bench_row };
|
|
for (rows) |row| {
|
|
if (row.bold) try cli.setBold(out, color);
|
|
try writeReturnRow(out, color, row);
|
|
if (row.bold) try cli.reset(out, color);
|
|
}
|
|
|
|
try out.print("\n", .{});
|
|
|
|
try cli.setBold(out, color);
|
|
try writeReturnRow(out, color, port_row);
|
|
try cli.reset(out, color);
|
|
|
|
// Conservative estimate
|
|
{
|
|
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", .{ "Conservative estimate", 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});
|
|
}
|
|
}
|
|
|
|
// ── Braille chart: median portfolio value ─────────────────────
|
|
if (horizons.len > 0) {
|
|
const last_idx = horizons.len - 1;
|
|
if (ctx.data.bands[last_idx]) |b| {
|
|
if (b.len >= 2) {
|
|
try out.print("\n", .{});
|
|
try cli.printBold(out, color, "Median Portfolio Value ({d}-Year, 99% withdrawal)\n\n", .{horizons[last_idx]});
|
|
|
|
// Synthesize candles from median values
|
|
const candles = try va.alloc(zfin.Candle, b.len);
|
|
for (b, 0..) |bp, i| {
|
|
const v: f32 = @floatCast(bp.p50);
|
|
candles[i] = .{
|
|
.date = zfin.Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 365)),
|
|
.open = v,
|
|
.high = v,
|
|
.low = v,
|
|
.close = v,
|
|
.adj_close = v,
|
|
.volume = 0,
|
|
};
|
|
}
|
|
|
|
var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
|
|
if (br) |*chart| {
|
|
try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
|
|
// Year axis instead of date axis
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" Now", .{});
|
|
const end_label_buf = try std.fmt.allocPrint(va, "{d}yr", .{horizons[last_idx]});
|
|
const pad = if (chart.n_cols > 3 + end_label_buf.len) chart.n_cols - 3 - end_label_buf.len else 0;
|
|
for (0..pad) |_| try out.print(" ", .{});
|
|
try out.print("{s}\n", .{end_label_buf});
|
|
try cli.reset(out, color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Terminal portfolio value ─────────────────────────────────
|
|
try out.print("\n", .{});
|
|
try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{});
|
|
|
|
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.terminal_col_width)});
|
|
|
|
const p_labels = [_][]const u8{ "Pessimistic (p10)", "Median (p50)", "Optimistic (p90)" };
|
|
const p_styles = [_]view.StyleIntent{ .muted, .normal, .muted };
|
|
for (p_labels, p_styles, 0..) |plabel, pstyle, pi| {
|
|
const row = try view.buildPercentileRow(va, plabel, pi, ctx.data.bands, pstyle);
|
|
try cli.printIntent(out, color, row.style, "{s}\n", .{row.text});
|
|
}
|
|
|
|
// ── Safe withdrawal table ──────────────────────────────────
|
|
try out.print("\n", .{});
|
|
try cli.printBold(out, color, "Safe Withdrawal (FIRECalc historical simulation)\n", .{});
|
|
|
|
// Header row
|
|
try out.print("{s}\n", .{try view.buildHeaderRow(va, horizons, view.withdrawal_col_width)});
|
|
|
|
// Withdrawal rows
|
|
for (confidence_levels, 0..) |conf, ci| {
|
|
const wr_rows = try view.buildWithdrawalRows(va, conf, horizons, ctx.data.withdrawals, ci);
|
|
try out.print("{s}\n", .{wr_rows.amount.text});
|
|
try cli.printFg(out, color, cli.CLR_MUTED, "{s}\n", .{wr_rows.rate.text});
|
|
}
|
|
|
|
// Life events summary — as-of mode uses ages-as-of-as_of; live
|
|
// mode uses current ages. `currentAgesAsOf(today)` returns the
|
|
// current ages, so this unifies both paths.
|
|
{
|
|
const events = ctx.config.getEvents();
|
|
if (events.len > 0) {
|
|
const ages_ref_date = if (resolution) |r| r.actual else fmt.todayDate();
|
|
const ages = ctx.config.currentAgesAsOf(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", .{});
|
|
}
|
|
|
|
/// Resolve the user's requested as-of date against the history directory.
|
|
///
|
|
/// Thin adapter over `history.resolveSnapshotDate` — the shared pure
|
|
/// resolver owns exact-then-fallback logic. This wrapper maps the
|
|
/// error set to `error.NoSnapshot` and surfaces user-visible stderr
|
|
/// messages (including the "Earliest available (later than requested)"
|
|
/// hint, which the TUI doesn't need).
|
|
///
|
|
/// Arena-allocates the intermediate `hist_dir` + filename strings;
|
|
/// pass a short-lived arena as `va`.
|
|
fn resolveAsOfSnapshot(
|
|
va: std.mem.Allocator,
|
|
file_path: []const u8,
|
|
requested: Date,
|
|
) !AsOfResolution {
|
|
const hist_dir = try history.deriveHistoryDir(va, file_path);
|
|
|
|
const resolved = history.resolveSnapshotDate(va, hist_dir, requested) catch |err| switch (err) {
|
|
error.NoSnapshotAtOrBefore => {
|
|
var req_buf: [10]u8 = undefined;
|
|
const req_str = requested.format(&req_buf);
|
|
const msg = std.fmt.allocPrint(va, "No snapshot at or before {s}.\n", .{req_str}) catch "No snapshot at or before the requested date.\n";
|
|
try cli.stderrPrint(msg);
|
|
// Second look at the nearest table for the "later available"
|
|
// hint. Cheap (filesystem scan, same dir).
|
|
const nearest = history.findNearestSnapshot(hist_dir, requested) catch {
|
|
try cli.stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n");
|
|
return error.NoSnapshot;
|
|
};
|
|
if (nearest.later) |later| {
|
|
var later_buf: [10]u8 = undefined;
|
|
const later_str = later.format(&later_buf);
|
|
const later_msg = std.fmt.allocPrint(va, "Earliest available: {s} (later than requested).\n", .{later_str}) catch "A later snapshot exists but was not used.\n";
|
|
try cli.stderrPrint(later_msg);
|
|
} else {
|
|
try cli.stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n");
|
|
}
|
|
return error.NoSnapshot;
|
|
},
|
|
else => |e| {
|
|
try cli.stderrPrint("Error resolving snapshot: ");
|
|
try cli.stderrPrint(@errorName(e));
|
|
try cli.stderrPrint("\n");
|
|
return error.NoSnapshot;
|
|
},
|
|
};
|
|
|
|
return .{ .requested = resolved.requested, .actual = resolved.actual };
|
|
}
|
|
|
|
/// 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}),
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
//
|
|
// The projections simulation and rendering are covered by the
|
|
// view-model tests in `src/views/projections.zig` and the analytics
|
|
// tests in `src/analytics/`. These tests focus on the CLI-surface
|
|
// behaviour that `run` is responsible for: as-of snapshot resolution,
|
|
// exact/nearest/miss branching, and error reporting.
|
|
|
|
const testing = std.testing;
|
|
const snapshot_model = @import("../models/snapshot.zig");
|
|
const snapshot_cmd = @import("snapshot.zig");
|
|
|
|
fn makeTestSvc() zfin.DataService {
|
|
const config = zfin.Config{ .cache_dir = "/tmp" };
|
|
return zfin.DataService.init(testing.allocator, config);
|
|
}
|
|
|
|
fn writeFixtureSnapshot(
|
|
dir: std.fs.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_cmd.renderSnapshot(allocator, snap);
|
|
defer allocator.free(rendered);
|
|
try dir.writeFile(.{ .sub_path = filename, .data = rendered });
|
|
}
|
|
|
|
/// Build a portfolio path inside `tmp` and return the joined string.
|
|
/// Caller owns the returned buffer.
|
|
fn makeTestPortfolioPath(tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 {
|
|
const dir_path = try tmp.dir.realpathAlloc(allocator, ".");
|
|
defer allocator.free(dir_path);
|
|
return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
|
|
}
|
|
|
|
test "resolveAsOfSnapshot: exact match returns actual == requested" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
try tmp.dir.makePath("history");
|
|
var hist_dir = try tmp.dir.openDir("history", .{});
|
|
defer hist_dir.close();
|
|
|
|
const d = Date.fromYmd(2026, 3, 13);
|
|
try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000);
|
|
|
|
const pf = try makeTestPortfolioPath(&tmp, testing.allocator);
|
|
defer testing.allocator.free(pf);
|
|
|
|
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena.deinit();
|
|
|
|
const res = try resolveAsOfSnapshot(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" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
try tmp.dir.makePath("history");
|
|
var hist_dir = try tmp.dir.openDir("history", .{});
|
|
defer hist_dir.close();
|
|
|
|
const earlier = Date.fromYmd(2026, 3, 12);
|
|
try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-12-portfolio.srf", earlier, 1_000_000);
|
|
|
|
const pf = try makeTestPortfolioPath(&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(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" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
try tmp.dir.makePath("history");
|
|
var hist_dir = try tmp.dir.openDir("history", .{});
|
|
defer hist_dir.close();
|
|
|
|
// Only a later snapshot exists — can't satisfy an earlier request.
|
|
const later = Date.fromYmd(2026, 4, 1);
|
|
try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-04-01-portfolio.srf", later, 1_000_000);
|
|
|
|
const pf = try makeTestPortfolioPath(&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(arena.allocator(), pf, requested);
|
|
try testing.expectError(error.NoSnapshot, result);
|
|
}
|
|
|
|
test "resolveAsOfSnapshot: empty history dir returns NoSnapshot" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
try tmp.dir.makePath("history");
|
|
|
|
const pf = try makeTestPortfolioPath(&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(arena.allocator(), pf, requested);
|
|
try testing.expectError(error.NoSnapshot, result);
|
|
}
|
|
|
|
test "run: as_of with no snapshots returns without error (stderr-only)" {
|
|
// 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(&tmp, testing.allocator);
|
|
defer testing.allocator.free(pf);
|
|
|
|
var buf: [4096]u8 = undefined;
|
|
var stream = std.Io.Writer.fixed(&buf);
|
|
|
|
const d = Date.fromYmd(2026, 3, 13);
|
|
try run(testing.allocator, &svc, pf, false, d, false, &stream);
|
|
|
|
// No body output because the resolution failed — the stderr
|
|
// message is swallowed by `cli.stderrPrint` and doesn't land in
|
|
// `stream`. This guarantees the error-path returns cleanly.
|
|
const out = stream.buffered();
|
|
try testing.expectEqual(@as(usize, 0), out.len);
|
|
}
|
|
|
|
test "run: as_of with matching snapshot produces body output" {
|
|
// 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.makePath("history");
|
|
var hist_dir = try tmp.dir.openDir("history", .{});
|
|
defer hist_dir.close();
|
|
|
|
const d = Date.fromYmd(2026, 3, 13);
|
|
try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-13-portfolio.srf", d, 1_000_000);
|
|
|
|
const pf = try makeTestPortfolioPath(&tmp, testing.allocator);
|
|
defer testing.allocator.free(pf);
|
|
|
|
var buf: [32_768]u8 = undefined;
|
|
var stream = std.Io.Writer.fixed(&buf);
|
|
try run(testing.allocator, &svc, pf, false, d, false, &stream);
|
|
|
|
const out = stream.buffered();
|
|
// Header should call out the as-of date explicitly.
|
|
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-13") != null);
|
|
// Benchmark + withdrawal tables still render even with missing candles.
|
|
try testing.expect(std.mem.indexOf(u8, out, "Safe Withdrawal") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "Terminal Portfolio Value") != null);
|
|
}
|
|
|
|
test "run: as_of auto-snap surfaces muted 'nearest' note" {
|
|
var tmp = std.testing.tmpDir(.{});
|
|
defer tmp.cleanup();
|
|
var svc = makeTestSvc();
|
|
defer svc.deinit();
|
|
|
|
try tmp.dir.makePath("history");
|
|
var hist_dir = try tmp.dir.openDir("history", .{});
|
|
defer hist_dir.close();
|
|
|
|
const actual = Date.fromYmd(2026, 3, 12);
|
|
try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-03-12-portfolio.srf", actual, 1_000_000);
|
|
|
|
const pf = try makeTestPortfolioPath(&tmp, testing.allocator);
|
|
defer testing.allocator.free(pf);
|
|
|
|
var buf: [32_768]u8 = undefined;
|
|
var stream = std.Io.Writer.fixed(&buf);
|
|
|
|
const requested = Date.fromYmd(2026, 3, 13);
|
|
try run(testing.allocator, &svc, pf, false, requested, false, &stream);
|
|
|
|
const out = stream.buffered();
|
|
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "(requested 2026-03-13") != null);
|
|
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2026-03-12") != null);
|
|
// 1 day earlier → singular "day", not "days"
|
|
try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
|
|
}
|