add new Money type with format helpers
This commit is contained in:
parent
d94ffb3410
commit
e62eb5f0a7
23 changed files with 681 additions and 499 deletions
37
AGENTS.md
37
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
|
||||
|
||||
|
|
|
|||
445
src/Money.zig
Normal file
445
src/Money.zig
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
178
src/format.zig
178
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:<w} {money:>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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue