clean up contributions command

This commit is contained in:
Emil Lerch 2026-05-02 11:02:06 -07:00
parent 7a05d53dc9
commit fef126471f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 1381 additions and 152 deletions

View file

@ -42,6 +42,18 @@ pub const AccountTaxEntry = struct {
institution: ?[]const u8 = null,
account_number: ?[]const u8 = null,
update_cadence: UpdateCadence = .weekly,
/// When true, raw cash-balance changes (`cash_delta` in the
/// contributions diff) on this account roll up into the
/// attribution total as real contributions.
///
/// Defaults to false because most cash accounts generate
/// `cash_delta` entries from internal movement interest posting,
/// dividend credit, CD coupon, settlement sweeps that would
/// inflate the attribution number if counted. Set to true only
/// for accounts whose cash movement is dominated by external
/// contributions (payroll ESPP accrual, direct 401k cash
/// deposits). See TODO.md for the design history.
cash_is_contribution: bool = false,
};
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
@ -120,6 +132,18 @@ pub const AccountMap = struct {
if (count == 0) return &.{};
return self.entries;
}
/// Is cash-balance movement on `account` treated as a real
/// contribution (vs. internal noise) for the attribution total?
/// Defaults to false when the account isn't in the map.
pub fn cashIsContribution(self: AccountMap, account: []const u8) bool {
for (self.entries) |e| {
if (std.mem.eql(u8, e.account, account)) {
return e.cash_is_contribution;
}
}
return false;
}
};
/// Parse an accounts.srf file into an AccountMap.
@ -147,6 +171,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
.update_cadence = entry.update_cadence,
.cash_is_contribution = entry.cash_is_contribution,
});
}
@ -377,6 +402,25 @@ test "parseAccountsFile" {
try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent"));
}
test "parseAccountsFile: cash_is_contribution default false, opt-in true" {
const data =
\\#!srfv1
\\account::Kelly ESPP,tax_type::taxable,cash_is_contribution:bool:true
\\account::Joint cash,tax_type::taxable
;
const allocator = std.testing.allocator;
var am = try parseAccountsFile(allocator, data);
defer am.deinit();
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
// Opted-in account
try std.testing.expect(am.cashIsContribution("Kelly ESPP"));
// Default-off account
try std.testing.expect(!am.cashIsContribution("Joint cash"));
// Unknown account defaults to false
try std.testing.expect(!am.cashIsContribution("Nonexistent"));
}
test "TaxType.label" {
try std.testing.expectEqualStrings("Taxable", TaxType.taxable.label());
try std.testing.expectEqualStrings("Roth (Post-Tax)", TaxType.roth.label());

View file

@ -1,6 +1,7 @@
const std = @import("std");
const zfin = @import("../root.zig");
const srf = @import("srf");
const history = @import("../history.zig");
pub const fmt = @import("../format.zig");
// Default CLI colors (match TUI default Monokai theme)
@ -521,6 +522,112 @@ pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []co
};
}
/// Parse a user-facing date argument that must resolve to a concrete
/// absolute date no "live"/"now"/empty. Accepts the same grammar
/// as `parseAsOfDate` (`YYYY-MM-DD` or relative shortcuts like `1W`,
/// `1M`, `1Q`, `1Y`, case-insensitive) minus the null-producing
/// inputs. Used by commands where a date-argument bound to a
/// specific date makes sense but "live" doesn't e.g. `compare`'s
/// positional args, `history --since`/`--until`, `snapshot --as-of`.
///
/// `today` is injected for test determinism. Production callers pass
/// `fmt.todayDate()`.
pub const RequiredDateError = AsOfParseError || error{LiveNotAllowed};
pub fn parseRequiredDate(input: []const u8, today: zfin.Date) RequiredDateError!zfin.Date {
const parsed = try parseAsOfDate(input, today);
return parsed orelse error.LiveNotAllowed;
}
/// Convenience pattern: parse a required date, print a helpful error
/// to stderr if it fails, and map every failure mode to a single
/// `error.InvalidDate`. Callers get a uniform error, stderr gets a
/// message that tells the user exactly what grammar is accepted
/// including the relative-shortcut syntax.
///
/// `today` is injected for test determinism.
pub fn parseRequiredDateOrStderr(
input: []const u8,
today: zfin.Date,
arg_label: []const u8,
) error{InvalidDate}!zfin.Date {
return parseRequiredDate(input, today) catch |err| {
var ebuf: [256]u8 = undefined;
const msg = switch (err) {
error.LiveNotAllowed => std.fmt.bufPrint(
&ebuf,
"Error: {s} must be a concrete date, not 'live'/'now'.\n",
.{arg_label},
) catch "Error: invalid date\n",
else => |e| blk: {
var inner: [256]u8 = undefined;
const detail = fmtAsOfParseError(&inner, input, e);
break :blk std.fmt.bufPrint(
&ebuf,
"Error: {s}: {s} (expected YYYY-MM-DD or a relative shortcut like 1W/1M/1Q/1Y)\n",
.{ arg_label, detail },
) catch "Error: invalid date\n";
},
};
stderrPrint(msg) catch {};
return error.InvalidDate;
};
}
// Snapshot resolution (CLI)
/// Snap a requested snapshot date to the nearest earlier snapshot
/// that exists in `hist_dir`, printing CLI-friendly stderr messages
/// when resolution fails.
///
/// Thin wrapper over `history.resolveSnapshotDate` that bundles the
/// "no snapshot at or before X, earliest available is Y" hint that
/// both `projections --as-of` and `compare` surface to the user.
/// Returns the full `ResolvedSnapshot` so callers can distinguish
/// exact vs. inexact matches (compare uses this to print a muted
/// "snapped to …" notice, projections uses `actual != requested` to
/// drive the header).
///
/// On `error.NoSnapshotAtOrBefore` the stderr messages are emitted
/// and the error is propagated verbatim; callers typically map it to
/// their own command-level error (`error.NoSnapshot`,
/// `error.SnapshotNotFound`, etc.). Other errors propagate without a
/// stderr write they indicate filesystem-level failures the caller
/// should surface itself.
///
/// Uses `arena` for the intermediate message strings; pass a
/// short-lived arena.
pub fn resolveSnapshotOrExplain(
arena: std.mem.Allocator,
hist_dir: []const u8,
requested: zfin.Date,
) !history.ResolvedSnapshot {
return history.resolveSnapshotDate(arena, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => {
var req_buf: [10]u8 = undefined;
const req_str = requested.format(&req_buf);
const msg = std.fmt.allocPrint(arena, "No snapshot at or before {s}.\n", .{req_str}) catch "No snapshot at or before the requested date.\n";
stderrPrint(msg) catch {};
// Second look at the nearest table for the "later
// available" hint. Cheap (filesystem scan, same dir).
const nearest = history.findNearestSnapshot(hist_dir, requested) catch {
stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {};
return err;
};
if (nearest.later) |later| {
var later_buf: [10]u8 = undefined;
const later_str = later.format(&later_buf);
const later_msg = std.fmt.allocPrint(arena, "Earliest available: {s} (later than requested).\n", .{later_str}) catch "A later snapshot exists but was not used.\n";
stderrPrint(later_msg) catch {};
} else {
stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {};
}
return err;
},
else => |e| return e,
};
}
// Watchlist loading
/// Load a watchlist SRF file containing symbol records.

