1414 lines
61 KiB
Zig
1414 lines
61 KiB
Zig
//! `src/views/compare.zig` - view model for the portfolio comparison UX.
|
|
//!
|
|
//! Renderer-agnostic display data: no ANSI, no writer, no vaxis. Sits
|
|
//! alongside `views/portfolio_sections.zig` and `views/history.zig`
|
|
//! in the views layer. CLI and TUI renderers both consume the
|
|
//! `CompareView` produced here:
|
|
//!
|
|
//! - CLI renderer: `src/commands/compare.zig` (ANSI writer)
|
|
//! - TUI renderer: `src/tui/history_tab.zig` (vaxis-styled lines)
|
|
//!
|
|
//! ## Semantics
|
|
//!
|
|
//! Two snapshots - "then" and "now" - each described as a map of
|
|
//! `symbol -> (shares, price)` plus a liquid-value total. "Now" may be
|
|
//! either another historical snapshot or the live portfolio; the view
|
|
//! doesn't care which.
|
|
//!
|
|
//! ### Liquid totals
|
|
//!
|
|
//! Raw delta: `now - then`. This **includes contributions and withdrawals**.
|
|
//! The per-symbol section below is the pure investment signal - total-level
|
|
//! returns are deliberately not adjusted for flows, because reconstructing
|
|
//! contribution history is out of scope.
|
|
//!
|
|
//! ### Per-symbol price change
|
|
//!
|
|
//! Only symbols held on **both** dates appear. Added/removed positions are
|
|
//! counted but not rendered - that matches the "I don't care about added/
|
|
//! removed" constraint and keeps the output scannable.
|
|
//!
|
|
//! Two per-symbol numbers:
|
|
//! - `pct_change` = `price_now / price_then - 1` (price-only, share-count
|
|
//! changes between the two dates don't affect it)
|
|
//! - `dollar_change` = `min(shares_then, shares_now) * (price_now - price_then)`
|
|
//! - the "held throughout" dollar impact. Uses the share floor to
|
|
//! isolate continuously-held exposure; shares added between the dates
|
|
//! don't contribute (matching the "don't count adds" intent),
|
|
//! shares sold don't either.
|
|
//!
|
|
//! ### Mixed share classes
|
|
//!
|
|
//! A symbol whose lots span share classes (a `ticker::` alias shared by
|
|
//! holdings at different `price_ratio`s) has no single meaningful
|
|
//! per-share price. Such a row is marked `price_comparable = false`: the
|
|
//! two price cells render as the no-data sentinel, and `pct_change` /
|
|
//! `dollar_change` switch to a VALUE basis - `value_now / value_then - 1`
|
|
//! and `value_now - value_then`.
|
|
//!
|
|
//! Whether that percentage is a real return then depends on one thing:
|
|
//! did the share count move? A group's value is
|
|
//! `base_price * SUM(shares_i * ratio_i)`, so with shares and ratios
|
|
//! fixed the value ratio reduces EXACTLY to the underlying price ratio.
|
|
//! Such a row is `pct_reliable` and sorts inline with everything else -
|
|
//! its percentage is as good as any price-derived one, it just has no
|
|
//! single price to show alongside.
|
|
//!
|
|
//! If shares were bought or sold, the figure absorbs that flow and no
|
|
//! longer isolates the market. Those rows are `pct_reliable = false`:
|
|
//! tallied separately, sorted last, and named in a footnote, so they are
|
|
//! easy to set aside as a block. `CompareView.unreliable_count` exists so
|
|
//! renderers can surface that.
|
|
//!
|
|
//! Sorted by `pct_change` descending - biggest winners first, with
|
|
//! unreliable rows last.
|
|
//!
|
|
//! ## Contract
|
|
//!
|
|
//! No IO, no fetching, no rendering. Callers are responsible for
|
|
//! loading snapshots, aggregating lot rows by symbol, and producing
|
|
//! the two `HoldingMap`s (see `src/compare.zig`). This module does
|
|
//! the math and returns a sorted, styled (via `StyleIntent`) view
|
|
//! that either renderer can consume.
|
|
|
|
const std = @import("std");
|
|
const fmt = @import("../format.zig");
|
|
const Date = @import("../Date.zig");
|
|
const Money = @import("../Money.zig");
|
|
const timeline = @import("../analytics/timeline.zig");
|
|
const view_hist = @import("history.zig");
|
|
|
|
pub const StyleIntent = fmt.StyleIntent;
|
|
|
|
// ── Data types ───────────────────────────────────────────────
|
|
|
|
/// A single per-symbol comparison row, pre-computed.
|
|
///
|
|
/// The `symbol` string is borrowed from the caller's `HoldingMap` - the
|
|
/// caller must keep that map (and its backing buffers) alive as long as
|
|
/// the view.
|
|
pub const SymbolChange = struct {
|
|
symbol: []const u8,
|
|
price_then: f64,
|
|
price_now: f64,
|
|
/// `min(shares_then, shares_now)` - the continuously-held floor.
|
|
/// Drives `dollar_change` when `price_comparable`.
|
|
shares_held_throughout: f64,
|
|
/// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by
|
|
/// 100 at format time via `fmtSignedPercentBuf` or similar.
|
|
///
|
|
/// BASIS DEPENDS ON `price_comparable`:
|
|
/// - true: `price_now / price_then - 1` - price-only, unaffected by
|
|
/// share-count changes between the dates.
|
|
/// - false: `value_now / value_then - 1` - total value change, which
|
|
/// ALSO includes anything bought or sold in the window.
|
|
/// No price-only figure is derivable for such a group.
|
|
pct_change: f64,
|
|
/// Signed. Meaning depends on `price_comparable`:
|
|
/// - true: `shares_held_throughout * (price_now - price_then)` -
|
|
/// the price-only impact on continuously-held shares.
|
|
/// - false: `value_now - value_then` - the TOTAL value change, on
|
|
/// the same basis as `pct_change` above.
|
|
dollar_change: f64,
|
|
/// False when either side of the comparison is a `mixed_class`
|
|
/// holding, meaning no single per-share price describes the group.
|
|
/// `buildSymbolRowCells` emits the no-data sentinel for the two price
|
|
/// cells, and `pct_change` / `dollar_change` switch to a VALUE basis.
|
|
///
|
|
/// Gates on EITHER side on purpose: a snapshot's per-lot price and the
|
|
/// live side's base-ticker price are not comparable quantities, so one
|
|
/// mixed side poisons the pair even if the other is clean.
|
|
price_comparable: bool = true,
|
|
/// Whether `pct_change` can be read as a genuine return.
|
|
///
|
|
/// Always true for `price_comparable` rows - a price ratio is
|
|
/// share-count-independent by construction.
|
|
///
|
|
/// For a mixed-class row it is true exactly when the share count did
|
|
/// NOT move between the two dates. That is not a heuristic: a group's
|
|
/// value is `base_price * SUM(shares_i * ratio_i)`, so with the shares
|
|
/// and ratios fixed, `value_now / value_then` reduces exactly to
|
|
/// `base_now / base_then` - the real underlying return. Once shares
|
|
/// move, the ratio also absorbs whatever was bought or sold and no
|
|
/// longer isolates the market.
|
|
///
|
|
/// Renderers use this to decide sort position and whether to warn:
|
|
/// reliable rows sort inline with everything else and count toward
|
|
/// gainers/losers, unreliable ones sort last and are called out.
|
|
///
|
|
/// Caveat: a `price_ratio` RESTATEMENT (rewriting the ratio without
|
|
/// changing shares) also breaks the reduction, and this cannot detect
|
|
/// that - the ratio is not recorded in a snapshot.
|
|
pct_reliable: bool = true,
|
|
/// `.positive` when the driving figure is > 0, `.negative` when < 0,
|
|
/// `.muted` when zero. Driven by `pct_change` when comparable, by
|
|
/// `dollar_change` otherwise.
|
|
style: StyleIntent,
|
|
};
|
|
|
|
/// The liquid-total row for the totals section. Raw delta (includes flows).
|
|
pub const TotalsRow = struct {
|
|
then: f64,
|
|
now: f64,
|
|
/// `now - then`.
|
|
delta: f64,
|
|
/// Ratio. `0.05` means +5%. Zero when `then` is zero (avoids NaN).
|
|
pct: f64,
|
|
/// `.positive`/`.negative`/`.muted` by `delta` sign.
|
|
style: StyleIntent,
|
|
};
|
|
|
|
/// Optional attribution breakdown of the liquid delta into
|
|
/// contributions (money in) vs investment gains (market movement).
|
|
///
|
|
/// Populated in both single-date ("vs current") and two-date modes
|
|
/// when the portfolio is git-tracked and both endpoints resolve to
|
|
/// commits via `git.resolveCommitRange`. Silently null when the git
|
|
/// lookup fails (missing repo, untracked file, no commits in range);
|
|
/// the compare view falls back to just the totals + per-symbol table.
|
|
///
|
|
/// Math: `delta = contributions + gains`, so `gains = delta - contributions`.
|
|
/// Signs are preserved: negative contributions (net withdrawal) and
|
|
/// negative gains (market loss) both appear.
|
|
pub const Attribution = struct {
|
|
/// Sum of new-money contributions plus DRIP reinvestments (what
|
|
/// `zfin contributions` reports as "money in").
|
|
contributions: f64,
|
|
/// `TotalsRow.delta - contributions`. The residual - what the
|
|
/// market actually did, PLUS anything the classifier declined to
|
|
/// count. See `uncounted_in` / `uncounted_out`.
|
|
gains: f64,
|
|
/// Gross movement the contributions pipeline deliberately excluded, split by
|
|
/// direction (`out` is negative). Present so the residual above can be read
|
|
/// with its error bars visible: raw cash-balance changes and share reductions
|
|
/// are excluded by design, and folding them into `gains` unannounced makes a
|
|
/// classification gap look like market performance.
|
|
///
|
|
/// Both zero on a clean week, in which case the renderer omits the line.
|
|
uncounted_in: f64 = 0,
|
|
uncounted_out: f64 = 0,
|
|
|
|
pub fn hasUncounted(self: Attribution) bool {
|
|
return @abs(self.uncounted_in) >= 0.005 or @abs(self.uncounted_out) >= 0.005;
|
|
}
|
|
};
|
|
|
|
/// Complete compare view. `symbols` is caller-owned; call `deinit()`.
|
|
pub const CompareView = struct {
|
|
then_date: Date,
|
|
now_date: Date,
|
|
/// `now_date.days - then_date.days`. Can be zero (same-day compare
|
|
/// is a no-op but won't error) or negative if the caller didn't
|
|
/// normalize order, but the CLI always swaps so it's ≥ 0 in
|
|
/// practice.
|
|
days_between: i32,
|
|
/// True when the "now" side is the live portfolio (not another
|
|
/// snapshot). Renderers can use this to distinguish "compared to
|
|
/// today" from "compared to another snapshot date".
|
|
now_is_live: bool,
|
|
liquid: TotalsRow,
|
|
/// Sorted by `pct_change` descending. Owned by the view.
|
|
symbols: []SymbolChange,
|
|
/// Count of held-throughout symbols - always `== symbols.len`;
|
|
/// surfaced here for convenience in rendering the "(N held
|
|
/// throughout)" subtitle.
|
|
held_count: usize,
|
|
/// Symbols present in "now" but not "then" - position opened
|
|
/// between the two dates. Never rendered as rows; shown as a count.
|
|
added_count: usize,
|
|
/// 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").
|
|
/// Counts only `price_comparable` rows.
|
|
gainer_count: usize = 0,
|
|
/// Number of held-throughout symbols with `pct_change < -flat_threshold`.
|
|
/// Counts only `price_comparable` rows.
|
|
loser_count: usize = 0,
|
|
/// Number of held-throughout symbols with `|pct_change| <= flat_threshold`.
|
|
/// Counts only `price_comparable` rows.
|
|
flat_count: usize = 0,
|
|
/// Number of held-throughout symbols whose percentage cannot be read
|
|
/// as a return (`SymbolChange.pct_reliable == false`): a mixed-class
|
|
/// group whose share count moved, so the figure mixes market movement
|
|
/// with whatever was bought or sold.
|
|
///
|
|
/// Its own bucket rather than folded into `flat_count`, which would
|
|
/// understate the gainers/losers tally. Note a mixed-class row with a
|
|
/// STATIC share count is NOT counted here - it buckets as a normal
|
|
/// gainer/loser/flat, because its value ratio is a genuine return.
|
|
///
|
|
/// `gainer_count + loser_count + flat_count + unreliable_count == held_count`.
|
|
unreliable_count: usize = 0,
|
|
/// Optional contributions-vs-gains breakdown of `liquid.delta`.
|
|
/// Populated by the CLI from `computeAttributionSpec` when a git repo
|
|
/// is available; always null in unit-tested / TUI flows.
|
|
attribution: ?Attribution = null,
|
|
|
|
/// Optional human-facing labels for each side. When set, the
|
|
/// renderer uses these instead of the bare `YYYY-MM-DD` for the
|
|
/// header - useful for bucketed selections where the user picked
|
|
/// (e.g.) "Q1 2025" and we want to render
|
|
/// "Q1 2025 (ended 2025-03-28)" rather than "2025-03-28" alone.
|
|
/// Null falls back to ISO-date rendering.
|
|
then_label: ?[]const u8 = null,
|
|
now_label: ?[]const u8 = null,
|
|
|
|
pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
|
|
allocator.free(self.symbols);
|
|
if (self.then_label) |l| allocator.free(l);
|
|
if (self.now_label) |l| allocator.free(l);
|
|
}
|
|
};
|
|
|
|
/// Build a human-facing label for a History-tab row selected as
|
|
/// the "then" or "now" side of a compare view. Returns an
|
|
/// allocator-owned string like `"Q1 2025 (ended 2025-03-28)"`,
|
|
/// or null when the caller should fall back to plain ISO-date
|
|
/// rendering. Null is returned for:
|
|
/// - Live rows (the "now" side often points at the in-progress
|
|
/// bucket; caller renders this as `"today"` itself).
|
|
/// - Daily rows or rows without a tier (the ISO date is already
|
|
/// the right label - annotating `"2025-03-28 (ended 2025-03-28)"`
|
|
/// would be useless duplication).
|
|
///
|
|
/// The label is built by composing `timeline.formatBucketLabel`
|
|
/// with an ` (ended YYYY-MM-DD)` suffix, so this function shares
|
|
/// all tier-formatting logic with the cascading-history table.
|
|
///
|
|
/// `tier`/`bucket_start` are optional to match the upstream
|
|
/// `TableRow`/CLI bucket-row shapes, where rows without a bucket
|
|
/// origin (live rows, plain daily rows) carry null. When
|
|
/// `bucket_start` is null but a tier is present the function
|
|
/// returns the ISO date alone (defensive - shouldn't happen for
|
|
/// non-daily rows, but keeps the renderer honest if it does).
|
|
pub fn buildBucketLabel(
|
|
allocator: std.mem.Allocator,
|
|
tier: ?timeline.Tier,
|
|
bucket_start: ?Date,
|
|
date: Date,
|
|
is_live: bool,
|
|
) !?[]const u8 {
|
|
if (is_live) return null;
|
|
const t = tier orelse return null;
|
|
if (t == .daily) return null;
|
|
|
|
var iso_buf: [10]u8 = undefined;
|
|
const iso = std.fmt.bufPrint(&iso_buf, "{f}", .{date}) catch "????-??-??";
|
|
|
|
// No bucket origin -> return ISO alone (caller may have wanted
|
|
// a label for some surface-specific reason; better than null
|
|
// here because we already committed to "non-daily" above).
|
|
const start = bucket_start orelse return try allocator.dupe(u8, iso);
|
|
|
|
var prefix_buf: [32]u8 = undefined;
|
|
const prefix = timeline.formatBucketLabel(&prefix_buf, t, start);
|
|
return try std.fmt.allocPrint(allocator, "{s} (ended {s})", .{ prefix, iso });
|
|
}
|
|
|
|
/// 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`, the
|
|
/// per-share price at that moment, and the total value. Caller-populated;
|
|
/// the view model doesn't know or care where the numbers came from.
|
|
///
|
|
/// `shares * price == value` must hold. That is not decoration: it is the
|
|
/// only thing that makes `price` meaningful, and it is exactly what broke
|
|
/// when a symbol's lots spanned share classes. See `mixed_class`.
|
|
pub const Holding = struct {
|
|
shares: f64,
|
|
price: f64,
|
|
/// Total market value. Carried explicitly rather than recomputed as
|
|
/// `shares * price`, because for a `mixed_class` group that product
|
|
/// is meaningless while the value is still exact.
|
|
value: f64 = 0,
|
|
/// True when this symbol's lots did NOT share a single per-share
|
|
/// price on this side of the comparison - i.e. the aggregation summed
|
|
/// across share classes (a direct-indexing sleeve at $775.34, a 401k
|
|
/// CIT at $182.86 and retail shares at $90.17 can all sit under one
|
|
/// `ticker::`). For such a group `shares` is a sum of counts in
|
|
/// different units and no single `price` describes it, so
|
|
/// `buildSymbolChange` refuses to compare prices and the renderer
|
|
/// shows the no-data sentinel instead of a fabricated move.
|
|
///
|
|
/// A single price genuinely cannot be recovered for these: snapshot
|
|
/// `LotRow` folds `price_ratio` into its per-lot `price` and never
|
|
/// records the base price, so base-equivalent share counts are not
|
|
/// derivable from an existing snapshot at all.
|
|
mixed_class: bool = false,
|
|
};
|
|
|
|
/// Symbol -> Holding. String keys are caller-owned; keep them alive as
|
|
/// long as the resulting `CompareView`.
|
|
pub const HoldingMap = std.StringHashMap(Holding);
|
|
|
|
// ── Pure builders ────────────────────────────────────────────
|
|
|
|
/// Compute a single per-symbol change from the two sides' holdings.
|
|
///
|
|
/// The pct-change denominator is `then.price`. If it is zero (shouldn't
|
|
/// happen for stocks but guards against bad data), the pct_change is
|
|
/// reported as 0 rather than a NaN/Inf leaking into the sort comparator.
|
|
///
|
|
/// When EITHER side is `mixed_class` no single per-share price describes
|
|
/// the group, so the price cells are suppressed and both figures switch
|
|
/// to a VALUE basis: `pct_change = value_now / value_then - 1`,
|
|
/// `dollar_change = value_now - value_then`.
|
|
///
|
|
/// Whether that percentage is a trustworthy return then depends on
|
|
/// whether the share count moved - see `SymbolChange.pct_reliable`. With
|
|
/// composition fixed the value ratio reduces exactly to the underlying
|
|
/// price ratio, so the row belongs inline with the price-basis rows. If
|
|
/// shares were bought or sold, the figure absorbs that flow and the row
|
|
/// is marked unreliable so a renderer can set it aside.
|
|
pub fn buildSymbolChange(symbol: []const u8, then: Holding, now: Holding) SymbolChange {
|
|
const held = @min(then.shares, now.shares);
|
|
const comparable = !then.mixed_class and !now.mixed_class;
|
|
|
|
if (!comparable) {
|
|
const dollar = now.value - then.value;
|
|
const pct = if (then.value != 0) (now.value / then.value - 1.0) else 0.0;
|
|
return .{
|
|
.symbol = symbol,
|
|
.price_then = then.price,
|
|
.price_now = now.price,
|
|
.shares_held_throughout = held,
|
|
.pct_change = pct,
|
|
.dollar_change = dollar,
|
|
.price_comparable = false,
|
|
.pct_reliable = sharesUnchanged(then.shares, now.shares),
|
|
.style = if (dollar > 0) .positive else if (dollar < 0) .negative else .muted,
|
|
};
|
|
}
|
|
|
|
const pct = if (then.price != 0) (now.price / then.price - 1.0) else 0.0;
|
|
const dollar = held * (now.price - then.price);
|
|
const style: StyleIntent = if (pct > 0) .positive else if (pct < 0) .negative else .muted;
|
|
return .{
|
|
.symbol = symbol,
|
|
.price_then = then.price,
|
|
.price_now = now.price,
|
|
.shares_held_throughout = held,
|
|
.pct_change = pct,
|
|
.dollar_change = dollar,
|
|
.style = style,
|
|
};
|
|
}
|
|
|
|
/// Whether two share counts are the same position rather than a
|
|
/// purchase, sale or DRIP.
|
|
///
|
|
/// Relative tolerance, because both sides arrive through float
|
|
/// arithmetic: the snapshot side recovers each lot's count as
|
|
/// `value / price` and the live side sums `effectiveShares()`, so a
|
|
/// genuinely static holding can differ in the last bits. The absolute
|
|
/// floor covers counts near zero.
|
|
///
|
|
/// A real purchase is orders of magnitude above this - the smallest
|
|
/// meaningful move is a fractional DRIP share, not 1e-6 of a share.
|
|
fn sharesUnchanged(a: f64, b: f64) bool {
|
|
const diff = @abs(a - b);
|
|
return diff <= @max(1e-6, @abs(a) * 1e-6);
|
|
}
|
|
|
|
/// Compute the liquid totals row. Safe when `then == 0` (pct -> 0 rather
|
|
/// than NaN).
|
|
pub fn buildTotalsRow(then: f64, now: f64) TotalsRow {
|
|
const delta = now - then;
|
|
const pct = if (then != 0) delta / then else 0.0;
|
|
const style: StyleIntent = if (delta > 0) .positive else if (delta < 0) .negative else .muted;
|
|
return .{
|
|
.then = then,
|
|
.now = now,
|
|
.delta = delta,
|
|
.pct = pct,
|
|
.style = style,
|
|
};
|
|
}
|
|
|
|
/// Bring a "then" snapshot's holdings into the "now" split basis, in
|
|
/// place. For each symbol present in `factors`, multiplies shares by
|
|
/// and divides price by the cumulative split ratio, so a split between
|
|
/// the two epochs doesn't read as a phantom share-count jump or a
|
|
/// price crash in `buildSymbolChange`.
|
|
///
|
|
/// Pure: the caller resolves `factors` (symbol -> cumulative split
|
|
/// ratio in the window `(max(cutover, then_date), now_date]`) from the
|
|
/// split corpus. This bridges the epoch gap between the "then" snapshot
|
|
/// and "now" so a held-throughout position nets to zero share change
|
|
/// across the split. A factor of 1.0 (or a symbol absent from `factors`)
|
|
/// is a no-op, so this is inert unless the split opt-in is on and a
|
|
/// split actually applies.
|
|
pub fn forwardAdjustThen(then_map: *HoldingMap, factors: *const std.StringHashMap(f64)) void {
|
|
var it = then_map.iterator();
|
|
while (it.next()) |e| {
|
|
const f = factors.get(e.key_ptr.*) orelse continue;
|
|
if (f == 1.0 or f == 0.0) continue;
|
|
e.value_ptr.shares *= f;
|
|
e.value_ptr.price /= f;
|
|
// `value` is deliberately untouched: a split changes the share
|
|
// count and the per-share price by reciprocal factors, so the
|
|
// holding's total value is unchanged. This keeps the
|
|
// `shares * price == value` invariant intact through the
|
|
// adjustment.
|
|
}
|
|
}
|
|
|
|
/// Primary view builder. Intersects `then_map` with `now_map`, computes
|
|
/// per-symbol changes for held-throughout symbols, counts added/removed,
|
|
/// and sorts descending by `pct_change`.
|
|
///
|
|
/// Symbol strings in the returned view borrow from `then_map`'s keys
|
|
/// (since we iterate `then_map` to build the intersection) - the caller
|
|
/// must keep `then_map` alive at least as long as the view. Alternatively,
|
|
/// if the view needs to outlive both maps, the caller can dupe the
|
|
/// strings before passing them in.
|
|
pub fn buildCompareView(
|
|
allocator: std.mem.Allocator,
|
|
then_date: Date,
|
|
now_date: Date,
|
|
now_is_live: bool,
|
|
liquid_then: f64,
|
|
liquid_now: f64,
|
|
then_map: *const HoldingMap,
|
|
now_map: *const HoldingMap,
|
|
) !CompareView {
|
|
var changes: std.ArrayList(SymbolChange) = .empty;
|
|
errdefer changes.deinit(allocator);
|
|
|
|
var added: usize = 0;
|
|
var removed: usize = 0;
|
|
|
|
// Count added: in now, not in then.
|
|
var now_it = now_map.iterator();
|
|
while (now_it.next()) |e| {
|
|
if (!then_map.contains(e.key_ptr.*)) added += 1;
|
|
}
|
|
|
|
// Walk "then" to build the intersection and count removed.
|
|
var then_it = then_map.iterator();
|
|
while (then_it.next()) |e| {
|
|
const sym = e.key_ptr.*;
|
|
const then_h = e.value_ptr.*;
|
|
if (now_map.get(sym)) |now_h| {
|
|
try changes.append(allocator, buildSymbolChange(sym, then_h, now_h));
|
|
} else {
|
|
removed += 1;
|
|
}
|
|
}
|
|
|
|
// Sort by pct_change descending, with rows whose percentage can't be
|
|
// trusted as a return pushed to the bottom. Those are mixed-class
|
|
// groups whose share count moved, so the figure absorbs whatever was
|
|
// bought or sold - parking them last keeps them out of the "biggest
|
|
// mover" slot and makes them easy to disregard as a block.
|
|
std.mem.sort(SymbolChange, changes.items, {}, struct {
|
|
fn lt(_: void, a: SymbolChange, b: SymbolChange) bool {
|
|
if (a.pct_reliable != b.pct_reliable) return a.pct_reliable;
|
|
return a.pct_change > b.pct_change;
|
|
}
|
|
}.lt);
|
|
|
|
// Bucket 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.
|
|
//
|
|
// A mixed-class row with a STATIC share count buckets normally: its
|
|
// value ratio reduces exactly to the underlying price ratio, so it is
|
|
// a real return even though no single per-share price exists to
|
|
// display. Only rows whose share count moved are set aside, because
|
|
// there the percentage is part market and part cash flow with no way
|
|
// to separate them.
|
|
var gainers: usize = 0;
|
|
var losers: usize = 0;
|
|
var flats: usize = 0;
|
|
var unreliable: usize = 0;
|
|
for (changes.items) |c| {
|
|
if (!c.pct_reliable) {
|
|
unreliable += 1;
|
|
} else 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,
|
|
.now_date = now_date,
|
|
.days_between = now_date.days - then_date.days,
|
|
.now_is_live = now_is_live,
|
|
.liquid = buildTotalsRow(liquid_then, liquid_now),
|
|
.symbols = items,
|
|
.held_count = items.len,
|
|
.added_count = added,
|
|
.removed_count = removed,
|
|
.gainer_count = gainers,
|
|
.loser_count = losers,
|
|
.flat_count = flats,
|
|
.unreliable_count = unreliable,
|
|
};
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
test "buildBucketLabel: live and daily rows return null" {
|
|
// Live row.
|
|
const got_live = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 11), true);
|
|
try testing.expectEqual(@as(?[]const u8, null), got_live);
|
|
|
|
// Daily-tier row.
|
|
const got_daily = try buildBucketLabel(testing.allocator, .daily, null, Date.fromYmd(2026, 5, 8), false);
|
|
try testing.expectEqual(@as(?[]const u8, null), got_daily);
|
|
|
|
// No tier at all.
|
|
const got_no_tier = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 8), false);
|
|
try testing.expectEqual(@as(?[]const u8, null), got_no_tier);
|
|
}
|
|
|
|
test "buildBucketLabel: quarterly row produces 'Qn YYYY (ended ...)' label" {
|
|
const lbl = (try buildBucketLabel(
|
|
testing.allocator,
|
|
.quarterly,
|
|
Date.fromYmd(2025, 1, 1),
|
|
Date.fromYmd(2025, 3, 28),
|
|
false,
|
|
)).?;
|
|
defer testing.allocator.free(lbl);
|
|
try testing.expectEqualStrings("Q1 2025 (ended 2025-03-28)", lbl);
|
|
}
|
|
|
|
test "buildBucketLabel: yearly row produces 'YYYY (ended ...)' label" {
|
|
const lbl = (try buildBucketLabel(
|
|
testing.allocator,
|
|
.yearly,
|
|
Date.fromYmd(2024, 1, 1),
|
|
Date.fromYmd(2024, 12, 31),
|
|
false,
|
|
)).?;
|
|
defer testing.allocator.free(lbl);
|
|
try testing.expectEqualStrings("2024 (ended 2024-12-31)", lbl);
|
|
}
|
|
|
|
test "buildBucketLabel: monthly row produces 'Mmm YYYY (ended ...)' label" {
|
|
const lbl = (try buildBucketLabel(
|
|
testing.allocator,
|
|
.monthly,
|
|
Date.fromYmd(2026, 1, 1),
|
|
Date.fromYmd(2026, 1, 30),
|
|
false,
|
|
)).?;
|
|
defer testing.allocator.free(lbl);
|
|
try testing.expectEqualStrings("Jan 2026 (ended 2026-01-30)", lbl);
|
|
}
|
|
|
|
test "buildBucketLabel: weekly row produces 'W of YYYY-MM-DD (ended ...)' label" {
|
|
const lbl = (try buildBucketLabel(
|
|
testing.allocator,
|
|
.weekly,
|
|
Date.fromYmd(2026, 4, 20),
|
|
Date.fromYmd(2026, 4, 22),
|
|
false,
|
|
)).?;
|
|
defer testing.allocator.free(lbl);
|
|
try testing.expectEqualStrings("W of 2026-04-20 (ended 2026-04-22)", lbl);
|
|
}
|
|
|
|
test "buildBucketLabel: missing bucket_start on non-daily tier falls back to ISO" {
|
|
const lbl = (try buildBucketLabel(
|
|
testing.allocator,
|
|
.quarterly,
|
|
null,
|
|
Date.fromYmd(2025, 3, 28),
|
|
false,
|
|
)).?;
|
|
defer testing.allocator.free(lbl);
|
|
try testing.expectEqualStrings("2025-03-28", lbl);
|
|
}
|
|
|
|
/// A single-share-class `Holding` for tests: `value` derived so the
|
|
/// `shares * price == value` invariant holds, `mixed_class` false.
|
|
fn h(shares: f64, price: f64) Holding {
|
|
return .{ .shares = shares, .price = price, .value = shares * price };
|
|
}
|
|
|
|
/// A mixed-share-class `Holding`: `value` given explicitly because
|
|
/// `shares * price` is meaningless for such a group.
|
|
fn hMixed(shares: f64, price: f64, value: f64) Holding {
|
|
return .{ .shares = shares, .price = price, .value = value, .mixed_class = true };
|
|
}
|
|
|
|
test "buildSymbolChange: positive price move, shares stable" {
|
|
const c = buildSymbolChange("AAPL", h(100, 150.0), h(100, 165.0));
|
|
try testing.expectEqualStrings("AAPL", c.symbol);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, 1500.0), c.dollar_change, 1e-9);
|
|
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
|
|
try testing.expectEqual(StyleIntent.positive, c.style);
|
|
}
|
|
|
|
test "buildSymbolChange: negative move" {
|
|
const c = buildSymbolChange("NFLX", h(10, 500.0), h(10, 400.0));
|
|
try testing.expectApproxEqAbs(@as(f64, -0.20), c.pct_change, 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, -1000.0), c.dollar_change, 1e-9);
|
|
try testing.expectEqual(StyleIntent.negative, c.style);
|
|
}
|
|
|
|
test "buildSymbolChange: zero price move -> muted style, zero dollar" {
|
|
const c = buildSymbolChange("VTI", h(50, 240.0), h(50, 240.0));
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9);
|
|
try testing.expectEqual(StyleIntent.muted, c.style);
|
|
}
|
|
|
|
test "buildSymbolChange: shares_held is min (added shares between dates)" {
|
|
// Started with 100, added 50, now at 150. Held-throughout floor = 100.
|
|
const c = buildSymbolChange("MSFT", h(100, 400.0), h(150, 420.0));
|
|
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
|
|
// dollar = 100 * (420-400) = 2000, not 150 * 20 = 3000
|
|
try testing.expectApproxEqAbs(@as(f64, 2000.0), c.dollar_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolChange: shares_held is min (sold shares between dates)" {
|
|
// Started with 200, sold down to 50. Held-throughout floor = 50.
|
|
const c = buildSymbolChange("GOOG", h(200, 160.0), h(50, 180.0));
|
|
try testing.expectEqual(@as(f64, 50), c.shares_held_throughout);
|
|
// dollar = 50 * (180-160) = 1000
|
|
try testing.expectApproxEqAbs(@as(f64, 1000.0), c.dollar_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolChange: zero price_then doesn't NaN" {
|
|
const c = buildSymbolChange("BAD", h(10, 0.0), h(10, 50.0));
|
|
try testing.expectEqual(@as(f64, 0.0), c.pct_change);
|
|
try testing.expectEqual(StyleIntent.muted, c.style);
|
|
// dollar_change is still 10 * 50 = 500 - that's the true held-throughout
|
|
// price delta even though % is undefined.
|
|
try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolChange: a mixed side abstains from the price comparison" {
|
|
// The two prices here are the real ones from the SPYM case: the "then"
|
|
// snapshot's first-seen lot was the direct-index sleeve at $708.72, the
|
|
// "now" side's base price is $90.17. Subtracting those is meaningless -
|
|
// it reported -87% on a position whose underlying rose.
|
|
const then = hMixed(5793.5618, 708.72, 1_367_247.82);
|
|
const now = hMixed(19_816.496, 90.17, 1_786_853.45);
|
|
const c = buildSymbolChange("BENCH", then, now);
|
|
|
|
try testing.expect(!c.price_comparable);
|
|
// Both figures switch to a VALUE basis - exact, but answering a
|
|
// different question than the price-basis rows (it includes the 3,426
|
|
// retail shares bought partway through the window).
|
|
try testing.expectApproxEqAbs(@as(f64, 419_605.63), c.dollar_change, 0.02);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.306898), c.pct_change, 1e-5);
|
|
try testing.expectEqual(StyleIntent.positive, c.style);
|
|
// The old price-basis math would have produced this instead:
|
|
const old_dollar = @min(then.shares, now.shares) * (now.price - then.price);
|
|
try testing.expect(old_dollar < 0); // wrong sign, on a position that GAINED
|
|
const old_pct = now.price / then.price - 1.0;
|
|
try testing.expect(old_pct < 0); // and a wrong-signed percentage
|
|
}
|
|
|
|
test "buildSymbolChange: one mixed side is enough to abstain" {
|
|
// Gates on EITHER side: a snapshot's ratio-scaled per-lot price and the
|
|
// live side's base price are not comparable quantities, so one mixed
|
|
// side poisons the pair.
|
|
const clean = h(100, 50.0);
|
|
const mixed = hMixed(100, 500.0, 12_345.0);
|
|
|
|
const a = buildSymbolChange("X", mixed, clean);
|
|
try testing.expect(!a.price_comparable);
|
|
const b = buildSymbolChange("X", clean, mixed);
|
|
try testing.expect(!b.price_comparable);
|
|
// Both clean -> normal comparison restored.
|
|
const ok = buildSymbolChange("X", clean, h(100, 55.0));
|
|
try testing.expect(ok.price_comparable);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.10), ok.pct_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolChange: a mixed row with a value loss reads negative" {
|
|
const c = buildSymbolChange("BENCH", hMixed(100, 700.0, 200_000.0), hMixed(200, 90.0, 150_000.0));
|
|
try testing.expect(!c.price_comparable);
|
|
try testing.expectApproxEqAbs(@as(f64, -50_000.0), c.dollar_change, 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, -0.25), c.pct_change, 1e-9);
|
|
try testing.expectEqual(StyleIntent.negative, c.style);
|
|
}
|
|
|
|
test "buildSymbolChange: static shares make a mixed row's percentage reliable" {
|
|
// The whole justification: value = base_price * SUM(shares_i * ratio_i).
|
|
// Hold the shares and ratios still and value scales EXACTLY with the
|
|
// underlying, so value_now/value_then IS the price return - even though
|
|
// no single per-share price exists to display.
|
|
//
|
|
// Here the group is worth 1000 then and 1100 now on an unchanged 10
|
|
// shares, so +10% is the real move.
|
|
const c = buildSymbolChange("BENCH", hMixed(10, 100.0, 1000.0), hMixed(10, 800.0, 1100.0));
|
|
try testing.expect(!c.price_comparable); // still no price to show
|
|
try testing.expect(c.pct_reliable); // ...but the return is sound
|
|
try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolChange: a moved share count makes it unreliable" {
|
|
const bought = buildSymbolChange("BENCH", hMixed(10, 100.0, 1000.0), hMixed(25, 800.0, 1500.0));
|
|
try testing.expect(!bought.pct_reliable);
|
|
const sold = buildSymbolChange("BENCH", hMixed(25, 100.0, 2500.0), hMixed(10, 800.0, 1100.0));
|
|
try testing.expect(!sold.pct_reliable);
|
|
// Even a fractional DRIP share counts - it is still money in.
|
|
const drip = buildSymbolChange("BENCH", hMixed(10, 100.0, 1000.0), hMixed(10.25, 800.0, 1100.0));
|
|
try testing.expect(!drip.pct_reliable);
|
|
}
|
|
|
|
test "buildSymbolChange: float noise in a static share count stays reliable" {
|
|
// The two sides reach their counts by different arithmetic - the
|
|
// snapshot side divides value by price, the live side sums
|
|
// effectiveShares - so a genuinely static holding can differ in the
|
|
// last bits. That must not read as a purchase.
|
|
const c = buildSymbolChange(
|
|
"BENCH",
|
|
hMixed(709.235272, 100.0, 1000.0),
|
|
hMixed(709.2352720000001, 800.0, 1100.0),
|
|
);
|
|
try testing.expect(c.pct_reliable);
|
|
}
|
|
|
|
test "buildSymbolChange: a price-comparable row is always reliable" {
|
|
// A price ratio is share-count-independent by construction, so buying
|
|
// more of an ordinary holding must NOT demote it.
|
|
const c = buildSymbolChange("MSFT", h(100, 400.0), h(150, 420.0));
|
|
try testing.expect(c.price_comparable);
|
|
try testing.expect(c.pct_reliable);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.05), c.pct_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolChange: a mixed row with zero then-value doesn't NaN" {
|
|
// Value basis divides by `then.value`; guard it the same way the price
|
|
// basis guards `then.price`.
|
|
const c = buildSymbolChange("BENCH", hMixed(0, 0, 0), hMixed(10, 5, 500.0));
|
|
try testing.expect(!c.price_comparable);
|
|
try testing.expectEqual(@as(f64, 0), c.pct_change);
|
|
try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
|
|
}
|
|
|
|
test "buildSymbolRowCells: a non-comparable row renders sentinels but a real dollar" {
|
|
var p_then: [24]u8 = undefined;
|
|
var p_now: [24]u8 = undefined;
|
|
var p_pct: [16]u8 = undefined;
|
|
var p_dollar: [32]u8 = undefined;
|
|
|
|
const s = buildSymbolChange("BENCH", hMixed(100, 700.0, 200_000.0), hMixed(200, 90.0, 250_000.0));
|
|
const cells = buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);
|
|
|
|
// The two PRICE cells are sentinels, padded to the column's DISPLAY
|
|
// width rather than its byte width - the sentinel is one column in
|
|
// three bytes, and the row templates pad by bytes, so an unpadded
|
|
// sentinel would skew every column to its right.
|
|
try testing.expectEqual(@as(usize, price_w), fmt.displayCols(cells.price_then));
|
|
try testing.expectEqual(@as(usize, price_w), fmt.displayCols(cells.price_now));
|
|
try testing.expect(std.mem.indexOf(u8, cells.price_then, fmt.no_data_sentinel) != null);
|
|
try testing.expect(std.mem.indexOf(u8, cells.price_now, fmt.no_data_sentinel) != null);
|
|
// Justification matches the spec each cell is fed to.
|
|
try testing.expect(std.mem.endsWith(u8, cells.price_then, fmt.no_data_sentinel));
|
|
try testing.expect(std.mem.startsWith(u8, cells.price_now, fmt.no_data_sentinel));
|
|
// The PERCENT cell is a real value-basis figure, rendered exactly like
|
|
// any other row (the row template pads it), NOT a sentinel.
|
|
// 250,000 / 200,000 - 1 = +25%.
|
|
try testing.expectEqualStrings("+25.00%", cells.pct);
|
|
// The dollar cell stays real - it is the exact value delta.
|
|
try testing.expectEqualStrings("+$50,000.00", cells.dollar);
|
|
// And no fabricated price leaks through.
|
|
try testing.expect(std.mem.indexOf(u8, cells.price_then, "700") == null);
|
|
try testing.expect(std.mem.indexOf(u8, cells.price_now, "90") == null);
|
|
}
|
|
|
|
test "buildCompareView: mixed rows get their own bucket and sort last" {
|
|
var then_map: HoldingMap = .init(testing.allocator);
|
|
defer then_map.deinit();
|
|
var now_map: HoldingMap = .init(testing.allocator);
|
|
defer now_map.deinit();
|
|
|
|
try then_map.put("WIN", h(10, 100.0));
|
|
try now_map.put("WIN", h(10, 120.0)); // +20%
|
|
try then_map.put("LOSE", h(10, 100.0));
|
|
try now_map.put("LOSE", h(10, 80.0)); // -20%
|
|
try then_map.put("FLAT", h(10, 100.0));
|
|
try now_map.put("FLAT", h(10, 100.0)); // 0%
|
|
// Mixed class, share count STATIC. Its value ratio reduces exactly to
|
|
// the underlying price ratio (1100/1000 = +10%), so it is a real
|
|
// return and belongs inline with the price-basis rows - it just has no
|
|
// single per-share price to display beside it.
|
|
try then_map.put("MIXSTATIC", hMixed(10, 100.0, 1000.0));
|
|
try now_map.put("MIXSTATIC", hMixed(10, 800.0, 1100.0));
|
|
// Mixed class, share count MOVED (10 -> 25). Its +50% is part market
|
|
// and part purchase with no way to separate them, so it is unreliable.
|
|
try then_map.put("MIXBOUGHT", hMixed(10, 100.0, 1000.0));
|
|
try now_map.put("MIXBOUGHT", hMixed(25, 800.0, 1500.0));
|
|
|
|
var view = try buildCompareView(
|
|
testing.allocator,
|
|
Date.fromYmd(2026, 1, 1),
|
|
Date.fromYmd(2026, 8, 1),
|
|
false,
|
|
100_000,
|
|
110_000,
|
|
&then_map,
|
|
&now_map,
|
|
);
|
|
defer view.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 5), view.held_count);
|
|
// MIXSTATIC counts as a normal gainer alongside WIN.
|
|
try testing.expectEqual(@as(usize, 2), view.gainer_count);
|
|
try testing.expectEqual(@as(usize, 1), view.loser_count);
|
|
try testing.expectEqual(@as(usize, 1), view.flat_count);
|
|
try testing.expectEqual(@as(usize, 1), view.unreliable_count);
|
|
// The documented invariant.
|
|
try testing.expectEqual(
|
|
view.held_count,
|
|
view.gainer_count + view.loser_count + view.flat_count + view.unreliable_count,
|
|
);
|
|
|
|
// Reliable rows first, descending by pct - MIXSTATIC sorts INLINE at
|
|
// +10%, between WIN (+20%) and FLAT (0%). Only the share-count change
|
|
// banishes a row to the bottom.
|
|
try testing.expectEqualStrings("WIN", view.symbols[0].symbol);
|
|
try testing.expectEqualStrings("MIXSTATIC", view.symbols[1].symbol);
|
|
try testing.expectEqualStrings("FLAT", view.symbols[2].symbol);
|
|
try testing.expectEqualStrings("LOSE", view.symbols[3].symbol);
|
|
try testing.expectEqualStrings("MIXBOUGHT", view.symbols[4].symbol);
|
|
|
|
// MIXSTATIC: no price to show, but a trustworthy return.
|
|
try testing.expect(!view.symbols[1].price_comparable);
|
|
try testing.expect(view.symbols[1].pct_reliable);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.10), view.symbols[1].pct_change, 1e-9);
|
|
|
|
// MIXBOUGHT: pinned last despite the largest percentage in the table,
|
|
// which is exactly the point - it is not comparable with the rest.
|
|
try testing.expect(!view.symbols[4].price_comparable);
|
|
try testing.expect(!view.symbols[4].pct_reliable);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.50), view.symbols[4].pct_change, 1e-9);
|
|
try testing.expectApproxEqAbs(@as(f64, 500.0), view.symbols[4].dollar_change, 1e-9);
|
|
try testing.expect(view.symbols[4].pct_change > view.symbols[0].pct_change);
|
|
}
|
|
|
|
test "buildTotalsRow: positive delta" {
|
|
const t = buildTotalsRow(1_000_000.0, 1_050_000.0);
|
|
try testing.expectApproxEqAbs(@as(f64, 50_000.0), t.delta, 1e-6);
|
|
try testing.expectApproxEqAbs(@as(f64, 0.05), t.pct, 1e-9);
|
|
try testing.expectEqual(StyleIntent.positive, t.style);
|
|
}
|
|
|
|
test "buildTotalsRow: negative delta" {
|
|
const t = buildTotalsRow(1_000_000.0, 950_000.0);
|
|
try testing.expectApproxEqAbs(@as(f64, -50_000.0), t.delta, 1e-6);
|
|
try testing.expectApproxEqAbs(@as(f64, -0.05), t.pct, 1e-9);
|
|
try testing.expectEqual(StyleIntent.negative, t.style);
|
|
}
|
|
|
|
test "buildTotalsRow: zero delta" {
|
|
const t = buildTotalsRow(100.0, 100.0);
|
|
try testing.expectEqual(@as(f64, 0.0), t.delta);
|
|
try testing.expectEqual(StyleIntent.muted, t.style);
|
|
}
|
|
|
|
test "buildTotalsRow: zero then doesn't NaN" {
|
|
const t = buildTotalsRow(0.0, 100.0);
|
|
try testing.expectEqual(@as(f64, 0.0), t.pct);
|
|
try testing.expectEqual(StyleIntent.positive, t.style); // delta > 0 so positive
|
|
}
|
|
|
|
test "forwardAdjustThen: split factor scales shares up, price down; absent symbols untouched" {
|
|
var then_map: HoldingMap = .init(testing.allocator);
|
|
defer then_map.deinit();
|
|
try then_map.put("NVDA", .{ .shares = 100, .price = 600.0 });
|
|
try then_map.put("AAPL", .{ .shares = 50, .price = 180.0 });
|
|
|
|
var factors = std.StringHashMap(f64).init(testing.allocator);
|
|
defer factors.deinit();
|
|
try factors.put("NVDA", 10.0); // 10:1 split between then and now
|
|
|
|
forwardAdjustThen(&then_map, &factors);
|
|
|
|
// NVDA brought into the post-split basis: 100 -> 1000 shares, $600 -> $60.
|
|
try testing.expectApproxEqAbs(@as(f64, 1000), then_map.get("NVDA").?.shares, 0.001);
|
|
try testing.expectApproxEqAbs(@as(f64, 60.0), then_map.get("NVDA").?.price, 0.001);
|
|
// AAPL absent from factors -> untouched.
|
|
try testing.expectApproxEqAbs(@as(f64, 50), then_map.get("AAPL").?.shares, 0.001);
|
|
try testing.expectApproxEqAbs(@as(f64, 180.0), then_map.get("AAPL").?.price, 0.001);
|
|
}
|
|
|
|
test "buildCompareView: intersection with added and removed" {
|
|
var then_map: HoldingMap = .init(testing.allocator);
|
|
defer then_map.deinit();
|
|
var now_map: HoldingMap = .init(testing.allocator);
|
|
defer now_map.deinit();
|
|
|
|
// Held in both (will appear)
|
|
try then_map.put("AAPL", .{ .shares = 100, .price = 150.0 });
|
|
try now_map.put("AAPL", .{ .shares = 100, .price = 165.0 });
|
|
try then_map.put("MSFT", .{ .shares = 50, .price = 400.0 });
|
|
try now_map.put("MSFT", .{ .shares = 50, .price = 395.0 });
|
|
// Removed (in then, not in now)
|
|
try then_map.put("NFLX", .{ .shares = 10, .price = 500.0 });
|
|
// Added (in now, not in then)
|
|
try now_map.put("TSLA", .{ .shares = 20, .price = 250.0 });
|
|
try now_map.put("NVDA", .{ .shares = 15, .price = 140.0 });
|
|
|
|
var view = try buildCompareView(
|
|
testing.allocator,
|
|
Date.fromYmd(2026, 4, 20),
|
|
Date.fromYmd(2026, 4, 30),
|
|
true, // live
|
|
1_000_000.0,
|
|
1_050_000.0,
|
|
&then_map,
|
|
&now_map,
|
|
);
|
|
defer view.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 2), view.held_count);
|
|
try testing.expectEqual(@as(usize, 2), view.added_count);
|
|
try testing.expectEqual(@as(usize, 1), view.removed_count);
|
|
try testing.expectEqual(@as(i32, 10), view.days_between);
|
|
try testing.expectEqual(true, view.now_is_live);
|
|
|
|
// Sort order: AAPL (+10%) before MSFT (-1.25%)
|
|
try testing.expectEqualStrings("AAPL", view.symbols[0].symbol);
|
|
try testing.expectEqualStrings("MSFT", view.symbols[1].symbol);
|
|
try testing.expect(view.symbols[0].pct_change > view.symbols[1].pct_change);
|
|
|
|
// Totals row
|
|
try testing.expectApproxEqAbs(@as(f64, 0.05), view.liquid.pct, 1e-9);
|
|
try testing.expectEqual(StyleIntent.positive, view.liquid.style);
|
|
}
|
|
|
|
test "buildCompareView: empty intersection" {
|
|
var then_map: HoldingMap = .init(testing.allocator);
|
|
defer then_map.deinit();
|
|
var now_map: HoldingMap = .init(testing.allocator);
|
|
defer now_map.deinit();
|
|
|
|
try then_map.put("OLD", .{ .shares = 10, .price = 100.0 });
|
|
try now_map.put("NEW", .{ .shares = 5, .price = 200.0 });
|
|
|
|
var view = try buildCompareView(
|
|
testing.allocator,
|
|
Date.fromYmd(2026, 1, 1),
|
|
Date.fromYmd(2026, 2, 1),
|
|
false,
|
|
1000.0,
|
|
2000.0,
|
|
&then_map,
|
|
&now_map,
|
|
);
|
|
defer view.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 0), view.held_count);
|
|
try testing.expectEqual(@as(usize, 1), view.added_count);
|
|
try testing.expectEqual(@as(usize, 1), view.removed_count);
|
|
try testing.expectEqual(false, view.now_is_live);
|
|
}
|
|
|
|
test "buildCompareView: both empty" {
|
|
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.held_count);
|
|
try testing.expectEqual(@as(usize, 0), view.added_count);
|
|
try testing.expectEqual(@as(usize, 0), view.removed_count);
|
|
try testing.expectEqual(@as(i32, 0), view.days_between);
|
|
try testing.expectEqual(StyleIntent.muted, view.liquid.style);
|
|
}
|
|
|
|
test "buildCompareView: sort strictly descending across many symbols" {
|
|
var then_map: HoldingMap = .init(testing.allocator);
|
|
defer then_map.deinit();
|
|
var now_map: HoldingMap = .init(testing.allocator);
|
|
defer now_map.deinit();
|
|
|
|
// Seed 5 held-throughout symbols with distinct returns
|
|
try then_map.put("A", .{ .shares = 10, .price = 100.0 });
|
|
try now_map.put("A", .{ .shares = 10, .price = 110.0 }); // +10%
|
|
try then_map.put("B", .{ .shares = 10, .price = 100.0 });
|
|
try now_map.put("B", .{ .shares = 10, .price = 80.0 }); // -20%
|
|
try then_map.put("C", .{ .shares = 10, .price = 100.0 });
|
|
try now_map.put("C", .{ .shares = 10, .price = 130.0 }); // +30%
|
|
try then_map.put("D", .{ .shares = 10, .price = 100.0 });
|
|
try now_map.put("D", .{ .shares = 10, .price = 95.0 }); // -5%
|
|
try then_map.put("E", .{ .shares = 10, .price = 100.0 });
|
|
try now_map.put("E", .{ .shares = 10, .price = 105.0 }); // +5%
|
|
|
|
var view = try buildCompareView(
|
|
testing.allocator,
|
|
Date.fromYmd(2026, 1, 1),
|
|
Date.fromYmd(2026, 2, 1),
|
|
false,
|
|
5000.0,
|
|
5200.0,
|
|
&then_map,
|
|
&now_map,
|
|
);
|
|
defer view.deinit(testing.allocator);
|
|
|
|
try testing.expectEqual(@as(usize, 5), view.held_count);
|
|
// Expected order by pct desc: C (+30), A (+10), E (+5), D (-5), B (-20)
|
|
try testing.expectEqualStrings("C", view.symbols[0].symbol);
|
|
try testing.expectEqualStrings("A", view.symbols[1].symbol);
|
|
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 ─────────────────────
|
|
//
|
|
// Shared between the CLI and TUI renderers. Before this section
|
|
// existed, both renderers duplicated the column widths, format
|
|
// strings, and money/percent formatting - which is exactly the drift
|
|
// hazard `views/history.zig` was built to prevent. Every width or
|
|
// label change now lives here.
|
|
|
|
/// Symbol column width - fits "BRK-B" + a note-derived CUSIP label
|
|
/// like "TGT2035" with slack.
|
|
pub const symbol_w: usize = 8;
|
|
/// Per-price column width. Fits "$999,999.99".
|
|
pub const price_w: usize = 10;
|
|
/// Percent column width. Fits "+999.99%".
|
|
pub const pct_w: usize = 8;
|
|
/// Signed-dollar column width. Fits "+$99,999,999.99" with slack.
|
|
pub const dollar_w: usize = 14;
|
|
/// Transition glyph between the `then` and `now` cells.
|
|
pub const arrow: []const u8 = " -> ";
|
|
|
|
// Comptime-built format specifiers so callers don't hardcode widths
|
|
// that might drift from the constants above. `cp` stringifies the
|
|
// width into a real Zig format spec; both CLI and TUI renderers
|
|
// compose these into their own line templates.
|
|
const cp = std.fmt.comptimePrint;
|
|
pub const symbol_fmt = cp("{{s:<{d}}}", .{symbol_w});
|
|
pub const price_left_fmt = cp("{{s:<{d}}}", .{price_w});
|
|
pub const price_right_fmt = cp("{{s:>{d}}}", .{price_w});
|
|
pub const pct_fmt = cp("{{s:>{d}}}", .{pct_w});
|
|
pub const dollar_fmt = cp("{{s:>{d}}}", .{dollar_w});
|
|
|
|
/// Single-color per-symbol row template. Ordered fields:
|
|
/// { symbol, price_then, arrow, price_now, pct, dollar }
|
|
/// Used by the TUI renderer (one style per line); the CLI composes
|
|
/// the row from smaller pieces to get per-segment coloring.
|
|
pub const symbol_row_fmt = symbol_fmt ++ " " ++ price_right_fmt ++
|
|
"{s}" ++ price_left_fmt ++ " " ++ pct_fmt ++ " " ++ dollar_fmt;
|
|
|
|
/// Pre-formatted cells for a single per-symbol row. Strings borrow
|
|
/// from the caller-owned buffers passed into `buildSymbolRowCells`.
|
|
///
|
|
/// `style` is a semantic intent; renderers map to their own style
|
|
/// system (CLI: ANSI via `cli.setStyleIntent`, TUI: vaxis via
|
|
/// `theme.styleFor`).
|
|
pub const SymbolRowCells = struct {
|
|
symbol: []const u8,
|
|
price_then: []const u8,
|
|
price_now: []const u8,
|
|
pct: []const u8,
|
|
dollar: []const u8,
|
|
style: StyleIntent,
|
|
};
|
|
|
|
/// Build a single SymbolChange into display-ready cells. The four
|
|
/// caller-owned buffers back the returned strings and must outlive
|
|
/// the result.
|
|
pub fn buildSymbolRowCells(
|
|
s: SymbolChange,
|
|
price_then_buf: *[24]u8,
|
|
price_now_buf: *[24]u8,
|
|
pct_buf: *[16]u8,
|
|
dollar_buf: *[32]u8,
|
|
) SymbolRowCells {
|
|
// A mixed-share-class row has no meaningful per-share price on either
|
|
// side, so those two cells get the no-data sentinel rather than two
|
|
// numbers that invite subtraction. The percent and dollar cells DO
|
|
// render - on a value basis (see `SymbolChange.pct_change`) - because
|
|
// a total value change is exactly computable, and when the share count
|
|
// held still it IS the underlying return. `SymbolChange.pct_reliable`
|
|
// and `CompareView.unreliable_count` let the renderer flag the rows
|
|
// where it is not.
|
|
//
|
|
// Pad the sentinel to display columns here. The row templates use
|
|
// byte-based `{s:>N}` specs, and the sentinel is one display column in
|
|
// three bytes, so leaving it unpadded under-pads the cell by two
|
|
// columns and skews every column to its right. Padding to exactly
|
|
// `price_w` makes the byte-based spec a no-op and keeps the table
|
|
// square. Justification matches the spec each cell is fed to:
|
|
// `price_right_fmt` right-justifies, `price_left_fmt` left.
|
|
if (!s.price_comparable) {
|
|
// `padRightToCols` appends in place and requires its content to
|
|
// already sit at the start of the buffer, so stage the sentinel
|
|
// there first. (`padLeftToCols` copies, hence the asymmetry.)
|
|
const staged = std.fmt.bufPrint(price_now_buf, "{s}", .{fmt.no_data_sentinel}) catch fmt.no_data_sentinel;
|
|
return .{
|
|
.symbol = s.symbol,
|
|
.price_then = fmt.padLeftToCols(price_then_buf, fmt.no_data_sentinel, price_w),
|
|
.price_now = fmt.padRightToCols(price_now_buf, staged, price_w),
|
|
.pct = view_hist.fmtSignedPercentBuf(pct_buf, s.pct_change),
|
|
.dollar = std.fmt.bufPrint(dollar_buf, "{f}", .{Money.from(s.dollar_change).signed()}) catch "$?",
|
|
.style = s.style,
|
|
};
|
|
}
|
|
return .{
|
|
.symbol = s.symbol,
|
|
.price_then = std.fmt.bufPrint(price_then_buf, "{f}", .{Money.from(s.price_then)}) catch "$?",
|
|
.price_now = std.fmt.bufPrint(price_now_buf, "{f}", .{Money.from(s.price_now)}) catch "$?",
|
|
.pct = view_hist.fmtSignedPercentBuf(pct_buf, s.pct_change),
|
|
.dollar = std.fmt.bufPrint(dollar_buf, "{f}", .{Money.from(s.dollar_change).signed()}) catch "$?",
|
|
.style = s.style,
|
|
};
|
|
}
|
|
|
|
/// Pre-formatted cells for the liquid totals line (then -> now, delta,
|
|
/// pct). Strings borrow from caller-owned buffers.
|
|
pub const TotalsCells = struct {
|
|
then: []const u8,
|
|
now: []const u8,
|
|
delta: []const u8,
|
|
pct: []const u8,
|
|
/// Style for the delta/pct portion; the then/now portion is
|
|
/// typically rendered in a muted/secondary style so the delta
|
|
/// stands out. CLI honors this split; the TUI applies `style` to
|
|
/// the whole line for simplicity.
|
|
style: StyleIntent,
|
|
};
|
|
|
|
pub fn buildTotalsCells(
|
|
t: TotalsRow,
|
|
then_buf: *[24]u8,
|
|
now_buf: *[24]u8,
|
|
delta_buf: *[32]u8,
|
|
pct_buf: *[16]u8,
|
|
) TotalsCells {
|
|
return .{
|
|
.then = std.fmt.bufPrint(then_buf, "{f}", .{Money.from(t.then)}) catch "$?",
|
|
.now = std.fmt.bufPrint(now_buf, "{f}", .{Money.from(t.now)}) catch "$?",
|
|
.delta = std.fmt.bufPrint(delta_buf, "{f}", .{Money.from(t.delta).signed()}) catch "$?",
|
|
.pct = view_hist.fmtSignedPercentBuf(pct_buf, t.pct),
|
|
.style = t.style,
|
|
};
|
|
}
|
|
|
|
/// Format the "now" side label for the header. Snapshot-now shows
|
|
/// the date alone; live-now shows the date with a `(live)` marker
|
|
/// so readers comparing this run against a later run (which would
|
|
/// read today's value from a snapshot file) understand why the
|
|
/// numbers might drift slightly.
|
|
///
|
|
/// **Why the marker matters.** When `compare 1W` runs with a live
|
|
/// "now", the value it shows for "now" is computed against today's
|
|
/// state of `portfolio.srf` plus today's cached prices. Next week,
|
|
/// when the same user runs `compare 1W` again, this week's value
|
|
/// becomes "then" - but is read from the snapshot file (e.g.
|
|
/// `history/<DATE>-portfolio.srf`) that was captured for the
|
|
/// snapshot's `as_of` date, not the date the user actually ran
|
|
/// `compare`. The two values can disagree slightly because:
|
|
///
|
|
/// - **Date-arg drift.** Live `compare` on day T evaluates
|
|
/// `positionsForAccount(today=T)`, `totalCash(T)`, etc.
|
|
/// `snapshot --as-of T-1` evaluates the same against T-1. Any
|
|
/// lot whose `open_date` or `close_date` falls between those
|
|
/// two dates contributes a non-zero delta even when prices are
|
|
/// identical (e.g. a Saturday-dated RSU credit that's
|
|
/// in-scope for live Saturday-`compare` but not for the prior
|
|
/// Friday's snapshot).
|
|
/// - **Working-copy edits between runs.** Edits to
|
|
/// `portfolio.srf` made after the live `compare` run but
|
|
/// before the next `snapshot` capture are reflected in the
|
|
/// snapshot but not in the prior live "now" value (or vice
|
|
/// versa).
|
|
/// - **Cache refresh on a trading day.** When markets are open,
|
|
/// cached candle prices may refresh between the live `compare`
|
|
/// and the eventual `snapshot` capture. (On weekends and
|
|
/// holidays this source vanishes; the other two still apply.)
|
|
///
|
|
/// The `(live)` marker tells the reader "this number is ephemeral -
|
|
/// the corresponding snapshot value may differ." Without it, users
|
|
/// reasonably assumed last week's "now" should equal this week's
|
|
/// "then" verbatim and were surprised by a few-thousand-dollar drift
|
|
/// on a multi-million-dollar portfolio.
|
|
///
|
|
/// `buf` backs both cases; caller must keep it alive.
|
|
pub fn nowLabel(cv: CompareView, buf: *[24]u8) []const u8 {
|
|
if (cv.now_is_live) {
|
|
return std.fmt.bufPrint(buf, "{f} (live)", .{cv.now_date}) catch "today (live)";
|
|
}
|
|
return std.fmt.bufPrint(buf, "{f}", .{cv.now_date}) catch "????-??-??";
|
|
}
|
|
|
|
/// Re-export of `format.dayPlural` so callers keep a single import.
|
|
/// The canonical implementation lives in `src/format.zig`.
|
|
pub const dayPlural = fmt.dayPlural;
|
|
|
|
test "buildSymbolRowCells: wires through the right formatters" {
|
|
var p_then: [24]u8 = undefined;
|
|
var p_now: [24]u8 = undefined;
|
|
var p_pct: [16]u8 = undefined;
|
|
var p_dollar: [32]u8 = undefined;
|
|
|
|
const s = SymbolChange{
|
|
.symbol = "FOO",
|
|
.price_then = 100.00,
|
|
.price_now = 110.00,
|
|
.shares_held_throughout = 10,
|
|
.pct_change = 0.10,
|
|
.dollar_change = 100.0,
|
|
.style = .positive,
|
|
};
|
|
const cells = buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);
|
|
|
|
try testing.expectEqualStrings("FOO", cells.symbol);
|
|
try testing.expectEqualStrings("$100.00", cells.price_then);
|
|
try testing.expectEqualStrings("$110.00", cells.price_now);
|
|
try testing.expectEqualStrings("+10.00%", cells.pct);
|
|
try testing.expectEqualStrings("+$100.00", cells.dollar);
|
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
|
}
|
|
|
|
test "buildTotalsCells: wires through the right formatters" {
|
|
var b_then: [24]u8 = undefined;
|
|
var b_now: [24]u8 = undefined;
|
|
var b_delta: [32]u8 = undefined;
|
|
var b_pct: [16]u8 = undefined;
|
|
|
|
const t = buildTotalsRow(10_000, 10_500);
|
|
const cells = buildTotalsCells(t, &b_then, &b_now, &b_delta, &b_pct);
|
|
|
|
try testing.expectEqualStrings("$10,000.00", cells.then);
|
|
try testing.expectEqualStrings("$10,500.00", cells.now);
|
|
try testing.expectEqualStrings("+$500.00", cells.delta);
|
|
try testing.expectEqualStrings("+5.00%", cells.pct);
|
|
try testing.expectEqual(StyleIntent.positive, cells.style);
|
|
}
|
|
|
|
test "nowLabel: live shows date with (live) marker, snapshot shows date alone" {
|
|
const cv_live = CompareView{
|
|
.then_date = Date.fromYmd(2024, 1, 15),
|
|
.now_date = Date.fromYmd(2024, 3, 15),
|
|
.days_between = 60,
|
|
.now_is_live = true,
|
|
.liquid = buildTotalsRow(100, 100),
|
|
.symbols = &.{},
|
|
.held_count = 0,
|
|
.added_count = 0,
|
|
.removed_count = 0,
|
|
};
|
|
var buf: [24]u8 = undefined;
|
|
try testing.expectEqualStrings("2024-03-15 (live)", nowLabel(cv_live, &buf));
|
|
|
|
const cv_snap = CompareView{
|
|
.then_date = Date.fromYmd(2024, 1, 15),
|
|
.now_date = Date.fromYmd(2024, 3, 15),
|
|
.days_between = 60,
|
|
.now_is_live = false,
|
|
.liquid = buildTotalsRow(100, 100),
|
|
.symbols = &.{},
|
|
.held_count = 0,
|
|
.added_count = 0,
|
|
.removed_count = 0,
|
|
};
|
|
try testing.expectEqualStrings("2024-03-15", nowLabel(cv_snap, &buf));
|
|
}
|
|
|
|
test "dayPlural: 1 day singular, everything else plural" {
|
|
try testing.expectEqualStrings("", dayPlural(1));
|
|
try testing.expectEqualStrings("s", dayPlural(0));
|
|
try testing.expectEqualStrings("s", dayPlural(2));
|
|
try testing.expectEqualStrings("s", dayPlural(60));
|
|
}
|