//! `zfin compare []` //! //! Compare two points in time for the portfolio. //! //! Single-date mode: `zfin compare 2024-01-15` — compares the named //! snapshot against the current live portfolio. //! //! Two-date mode: `zfin compare 2024-01-15 2024-03-15` — compares two //! historical snapshots. Order of arguments doesn't matter; the command //! always displays older → newer. //! //! ## Output //! //! Shape only (values illustrative): //! //! ``` //! Portfolio comparison: (N days) //! //! Liquid: <+/-delta> <+/-pct%> //! //! Per-symbol price change (K held throughout) //! SYM1 <+/-pct%> <+/-dollar> //! SYM2 <+/-pct%> <+/-dollar> //! ... //! //! (A added, R removed since — hidden) //! ``` //! //! ## Missing snapshot //! //! If the exact date isn't in `history/`, we print the nearest earlier //! and later available dates to stderr and exit non-zero — we don't //! silently snap, because the user should pick which direction. //! //! ## Structure //! //! Most of the work happens elsewhere: //! - `src/history.zig` — single-snapshot IO (loadSnapshotAt, //! findNearestSnapshot) //! - `src/compare.zig` — Side-loading + aggregation //! - `src/views/compare.zig` — pure view model //! //! This file owns the CLI-specific pieces: arg parsing, the //! live-portfolio pipeline (fetch prices + build summary), the //! stderr-to-user "nearest snapshot" suggestions, and the ANSI //! renderer. const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; const Date = zfin.Date; const history = @import("../history.zig"); const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); const view_hist = @import("../views/history.zig"); const contributions_cmd = @import("contributions.zig"); pub const Error = error{ UnexpectedArg, MissingDateArg, InvalidDate, SameDate, SnapshotNotFound, PortfolioLoadFailed, }; /// Command entry point. pub fn run( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, cmd_args: []const []const u8, color: bool, out: *std.Io.Writer, ) !void { // ── Parse args ─────────────────────────────────────────── if (cmd_args.len == 0) { try cli.stderrPrint("Error: 'compare' requires one or two dates (YYYY-MM-DD).\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"); return error.MissingDateArg; } if (cmd_args.len > 2) { try cli.stderrPrint("Error: 'compare' takes at most two arguments.\n"); return error.UnexpectedArg; } const date1 = Date.parse(cmd_args[0]) catch { try cli.stderrPrint("Error: invalid date format: "); try cli.stderrPrint(cmd_args[0]); try cli.stderrPrint(" (expected YYYY-MM-DD)\n"); return error.InvalidDate; }; // Resolve (then_date, now_date, now_is_live). In single-date mode the // 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) Date.parse(cmd_args[1]) catch { try cli.stderrPrint("Error: invalid date format: "); try cli.stderrPrint(cmd_args[1]); try cli.stderrPrint(" (expected YYYY-MM-DD)\n"); return error.InvalidDate; } else null; const now_is_live = date2 == null; // SAFETY: both unconditionally assigned in every branch of the // `if (now_is_live)` block below before first read. var then_date: Date = undefined; // SAFETY: see above. var now_date: Date = undefined; if (now_is_live) { const today = fmt.todayDate(); if (date1.days == today.days) { try cli.stderrPrint("Error: cannot compare today against today's live portfolio.\n"); return error.SameDate; } // A future date in single-date mode is nonsense — the "then" // snapshot wouldn't exist and we'd compare live-now against // live-now anyway. Reject early. if (date1.days > today.days) { try cli.stderrPrint("Error: cannot compare against a future date.\n"); return error.InvalidDate; } then_date = date1; now_date = today; } else { if (date1.days == date2.?.days) { try cli.stderrPrint("Error: both dates are the same — nothing to compare.\n"); return error.SameDate; } then_date = if (date1.days < date2.?.days) date1 else date2.?; now_date = if (date1.days < date2.?.days) date2.? else date1; } // ── Resolve history dir ────────────────────────────────── const hist_dir = try history.deriveHistoryDir(allocator, portfolio_path); defer allocator.free(hist_dir); // ── Load both sides ────────────────────────────────────── // // "Then" is always a snapshot. "Now" is either another snapshot // (two-date mode) or the live portfolio (single-date mode). Once // loaded, both sides are shaped identically — a HoldingMap + liquid // total — and feed a single comparison path below. var then_side = compare_core.loadSnapshotSide(allocator, hist_dir, then_date) catch |err| switch (err) { error.FileNotFound => { try suggestNearest(allocator, hist_dir, then_date); return error.SnapshotNotFound; }, else => return err, }; defer then_side.deinit(allocator); if (now_is_live) { var now_live = try LiveSide.load(allocator, svc, portfolio_path, color); defer now_live.deinit(allocator); // Attribution spans then_date → HEAD (or working copy if dirty). // `computeAttribution` with until=null uses exactly that semantics. const attribution = contributions_cmd.computeAttribution(allocator, svc, portfolio_path, then_date, null, color); try renderFromParts(out, color, allocator, .{ .then_date = then_date, .now_date = now_date, .now_is_live = true, .then_liquid = then_side.liquid, .now_liquid = now_live.liquid, .then_map = &then_side.map, .now_map = &now_live.map, .attribution = attribution, }); } else { var now_side = compare_core.loadSnapshotSide(allocator, hist_dir, now_date) catch |err| switch (err) { error.FileNotFound => { try suggestNearest(allocator, hist_dir, now_date); return error.SnapshotNotFound; }, else => return err, }; defer now_side.deinit(allocator); // Attribution spans the explicit then_date → now_date window. const attribution = contributions_cmd.computeAttribution(allocator, svc, portfolio_path, then_date, now_date, color); try renderFromParts(out, color, allocator, .{ .then_date = then_date, .now_date = now_date, .now_is_live = false, .then_liquid = then_side.liquid, .now_liquid = now_side.liquid, .then_map = &then_side.map, .now_map = &now_side.map, .attribution = attribution, }); } } /// Inputs needed to build + render a `CompareView`. Bundled into a /// struct so `renderFromParts` stays one line of call-site noise /// instead of an 11-positional-arg parade. /// /// `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. const RenderArgs = struct { then_date: Date, now_date: Date, now_is_live: bool, then_liquid: f64, now_liquid: f64, then_map: *const view.HoldingMap, now_map: *const view.HoldingMap, attribution: ?contributions_cmd.AttributionSummary, }; /// Build the view from two holdings maps + totals, then render. /// Factored out so both the live and snapshot "now" paths share a /// single call site. /// /// `args.attribution` is optional — when the contributions pipeline /// resolves cleanly against the portfolio's git history, the /// contributions-vs-gains split is surfaced in the rendered output. /// Null when git is unavailable or the window doesn't map to commits. fn renderFromParts( out: *std.Io.Writer, color: bool, allocator: std.mem.Allocator, args: RenderArgs, ) !void { var cv = try view.buildCompareView( allocator, args.then_date, args.now_date, args.now_is_live, args.then_liquid, args.now_liquid, args.then_map, args.now_map, ); defer cv.deinit(allocator); // Wire the attribution into the view so the renderer can surface // it. `total()` is the caller's numeric — gains are derived from // the liquid delta. if (args.attribution) |a| { cv.attribution = .{ .contributions = a.total(), .gains = cv.liquid.delta - a.total(), }; } try renderCompare(out, color, cv); } // ── Live-portfolio side (CLI-only) ─────────────────────────── /// Owning bundle for the live-portfolio endpoint used by CLI /// single-date mode. Fetches prices, builds a PortfolioSummary, and /// aggregates the live stock lots into a HoldingMap. /// /// Not used by the TUI — the TUI uses its already-loaded portfolio /// state directly and calls `compare_core.aggregateLiveStocks` inline. const LiveSide = struct { loaded: cli.LoadedPortfolio, pf_data: cli.PortfolioData, prices: std.StringHashMap(f64), map: view.HoldingMap, liquid: f64, fn load( allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, color: bool, ) !LiveSide { var loaded_pf = cli.loadPortfolio(allocator, portfolio_path) orelse return error.PortfolioLoadFailed; errdefer loaded_pf.deinit(allocator); if (loaded_pf.portfolio.lots.len == 0) { try cli.stderrPrint("Portfolio is empty.\n"); return error.PortfolioLoadFailed; } var prices = std.StringHashMap(f64).init(allocator); errdefer prices.deinit(); if (loaded_pf.syms.len > 0) { var load_result = cli.loadPortfolioPrices(svc, loaded_pf.syms, &.{}, false, color); defer load_result.deinit(); var it = load_result.prices.iterator(); while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; } var pf_data = cli.buildPortfolioData( allocator, loaded_pf.portfolio, loaded_pf.positions, loaded_pf.syms, &prices, svc, ) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint("Error computing portfolio summary.\n"); return error.PortfolioLoadFailed; }, else => return err, }; errdefer pf_data.deinit(allocator); var map: view.HoldingMap = .init(allocator); errdefer map.deinit(); try compare_core.aggregateLiveStocks(&loaded_pf.portfolio, &prices, &map); return .{ .loaded = loaded_pf, .pf_data = pf_data, .prices = prices, .map = map, .liquid = pf_data.summary.total_value, }; } fn deinit(self: *LiveSide, allocator: std.mem.Allocator) void { self.map.deinit(); self.prices.deinit(); self.pf_data.deinit(allocator); self.loaded.deinit(allocator); } }; // ── Nearest-snapshot suggestion (stderr, CLI-only) ─────────── /// Print a "no snapshot for " message plus the nearest earlier /// and later available dates to stderr. Wraps the pure /// `history.findNearestSnapshot` with CLI-specific output. fn suggestNearest( allocator: std.mem.Allocator, hist_dir: []const u8, target: Date, ) !void { const nearest = history.findNearestSnapshot(hist_dir, target) catch |err| { try cli.stderrPrint("Error scanning history directory: "); try cli.stderrPrint(@errorName(err)); try cli.stderrPrint("\n"); return; }; var buf: [128]u8 = undefined; var target_buf: [10]u8 = undefined; const target_str = target.format(&target_buf); const line = try std.fmt.allocPrint(allocator, "No snapshot for {s}.\n", .{target_str}); defer allocator.free(line); try cli.stderrPrint(line); if (nearest.earlier == null and nearest.later == null) { try cli.stderrPrint("No snapshots in "); try cli.stderrPrint(hist_dir); try cli.stderrPrint(" — run `zfin snapshot` to create one.\n"); return; } try cli.stderrPrint("Nearest available:\n"); if (nearest.earlier) |d| { var d_buf: [10]u8 = undefined; const d_str = d.format(&d_buf); const diff = target.days - d.days; const msg = try std.fmt.bufPrint(&buf, " earlier: {s} ({d} day{s} before)\n", .{ d_str, diff, view.dayPlural(diff) }); try cli.stderrPrint(msg); } if (nearest.later) |d| { var d_buf: [10]u8 = undefined; const d_str = d.format(&d_buf); const diff = d.days - target.days; const msg = try std.fmt.bufPrint(&buf, " later: {s} ({d} day{s} after)\n", .{ d_str, diff, view.dayPlural(diff) }); try cli.stderrPrint(msg); } } // ── ANSI rendering (CLI-only) ──────────────────────────────── // // Thin adapter: pulls pre-formatted cells from `views/compare.zig` // and drops them into an ANSI-colored layout. Column widths, money // formatting, and label pluralization all come from the view layer — // this function owns only the styling mechanism (ANSI escapes) and // 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 { var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; const then_str = cv.then_date.format(&then_buf); const now_str = view.nowLabel(cv, &now_buf); // Header try cli.setBold(out, color); try cli.printFg(out, color, cli.CLR_HEADER, "Portfolio comparison: {s} → {s} ({d} day{s})\n", .{ then_str, now_str, cv.days_between, view.dayPlural(cv.days_between), }); try out.print("\n", .{}); // Totals line — two-color: muted "then → now", intent-colored delta/pct. try renderTotalsLine(out, color, cv.liquid); // Optional attribution line: breaks the liquid delta into // contributions vs. market gains/losses. Only present when the // `compare` CLI had a git repo to work with. if (cv.attribution) |a| { try renderAttributionLine(out, color, cv.liquid.delta, a); } try out.print("\n", .{}); // Per-symbol table if (cv.held_count == 0) { try cli.printFg(out, color, cli.CLR_MUTED, "No symbols held throughout this period.\n", .{}); } else { try cli.printFg(out, color, cli.CLR_MUTED, "Per-symbol price change ({d} held throughout)\n", .{cv.held_count}); for (cv.symbols) |s| { try renderSymbolRow(out, color, s); } } // Hidden count if (cv.added_count > 0 or cv.removed_count > 0) { try out.print("\n", .{}); try cli.printFg(out, color, cli.CLR_MUTED, "({d} added, {d} removed since {s} — hidden)\n", .{ cv.added_count, cv.removed_count, then_str, }); } } fn renderTotalsLine(out: *std.Io.Writer, color: bool, t: view.TotalsRow) !void { var then_buf: [24]u8 = undefined; var now_buf: [24]u8 = undefined; var delta_buf: [32]u8 = undefined; var pct_buf: [16]u8 = undefined; const c = view.buildTotalsCells(t, &then_buf, &now_buf, &delta_buf, &pct_buf); try out.print("Liquid: ", .{}); try cli.printFg(out, color, cli.CLR_MUTED, "{s}{s}{s}", .{ c.then, view.arrow, c.now }); try cli.printIntent(out, color, c.style, " {s} {s}\n", .{ c.delta, c.pct }); } /// Render the contributions-vs-gains attribution line directly beneath /// the Liquid totals. Matches the email format: /// /// Attribution: +$30,148.02 delta = +$22,636.00 contributions + +$7,512.02 gains /// /// All three amounts are signed: negative contributions (net /// withdrawal) and negative gains (market loss) both print with a /// leading `-`. Indent of the label column aligns with "Liquid:". fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attribution: view.Attribution) !void { var delta_buf: [32]u8 = undefined; var contrib_buf: [32]u8 = undefined; var gains_buf: [32]u8 = undefined; const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, delta); const contrib_str = view_hist.fmtSignedMoneyBuf(&contrib_buf, attribution.contributions); const gains_str = view_hist.fmtSignedMoneyBuf(&gains_buf, attribution.gains); // Sign-aware joiner between `contributions` and `gains`: // gains >= 0 → " + " (explicit addition). // gains < 0 → " " (the leading "-" on gains_str carries the sign; // avoids visual clutter of "+$X + -$Y"). const joiner: []const u8 = if (attribution.gains >= 0) " + " else " "; try cli.printFg(out, color, cli.CLR_MUTED, "Attribution: ", .{}); try cli.printGainLoss(out, color, delta, "{s}", .{delta_str}); try cli.printFg(out, color, cli.CLR_MUTED, " delta = ", .{}); try cli.printGainLoss(out, color, attribution.contributions, "{s}", .{contrib_str}); try cli.printFg(out, color, cli.CLR_MUTED, " contributions{s}", .{joiner}); try cli.printGainLoss(out, color, attribution.gains, "{s}", .{gains_str}); try cli.printFg(out, color, cli.CLR_MUTED, " gains\n", .{}); } fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void { var then_buf: [24]u8 = undefined; var now_buf: [24]u8 = undefined; var pct_buf: [16]u8 = undefined; var dollar_buf: [32]u8 = undefined; const c = view.buildSymbolRowCells(s, &then_buf, &now_buf, &pct_buf, &dollar_buf); // Leading indent + symbol in default color. try out.print(" " ++ view.symbol_fmt ++ " ", .{c.symbol}); // "then → now" in muted color. try cli.printFg(out, color, cli.CLR_MUTED, view.price_right_fmt ++ "{s}" ++ view.price_left_fmt, .{ c.price_then, view.arrow, c.price_now }); // Delta/pct in intent color. try cli.printIntent(out, color, c.style, " " ++ view.pct_fmt ++ " " ++ view.dollar_fmt ++ "\n", .{ c.pct, c.dollar }); } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; const snapshot_model = @import("../models/snapshot.zig"); // Aggregation and liquid-from-snapshot tests moved to src/compare.zig. // Snapshot-IO tests (findNearestSnapshot, loadSnapshotAt) moved to // src/history.zig. This file keeps only CLI-surface tests. test "renderCompare: basic output includes expected elements" { // Build a minimal comparison view by hand. Symbols and dollar // values are intentionally generic/round — this test is about the // rendering scaffolding, not about matching anyone's real portfolio. const symbols = [_]view.SymbolChange{ .{ .symbol = "FOO", .price_then = 100.00, .price_now = 110.00, .shares_held_throughout = 100, .pct_change = 0.10, .dollar_change = 1000, .style = .positive, }, .{ .symbol = "BAR", .price_then = 50.00, .price_now = 49.00, .shares_held_throughout = 200, .pct_change = -0.02, .dollar_change = -200, .style = .negative, }, }; const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 1, 25), .days_between = 10, .now_is_live = true, .liquid = view.buildTotalsRow(10_000, 10_500), .symbols = @constCast(&symbols), .held_count = 2, .added_count = 3, .removed_count = 1, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); // Header try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → today") != null); try testing.expect(std.mem.indexOf(u8, out, "(10 days)") != null); // Totals try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); try testing.expect(std.mem.indexOf(u8, out, "$10,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "$10,500.00") != null); try testing.expect(std.mem.indexOf(u8, out, "+$500.00") != null); // Per-symbol try testing.expect(std.mem.indexOf(u8, out, "held throughout") != null); try testing.expect(std.mem.indexOf(u8, out, "FOO") != null); try testing.expect(std.mem.indexOf(u8, out, "BAR") != null); // Hidden try testing.expect(std.mem.indexOf(u8, out, "(3 added, 1 removed since 2024-01-15 — hidden)") != null); } test "renderCompare: two-snapshot mode shows real date, not 'today'" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(100, 110), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-03-15") != null); try testing.expect(std.mem.indexOf(u8, out, "today") == null); try testing.expect(std.mem.indexOf(u8, out, "No symbols held throughout") != null); // No "hidden" line when both counts are zero try testing.expect(std.mem.indexOf(u8, out, "hidden") == null); } test "renderCompare: 1-day diff uses singular 'day'" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 1, 16), .days_between = 1, .now_is_live = false, .liquid = view.buildTotalsRow(100, 100), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(1 day)") != null); try testing.expect(std.mem.indexOf(u8, out, "(1 days)") == null); } test "renderCompare: only added positions (no removed)" { const symbols = [_]view.SymbolChange{ .{ .symbol = "AAPL", .price_then = 150, .price_now = 160, .shares_held_throughout = 100, .pct_change = 0.0667, .dollar_change = 1000, .style = .positive, }, }; const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(10000, 11000), .symbols = @constCast(&symbols), .held_count = 1, .added_count = 2, .removed_count = 0, }; var buf: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2024-01-15 — hidden)") != null); } test "renderCompare: negative totals delta" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(1_000_000, 900_000), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, }; var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); // Delta is signed negative; pct same try testing.expect(std.mem.indexOf(u8, out, "-$100,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "-10.00%") != null); } test "renderCompare: attribution line when attribution is set" { const cv = view.CompareView{ .then_date = Date.fromYmd(2026, 3, 13), .now_date = Date.fromYmd(2026, 4, 2), .days_between = 20, .now_is_live = true, .liquid = view.buildTotalsRow(7_698_825.62, 7_728_973.64), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, // Numbers match the real-world email example: // +$30,148 delta = +$22,636 contributions + +$7,512 gains .attribution = .{ .contributions = 22_636.00, .gains = 7_512.02, }, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Attribution:") != null); try testing.expect(std.mem.indexOf(u8, out, "+$30,148.02") != null); try testing.expect(std.mem.indexOf(u8, out, "+$22,636.00") != null); try testing.expect(std.mem.indexOf(u8, out, "+$7,512.02") != null); try testing.expect(std.mem.indexOf(u8, out, "contributions") != null); try testing.expect(std.mem.indexOf(u8, out, "gains") != null); } test "renderCompare: no attribution line when attribution is null" { const cv = view.CompareView{ .then_date = Date.fromYmd(2024, 1, 15), .now_date = Date.fromYmd(2024, 3, 15), .days_between = 60, .now_is_live = false, .liquid = view.buildTotalsRow(100, 110), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, // attribution intentionally omitted (defaults to null) }; var buf: [2048]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == null); } test "renderCompare: attribution handles negative gains" { // Window where contributions happened but market fell. const cv = view.CompareView{ .then_date = Date.fromYmd(2026, 3, 13), .now_date = Date.fromYmd(2026, 4, 2), .days_between = 20, .now_is_live = true, // Liquid went UP (net), but only because contributions // overcompensated for market losses. .liquid = view.buildTotalsRow(1_000_000, 1_005_000), .symbols = &.{}, .held_count = 0, .added_count = 0, .removed_count = 0, .attribution = .{ .contributions = 15_000, .gains = -10_000, // delta − contributions = 5000 − 15000 = −10k }, }; var buf: [4096]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); try renderCompare(&stream, false, cv); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "+$15,000.00") != null); try testing.expect(std.mem.indexOf(u8, out, "-$10,000.00") != null); } // ── run() entry-point validation tests ───────────────────────── fn makeTestSvc() zfin.DataService { // Minimal in-memory config. `cache_dir` must be set; "/tmp" is fine // since these tests never hit the cache. const config = zfin.Config{ .cache_dir = "/tmp" }; return zfin.DataService.init(testing.allocator, config); } 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 "run: zero args returns MissingDateArg" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const result = run(testing.allocator, &svc, pf, &.{}, false, &stream); try testing.expectError(error.MissingDateArg, result); } test "run: three args returns UnexpectedArg" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-02-15", "2024-03-15" }; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.UnexpectedArg, result); } test "run: bad date1 returns InvalidDate" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"not-a-date"}; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: valid date1 + bad date2 returns InvalidDate" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024/03/15" }; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: same date twice returns SameDate" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{ "2024-01-15", "2024-01-15" }; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.SameDate, result); } test "run: one date equal to today returns SameDate" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); var today_buf: [10]u8 = undefined; const today_str = fmt.todayDate().format(&today_buf); const args = [_][]const u8{today_str}; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.SameDate, result); } test "run: single-date past-date with empty history returns SnapshotNotFound" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"2020-01-01"}; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.SnapshotNotFound, result); } test "run: single-date future-date rejected as InvalidDate" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); const args = [_][]const u8{"2099-01-01"}; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.InvalidDate, result); } test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)" { 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: [1024]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); // Intentionally reversed — verifies the swap happens without // error (both dates will fail to load with SnapshotNotFound). const args = [_][]const u8{ "2024-03-15", "2024-01-15" }; const result = run(testing.allocator, &svc, pf, &args, false, &stream); try testing.expectError(error.SnapshotNotFound, result); } test "run: two-date happy path via fixtures" { 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 d1 = Date.fromYmd(2024, 1, 15); const d2 = Date.fromYmd(2024, 3, 15); const lots_then = [_]snapshot_model.LotRow{ .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "Roth", .security_type = "Stock", .shares = 100, .open_price = 120, .cost_basis = 12_000, .value = 15_000, .price = 150.0, .quote_date = d1, }, }; const lots_now = [_]snapshot_model.LotRow{ .{ .kind = "lot", .symbol = "AAPL", .lot_symbol = "AAPL", .account = "Roth", .security_type = "Stock", .shares = 100, .open_price = 120, .cost_basis = 12_000, .value = 16_500, .price = 165.0, .quote_date = d2, }, }; try writeFixtureSnapshot(hist_dir, testing.allocator, "2024-01-15-portfolio.srf", d1, 15_000, 15_000, &lots_then); try writeFixtureSnapshot(hist_dir, testing.allocator, "2024-03-15-portfolio.srf", d2, 16_500, 16_500, &lots_now); 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 args = [_][]const u8{ "2024-01-15", "2024-03-15" }; try run(testing.allocator, &svc, pf, &args, false, &stream); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try testing.expect(std.mem.indexOf(u8, out, "+10.00%") != null); } fn writeFixtureSnapshot( dir: std.fs.Dir, allocator: std.mem.Allocator, filename: []const u8, as_of: Date, liquid: f64, net_worth: f64, stock_rows: []const snapshot_model.LotRow, ) !void { const snapshot_cmd = @import("snapshot.zig"); const totals = [_]snapshot_model.TotalRow{ .{ .kind = "total", .scope = "net_worth", .value = net_worth }, .{ .kind = "total", .scope = "liquid", .value = liquid }, .{ .kind = "total", .scope = "illiquid", .value = net_worth - liquid }, }; 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(stock_rows), }; const rendered = try snapshot_cmd.renderSnapshot(allocator, snap); defer allocator.free(rendered); try dir.writeFile(.{ .sub_path = filename, .data = rendered }); }