zfin/src/commands/compare.zig
Emil Lerch 94311f6ff7
All checks were successful
Generic zig build / build (push) Successful in 2m3s
Generic zig build / deploy (push) Successful in 15s
use new common cli helpers throughout
2026-05-01 16:22:19 -07:00

1011 lines
37 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `zfin compare <DATE1> [<DATE2>]`
//!
//! 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: <then> → <now> (N days)
//!
//! Liquid: <then_total> → <now_total> <+/-delta> <+/-pct%>
//!
//! Per-symbol price change (K held throughout)
//! SYM1 <price_then> → <price_now> <+/-pct%> <+/-dollar>
//! SYM2 <price_then> → <price_now> <+/-pct%> <+/-dollar>
//! ...
//!
//! (A added, R removed since <then> — 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 <DATE> (compare date vs current)\n");
try cli.stderrPrint(" zfin compare <DATE1> <DATE2> (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 <date>" 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 });
}