1713 lines
66 KiB
Zig
1713 lines
66 KiB
Zig
//! `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);
|
||
}
|