From e62eb5f0a709fe9a4b7c0524b0cd2ea71bde61f6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 12 May 2026 18:00:35 -0700 Subject: [PATCH] add new Money type with format helpers --- AGENTS.md | 37 ++- src/Money.zig | 445 +++++++++++++++++++++++++++++++ src/commands/analysis.zig | 10 +- src/commands/audit.zig | 72 ++--- src/commands/compare.zig | 10 +- src/commands/contributions.zig | 80 +++--- src/commands/milestones.zig | 26 +- src/commands/options.zig | 4 +- src/commands/perf.zig | 4 +- src/commands/portfolio.zig | 75 ++---- src/commands/projections.zig | 32 +-- src/commands/quote.zig | 7 +- src/format.zig | 178 +------------ src/tui/analysis_tab.zig | 17 +- src/tui/options_tab.zig | 4 +- src/tui/perf_tab.zig | 4 +- src/tui/portfolio_tab.zig | 71 +++-- src/tui/projections_tab.zig | 20 +- src/tui/quote_tab.zig | 9 +- src/views/compare.zig | 13 +- src/views/history.zig | 29 +- src/views/portfolio_sections.zig | 7 +- src/views/projections.zig | 26 +- 23 files changed, 681 insertions(+), 499 deletions(-) create mode 100644 src/Money.zig diff --git a/AGENTS.md b/AGENTS.md index 4329669..3501bb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,17 +127,30 @@ already exist and have caught me out: - `Date.ageOn` — calendar-precise age (handles "birthday hasn't occurred this year yet"). Distinct from `wholeYearsBetween`. - `Date.format` — "YYYY-MM-DD". -- `format.fmtMoneyAbs` — "$1,234.56" with commas, always 2 dp. -- `format.fmtMoneyAbsWhole` — "$1,234" rounded to whole dollars. -- `format.fmtMoneyAbsTrim` — like `fmtMoneyAbs` but elides `.00`. +- `Money.from(amount)` with `{f}` — "$1,234.56" with commas, + always 2 dp. Standard format method (Zig 0.15+ format-method + protocol) — no buffer ceremony. +- `Money.from(amount).whole()` — "$1,234" rounded to whole + dollars. Returns a wrapper struct; render with `{f}`. +- `Money.from(amount).trim()` — like default but elides `.00`. +- `Money.from(amount).signed()` — "+$1,234.56" / "-$1,234.56". +- `Money.from(amount).padRight(N)` / `padLeft(N)` — column-aligned + output. Composes with `.whole()`/`.trim()`/`.signed()` (each + variant exposes its own `padRight`/`padLeft`). Generic over the + inner type via `Padded(T)` so the same wrapper works for any + `format`-bearing type. - `format.fmtIntCommas` — "1,234,567" without `$`. -- `format.formatReturn` / `views.history.fmtSignedMoneyBuf` — signed money/percent for trailing-returns and gain/loss displays. +- `format.formatReturn` — signed percent for trailing-returns + and gain/loss displays. **Search recipes that catch the most cases:** ``` -# Money formatters -grep -rn "fn fmt.*[Mm]oney\|fn fmt.*[Dd]ollar\|fn .*[Ww]hole" src/ +# Money — Money.zig should be your first stop. Search for callers: +grep -rn "Money.from\|fmt.fmtMoney" src/ + +# Bare-money formatter footguns (these existed pre-Money.zig and +# should be migrated to Money if found): grep -rn "@round.*amount\|@intFromFloat(@round" src/ grep -rn "endsWith.*\\\".00\\\"\|lastIndexOfScalar.*'\\\\.'" src/ @@ -162,13 +175,15 @@ right module: - Date / calendar math → `src/models/date.zig`, as a `Date` method when the receiver is natural. -- Money / number formatting → `src/format.zig`. +- Money formatting → `src/Money.zig`. New variants are wrapper + structs returned from `Money` methods; each implements + `format(self, *Writer) !void` so it works with `{f}`. +- Other number formatting (non-money) → `src/format.zig`. - Per-domain formatting that wraps the above → keep in the - domain's view module (e.g. `views/history.zig` for the - history-display sign-prefix wrapper). + domain's view module. Add tests in the same file. Money helpers belong next to -`fmtMoneyAbs`'s tests; date helpers belong next to +the other tests in `Money.zig`; date helpers belong next to `yearsBetween`'s tests. ### NEVER invoke ripgrep. EVER. @@ -353,7 +368,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) ### Formatting pattern -Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtMoneyAbs(&buf, amount)` returns `[]const u8`. The sign handling is always caller-side. +Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtIntCommas(&buf, value)` returns `[]const u8`. Money formatting now lives in `src/Money.zig` and uses the `{f}` format-method protocol — see the "Time and money helpers" prohibition section above. ### Provider pattern diff --git a/src/Money.zig b/src/Money.zig new file mode 100644 index 0000000..165b74c --- /dev/null +++ b/src/Money.zig @@ -0,0 +1,445 @@ +//! Money: a thin wrapper around `f64` representing a dollar amount, +//! with format methods that integrate with `std.fmt`'s `{f}` +//! specifier. +//! +//! Storage stays `f64` (matches existing zfin convention; no decimal +//! arithmetic). The wrapper exists for two reasons: +//! +//! 1. **Format-method dispatch.** Zig 0.15+'s format-method +//! protocol (`pub fn format(self, w: *Writer) !void`) lets +//! `try writer.print("{f}", .{m})` render a Money value +//! directly with no buffer ceremony. Distinct rendering +//! modes — whole-dollar, trim-trailing-`.00`, signed — are +//! exposed as wrapper-returning methods on `Money`: +//! `m.whole()`, `m.trim()`, `m.signed()`. Each returns a +//! small wrapper struct whose own `format` method emits the +//! variant. +//! +//! 2. **Single home for money formatting.** Replaces the +//! previous scatter across `src/format.zig` (fmtMoneyAbs, +//! fmtMoneyAbsWhole, fmtMoneyAbsTrim) and +//! `src/views/history.zig` (fmtSignedMoneyBuf). +//! +//! ## Usage +//! +//! ```zig +//! const Money = @import("Money.zig"); +//! +//! // Default {f}: "$1,234.56" with always-present cents. +//! try writer.print("{f}\n", .{Money.from(1234.56)}); +//! +//! // Whole-dollar: "$1,235" rounded. +//! try writer.print("{f}\n", .{Money.from(1234.56).whole()}); +//! +//! // Elide trailing .00: "$1,234" if whole, "$1,234.56" if fractional. +//! try writer.print("{f}\n", .{Money.from(1234.00).trim()}); +//! +//! // Signed: "+$1,234.56" / "-$1,234.56" / "$0.00". +//! try writer.print("{f}\n", .{Money.from(-1234.56).signed()}); +//! ``` +//! +//! ## On `f64` precision +//! +//! For zfin's use case (personal-finance, totals up to ~$100M, +//! display rounded to cents), f64 has plenty of headroom. f64 +//! starts losing cent precision around $10^15 ($1 quadrillion). +//! See AGENTS.md "Time and money helpers" for the full discussion. +//! When this stops being true, change the underlying storage in +//! ONE place (this file) and the format methods get redone — no +//! call-site churn. + +const std = @import("std"); + +amount: f64, + +const Money = @This(); + +/// Construct a Money from a raw f64 dollar amount. +pub fn from(amount: f64) Money { + return .{ .amount = amount }; +} + +/// Zero dollars. Convenience for explicit-init sites. +pub const zero: Money = .{ .amount = 0 }; + +// ── Standard format method (called via `{f}`) ────────────────── + +/// Default format: `$1,234.56`. Always positive (sign is the +/// caller's job). 2 decimal places. +pub fn format(self: Money, w: *std.Io.Writer) std.Io.Writer.Error!void { + try writeAbsCents(w, self.amount); +} + +// ── Variant wrappers ─────────────────────────────────────────── + +/// Wrapper whose `{f}` emits whole dollars rounded: `$1,234`. +/// Distinct from `trim()` — `whole()` rounds 1234.56 to `$1,235`. +pub fn whole(self: Money) Whole { + return .{ .amount = self.amount }; +} + +/// Wrapper whose `{f}` elides a trailing `.00` but preserves +/// non-zero cents: `$1,234` for 1234.00, `$1,234.56` for 1234.56. +/// Use for cosmetic displays where cents-when-present are still +/// informative. +pub fn trim(self: Money) Trim { + return .{ .amount = self.amount }; +} + +/// Wrapper whose `{f}` emits a leading sign: `+$1,234.56`, +/// `-$1,234.56`, or `$0.00` for zero. +pub fn signed(self: Money) Signed { + return .{ .amount = self.amount }; +} + +/// Pad the default-formatted Money to `width` columns, right-aligned. +/// For column-aligned tabular output: `try out.print("{f}", .{Money.from(x).padRight(10)})`. +/// +/// Composes with the variant wrappers — call `.padRight(N)` on a +/// `Whole`, `Trim`, or `Signed` directly via the same generic +/// helper. See `Padded` below. +pub fn padRight(self: Money, width: usize) Padded(Money) { + return .{ .inner = self, .width = width, .alignment = .right }; +} + +/// Pad the default-formatted Money to `width` columns, left-aligned. +pub fn padLeft(self: Money, width: usize) Padded(Money) { + return .{ .inner = self, .width = width, .alignment = .left }; +} + +pub const Whole = struct { + amount: f64, + + pub fn format(self: Whole, w: *std.Io.Writer) std.Io.Writer.Error!void { + try writeAbsWhole(w, self.amount); + } + + pub fn padRight(self: Whole, width: usize) Padded(Whole) { + return .{ .inner = self, .width = width, .alignment = .right }; + } + pub fn padLeft(self: Whole, width: usize) Padded(Whole) { + return .{ .inner = self, .width = width, .alignment = .left }; + } +}; + +pub const Trim = struct { + amount: f64, + + pub fn format(self: Trim, w: *std.Io.Writer) std.Io.Writer.Error!void { + // Render via a fixed buffer, then trim the .00 if present. + // The buffer is sized for the worst case (negative billions + // with cents, ~$1,234,567,890.12 = 14 chars); 24 bytes is + // generous. + var tmp: [24]u8 = undefined; + var fixed = std.Io.Writer.fixed(&tmp); + writeAbsCents(&fixed, self.amount) catch unreachable; + const written = fixed.buffered(); + const out = if (std.mem.endsWith(u8, written, ".00")) + written[0 .. written.len - 3] + else + written; + try w.writeAll(out); + } + + pub fn padRight(self: Trim, width: usize) Padded(Trim) { + return .{ .inner = self, .width = width, .alignment = .right }; + } + pub fn padLeft(self: Trim, width: usize) Padded(Trim) { + return .{ .inner = self, .width = width, .alignment = .left }; + } +}; + +pub const Signed = struct { + amount: f64, + + pub fn format(self: Signed, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (self.amount > 0) try w.writeByte('+'); + if (self.amount < 0) try w.writeByte('-'); + try writeAbsCents(w, self.amount); + } + + pub fn padRight(self: Signed, width: usize) Padded(Signed) { + return .{ .inner = self, .width = width, .alignment = .right }; + } + pub fn padLeft(self: Signed, width: usize) Padded(Signed) { + return .{ .inner = self, .width = width, .alignment = .left }; + } +}; + +/// Generic alignment wrapper. Wraps any `T` whose `format` method +/// emits some text, then pads the result to `width` columns with +/// spaces on the requested side. Composes with `Money` and all of +/// its variant wrappers — and with anything else that exposes a +/// `format(self, *Writer) !void`. +/// +/// Comptime instantiation per inner type is essentially free: the +/// compiler emits one body per `Padded(T)` actually used, with the +/// inner `format` call inlined. +pub fn Padded(comptime T: type) type { + return struct { + inner: T, + width: usize, + alignment: enum { left, right }, + + const Self = @This(); + + pub fn format(self: Self, w: *std.Io.Writer) std.Io.Writer.Error!void { + // Render the inner value into a stack buffer first so + // we can measure its length and pad. 64 bytes is more + // than enough for any realistic Money render + // (~14 chars worst case for $X,XXX,XXX,XXX.XX with sign). + var tmp: [64]u8 = undefined; + var fixed = std.Io.Writer.fixed(&tmp); + self.inner.format(&fixed) catch unreachable; + const text = fixed.buffered(); + + const pad = if (text.len >= self.width) 0 else self.width - text.len; + switch (self.alignment) { + .right => { + try w.splatByteAll(' ', pad); + try w.writeAll(text); + }, + .left => { + try w.writeAll(text); + try w.splatByteAll(' ', pad); + }, + } + } + }; +} + +// ── Internal: byte-emission shared by all variants ───────────── + +/// Write the absolute value of `amount` as `$X,XXX.XX` directly to +/// `w`. Same algorithm as the original `fmt.fmtMoneyAbs` — produces +/// byte-identical output, just streams to a writer instead of +/// returning a slice. +fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void { + const cents = @as(i64, @intFromFloat(@round(amount * 100.0))); + const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents)); + const dollars = abs_cents / 100; + const rem = abs_cents % 100; + + // Build into a stack buffer, then write all at once. The + // buffer is sized for the worst case (24-char negative + // billions); using a fixed buffer keeps the per-digit + // append-and-shift logic identical to the original. + var tmp: [24]u8 = undefined; + var pos: usize = tmp.len; + + // Cents + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(rem % 10)); + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(rem / 10)); + pos -= 1; + tmp[pos] = '.'; + + // Dollars with commas + var d = dollars; + var digit_count: usize = 0; + if (d == 0) { + pos -= 1; + tmp[pos] = '0'; + } else { + while (d > 0) { + if (digit_count > 0 and digit_count % 3 == 0) { + pos -= 1; + tmp[pos] = ','; + } + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(d % 10)); + d /= 10; + digit_count += 1; + } + } + pos -= 1; + tmp[pos] = '$'; + + try w.writeAll(tmp[pos..]); +} + +/// Write the absolute value of `amount` as `$X,XXX` (rounded to +/// whole dollars, no decimal portion) directly to `w`. +fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void { + const dollars_signed = @as(i64, @intFromFloat(@round(amount))); + const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed); + + var tmp: [24]u8 = undefined; + var pos: usize = tmp.len; + + var d = dollars; + var digit_count: usize = 0; + if (d == 0) { + pos -= 1; + tmp[pos] = '0'; + } else { + while (d > 0) { + if (digit_count > 0 and digit_count % 3 == 0) { + pos -= 1; + tmp[pos] = ','; + } + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(d % 10)); + d /= 10; + digit_count += 1; + } + } + pos -= 1; + tmp[pos] = '$'; + + try w.writeAll(tmp[pos..]); +} + +// ── Tests ────────────────────────────────────────────────────── + +const testing = std.testing; + +/// Render a value via the `{f}` format spec into an arena-allocated +/// string. Used by tests to verify format-method output matches the +/// expected string exactly. +fn renderToString(allocator: std.mem.Allocator, value: anytype) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{value}); + return aw.toOwnedSlice(); +} + +test "Money default {f}: $X,XXX.XX with cents" { + const allocator = testing.allocator; + + const cases = [_]struct { amount: f64, expected: []const u8 }{ + .{ .amount = 0, .expected = "$0.00" }, + .{ .amount = 1, .expected = "$1.00" }, + .{ .amount = 1.23, .expected = "$1.23" }, + .{ .amount = 1234.56, .expected = "$1,234.56" }, + .{ .amount = 1_234_567.89, .expected = "$1,234,567.89" }, + // Negative amounts emit absolute value — sign is caller's job + // unless they use `.signed()`. + .{ .amount = -1234.56, .expected = "$1,234.56" }, + }; + + for (cases) |c| { + const s = try renderToString(allocator, Money.from(c.amount)); + defer allocator.free(s); + try testing.expectEqualStrings(c.expected, s); + } +} + +test "Money.whole(): rounds to dollars, no decimals" { + const allocator = testing.allocator; + + const cases = [_]struct { amount: f64, expected: []const u8 }{ + .{ .amount = 0, .expected = "$0" }, + .{ .amount = 1, .expected = "$1" }, + .{ .amount = 1234, .expected = "$1,234" }, + .{ .amount = 1_234_567, .expected = "$1,234,567" }, + // @round is half-away-from-zero. + .{ .amount = 1.5, .expected = "$2" }, + .{ .amount = 1.49, .expected = "$1" }, + .{ .amount = 0.4, .expected = "$0" }, + // Negative magnitude rounds: |-1234.56| = 1234.56 → 1235. + .{ .amount = -1234.56, .expected = "$1,235" }, + }; + + for (cases) |c| { + const s = try renderToString(allocator, Money.from(c.amount).whole()); + defer allocator.free(s); + try testing.expectEqualStrings(c.expected, s); + } +} + +test "Money.trim(): elide .00 only" { + const allocator = testing.allocator; + + const cases = [_]struct { amount: f64, expected: []const u8 }{ + // Whole values elide .00. + .{ .amount = 0, .expected = "$0" }, + .{ .amount = 1234, .expected = "$1,234" }, + .{ .amount = 1_234_567, .expected = "$1,234,567" }, + // Non-zero cents preserved. + .{ .amount = 1234.56, .expected = "$1,234.56" }, + .{ .amount = 0.01, .expected = "$0.01" }, + .{ .amount = 0.5, .expected = "$0.50" }, + // Sub-cent rounds-to-zero-cents elides too. + .{ .amount = 10.001, .expected = "$10" }, + // Negative magnitude. + .{ .amount = -1234, .expected = "$1,234" }, + .{ .amount = -1234.56, .expected = "$1,234.56" }, + }; + + for (cases) |c| { + const s = try renderToString(allocator, Money.from(c.amount).trim()); + defer allocator.free(s); + try testing.expectEqualStrings(c.expected, s); + } +} + +test "Money.signed(): leading sign for non-zero, none for zero" { + const allocator = testing.allocator; + + const cases = [_]struct { amount: f64, expected: []const u8 }{ + .{ .amount = 1234.56, .expected = "+$1,234.56" }, + .{ .amount = -1234.56, .expected = "-$1,234.56" }, + // Zero gets no sign — purely cosmetic, matches the prior + // `fmtSignedMoneyBuf` convention. + .{ .amount = 0, .expected = "$0.00" }, + }; + + for (cases) |c| { + const s = try renderToString(allocator, Money.from(c.amount).signed()); + defer allocator.free(s); + try testing.expectEqualStrings(c.expected, s); + } +} + +test "Money.zero is zero" { + try testing.expectEqual(@as(f64, 0), Money.zero.amount); +} + +test "Money.from preserves the underlying f64" { + try testing.expectEqual(@as(f64, 1234.56), Money.from(1234.56).amount); + try testing.expectEqual(@as(f64, -42), Money.from(-42).amount); +} + +test "Money.padRight pads with leading spaces" { + const allocator = testing.allocator; + // "$1,234.56" is 9 chars; pad to 12 → 3 leading spaces. + const s = try renderToString(allocator, Money.from(1234.56).padRight(12)); + defer allocator.free(s); + try testing.expectEqualStrings(" $1,234.56", s); +} + +test "Money.padLeft pads with trailing spaces" { + const allocator = testing.allocator; + const s = try renderToString(allocator, Money.from(1234.56).padLeft(12)); + defer allocator.free(s); + try testing.expectEqualStrings("$1,234.56 ", s); +} + +test "padRight: text wider than width emits unchanged (no truncation)" { + const allocator = testing.allocator; + // "$1,234,567.89" is 13 chars; padding to 5 → unchanged. + const s = try renderToString(allocator, Money.from(1_234_567.89).padRight(5)); + defer allocator.free(s); + try testing.expectEqualStrings("$1,234,567.89", s); +} + +test "padRight composes with whole/trim/signed variants" { + const allocator = testing.allocator; + + // Whole + padRight: "$1,234" is 6 chars; pad to 10 → 4 spaces. + const s_whole = try renderToString(allocator, Money.from(1234).whole().padRight(10)); + defer allocator.free(s_whole); + try testing.expectEqualStrings(" $1,234", s_whole); + + // Trim + padRight: "$1,234.56" is 9 chars; pad to 12 → 3 spaces. + const s_trim = try renderToString(allocator, Money.from(1234.56).trim().padRight(12)); + defer allocator.free(s_trim); + try testing.expectEqualStrings(" $1,234.56", s_trim); + + // Signed + padRight: "+$1,234.56" is 10 chars; pad to 14 → 4 spaces. + const s_signed = try renderToString(allocator, Money.from(1234.56).signed().padRight(14)); + defer allocator.free(s_signed); + try testing.expectEqualStrings(" +$1,234.56", s_signed); +} diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 3414f0b..78dd007 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); /// CLI `analysis` command: show portfolio analysis breakdowns. pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { @@ -91,11 +92,7 @@ pub fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f // Equities vs Fixed Income summary { - var eq_buf: [24]u8 = undefined; - var fi_buf: [24]u8 = undefined; - const eq_dollars = fmt.fmtMoneyAbs(&eq_buf, stock_pct * total_value); - const fi_dollars = fmt.fmtMoneyAbs(&fi_buf, bond_pct * total_value); - try cli.printFg(out, color, cli.CLR_MUTED, " Equities {d:.1}% ({s}) / Fixed Income {d:.1}% ({s})\n\n", .{ stock_pct * 100, eq_dollars, bond_pct * 100, fi_dollars }); + try cli.printFg(out, color, cli.CLR_MUTED, " Equities {d:.1}% ({f}) / Fixed Income {d:.1}% ({f})\n\n", .{ stock_pct * 100, Money.from(stock_pct * total_value), bond_pct * 100, Money.from(bond_pct * total_value) }); } const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{ @@ -130,7 +127,6 @@ pub fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f /// Print a breakdown section with block-element bar charts to the CLI output. pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { for (items) |item| { - var val_buf: [24]u8 = undefined; const pct = item.weight * 100.0; // Build bar using shared function @@ -148,7 +144,7 @@ pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.B if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]); try out.writeAll(bar); if (color) try fmt.ansiReset(out); - try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoneyAbs(&val_buf, item.value) }); + try out.print(" {d:>5.1}% {f}\n", .{ pct, Money.from(item.value) }); } } diff --git a/src/commands/audit.zig b/src/commands/audit.zig index ca427ae..c4f1bc5 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); const analysis = @import("../analytics/analysis.zig"); const portfolio_mod = @import("../models/portfolio.zig"); const contributions = @import("contributions.zig"); @@ -636,17 +637,15 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o for (results) |r| { const label = if (r.account_name.len > 0) r.account_name else r.schwab_name; - var pf_cash_buf: [24]u8 = undefined; var br_cash_buf: [24]u8 = undefined; - var pf_total_buf: [24]u8 = undefined; var br_total_buf: [24]u8 = undefined; const br_cash_str = if (r.schwab_cash) |c| - fmt.fmtMoneyAbs(&br_cash_buf, c) + std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?" else "--"; const br_total_str = if (r.schwab_total) |t| - fmt.fmtMoneyAbs(&br_total_buf, t) + std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?" else "--"; @@ -669,9 +668,9 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o try out.print(" ", .{}); if (!cash_ok) { const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, "{s:>14}", .{fmt.fmtMoneyAbs(&pf_cash_buf, r.portfolio_cash)}); + try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)}); } else { - try out.print("{s:>14}", .{fmt.fmtMoneyAbs(&pf_cash_buf, r.portfolio_cash)}); + try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)}); } // BR Cash @@ -681,9 +680,9 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o try out.print(" ", .{}); if (!total_ok and !cash_ok) { const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, "{s:>14}", .{fmt.fmtMoneyAbs(&pf_total_buf, r.portfolio_total)}); + try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)}); } else { - try out.print("{s:>14}", .{fmt.fmtMoneyAbs(&pf_total_buf, r.portfolio_total)}); + try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)}); } // BR Total @@ -693,15 +692,13 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o if (is_unmapped) { try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{}); } else if (!cash_ok) { - var delta_buf: [24]u8 = undefined; const d = r.cash_delta.?; const sign: []const u8 = if (d >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }); + try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) }); } else if (!total_ok) { - var delta_buf: [24]u8 = undefined; const d = r.total_delta.?; const sign: []const u8 = if (d >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }); + try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) }); } try out.print("\n", .{}); @@ -711,14 +708,11 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o // Grand totals try out.print("\n", .{}); - var pf_grand_buf: [24]u8 = undefined; - var br_grand_buf: [24]u8 = undefined; - var grand_delta_buf: [24]u8 = undefined; const grand_delta = grand_br - grand_pf; - try cli.printBold(out, color, " Total: portfolio {s} schwab {s}", .{ - fmt.fmtMoneyAbs(&pf_grand_buf, grand_pf), - fmt.fmtMoneyAbs(&br_grand_buf, grand_br), + try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{ + Money.from(grand_pf), + Money.from(grand_br), }); if (@abs(grand_delta) < 1.0) { @@ -726,7 +720,7 @@ fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, o } else { const sign: []const u8 = if (grand_delta >= 0) "+" else "-"; const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, " delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&grand_delta_buf, @abs(grand_delta)) }); + try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) }); } try out.print("\n", .{}); @@ -1274,12 +1268,12 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. // Format share strings const pf_shares_str: []const u8 = if (cmp.is_cash) - (fmt.fmtMoneyAbs(&pf_shares_buf, cmp.portfolio_value)) + (std.fmt.bufPrint(&pf_shares_buf, "{f}", .{Money.from(cmp.portfolio_value)}) catch "$?") else std.fmt.bufPrint(&pf_shares_buf, "{d:.3}", .{cmp.portfolio_shares}) catch "?"; const br_shares_str: []const u8 = if (cmp.is_cash) - (if (cmp.brokerage_value) |v| fmt.fmtMoneyAbs(&br_shares_buf, v) else "--") + (if (cmp.brokerage_value) |v| (std.fmt.bufPrint(&br_shares_buf, "{f}", .{Money.from(v)}) catch "$?") else "--") else if (cmp.brokerage_shares) |s| (std.fmt.bufPrint(&br_shares_buf, "{d:.3}", .{s}) catch "?") else @@ -1320,9 +1314,8 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. if (is_cash_mismatch) { if (cmp.value_delta) |d| { - var delta_buf: [24]u8 = undefined; const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch "Cash mismatch"; + break :blk std.fmt.bufPrint(&status_buf, "Cash {s}{f}", .{ sign, Money.from(@abs(d)) }) catch "Cash mismatch"; } } @@ -1340,9 +1333,8 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. // Shares match — show value delta (stale price) if any, muted if (cmp.value_delta) |d| { if (@abs(d) >= 1.0) { - var delta_buf: [24]u8 = undefined; const sign: []const u8 = if (d >= 0) "+" else "-"; - break :blk std.fmt.bufPrint(&status_buf, "Value {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(d)) }) catch ""; + break :blk std.fmt.bufPrint(&status_buf, "Value {s}{f}", .{ sign, Money.from(@abs(d)) }) catch ""; } } @@ -1379,11 +1371,8 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. } // Account totals - var pf_total_buf: [24]u8 = undefined; - var br_total_buf: [24]u8 = undefined; - var delta_buf: [24]u8 = undefined; - try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {s:>10} {s:>10} ", .{ - "", "", "", fmt.fmtMoneyAbs(&pf_total_buf, acct.portfolio_total), fmt.fmtMoneyAbs(&br_total_buf, acct.brokerage_total), + try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>12} {s:>12} {f} {f} ", .{ + "", "", "", Money.from(acct.portfolio_total).padRight(10), Money.from(acct.brokerage_total).padRight(10), }); const adj_delta = acct.total_delta - acct.option_value_delta; @@ -1392,13 +1381,12 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. } else { const sign: []const u8 = if (adj_delta >= 0) "+" else "-"; const rgb = if (adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, "Delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&delta_buf, @abs(adj_delta)) }); + try cli.printFg(out, color, rgb, "Delta {s}{f}", .{ sign, Money.from(@abs(adj_delta)) }); } if (@abs(acct.option_value_delta) >= 1.0) { - var opt_delta_buf: [24]u8 = undefined; const opt_sign: []const u8 = if (acct.option_value_delta >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{s})", .{ opt_sign, fmt.fmtMoneyAbs(&opt_delta_buf, @abs(acct.option_value_delta)) }); + try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(acct.option_value_delta)) }); } try out.print("\n\n", .{}); @@ -1408,15 +1396,12 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. } // Grand totals - var pf_grand_buf: [24]u8 = undefined; - var br_grand_buf: [24]u8 = undefined; - var grand_delta_buf: [24]u8 = undefined; const grand_delta = total_brokerage - total_portfolio; const grand_adj_delta = grand_delta - total_option_delta; - try cli.printBold(out, color, " Total: portfolio {s} brokerage {s}", .{ - fmt.fmtMoneyAbs(&pf_grand_buf, total_portfolio), - fmt.fmtMoneyAbs(&br_grand_buf, total_brokerage), + try cli.printBold(out, color, " Total: portfolio {f} brokerage {f}", .{ + Money.from(total_portfolio), + Money.from(total_brokerage), }); if (@abs(grand_adj_delta) < 1.0) { @@ -1424,13 +1409,12 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io. } else { const sign: []const u8 = if (grand_adj_delta >= 0) "+" else "-"; const rgb = if (grand_adj_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE; - try cli.printFg(out, color, rgb, " delta {s}{s}", .{ sign, fmt.fmtMoneyAbs(&grand_delta_buf, @abs(grand_adj_delta)) }); + try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_adj_delta)) }); } if (@abs(total_option_delta) >= 1.0) { - var opt_grand_buf: [24]u8 = undefined; const opt_sign: []const u8 = if (total_option_delta >= 0) "+" else "-"; - try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{s})", .{ opt_sign, fmt.fmtMoneyAbs(&opt_grand_buf, @abs(total_option_delta)) }); + try cli.printFg(out, color, cli.CLR_MUTED, " (options {s}{f})", .{ opt_sign, Money.from(@abs(total_option_delta)) }); } try out.print("\n", .{}); @@ -1686,7 +1670,7 @@ fn printLargeLotWarning( ) !void { var val_buf: [32]u8 = undefined; var date_buf: [10]u8 = undefined; - const value_str = fmt.fmtMoneyAbs(&val_buf, lot.value); + const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; const date_str = lot.open_date.format(&date_buf); const kind_label: []const u8 = switch (lot.security_type) { .stock => "STOCK", @@ -1791,7 +1775,7 @@ fn runHygieneCheck( const date_str = pd.format(&date_buf); const note_display = lot.note orelse ""; var price_buf: [24]u8 = undefined; - const price_str = fmt.fmtMoneyAbs(&price_buf, lot.price.?); + const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(lot.price.?)}) catch "$?"; try out.print(" {s:<16} {s:<16} {s:>10} {s} ", .{ lot.symbol, diff --git a/src/commands/compare.zig b/src/commands/compare.zig index e670dd9..2ff078a 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -51,6 +51,7 @@ const cli = @import("common.zig"); const git = @import("../git.zig"); const fmt = cli.fmt; const Date = zfin.Date; +const Money = @import("../Money.zig"); const history = @import("../history.zig"); const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); @@ -735,20 +736,15 @@ fn renderAttributionLine(out: *std.Io.Writer, color: bool, delta: f64, attributi // color, cv.liquid.delta, a)`) and future revisions // that want to restate the Δ have it in scope. - var gains_buf: [32]u8 = undefined; - var contrib_buf: [32]u8 = undefined; - const gains_str = view_hist.fmtSignedMoneyBuf(&gains_buf, attribution.gains); - const contrib_str = view_hist.fmtSignedMoneyBuf(&contrib_buf, attribution.contributions); - // 19-char label column aligns the amount columns. "Investment // gains:" is 17 chars → 2 trailing pad; "Cash contributions:" is // 19 chars → 0 trailing pad. The 2-space gutter that follows // keeps the amounts clearly separated from the labels even on // narrow terminals. try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Investment gains:"}); - try cli.printGainLoss(out, color, attribution.gains, "{s}\n", .{gains_str}); + try cli.printGainLoss(out, color, attribution.gains, "{f}\n", .{Money.from(attribution.gains).signed()}); try cli.printFg(out, color, cli.CLR_MUTED, " {s:<19} ", .{"Cash contributions:"}); - try cli.printGainLoss(out, color, attribution.contributions, "{s}\n", .{contrib_str}); + try cli.printGainLoss(out, color, attribution.contributions, "{f}\n", .{Money.from(attribution.contributions).signed()}); } fn renderSymbolRow(out: *std.Io.Writer, color: bool, s: view.SymbolChange) !void { diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 8a5c0af..8659029 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -165,6 +165,7 @@ const git = @import("../git.zig"); const analysis = @import("../analytics/analysis.zig"); const transaction_log = @import("../models/transaction_log.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); const Date = zfin.Date; const Lot = zfin.Lot; const LotType = @import("../models/portfolio.zig").LotType; @@ -2254,22 +2255,17 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co // Grand totals try cli.setBold(out, color); try cli.printFg(out, color, h_color, "Totals\n", .{}); - var buf1: [32]u8 = undefined; - var buf2: [32]u8 = undefined; - var buf3: [32]u8 = undefined; - var buf4: [32]u8 = undefined; - var buf_grand: [32]u8 = undefined; - try out.print(" New contributions / purchases: {s}\n", .{fmt.fmtMoneyAbs(&buf1, total_new)}); - try out.print(" DRIP (confirmed): {s}\n", .{fmt.fmtMoneyAbs(&buf2, total_drip)}); - try out.print(" Rollup share deltas: {s} (DRIP or contribution; can't distinguish)\n", .{fmt.fmtMoneyAbs(&buf3, total_rollup)}); + try out.print(" New contributions / purchases: {f}\n", .{Money.from(total_new)}); + try out.print(" DRIP (confirmed): {f}\n", .{Money.from(total_drip)}); + try out.print(" Rollup share deltas: {f} (DRIP or contribution; can't distinguish)\n", .{Money.from(total_rollup)}); if (total_cd_int > 0) { - try out.print(" CD interest captured: {s}\n", .{fmt.fmtMoneyAbs(&buf4, total_cd_int)}); + try out.print(" CD interest captured: {f}\n", .{Money.from(total_cd_int)}); } // Grand total across everything "money in"-ish. CD interest is // included because it's real return realized during the window, // even though it originated inside the portfolio. const grand = total_new + total_drip + total_rollup + total_cd_int; - try cli.printFg(out, color, h_color, " Grand total: {s}\n", .{fmt.fmtMoneyAbs(&buf_grand, grand)}); + try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)}); } fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void { @@ -2282,8 +2278,7 @@ fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void { } fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { - var buf: [32]u8 = undefined; - try cli.printFg(out, color, hdr, " {s}: {s}\n", .{ label, fmt.fmtMoneyAbs(&buf, v) }); + try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) }); } fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !void { @@ -2291,8 +2286,8 @@ fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !voi var price_buf: [32]u8 = undefined; var val_buf: [32]u8 = undefined; const share_str = std.fmt.bufPrint(&share_buf, "{d:.4}", .{c.delta_shares}) catch "?"; - const price_str = fmt.fmtMoneyAbs(&price_buf, c.unit_value); - const val_str = fmt.fmtMoneyAbs(&val_buf, c.value()); + const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(c.unit_value)}) catch "$?"; + const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?"; const acct = if (c.account.len == 0) "(no account)" else c.account; try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct }); @@ -2306,7 +2301,6 @@ fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !voi } fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bool) !void { - var face_buf: [32]u8 = undefined; var mat_buf: [10]u8 = undefined; const mat_str = if (c.maturity_date) |d| d.format(&mat_buf) else "(no maturity)"; const acct = if (c.account.len == 0) "(no account)" else c.account; @@ -2315,32 +2309,29 @@ fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bo .cd_removed_early => "removed EARLY", else => "removed", }; - try out.print(" {s:<14}{s:<24} {s:<16} face {s} maturity {s}\n", .{ + try out.print(" {s:<14}{s:<24} {s:<16} face {f} maturity {s}\n", .{ c.symbol, acct, verb, - fmt.fmtMoneyAbs(&face_buf, c.face_value), + Money.from(c.face_value), mat_str, }); if (implied_interest) |i| { - var int_buf: [32]u8 = undefined; - try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {s}\n", .{ "", "", fmt.fmtMoneyAbs(&int_buf, i) }); + try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {f}\n", .{ "", "", Money.from(i) }); } } fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, color: bool) !void { - var val_buf: [32]u8 = undefined; const v = c.value(); const acct = if (c.account.len == 0) "(no account)" else c.account; const sign = if (v >= 0) "+" else "-"; try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct }); - try cli.printGainLoss(out, color, v, "{s}{s}", .{ sign, fmt.fmtMoneyAbs(&val_buf, @abs(v)) }); + try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) }); // Hint if a CD matured in the same account. for (report.changes) |o| { if (o.kind == .cd_matured and std.mem.eql(u8, o.account, c.account)) { - var face_buf: [32]u8 = undefined; - try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {s})", .{fmt.fmtMoneyAbs(&face_buf, o.face_value)}); + try cli.printFg(out, color, cli.CLR_MUTED, " (may include CD maturity of {f})", .{Money.from(o.face_value)}); break; } } @@ -2348,14 +2339,12 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col } fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { - var old_buf: [32]u8 = undefined; - var new_buf: [32]u8 = undefined; const acct = if (c.account.len == 0) "(no account)" else c.account; - try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {s} → {s}\n", .{ + try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {f} → {f}\n", .{ c.symbol, acct, - fmt.fmtMoneyAbs(&old_buf, c.old_price), - fmt.fmtMoneyAbs(&new_buf, c.new_price), + Money.from(c.old_price), + Money.from(c.new_price), }); } @@ -2367,15 +2356,13 @@ fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !v try out.print(" {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" }); }, .lot_removed => { - var face_buf: [32]u8 = undefined; - try out.print(" {s:<14}{s:<24} {s} lot removed (face {s})", .{ - c.symbol, acct, @tagName(c.security_type), fmt.fmtMoneyAbs(&face_buf, c.face_value), + try out.print(" {s:<14}{s:<24} {s} lot removed (face {f})", .{ + c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value), }); }, .drip_negative => { - var val_buf: [32]u8 = undefined; - try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({s})", .{ - c.symbol, acct, fmt.fmtMoneyAbs(&val_buf, @abs(c.value())), + try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({f})", .{ + c.symbol, acct, Money.from(@abs(c.value())), }); }, else => {}, @@ -2385,12 +2372,11 @@ fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !v } fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) !void { - var buf: [32]u8 = undefined; try out.print("{s} ", .{label}); if (v == 0) { try cli.printFg(out, color, cli.CLR_MUTED, "{s:>12}", .{"-"}); } else { - try cli.printFg(out, color, cli.CLR_POSITIVE, "{s:>12}", .{fmt.fmtMoneyAbs(&buf, v)}); + try cli.printFg(out, color, cli.CLR_POSITIVE, "{f}", .{Money.from(v).padRight(12)}); } } @@ -2408,7 +2394,7 @@ fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) var date_buf: [10]u8 = undefined; const date_str = if (c.transfer_date) |d| d.format(&date_buf) else "????-??-??"; var val_buf: [32]u8 = undefined; - const val_str = fmt.fmtMoneyAbs(&val_buf, c.transfer_attributed); + const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?"; const from_str = c.transfer_from orelse "?"; // For transfer_in / partial on the destination side, c.account @@ -2439,20 +2425,18 @@ fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) // the SYM@DATE. For cash, show "→ cash". For partial, show the // lot_value / attributed breakdown. if (c.kind == .partial_transfer_in) { - var lot_buf: [32]u8 = undefined; - var res_buf: [32]u8 = undefined; const lot_value = c.value(); const residual = lot_value - c.transfer_attributed; try cli.printFg( out, color, muted, - " → {s} ({s} of {s} lot — {s} from pre-existing cash)\n", + " → {s} ({f} of {f} lot — {f} from pre-existing cash)\n", .{ if (c.symbol.len > 0) c.symbol else "cash", - fmt.fmtMoneyAbs(&val_buf, c.transfer_attributed), - fmt.fmtMoneyAbs(&lot_buf, lot_value), - fmt.fmtMoneyAbs(&res_buf, residual), + Money.from(c.transfer_attributed), + Money.from(lot_value), + Money.from(residual), }, ); } else if (c.symbol.len > 0) { @@ -2476,7 +2460,7 @@ fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: var date_buf: [10]u8 = undefined; const date_str = if (c.transfer_date) |d| d.format(&date_buf) else "????-??-??"; var val_buf: [32]u8 = undefined; - const val_str = fmt.fmtMoneyAbs(&val_buf, c.transfer_attributed); + const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?"; const from_str = c.transfer_from orelse "?"; try cli.setFg(out, color, warn); @@ -2493,21 +2477,19 @@ fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: /// split so the user can tell at a glance why the number is smaller /// than the lot's face value. fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8, muted: [3]u8) !void { - var val_buf: [32]u8 = undefined; - var lot_buf: [32]u8 = undefined; const acct = if (c.account.len == 0) "(no account)" else c.account; const residual = c.attributedValue(); const lot_value = c.value(); const sym = if (c.symbol.len > 0) c.symbol else "cash"; try out.print(" {s:<14}{s:<24}", .{ sym, acct }); - try cli.printFg(out, color, pos, " {s}", .{fmt.fmtMoneyAbs(&val_buf, residual)}); + try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); try cli.printFg( out, color, muted, - " (of {s} total — rest from transfer)\n", - .{fmt.fmtMoneyAbs(&lot_buf, lot_value)}, + " (of {f} total — rest from transfer)\n", + .{Money.from(lot_value)}, ); } diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index 1d6cfeb..7a6c392 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -21,6 +21,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const history = @import("../history.zig"); const Date = @import("../models/date.zig").Date; const milestones = @import("../analytics/milestones.zig"); @@ -212,32 +213,29 @@ fn renderHeader( reference_year: u16, series: []const milestones.Point, ) !void { - var buf: [64]u8 = undefined; try cli.setBold(out, color); switch (step) { .absolute => |s| { - const money = fmt.fmtMoneyAbs(&buf, s); if (want_real) { try out.print( - "Milestones — step {s} (real, reference year: {d})\n", - .{ money, reference_year }, + "Milestones — step {f} (real, reference year: {d})\n", + .{ Money.from(s), reference_year }, ); } else { try out.print( - "Milestones — step {s} (nominal)\n", - .{money}, + "Milestones — step {f} (nominal)\n", + .{Money.from(s)}, ); } }, .relative => |f| { const start = series[0].value; - const start_money = fmt.fmtMoneyAbs(&buf, start); var date_buf: [10]u8 = undefined; const start_date_str = series[0].date.format(&date_buf); const real_str = if (want_real) " (real)" else ""; try out.print( - "Milestones — step {d}x from {s} ({s}){s}\n", - .{ f, start_money, start_date_str, real_str }, + "Milestones — step {d}x from {f} ({s}){s}\n", + .{ f, Money.from(start), start_date_str, real_str }, ); }, } @@ -255,14 +253,10 @@ fn renderNoCrossings( if (p.value > max_v) max_v = p.value; } const start_v = series[0].value; - var buf_max: [32]u8 = undefined; - var buf_start: [32]u8 = undefined; - const max_str = fmt.fmtMoneyAbs(&buf_max, max_v); - const start_str = fmt.fmtMoneyAbs(&buf_start, start_v); try cli.setStyleIntent(out, color, .muted); try out.print( - " No milestones reached. Series max: {s} (start: {s}).\n", - .{ max_str, start_str }, + " No milestones reached. Series max: {f} (start: {f}).\n", + .{ Money.from(max_v), Money.from(start_v) }, ); try cli.reset(out, color); } @@ -291,7 +285,7 @@ fn renderTable( var date_buf: [10]u8 = undefined; const date_str = c.date.format(&date_buf); var money_buf: [32]u8 = undefined; - const money_str = fmt.fmtMoneyAbs(&money_buf, c.threshold); + const money_str = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(c.threshold)}) catch "$?"; // The "days since prev" cell holds either "N days" (ASCII) // or the em-dash sentinel "—" (3 bytes / 1 display col). diff --git a/src/commands/options.zig b/src/commands/options.zig index a27e96e..4f9482a 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); pub fn run(io: std.Io, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void { const result = svc.getOptions(symbol) catch |err| switch (err) { @@ -33,8 +34,7 @@ pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: [ try cli.printBold(out, color, "\nOptions Chain for {s}\n", .{symbol}); try out.print("========================================\n", .{}); if (chains[0].underlying_price) |price| { - var price_buf: [24]u8 = undefined; - try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, ntm }); + try out.print("Underlying: {f} {d} expiration(s) +/- {d} strikes NTM\n", .{ Money.from(price), chains.len, ntm }); } else { try out.print("{d} expiration(s) available\n", .{chains.len}); } diff --git a/src/commands/perf.zig b/src/commands/perf.zig index ad449ea..7ab661a 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { @@ -37,8 +38,7 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, sym try out.print("{s}", .{end_date.format(&db)}); } try cli.reset(out, color); - var close_buf: [24]u8 = undefined; - try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoneyAbs(&close_buf, c[c.len - 1].close)}); + try out.print(")\nLatest close: {f}\n", .{Money.from(c[c.len - 1].close)}); const has_divs = result.asof_total != null; diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 009e7df..5697eae 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void { @@ -156,14 +157,11 @@ pub fn display( // Summary bar { - var val_buf: [24]u8 = undefined; - var cost_buf: [24]u8 = undefined; - var gl_buf: [24]u8 = undefined; const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; - try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) }); - try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "Gain/Loss: {c}{s} ({d:.1}%)", .{ + try out.print(" Value: {f} Cost: {f} ", .{ Money.from(summary.total_value), Money.from(summary.total_cost) }); + try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "Gain/Loss: {c}{f} ({d:.1}%)", .{ @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'), - fmt.fmtMoneyAbs(&gl_buf, gl_abs), + Money.from(gl_abs), summary.unrealized_return * 100.0, }); try out.print("\n", .{}); @@ -222,12 +220,7 @@ pub fn display( // Position summary row { - var mv_buf: [24]u8 = undefined; - var cost_buf2: [24]u8 = undefined; - var price_buf2: [24]u8 = undefined; - var gl_val_buf: [24]u8 = undefined; const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss; - const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs); const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-"; // Date + ST/LT for single-lot positions @@ -243,13 +236,13 @@ pub fn display( } if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING); - try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{ - a.display_symbol, a.shares, fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost), + try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} ", .{ + a.display_symbol, a.shares, Money.from(a.avg_cost).padRight(10), }); - try out.print("{s:>10}", .{fmt.fmtMoneyAbs(&price_buf2, a.current_price)}); - try out.print(" {s:>16} ", .{fmt.fmtMoneyAbs(&mv_buf, a.market_value)}); + try out.print("{f}", .{Money.from(a.current_price).padRight(10)}); + try out.print(" {f} ", .{Money.from(a.market_value).padRight(16)}); try cli.setGainLoss(out, color, a.unrealized_gain_loss); - try out.print("{s}{s:>13}", .{ sign, gl_money }); + try out.print("{s}{f}", .{ sign, Money.from(gl_abs).padRight(13) }); if (a.is_manual_price) { try cli.setFg(out, color, cli.CLR_WARNING); } else { @@ -313,25 +306,22 @@ pub fn display( "", "", "", "", "", "", "", }); { - var total_mv_buf: [24]u8 = undefined; - var total_gl_buf: [24]u8 = undefined; const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; - try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{ - "", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value), + try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {f} ", .{ + "", "", "", "TOTAL", Money.from(summary.total_value).padRight(16), }); - try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{s:>13}", .{ + try cli.printGainLoss(out, color, summary.unrealized_gain_loss, "{c}{f}", .{ @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'), - fmt.fmtMoneyAbs(&total_gl_buf, gl_abs), + Money.from(gl_abs).padRight(13), }); try out.print(" {s:>7}\n", .{"100.0%"}); } if (summary.realized_gain_loss != 0) { - var rpl_buf: [24]u8 = undefined; const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss; - try cli.printGainLoss(out, color, summary.realized_gain_loss, "\n Realized P&L: {c}{s}\n", .{ + try cli.printGainLoss(out, color, summary.realized_gain_loss, "\n Realized P&L: {c}{f}\n", .{ @as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'), - fmt.fmtMoneyAbs(&rpl_buf, rpl_abs), + Money.from(rpl_abs), }); } @@ -363,9 +353,8 @@ pub fn display( } // Options total try cli.printFg(out, color, cli.CLR_MUTED, " {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" }); - var opt_total_buf: [24]u8 = undefined; - try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{ - "", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_premium), + try out.print(" {s:>30} {s:>6} {s:>12} {f}\n", .{ + "", "", "TOTAL", Money.from(opt_total_premium).padRight(14), }); } } @@ -389,9 +378,8 @@ pub fn display( } // CD total try cli.printFg(out, color, cli.CLR_MUTED, " {s:->12} {s:->14}\n", .{ "", "" }); - var cd_total_buf: [24]u8 = undefined; - try out.print(" {s:>12} {s:>14}\n", .{ - "TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total), + try out.print(" {s:>12} {f}\n", .{ + "TOTAL", Money.from(cd_section_total).padRight(14), }); } } @@ -447,14 +435,11 @@ pub fn display( if (portfolio.hasType(.illiquid)) { const illiquid_total = portfolio.totalIlliquid(as_of); const net_worth = zfin.valuation.netWorth(as_of, portfolio.*, summary.*); - var nw_buf: [24]u8 = undefined; - var liq_buf: [24]u8 = undefined; - var il_buf: [24]u8 = undefined; try out.print("\n", .{}); - try cli.printBold(out, color, " Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{ - fmt.fmtMoneyAbs(&nw_buf, net_worth), - fmt.fmtMoneyAbs(&liq_buf, summary.total_value), - fmt.fmtMoneyAbs(&il_buf, illiquid_total), + try cli.printBold(out, color, " Net Worth: {f} (Liquid: {f} Illiquid: {f})\n", .{ + Money.from(net_worth), + Money.from(summary.total_value), + Money.from(illiquid_total), }); } @@ -465,7 +450,7 @@ pub fn display( for (watch_symbols) |sym| { var price_str: [16]u8 = undefined; const ps: []const u8 = if (watch_prices.get(sym)) |close| - fmt.fmtMoneyAbs(&price_str, close) + std.fmt.bufPrint(&price_str, "{f}", .{Money.from(close)}) catch "$?" else "--"; try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps }); @@ -507,7 +492,6 @@ pub fn display( } pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_price: f64) !void { - var lot_price_buf: [24]u8 = undefined; var lot_date_buf: [10]u8 = undefined; const date_str = lot.open_date.format(&lot_date_buf); const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date); @@ -516,18 +500,13 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin const use_price = lot.close_price orelse current_price; const gl = lot.shares * (use_price - lot.open_price); - var lot_gl_buf: [24]u8 = undefined; const lot_gl_abs = if (gl >= 0) gl else -gl; - const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_buf, lot_gl_abs); const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; - var lot_mv_buf: [24]u8 = undefined; - const lot_mv = fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price); - - try cli.printFg(out, color, cli.CLR_MUTED, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ - status_str, lot.shares, fmt.fmtMoneyAbs(&lot_price_buf, lot.open_price), "", lot_mv, + try cli.printFg(out, color, cli.CLR_MUTED, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {f} {s:>10} {f} ", .{ + status_str, lot.shares, Money.from(lot.open_price).padRight(10), "", Money.from(lot.shares * use_price).padRight(16), }); - try cli.printGainLoss(out, color, gl, "{s}{s:>13}", .{ lot_sign, lot_gl_money }); + try cli.printGainLoss(out, color, gl, "{s}{f}", .{ lot_sign, Money.from(lot_gl_abs).padRight(13) }); try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); } diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 496a752..d6c7fc7 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -13,6 +13,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; const Date = zfin.Date; +const Money = @import("../Money.zig"); const performance = @import("../analytics/performance.zig"); const projections = @import("../analytics/projections.zig"); const benchmark = @import("../analytics/benchmark.zig"); @@ -637,17 +638,13 @@ fn renderCompareRowPct(out: *std.Io.Writer, color: bool, label: []const u8, then /// Render a "label: then → now Δ" row for money values. fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, then_val: f64, now_val: f64) !void { const delta = now_val - then_val; - var then_buf: [32]u8 = undefined; - var now_buf: [32]u8 = undefined; - var delta_buf: [32]u8 = undefined; - const then_str = fmt.fmtMoneyAbs(&then_buf, then_val); - const now_str = fmt.fmtMoneyAbs(&now_buf, now_val); - const view_hist = @import("../views/history.zig"); - const delta_str = view_hist.fmtSignedMoneyBuf(&delta_buf, delta); try cli.printFg(out, color, cli.CLR_MUTED, " {s:<22} ", .{label}); - try cli.printFg(out, color, cli.CLR_MUTED, "{s: >10} → {s: >10} ", .{ then_str, now_str }); - try cli.printGainLoss(out, color, delta, "{s: >12}\n", .{delta_str}); + try cli.printFg(out, color, cli.CLR_MUTED, "{f} → {f} ", .{ + Money.from(then_val).padRight(10), + Money.from(now_val).padRight(10), + }); + try cli.printGainLoss(out, color, delta, "{f}\n", .{Money.from(delta).signed().padRight(12)}); } /// Resolve the user's requested as-of date against the history directory. @@ -733,14 +730,11 @@ fn renderAccumulationBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocat // Accumulation-phase stats: median + p10-p90 range at the // retirement boundary. if (ctx.accumulation) |acc| { - var median_buf: [24]u8 = undefined; - var p10_buf: [24]u8 = undefined; - var p90_buf: [24]u8 = undefined; - const median_str = fmt.fmtMoneyAbsTrim(&median_buf, acc.median_at_retirement); - const p10_str = fmt.fmtMoneyAbsTrim(&p10_buf, acc.p10_at_retirement); - const p90_str = fmt.fmtMoneyAbsTrim(&p90_buf, acc.p90_at_retirement); - try out.print(" Median portfolio at retirement: {s}\n", .{median_str}); - try out.print(" Range (10th\u{2013}90th percentile): {s} to {s}\n", .{ p10_str, p90_str }); + try out.print(" Median portfolio at retirement: {f}\n", .{Money.from(acc.median_at_retirement).trim()}); + try out.print(" Range (10th\u{2013}90th percentile): {f} to {f}\n", .{ + Money.from(acc.p10_at_retirement).trim(), + Money.from(acc.p90_at_retirement).trim(), + }); } } @@ -756,10 +750,8 @@ fn renderEarliestBlock(out: *std.Io.Writer, color: bool, va: std.mem.Allocator, const target = ctx.config.target_spending orelse return; try out.print("\n", .{}); - var amt_buf: [24]u8 = undefined; - const amt_str = fmt.fmtMoneyAbsTrim(&amt_buf, target); const adj: []const u8 = if (ctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal"; - try cli.printBold(out, color, "Earliest retirement (target spending: {s}/yr {s})\n", .{ amt_str, adj }); + try cli.printBold(out, color, "Earliest retirement (target spending: {f}/yr {s})\n", .{ Money.from(target).trim(), adj }); const horizons = ctx.config.getHorizons(); const confs = ctx.config.getConfidenceLevels(); diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 51e0500..a84ce02 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -2,6 +2,7 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; +const Money = @import("../Money.zig"); /// Quote data extracted from the real-time API (or synthesized from candles). pub const QuoteData = struct { @@ -52,11 +53,9 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote // Header try cli.setBold(out, color); if (quote) |q| { - var price_buf: [24]u8 = undefined; - try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoneyAbs(&price_buf, q.price) }); + try out.print("\n{s} {f}\n", .{ symbol, Money.from(q.price) }); } else if (candles.len > 0) { - var price_buf: [24]u8 = undefined; - try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoneyAbs(&price_buf, candles[candles.len - 1].close) }); + try out.print("\n{s} {f} (close)\n", .{ symbol, Money.from(candles[candles.len - 1].close) }); } else { try out.print("\n{s}\n", .{symbol}); } diff --git a/src/format.zig b/src/format.zig index f15f26d..9229959 100644 --- a/src/format.zig +++ b/src/format.zig @@ -1,10 +1,11 @@ //! Shared formatting utilities used by both CLI and TUI. //! -//! Number formatting (fmtMoneyAbs, fmtIntCommas, etc.), financial helpers +//! Number formatting (fmtIntCommas, etc.), financial helpers //! (capitalGainsIndicator, filterNearMoney), and braille chart computation. const std = @import("std"); const Date = @import("models/date.zig").Date; +const Money = @import("Money.zig"); const Candle = @import("models/candle.zig").Candle; const Lot = @import("models/portfolio.zig").Lot; const OptionContract = @import("models/option.zig").OptionContract; @@ -52,7 +53,7 @@ pub fn fmtCashHeader(buf: []u8) []const u8 { /// Returns a slice of `buf`. pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64, note: ?[]const u8) []const u8 { var money_buf: [24]u8 = undefined; - const money = fmtMoneyAbs(&money_buf, amount); + const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(amount)}) catch "$?"; const w = cash_acct_width; // " {name:14} {note}" const prefix = " "; @@ -102,7 +103,7 @@ pub fn fmtCashSep(buf: []u8) []const u8 { /// Format the cash total row. pub fn fmtCashTotal(buf: []u8, total: f64) []const u8 { var money_buf: [24]u8 = undefined; - const money = fmtMoneyAbs(&money_buf, total); + const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(total)}) catch "$?"; const w = cash_acct_width; var pos: usize = 0; @memcpy(buf[0..2], " "); @@ -164,105 +165,10 @@ pub const fmtIlliquidTotal = fmtCashTotal; // ── Number formatters ──────────────────────────────────────── -/// Format a dollar amount with commas and 2 decimals: $1,234.56 -/// Always returns the absolute value — callers handle sign display. -pub fn fmtMoneyAbs(buf: []u8, amount: f64) []const u8 { - const cents = @as(i64, @intFromFloat(@round(amount * 100.0))); - const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents)); - const dollars = abs_cents / 100; - const rem = abs_cents % 100; - - var tmp: [24]u8 = undefined; - var pos: usize = tmp.len; - - // Cents - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(rem % 10)); - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(rem / 10)); - pos -= 1; - tmp[pos] = '.'; - - // Dollars with commas - var d = dollars; - var digit_count: usize = 0; - if (d == 0) { - pos -= 1; - tmp[pos] = '0'; - } else { - while (d > 0) { - if (digit_count > 0 and digit_count % 3 == 0) { - pos -= 1; - tmp[pos] = ','; - } - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(d % 10)); - d /= 10; - digit_count += 1; - } - } - pos -= 1; - tmp[pos] = '$'; - - const len = tmp.len - pos; - if (len > buf.len) return "$?"; - @memcpy(buf[0..len], tmp[pos..]); - return buf[0..len]; -} - -/// Format a dollar amount with commas, rounded to whole dollars -/// (no `.00` tail): $1,234. Always returns the absolute value — -/// callers handle sign display. -/// -/// Use when the cents are noise (chart-axis labels, projection -/// summaries where the numbers are already noisy estimates, etc.). -/// For exact accounting where cents matter, use `fmtMoneyAbs`. -pub fn fmtMoneyAbsWhole(buf: []u8, amount: f64) []const u8 { - const dollars_signed = @as(i64, @intFromFloat(@round(amount))); - const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed); - - var tmp: [24]u8 = undefined; - var pos: usize = tmp.len; - - var d = dollars; - var digit_count: usize = 0; - if (d == 0) { - pos -= 1; - tmp[pos] = '0'; - } else { - while (d > 0) { - if (digit_count > 0 and digit_count % 3 == 0) { - pos -= 1; - tmp[pos] = ','; - } - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(d % 10)); - d /= 10; - digit_count += 1; - } - } - pos -= 1; - tmp[pos] = '$'; - - const len = tmp.len - pos; - if (len > buf.len) return "$?"; - @memcpy(buf[0..len], tmp[pos..]); - return buf[0..len]; -} - -/// Format a dollar amount like `fmtMoneyAbs`, but elide the -/// `.00` tail when the value is a whole number of dollars: shows -/// `$1,234` for 1234.0 but keeps `$1,234.56` for non-zero cents. -/// -/// Distinct from `fmtMoneyAbsWhole`, which always rounds to dollars -/// and never shows cents. Use this for cosmetic displays where -/// cents-when-present are still informative (projection -/// summaries, status messages) but `.00` is just noise. -pub fn fmtMoneyAbsTrim(buf: []u8, amount: f64) []const u8 { - const s = fmtMoneyAbs(buf, amount); - if (std.mem.endsWith(u8, s, ".00")) return s[0 .. s.len - 3]; - return s; -} +// Money formatters live in `src/Money.zig`. Use +// `Money.from(amount)` with `{f}` for the default `$1,234.56` +// rendering, or call `.whole()` / `.trim()` / `.signed()` / +// `.padRight(N)` for variants. See `Money.zig` for the API. /// Format an integer with commas (e.g. 1234567 -> "1,234,567"). pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 { @@ -611,16 +517,15 @@ pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation { /// Format a DRIP summary line: "ST: 12 DRIP lots, 3.5 shares, avg $45.67 (2023-01 to 2024-06)" pub fn fmtDripSummary(buf: []u8, label: []const u8, summary: DripSummary) []const u8 { - var avg_buf: [24]u8 = undefined; var d1_buf: [10]u8 = undefined; var d2_buf: [10]u8 = undefined; const d1: []const u8 = if (summary.first_date) |d| d.format(&d1_buf)[0..7] else "?"; const d2: []const u8 = if (summary.last_date) |d| d.format(&d2_buf)[0..7] else "?"; - return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})", .{ + return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {f} ({s} to {s})", .{ label, summary.lot_count, summary.shares, - fmtMoneyAbs(&avg_buf, summary.avgCost()), + Money.from(summary.avgCost()), d1, d2, }) catch "?"; @@ -640,7 +545,7 @@ pub fn fmtGainLoss(buf: []u8, pnl: f64) GainLossResult { const positive = pnl >= 0; const abs_val = if (positive) pnl else -pnl; var money_buf: [24]u8 = undefined; - const money = fmtMoneyAbs(&money_buf, abs_val); + const money = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(abs_val)}) catch "$?"; const sign: []const u8 = if (positive) "+" else "-"; const text = std.fmt.bufPrint(buf, "{s}{s}", .{ sign, money }) catch "?"; return .{ .text = text, .positive = positive }; @@ -963,11 +868,9 @@ pub fn computeBrailleChart( // Price labels var result: BrailleChart = undefined; - var max_tmp: [24]u8 = undefined; - var min_tmp: [24]u8 = undefined; - const max_str = std.fmt.bufPrint(&result.max_label, "{s}", .{fmtMoneyAbs(&max_tmp, max_price)}) catch ""; + const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch ""; result.max_label_len = max_str.len; - const min_str = std.fmt.bufPrint(&result.min_label, "{s}", .{fmtMoneyAbs(&min_tmp, min_price)}) catch ""; + const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch ""; result.min_label_len = min_str.len; const n_cols = @min(data.len, chart_width); @@ -1131,61 +1034,6 @@ pub fn ansiReset(out: *std.Io.Writer) !void { // ── Tests ──────────────────────────────────────────────────── -test "fmtMoneyAbs" { - var buf: [24]u8 = undefined; - try std.testing.expectEqualStrings("$0.00", fmtMoneyAbs(&buf, 0)); - try std.testing.expectEqualStrings("$1.23", fmtMoneyAbs(&buf, 1.23)); - try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbs(&buf, 1234.56)); - try std.testing.expectEqualStrings("$1,234,567.89", fmtMoneyAbs(&buf, 1234567.89)); -} - -test "fmtMoneyAbs negative" { - // Returns absolute value — callers handle sign display. - var buf: [24]u8 = undefined; - const result = fmtMoneyAbs(&buf, -1234.56); - try std.testing.expectEqualStrings("$1,234.56", result); -} - -test "fmtMoneyAbsWhole" { - var buf: [24]u8 = undefined; - try std.testing.expectEqualStrings("$0", fmtMoneyAbsWhole(&buf, 0)); - try std.testing.expectEqualStrings("$1", fmtMoneyAbsWhole(&buf, 1)); - try std.testing.expectEqualStrings("$1,234", fmtMoneyAbsWhole(&buf, 1234)); - try std.testing.expectEqualStrings("$1,234,567", fmtMoneyAbsWhole(&buf, 1234567)); - - // Rounds half-away-from-zero (matches @round behavior for f64). - try std.testing.expectEqualStrings("$2", fmtMoneyAbsWhole(&buf, 1.5)); - try std.testing.expectEqualStrings("$1", fmtMoneyAbsWhole(&buf, 1.49)); - // Sub-dollar amount rounds to 0. - try std.testing.expectEqualStrings("$0", fmtMoneyAbsWhole(&buf, 0.4)); - - // Returns absolute value, like fmtMoneyAbs. Negative inputs - // get their magnitude rounded — `|-1234.56| = 1234.56` rounds - // to `$1,235`. - try std.testing.expectEqualStrings("$1,235", fmtMoneyAbsWhole(&buf, -1234.56)); -} - -test "fmtMoneyAbsTrim" { - var buf: [24]u8 = undefined; - // Whole dollars elide the .00 tail. - try std.testing.expectEqualStrings("$0", fmtMoneyAbsTrim(&buf, 0)); - try std.testing.expectEqualStrings("$1,234", fmtMoneyAbsTrim(&buf, 1234)); - try std.testing.expectEqualStrings("$1,234,567", fmtMoneyAbsTrim(&buf, 1234567)); - - // Non-zero cents are preserved (distinct from fmtMoneyAbsWhole - // which would round these). - try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbsTrim(&buf, 1234.56)); - try std.testing.expectEqualStrings("$0.01", fmtMoneyAbsTrim(&buf, 0.01)); - try std.testing.expectEqualStrings("$0.50", fmtMoneyAbsTrim(&buf, 0.5)); - - // Sub-cent rounds-to-zero-cents elides too. - try std.testing.expectEqualStrings("$10", fmtMoneyAbsTrim(&buf, 10.001)); - - // Returns absolute value, like fmtMoneyAbs. - try std.testing.expectEqualStrings("$1,234", fmtMoneyAbsTrim(&buf, -1234)); - try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbsTrim(&buf, -1234.56)); -} - test "fmtIntCommas" { var buf: [32]u8 = undefined; try std.testing.expectEqualStrings("0", fmtIntCommas(&buf, 0)); diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index 7ee41e8..d268ea1 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const App = tui.App; @@ -117,13 +118,12 @@ pub fn renderAnalysisLines( // Equities vs Fixed Income summary if (stock_pct > 0 or bond_pct > 0) { - var eq_buf: [24]u8 = undefined; - var fi_buf: [24]u8 = undefined; - const eq_dollars = fmt.fmtMoneyAbs(&eq_buf, stock_pct * total_value); - const fi_dollars = fmt.fmtMoneyAbs(&fi_buf, bond_pct * total_value); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " Equities {d:.1}% ({s}) / Fixed Income {d:.1}% ({s})", .{ - stock_pct * 100, eq_dollars, bond_pct * 100, fi_dollars, + .text = try std.fmt.allocPrint(arena, " Equities {d:.1}% ({f}) / Fixed Income {d:.1}% ({f})", .{ + stock_pct * 100, + Money.from(stock_pct * total_value), + bond_pct * 100, + Money.from(bond_pct * total_value), }), .style = th.mutedStyle(), }); @@ -165,7 +165,6 @@ pub fn renderAnalysisLines( } pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 { - var val_buf: [24]u8 = undefined; const pct = item.weight * 100.0; const bar = try buildBlockBar(arena, item.weight, bar_width); // Build label padded to label_width @@ -174,8 +173,8 @@ pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownI const padded_label = try arena.alloc(u8, label_width); @memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]); if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' '); - return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{ - padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value), + return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}", .{ + padded_label, bar, pct, Money.from(item.value), }); } diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 882cafb..58b2dfd 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); @@ -67,8 +68,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } if (chains[0].underlying_price) |price| { - var price_buf: [24]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, app.options_near_the_money }), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {f} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ Money.from(price), chains.len, app.options_near_the_money }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); diff --git a/src/tui/perf_tab.zig b/src/tui/perf_tab.zig index 9a6d0bb..3a62394 100644 --- a/src/tui/perf_tab.zig +++ b/src/tui/perf_tab.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); @@ -108,8 +109,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine if (app.candles) |cc| { if (cc.len > 0) { - var close_buf: [24]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {f}", .{Money.from(cc[cc.len - 1].close)}), .style = th.contentStyle() }); } } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index b458c5e..3ccba0b 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); const cli = @import("../commands/common.zig"); const theme = @import("theme.zig"); @@ -786,15 +787,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af}); try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() }); - var val_buf: [24]u8 = undefined; - var cost_buf: [24]u8 = undefined; - var gl_buf: [24]u8 = undefined; - const val_str = fmt.fmtMoneyAbs(&val_buf, filtered_value); - const cost_str = fmt.fmtMoneyAbs(&cost_buf, filtered_cost); const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl; - const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs); - const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{ - val_str, cost_str, if (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, filtered_return * 100.0, + const summary_text = try std.fmt.allocPrint(arena, " Value: {f} Cost: {f} Gain/Loss: {s}{f} ({d:.1}%)", .{ + Money.from(filtered_value), + Money.from(filtered_cost), + if (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), + Money.from(gl_abs), + filtered_return * 100.0, }); const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = summary_text, .style = summary_style }); @@ -807,15 +806,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width // No historical snapshots or net worth when filtered } else { // Unfiltered mode: use portfolio_summary totals directly - var val_buf: [24]u8 = undefined; - var cost_buf: [24]u8 = undefined; - var gl_buf: [24]u8 = undefined; - const val_str = fmt.fmtMoneyAbs(&val_buf, s.total_value); - const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost); const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss; - const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs); - const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{ - val_str, cost_str, if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0, + const summary_text = try std.fmt.allocPrint(arena, " Value: {f} Cost: {f} Gain/Loss: {s}{f} ({d:.1}%)", .{ + Money.from(s.total_value), + Money.from(s.total_cost), + if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), + Money.from(gl_abs), + s.unrealized_return * 100.0, }); const summary_style = if (s.unrealized_gain_loss >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = summary_text, .style = summary_style }); @@ -832,12 +829,10 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width if (pf.hasType(.illiquid)) { const illiquid_total = pf.totalIlliquid(app.today); const net_worth = zfin.valuation.netWorth(app.today, pf, s); - var nw_buf: [24]u8 = undefined; - var il_buf: [24]u8 = undefined; - const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {s} (Liquid: {s} Illiquid: {s})", .{ - fmt.fmtMoneyAbs(&nw_buf, net_worth), - val_str, - fmt.fmtMoneyAbs(&il_buf, illiquid_total), + const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {f} (Liquid: {f} Illiquid: {f})", .{ + Money.from(net_worth), + Money.from(s.total_value), + Money.from(illiquid_total), }); try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() }); } @@ -929,18 +924,18 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width const pnl_pct = if (fa.cost_basis > 0) (display_gl / fa.cost_basis) * 100.0 else @as(f64, 0); var gl_val_buf: [24]u8 = undefined; const gl_abs = if (display_gl >= 0) display_gl else -display_gl; - const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs); + const gl_money = std.fmt.bufPrint(&gl_val_buf, "{f}", .{Money.from(gl_abs)}) catch "$?"; var pnl_buf: [20]u8 = undefined; const pnl_str = if (display_gl >= 0) std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?" else std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?"; var mv_buf: [24]u8 = undefined; - const mv_str = fmt.fmtMoneyAbs(&mv_buf, display_mv); + const mv_str = std.fmt.bufPrint(&mv_buf, "{f}", .{Money.from(display_mv)}) catch "$?"; var cost_buf2: [24]u8 = undefined; - const cost_str = fmt.fmtMoneyAbs(&cost_buf2, display_avg_cost); + const cost_str = std.fmt.bufPrint(&cost_buf2, "{f}", .{Money.from(display_avg_cost)}) catch "$?"; var price_buf2: [24]u8 = undefined; - const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price); + const price_str = std.fmt.bufPrint(&price_buf2, "{f}", .{Money.from(a.current_price)}) catch "$?"; // Date + ST/LT: show for single-lot, blank for multi-lot var pos_date_buf: [10]u8 = undefined; @@ -1005,18 +1000,16 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width const use_price = lot.close_price orelse price; const gl = lot.shares * (use_price - lot.open_price); lot_positive = gl >= 0; - var lot_gl_money_buf: [24]u8 = undefined; - const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_money_buf, if (gl >= 0) gl else -gl); - lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{ - if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money, + lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{ + if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), + Money.from(if (gl >= 0) gl else -gl), }); - var lot_mv_buf: [24]u8 = undefined; - lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)}); + lot_mv_str = try std.fmt.allocPrint(arena, "{f}", .{Money.from(lot.shares * use_price)}); } } var price_str2: [24]u8 = undefined; - const lot_price_str = fmt.fmtMoneyAbs(&price_str2, lot.open_price); + const lot_price_str = std.fmt.bufPrint(&price_str2, "{f}", .{Money.from(lot.open_price)}) catch "$?"; const status_str: []const u8 = if (lot.isOpen(app.today)) "open" else "closed"; const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date); const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); @@ -1038,7 +1031,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width .watchlist => { var price_str3: [16]u8 = undefined; const ps: []const u8 = if (app.watchlist_prices) |wp| - (if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--") + (if (wp.get(row.symbol)) |p| (std.fmt.bufPrint(&price_str3, "{f}", .{Money.from(p)}) catch "$?") else "--") else "--"; const star2: []const u8 = if (is_active_sym) "* " else " "; @@ -1085,11 +1078,10 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width .cash_total => { if (app.portfolio) |pf| { const total_cash = pf.totalCash(app.today); - var cash_buf: [24]u8 = undefined; const arrow3: []const u8 = if (app.cash_expanded) "v " else "> "; - const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{ + const text = try std.fmt.allocPrint(arena, " {s}Total Cash {f}", .{ arrow3, - fmt.fmtMoneyAbs(&cash_buf, total_cash), + Money.from(total_cash).padRight(14), }); const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = row_style4 }); @@ -1107,11 +1099,10 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width .illiquid_total => { if (app.portfolio) |pf| { const total_illiquid = pf.totalIlliquid(app.today); - var illiquid_buf: [24]u8 = undefined; const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> "; - const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{ + const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {f}", .{ arrow4, - fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid), + Money.from(total_illiquid).padRight(14), }); const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = row_style6 }); diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 65a0892..b49afcc 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -23,6 +23,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const chart = @import("chart.zig"); @@ -399,7 +400,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, // Format as whole dollars (no decimals) var lbl_buf: [16]u8 = undefined; - const lbl = fmt.fmtMoneyAbsWhole(&lbl_buf, val); + const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(val).whole()}) catch "$?"; const start_idx = row * @as(usize, width) + label_col; for (lbl, 0..) |ch, ci| { @@ -684,18 +685,15 @@ fn appendAccumulationBlocks( } if (pctx.accumulation) |acc| { - var median_buf: [24]u8 = undefined; - var p10_buf: [24]u8 = undefined; - var p90_buf: [24]u8 = undefined; - const median_str = fmt.fmtMoneyAbsTrim(&median_buf, acc.median_at_retirement); - const p10_str = fmt.fmtMoneyAbsTrim(&p10_buf, acc.p10_at_retirement); - const p90_str = fmt.fmtMoneyAbsTrim(&p90_buf, acc.p90_at_retirement); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " Median portfolio at retirement: {s}", .{median_str}), + .text = try std.fmt.allocPrint(arena, " Median portfolio at retirement: {f}", .{Money.from(acc.median_at_retirement).trim()}), .style = th.contentStyle(), }); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " Range (10th\u{2013}90th percentile): {s} to {s}", .{ p10_str, p90_str }), + .text = try std.fmt.allocPrint(arena, " Range (10th\u{2013}90th percentile): {f} to {f}", .{ + Money.from(acc.p10_at_retirement).trim(), + Money.from(acc.p90_at_retirement).trim(), + }), .style = th.mutedStyle(), }); } @@ -703,13 +701,11 @@ fn appendAccumulationBlocks( // Earliest retirement block (target-spending input). if (pctx.earliest) |earliest| { const target = pctx.config.target_spending orelse return; - var amt_buf: [24]u8 = undefined; - const amt_str = fmt.fmtMoneyAbsTrim(&amt_buf, target); const adj: []const u8 = if (pctx.config.target_spending_inflation_adjusted) "CPI-adjusted" else "nominal"; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ - .text = try std.fmt.allocPrint(arena, " Earliest retirement (target spending: {s}/yr {s})", .{ amt_str, adj }), + .text = try std.fmt.allocPrint(arena, " Earliest retirement (target spending: {f}/yr {s})", .{ Money.from(target).trim(), adj }), .style = th.headerStyle(), }); diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 3016e13..179374d 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -2,6 +2,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const chart = @import("chart.zig"); const tui = @import("../tui.zig"); @@ -289,7 +290,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, if (row >= height) continue; var lbl_buf: [16]u8 = undefined; - const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val); + const lbl = std.fmt.bufPrint(&lbl_buf, "{f}", .{Money.from(price_val)}) catch "$?"; const start_idx = row * @as(usize, width) + label_col; for (lbl, 0..) |ch, ci| { const idx = start_idx + ci; @@ -386,8 +387,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const c = app.candles orelse { if (quote_data) |q| { // No candle data but have a quote - show it - var qclose_buf: [24]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(q.close)}), .style = th.contentStyle() }); { var chg_buf: [64]u8 = undefined; const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle(); @@ -467,14 +467,13 @@ fn buildDetailColumns( ) !void { const th = app.theme; var date_buf: [10]u8 = undefined; - var close_buf: [24]u8 = undefined; var vol_buf: [32]u8 = undefined; // Column 1: Price/OHLCV var col1 = Column.init(); col1.width = 30; try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle()); - try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle()); + try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(price)}), th.contentStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle()); try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle()); diff --git a/src/views/compare.zig b/src/views/compare.zig index 0ef9b48..ed9bb5e 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -50,6 +50,7 @@ const std = @import("std"); const fmt = @import("../format.zig"); const Date = @import("../models/date.zig").Date; +const Money = @import("../Money.zig"); const timeline = @import("../analytics/timeline.zig"); const view_hist = @import("history.zig"); @@ -788,10 +789,10 @@ pub fn buildSymbolRowCells( ) SymbolRowCells { return .{ .symbol = s.symbol, - .price_then = fmt.fmtMoneyAbs(price_then_buf, s.price_then), - .price_now = fmt.fmtMoneyAbs(price_now_buf, s.price_now), + .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 = view_hist.fmtSignedMoneyBuf(dollar_buf, s.dollar_change), + .dollar = std.fmt.bufPrint(dollar_buf, "{f}", .{Money.from(s.dollar_change).signed()}) catch "$?", .style = s.style, }; } @@ -818,9 +819,9 @@ pub fn buildTotalsCells( pct_buf: *[16]u8, ) TotalsCells { return .{ - .then = fmt.fmtMoneyAbs(then_buf, t.then), - .now = fmt.fmtMoneyAbs(now_buf, t.now), - .delta = view_hist.fmtSignedMoneyBuf(delta_buf, t.delta), + .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, }; diff --git a/src/views/history.zig b/src/views/history.zig index 215027b..582bf77 100644 --- a/src/views/history.zig +++ b/src/views/history.zig @@ -20,6 +20,7 @@ const std = @import("std"); const timeline = @import("../analytics/timeline.zig"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const StyleIntent = fmt.StyleIntent; // ── Column widths (shared by CLI + TUI) ────────────────────── @@ -106,7 +107,7 @@ pub fn buildWindowRowCells( }; const delta_str: []const u8 = if (row.delta_abs) |d| - fmtSignedMoneyBuf(delta_buf, d) + std.fmt.bufPrint(delta_buf, "{f}", .{Money.from(d).signed()}) catch "$?" else "n/a"; @@ -131,19 +132,6 @@ pub fn buildWindowRowCells( }; } -/// Format a signed dollar amount: `"+$1,234.56"`, `"-$1,234.56"`, -/// `"$0.00"`. Returns a slice of `buf`. -/// -/// Separate from `fmt.fmtMoneyAbs` (which omits the sign) because the -/// windows block's Δ column needs the leading sign to distinguish -/// gains from losses at a glance. -pub fn fmtSignedMoneyBuf(buf: *[32]u8, value: f64) []const u8 { - const prefix: []const u8 = if (value > 0) "+" else if (value < 0) "-" else ""; - var tmp: [24]u8 = undefined; - const abs_str = fmt.fmtMoneyAbs(&tmp, value); - return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, abs_str }) catch "?"; -} - /// Format a signed percentage: `"+0.41%"`, `"-1.07%"`, `"0.00%"`. /// Input is a ratio (0.0041 → "+0.41%"). Returns a slice of `buf`. /// @@ -186,9 +174,9 @@ pub fn fmtValueDeltaCell( var val_buf: [24]u8 = undefined; var delta_inner: [32]u8 = undefined; var delta_outer: [40]u8 = undefined; - const val_str = fmt.fmtMoneyAbs(&val_buf, value); + const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(value)}) catch "$?"; const d_inner: []const u8 = if (delta_opt) |d| - fmtSignedMoneyBuf(&delta_inner, d) + std.fmt.bufPrint(&delta_inner, "{f}", .{Money.from(d).signed()}) catch "$?" else "—"; const d_str = std.fmt.bufPrint(&delta_outer, "({s})", .{d_inner}) catch return "?"; @@ -359,15 +347,6 @@ test "buildWindowRowCells: zero start_value → pct n/a, delta present" { try testing.expectEqual(StyleIntent.positive, cells.style); } -// ── fmtSignedMoneyBuf ── - -test "fmtSignedMoneyBuf: signs + zero + thousands" { - var buf: [32]u8 = undefined; - try testing.expectEqualStrings("+$1,234.56", fmtSignedMoneyBuf(&buf, 1234.56)); - try testing.expectEqualStrings("-$1,234.56", fmtSignedMoneyBuf(&buf, -1234.56)); - try testing.expectEqualStrings("$0.00", fmtSignedMoneyBuf(&buf, 0)); -} - // ── fmtSignedPercentBuf ── test "fmtSignedPercentBuf: signs + zero + flush digits" { diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 043c2b9..9cd3e79 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -8,6 +8,7 @@ const std = @import("std"); const Lot = @import("../models/portfolio.zig").Lot; const Date = @import("../models/date.zig").Date; const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); // ── Options ─────────────────────────────────────────────────── @@ -88,7 +89,7 @@ pub const Options = struct { var cost_buf: [24]u8 = undefined; var prem_val_buf: [24]u8 = undefined; - const prem_money = fmt.fmtMoneyAbs(&prem_val_buf, premium); + const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?"; var prem_buf: [20]u8 = undefined; const prem_str = if (received) std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?" @@ -99,7 +100,7 @@ pub const Options = struct { const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{ lot.symbol, qty, - fmt.fmtMoneyAbs(&cost_buf, cost_per), + std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?", prem_str, acct, }); @@ -201,7 +202,7 @@ pub const CDs = struct { const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{ lot.symbol, - fmt.fmtMoneyAbs(&face_buf, lot.shares), + std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?", rate_str, mat_str, note_display, diff --git a/src/views/projections.zig b/src/views/projections.zig index 12610a4..d95f489 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -4,6 +4,7 @@ /// and TUI renderers can consume through thin style-mapping adapters. const std = @import("std"); const fmt = @import("../format.zig"); +const Money = @import("../Money.zig"); const performance = @import("../analytics/performance.zig"); const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); @@ -82,12 +83,7 @@ pub const WithdrawalCell = struct { /// Caller owns both buffers (at least 24 bytes each). /// Strips trailing ".00" from whole-dollar amounts for clean display. pub fn fmtWithdrawalCell(amount_buf: []u8, rate_buf: []u8, result: projections.WithdrawalResult) WithdrawalCell { - const money_str = fmt.fmtMoneyAbs(amount_buf, result.annual_amount); - // Strip trailing ".00" for clean display - const clean_amount = if (std.mem.endsWith(u8, money_str, ".00")) - money_str[0 .. money_str.len - 3] - else - money_str; + const clean_amount = std.fmt.bufPrint(amount_buf, "{f}", .{Money.from(result.annual_amount).trim()}) catch "$?"; const rate_str = std.fmt.bufPrint(rate_buf, "{d:.2}%", .{result.withdrawal_rate * 100}) catch "??%"; return .{ .amount_text = clean_amount, .rate_text = rate_str }; } @@ -752,14 +748,8 @@ fn shortParts(buf: []u8, years: u16, date_str: []const u8) RetirementLineParts { /// suppressing the contribution row alone keeps the block tidy. pub fn fmtContributionLine(arena: std.mem.Allocator, amount: f64, inflation_adjusted: bool, accumulation_years: u16) !?[]const u8 { if (amount == 0 and accumulation_years == 0) return null; - var amt_buf: [24]u8 = undefined; - const amt_str = fmt.fmtMoneyAbs(&amt_buf, amount); - const amt_nodec = if (std.mem.endsWith(u8, amt_str, ".00")) - amt_str[0 .. amt_str.len - 3] - else - amt_str; const adj_note: []const u8 = if (inflation_adjusted) " (CPI-adjusted)" else " (nominal)"; - return try std.fmt.allocPrint(arena, "Annual contributions: {s}{s}", .{ amt_nodec, adj_note }); + return try std.fmt.allocPrint(arena, "Annual contributions: {f}{s}", .{ Money.from(amount).trim(), adj_note }); } /// A single cell in the "Earliest retirement" grid: either a formatted @@ -912,7 +902,7 @@ pub fn buildPercentileRow( else => 0, }; var mbuf: [24]u8 = undefined; - const txt = fmt.fmtMoneyAbs(&mbuf, val); + const txt = std.fmt.bufPrint(&mbuf, "{f}", .{Money.from(val)}) catch "$?"; try row.appendNTimes(arena, ' ', terminal_col_width -| txt.len); try row.appendSlice(arena, txt); } else { @@ -946,12 +936,8 @@ pub fn fmtEventLine(arena: std.mem.Allocator, ev: *const projections.LifeEvent, var amt_buf: [24]u8 = undefined; const sign: []const u8 = if (is_income) "+" else "-"; const abs_amount = @abs(amount); - const amt_str = fmt.fmtMoneyAbs(&amt_buf, abs_amount); - // Strip decimals - const amt_nodec = if (std.mem.lastIndexOfScalar(u8, amt_str, '.')) |dot| - amt_str[0..dot] - else - amt_str; + // Whole-dollar form (no decimals) for compact event-line display. + const amt_nodec = std.fmt.bufPrint(&amt_buf, "{f}", .{Money.from(abs_amount).whole()}) catch "$?"; const start_yr = ev.startYear(current_ages); const timing = if (start_yr) |sy| blk: {