diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 6628065..19eb873 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -55,6 +55,7 @@ const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); const view_hist = @import("../views/history.zig"); const contributions = @import("contributions.zig"); +const projections = @import("projections.zig"); pub const Error = error{ UnexpectedArg, @@ -75,21 +76,72 @@ pub fn run( out: *std.Io.Writer, ) !void { // ── Parse args ─────────────────────────────────────────── - if (cmd_args.len == 0) { + // + // `--projections` is an opt-in flag that embeds the + // projected-return + safe-withdrawal delta block between the + // Liquid totals and the per-symbol table. Kept opt-in because + // building the projection side for both dates roughly doubles + // command runtime (Monte Carlo SWR search + benchmark trailing + // returns for both endpoints). Strip it before positional date + // parsing so it can appear anywhere in the arg list. We do NOT + // accept `-p` as a shortcut because `-p` is already the global + // `--portfolio` flag at the top level — shadowing it here would + // confuse users who reach for the short form. + // + // `--no-events` mirrors the `projections` command's flag of the + // same name: it suppresses life events in the underlying + // projection simulation. Only meaningful alongside + // `--projections` (silently ignored otherwise). + var with_projections = false; + var events_enabled = true; + var positional: std.ArrayList([]const u8) = .empty; + defer positional.deinit(allocator); + for (cmd_args) |a| { + if (std.mem.eql(u8, a, "--projections")) { + with_projections = true; + } else if (std.mem.eql(u8, a, "--no-events")) { + events_enabled = false; + } else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) { + // Any other dash-prefixed token is an unknown compare flag. + // Catch it explicitly with a pointed message rather than + // letting it fall through to the date parser (which would + // emit a generic "invalid date" error). Most likely cause: + // user reached for `-p` expecting `--projections` — that + // shortcut was intentionally not exposed to avoid + // shadowing the global `-p` / `--portfolio` flag. + try cli.stderrPrint("Error: unknown flag for 'compare': "); + try cli.stderrPrint(a); + try cli.stderrPrint("\nKnown flags: --projections, --no-events.\n"); + if (std.mem.eql(u8, a, "-p")) { + try cli.stderrPrint(" (Tip: the projections flag is spelled `--projections` in full.\n"); + try cli.stderrPrint(" `-p` is reserved for the global --portfolio option and must appear\n"); + try cli.stderrPrint(" before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n"); + } + return error.UnexpectedArg; + } else { + try positional.append(allocator, a); + } + } + const args = positional.items; + + if (args.len == 0) { try cli.stderrPrint("Error: 'compare' requires one or two dates.\n"); try cli.stderrPrint("Usage:\n"); - try cli.stderrPrint(" zfin compare (compare date vs current)\n"); - try cli.stderrPrint(" zfin compare (compare two dates)\n"); + try cli.stderrPrint(" zfin compare [--projections [--no-events]] (compare date vs current)\n"); + try cli.stderrPrint(" zfin compare [--projections [--no-events]] (compare two dates)\n"); try cli.stderrPrint("Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n"); + try cli.stderrPrint("Flags:\n"); + try cli.stderrPrint(" --projections Include projected return + safe-withdrawal deltas.\n"); + try cli.stderrPrint(" --no-events (with --projections) Exclude life events from the simulation.\n"); return error.MissingDateArg; } - if (cmd_args.len > 2) { - try cli.stderrPrint("Error: 'compare' takes at most two arguments.\n"); + if (args.len > 2) { + try cli.stderrPrint("Error: 'compare' takes at most two dates.\n"); return error.UnexpectedArg; } const today = fmt.todayDate(); - const date1 = cli.parseRequiredDateOrStderr(cmd_args[0], today, "date1") catch |err| switch (err) { + const date1 = cli.parseRequiredDateOrStderr(args[0], today, "date1") catch |err| switch (err) { error.InvalidDate => return error.InvalidDate, }; @@ -97,8 +149,8 @@ pub fn run( // user-given date is "then" and today is "now" (from the live // portfolio). In two-date mode both are snapshots and we swap to // guarantee older → newer. - const date2: ?Date = if (cmd_args.len == 2) - (cli.parseRequiredDateOrStderr(cmd_args[1], today, "date2") catch |err| switch (err) { + const date2: ?Date = if (args.len == 2) + (cli.parseRequiredDateOrStderr(args[1], today, "date2") catch |err| switch (err) { error.InvalidDate => return error.InvalidDate, }) else @@ -191,6 +243,39 @@ pub fn run( var then_side = try compare_core.loadSnapshotSide(allocator, hist_dir, then_date); defer then_side.deinit(allocator); + // Projections: only computed when --projections/-p flag is set. + // Uses the SNAPPED dates (not requested) because projections are + // snapshot-based — they need actual files on disk to load. + // When `now_is_live`, pass null for the "now" side so projections + // uses the live portfolio. When two-date mode, pass the snapped + // now_date. + var projections_result: ?projections.KeyComparisonResult = null; + defer if (projections_result) |r| r.cleanup(); + var projections_block: ?ProjectionsBlock = null; + if (with_projections) { + const now_date_for_proj: ?Date = if (now_is_live) null else now_date; + projections_result = projections.computeKeyComparison( + allocator, + arena, + svc, + portfolio_path, + events_enabled, + then_date, + now_date_for_proj, + ) catch |err| blk: { + // Projections computation failed — fall back to compare + // output without the block. User still gets the core + // Liquid/attribution/per-symbol view. + var ebuf: [160]u8 = undefined; + const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n"; + cli.stderrPrint(msg) catch {}; + break :blk null; + }; + if (projections_result) |r| { + projections_block = .{ .then = r.then, .now = r.now }; + } + } + if (now_is_live) { var now_live = try LiveSide.load(allocator, svc, portfolio_path, color); defer now_live.deinit(allocator); @@ -211,6 +296,7 @@ pub fn run( .then_map = &then_side.map, .now_map = &now_live.map, .attribution = attribution, + .projections = projections_block, }); } else { var now_side = try compare_core.loadSnapshotSide(allocator, hist_dir, now_date); @@ -231,6 +317,7 @@ pub fn run( .then_map = &then_side.map, .now_map = &now_side.map, .attribution = attribution, + .projections = projections_block, }); } } @@ -267,7 +354,12 @@ fn printSnapNote(color: bool, requested: Date, actual: Date, label: []const u8) /// /// `then_map` / `now_map` are borrowed pointers; the caller keeps the /// underlying maps alive through the render call. `attribution` is -/// optional and folded into the view only when set. +/// optional and folded into the view only when set. `projections` is +/// optional — when set, a compact projected-return + safe-withdrawal +/// delta block renders between the attribution and the per-symbol +/// table. Kept outside `CompareView` because CompareView is +/// renderer-agnostic and the projection data carries CLI-specific +/// computation (Monte Carlo SWR, trailing returns). const RenderArgs = struct { then_date: Date, now_date: Date, @@ -277,6 +369,16 @@ const RenderArgs = struct { then_map: *const view.HoldingMap, now_map: *const view.HoldingMap, attribution: ?contributions.AttributionSummary, + projections: ?ProjectionsBlock = null, +}; + +/// Pre-computed projections block for embedding in the compare +/// output. Caller computes (runs the projections pipeline for both +/// endpoints) and passes in; the renderer just prints. Gates the +/// perf cost of projection computation on the `--projections` flag. +const ProjectionsBlock = struct { + then: projections.KeyMetrics, + now: projections.KeyMetrics, }; /// Build the view from two holdings maps + totals, then render. @@ -315,7 +417,7 @@ fn renderFromParts( }; } - try renderCompare(out, color, cv); + try renderCompare(out, color, cv, args.projections); } // ── Live-portfolio side (CLI-only) ─────────────────────────── @@ -403,7 +505,7 @@ const LiveSide = struct { // the renderer-specific layout choices (leading indent, newline // placement, two-color totals line). -fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { +fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void { var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; const then_str = cv.then_date.format(&then_buf); @@ -429,6 +531,14 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void { try renderAttributionLine(out, color, cv.liquid.delta, a); } + // Optional projections block (opt-in via --projections/-p). + // Slots between the attribution rows and the per-symbol table + // so the "headline" numbers cluster together at the top. + if (proj) |p| { + try out.print("\n", .{}); + try projections.renderKeyComparisonRows(out, color, p.then, p.now); + } + try out.print("\n", .{}); // Per-symbol table @@ -604,7 +714,7 @@ test "renderCompare: basic output includes expected elements" { var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Header @@ -638,7 +748,7 @@ test "renderCompare: two-snapshot mode shows real date, not 'today'" { var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null); @@ -663,7 +773,7 @@ test "renderCompare: 1-day diff uses singular 'day'" { var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(1 day)") != null); @@ -696,7 +806,7 @@ test "renderCompare: only added positions (no removed)" { var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null); @@ -717,7 +827,7 @@ test "renderCompare: negative totals delta" { var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Delta is signed negative; pct same @@ -746,7 +856,7 @@ test "renderCompare: attribution line when attribution is set" { var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") != null); @@ -774,7 +884,7 @@ test "renderCompare: no attribution line when attribution is null" { var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") == null); @@ -803,7 +913,7 @@ test "renderCompare: attribution handles negative gains" { var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "+$15,000.00") != null); @@ -857,7 +967,7 @@ test "renderCompare: gainer/loser summary line renders with pluralization" { var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Plural gainers, singular loser @@ -885,7 +995,7 @@ test "renderCompare: gainer/loser summary suppressed when no held symbols" { var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); // Neither "gainer" nor "loser" should appear — the summary is @@ -941,7 +1051,7 @@ test "renderCompare: gainer/loser summary includes flat when present" { var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try renderCompare(&stream, false, cv); + try renderCompare(&stream, false, cv, null); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "1 gainer") != null); diff --git a/src/commands/projections.zig b/src/commands/projections.zig index a2b2ad7..ac6a0b2 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -300,6 +300,329 @@ pub fn run( 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, +}; + +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, + }; +} + +/// Build a `ProjectionContext` against a historical snapshot date. +/// +/// Caller owns `snap_bundle_out.*` on success — it must outlive the +/// returned context because allocations borrow symbol strings from +/// the snapshot's backing buffer. +fn loadAsOfContext( + allocator: std.mem.Allocator, + va: std.mem.Allocator, + svc: *zfin.DataService, + file_path: []const u8, + portfolio_dir: []const u8, + events_enabled: bool, + requested_date: Date, + resolution_out: *AsOfResolution, + snap_bundle_out: *history.LoadedSnapshot, +) !view.ProjectionContext { + resolution_out.* = resolveAsOfSnapshot(va, file_path, requested_date) catch |err| return err; + const hist_dir = try history.deriveHistoryDir(va, file_path); + snap_bundle_out.* = try history.loadSnapshotAt(allocator, hist_dir, resolution_out.actual); + return try view.loadProjectionContextAsOf( + va, + portfolio_dir, + &snap_bundle_out.snap, + resolution_out.actual, + svc, + events_enabled, + ); +} + +/// `--vs ` entry point: compare two projections side-by-side +/// with deltas. By default `now` is the live portfolio; when +/// `as_of_now` is non-null, `now` is also a historical snapshot — +/// letting the caller compare any two points in time without +/// intermediate arithmetic. +/// +/// Target audience is the weekly review email's header — the +/// "Projected Return" and "1st Year Withdrawal" rows with Δ columns. +/// For the full benchmark table / SWR grid / percentile bands, run +/// `zfin projections` and `zfin projections --as-of ` separately. +pub fn runCompare( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + file_path: []const u8, + events_enabled: bool, + vs_date: Date, + as_of_now: ?Date, + color: bool, + out: *std.Io.Writer, +) !void { + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const va = arena_state.allocator(); + + const result = computeKeyComparison(allocator, va, svc, file_path, events_enabled, vs_date, as_of_now) catch |err| switch (err) { + error.NoSnapshot, error.PortfolioLoadFailed => return, + else => return err, + }; + defer result.cleanup(); + + try out.print("\n", .{}); + var then_buf: [10]u8 = undefined; + var now_buf: [10]u8 = undefined; + const then_str = result.resolution.actual.format(&then_buf); + const now_str = if (result.now_resolution) |nr| nr.actual.format(&now_buf) else "today"; + const days_between = if (result.now_resolution) |nr| + nr.actual.days - result.resolution.actual.days + else + fmt.todayDate().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; + var req_buf: [10]u8 = undefined; + try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ + result.resolution.requested.format(&req_buf), + 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; + var req_buf: [10]u8 = undefined; + try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ + nr.requested.format(&req_buf), + now_str, + diff, + if (diff == 1) "" else "s", + }); + } + } + try out.print("\n", .{}); + + try renderKeyComparisonRows(out, color, result.then, result.now); + + 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", .{}); +} + +/// Shared key-metrics comparison used by both `projections --vs` and +/// `compare --projections`. Returns `then`/`now` metrics ready for +/// rendering, plus the snapshot resolutions for header rendering. +/// Caller must invoke `cleanup()` to release retained snapshots. +/// +/// When `as_of_now` is null, the "now" side is the live portfolio. +/// When set, it's loaded as a snapshot — the function then retains +/// two snapshot bundles so both must be cleaned up. +pub const KeyComparisonResult = struct { + then: KeyMetrics, + now: KeyMetrics, + /// Resolution of the "then" snapshot. Always present. + resolution: AsOfResolution, + /// Resolution of the "now" snapshot. Null when now is live. + now_resolution: ?AsOfResolution, + retained_then: history.LoadedSnapshot, + retained_now: ?history.LoadedSnapshot, + retained_allocator: std.mem.Allocator, + + pub fn cleanup(self: KeyComparisonResult) void { + var mut = self; + mut.retained_then.deinit(self.retained_allocator); + if (mut.retained_now) |*s| s.deinit(self.retained_allocator); + } +}; + +pub fn computeKeyComparison( + allocator: std.mem.Allocator, + va: std.mem.Allocator, + svc: *zfin.DataService, + file_path: []const u8, + events_enabled: bool, + vs_date: Date, + as_of_now: ?Date, +) !KeyComparisonResult { + const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; + const portfolio_dir = file_path[0..dir_end]; + + // Load "then" snapshot first. If it doesn't exist we bail before + // doing the (more expensive) "now" side. + var then_resolution: AsOfResolution = undefined; + var then_snap: history.LoadedSnapshot = undefined; + const then_ctx = try loadAsOfContext( + allocator, + va, + svc, + file_path, + portfolio_dir, + events_enabled, + vs_date, + &then_resolution, + &then_snap, + ); + + // Now side — either another snapshot or the live portfolio. + if (as_of_now) |now_date| { + var now_resolution: AsOfResolution = undefined; + var now_snap: history.LoadedSnapshot = undefined; + const now_ctx = loadAsOfContext( + allocator, + va, + svc, + file_path, + portfolio_dir, + events_enabled, + now_date, + &now_resolution, + &now_snap, + ) catch |err| { + then_snap.deinit(allocator); + return err; + }; + + return .{ + .then = extractKeyMetrics(then_ctx), + .now = extractKeyMetrics(now_ctx), + .resolution = then_resolution, + .now_resolution = now_resolution, + .retained_then = then_snap, + .retained_now = now_snap, + .retained_allocator = allocator, + }; + } + + // Live "now" side — mirrors `run()`'s live path. + var loaded = cli.loadPortfolio(allocator, file_path) orelse { + then_snap.deinit(allocator); + return error.PortfolioLoadFailed; + }; + defer loaded.deinit(allocator); + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + for (loaded.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, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc) catch |err| switch (err) { + error.NoAllocations, error.SummaryFailed => { + then_snap.deinit(allocator); + try cli.stderrPrint("Error computing portfolio summary.\n"); + return error.PortfolioLoadFailed; + }, + else => { + then_snap.deinit(allocator); + return err; + }, + }; + defer pf_data.deinit(allocator); + + const now_ctx = try view.loadProjectionContext( + va, + portfolio_dir, + pf_data.summary.allocations, + pf_data.summary.total_value, + loaded.portfolio.totalCash(), + loaded.portfolio.totalCdFaceValue(), + svc, + events_enabled, + ); + + return .{ + .then = extractKeyMetrics(then_ctx), + .now = extractKeyMetrics(now_ctx), + .resolution = then_resolution, + .now_resolution = null, + .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`). +pub fn renderKeyComparisonRows( + out: *std.Io.Writer, + color: bool, + then: KeyMetrics, + now: KeyMetrics, +) !void { + 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; + var then_buf: [32]u8 = undefined; + var now_buf: [32]u8 = undefined; + var delta_buf: [32]u8 = undefined; + const then_str = fmt.fmtMoneyAbs(&then_buf, then_val); + const now_str = fmt.fmtMoneyAbs(&now_buf, now_val); + const view_hist = @import("../views/history.zig"); + const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, delta); + + 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: >12}\n", .{delta_str}); +} + /// Resolve the user's requested as-of date against the history directory. /// /// Thin adapter over `cli.resolveSnapshotOrExplain` — the shared CLI diff --git a/src/main.zig b/src/main.zig index d4fc60b..32f7dba 100644 --- a/src/main.zig +++ b/src/main.zig @@ -86,6 +86,19 @@ const usage = \\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'. \\ Auto-snaps to nearest-earlier snapshot if the \\ exact date has no snapshot file. + \\ --vs Compact side-by-side comparison: projected return + \\ and safe-withdrawal @99% for live vs DATE, with + \\ deltas. Combine with --as-of to compare two + \\ historical dates (--vs = then, --as-of = now). + \\ + \\Compare command options: + \\ --projections Include projected return + safe-withdrawal @99% + \\ deltas between the attribution rows and the + \\ per-symbol table. Opt-in because projections cost + \\ ~1-2s per endpoint (Monte Carlo SWR search). + \\ --no-events (with --projections) Exclude life events from the + \\ underlying projection simulation. Matches the + \\ `projections --no-events` flag. \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) @@ -433,14 +446,17 @@ fn runCli() !u8 { } else if (std.mem.eql(u8, command, "projections")) { var events_enabled = true; var as_of: ?zfin.Date = null; + var vs_date: ?zfin.Date = 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, "--as-of")) { + } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint("Error: --as-of requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); + try cli.stderrPrint("Error: "); + try cli.stderrPrint(a); + try cli.stderrPrint(" requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); return 1; } const value = cmd_args[i + 1]; @@ -452,23 +468,40 @@ fn runCli() !u8 { try cli.stderrPrint("\n"); return 1; }; - // null = live (leave as_of null); non-null = resolved date. if (parsed) |d| { if (d.days > today.days) { - try cli.stderrPrint("Error: --as-of date is in the future.\n"); + try cli.stderrPrint("Error: date is in the future.\n"); return 1; } - as_of = d; + 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; // consume the value } else { try reportUnexpectedArg("projections", a); return 1; } } + if (as_of != null and vs_date == null) { + // Single-date mode: view that snapshot only. + } const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); defer if (pf.resolved) |r| r.deinit(allocator); - try commands.projections.run(allocator, &svc, pf.path, events_enabled, as_of, color, out); + if (vs_date) |d| { + // Compare mode. `as_of` (if set) designates the "now" + // side — otherwise now is live. `--vs` alone compares + // live against a historical date; `--vs X --as-of Y` + // compares two historical dates with Y being the later + // one. + try commands.projections.runCompare(allocator, &svc, pf.path, events_enabled, d, as_of, color, out); + } else { + try commands.projections.run(allocator, &svc, pf.path, events_enabled, as_of, color, out); + } } else if (std.mem.eql(u8, command, "contributions")) { var since: ?zfin.Date = null; var until: ?zfin.Date = null;