View file

@ -54,7 +54,7 @@ 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");
const contributions = @import("contributions.zig");
pub const Error = error{
UnexpectedArg,
@ -76,10 +76,11 @@ pub fn run(
) !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("Error: 'compare' requires one or two dates.\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");
try cli.stderrPrint("Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n");
return error.MissingDateArg;
}
if (cmd_args.len > 2) {
@ -87,11 +88,9 @@ pub fn run(
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;
const today = fmt.todayDate();
const date1 = cli.parseRequiredDateOrStderr(cmd_args[0], today, "date1") catch |err| switch (err) {
error.InvalidDate => return error.InvalidDate,
};
// Resolve (then_date, now_date, now_is_live). In single-date mode the
@ -99,12 +98,9 @@ pub fn run(
// 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;
}
(cli.parseRequiredDateOrStderr(cmd_args[1], today, "date2") catch |err| switch (err) {
error.InvalidDate => return error.InvalidDate,
})
else
null;
@ -115,7 +111,6 @@ pub fn run(
// 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;
@ -142,28 +137,70 @@ pub fn run(
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 (e.g. `1W` resolves to a weekday but snapshots only exist
// for business days, or the cron was down that day). Snap to the
// most recent snapshot at-or-before the requested date same
// semantics as `projections --as-of`. When the snap is inexact,
// overwrite the date used downstream so the rendered header
// reflects what was actually loaded.
//
// IMPORTANT: we keep the *original requested* dates around for
// attribution. The attribution is a git-window query that should
// match what `zfin contributions --since <SAME_INPUT>` would
// produce if we passed the snapped dates to attribution, the
// git window would widen every time the requested date doesn't
// land on a snapshot, causing compare's attribution to disagree
// with contributions' totals over the same nominal window.
// Snapshot loading uses the snapped dates (so we actually find a
// file on disk); attribution uses the original dates.
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(arena, hist_dir, then_date) catch return error.SnapshotNotFound;
if (!then_resolved.exact) {
try printSnapNote(color, then_resolved.requested, then_resolved.actual, "then");
}
then_date = then_resolved.actual;
if (!now_is_live) {
const now_resolved = cli.resolveSnapshotOrExplain(arena, hist_dir, now_date) catch return error.SnapshotNotFound;
if (!now_resolved.exact) {
try printSnapNote(color, now_resolved.requested, now_resolved.actual, "now");
}
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.
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,
};
//
// 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(allocator, hist_dir, then_date);
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);
// Attribution spans then_date_requested HEAD (or working
// copy if dirty). `computeAttribution` with until=null uses
// exactly that semantics. Using the REQUESTED date (not the
// snapped-to-snapshot date) keeps the git window aligned
// with `zfin contributions --since <SAME_INPUT>`.
const attribution = contributions.computeAttribution(allocator, svc, portfolio_path, then_date_requested, null, color);
try renderFromParts(out, color, allocator, .{
.then_date = then_date,
@ -176,17 +213,14 @@ pub fn run(
.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,
};
var now_side = try compare_core.loadSnapshotSide(allocator, hist_dir, now_date);
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);
// Attribution spans then_date_requested now_date_requested.
// Using the REQUESTED dates (not the snapped-to-snapshot
// dates) keeps the git window aligned with what `zfin
// contributions --since <X> --until <Y>` would produce.
const attribution = contributions.computeAttribution(allocator, svc, portfolio_path, then_date_requested, now_date_requested, color);
try renderFromParts(out, color, allocator, .{
.then_date = then_date,
@ -201,6 +235,32 @@ pub fn run(
}
}
/// Snap a requested date to the nearest-earlier snapshot that exists
/// on disk. On `NoSnapshotAtOrBefore`, prints a user-facing "no
/// snapshot" error to stderr (with the nearest-later suggestion when
/// available) and propagates the underlying error so the caller can
/// map it to its own domain (e.g. `error.SnapshotNotFound`).
fn printSnapNote(color: bool, requested: Date, actual: Date, label: []const u8) !void {
var req_buf: [10]u8 = undefined;
var act_buf: [10]u8 = undefined;
const req_str = requested.format(&req_buf);
const act_str = actual.format(&act_buf);
const days = requested.days - actual.days;
var msg_buf: [160]u8 = undefined;
const msg = std.fmt.bufPrint(
&msg_buf,
"(requested {s} for {s}; nearest snapshot: {s}, {d} day{s} earlier)\n",
.{ req_str, label, act_str, days, if (days == 1) "" else "s" },
) catch "(snapped to nearest snapshot)\n";
var stderr_buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&stderr_buf);
const out = &writer.interface;
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);
try out.flush();
}
/// 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.
@ -216,7 +276,7 @@ const RenderArgs = struct {
now_liquid: f64,
then_map: *const view.HoldingMap,
now_map: *const view.HoldingMap,
attribution: ?contributions_cmd.AttributionSummary,
attribution: ?contributions.AttributionSummary,
};
/// Build the view from two holdings maps + totals, then render.
@ -334,55 +394,6 @@ const LiveSide = struct {
}
};
// 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`
@ -429,6 +440,8 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
for (cv.symbols) |s| {
try renderSymbolRow(out, color, s);
}
try renderGainerLoserSummary(out, color, cv);
}
// Hidden count
@ -442,6 +455,37 @@ fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView) !void {
}
}
/// 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;
@ -753,6 +797,145 @@ test "renderCompare: attribution handles negative gains" {
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);
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);
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);
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 {
@ -898,6 +1081,42 @@ test "run: single-date future-date rejected as InvalidDate" {
try testing.expectError(error.InvalidDate, result);
}
test "run: relative shortcut resolves (1W -> SnapshotNotFound against empty history)" {
// 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(&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 = run(testing.allocator, &svc, pf, &args, false, &stream);
try testing.expectError(error.SnapshotNotFound, result);
}
test "run: 'live' string rejected as InvalidDate (not a real prior date)" {
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{"live"};
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();
@ -985,7 +1204,7 @@ fn writeFixtureSnapshot(
net_worth: f64,
stock_rows: []const snapshot_model.LotRow,
) !void {
const snapshot_cmd = @import("snapshot.zig");
const snapshot = @import("snapshot.zig");
const totals = [_]snapshot_model.TotalRow{
.{ .kind = "total", .scope = "net_worth", .value = net_worth },
.{ .kind = "total", .scope = "liquid", .value = liquid },
@ -1005,7 +1224,7 @@ fn writeFixtureSnapshot(
.accounts = &.{},
.lots = @constCast(stock_rows),
};
const rendered = try snapshot_cmd.renderSnapshot(allocator, snap);
const rendered = try snapshot.renderSnapshot(allocator, snap);
defer allocator.free(rendered);
try dir.writeFile(.{ .sub_path = filename, .data = rendered });
}

File diff suppressed because it is too large Load diff

View file

@ -65,7 +65,13 @@ pub const PortfolioOpts = struct {
};
/// Parse the arg list for portfolio-mode flags. Pure function no IO.
///
/// `--since` and `--until` accept the same grammar as other commands:
/// `YYYY-MM-DD` or a relative shortcut like `1W`, `1M`, `1Q`, `1Y`.
/// Relative forms resolve against today (from the system clock) at
/// call time; pass explicit ISO dates for test determinism.
pub fn parsePortfolioOpts(args: []const []const u8) Error!PortfolioOpts {
const today = fmt.todayDate();
var opts: PortfolioOpts = .{};
var i: usize = 0;
while (i < args.len) : (i += 1) {
@ -73,11 +79,11 @@ pub fn parsePortfolioOpts(args: []const []const u8) Error!PortfolioOpts {
if (std.mem.eql(u8, a, "--since")) {
i += 1;
if (i >= args.len) return error.MissingFlagValue;
opts.since = Date.parse(args[i]) catch return error.InvalidFlagValue;
opts.since = cli.parseRequiredDate(args[i], today) catch return error.InvalidFlagValue;
} else if (std.mem.eql(u8, a, "--until")) {
i += 1;
if (i >= args.len) return error.MissingFlagValue;
opts.until = Date.parse(args[i]) catch return error.InvalidFlagValue;
opts.until = cli.parseRequiredDate(args[i], today) catch return error.InvalidFlagValue;
} else if (std.mem.eql(u8, a, "--metric")) {
i += 1;
if (i >= args.len) return error.MissingFlagValue;

View file

@ -302,11 +302,12 @@ pub fn run(
/// Resolve the user's requested as-of date against the history directory.
///
/// Thin adapter over `history.resolveSnapshotDate` the shared pure
/// resolver owns exact-then-fallback logic. This wrapper maps the
/// error set to `error.NoSnapshot` and surfaces user-visible stderr
/// messages (including the "Earliest available (later than requested)"
/// hint, which the TUI doesn't need).
/// Thin adapter over `cli.resolveSnapshotOrExplain` the shared CLI
/// helper owns exact-then-fallback resolution and the stderr
/// messaging. This wrapper just maps the error set to
/// `error.NoSnapshot` (projections-specific) and strips the `exact`
/// flag since projections doesn't surface that distinction in its
/// header (it just uses `actual` directly).
///
/// Arena-allocates the intermediate `hist_dir` + filename strings;
/// pass a short-lived arena as `va`.
@ -317,28 +318,8 @@ fn resolveAsOfSnapshot(
) !AsOfResolution {
const hist_dir = try history.deriveHistoryDir(va, file_path);
const resolved = history.resolveSnapshotDate(va, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => {
var req_buf: [10]u8 = undefined;
const req_str = requested.format(&req_buf);
const msg = std.fmt.allocPrint(va, "No snapshot at or before {s}.\n", .{req_str}) catch "No snapshot at or before the requested date.\n";
try cli.stderrPrint(msg);
// Second look at the nearest table for the "later available"
// hint. Cheap (filesystem scan, same dir).
const nearest = history.findNearestSnapshot(hist_dir, requested) catch {
try cli.stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n");
return error.NoSnapshot;
};
if (nearest.later) |later| {
var later_buf: [10]u8 = undefined;
const later_str = later.format(&later_buf);
const later_msg = std.fmt.allocPrint(va, "Earliest available: {s} (later than requested).\n", .{later_str}) catch "A later snapshot exists but was not used.\n";
try cli.stderrPrint(later_msg);
} else {
try cli.stderrPrint("No snapshots in history/ — run `zfin snapshot` to create one.\n");
}
return error.NoSnapshot;
},
const resolved = cli.resolveSnapshotOrExplain(va, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => return error.NoSnapshot,
else => |e| {
try cli.stderrPrint("Error resolving snapshot: ");
try cli.stderrPrint(@errorName(e));
@ -380,7 +361,7 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi
const testing = std.testing;
const snapshot_model = @import("../models/snapshot.zig");
const snapshot_cmd = @import("snapshot.zig");
const snapshot = @import("snapshot.zig");
fn makeTestSvc() zfin.DataService {
const config = zfin.Config{ .cache_dir = "/tmp" };
@ -428,7 +409,7 @@ fn writeFixtureSnapshot(
.accounts = &.{},
.lots = @constCast(&lots),
};
const rendered = try snapshot_cmd.renderSnapshot(allocator, snap);
const rendered = try snapshot.renderSnapshot(allocator, snap);
defer allocator.free(rendered);
try dir.writeFile(.{ .sub_path = filename, .data = rendered });
}

View file

@ -97,12 +97,11 @@ pub fn run(
} else if (std.mem.eql(u8, a, "--as-of")) {
i += 1;
if (i >= args.len) {
try cli.stderrPrint("Error: --as-of requires a date (YYYY-MM-DD)\n");
try cli.stderrPrint("Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n");
return error.UnexpectedArg;
}
as_of_override = Date.parse(args[i]) catch {
try cli.stderrPrint("Error: --as-of: invalid date (expected YYYY-MM-DD)\n");
return error.UnexpectedArg;
as_of_override = cli.parseRequiredDateOrStderr(args[i], cli.fmt.todayDate(), "--as-of") catch |err| switch (err) {
error.InvalidDate => return error.UnexpectedArg,
};
} else {
try cli.stderrPrint("Error: unknown argument to 'snapshot': ");

View file

@ -331,10 +331,16 @@ pub fn commitAtOrBeforeDate(
rel_path: []const u8,
date_iso: []const u8,
) Error!?[]const u8 {
// `git log --until=DATE` uses the end of the given date as the
// inclusive upper bound. Adding a "T23:59:59" suffix isn't
// necessary git already interprets bare dates as "end of day".
const until_arg = try std.fmt.allocPrint(allocator, "--until={s}", .{date_iso});
// `git log --until=DATE` with a bare YYYY-MM-DD uses the *current
// time-of-day* applied to DATE as the cutoff NOT end of day as
// intuition suggests. That means at 10:40am today, `--until=X`
// excludes any commits on X made after 10:40am, which causes
// day-of-review windows to randomly include or exclude commits
// depending on when the command is run. Explicitly pin the cutoff
// to 23:59:59 local so "since 2026-04-25" always means "include
// all commits on 2026-04-25, regardless of what time I'm
// looking".
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
defer allocator.free(until_arg);
const result = std.process.Child.run(.{

View file

@ -137,6 +137,14 @@ pub const CompareView = struct {
/// Symbols present in "then" but not "now" position closed
/// between the two dates. Never rendered as rows; shown as a count.
removed_count: usize,
/// Number of held-throughout symbols with `pct_change > flat_threshold`.
/// Intended for the per-symbol summary footer ("21 gainers, 5 losers").
gainer_count: usize = 0,
/// Number of held-throughout symbols with `pct_change < -flat_threshold`.
loser_count: usize = 0,
/// Number of held-throughout symbols with `|pct_change| <= flat_threshold`.
/// `gainer_count + loser_count + flat_count == held_count`.
flat_count: usize = 0,
/// Optional contributions-vs-gains breakdown of `liquid.delta`.
/// Populated by the CLI from `computeAttribution` when a git repo
/// is available; always null in unit-tested / TUI flows.
@ -147,6 +155,14 @@ pub const CompareView = struct {
}
};
/// Threshold under which a pct_change is considered flat for the
/// gainer/loser summary footer. `0.0001 == 0.01%`. Chosen so penny-
/// level rounding noise on high-priced positions (e.g. a $500 stock
/// moving one cent is 0.002%) stays out of the "gainers" bucket while
/// anything visibly colored as positive/negative in the per-symbol
/// table crosses the threshold.
pub const flat_threshold: f64 = 0.0001;
/// One entry in a holdings snapshot total shares held of `symbol` and
/// the per-share price at that moment. Caller-populated; the view model
/// doesn't know or care where the numbers came from.
@ -261,6 +277,23 @@ pub fn buildCompareView(
}
}.lt);
// Bucket held-throughout rows into gainers / losers / flat using
// `flat_threshold` so that cent-rounding noise on a high-priced
// position doesn't get counted as a win or a loss. Computed after
// the sort purely for locality buckets are independent of order.
var gainers: usize = 0;
var losers: usize = 0;
var flats: usize = 0;
for (changes.items) |c| {
if (c.pct_change > flat_threshold) {
gainers += 1;
} else if (c.pct_change < -flat_threshold) {
losers += 1;
} else {
flats += 1;
}
}
const items = try changes.toOwnedSlice(allocator);
return .{
.then_date = then_date,
@ -272,6 +305,9 @@ pub fn buildCompareView(
.held_count = items.len,
.added_count = added,
.removed_count = removed,
.gainer_count = gainers,
.loser_count = losers,
.flat_count = flats,
};
}
@ -487,6 +523,73 @@ test "buildCompareView: sort strictly descending across many symbols" {
try testing.expectEqualStrings("E", view.symbols[2].symbol);
try testing.expectEqualStrings("D", view.symbols[3].symbol);
try testing.expectEqualStrings("B", view.symbols[4].symbol);
// Gainer/loser buckets: C/A/E > flat_threshold, D/B < -flat_threshold
try testing.expectEqual(@as(usize, 3), view.gainer_count);
try testing.expectEqual(@as(usize, 2), view.loser_count);
try testing.expectEqual(@as(usize, 0), view.flat_count);
}
test "buildCompareView: gainer/loser/flat buckets with near-zero moves" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
// Clear gainer
try then_map.put("UP", .{ .shares = 1, .price = 100.0 });
try now_map.put("UP", .{ .shares = 1, .price = 101.0 }); // +1%
// Clear loser
try then_map.put("DN", .{ .shares = 1, .price = 100.0 });
try now_map.put("DN", .{ .shares = 1, .price = 98.0 }); // -2%
// Exactly flat
try then_map.put("FLAT", .{ .shares = 1, .price = 100.0 });
try now_map.put("FLAT", .{ .shares = 1, .price = 100.0 }); // 0%
// Rounding-noise positive (below flat_threshold of 0.01%)
try then_map.put("NOISE", .{ .shares = 1, .price = 1000.0 });
try now_map.put("NOISE", .{ .shares = 1, .price = 1000.05 }); // +0.005%
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 2, 1),
false,
4000.0,
4001.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 4), view.held_count);
try testing.expectEqual(@as(usize, 1), view.gainer_count);
try testing.expectEqual(@as(usize, 1), view.loser_count);
try testing.expectEqual(@as(usize, 2), view.flat_count);
// Sanity: buckets sum to held_count.
try testing.expectEqual(view.held_count, view.gainer_count + view.loser_count + view.flat_count);
}
test "buildCompareView: zero held-throughout yields zero counts for all three buckets" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 1, 1),
true,
0.0,
0.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), view.gainer_count);
try testing.expectEqual(@as(usize, 0), view.loser_count);
try testing.expectEqual(@as(usize, 0), view.flat_count);
}
// Layout constants + row-cell builders