clean up contributions command
This commit is contained in:
parent
7a05d53dc9
commit
fef126471f
9 changed files with 1381 additions and 152 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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': ");
|
||||
|
|
|
|||
14
src/git.zig
14
src/git.zig
|
|
@ -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(.{
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue