zfin/src/commands/compare.zig

1713 lines
66 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 framework = @import("framework.zig");
const TimeRange = @import("TimeRange.zig");
const git = @import("../git.zig");
const fmt = cli.fmt;
const Date = zfin.Date;
const Money = @import("../Money.zig");
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 = @import("contributions.zig");
const projections = @import("projections.zig");
pub const Error = error{
UnexpectedArg,
MissingDateArg,
InvalidDate,
SameDate,
SnapshotNotFound,
PortfolioLoadFailed,
};
/// Parsed compare args. Carries already-resolved values so `run` is
/// pure orchestration.
pub const ParsedArgs = struct {
with_projections: bool = false,
events_enabled: bool = true,
/// Resolved before-side snapshot date. Null if neither
/// --snapshot-before, a positional date, nor --commit-before
/// (with a date spec) was supplied — `run` errors on null.
snapshot_before: ?Date = null,
/// Resolved after-side snapshot date. Null + !after_is_live
/// means "compare against today's live portfolio."
snapshot_after: ?Date = null,
after_is_live: bool = true,
commit_before: ?git.CommitSpec = null,
commit_after: ?git.CommitSpec = null,
};
pub const meta: framework.Meta = .{
.name = "compare",
.group = .timeseries,
.synopsis = "Compare portfolio at two points in time (or vs. today)",
.help =
\\Usage:
\\ zfin compare <DATE> compare DATE vs. live
\\ zfin compare <DATE1> <DATE2> compare two historical dates
\\ zfin compare [--snapshot-before DATE] [--snapshot-after DATE]
\\ [--commit-before SPEC] [--commit-after SPEC]
\\ [--projections [--no-events]]
\\
\\Compare two points in time for the portfolio: liquid totals,
\\per-symbol price moves, attribution / contributions diff. The
\\positional happy-path is what most users want; the named
\\overrides exist for the cases where the snapshot date and the
\\git-commit-at-that-date diverge (e.g. you committed two days
\\after a Sunday review).
\\
\\Date forms: YYYY-MM-DD or relative (1W/1M/1Q/1Y).
\\Commit-spec forms: YYYY-MM-DD, relative, HEAD, HEAD~N, SHA, working.
\\
\\Options:
\\ --projections Include projected-return + SWR@99%
\\ deltas. Adds ~1-2s per endpoint
\\ (Monte Carlo SWR search).
\\ --no-events (with --projections) exclude life
\\ events from the projection.
\\ --snapshot-before <DATE> Override the before-side snapshot.
\\ --snapshot-after <DATE> Override the after-side snapshot;
\\ accepts `live` for the current
\\ portfolio.
\\ --commit-before <SPEC> Pin the before-side commit for the
\\ attribution / contributions block.
\\ --commit-after <SPEC> Pin the after-side commit; accepts
\\ `working` for the working copy.
\\
,
.uppercase_first_arg = false,
};
comptime {
framework.validateCommandModule(@This());
}
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
const io = ctx.io;
const today = ctx.today;
const allocator = ctx.allocator;
// ── Pass 1: TimeRange.parse for endpoint flags ───────────
const tr_result = TimeRange.parse(io, allocator, today, cmd_args, .{
.accept_snapshot_before = true,
.accept_snapshot_after = true,
.accept_commit_before = true,
.accept_commit_after = true,
}) catch |err| switch (err) {
error.MissingValue,
error.InvalidValue,
error.WorkingCopyOnBeforeSide,
error.LiveNotAllowed,
=> return error.InvalidDate,
error.DuplicateEndpoint, error.RepeatedFlag => return error.UnexpectedArg,
error.OutOfMemory => return error.OutOfMemory,
};
defer allocator.free(tr_result.consumed);
// Build a "consumed" lookup so the second pass can skip the
// tokens TimeRange already absorbed.
var consumed_set = std.AutoHashMap(usize, void).init(allocator);
defer consumed_set.deinit();
for (tr_result.consumed) |idx| try consumed_set.put(idx, {});
var parsed: ParsedArgs = .{};
// Translate TimeRange endpoints → ParsedArgs. We split the
// typed Endpoint back out because the rest of compare.zig wants
// separate `snapshot_*` and `commit_*` knobs (a single endpoint
// axis isn't expressive enough — compare can carry an
// independent commit-spec and snapshot-date on the same side).
if (tr_result.range.before) |ep| switch (ep) {
.date => |d| parsed.snapshot_before = d,
.commit_spec => |s| parsed.commit_before = s,
.live => unreachable, // --snapshot-before rejects live; --commit-before is commit_spec
};
if (tr_result.range.after) |ep| switch (ep) {
.date => |d| {
parsed.snapshot_after = d;
parsed.after_is_live = false;
},
.commit_spec => |s| parsed.commit_after = s,
.live => parsed.after_is_live = true,
};
// ── Pass 2: --projections / --no-events / positionals ────
var positional: std.ArrayList([]const u8) = .empty;
defer positional.deinit(allocator);
var arg_i: usize = 0;
while (arg_i < cmd_args.len) : (arg_i += 1) {
if (consumed_set.contains(arg_i)) continue;
const a = cmd_args[arg_i];
if (std.mem.eql(u8, a, "--projections")) {
parsed.with_projections = true;
} else if (std.mem.eql(u8, a, "--no-events")) {
parsed.events_enabled = false;
} else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) {
try cli.stderrPrint(io, "Error: unknown flag for 'compare': ");
try cli.stderrPrint(io, a);
try cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n");
if (std.mem.eql(u8, a, "-p")) {
try cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n");
try cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n");
try cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n");
}
return error.UnexpectedArg;
} else {
try positional.append(allocator, a);
}
}
const args = positional.items;
// ── Resolve positional dates into the snapshot axes ──────
if (args.len > 2) {
try cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n");
return error.UnexpectedArg;
}
const date1: ?Date = if (args.len >= 1)
(cli.parseRequiredDateOrStderr(io, args[0], today, "date1") catch |err| switch (err) {
error.InvalidDate => return error.InvalidDate,
})
else
null;
const date2: ?Date = if (args.len == 2)
(cli.parseRequiredDateOrStderr(io, args[1], today, "date2") catch |err| switch (err) {
error.InvalidDate => return error.InvalidDate,
})
else
null;
// Positional fallbacks: positional[0] feeds the before-snapshot
// axis if --snapshot-before wasn't given; positional[1] feeds
// the after-snapshot axis if --snapshot-after wasn't given.
if (parsed.snapshot_before == null) parsed.snapshot_before = date1;
if (parsed.snapshot_after == null and date2 != null) {
parsed.snapshot_after = date2;
parsed.after_is_live = false;
}
// Swap if user provided positional dates in reverse order and no
// explicit override. Overrides are respected as given (user
// asked for it explicitly).
if (!parsed.after_is_live and parsed.snapshot_after != null and
date1 != null and date2 != null and date1.?.days > date2.?.days and
// Only swap when there were no explicit overrides (the
// positional-fallback branches set snapshot_before/after
// from date1/date2 themselves).
cmd_args_has_no_snap_overrides(cmd_args))
{
const tmp = parsed.snapshot_before.?;
parsed.snapshot_before = parsed.snapshot_after;
parsed.snapshot_after = tmp;
}
// Require at least one source of the "then" date.
const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null;
if (!have_then_anchor) {
try cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n");
try cli.stderrPrint(io, "Usage:\n");
try cli.stderrPrint(io, " zfin compare <DATE> (compare date vs current)\n");
try cli.stderrPrint(io, " zfin compare <DATE1> <DATE2> (compare two dates)\n");
try cli.stderrPrint(io, " zfin compare --snapshot-before <DATE> [--commit-before <SPEC>] (explicit axes)\n");
try cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
try cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n");
return error.MissingDateArg;
}
return parsed;
}
/// Tiny helper for the swap-on-reverse-order check: confirms the
/// user did NOT pass --snapshot-before or --snapshot-after
/// explicitly. When either is set explicitly, we never swap.
fn cmd_args_has_no_snap_overrides(cmd_args: []const []const u8) bool {
for (cmd_args) |a| {
if (std.mem.eql(u8, a, "--snapshot-before") or
std.mem.eql(u8, a, "--snapshot-after"))
{
return false;
}
}
return true;
}
/// Command entry point.
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const portfolio_path = pf.path;
const with_projections = parsed.with_projections;
const events_enabled = parsed.events_enabled;
const snapshot_after_live = parsed.after_is_live and parsed.snapshot_after == null;
const commit_before_override: ?git.CommitSpec = parsed.commit_before;
const commit_after_override: ?git.CommitSpec = parsed.commit_after;
// Resolve the snapshot "then" / "now" dates from the parsed
// ParsedArgs. The before-side is guaranteed non-null by parseArgs
// (returned MissingDateArg otherwise). The after-side may be null
// when after_is_live; we substitute today's date so the rest of
// the pipeline can stay date-driven.
const then_requested: Date = parsed.snapshot_before orelse blk: {
// Fallback: --commit-before with a date_at_or_before spec.
if (commit_before_override) |cb| switch (cb) {
.date_at_or_before => |d| break :blk d,
else => {
try cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n");
return error.MissingDateArg;
},
};
unreachable; // parseArgs guarantees an anchor
};
const now_is_live = parsed.after_is_live and parsed.snapshot_after == null;
const now_requested: Date = parsed.snapshot_after orelse as_of;
// Validate snapshot date ordering.
if (now_is_live) {
if (then_requested.days == as_of.days) {
try cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n");
return error.SameDate;
}
if (then_requested.days > as_of.days) {
try cli.stderrPrint(io, "Error: cannot compare against a future date.\n");
return error.InvalidDate;
}
} else if (!snapshot_after_live) {
if (then_requested.days == now_requested.days) {
try cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n");
return error.SameDate;
}
}
// parseArgs already handled the date1/date2 swap-on-reverse case,
// so then_date / now_date here just reflect what's in `parsed`.
var then_date: Date = then_requested;
var now_date: Date = now_requested;
// ── Resolve history dir ──────────────────────────────────
const hist_dir = try history.deriveHistoryDir(allocator, portfolio_path);
defer allocator.free(hist_dir);
// ── Snap dates to nearest-earlier snapshots ──────────────
//
// The requested date may not correspond to an actual snapshot
// file. Snap to the most recent snapshot at-or-before. Record
// the original requested date so we can pass it to the
// attribution git-window independently (liquid uses the snap'd
// date; attribution uses the requested date unless --commit-*
// overrides).
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const then_date_requested = then_date;
const now_date_requested = now_date;
const then_resolved = cli.resolveSnapshotOrExplain(io, arena, hist_dir, then_date) catch return error.SnapshotNotFound;
if (!then_resolved.exact) {
var stderr_buf: [256]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
try printSnapNote(&stderr_writer.interface, color, then_resolved.requested, then_resolved.actual, "then");
try stderr_writer.interface.flush();
}
then_date = then_resolved.actual;
if (!now_is_live) {
const now_resolved = cli.resolveSnapshotOrExplain(io, arena, hist_dir, now_date) catch return error.SnapshotNotFound;
if (!now_resolved.exact) {
var stderr_buf: [256]u8 = undefined;
var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buf);
try printSnapNote(&stderr_writer.interface, color, now_resolved.requested, now_resolved.actual, "now");
try stderr_writer.interface.flush();
}
now_date = now_resolved.actual;
}
// ── 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.
//
// After the snap above, the dates are guaranteed to correspond to
// actual snapshot files — FileNotFound here would be a disk race
// (file deleted between the snap check and the load), not a
// missing-snapshot UX problem.
var then_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, then_date);
defer then_side.deinit(allocator);
// Projections: only computed when --projections/-p flag is set.
// Uses the SNAPPED dates (not requested) because projections are
// snapshot-based — they need actual files on disk to load.
// When `now_is_live`, pass null for the "now" side so projections
// uses the live portfolio. When two-date mode, pass the snapped
// now_date.
var projections_result: ?projections.KeyComparisonResult = null;
defer if (projections_result) |r| r.cleanup();
var projections_block: ?ProjectionsBlock = null;
if (with_projections) {
const proj_now_date: Date = if (now_is_live) as_of else now_date;
projections_result = projections.computeKeyComparison(
io,
allocator,
arena,
svc,
portfolio_path,
.{
.events_enabled = events_enabled,
.vs_date = then_date,
.now_date = proj_now_date,
.now_from_snapshot = !now_is_live,
.refresh = ctx.globals.refresh_policy,
},
) catch |err| blk: {
// Projections computation failed — fall back to compare
// output without the block. User still gets the core
// Liquid/attribution/per-symbol view.
var ebuf: [160]u8 = undefined;
const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n";
cli.stderrPrint(io, msg) catch {};
break :blk null;
};
if (projections_result) |r| {
projections_block = .{ .then = r.then, .now = r.now, .events_enabled = r.events_enabled };
}
}
// Build the CommitSpecs for attribution. Explicit --commit-*
// overrides win; otherwise fall back to the requested snapshot
// date. now_is_live implies `null` for after (= working copy
// default handling downstream).
const attr_before: git.CommitSpec = commit_before_override orelse
.{ .date_at_or_before = then_date_requested };
const attr_after_opt: ?git.CommitSpec = if (commit_after_override) |s|
s
else if (now_is_live)
null
else
.{ .date_at_or_before = now_date_requested };
if (now_is_live) {
var now_live = try LiveSide.load(io, allocator, svc, portfolio_path, as_of, color, ctx.globals.refresh_policy);
defer now_live.deinit(allocator);
// Attribution uses the resolved CommitSpecs so --commit-*
// overrides + date fallbacks share one classifier. The caller
// adapts dates to `CommitSpec.date_at_or_before` upstream.
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
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,
.projections = projections_block,
});
} else {
var now_side = try compare_core.loadSnapshotSide(io, allocator, hist_dir, now_date);
defer now_side.deinit(allocator);
const attribution = contributions.computeAttributionSpec(io, allocator, svc, portfolio_path, attr_before, attr_after_opt, as_of, color, ctx.globals.refresh_policy);
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,
.projections = projections_block,
});
}
}
/// Render a muted "(requested X for Y; nearest snapshot: Z, N day(s)
/// earlier)" note explaining that a requested as-of date was snapped
/// backward to the nearest available snapshot. Pure formatter — caller
/// supplies the writer (typically stderr) and decides about flushing.
fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void {
const days = requested.days - actual.days;
var msg_buf: [160]u8 = undefined;
const msg = std.fmt.bufPrint(
&msg_buf,
"(requested {f} for {s}; nearest snapshot: {f}, {d} day{s} earlier)\n",
.{ requested, label, actual, days, if (days == 1) "" else "s" },
) catch "(snapped to nearest snapshot)\n";
if (color) try fmt.ansiSetFg(out, cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2]);
try out.writeAll(msg);
if (color) try fmt.ansiReset(out);
}
/// 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. `projections` is
/// optional — when set, a compact projected-return + safe-withdrawal
/// delta block renders between the attribution and the per-symbol
/// table. Kept outside `CompareView` because CompareView is
/// renderer-agnostic and the projection data carries CLI-specific
/// computation (Monte Carlo SWR, trailing returns).
const RenderArgs = struct {
then_date: Date,
now_date: Date,
now_is_live: bool,
then_liquid: f64,
now_liquid: f64,
then_map: *const view.HoldingMap,
now_map: *const view.HoldingMap,
attribution: ?contributions.AttributionSummary,
projections: ?ProjectionsBlock = null,
};
/// Pre-computed projections block for embedding in the compare
/// output. Caller computes (runs the projections pipeline for both
/// endpoints) and passes in; the renderer just prints. Gates the
/// perf cost of projection computation on the `--projections` flag.
const ProjectionsBlock = struct {
then: projections.KeyMetrics,
now: projections.KeyMetrics,
events_enabled: bool,
};
/// 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, args.projections);
}
// ── 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(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
as_of: Date,
color: bool,
refresh: framework.RefreshPolicy,
) !LiveSide {
var loaded_pf = cli.loadPortfolio(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed;
errdefer loaded_pf.deinit(allocator);
if (loaded_pf.portfolio.lots.len == 0) {
try cli.stderrPrint(io, "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(io, svc, loaded_pf.syms, &.{}, refresh, 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,
as_of,
) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "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(as_of, &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);
}
};
// ── 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, proj: ?ProjectionsBlock) !void {
var then_buf: [10]u8 = undefined;
var now_buf: [24]u8 = undefined;
const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??";
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);
}
// Optional projections block (opt-in via --projections/-p).
// Slots between the attribution rows and the per-symbol table
// so the "headline" numbers cluster together at the top.
if (proj) |p| {
try out.print("\n", .{});
try projections.renderKeyComparisonRows(out, color, p.then, p.now, p.events_enabled);
}
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);
}
try renderGainerLoserSummary(out, color, cv);
}
// 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,
});
}
}
/// Render the gainer/loser/flat summary line under the per-symbol
/// table. Flat counts only surface when non-zero to keep the signal
/// tight — a full window of winners shouldn't read "0 flat".
///
/// 21 gainers, 5 losers
/// 21 gainers, 5 losers, 2 flat
///
/// Colored segments match the per-symbol rows: gainers in the positive
/// intent, losers in the negative intent, "flat" (and punctuation) in
/// the muted intent. "gainer" and "loser" are colored unconditionally
/// — a zero count still communicates something about the window (e.g.
/// "0 losers" in negative tint reinforces "everything was green").
/// Callers gate on `cv.held_count > 0`.
fn renderGainerLoserSummary(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
try cli.printFg(out, color, cli.CLR_MUTED, " ", .{});
try cli.printFg(out, color, cli.CLR_POSITIVE, "{d} gainer{s}", .{
cv.gainer_count,
if (cv.gainer_count == 1) "" else "s",
});
try cli.printFg(out, color, cli.CLR_MUTED, ", ", .{});
try cli.printFg(out, color, cli.CLR_NEGATIVE, "{d} loser{s}", .{
cv.loser_count,
if (cv.loser_count == 1) "" else "s",
});
if (cv.flat_count > 0) {
try cli.printFg(out, color, cli.CLR_MUTED, ", {d} flat\n", .{cv.flat_count});
} else {
try out.print("\n", .{});
}
}
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 investment-gains vs. cash-contributions breakdown of
/// the liquid delta, stacked as two labeled rows directly beneath
/// the Liquid line:
///
/// Investment gains: +$85,062.72
/// Cash contributions: +$7,866.22
///
/// Both amounts are signed. Negative gains (market loss) and
/// negative contributions (net withdrawal) print with a leading `-`
/// in the negative color. Labels use the muted color to stay out of
/// the way of the eye scan for the numbers.
///
/// Labels are padded to a fixed width so the `$` column aligns
/// regardless of label length. Indent of 2 spaces matches the
/// gainer/loser summary beneath the per-symbol table.
///
/// Math identity: `delta = gains + contributions`, so
/// `gains = delta - contributions`. The caller derives `gains` from
/// the liquid delta before this is called; we just render.
fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attribution: view.Attribution) !void {
_ = delta; // delta is already shown on the Liquid row above; we
// don't repeat it here. Kept in the signature so the
// callsite stays readable (`renderAttributionLine(out,
// color, cv.liquid.delta, a)`) and future revisions
// that want to restate the Δ have it in scope.
// 19-char label column aligns the amount columns. "Investment
// gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is
// 19 chars → 0 trailing pad. The 2-space gutter that follows
// keeps the amounts clearly separated from the labels even on
// narrow terminals.
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"});
try cli.printGainLoss(out, color, attribution.gains, "{f}\n", .{Money.from(attribution.gains).signed()});
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Cash contributions:"});
try cli.printGainLoss(out, color, attribution.contributions, "{f}\n", .{Money.from(attribution.contributions).signed()});
}
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;
fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs {
var ctx: framework.RunCtx = .{
.io = std.testing.io,
.allocator = std.testing.allocator,
.gpa = std.testing.allocator,
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = null,
.globals = .{},
.today = today,
.now_s = 0,
.color = false,
.out = undefined,
};
return parseArgs(&ctx, args);
}
test "parseArgs: single positional date populates snapshot_before" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"2026-04-01"};
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 4, 1)));
try testing.expect(parsed.snapshot_after == null);
try testing.expect(parsed.after_is_live);
}
test "parseArgs: two positional dates populate both axes" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "2026-05-01" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 4, 1)));
try testing.expect(parsed.snapshot_after.?.eql(Date.fromYmd(2026, 5, 1)));
try testing.expect(!parsed.after_is_live);
}
test "parseArgs: reverse-order positional dates auto-swap" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-05-01", "2026-04-01" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 4, 1)));
try testing.expect(parsed.snapshot_after.?.eql(Date.fromYmd(2026, 5, 1)));
}
test "parseArgs: --projections sets the flag" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "--projections" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.with_projections);
try testing.expect(parsed.events_enabled); // default
}
test "parseArgs: --projections --no-events" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "--projections", "--no-events" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.with_projections);
try testing.expect(!parsed.events_enabled);
}
test "parseArgs: --snapshot-before override wins over positional" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--snapshot-before", "2026-03-01", "2026-04-01" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.snapshot_before.?.eql(Date.fromYmd(2026, 3, 1)));
}
test "parseArgs: --commit-before captures git ref" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "--commit-before", "HEAD~1" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.commit_before != null);
switch (parsed.commit_before.?) {
.git_ref => |r| try testing.expectEqualStrings("HEAD~1", r),
else => try testing.expect(false),
}
}
test "parseArgs: --snapshot-after live keeps after_is_live" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "--snapshot-after", "live" };
const parsed = try parseArgsForTest(today, &args);
try testing.expect(parsed.snapshot_after == null);
try testing.expect(parsed.after_is_live);
}
test "parseArgs: missing anchor errors" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{};
try testing.expectError(error.MissingDateArg, parseArgsForTest(today, &args));
}
test "parseArgs: unknown flag errors" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "--bogus" };
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}
test "parseArgs: too many positional dates errors" {
const today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "2026-04-01", "2026-05-01", "2026-06-01" };
try testing.expectError(error.UnexpectedArg, parseArgsForTest(today, &args));
}
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, null);
const out = stream.buffered();
// Header
try testing.expect(std.mem.indexOf(u8, out, "2024-01-15 → 2024-01-25 (live)") != 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, no (live) marker" {
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, null);
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, "(live)") == 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, null);
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, null);
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, null);
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, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") != null);
try testing.expect(std.mem.indexOf(u8, out, "Cash contributions:") != null);
try testing.expect(std.mem.indexOf(u8, out, "+$30,148.02") != null); // on the Liquid row above
try testing.expect(std.mem.indexOf(u8, out, "+$22,636.00") != null);
try testing.expect(std.mem.indexOf(u8, out, "+$7,512.02") != null);
// The old `Attribution:` prefix is gone.
try testing.expect(std.mem.indexOf(u8, out, "Attribution:") == 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, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Investment gains:") == null);
try testing.expect(std.mem.indexOf(u8, out, "Cash contributions:") == 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, null);
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);
}
test "renderCompare: gainer/loser summary line renders with pluralization" {
const symbols = [_]view.SymbolChange{
.{
.symbol = "A",
.price_then = 100,
.price_now = 110,
.shares_held_throughout = 1,
.pct_change = 0.10,
.dollar_change = 10,
.style = .positive,
},
.{
.symbol = "B",
.price_then = 100,
.price_now = 105,
.shares_held_throughout = 1,
.pct_change = 0.05,
.dollar_change = 5,
.style = .positive,
},
.{
.symbol = "C",
.price_then = 100,
.price_now = 95,
.shares_held_throughout = 1,
.pct_change = -0.05,
.dollar_change = -5,
.style = .negative,
},
};
const cv = view.CompareView{
.then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 1, 22),
.days_between = 7,
.now_is_live = true,
.liquid = view.buildTotalsRow(300, 310),
.symbols = @constCast(&symbols),
.held_count = 3,
.added_count = 0,
.removed_count = 0,
.gainer_count = 2,
.loser_count = 1,
.flat_count = 0,
};
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Plural gainers, singular loser
try testing.expect(std.mem.indexOf(u8, out, "2 gainers") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 loser") != null);
// Singular "loser" shouldn't have trailing 's' — look for the
// comma-terminated form to disambiguate.
try testing.expect(std.mem.indexOf(u8, out, "1 losers") == null);
// No flat segment when flat_count == 0
try testing.expect(std.mem.indexOf(u8, out, "flat") == null);
}
test "renderCompare: gainer/loser summary suppressed when no held symbols" {
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: [2048]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
// Neither "gainer" nor "loser" should appear — the summary is
// gated on held_count > 0.
try testing.expect(std.mem.indexOf(u8, out, "gainer") == null);
try testing.expect(std.mem.indexOf(u8, out, "loser") == null);
}
test "renderCompare: gainer/loser summary includes flat when present" {
const symbols = [_]view.SymbolChange{
.{
.symbol = "UP",
.price_then = 100,
.price_now = 110,
.shares_held_throughout = 1,
.pct_change = 0.10,
.dollar_change = 10,
.style = .positive,
},
.{
.symbol = "FLAT1",
.price_then = 100,
.price_now = 100,
.shares_held_throughout = 1,
.pct_change = 0,
.dollar_change = 0,
.style = .muted,
},
.{
.symbol = "FLAT2",
.price_then = 100,
.price_now = 100,
.shares_held_throughout = 1,
.pct_change = 0,
.dollar_change = 0,
.style = .muted,
},
};
const cv = view.CompareView{
.then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 1, 22),
.days_between = 7,
.now_is_live = true,
.liquid = view.buildTotalsRow(300, 310),
.symbols = @constCast(&symbols),
.held_count = 3,
.added_count = 0,
.removed_count = 0,
.gainer_count = 1,
.loser_count = 0,
.flat_count = 2,
};
var buf: [4096]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try renderCompare(&stream, false, cv, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "1 gainer") != null);
try testing.expect(std.mem.indexOf(u8, out, "0 losers") != null);
try testing.expect(std.mem.indexOf(u8, out, "2 flat") != 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(std.testing.io, testing.allocator, config);
}
fn makeTestPortfolioPath(io: std.Io, tmp: *std.testing.TmpDir, allocator: std.mem.Allocator) ![]u8 {
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
return std.fs.path.join(allocator, &.{ dir_path, "portfolio.srf" });
}
/// Test helper: build a `RunCtx` with the bare-minimum fields the
/// compare command needs, then parse args and run. Wraps the
/// pre-framework `run(...)` signature so the existing tests below
/// keep working with minimal churn. `today` is injected so tests
/// stay deterministic.
fn runArgs(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
pf_path: []const u8,
cmd_args: []const []const u8,
today: Date,
color: bool,
out: *std.Io.Writer,
) !void {
var ctx: framework.RunCtx = .{
.io = io,
.allocator = allocator,
.gpa = allocator,
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = svc,
.globals = .{ .portfolio_path = pf_path },
.today = today,
.now_s = 0,
.color = color,
.out = out,
};
const parsed = try parseArgs(&ctx, cmd_args);
try run(&ctx, parsed);
}
test "run: zero args returns MissingDateArg" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
const result = runArgs(io, testing.allocator, &svc, pf, &.{}, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.MissingDateArg, result);
}
test "run: three args returns UnexpectedArg" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.UnexpectedArg, result);
}
test "run: bad date1 returns InvalidDate" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.InvalidDate, result);
}
test "run: valid date1 + bad date2 returns InvalidDate" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.InvalidDate, result);
}
test "run: same date twice returns SameDate" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.SameDate, result);
}
test "run: one date equal to today returns SameDate" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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_date = Date.fromYmd(2024, 3, 15);
const today_str = std.fmt.bufPrint(&today_buf, "{f}", .{today_date}) catch unreachable;
const args = [_][]const u8{today_str};
const result = runArgs(io, testing.allocator, &svc, pf, &args, today_date, false, &stream);
try testing.expectError(error.SameDate, result);
}
test "run: single-date past-date with empty history returns SnapshotNotFound" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.SnapshotNotFound, result);
}
test "run: single-date future-date rejected as InvalidDate" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.InvalidDate, result);
}
test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" {
const io = std.testing.io;
// Verifies that `zfin compare 1W` doesn't bail with InvalidDate
// for a non-ISO string — the relative shortcut resolves to an
// absolute date, which then tries to load a snapshot that
// doesn't exist.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
const args = [_][]const u8{"1W"};
const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.SnapshotNotFound, result);
}
test "run: 'live' string rejected as InvalidDate (not a real prior date)" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &tmp, testing.allocator);
defer testing.allocator.free(pf);
var buf: [1024]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
const args = [_][]const u8{"live"};
const result = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.InvalidDate, result);
}
test "run: two-date with empty history returns SnapshotNotFound (auto-swap path)" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
const pf = try makeTestPortfolioPath(io, &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 = runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), false, &stream);
try testing.expectError(error.SnapshotNotFound, result);
}
test "run: two-date happy path via fixtures" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var svc = makeTestSvc();
defer svc.deinit();
try tmp.dir.createDirPath(io, "history");
var hist_dir = try tmp.dir.openDir(io, "history", .{});
defer hist_dir.close(io);
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(io, hist_dir, testing.allocator, "2024-01-15-portfolio.srf", d1, 15_000, 15_000, &lots_then);
try writeFixtureSnapshot(io, hist_dir, testing.allocator, "2024-03-15-portfolio.srf", d2, 16_500, 16_500, &lots_now);
const pf = try makeTestPortfolioPath(io, &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 runArgs(io, testing.allocator, &svc, pf, &args, Date.fromYmd(2024, 3, 15), 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(
io: std.Io,
dir: std.Io.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 = @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.renderSnapshot(allocator, snap);
defer allocator.free(rendered);
try dir.writeFile(io, .{ .sub_path = filename, .data = rendered });
}
test "printSnapNote: 1 day earlier uses singular 'day'" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 14), "then");
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "requested 2024-03-15 for then") != null);
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2024-03-14") != null);
try testing.expect(std.mem.indexOf(u8, out, "1 day earlier") != null);
// Singular: must NOT contain "1 days"
try testing.expect(std.mem.indexOf(u8, out, "1 days") == null);
// Trailing newline
try testing.expectEqual(@as(u8, '\n'), out[out.len - 1]);
}
test "printSnapNote: multi-day uses plural 'days'" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 8), "now");
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "requested 2024-03-15 for now") != null);
try testing.expect(std.mem.indexOf(u8, out, "nearest snapshot: 2024-03-08") != null);
try testing.expect(std.mem.indexOf(u8, out, "7 days earlier") != null);
}
test "printSnapNote: label is interpolated verbatim" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 12), "vs");
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "for vs;") != null);
}
test "printSnapNote: color=false emits no ANSI escapes" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, false, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 12), "then");
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") == null);
}
test "printSnapNote: color=true emits muted-fg ANSI escape and reset" {
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, true, Date.fromYmd(2024, 3, 15), Date.fromYmd(2024, 3, 12), "then");
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "\x1b[38;2;") != null);
// Reset ANSI before newline
try testing.expect(std.mem.indexOf(u8, out, "\x1b[0m") != null);
}
test "printSnapNote: month-boundary day delta computes calendar days" {
// 2024-04-01 requested, 2024-03-30 actual → 2 days earlier.
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try printSnapNote(&w, false, Date.fromYmd(2024, 4, 1), Date.fromYmd(2024, 3, 30), "then");
try testing.expect(std.mem.indexOf(u8, w.buffered(), "2 days earlier") != null);
}