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