From 6a2cc8e775789dd24ed92f5a5f14d79804a0bbd4 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 1 May 2026 08:52:43 -0700 Subject: [PATCH] add new compare command (CLI only for now) --- README.md | 56 ++ src/commands/compare.zig | 1465 ++++++++++++++++++++++++++++++++++++++ src/main.zig | 18 + src/views/compare.zig | 460 ++++++++++++ 4 files changed, 1999 insertions(+) create mode 100644 src/commands/compare.zig create mode 100644 src/views/compare.zig diff --git a/README.md b/README.md index ab0fe0e..93b1394 100644 --- a/README.md +++ b/README.md @@ -175,12 +175,66 @@ Commands: earnings Earnings history and upcoming events etf ETF profile (expense ratio, holdings, sectors) portfolio Portfolio analysis from .srf file + snapshot [opts] Write a daily portfolio snapshot to history/ + compare [] + Compare portfolio state across two dates cache stats Show cached symbols cache clear Delete all cached data interactive, i Launch interactive TUI help Show usage ``` +### compare + +Compare the portfolio at two points in time. Useful for answering +"how am I doing since X" without the noise of the full portfolio +display. + +``` +zfin compare Compare snapshot at DATE vs current live portfolio +zfin compare Compare two historical snapshots +``` + +Arguments can be given in any order — the command always displays +older → newer. Dates are `YYYY-MM-DD`. Snapshots come from +`history/YYYY-MM-DD-portfolio.srf` files produced by +`zfin snapshot` (typically run via cron). + +**Output:** + +- **Liquid:** raw value change — includes any contributions or + withdrawals made between the two dates (adjusting for flows is + out of scope). +- **Per-symbol price change:** for symbols held on *both* dates. + Sorted by % change descending (biggest winners first). The dollar + column uses the shares-held-throughout floor (`min(shares_then, + shares_now)`) so newly-added shares don't inflate it and sold + shares don't deflate it. +- **Hidden count:** positions added or removed between the two dates + are counted but not rendered. + +On a missing snapshot date, the command prints the nearest earlier +and later available dates to stderr and exits 1 — no silent +snapping. + +Example output shape (values illustrative): + +``` +$ zfin compare 2024-01-15 2024-03-15 +Portfolio comparison: 2024-01-15 → 2024-03-15 (60 days) + +Liquid: $100,000.00 → $105,000.00 +$5,000.00 +5.00% + +Per-symbol price change (5 held throughout) + FOO $40.00 → $44.00 +10.00% +$400.00 + BAR $100.00 → $105.00 +5.00% +$250.00 + ... + BAZ $50.00 → $48.00 -4.00% -$80.00 + +(1 added, 1 removed since 2024-01-15 — hidden) +``` + + ### Interactive TUI flags ``` @@ -472,6 +526,8 @@ Commands: etf ETF profile (expense ratio, holdings, sectors) portfolio [FILE] Portfolio summary (default: portfolio.srf) analysis [FILE] Portfolio analysis breakdowns (default: portfolio.srf) + snapshot [opts] Write a daily portfolio snapshot to history/ + compare [] Compare portfolio state across two dates enrich Generate metadata.srf from Alpha Vantage lookup CUSIP to ticker lookup via OpenFIGI cache stats Show cached symbols diff --git a/src/commands/compare.zig b/src/commands/compare.zig new file mode 100644 index 0000000..86499fe --- /dev/null +++ b/src/commands/compare.zig @@ -0,0 +1,1465 @@ +//! `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. +//! +//! ## Semantics +//! +//! See `src/views/compare.zig` for the full rationale. In summary: +//! raw liquid delta (includes flows), per-symbol price-only % change, +//! dollar impact at `min(shares)` (held-throughout floor), symbols in +//! both snapshots only. + +const std = @import("std"); +const zfin = @import("../root.zig"); +const cli = @import("common.zig"); +const fmt = cli.fmt; +const Date = zfin.Date; +const history_io = @import("../history.zig"); +const snapshot_model = @import("../models/snapshot.zig"); +const view = @import("../views/compare.zig"); +const view_hist = @import("../views/history.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_io.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 = try loadSnapshotSide(allocator, hist_dir, then_date); + defer then_side.deinit(allocator); + + var now_side = if (now_is_live) + try loadLiveSide(allocator, svc, portfolio_path, color) + else + try loadSnapshotSide(allocator, hist_dir, now_date); + defer now_side.deinit(allocator); + + // ── Build view + render ────────────────────────────────── + var cv = try view.buildCompareView( + allocator, + then_date, + now_date, + now_is_live, + then_side.liquid, + now_side.liquid, + &then_side.map, + &now_side.map, + ); + defer cv.deinit(allocator); + + try renderCompare(out, color, cv); +} + +// ── Side loading ───────────────────────────────────────────── + +/// One side of the comparison — aggregated per-symbol holdings plus the +/// liquid total for that point in time. Owns any backing resources +/// (snapshot bytes or live-portfolio state) that the map strings borrow +/// from, so the caller just calls `deinit` and doesn't need to know +/// which source the side came from. +const Side = struct { + map: view.HoldingMap, + liquid: f64, + resources: Resources, + + const Resources = union(enum) { + snapshot: LoadedSnapshot, + live: LiveResources, + }; + + fn deinit(self: *Side, allocator: std.mem.Allocator) void { + self.map.deinit(); + switch (self.resources) { + .snapshot => |*s| s.deinit(allocator), + .live => |*l| l.deinit(allocator), + } + } +}; + +/// Backing resources for the live-portfolio side: the parsed portfolio +/// file plus the derived summary/prices. Kept together so `Side.deinit` +/// can tear them down in one sweep. +const LiveResources = struct { + loaded: cli.LoadedPortfolio, + pf_data: cli.PortfolioData, + prices: std.StringHashMap(f64), + + fn deinit(self: *LiveResources, allocator: std.mem.Allocator) void { + self.prices.deinit(); + self.pf_data.deinit(allocator); + self.loaded.deinit(allocator); + } +}; + +/// Load the snapshot for `date` from `hist_dir` and build a Side from +/// its stock lots + liquid total. On miss, prints nearest-available +/// dates to stderr and returns `error.SnapshotNotFound`. +fn loadSnapshotSide( + allocator: std.mem.Allocator, + hist_dir: []const u8, + date: Date, +) !Side { + var loaded = try loadSnapshotOrSuggest(allocator, hist_dir, date); + errdefer loaded.deinit(allocator); + + var map: view.HoldingMap = .init(allocator); + errdefer map.deinit(); + try aggregateSnapshotStocks(&loaded.snap, &map); + + return .{ + .map = map, + .liquid = liquidFromSnapshot(&loaded.snap), + .resources = .{ .snapshot = loaded }, + }; +} + +/// Load the live portfolio, fetch prices, and build a Side from the +/// open stock lots + `PortfolioSummary.total_value` (which is the +/// liquid total — see `adjustForNonStockAssets` in analytics/valuation.zig). +fn loadLiveSide( + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + color: bool, +) !Side { + 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 aggregateLiveStocks(&loaded_pf.portfolio, &prices, &map); + + return .{ + .map = map, + .liquid = pf_data.summary.total_value, + .resources = .{ .live = .{ + .loaded = loaded_pf, + .pf_data = pf_data, + .prices = prices, + } }, + }; +} + +// ── Snapshot loading ───────────────────────────────────────── + +/// Owned snapshot + its backing byte buffer. Deinit in reverse. +const LoadedSnapshot = struct { + snap: snapshot_model.Snapshot, + bytes: []u8, + + pub fn deinit(self: *LoadedSnapshot, allocator: std.mem.Allocator) void { + self.snap.deinit(allocator); + allocator.free(self.bytes); + } +}; + +/// Load the snapshot file at `hist_dir/-portfolio.srf`. On miss, +/// scan `hist_dir` for the nearest earlier and later snapshots and print +/// them to stderr so the user can pick, then return +/// `error.SnapshotNotFound`. Returns `error.NoSnapshotsAvailable` if +/// there are zero snapshots at all. +fn loadSnapshotOrSuggest( + allocator: std.mem.Allocator, + hist_dir: []const u8, + target: Date, +) !LoadedSnapshot { + // Build the canonical file path. + var date_buf: [10]u8 = undefined; + const date_str = target.format(&date_buf); + const filename = try std.fmt.allocPrint(allocator, "{s}-portfolio.srf", .{date_str}); + defer allocator.free(filename); + const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename }); + defer allocator.free(full_path); + + const bytes = std.fs.cwd().readFileAlloc(allocator, full_path, 16 * 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => { + try suggestNearest(allocator, hist_dir, target); + return error.SnapshotNotFound; + }, + else => return err, + }; + errdefer allocator.free(bytes); + + const snap = history_io.parseSnapshotBytes(allocator, bytes) catch |err| { + try cli.stderrPrint("Error: failed to parse snapshot file: "); + try cli.stderrPrint(full_path); + try cli.stderrPrint("\n"); + return err; + }; + + return .{ .snap = snap, .bytes = bytes }; +} + +/// Scan `hist_dir` for `*-portfolio.srf` files, find the nearest earlier +/// and later dates relative to `target`, and print to stderr. Always +/// prints *something* — at minimum a "no snapshots available" message. +fn suggestNearest( + allocator: std.mem.Allocator, + hist_dir: []const u8, + target: Date, +) !void { + const nearest = findNearestSnapshot(allocator, 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, pluralS(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, pluralS(diff) }); + try cli.stderrPrint(msg); + } +} + +fn pluralS(n: i32) []const u8 { + return if (n == 1) "" else "s"; +} + +pub const Nearest = struct { + earlier: ?Date, + later: ?Date, +}; + +/// Scan `hist_dir` for `YYYY-MM-DD-portfolio.srf` files and return the +/// dates closest to `target` — the largest earlier-or-equal-minus-1 date +/// (strictly earlier than `target`) and the smallest strictly-later date. +/// Files whose name doesn't parse as `YYYY-MM-DD-portfolio.srf` are +/// silently ignored. +pub fn findNearestSnapshot( + allocator: std.mem.Allocator, + hist_dir: []const u8, + target: Date, +) !Nearest { + _ = allocator; // currently no heap use; kept for future-proofing + var dir = std.fs.cwd().openDir(hist_dir, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return .{ .earlier = null, .later = null }, + else => return err, + }; + defer dir.close(); + + var earlier: ?Date = null; + var later: ?Date = null; + + var it = dir.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, history_io.snapshot_suffix)) continue; + // Expect exactly "YYYY-MM-DD" + suffix. + const expected_len = 10 + history_io.snapshot_suffix.len; + if (entry.name.len != expected_len) continue; + const d = Date.parse(entry.name[0..10]) catch continue; + + if (d.days < target.days) { + if (earlier == null or d.days > earlier.?.days) earlier = d; + } else if (d.days > target.days) { + if (later == null or d.days < later.?.days) later = d; + } + // Exact hit shouldn't happen (we get here only on miss), but + // ignoring it is the right call either way — we're looking for + // neighbors of the missing date. + } + + return .{ .earlier = earlier, .later = later }; +} + +// ── Aggregation helpers ────────────────────────────────────── + +/// Walk a snapshot's lot rows, filter to `security_type == "Stock"`, +/// and group by symbol into `out_map`. Shares are summed; price is +/// taken from the first lot seen (all stock lots of a symbol share +/// the same `price` field in a given snapshot). +/// +/// `out_map` keys and values both borrow from the snapshot's backing +/// byte buffer (for keys) and from the stack (for values). Caller must +/// keep the snapshot alive as long as the map is used. +pub fn aggregateSnapshotStocks( + snap: *const snapshot_model.Snapshot, + out_map: *view.HoldingMap, +) !void { + for (snap.lots) |lot| { + if (!std.mem.eql(u8, lot.security_type, "Stock")) continue; + const price = lot.price orelse continue; + if (out_map.getPtr(lot.symbol)) |h| { + h.shares += lot.shares; + // price is already set from first-seen; leave it. + } else { + try out_map.put(lot.symbol, .{ .shares = lot.shares, .price = price }); + } + } +} + +/// Walk the live portfolio's stock lots, group by `priceSymbol()`, +/// and look up the current price from `prices`. Mirrors the snapshot +/// aggregation so the two sides are apples-to-apples. +pub fn aggregateLiveStocks( + portfolio: *const zfin.Portfolio, + prices: *const std.StringHashMap(f64), + out_map: *view.HoldingMap, +) !void { + const today = fmt.todayDate(); + for (portfolio.lots) |lot| { + if (lot.security_type != .stock) continue; + if (!lot.lotIsOpenAsOf(today)) continue; + const sym = lot.priceSymbol(); + const raw_price = prices.get(sym) orelse continue; + // Honor `price_ratio` the same way snapshot does via + // `lot.effectivePrice` (false = raw price, we want effective). + const eff_price = lot.effectivePrice(raw_price, false); + if (out_map.getPtr(sym)) |h| { + h.shares += lot.shares; + // Price should be identical across lots of the same symbol + // (same raw price × same price_ratio). Leave first-seen. + } else { + try out_map.put(sym, .{ .shares = lot.shares, .price = eff_price }); + } + } +} + +/// Find the `scope=="liquid"` total in a snapshot. Returns 0.0 if not +/// present (old snapshots from before liquid/illiquid split — shouldn't +/// happen in practice). +pub fn liquidFromSnapshot(snap: *const snapshot_model.Snapshot) f64 { + for (snap.totals) |t| { + if (std.mem.eql(u8, t.scope, "liquid")) return t.value; + } + return 0.0; +} + +// ── Rendering ──────────────────────────────────────────────── + +/// Column widths, chosen so symbols up to 8 chars plus $999,999.99 +/// prices and ±999.99% pcts all fit. `display_symbol` for CUSIP-note +/// positions (e.g. "TGT2035") also fits. +const col = struct { + const symbol_w: usize = 8; + const price_w: usize = 10; // $999,999.99 + const pct_w: usize = 10; // +999.99% + const dollar_w: usize = 16; // +$99,999,999.99 with slack + const arrow: []const u8 = " → "; +}; + +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 = if (cv.now_is_live) "today" else blk: { + _ = cv.now_date.format(&now_buf); + break :blk now_buf[0..10]; + }; + + // ── Header ── + try cli.setFg(out, color, cli.CLR_HEADER); + try cli.setBold(out, color); + try out.print("Portfolio comparison: {s} → {s} ({d} day{s})\n", .{ + then_str, + now_str, + cv.days_between, + pluralS(cv.days_between), + }); + try cli.reset(out, color); + try out.print("\n", .{}); + + // ── Totals line ── + try renderTotalsLine(out, color, cv.liquid); + try out.print("\n", .{}); + + // ── Per-symbol table ── + if (cv.held_count == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("No symbols held throughout this period.\n", .{}); + try cli.reset(out, color); + } else { + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("Per-symbol price change ({d} held throughout)\n", .{cv.held_count}); + try cli.reset(out, color); + + 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.setFg(out, color, cli.CLR_MUTED); + try out.print("({d} added, {d} removed since {s} — hidden)\n", .{ + cv.added_count, + cv.removed_count, + then_str, + }); + try cli.reset(out, color); + } +} + +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 then_str = fmt.fmtMoneyAbs(&then_buf, t.then); + const now_str = fmt.fmtMoneyAbs(&now_buf, t.now); + const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, t.delta); + const pct_str = view_hist.fmtSignedPercentBuf(&pct_buf, t.pct); + + // Label + try out.print("Liquid: ", .{}); + // Value then, arrow, value now — muted color + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s}{s}{s}", .{ then_str, col.arrow, now_str }); + try cli.reset(out, color); + // Separator spaces, then delta + pct in intent color + try cli.setStyleIntent(out, color, t.style); + try out.print(" {s} {s}\n", .{ delta_str, pct_str }); + try cli.reset(out, color); +} + +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 then_str = fmt.fmtMoneyAbs(&then_buf, s.price_then); + const now_str = fmt.fmtMoneyAbs(&now_buf, s.price_now); + const pct_str = view_hist.fmtSignedPercentBuf(&pct_buf, s.pct_change); + const dollar_str = view_hist.fmtSignedMoneyBuf(&dollar_buf, s.dollar_change); + + try out.print(" ", .{}); + // Symbol in bold-ish (just reset + regular color for now; bold risks + // clashing in CLI themes without bold support). + try out.print("{s:<8} ", .{s.symbol}); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("{s:>10}{s}{s:<10}", .{ then_str, col.arrow, now_str }); + try cli.reset(out, color); + try cli.setStyleIntent(out, color, s.style); + try out.print(" {s:>8} {s:>14}\n", .{ pct_str, dollar_str }); + try cli.reset(out, color); +} + +// ── Tests ──────────────────────────────────────────────────── + +const testing = std.testing; + +test "aggregateSnapshotStocks: sums shares, filters non-stock, takes first price" { + var map: view.HoldingMap = .init(testing.allocator); + defer map.deinit(); + + const lots = [_]snapshot_model.LotRow{ + .{ + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "Roth", + .security_type = "Stock", + .shares = 100, + .open_price = 120, + .cost_basis = 12000, + .value = 15000, + .price = 150.0, + .quote_date = Date.fromYmd(2026, 4, 20), + }, + .{ + .symbol = "AAPL", + .lot_symbol = "AAPL", + .account = "IRA", + .security_type = "Stock", + .shares = 50, + .open_price = 130, + .cost_basis = 6500, + .value = 7500, + .price = 150.0, + .quote_date = Date.fromYmd(2026, 4, 20), + }, + // Cash lot — should be filtered + .{ + .symbol = "CASH", + .lot_symbol = "CASH", + .account = "Roth", + .security_type = "Cash", + .shares = 1000, + .open_price = 0, + .cost_basis = 1000, + .value = 1000, + .price = null, + .quote_date = null, + }, + // Another stock + .{ + .symbol = "MSFT", + .lot_symbol = "MSFT", + .account = "Roth", + .security_type = "Stock", + .shares = 25, + .open_price = 380, + .cost_basis = 9500, + .value = 10000, + .price = 400.0, + .quote_date = Date.fromYmd(2026, 4, 20), + }, + }; + const totals = [_]snapshot_model.TotalRow{ + .{ .scope = "liquid", .value = 1_000_000 }, + .{ .scope = "net_worth", .value = 1_100_000 }, + .{ .scope = "illiquid", .value = 100_000 }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 20), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = @constCast(&lots), + }; + + try aggregateSnapshotStocks(&snap, &map); + + try testing.expectEqual(@as(u32, 2), map.count()); // AAPL, MSFT — not CASH + try testing.expectEqual(@as(f64, 150), (map.get("AAPL") orelse unreachable).shares); + try testing.expectEqual(@as(f64, 150.0), (map.get("AAPL") orelse unreachable).price); + try testing.expectEqual(@as(f64, 25), (map.get("MSFT") orelse unreachable).shares); +} + +test "liquidFromSnapshot: finds liquid scope" { + const totals = [_]snapshot_model.TotalRow{ + .{ .scope = "net_worth", .value = 1_100_000 }, + .{ .scope = "liquid", .value = 1_000_000 }, + .{ .scope = "illiquid", .value = 100_000 }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 20), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + try testing.expectEqual(@as(f64, 1_000_000), liquidFromSnapshot(&snap)); +} + +test "liquidFromSnapshot: returns 0 when no liquid scope" { + const totals = [_]snapshot_model.TotalRow{ + .{ .scope = "net_worth", .value = 1_100_000 }, + }; + const snap = snapshot_model.Snapshot{ + .meta = .{ + .snapshot_version = 1, + .as_of_date = Date.fromYmd(2026, 4, 20), + .captured_at = 0, + .zfin_version = "test", + .stale_count = 0, + }, + .totals = @constCast(&totals), + .tax_types = &.{}, + .accounts = &.{}, + .lots = &.{}, + }; + try testing.expectEqual(@as(f64, 0.0), liquidFromSnapshot(&snap)); +} + +test "findNearestSnapshot: empty dir returns both null" { + // Build a temp dir with no snapshot files. + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot(testing.allocator, path, Date.fromYmd(2026, 4, 20)); + try testing.expectEqual(@as(?Date, null), result.earlier); + try testing.expectEqual(@as(?Date, null), result.later); +} + +test "findNearestSnapshot: non-existent dir returns both null" { + const result = try findNearestSnapshot( + testing.allocator, + "/tmp/zfin-compare-test-never-exists-12345", + Date.fromYmd(2026, 4, 20), + ); + try testing.expectEqual(@as(?Date, null), result.earlier); + try testing.expectEqual(@as(?Date, null), result.later); +} + +test "findNearestSnapshot: earlier and later around gap" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // Create a few snapshot files spanning a gap around 2026-04-19. + try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-20-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-25-portfolio.srf", .data = "" }); + // Noise files that should be ignored. + try tmp.dir.writeFile(.{ .sub_path = "random.txt", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "rollup.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "bogus-date-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot( + testing.allocator, + path, + Date.fromYmd(2026, 4, 19), + ); + try testing.expect(result.earlier != null); + try testing.expect(result.later != null); + try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 17).days), result.earlier.?.days); + try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 20).days), result.later.?.days); +} + +test "findNearestSnapshot: before earliest — only later set" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot( + testing.allocator, + path, + Date.fromYmd(2026, 1, 1), + ); + try testing.expectEqual(@as(?Date, null), result.earlier); + try testing.expect(result.later != null); + try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 15).days), result.later.?.days); +} + +test "findNearestSnapshot: after latest — only earlier set" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + const result = try findNearestSnapshot( + testing.allocator, + path, + Date.fromYmd(2027, 1, 1), + ); + try testing.expect(result.earlier != null); + try testing.expectEqual(@as(?Date, null), result.later); + try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 17).days), result.earlier.?.days); +} + +test "findNearestSnapshot: exact hit — still returns neighbors" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.writeFile(.{ .sub_path = "2026-04-15-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-17-portfolio.srf", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "2026-04-20-portfolio.srf", .data = "" }); + + const path = try tmp.dir.realpathAlloc(testing.allocator, "."); + defer testing.allocator.free(path); + + // We don't call this function on exact hits in practice (the file + // read succeeds), but if we did, it should find the next-nearest + // on each side. + const result = try findNearestSnapshot( + testing.allocator, + path, + Date.fromYmd(2026, 4, 17), + ); + try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 15).days), result.earlier.?.days); + try testing.expectEqual(@as(i32, Date.fromYmd(2026, 4, 20).days), result.later.?.days); +} + +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(2026, 4, 20), + .now_date = Date.fromYmd(2026, 4, 30), + .days_between = 10, + .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, "2026-04-20 → 2026-04-30") != 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(2026, 4, 20), + .now_date = Date.fromYmd(2026, 4, 21), + .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(2026, 4, 20), + .now_date = Date.fromYmd(2026, 4, 30), + .days_between = 10, + .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(); + + // Hidden line should still appear (added>0 is enough) + try testing.expect(std.mem.indexOf(u8, out, "(2 added, 0 removed since 2026-04-20 — hidden)") != null); +} + +test "renderCompare: negative totals delta" { + const cv = view.CompareView{ + .then_date = Date.fromYmd(2026, 4, 20), + .now_date = Date.fromYmd(2026, 4, 30), + .days_between = 10, + .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); +} + +// ── Integration: fixture snapshots end-to-end ────────────────── + +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 }); +} + +test "compare: detailed end-to-end roundtrip through fixture snapshots" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + // Snapshot files live under /history/. + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const d1 = Date.fromYmd(2026, 4, 20); + const d2 = Date.fromYmd(2026, 4, 30); + + // "Then" snapshot: AAPL 100 @ $150, MSFT 50 @ $400, NFLX 10 @ $500, + // plus a Cash lot that must be ignored. + const then_lots = [_]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, + }, + .{ + .kind = "lot", + .symbol = "MSFT", + .lot_symbol = "MSFT", + .account = "Roth", + .security_type = "Stock", + .shares = 50, + .open_price = 380, + .cost_basis = 19_000, + .value = 20_000, + .price = 400.0, + .quote_date = d1, + }, + .{ + .kind = "lot", + .symbol = "NFLX", + .lot_symbol = "NFLX", + .account = "IRA", + .security_type = "Stock", + .shares = 10, + .open_price = 500, + .cost_basis = 5_000, + .value = 5_000, + .price = 500.0, + .quote_date = d1, + }, + .{ + .kind = "lot", + .symbol = "CASH", + .lot_symbol = "CASH", + .account = "Roth", + .security_type = "Cash", + .shares = 1_000, + .open_price = 0, + .cost_basis = 1_000, + .value = 1_000, + .price = null, + .quote_date = null, + }, + }; + try writeFixtureSnapshot( + hist_dir, + testing.allocator, + "2026-04-20-portfolio.srf", + d1, + 41_000.0, + 41_000.0, + &then_lots, + ); + + // "Now" snapshot: AAPL still 100 @ $165 (+10%), MSFT 50 @ $420 (+5%), + // NFLX dropped (removed), TSLA added 20 @ $250. + const now_lots = [_]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, + }, + .{ + .kind = "lot", + .symbol = "MSFT", + .lot_symbol = "MSFT", + .account = "Roth", + .security_type = "Stock", + .shares = 50, + .open_price = 380, + .cost_basis = 19_000, + .value = 21_000, + .price = 420.0, + .quote_date = d2, + }, + .{ + .kind = "lot", + .symbol = "TSLA", + .lot_symbol = "TSLA", + .account = "IRA", + .security_type = "Stock", + .shares = 20, + .open_price = 240, + .cost_basis = 4_800, + .value = 5_000, + .price = 250.0, + .quote_date = d2, + }, + }; + try writeFixtureSnapshot( + hist_dir, + testing.allocator, + "2026-04-30-portfolio.srf", + d2, + 42_500.0, + 42_500.0, + &now_lots, + ); + + const pf = try makeTestPortfolioPath(&tmp, testing.allocator); + defer testing.allocator.free(pf); + + var buf: [8192]u8 = undefined; + var stream = std.Io.Writer.fixed(&buf); + + const args = [_][]const u8{ "2026-04-20", "2026-04-30" }; + try run(testing.allocator, &svc, pf, &args, false, &stream); + const out = stream.buffered(); + + // Header + try testing.expect(std.mem.indexOf(u8, out, "Portfolio comparison: 2026-04-20 → 2026-04-30 (10 days)") != null); + + // Totals: 41000 -> 42500, +1500, +3.66% + try testing.expect(std.mem.indexOf(u8, out, "Liquid:") != null); + try testing.expect(std.mem.indexOf(u8, out, "$41,000.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "$42,500.00") != null); + try testing.expect(std.mem.indexOf(u8, out, "+$1,500.00") != null); + + // Per-symbol: AAPL +10%, MSFT +5% — both held. NFLX removed, TSLA added. + // Sort order: AAPL (10%) before MSFT (5%) + const aapl_idx = std.mem.indexOf(u8, out, "AAPL") orelse unreachable; + const msft_idx = std.mem.indexOf(u8, out, "MSFT") orelse unreachable; + try testing.expect(aapl_idx < msft_idx); + + // Added = 1 (TSLA), Removed = 1 (NFLX) + try testing.expect(std.mem.indexOf(u8, out, "(1 added, 1 removed since 2026-04-20 — hidden)") != null); + + // Non-stock (cash) never appears + try testing.expect(std.mem.indexOf(u8, out, "CASH") == null); + // Removed position never appears as a row + try testing.expect(std.mem.indexOf(u8, out, "NFLX") == null); + // Added position never appears as a row + try testing.expect(std.mem.indexOf(u8, out, "TSLA") == null); + + // Sanity: 2 held throughout + try testing.expect(std.mem.indexOf(u8, out, "(2 held throughout)") != null); +} + +// ── run() entry-point validation tests ───────────────────────── +// +// These exercise the top-level argument validation path in `run()`. +// The `svc` argument is a constructed-but-unused DataService (empty +// config), because the validation path returns before touching the +// service in every test below. + +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 { + // Construct a fake portfolio path pointing into the tmp dir. The + // file doesn't need to exist — we're exercising arg validation and + // snapshot-miss paths, both of which return before trying to read + // the portfolio. + 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{ "2026-04-20", "2026-04-25", "2026-04-30" }; + 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{ "2026-04-20", "2026/04/30" }; + 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{ "2026-04-20", "2026-04-20" }; + 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); + + // Whatever today is, that's the arg. + 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: two valid dates missing snapshots returns SnapshotNotFound (auto-swap path)" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + // Build a portfolio path in the tmp dir — the history/ dir next to + // it won't exist, so the snapshot-load will miss. + 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 reverse order — verifies the swap path runs without + // error (both dates fail to load with the same SnapshotNotFound). + const args = [_][]const u8{ "2026-04-30", "2026-04-20" }; + const result = run(testing.allocator, &svc, pf, &args, false, &stream); + try testing.expectError(error.SnapshotNotFound, result); +} + +test "run: single-date past-date returns SnapshotNotFound with no history" { + 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); + + // A far-past date that can't match today — hits the + // single-date path → loadSnapshotSide → loadSnapshotOrSuggest miss. + 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); + + // A far-future date in single-date mode is nonsense — the "then" + // side would be a snapshot for that future date (which can't exist), + // so we reject at the arg-validation layer instead of stumbling + // through snapshot-not-found handling. + 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 happy path via fixtures" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var svc = makeTestSvc(); + defer svc.deinit(); + + // Snapshot files must live under /history/. + try tmp.dir.makePath("history"); + var hist_dir = try tmp.dir.openDir("history", .{}); + defer hist_dir.close(); + + const d1 = Date.fromYmd(2026, 4, 20); + const d2 = Date.fromYmd(2026, 4, 30); + + 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, "2026-04-20-portfolio.srf", d1, 15_000, 15_000, &lots_then); + try writeFixtureSnapshot(hist_dir, testing.allocator, "2026-04-30-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{ "2026-04-20", "2026-04-30" }; + 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); +} diff --git a/src/main.zig b/src/main.zig index d8054fb..1577ba4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -20,6 +20,7 @@ const usage = \\ analysis Show portfolio analysis \\ contributions Show money added since last commit (git-based diff) \\ snapshot [opts] Write a daily portfolio snapshot to history/ + \\ compare [] Compare portfolio against snapshot (one date = vs today) \\ enrich Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ audit [opts] Reconcile portfolio against brokerage export @@ -282,6 +283,7 @@ fn runCli() !u8 { !std.mem.eql(u8, command, "portfolio") and !std.mem.eql(u8, command, "projections") and !std.mem.eql(u8, command, "snapshot") and + !std.mem.eql(u8, command, "compare") and !std.mem.eql(u8, command, "version"); // Upper-case the first arg for symbol-taking commands, but skip when // the arg is a flag (starts with '-'). This lets commands like @@ -443,6 +445,21 @@ fn runCli() !u8 { error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1, else => return err, }; + } else if (std.mem.eql(u8, command, "compare")) { + const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); + defer if (pf.resolved) |r| r.deinit(allocator); + commands.compare.run(allocator, &svc, pf.path, cmd_args, color, out) catch |err| switch (err) { + // All user-level validation errors return 1 silently — the + // command already printed a message to stderr. + error.UnexpectedArg, + error.MissingDateArg, + error.InvalidDate, + error.SameDate, + error.SnapshotNotFound, + error.PortfolioLoadFailed, + => return 1, + else => return err, + }; } else { try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); return 1; @@ -515,6 +532,7 @@ const commands = struct { const enrich = @import("commands/enrich.zig"); const contributions = @import("commands/contributions.zig"); const snapshot = @import("commands/snapshot.zig"); + const compare = @import("commands/compare.zig"); const version = @import("commands/version.zig"); const projections = @import("commands/projections.zig"); }; diff --git a/src/views/compare.zig b/src/views/compare.zig new file mode 100644 index 0000000..298f59a --- /dev/null +++ b/src/views/compare.zig @@ -0,0 +1,460 @@ +//! `src/views/compare.zig` — view model for the portfolio comparison UX. +//! +//! Used by the CLI `compare` command and (eventually) a TUI compare-mode +//! overlay on the history tab. Sits alongside `views/portfolio_sections.zig` +//! and `views/history.zig` in the views layer — renderer-agnostic display +//! data, no ANSI, no writer, no vaxis. +//! +//! ## Semantics +//! +//! Two snapshots — "then" and "now" — each described as a map of +//! `symbol -> (shares, price)` plus a liquid-value total. "Now" may be +//! either another historical snapshot or the live portfolio; the view +//! doesn't care which. +//! +//! ### Liquid totals +//! +//! Raw delta: `now - then`. This **includes contributions and withdrawals**. +//! The per-symbol section below is the pure investment signal — total-level +//! returns are deliberately not adjusted for flows, because reconstructing +//! contribution history is out of scope. +//! +//! ### Per-symbol price change +//! +//! Only symbols held on **both** dates appear. Added/removed positions are +//! counted but not rendered — that matches the "I don't care about added/ +//! removed" constraint and keeps the output scannable. +//! +//! Two per-symbol numbers: +//! - `pct_change` = `price_now / price_then - 1` (price-only, share-count +//! changes between the two dates don't affect it) +//! - `dollar_change` = `min(shares_then, shares_now) * (price_now - price_then)` +//! — the "held throughout" dollar impact. Uses the share floor to +//! isolate continuously-held exposure; shares added between the dates +//! don't contribute (matching the "don't count adds" intent), +//! shares sold don't either. +//! +//! Sorted by `pct_change` descending — biggest winners first. +//! +//! ## Contract +//! +//! No IO, no fetching, no rendering. The CLI command is responsible for +//! loading snapshots, aggregating lot rows by symbol, and producing the +//! two `HoldingMap`s. This module does the math and returns a sorted, +//! styled view. + +const std = @import("std"); +const fmt = @import("../format.zig"); +const Date = @import("../models/date.zig").Date; + +pub const StyleIntent = fmt.StyleIntent; + +// ── Data types ─────────────────────────────────────────────── + +/// A single per-symbol comparison row, pre-computed. +/// +/// The `symbol` string is borrowed from the caller's `HoldingMap` — the +/// caller must keep that map (and its backing buffers) alive as long as +/// the view. +pub const SymbolChange = struct { + symbol: []const u8, + price_then: f64, + price_now: f64, + /// `min(shares_then, shares_now)` — the continuously-held floor. + /// Drives `dollar_change`. + shares_held_throughout: f64, + /// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by + /// 100 at format time via `fmtSignedPercentBuf` or similar. + pct_change: f64, + /// `shares_held_throughout * (price_now - price_then)`. Signed. + dollar_change: f64, + /// `.positive` when pct_change > 0, `.negative` when < 0, + /// `.muted` when exactly zero. + style: StyleIntent, +}; + +/// The liquid-total row for the totals section. Raw delta (includes flows). +pub const TotalsRow = struct { + then: f64, + now: f64, + /// `now - then`. + delta: f64, + /// Ratio. `0.05` means +5%. Zero when `then` is zero (avoids NaN). + pct: f64, + /// `.positive`/`.negative`/`.muted` by `delta` sign. + style: StyleIntent, +}; + +/// Complete compare view. `symbols` is caller-owned; call `deinit()`. +pub const CompareView = struct { + then_date: Date, + now_date: Date, + /// `now_date.days - then_date.days`. Can be zero (same-day compare + /// is a no-op but won't error) or negative if the caller didn't + /// normalize order, but the CLI always swaps so it's ≥ 0 in + /// practice. + days_between: i32, + /// True when the "now" side is the live portfolio (not another + /// snapshot). Renderers can use this to distinguish "compared to + /// today" from "compared to another snapshot date". + now_is_live: bool, + liquid: TotalsRow, + /// Sorted by `pct_change` descending. Owned by the view. + symbols: []SymbolChange, + /// Count of held-throughout symbols — always `== symbols.len`; + /// surfaced here for convenience in rendering the "(N held + /// throughout)" subtitle. + held_count: usize, + /// Symbols present in "now" but not "then" — position opened + /// between the two dates. Never rendered as rows; shown as a count. + added_count: usize, + /// Symbols present in "then" but not "now" — position closed + /// between the two dates. Never rendered as rows; shown as a count. + removed_count: usize, + + pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void { + allocator.free(self.symbols); + } +}; + +/// One entry in a holdings snapshot — total shares held of `symbol` and +/// the per-share price at that moment. Caller-populated; the view model +/// doesn't know or care where the numbers came from. +pub const Holding = struct { + shares: f64, + price: f64, +}; + +/// Symbol → Holding. String keys are caller-owned; keep them alive as +/// long as the resulting `CompareView`. +pub const HoldingMap = std.StringHashMap(Holding); + +// ── Pure builders ──────────────────────────────────────────── + +/// Compute a single per-symbol change from the raw inputs. +/// +/// The pct-change denominator is `price_then`. If `price_then` is zero +/// (shouldn't happen for stocks but guards against bad data), the +/// pct_change is reported as 0 rather than a NaN/Inf leaking into the +/// sort comparator. +pub fn buildSymbolChange( + symbol: []const u8, + shares_then: f64, + price_then: f64, + shares_now: f64, + price_now: f64, +) SymbolChange { + const held = @min(shares_then, shares_now); + const pct = if (price_then != 0) (price_now / price_then - 1.0) else 0.0; + const dollar = held * (price_now - price_then); + const style: StyleIntent = if (pct > 0) .positive else if (pct < 0) .negative else .muted; + return .{ + .symbol = symbol, + .price_then = price_then, + .price_now = price_now, + .shares_held_throughout = held, + .pct_change = pct, + .dollar_change = dollar, + .style = style, + }; +} + +/// Compute the liquid totals row. Safe when `then == 0` (pct → 0 rather +/// than NaN). +pub fn buildTotalsRow(then: f64, now: f64) TotalsRow { + const delta = now - then; + const pct = if (then != 0) delta / then else 0.0; + const style: StyleIntent = if (delta > 0) .positive else if (delta < 0) .negative else .muted; + return .{ + .then = then, + .now = now, + .delta = delta, + .pct = pct, + .style = style, + }; +} + +/// Primary view builder. Intersects `then_map` with `now_map`, computes +/// per-symbol changes for held-throughout symbols, counts added/removed, +/// and sorts descending by `pct_change`. +/// +/// Symbol strings in the returned view borrow from `then_map`'s keys +/// (since we iterate `then_map` to build the intersection) — the caller +/// must keep `then_map` alive at least as long as the view. Alternatively, +/// if the view needs to outlive both maps, the caller can dupe the +/// strings before passing them in. +pub fn buildCompareView( + allocator: std.mem.Allocator, + then_date: Date, + now_date: Date, + now_is_live: bool, + liquid_then: f64, + liquid_now: f64, + then_map: *const HoldingMap, + now_map: *const HoldingMap, +) !CompareView { + var changes: std.ArrayList(SymbolChange) = .empty; + errdefer changes.deinit(allocator); + + var added: usize = 0; + var removed: usize = 0; + + // Count added: in now, not in then. + var now_it = now_map.iterator(); + while (now_it.next()) |e| { + if (!then_map.contains(e.key_ptr.*)) added += 1; + } + + // Walk "then" to build the intersection and count removed. + var then_it = then_map.iterator(); + while (then_it.next()) |e| { + const sym = e.key_ptr.*; + const then_h = e.value_ptr.*; + if (now_map.get(sym)) |now_h| { + try changes.append(allocator, buildSymbolChange( + sym, + then_h.shares, + then_h.price, + now_h.shares, + now_h.price, + )); + } else { + removed += 1; + } + } + + // Sort by pct_change descending. Stable is fine; stability isn't + // semantically relevant here but is cheaper in the not-all-unique case. + std.mem.sort(SymbolChange, changes.items, {}, struct { + fn lt(_: void, a: SymbolChange, b: SymbolChange) bool { + return a.pct_change > b.pct_change; + } + }.lt); + + const items = try changes.toOwnedSlice(allocator); + return .{ + .then_date = then_date, + .now_date = now_date, + .days_between = now_date.days - then_date.days, + .now_is_live = now_is_live, + .liquid = buildTotalsRow(liquid_then, liquid_now), + .symbols = items, + .held_count = items.len, + .added_count = added, + .removed_count = removed, + }; +} + +// ── Tests ──────────────────────────────────────────────────── + +const testing = std.testing; + +test "buildSymbolChange: positive price move, shares stable" { + const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0); + try testing.expectEqualStrings("AAPL", c.symbol); + try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9); + try testing.expectApproxEqAbs(@as(f64, 1500.0), c.dollar_change, 1e-9); + try testing.expectEqual(@as(f64, 100), c.shares_held_throughout); + try testing.expectEqual(StyleIntent.positive, c.style); +} + +test "buildSymbolChange: negative move" { + const c = buildSymbolChange("NFLX", 10, 500.0, 10, 400.0); + try testing.expectApproxEqAbs(@as(f64, -0.20), c.pct_change, 1e-9); + try testing.expectApproxEqAbs(@as(f64, -1000.0), c.dollar_change, 1e-9); + try testing.expectEqual(StyleIntent.negative, c.style); +} + +test "buildSymbolChange: zero price move → muted style, zero dollar" { + const c = buildSymbolChange("VTI", 50, 240.0, 50, 240.0); + try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9); + try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9); + try testing.expectEqual(StyleIntent.muted, c.style); +} + +test "buildSymbolChange: shares_held is min (added shares between dates)" { + // Started with 100, added 50, now at 150. Held-throughout floor = 100. + const c = buildSymbolChange("MSFT", 100, 400.0, 150, 420.0); + try testing.expectEqual(@as(f64, 100), c.shares_held_throughout); + // dollar = 100 * (420-400) = 2000, not 150 * 20 = 3000 + try testing.expectApproxEqAbs(@as(f64, 2000.0), c.dollar_change, 1e-9); +} + +test "buildSymbolChange: shares_held is min (sold shares between dates)" { + // Started with 200, sold down to 50. Held-throughout floor = 50. + const c = buildSymbolChange("GOOG", 200, 160.0, 50, 180.0); + try testing.expectEqual(@as(f64, 50), c.shares_held_throughout); + // dollar = 50 * (180-160) = 1000 + try testing.expectApproxEqAbs(@as(f64, 1000.0), c.dollar_change, 1e-9); +} + +test "buildSymbolChange: zero price_then doesn't NaN" { + const c = buildSymbolChange("BAD", 10, 0.0, 10, 50.0); + try testing.expectEqual(@as(f64, 0.0), c.pct_change); + try testing.expectEqual(StyleIntent.muted, c.style); + // dollar_change is still 10 * 50 = 500 — that's the true held-throughout + // price delta even though % is undefined. + try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9); +} + +test "buildTotalsRow: positive delta" { + const t = buildTotalsRow(1_000_000.0, 1_050_000.0); + try testing.expectApproxEqAbs(@as(f64, 50_000.0), t.delta, 1e-6); + try testing.expectApproxEqAbs(@as(f64, 0.05), t.pct, 1e-9); + try testing.expectEqual(StyleIntent.positive, t.style); +} + +test "buildTotalsRow: negative delta" { + const t = buildTotalsRow(1_000_000.0, 950_000.0); + try testing.expectApproxEqAbs(@as(f64, -50_000.0), t.delta, 1e-6); + try testing.expectApproxEqAbs(@as(f64, -0.05), t.pct, 1e-9); + try testing.expectEqual(StyleIntent.negative, t.style); +} + +test "buildTotalsRow: zero delta" { + const t = buildTotalsRow(100.0, 100.0); + try testing.expectEqual(@as(f64, 0.0), t.delta); + try testing.expectEqual(StyleIntent.muted, t.style); +} + +test "buildTotalsRow: zero then doesn't NaN" { + const t = buildTotalsRow(0.0, 100.0); + try testing.expectEqual(@as(f64, 0.0), t.pct); + try testing.expectEqual(StyleIntent.positive, t.style); // delta > 0 so positive +} + +test "buildCompareView: intersection with added and removed" { + var then_map: HoldingMap = .init(testing.allocator); + defer then_map.deinit(); + var now_map: HoldingMap = .init(testing.allocator); + defer now_map.deinit(); + + // Held in both (will appear) + try then_map.put("AAPL", .{ .shares = 100, .price = 150.0 }); + try now_map.put("AAPL", .{ .shares = 100, .price = 165.0 }); + try then_map.put("MSFT", .{ .shares = 50, .price = 400.0 }); + try now_map.put("MSFT", .{ .shares = 50, .price = 395.0 }); + // Removed (in then, not in now) + try then_map.put("NFLX", .{ .shares = 10, .price = 500.0 }); + // Added (in now, not in then) + try now_map.put("TSLA", .{ .shares = 20, .price = 250.0 }); + try now_map.put("NVDA", .{ .shares = 15, .price = 140.0 }); + + var view = try buildCompareView( + testing.allocator, + Date.fromYmd(2026, 4, 20), + Date.fromYmd(2026, 4, 30), + true, // live + 1_000_000.0, + 1_050_000.0, + &then_map, + &now_map, + ); + defer view.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), view.held_count); + try testing.expectEqual(@as(usize, 2), view.added_count); + try testing.expectEqual(@as(usize, 1), view.removed_count); + try testing.expectEqual(@as(i32, 10), view.days_between); + try testing.expectEqual(true, view.now_is_live); + + // Sort order: AAPL (+10%) before MSFT (-1.25%) + try testing.expectEqualStrings("AAPL", view.symbols[0].symbol); + try testing.expectEqualStrings("MSFT", view.symbols[1].symbol); + try testing.expect(view.symbols[0].pct_change > view.symbols[1].pct_change); + + // Totals row + try testing.expectApproxEqAbs(@as(f64, 0.05), view.liquid.pct, 1e-9); + try testing.expectEqual(StyleIntent.positive, view.liquid.style); +} + +test "buildCompareView: empty intersection" { + var then_map: HoldingMap = .init(testing.allocator); + defer then_map.deinit(); + var now_map: HoldingMap = .init(testing.allocator); + defer now_map.deinit(); + + try then_map.put("OLD", .{ .shares = 10, .price = 100.0 }); + try now_map.put("NEW", .{ .shares = 5, .price = 200.0 }); + + var view = try buildCompareView( + testing.allocator, + Date.fromYmd(2026, 1, 1), + Date.fromYmd(2026, 2, 1), + false, + 1000.0, + 2000.0, + &then_map, + &now_map, + ); + defer view.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), view.held_count); + try testing.expectEqual(@as(usize, 1), view.added_count); + try testing.expectEqual(@as(usize, 1), view.removed_count); + try testing.expectEqual(false, view.now_is_live); +} + +test "buildCompareView: both empty" { + var then_map: HoldingMap = .init(testing.allocator); + defer then_map.deinit(); + var now_map: HoldingMap = .init(testing.allocator); + defer now_map.deinit(); + + var view = try buildCompareView( + testing.allocator, + Date.fromYmd(2026, 1, 1), + Date.fromYmd(2026, 1, 1), + true, + 0.0, + 0.0, + &then_map, + &now_map, + ); + defer view.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), view.held_count); + try testing.expectEqual(@as(usize, 0), view.added_count); + try testing.expectEqual(@as(usize, 0), view.removed_count); + try testing.expectEqual(@as(i32, 0), view.days_between); + try testing.expectEqual(StyleIntent.muted, view.liquid.style); +} + +test "buildCompareView: sort strictly descending across many symbols" { + var then_map: HoldingMap = .init(testing.allocator); + defer then_map.deinit(); + var now_map: HoldingMap = .init(testing.allocator); + defer now_map.deinit(); + + // Seed 5 held-throughout symbols with distinct returns + try then_map.put("A", .{ .shares = 10, .price = 100.0 }); + try now_map.put("A", .{ .shares = 10, .price = 110.0 }); // +10% + try then_map.put("B", .{ .shares = 10, .price = 100.0 }); + try now_map.put("B", .{ .shares = 10, .price = 80.0 }); // -20% + try then_map.put("C", .{ .shares = 10, .price = 100.0 }); + try now_map.put("C", .{ .shares = 10, .price = 130.0 }); // +30% + try then_map.put("D", .{ .shares = 10, .price = 100.0 }); + try now_map.put("D", .{ .shares = 10, .price = 95.0 }); // -5% + try then_map.put("E", .{ .shares = 10, .price = 100.0 }); + try now_map.put("E", .{ .shares = 10, .price = 105.0 }); // +5% + + var view = try buildCompareView( + testing.allocator, + Date.fromYmd(2026, 1, 1), + Date.fromYmd(2026, 2, 1), + false, + 5000.0, + 5200.0, + &then_map, + &now_map, + ); + defer view.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 5), view.held_count); + // Expected order by pct desc: C (+30), A (+10), E (+5), D (-5), B (-20) + try testing.expectEqualStrings("C", view.symbols[0].symbol); + try testing.expectEqualStrings("A", view.symbols[1].symbol); + try testing.expectEqualStrings("E", view.symbols[2].symbol); + try testing.expectEqualStrings("D", view.symbols[3].symbol); + try testing.expectEqualStrings("B", view.symbols[4].symbol); +}