diff --git a/AGENTS.md b/AGENTS.md index 3501bb6..9d8810d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,14 @@ already exist and have caught me out: - `Date.wholeYearsBetween` — floored, returns u16. - `Date.ageOn` — calendar-precise age (handles "birthday hasn't occurred this year yet"). Distinct from `wholeYearsBetween`. -- `Date.format` — "YYYY-MM-DD". +- `Date.format` — Zig 0.15+ writer-style format method. Use `{f}` + to render "YYYY-MM-DD" directly into a writer. +- `Date.padRight(N)` / `Date.padLeft(N)` — column-aligned wrappers + for `{f}` rendering. Use these instead of `{s:>N}` when you + previously would have called a buffer-into-slice formatter and + passed the slice to a width-spec. +- For cases that need a `[]const u8` (URL params, struct fields), + call `std.fmt.bufPrint(&buf, "{f}", .{my_date})` into a `[10]u8`. - `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. @@ -173,7 +180,7 @@ and theirs. If the search confirms nothing exists, add the new helper to the right module: -- Date / calendar math → `src/models/date.zig`, as a `Date` method +- Date / calendar math → `src/Date.zig`, as a `Date` method when the receiver is natural. - Money formatting → `src/Money.zig`. New variants are wrapper structs returned from `Money` methods; each implements @@ -325,7 +332,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) ### Key design decisions -- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("models/date.zig")`. This is intentional — it lets `refAllDecls` in the test binary discover all tests across the entire source tree. +- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional — it lets `refAllDecls` in the test binary discover all tests across the entire source tree. - **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs. @@ -343,7 +350,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) | Directory | Purpose | |-----------|---------| -| `src/models/` | Data types: `Date` (days since epoch), `Candle`, `Dividend`, `Split`, `Lot`, `Portfolio`, `OptionContract`, `EarningsEvent`, `EtfProfile`, `Quote` | +| `src/models/` | Data types: `Candle`, `Dividend`, `Split`, `Lot`, `Portfolio`, `OptionContract`, `EarningsEvent`, `EtfProfile`, `Quote`. (`Date` and `Money` are top-level types in `src/Date.zig` and `src/Money.zig`.) | | `src/providers/` | API clients: each provider has its own struct with `init(allocator, api_key)` + fetch methods. `json_utils.zig` has shared JSON parsing helpers. | | `src/analytics/` | Pure computation: `performance.zig` (Morningstar-style trailing returns), `risk.zig` (Sharpe, drawdown), `valuation.zig` (portfolio summary), `analysis.zig` (breakdowns by class/sector/geo) | | `src/commands/` | CLI command handlers: each has a `run()` function taking `(allocator, *DataService, symbol, color, *Writer)`. `common.zig` has shared CLI helpers and color constants. | @@ -364,7 +371,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) ### The `Date` type -`Date` is an `i32` of days since Unix epoch. It is used everywhere instead of timestamps. Construction: `Date.fromYmd(2024, 1, 15)` or `Date.parse("2024-01-15")`. Formatting: `date.format(&buf)` writes `YYYY-MM-DD` into a `*[10]u8`. The type has SRF serialization hooks (`srfParse`, `srfFormat`). +`Date` is an `i32` of days since Unix epoch. It is used everywhere instead of timestamps. Construction: `Date.fromYmd(2024, 1, 15)` or `Date.parse("2024-01-15")`. Formatting: `try writer.print("{f}", .{date})` writes `YYYY-MM-DD` directly to a writer (Zig 0.15+ format method). For column-aligned output, use `date.padLeft(12)` / `date.padRight(12)` with `{f}`. For cases that need a `[]const u8` (URL params, struct fields), call `std.fmt.bufPrint(&buf, "{f}", .{date})` into a `[10]u8` buffer. The type has SRF serialization hooks (`srfParse`, `srfFormat`). ### Formatting pattern diff --git a/src/models/date.zig b/src/Date.zig similarity index 50% rename from src/models/date.zig rename to src/Date.zig index dea0b61..b165a54 100644 --- a/src/models/date.zig +++ b/src/Date.zig @@ -1,213 +1,254 @@ -/// Date represented as days since epoch (compact, sortable). -/// Use helper functions for formatting and parsing. -pub const Date = struct { - /// Days since 1970-01-01 - days: i32, +//! Date represented as days since epoch (compact, sortable). +//! +//! This file IS the `Date` struct (Zig "files-are-structs" +//! pattern). Importers do `const Date = @import("Date.zig");` +//! and use it as the type directly — no `.Date` field +//! extraction. +//! +//! ## Format methods +//! +//! - `Date.format(self, *std.Io.Writer) !void` — Zig 0.15+ +//! format-method protocol. Renders "YYYY-MM-DD" via the `{f}` +//! format spec: `try writer.print("{f}", .{my_date})`. +//! - `Date.padRight(width)` / `Date.padLeft(width)` — wrapper +//! structs for column-aligned output: `{f}` + `my_date.padLeft(12)`. +//! Use when previously you would have written `{s:>12}` with +//! the legacy buffer-form formatter. +//! +//! For cases that need a `[]const u8` (URL params, struct field +//! assignment), call `std.fmt.bufPrint(&buf, "{f}", .{my_date})` +//! into a `[10]u8` buffer. +//! +//! ## Construction +//! +//! - `Date.fromYmd(y, m, d)` — calendar date +//! - `Date.fromEpoch(secs)` — Unix epoch seconds +//! - `Date.parse("YYYY-MM-DD")` — ISO string - pub const epoch = Date{ .days = 0 }; +const std = @import("std"); +const srf = @import("srf"); - pub fn year(self: Date) i16 { - return epochDaysToYmd(self.days).year; +/// Days since 1970-01-01. +days: i32, + +/// Self-reference so internal code can use the simple `Date.foo` +/// form rather than `@This().foo` — matches the call-site style of +/// every external consumer. +const Date = @This(); + +/// Generic alignment wrapper, shared with `Money` and any other +/// type that exposes a `format(self, *Writer)` method. +pub const Padded = @import("padded.zig").Padded; + +pub const epoch: Date = .{ .days = 0 }; + +pub fn year(self: Date) i16 { + return epochDaysToYmd(self.days).year; +} + +pub fn month(self: Date) u8 { + return epochDaysToYmd(self.days).month; +} + +pub fn day(self: Date) u8 { + return epochDaysToYmd(self.days).day; +} + +pub fn fromYmd(y: i16, m: u8, d: u8) Date { + return .{ .days = ymdToEpochDays(y, m, d) }; +} + +/// Parse "YYYY-MM-DD" format. +pub fn parse(str: []const u8) !Date { + if (str.len != 10 or str[4] != '-' or str[7] != '-') return error.InvalidDateFormat; + const y = std.fmt.parseInt(i16, str[0..4], 10) catch return error.InvalidDateFormat; + const m = std.fmt.parseInt(u8, str[5..7], 10) catch return error.InvalidDateFormat; + const d = std.fmt.parseInt(u8, str[8..10], 10) catch return error.InvalidDateFormat; + return fromYmd(y, m, d); +} + +/// Hook for srf Record.to(T) coercion. +pub fn srfParse(str: []const u8) !Date { + return parse(str); +} + +/// Hook for srf Record.from(T) serialization. +pub fn srfFormat(self: Date, allocator: std.mem.Allocator, comptime field_name: []const u8) !srf.Value { + _ = field_name; + const ymd = epochDaysToYmd(self.days); + const y: u16 = @intCast(ymd.year); + const buf = try std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}", .{ y, ymd.month, ymd.day }); + return .{ .string = buf }; +} + +/// Zig 0.15+ format method: writes "YYYY-MM-DD" to the writer. +/// Invoked via `try w.print("{f}", .{my_date})`. +pub fn format(self: Date, w: *std.Io.Writer) std.Io.Writer.Error!void { + const ymd = epochDaysToYmd(self.days); + const y: u16 = @intCast(ymd.year); + try w.print("{d:0>4}-{d:0>2}-{d:0>2}", .{ y, ymd.month, ymd.day }); +} + +/// Pad the formatted Date to `width` columns, right-aligned with +/// leading spaces. For column-aligned tabular output: +/// `try out.print("{f}", .{my_date.padRight(12)})`. Date renders +/// as 10 chars; widths < 10 emit the date unchanged. +pub fn padRight(self: Date, width: usize) Padded(Date) { + return .{ .inner = self, .width = width, .alignment = .right }; +} + +/// Pad the formatted Date to `width` columns, left-aligned with +/// trailing spaces. +pub fn padLeft(self: Date, width: usize) Padded(Date) { + return .{ .inner = self, .width = width, .alignment = .left }; +} + +/// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday. +pub fn dayOfWeek(self: Date) u8 { + // 1970-01-01 was a Thursday (day 3 in 0=Mon scheme) + const d = @mod(self.days + 3, @as(i32, 7)); + return @intCast(if (d < 0) d + 7 else d); +} + +pub fn eql(a: Date, b: Date) bool { + return a.days == b.days; +} + +pub fn lessThan(a: Date, b: Date) bool { + return a.days < b.days; +} + +pub fn addDays(self: Date, n: i32) Date { + return .{ .days = self.days + n }; +} + +/// Convert to Unix epoch seconds (midnight UTC on this date). +pub fn toEpoch(self: Date) i64 { + return @as(i64, self.days) * std.time.s_per_day; +} + +/// Create a Date from a Unix epoch timestamp (seconds since 1970-01-01). +pub fn fromEpoch(epoch_secs: i64) Date { + return .{ .days = @intCast(@divFloor(epoch_secs, std.time.s_per_day)) }; +} + +/// Subtract N calendar years. Clamps Feb 29 -> Feb 28 if target is not a leap year. +pub fn subtractYears(self: Date, n: u16) Date { + const ymd = epochDaysToYmd(self.days); + const new_year: i16 = ymd.year - @as(i16, @intCast(n)); + const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day; + return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; +} + +/// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not +/// a leap year. Mirror of `subtractYears` — used by callers that +/// need "what date will it be when this person turns N", i.e. +/// `birthdate.addYears(target_age)`. +pub fn addYears(self: Date, n: u16) Date { + const ymd = epochDaysToYmd(self.days); + const new_year: i16 = ymd.year + @as(i16, @intCast(n)); + const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day; + return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; +} + +/// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28). +pub fn subtractMonths(self: Date, n: u16) Date { + const ymd = epochDaysToYmd(self.days); + var m: i32 = @as(i32, ymd.month) - @as(i32, n); + var y: i16 = ymd.year; + while (m < 1) { + m += 12; + y -= 1; } + const new_month: u8 = @intCast(m); + const max_day = daysInMonth(y, new_month); + const new_day: u8 = if (ymd.day > max_day) max_day else ymd.day; + return .{ .days = ymdToEpochDays(y, new_month, new_day) }; +} - pub fn month(self: Date) u8 { - return epochDaysToYmd(self.days).month; +/// Return the last day of the previous month. +/// E.g., if self is 2026-02-24, returns 2026-01-31. +pub fn lastDayOfPriorMonth(self: Date) Date { + const ymd = epochDaysToYmd(self.days); + if (ymd.month == 1) { + return fromYmd(ymd.year - 1, 12, 31); + } else { + return fromYmd(ymd.year, ymd.month - 1, daysInMonth(ymd.year, ymd.month - 1)); } +} - pub fn day(self: Date) u8 { - return epochDaysToYmd(self.days).day; - } +/// Last day of `(y, m)`. E.g., `lastDayOfMonth(2024, 2)` +/// returns `2024-02-29`. `m` must be 1..12; values outside +/// that range trigger `unreachable` in `daysInMonth`. +pub fn lastDayOfMonth(y: i16, m: u8) Date { + return fromYmd(y, m, daysInMonth(y, m)); +} - pub fn fromYmd(y: i16, m: u8, d: u8) Date { - return .{ .days = ymdToEpochDays(y, m, d) }; - } +fn daysInMonth(y: i16, m: u8) u8 { + const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + if (m == 2 and isLeapYear(y)) return 29; + return table[m - 1]; +} - /// Parse "YYYY-MM-DD" format - pub fn parse(str: []const u8) !Date { - if (str.len != 10 or str[4] != '-' or str[7] != '-') return error.InvalidDateFormat; - const y = std.fmt.parseInt(i16, str[0..4], 10) catch return error.InvalidDateFormat; - const m = std.fmt.parseInt(u8, str[5..7], 10) catch return error.InvalidDateFormat; - const d = std.fmt.parseInt(u8, str[8..10], 10) catch return error.InvalidDateFormat; - return fromYmd(y, m, d); - } +/// Three-letter English abbreviation of a month number +/// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for +/// out-of-range input rather than panicking — display +/// helpers prefer a placeholder over a crash. +pub fn monthShort(m: u8) []const u8 { + const table = [_][]const u8{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + }; + if (m < 1 or m > 12) return "???"; + return table[m - 1]; +} - /// Hook for srf Record.to(T) coercion. - pub fn srfParse(str: []const u8) !Date { - return parse(str); - } +/// Returns approximate number of years between two dates. +pub fn yearsBetween(from: Date, to: Date) f64 { + return @as(f64, @floatFromInt(to.days - from.days)) / 365.25; +} - /// Hook for srf Record.from(T) serialization. - pub fn srfFormat(self: Date, allocator: std.mem.Allocator, comptime field_name: []const u8) !srf.Value { - _ = field_name; - const buf = try allocator.alloc(u8, 10); - _ = self.format(buf[0..10]); - return .{ .string = buf }; - } +/// Whole years between two dates, floored to a non-negative +/// `u16`. Returns 0 when `to` is at or before `from`. Built on +/// `yearsBetween` (365.25-day approximation) — sufficient for +/// "how many full years until X" displays where the displayed +/// date itself is the precision-bearing value. +/// +/// Distinct from `ageOn`, which is calendar-precise — use that +/// when the answer must match calendar-anniversary intuition +/// (e.g. "what age will I be on this exact date"). +pub fn wholeYearsBetween(from: Date, to: Date) u16 { + if (to.days <= from.days) return 0; + const years = yearsBetween(from, to); + const floored: i32 = @intFromFloat(@floor(years)); + if (floored < 0) return 0; + return @intCast(floored); +} - /// Format as "YYYY-MM-DD" - pub fn format(self: Date, buf: *[10]u8) []const u8 { - const ymd = epochDaysToYmd(self.days); - const y: u16 = @intCast(ymd.year); - buf[0] = '0' + @as(u8, @intCast(y / 1000)); - buf[1] = '0' + @as(u8, @intCast((y / 100) % 10)); - buf[2] = '0' + @as(u8, @intCast((y / 10) % 10)); - buf[3] = '0' + @as(u8, @intCast(y % 10)); - buf[4] = '-'; - buf[5] = '0' + @as(u8, @intCast(ymd.month / 10)); - buf[6] = '0' + @as(u8, @intCast(ymd.month % 10)); - buf[7] = '-'; - buf[8] = '0' + @as(u8, @intCast(ymd.day / 10)); - buf[9] = '0' + @as(u8, @intCast(ymd.day % 10)); - return buf[0..10]; - } +/// Calendar-year age of a person born on `self` evaluated on +/// `on`. Whole-year integer math: subtract years, then drop one +/// if the birthday hasn't occurred yet that calendar year. +/// Returns 0 when `on` is at or before `self`. +/// +/// Distinct from `wholeYearsBetween`, which uses a 365.25-day +/// approximation that floors-down the exact-anniversary case to +/// `age − 1`. For "what age will I be on date X" displays where +/// the answer must match the calendar (e.g. you turn 65 ON +/// your 65th birthday, not the day after), use `ageOn`. +pub fn ageOn(self: Date, on: Date) u16 { + if (on.days <= self.days) return 0; + var years: i16 = on.year() - self.year(); + const before_birthday = (on.month() < self.month()) or + (on.month() == self.month() and on.day() < self.day()); + if (before_birthday) years -= 1; + if (years < 0) return 0; + return @intCast(years); +} - /// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday. - pub fn dayOfWeek(self: Date) u8 { - // 1970-01-01 was a Thursday (day 3 in 0=Mon scheme) - const d = @mod(self.days + 3, @as(i32, 7)); - return @intCast(if (d < 0) d + 7 else d); - } - - pub fn eql(a: Date, b: Date) bool { - return a.days == b.days; - } - - pub fn lessThan(a: Date, b: Date) bool { - return a.days < b.days; - } - - pub fn addDays(self: Date, n: i32) Date { - return .{ .days = self.days + n }; - } - - /// Convert to Unix epoch seconds (midnight UTC on this date). - pub fn toEpoch(self: Date) i64 { - return @as(i64, self.days) * std.time.s_per_day; - } - - /// Create a Date from a Unix epoch timestamp (seconds since 1970-01-01). - pub fn fromEpoch(epoch_secs: i64) Date { - return .{ .days = @intCast(@divFloor(epoch_secs, std.time.s_per_day)) }; - } - - /// Subtract N calendar years. Clamps Feb 29 -> Feb 28 if target is not a leap year. - pub fn subtractYears(self: Date, n: u16) Date { - const ymd = epochDaysToYmd(self.days); - const new_year: i16 = ymd.year - @as(i16, @intCast(n)); - const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day; - return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; - } - - /// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not - /// a leap year. Mirror of `subtractYears` — used by callers that - /// need "what date will it be when this person turns N", i.e. - /// `birthdate.addYears(target_age)`. - pub fn addYears(self: Date, n: u16) Date { - const ymd = epochDaysToYmd(self.days); - const new_year: i16 = ymd.year + @as(i16, @intCast(n)); - const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day; - return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; - } - - /// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28). - pub fn subtractMonths(self: Date, n: u16) Date { - const ymd = epochDaysToYmd(self.days); - var m: i32 = @as(i32, ymd.month) - @as(i32, n); - var y: i16 = ymd.year; - while (m < 1) { - m += 12; - y -= 1; - } - const new_month: u8 = @intCast(m); - const max_day = daysInMonth(y, new_month); - const new_day: u8 = if (ymd.day > max_day) max_day else ymd.day; - return .{ .days = ymdToEpochDays(y, new_month, new_day) }; - } - - /// Return the last day of the previous month. - /// E.g., if self is 2026-02-24, returns 2026-01-31. - pub fn lastDayOfPriorMonth(self: Date) Date { - const ymd = epochDaysToYmd(self.days); - if (ymd.month == 1) { - return fromYmd(ymd.year - 1, 12, 31); - } else { - return fromYmd(ymd.year, ymd.month - 1, daysInMonth(ymd.year, ymd.month - 1)); - } - } - - /// Last day of `(y, m)`. E.g., `lastDayOfMonth(2024, 2)` - /// returns `2024-02-29`. `m` must be 1..12; values outside - /// that range trigger `unreachable` in `daysInMonth`. - pub fn lastDayOfMonth(y: i16, m: u8) Date { - return fromYmd(y, m, daysInMonth(y, m)); - } - - fn daysInMonth(y: i16, m: u8) u8 { - const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; - if (m == 2 and isLeapYear(y)) return 29; - return table[m - 1]; - } - - /// Three-letter English abbreviation of a month number - /// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for - /// out-of-range input rather than panicking — display - /// helpers prefer a placeholder over a crash. - pub fn monthShort(m: u8) []const u8 { - const table = [_][]const u8{ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - }; - if (m < 1 or m > 12) return "???"; - return table[m - 1]; - } - - /// Returns approximate number of years between two dates - pub fn yearsBetween(from: Date, to: Date) f64 { - return @as(f64, @floatFromInt(to.days - from.days)) / 365.25; - } - - /// Whole years between two dates, floored to a non-negative - /// `u16`. Returns 0 when `to` is at or before `from`. Built on - /// `yearsBetween` (365.25-day approximation) — sufficient for - /// "how many full years until X" displays where the displayed - /// date itself is the precision-bearing value. - /// - /// Distinct from `ageOn`, which is calendar-precise — use that - /// when the answer must match calendar-anniversary intuition - /// (e.g. "what age will I be on this exact date"). - pub fn wholeYearsBetween(from: Date, to: Date) u16 { - if (to.days <= from.days) return 0; - const years = yearsBetween(from, to); - const floored: i32 = @intFromFloat(@floor(years)); - if (floored < 0) return 0; - return @intCast(floored); - } - - /// Calendar-year age of a person born on `self` evaluated on - /// `on`. Whole-year integer math: subtract years, then drop one - /// if the birthday hasn't occurred yet that calendar year. - /// Returns 0 when `on` is at or before `self`. - /// - /// Distinct from `wholeYearsBetween`, which uses a 365.25-day - /// approximation that floors-down the exact-anniversary case to - /// `age − 1`. For "what age will I be on date X" displays where - /// the answer must match the calendar (e.g. you turn 65 ON - /// your 65th birthday, not the day after), use `ageOn`. - pub fn ageOn(self: Date, on: Date) u16 { - if (on.days <= self.days) return 0; - var years: i16 = on.year() - self.year(); - const before_birthday = (on.month() < self.month()) or - (on.month() == self.month() and on.day() < self.day()); - if (before_birthday) years -= 1; - if (years < 0) return 0; - return @intCast(years); - } - - fn isLeapYear(y: i16) bool { - const yu: u16 = @bitCast(y); - return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0); - } -}; +fn isLeapYear(y: i16) bool { + const yu: u16 = @bitCast(y); + return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0); +} const Ymd = struct { year: i16, month: u8, day: u8 }; @@ -239,8 +280,7 @@ fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 { return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468); } -const std = @import("std"); -const srf = @import("srf"); +// ── Tests ──────────────────────────────────────────────────── test "date roundtrip" { const d = Date.fromYmd(2024, 6, 15); @@ -256,10 +296,59 @@ test "date parse" { try std.testing.expectEqual(@as(u8, 15), d.day()); } -test "date format" { +test "format produces YYYY-MM-DD via {f}" { const d = Date.fromYmd(2024, 1, 5); var buf: [10]u8 = undefined; - const s = d.format(&buf); + const s = try std.fmt.bufPrint(&buf, "{f}", .{d}); + try std.testing.expectEqualStrings("2024-01-05", s); +} + +test "format method produces YYYY-MM-DD via {f}" { + const d = Date.fromYmd(2024, 1, 5); + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{d}); + const s = try aw.toOwnedSlice(); + defer std.testing.allocator.free(s); + try std.testing.expectEqualStrings("2024-01-05", s); +} + +test "format method handles four-digit years correctly" { + // Edge case: year boundaries. + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{Date.fromYmd(1970, 1, 1)}); + try aw.writer.print(" ", .{}); + try aw.writer.print("{f}", .{Date.fromYmd(2099, 12, 31)}); + const s = try aw.toOwnedSlice(); + defer std.testing.allocator.free(s); + try std.testing.expectEqualStrings("1970-01-01 2099-12-31", s); +} + +test "padRight pads with leading spaces" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padRight(14)}); + const s = try aw.toOwnedSlice(); + defer std.testing.allocator.free(s); + try std.testing.expectEqualStrings(" 2024-01-05", s); +} + +test "padLeft pads with trailing spaces" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padLeft(14)}); + const s = try aw.toOwnedSlice(); + defer std.testing.allocator.free(s); + try std.testing.expectEqualStrings("2024-01-05 ", s); +} + +test "padRight: width <= 10 emits the date unchanged" { + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padRight(8)}); + const s = try aw.toOwnedSlice(); + defer std.testing.allocator.free(s); try std.testing.expectEqualStrings("2024-01-05", s); } diff --git a/src/Money.zig b/src/Money.zig index 165b74c..433514a 100644 --- a/src/Money.zig +++ b/src/Money.zig @@ -54,6 +54,10 @@ amount: f64, const Money = @This(); +/// Generic alignment wrapper, shared with `Date` and any other +/// type that exposes a `format(self, *Writer)` method. +pub const Padded = @import("padded.zig").Padded; + /// Construct a Money from a raw f64 dollar amount. pub fn from(amount: f64) Money { return .{ .amount = amount }; @@ -166,48 +170,6 @@ pub const Signed = struct { } }; -/// 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 diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 13dd300..a22350e 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -9,7 +9,7 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati const ClassificationMap = @import("../models/classification.zig").ClassificationMap; const LotType = @import("../models/portfolio.zig").LotType; const Portfolio = @import("../models/portfolio.zig").Portfolio; -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); /// A single slice of a breakdown (e.g., "Technology" -> 25.3%) pub const BreakdownItem = struct { diff --git a/src/analytics/benchmark.zig b/src/analytics/benchmark.zig index 7a0524a..ca29002 100644 --- a/src/analytics/benchmark.zig +++ b/src/analytics/benchmark.zig @@ -290,7 +290,7 @@ pub fn buildComparison( // ── Tests ────────────────────────────────────────────────────── -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); fn makePR(total: f64, ann: ?f64) PerformanceResult { return .{ diff --git a/src/analytics/indicators.zig b/src/analytics/indicators.zig index e7ca1a5..4588459 100644 --- a/src/analytics/indicators.zig +++ b/src/analytics/indicators.zig @@ -3,6 +3,7 @@ const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; +const Date = @import("../Date.zig"); /// Simple Moving Average for a window of `period` values ending at index `end` (inclusive). /// Returns null if there aren't enough data points. @@ -196,8 +197,8 @@ test "bollingerBands basic" { test "closePrices" { const alloc = std.testing.allocator; const candles = [_]Candle{ - .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1000 }, - .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 2000 }, + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 2000 }, }; const prices = try closePrices(alloc, &candles); defer alloc.free(prices); @@ -209,8 +210,8 @@ test "closePrices" { test "volumes" { const alloc = std.testing.allocator; const candles = [_]Candle{ - .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1500 }, - .{ .date = @import("../models/date.zig").Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 3000 }, + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 105, .low = 99, .close = 102, .adj_close = 102, .volume = 1500 }, + .{ .date = Date.fromYmd(2024, 1, 3), .open = 102, .high = 107, .low = 101, .close = 105, .adj_close = 105, .volume = 3000 }, }; const vols = try volumes(alloc, &candles); defer alloc.free(vols); diff --git a/src/analytics/milestones.zig b/src/analytics/milestones.zig index bb19f79..c50bc2f 100644 --- a/src/analytics/milestones.zig +++ b/src/analytics/milestones.zig @@ -16,7 +16,7 @@ //! detector ignorant of inflation semantics. const std = @import("std"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); // ── Step expression parser ─────────────────────────────────── diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig index 0c4e1e2..dd5fbc0 100644 --- a/src/analytics/performance.zig +++ b/src/analytics/performance.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; const portfolio = @import("../models/portfolio.zig"); diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index 73a0559..af7cb34 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -13,7 +13,7 @@ const std = @import("std"); const log = std.log.scoped(.projections); const shiller = @import("../data/shiller.zig"); const srf = @import("srf"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); // ── Life events ───────────────────────────────────────────────── @@ -2484,7 +2484,7 @@ fn retirementLineForTest(buf: []u8, resolved: ResolvedRetirement) []const u8 { return std.fmt.bufPrint(buf, "Years until possible retirement: none", .{}) catch "Years until possible retirement: none"; } var date_buf: [10]u8 = undefined; - const date_str = if (resolved.date) |d| d.format(&date_buf) else "????-??-??"; + const date_str = if (resolved.date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; return std.fmt.bufPrint(buf, "Years until possible retirement: {d} ({s})", .{ resolved.accumulation_years, date_str, diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index ef87d2a..e6902dc 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const months_per_year: f64 = 12.0; diff --git a/src/analytics/timeline.zig b/src/analytics/timeline.zig index 9cbbb38..b3cfd19 100644 --- a/src/analytics/timeline.zig +++ b/src/analytics/timeline.zig @@ -23,7 +23,7 @@ //! summary cache, not a replacement for the per-day snapshot files. const std = @import("std"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const snapshot = @import("../models/snapshot.zig"); const valuation = @import("valuation.zig"); const HistoricalPeriod = valuation.HistoricalPeriod; @@ -1178,10 +1178,9 @@ pub fn finerTier(tier: Tier) ?Tier { /// `buf` should be at least 16 bytes for the longest label /// ("W of YYYY-MM-DD" = 15). Returns `"?"` if `bufPrint` fails. pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 { - var date_inner: [10]u8 = undefined; return switch (tier) { - .daily => std.fmt.bufPrint(buf, "{s}", .{bucket_start.format(&date_inner)}) catch "?", - .weekly => std.fmt.bufPrint(buf, "W of {s}", .{bucket_start.format(&date_inner)}) catch "?", + .daily => std.fmt.bufPrint(buf, "{f}", .{bucket_start}) catch "?", + .weekly => std.fmt.bufPrint(buf, "W of {f}", .{bucket_start}) catch "?", .monthly => std.fmt.bufPrint(buf, "{s} {d}", .{ Date.monthShort(bucket_start.month()), bucket_start.year() }) catch "?", .quarterly => std.fmt.bufPrint(buf, "Q{d} {d}", .{ ((bucket_start.month() - 1) / 3) + 1, bucket_start.year() }) catch "?", .yearly => std.fmt.bufPrint(buf, "{d}", .{bucket_start.year()}) catch "?", diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 6d9ce75..80b8264 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -1,6 +1,6 @@ const std = @import("std"); const Candle = @import("../models/candle.zig").Candle; -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const portfolio_mod = @import("../models/portfolio.zig"); /// Portfolio-level metrics computed from weighted position data. diff --git a/src/cache/store.zig b/src/cache/store.zig index 1d1dffa..545b9d8 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -3,7 +3,7 @@ const log = std.log.scoped(.cache); const srf = @import("srf"); const atomic = @import("../atomic.zig"); const version = @import("../version.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Dividend = @import("../models/dividend.zig").Dividend; const DividendType = @import("../models/dividend.zig").DividendType; diff --git a/src/commands/audit.zig b/src/commands/audit.zig index c4f1bc5..1c4620d 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -197,7 +197,7 @@ fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool { return price == 1.0 and cost == 1.0; } -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); /// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a /// portfolio lot by comparing parsed components against the lot's structured @@ -1671,7 +1671,7 @@ fn printLargeLotWarning( var val_buf: [32]u8 = undefined; var date_buf: [10]u8 = undefined; const value_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(lot.value)}) catch "$?"; - const date_str = lot.open_date.format(&date_buf); + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; const kind_label: []const u8 = switch (lot.security_type) { .stock => "STOCK", .cash => "CASH", @@ -1772,7 +1772,7 @@ fn runHygieneCheck( stale_count += 1; var date_buf: [10]u8 = undefined; - const date_str = pd.format(&date_buf); + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{pd}) catch "????-??-??"; const note_display = lot.note orelse ""; var price_buf: [24]u8 = undefined; const price_str = std.fmt.bufPrint(&price_buf, "{f}", .{Money.from(lot.price.?)}) catch "$?"; @@ -3114,7 +3114,7 @@ test "printLargeLotWarning: cash destination emits dest_lot::cash template" { .symbol = "", .security_type = .cash, .value = 50_000.0, - .open_date = @import("../models/date.zig").Date.fromYmd(2026, 5, 10), + .open_date = Date.fromYmd(2026, 5, 10), }; try printLargeLotWarning(&writer, lot, false); // color=false → no ANSI escapes @@ -3138,7 +3138,7 @@ test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template" .symbol = "SYM", .security_type = .stock, .value = 25_000.0, - .open_date = @import("../models/date.zig").Date.fromYmd(2026, 5, 3), + .open_date = Date.fromYmd(2026, 5, 3), }; try printLargeLotWarning(&writer, lot, false); diff --git a/src/commands/cache.zig b/src/commands/cache.zig index 1b5bb4e..9a40bf0 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -189,7 +189,7 @@ fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8, return .{ .exists = true, .size = stat.size }; var last_date_buf: [10]u8 = undefined; - const date_str = meta_result.meta.last_date.format(&last_date_buf); + const date_str = std.fmt.bufPrint(&last_date_buf, "{f}", .{meta_result.meta.last_date}) catch unreachable; return .{ .exists = true, diff --git a/src/commands/common.zig b/src/commands/common.zig index d2e185b..7210047 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -796,9 +796,7 @@ pub fn resolveSnapshotOrExplain( ) !history.ResolvedSnapshot { return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) { error.NoSnapshotAtOrBefore => { - var req_buf: [10]u8 = undefined; - const req_str = requested.format(&req_buf); - const msg = std.fmt.allocPrint(arena, "No snapshot at or before {s}.\n", .{req_str}) catch "No snapshot at or before the requested date.\n"; + const msg = std.fmt.allocPrint(arena, "No snapshot at or before {f}.\n", .{requested}) catch "No snapshot at or before the requested date.\n"; stderrPrint(io, msg) catch {}; // Second look at the nearest table for the "later // available" hint. Cheap (filesystem scan, same dir). @@ -807,9 +805,7 @@ pub fn resolveSnapshotOrExplain( return err; }; if (nearest.later) |later| { - var later_buf: [10]u8 = undefined; - const later_str = later.format(&later_buf); - const later_msg = std.fmt.allocPrint(arena, "Earliest available: {s} (later than requested).\n", .{later_str}) catch "A later snapshot exists but was not used.\n"; + const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n"; stderrPrint(io, later_msg) catch {}; } else { stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {}; diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 2ff078a..cc1769b 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -430,16 +430,12 @@ pub fn run( /// backward to the nearest available snapshot. Pure formatter — caller /// supplies the writer (typically stderr) and decides about flushing. fn printSnapNote(out: *std.Io.Writer, color: bool, requested: Date, actual: Date, label: []const u8) !void { - var req_buf: [10]u8 = undefined; - var act_buf: [10]u8 = undefined; - const req_str = requested.format(&req_buf); - const act_str = actual.format(&act_buf); const days = requested.days - actual.days; var msg_buf: [160]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, - "(requested {s} for {s}; nearest snapshot: {s}, {d} day{s} earlier)\n", - .{ req_str, label, act_str, days, if (days == 1) "" else "s" }, + "(requested {f} for {s}; nearest snapshot: {f}, {d} day{s} earlier)\n", + .{ requested, label, actual, days, if (days == 1) "" else "s" }, ) catch "(snapped to nearest snapshot)\n"; if (color) try fmt.ansiSetFg(out, cli.CLR_MUTED[0], cli.CLR_MUTED[1], cli.CLR_MUTED[2]); try out.writeAll(msg); @@ -609,7 +605,7 @@ const LiveSide = struct { fn renderCompare(out: *std.Io.Writer, color: bool, cv: view.CompareView, proj: ?ProjectionsBlock) !void { var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; - const then_str = cv.then_date.format(&then_buf); + const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??"; const now_str = view.nowLabel(cv, &now_buf); // Header @@ -1268,7 +1264,7 @@ test "run: one date equal to today returns SameDate" { var today_buf: [10]u8 = undefined; const today_date = Date.fromYmd(2024, 3, 15); - const today_str = today_date.format(&today_buf); + const today_str = std.fmt.bufPrint(&today_buf, "{f}", .{today_date}) catch unreachable; const args = [_][]const u8{today_str}; const result = run(io, testing.allocator, &svc, pf, &args, today_date, false, &stream); diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 8659029..58d336a 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -491,7 +491,7 @@ fn specDisplayString(spec: ?git.CommitSpec, date_buf: *[10]u8) []const u8 { const s = spec orelse return "(unset)"; return switch (s) { .git_ref => |r| r, - .date_at_or_before => |d| d.format(date_buf), + .date_at_or_before => |d| std.fmt.bufPrint(date_buf, "{f}", .{d}) catch "????-??-??", .working_copy => "working", }; } @@ -523,20 +523,18 @@ fn maybeSnapNote( const gap_days = requested_date.days - commit_date.days; if (gap_days <= 1) return; - var req_buf: [10]u8 = undefined; - var commit_buf: [10]u8 = undefined; var msg_buf: [320]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, - "(git {s} uses commit {s} from {s}, {d} day{s} before requested {s} — " ++ + "(git {s} uses commit {s} from {f}, {d} day{s} before requested {f} — " ++ "use --commit-{s} HEAD or a later date to pin to your latest reconciliation commit)\n", .{ label, shortSha(resolved_ref), - commit_date.format(&commit_buf), + commit_date, gap_days, if (gap_days == 1) "" else "s", - requested_date.format(&req_buf), + requested_date, label, }, ) catch return; @@ -592,11 +590,7 @@ fn specLabel(arena: std.mem.Allocator, spec: ?git.CommitSpec, resolved_ref: []co const s = spec orelse return arena.dupe(u8, resolved_ref); return switch (s) { .git_ref => |r| arena.dupe(u8, r), - .date_at_or_before => |d| blk: { - var buf: [10]u8 = undefined; - const date_str = d.format(&buf); - break :blk std.fmt.allocPrint(arena, "commit at-or-before {s}", .{date_str}); - }, + .date_at_or_before => |d| std.fmt.allocPrint(arena, "commit at-or-before {f}", .{d}), .working_copy => arena.dupe(u8, "working copy"), }; } @@ -624,11 +618,11 @@ fn buildLabel( } var since_buf: [10]u8 = undefined; - const since_str = since.?.format(&since_buf); + const since_str = std.fmt.bufPrint(&since_buf, "{f}", .{since.?}) catch "????-??-??"; if (until) |until_date| { var until_buf: [10]u8 = undefined; - const until_str = until_date.format(&until_buf); + const until_str = std.fmt.bufPrint(&until_buf, "{f}", .{until_date}) catch "????-??-??"; return std.fmt.allocPrint(arena, "Comparing {s} ({s}) against {s} ({s})", .{ short(range.before_rev), since_str, @@ -1038,12 +1032,11 @@ const Report = struct { /// Build a canonical lookup key for matching lots between snapshots. /// Key: (security_type, symbol, account, open_date, open_price). fn lotKey(allocator: std.mem.Allocator, lot: Lot) ![]u8 { - var open_date_buf: [10]u8 = undefined; - return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{s}|{d:.6}", .{ + return std.fmt.allocPrint(allocator, "{s}|{s}|{s}|{f}|{d:.6}", .{ @tagName(lot.security_type), lot.symbol, lot.account orelse "", - lot.open_date.format(&open_date_buf), + lot.open_date, lot.open_price, }); } @@ -1460,8 +1453,8 @@ fn computeReport( { var old_buf: [10]u8 = undefined; var new_buf: [10]u8 = undefined; - const old_str = if (before_lot.maturity_date) |d| d.format(&old_buf) else "(none)"; - const new_str = if (lot.maturity_date) |d| d.format(&new_buf) else "(none)"; + const old_str = if (before_lot.maturity_date) |d| (std.fmt.bufPrint(&old_buf, "{f}", .{d}) catch "????-??-??") else "(none)"; + const new_str = if (lot.maturity_date) |d| (std.fmt.bufPrint(&new_buf, "{f}", .{d}) catch "????-??-??") else "(none)"; const detail = try std.fmt.allocPrint(allocator, "maturity_date {s} -> {s}", .{ old_str, new_str }); try changes.append(allocator, .{ .kind = .flagged, @@ -2302,7 +2295,7 @@ 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 mat_buf: [10]u8 = undefined; - const mat_str = if (c.maturity_date) |d| d.format(&mat_buf) else "(no maturity)"; + const mat_str = if (c.maturity_date) |d| (std.fmt.bufPrint(&mat_buf, "{f}", .{d}) catch "????-??-??") else "(no maturity)"; const acct = if (c.account.len == 0) "(no account)" else c.account; const verb = switch (c.kind) { .cd_matured => "matured", @@ -2392,7 +2385,7 @@ fn printSummaryCell(out: *std.Io.Writer, label: []const u8, v: f64, color: bool) /// ``` fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { var date_buf: [10]u8 = undefined; - const date_str = if (c.transfer_date) |d| d.format(&date_buf) else "????-??-??"; + const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?"; @@ -2458,7 +2451,7 @@ fn printTransferLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) /// `printFlaggedLine` for visual consistency. fn printUnmatchedTransferLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !void { var date_buf: [10]u8 = undefined; - const date_str = if (c.transfer_date) |d| d.format(&date_buf) else "????-??-??"; + const date_str = if (c.transfer_date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.transfer_attributed)}) catch "$?"; const from_str = c.transfer_from orelse "?"; @@ -4173,7 +4166,7 @@ test "specDisplayString: git_ref returns ref verbatim" { test "specDisplayString: date_at_or_before formats date YYYY-MM-DD" { var buf: [10]u8 = undefined; - const d = @import("../models/date.zig").Date.fromYmd(2024, 3, 15); + const d = Date.fromYmd(2024, 3, 15); try std.testing.expectEqualStrings("2024-03-15", specDisplayString(.{ .date_at_or_before = d }, &buf)); } @@ -4197,7 +4190,7 @@ test "specLabel: date renders 'commit at-or-before YYYY-MM-DD'" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); - const d = @import("../models/date.zig").Date.fromYmd(2024, 3, 15); + const d = Date.fromYmd(2024, 3, 15); const result = try specLabel(arena, .{ .date_at_or_before = d }, "ignored"); try std.testing.expectEqualStrings("commit at-or-before 2024-03-15", result); } @@ -4257,7 +4250,7 @@ test "buildLabel: --since only, dirty -> against working copy" { defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; - const since = @import("../models/date.zig").Date.fromYmd(2024, 3, 15); + const since = Date.fromYmd(2024, 3, 15); const result = try buildLabel(arena, range, since, null, true); try std.testing.expect(std.mem.indexOf(u8, result, "abc1234") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); @@ -4269,7 +4262,7 @@ test "buildLabel: --since only, clean -> against HEAD" { defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = null }; - const since = @import("../models/date.zig").Date.fromYmd(2024, 3, 15); + const since = Date.fromYmd(2024, 3, 15); const result = try buildLabel(arena, range, since, null, false); try std.testing.expect(std.mem.indexOf(u8, result, "against HEAD") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); @@ -4280,8 +4273,8 @@ test "buildLabel: --since + --until renders both dates and short SHAs" { defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "abc1234567890", .after_rev = "def4567890123" }; - const since = @import("../models/date.zig").Date.fromYmd(2024, 1, 15); - const until = @import("../models/date.zig").Date.fromYmd(2024, 3, 15); + const since = Date.fromYmd(2024, 1, 15); + const until = Date.fromYmd(2024, 3, 15); const result = try buildLabel(arena, range, since, until, false); try std.testing.expect(std.mem.indexOf(u8, result, "2024-01-15") != null); try std.testing.expect(std.mem.indexOf(u8, result, "2024-03-15") != null); @@ -4294,8 +4287,8 @@ test "buildLabelFromSpecs: both date specs -> falls through to buildLabel" { defer arena_state.deinit(); const arena = arena_state.allocator(); const range = git.CommitRange{ .before_rev = "aaaaaaa1234567", .after_rev = "bbbbbbb1234567" }; - const before_d = @import("../models/date.zig").Date.fromYmd(2024, 1, 15); - const after_d = @import("../models/date.zig").Date.fromYmd(2024, 3, 15); + const before_d = Date.fromYmd(2024, 1, 15); + const after_d = Date.fromYmd(2024, 3, 15); const result = try buildLabelFromSpecs( arena, range, diff --git a/src/commands/divs.zig b/src/commands/divs.zig index 91d6ff4..a2b02e4 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -50,11 +50,9 @@ pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_pri var ttm: f64 = 0; for (dividends) |div| { - var ex_buf: [10]u8 = undefined; - try out.print("{s:>12} {d:>10.4}", .{ div.ex_date.format(&ex_buf), div.amount }); + try out.print("{f} {d:>10.4}", .{ div.ex_date.padLeft(12), div.amount }); if (div.pay_date) |pd| { - var pay_buf: [10]u8 = undefined; - try out.print(" {s:>12}", .{pd.format(&pay_buf)}); + try out.print(" {f}", .{pd.padLeft(12)}); } else { try out.print(" {s:>12}", .{"--"}); } diff --git a/src/commands/etf.zig b/src/commands/etf.zig index c40f6d1..d0f5394 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -40,8 +40,7 @@ pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, o try out.print(" Portfolio Turnover: {d:.1}%\n", .{pt * 100.0}); } if (profile.inception_date) |d| { - var db: [10]u8 = undefined; - try out.print(" Inception Date: {s}\n", .{d.format(&db)}); + try out.print(" Inception Date: {f}\n", .{d}); } if (profile.leveraged) { try cli.printFg(out, color, cli.CLR_NEGATIVE, " Leveraged: YES\n", .{}); diff --git a/src/commands/history.zig b/src/commands/history.zig index b3a0abd..8e50e88 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -38,7 +38,7 @@ const history = @import("../history.zig"); const snapshot_model = @import("../models/snapshot.zig"); const view = @import("../views/history.zig"); const fmt = cli.fmt; -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); pub const Error = error{ UnexpectedArg, @@ -194,10 +194,9 @@ pub fn displaySymbol(candles: []const zfin.Candle, symbol: []const u8, color: bo try cli.reset(out, color); for (candles) |candle| { - var db: [10]u8 = undefined; var vb: [32]u8 = undefined; - try cli.printGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0, "{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), + try cli.printGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0, "{f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{ + candle.date.padLeft(12), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), }); } try out.print("\n{d} trading days\n\n", .{candles.len}); @@ -503,12 +502,11 @@ fn writeTableRow( // Composite cells are built via the shared `view.fmtValueDeltaCell` // so the TUI's cells align byte-for-byte with what's emitted here. - var db: [10]u8 = undefined; var cbuf_l: [64]u8 = undefined; var cbuf_i: [64]u8 = undefined; var cbuf_n: [64]u8 = undefined; - try out.print(" {s:>10} ", .{row.date.format(&db)}); + try out.print(" {f} ", .{row.date.padLeft(10)}); try out.writeAll(view.fmtValueDeltaCell(&cbuf_l, row.liquid, row.d_liquid, view.table_cell_width)); try out.writeAll(" "); try out.writeAll(view.fmtValueDeltaCell(&cbuf_i, row.illiquid, row.d_illiquid, view.table_cell_width)); diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index 7a6c392..b74a8bf 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -23,7 +23,7 @@ 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 Date = @import("../Date.zig"); const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); @@ -230,12 +230,10 @@ fn renderHeader( }, .relative => |f| { const start = series[0].value; - 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 {f} ({s}){s}\n", - .{ f, Money.from(start), start_date_str, real_str }, + "Milestones — step {d}x from {f} ({f}){s}\n", + .{ f, Money.from(start), series[0].date, real_str }, ); }, } @@ -283,7 +281,7 @@ fn renderTable( var has_start_row = false; for (crossings) |c| { var date_buf: [10]u8 = undefined; - const date_str = c.date.format(&date_buf); + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{c.date}) catch "????-??-??"; var money_buf: [32]u8 = undefined; const money_str = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(c.threshold)}) catch "$?"; diff --git a/src/commands/options.zig b/src/commands/options.zig index 4f9482a..c86538e 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -54,15 +54,14 @@ pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: [ // List all expirations, expanding the nearest monthly for (chains, 0..) |chain, ci| { - var db: [10]u8 = undefined; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const is_expanded = auto_expand_idx != null and ci == auto_expand_idx.?; try out.print("\n", .{}); if (is_expanded) { try cli.setBold(out, color); - try out.print("{s} ({d} calls, {d} puts)", .{ - chain.expiration.format(&db), chain.calls.len, chain.puts.len, + try out.print("{f} ({d} calls, {d} puts)", .{ + chain.expiration, chain.calls.len, chain.puts.len, }); if (is_monthly) try out.print(" [monthly]", .{}); try cli.reset(out, color); @@ -75,8 +74,8 @@ pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: [ try printSection(out, "PUTS", chain.puts, atm_price, ntm, false, color); } else { try cli.setFg(out, color, if (is_monthly) cli.CLR_HEADER else cli.CLR_MUTED); - try out.print("{s} ({d} calls, {d} puts)", .{ - chain.expiration.format(&db), chain.calls.len, chain.puts.len, + try out.print("{f} ({d} calls, {d} puts)", .{ + chain.expiration, chain.calls.len, chain.puts.len, }); if (is_monthly) try out.print(" [monthly]", .{}); try cli.reset(out, color); diff --git a/src/commands/perf.zig b/src/commands/perf.zig index 7ab661a..c85823d 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -28,32 +28,20 @@ pub fn run(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService, sym try out.print("========================================\n", .{}); try cli.setFg(out, color, cli.CLR_MUTED); try out.print("Data points: {d} (", .{c.len}); - { - var db: [10]u8 = undefined; - try out.print("{s}", .{c[0].date.format(&db)}); - } + try out.print("{f}", .{c[0].date}); try out.print(" to ", .{}); - { - var db: [10]u8 = undefined; - try out.print("{s}", .{end_date.format(&db)}); - } + try out.print("{f}", .{end_date}); try cli.reset(out, color); try out.print(")\nLatest close: {f}\n", .{Money.from(c[c.len - 1].close)}); const has_divs = result.asof_total != null; // -- As-of-date returns -- - { - var db: [10]u8 = undefined; - try cli.printBold(out, color, "\nAs-of {s}:\n", .{end_date.format(&db)}); - } + try cli.printBold(out, color, "\nAs-of {f}:\n", .{end_date}); try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null, color); // -- Month-end returns -- - { - var db: [10]u8 = undefined; - try cli.printBold(out, color, "\nMonth-end ({s}):\n", .{month_end.format(&db)}); - } + try cli.printBold(out, color, "\nMonth-end ({f}):\n", .{month_end}); try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color); if (!has_divs) { diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 5697eae..cdea142 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -228,10 +228,8 @@ pub fn display( var date_col_len: usize = 0; if (!is_multi and lots_for_sym.items.len == 1) { const lot = lots_for_sym.items[0]; - var pos_date_buf: [10]u8 = undefined; - const ds = lot.open_date.format(&pos_date_buf); const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date); - const written = std.fmt.bufPrint(&date_col, "{s} {s}", .{ ds, indicator }) catch ""; + const written = std.fmt.bufPrint(&date_col, "{f} {s}", .{ lot.open_date, indicator }) catch ""; date_col_len = written.len; } @@ -492,8 +490,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_date_buf: [10]u8 = undefined; - const date_str = lot.open_date.format(&lot_date_buf); const indicator = fmt.capitalGainsIndicator(as_of, lot.open_date); const status_str: []const u8 = if (lot.isOpen(as_of)) "open" else "closed"; const acct_col: []const u8 = lot.account orelse ""; @@ -507,7 +503,7 @@ pub fn printLotRow(as_of: zfin.Date, out: *std.Io.Writer, color: bool, lot: zfin 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}{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 }); + try cli.printFg(out, color, cli.CLR_MUTED, " {s:>8} {f} {s} {s}\n", .{ "", lot.open_date, indicator, acct_col }); } // ── Tests ──────────────────────────────────────────────────── diff --git a/src/commands/projections.zig b/src/commands/projections.zig index d6c7fc7..7b9c6bc 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -145,8 +145,7 @@ pub fn run( try out.print("\n", .{}); if (resolution) |r| { - var buf: [10]u8 = undefined; - try cli.printBold(out, color, "Projections (as of {s})\n", .{r.actual.format(&buf)}); + try cli.printBold(out, color, "Projections (as of {f})\n", .{r.actual}); } else { try cli.printBold(out, color, "Projections ({s})\n", .{file_path}); } @@ -157,11 +156,9 @@ pub fn run( if (resolution) |r| { if (r.actual.days != r.requested.days) { const diff = r.requested.days - r.actual.days; - var req_buf: [10]u8 = undefined; - var act_buf: [10]u8 = undefined; - try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s}; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ - r.requested.format(&req_buf), - r.actual.format(&act_buf), + try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f}; nearest snapshot: {f}, {d} day{s} earlier)\n", .{ + r.requested, + r.actual, diff, fmt.dayPlural(diff), }); @@ -414,8 +411,8 @@ pub fn runCompare( try out.print("\n", .{}); var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; - const then_str = result.resolution.actual.format(&then_buf); - const now_str = if (result.now_resolution) |nr| nr.actual.format(&now_buf) else "today"; + const then_str = std.fmt.bufPrint(&then_buf, "{f}", .{result.resolution.actual}) catch "????-??-??"; + const now_str = if (result.now_resolution) |nr| (std.fmt.bufPrint(&now_buf, "{f}", .{nr.actual}) catch "????-??-??") else "today"; const days_between = if (result.now_resolution) |nr| nr.actual.days - result.resolution.actual.days else @@ -431,9 +428,8 @@ pub fn runCompare( // Snap notes for either endpoint, if applicable. if (result.resolution.actual.days != result.resolution.requested.days) { const diff = result.resolution.requested.days - result.resolution.actual.days; - var req_buf: [10]u8 = undefined; - try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ - result.resolution.requested.format(&req_buf), + try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for then; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ + result.resolution.requested, then_str, diff, if (diff == 1) "" else "s", @@ -442,9 +438,8 @@ pub fn runCompare( if (result.now_resolution) |nr| { if (nr.actual.days != nr.requested.days) { const diff = nr.requested.days - nr.actual.days; - var req_buf: [10]u8 = undefined; - try cli.printFg(out, color, cli.CLR_MUTED, "(requested {s} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ - nr.requested.format(&req_buf), + try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f} for now; nearest snapshot: {s}, {d} day{s} earlier)\n", .{ + nr.requested, now_str, diff, if (diff == 1) "" else "s", diff --git a/src/commands/quote.zig b/src/commands/quote.zig index a84ce02..5aa1f26 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -73,9 +73,8 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote const low_val = if (quote) |q| q.low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0); const vol_val = if (quote) |q| q.volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0); - var date_buf: [10]u8 = undefined; var vol_buf: [32]u8 = undefined; - try out.print(" Date: {s}\n", .{latest_date.format(&date_buf)}); + try out.print(" Date: {f}\n", .{latest_date}); try out.print(" Open: ${d:.2}\n", .{open_val}); try out.print(" High: ${d:.2}\n", .{high_val}); try out.print(" Low: ${d:.2}\n", .{low_val}); diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 39e7878..e7c248e 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -37,7 +37,7 @@ const fmt = @import("../format.zig"); const atomic = @import("../atomic.zig"); const version = @import("../version.zig"); const portfolio_mod = @import("../models/portfolio.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const model = @import("../models/snapshot.zig"); const git = @import("../git.zig"); @@ -158,7 +158,7 @@ pub fn run( if (!force and out_override == null and as_of_override == null) { if (try probeFreshAsOfDate(allocator, svc, syms)) |candidate| { var cand_buf: [10]u8 = undefined; - const cand_str = candidate.format(&cand_buf); + const cand_str = std.fmt.bufPrint(&cand_buf, "{f}", .{candidate}) catch "????-??-??"; const candidate_path = try deriveSnapshotPath(allocator, portfolio_path, cand_str); defer allocator.free(candidate_path); if (std.Io.Dir.cwd().access(io, candidate_path, .{})) |_| { @@ -216,12 +216,11 @@ pub fn run( // Not applied in auto mode: auto mode's as_of already comes from // cache mode and is guaranteed to be a trading day. if (as_of_override != null and !hasAnyTradingDayCandle(svc, syms, as_of)) { - var date_buf: [10]u8 = undefined; var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &msg_buf, - "skipping {s}: no market data (weekend or holiday)\n", - .{as_of.format(&date_buf)}, + "skipping {f}: no market data (weekend or holiday)\n", + .{as_of}, ) catch "skipping non-trading day\n"; try cli.stderrPrint(io, msg); return; @@ -264,7 +263,7 @@ pub fn run( // Derive output path. var as_of_buf: [10]u8 = undefined; - const as_of_str = as_of.format(&as_of_buf); + const as_of_str = std.fmt.bufPrint(&as_of_buf, "{f}", .{as_of}) catch "????-??-??"; const derived_path = if (out_override) |p| try allocator.dupe(u8, p) @@ -372,12 +371,11 @@ fn loadPortfolioAtDate( if (loadPortfolioFromGit(io, allocator, portfolio_path, target)) |bytes| return bytes else |err| switch (err) { error.NotInGitRepo, error.GitUnavailable, error.PathMissingInRev, error.UnknownRevision, error.NoCommitBeforeDate => { // Fall through to working-copy fallback below. - var date_buf: [10]u8 = undefined; var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, - "warning: no git history for portfolio at {s}; using working copy as approximation\n", - .{target.format(&date_buf)}, + "warning: no git history for portfolio at {f}; using working copy as approximation\n", + .{target}, ) catch "warning: no git history for portfolio at requested date\n"; try cli.stderrPrint(io, msg); }, diff --git a/src/commands/splits.zig b/src/commands/splits.zig index cec6eb8..62558c4 100644 --- a/src/commands/splits.zig +++ b/src/commands/splits.zig @@ -36,8 +36,7 @@ pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out: try cli.reset(out, color); for (splits) |s| { - var db: [10]u8 = undefined; - try out.print("{s:>12} {d:.0}:{d:.0}\n", .{ s.date.format(&db), s.numerator, s.denominator }); + try out.print("{f} {d:.0}:{d:.0}\n", .{ s.date.padLeft(12), s.numerator, s.denominator }); } try out.print("\n{d} split(s)\n\n", .{splits.len}); } diff --git a/src/commands/version.zig b/src/commands/version.zig index 1174114..3429b5f 100644 --- a/src/commands/version.zig +++ b/src/commands/version.zig @@ -12,7 +12,7 @@ const builtin = @import("builtin"); const zfin = @import("../root.zig"); const version = @import("../version.zig"); const cli = @import("common.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); /// Run the version command. /// @@ -56,7 +56,7 @@ pub fn writeVersion( const build_date_buf = blk: { var buf: [10]u8 = undefined; const d = Date.fromEpoch(version.build_timestamp); - const s = d.format(&buf); + const s = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable; break :blk .{ .buf = buf, .len = s.len }; }; const build_date = build_date_buf.buf[0..build_date_buf.len]; @@ -209,6 +209,6 @@ test "Date.fromEpoch round-trip for build timestamp" { const ts: i64 = 1_745_222_400; // 2025-04-21 00:00 UTC const d = Date.fromEpoch(ts); var buf: [10]u8 = undefined; - const s = d.format(&buf); + const s = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable; try std.testing.expectEqualStrings("2025-04-21", s); } diff --git a/src/data/imported_values.zig b/src/data/imported_values.zig index d182388..16d2fb3 100644 --- a/src/data/imported_values.zig +++ b/src/data/imported_values.zig @@ -35,7 +35,7 @@ const std = @import("std"); const srf = @import("srf"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); // ── Types ──────────────────────────────────────────────────── diff --git a/src/data/shiller.zig b/src/data/shiller.zig index b919ad9..600d8cc 100644 --- a/src/data/shiller.zig +++ b/src/data/shiller.zig @@ -12,7 +12,7 @@ /// /// All returns are nominal, expressed as decimals (0.12 = 12%). const std = @import("std"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const generated = @import("shiller_generated"); pub const ShillerYear = @import("shiller_year").ShillerYear; diff --git a/src/data/staleness.zig b/src/data/staleness.zig index 53b6c08..5f035cb 100644 --- a/src/data/staleness.zig +++ b/src/data/staleness.zig @@ -33,7 +33,7 @@ //! comment (or a `TODO` at the top). const std = @import("std"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const risk = @import("../analytics/risk.zig"); const shiller = @import("shiller.zig"); @@ -95,16 +95,11 @@ pub fn check( if (wrote_any) try writer.writeAll("\n"); wrote_any = true; - var due_buf: [10]u8 = undefined; - var upd_buf: [10]u8 = undefined; - const due_str = this_years_due.format(&due_buf); - const upd_str = entry.last_updated.format(&upd_buf); - try writer.print( "warning: {s} is overdue for refresh.\n" ++ - " Expected by {s}; last updated {s}.\n" ++ + " Expected by {f}; last updated {f}.\n" ++ " See {s} for refresh instructions.\n", - .{ entry.name, due_str, upd_str, entry.source_file }, + .{ entry.name, this_years_due, entry.last_updated, entry.source_file }, ); } } diff --git a/src/format.zig b/src/format.zig index 9229959..b1a3f48 100644 --- a/src/format.zig +++ b/src/format.zig @@ -4,7 +4,7 @@ //! (capitalGainsIndicator, filterNearMoney), and braille chart computation. const std = @import("std"); -const Date = @import("models/date.zig").Date; +const Date = @import("Date.zig"); const Money = @import("Money.zig"); const Candle = @import("models/candle.zig").Candle; const Lot = @import("models/portfolio.zig").Lot; @@ -519,8 +519,14 @@ pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation { pub fn fmtDripSummary(buf: []u8, label: []const u8, summary: DripSummary) []const u8 { 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 "?"; + const d1: []const u8 = if (summary.first_date) |d| blk: { + const s = std.fmt.bufPrint(&d1_buf, "{f}", .{d}) catch break :blk "?"; + break :blk s[0..7]; + } else "?"; + const d2: []const u8 = if (summary.last_date) |d| blk: { + const s = std.fmt.bufPrint(&d2_buf, "{f}", .{d}) catch break :blk "?"; + break :blk s[0..7]; + } else "?"; return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {f} ({s} to {s})", .{ label, summary.lot_count, @@ -564,7 +570,7 @@ pub fn fmtEarningsRow(buf: []u8, e: EarningsEvent) EarningsRowResult { const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; var db: [10]u8 = undefined; - const date_str = e.date.format(&db); + const date_str = std.fmt.bufPrint(&db, "{f}", .{e.date}) catch "????-??-??"; var q_buf: [4]u8 = undefined; const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--"; @@ -686,10 +692,9 @@ pub fn fmtHistoricalChange(buf: []u8, snap_count: usize, pct: f64) []const u8 { /// Format a candle as a fixed-width row: " YYYY-MM-DD 150.00 155.00 149.00 153.00 50,000,000" pub fn fmtCandleRow(buf: []u8, candle: Candle) []const u8 { - var db: [10]u8 = undefined; var vb: [32]u8 = undefined; - return std.fmt.bufPrint(buf, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume), + return std.fmt.bufPrint(buf, " {f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{ + candle.date.padLeft(12), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume), }) catch ""; } diff --git a/src/git.zig b/src/git.zig index 731145c..6dbb0b5 100644 --- a/src/git.zig +++ b/src/git.zig @@ -15,7 +15,7 @@ //! than inlining `std.process.Child.run` in the command module. const std = @import("std"); -const Date = @import("models/date.zig").Date; +const Date = @import("Date.zig"); // ── Types ──────────────────────────────────────────────────── @@ -521,7 +521,7 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: Commi .git_ref => |r| r, .date_at_or_before => |d| blk: { var buf: [10]u8 = undefined; - const date_str = d.format(&buf); + const date_str = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable; const sha = (try commitAtOrBeforeDate(io, arena, repo.root, repo.rel_path, date_str)) orelse return error.NoCommitAtOrBefore; break :blk sha; diff --git a/src/history.zig b/src/history.zig index cee9ea8..28e54e5 100644 --- a/src/history.zig +++ b/src/history.zig @@ -33,7 +33,7 @@ const std = @import("std"); const builtin = @import("builtin"); const srf = @import("srf"); const snapshot = @import("models/snapshot.zig"); -const Date = @import("models/date.zig").Date; +const Date = @import("Date.zig"); const Candle = @import("models/candle.zig").Candle; const timeline = @import("analytics/timeline.zig"); const valuation = @import("analytics/valuation.zig"); @@ -337,7 +337,7 @@ pub fn loadSnapshotAt( target: Date, ) !LoadedSnapshot { var date_buf: [10]u8 = undefined; - const date_str = target.format(&date_buf); + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch unreachable; const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix }); defer allocator.free(filename); const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename }); @@ -436,7 +436,7 @@ pub fn resolveSnapshotDate( requested: Date, ) ResolveSnapshotError!ResolvedSnapshot { var date_buf: [10]u8 = undefined; - const date_str = requested.format(&date_buf); + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch unreachable; const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix }); const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename }); diff --git a/src/main.zig b/src/main.zig index b393a9f..ea07688 100644 --- a/src/main.zig +++ b/src/main.zig @@ -291,7 +291,7 @@ fn runCli(init: std.process.Init) !u8 { // // wall-clock required: the one legitimate Timestamp.now() call in // main dispatch — everything downstream takes now_s / today. - const Date = @import("models/date.zig").Date; + const Date = @import("Date.zig"); const now_s = std.Io.Timestamp.now(io, .real).toSeconds(); const today = Date.fromEpoch(now_s); diff --git a/src/models/candle.zig b/src/models/candle.zig index e7a2193..508fbea 100644 --- a/src/models/candle.zig +++ b/src/models/candle.zig @@ -1,4 +1,4 @@ -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); /// A single OHLCV bar, normalized from any provider. pub const Candle = struct { diff --git a/src/models/dividend.zig b/src/models/dividend.zig index 85ba695..51ee84b 100644 --- a/src/models/dividend.zig +++ b/src/models/dividend.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); pub const DividendType = enum { regular, diff --git a/src/models/earnings.zig b/src/models/earnings.zig index 81b0e8a..1da77e4 100644 --- a/src/models/earnings.zig +++ b/src/models/earnings.zig @@ -1,4 +1,4 @@ -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); pub const ReportTime = enum { bmo, // before market open diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig index e1c42b2..259d89a 100644 --- a/src/models/etf_profile.zig +++ b/src/models/etf_profile.zig @@ -1,4 +1,4 @@ -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); /// Top holding in an ETF. pub const Holding = struct { diff --git a/src/models/option.zig b/src/models/option.zig index 9231bcc..1b79b5f 100644 --- a/src/models/option.zig +++ b/src/models/option.zig @@ -1,4 +1,4 @@ -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); pub const ContractType = enum { call, diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index d782695..cd9721c 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); const Candle = @import("candle.zig").Candle; // ── Pricing model ──────────────────────────────────────────── diff --git a/src/models/snapshot.zig b/src/models/snapshot.zig index 720c9ee..aa691c7 100644 --- a/src/models/snapshot.zig +++ b/src/models/snapshot.zig @@ -32,7 +32,7 @@ //! that's the behavior we want for `price`, `quote_date`, etc. const std = @import("std"); -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); pub const MetaRow = struct { kind: []const u8 = "", diff --git a/src/models/split.zig b/src/models/split.zig index 5de632a..5833ac5 100644 --- a/src/models/split.zig +++ b/src/models/split.zig @@ -1,4 +1,4 @@ -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); /// A stock split event. pub const Split = struct { diff --git a/src/models/transaction_log.zig b/src/models/transaction_log.zig index 511bb16..e41ba3b 100644 --- a/src/models/transaction_log.zig +++ b/src/models/transaction_log.zig @@ -57,7 +57,7 @@ const std = @import("std"); const builtin = @import("builtin"); const srf = @import("srf"); -const Date = @import("date.zig").Date; +const Date = @import("../Date.zig"); const logger = std.log.scoped(.transaction_log); @@ -115,7 +115,7 @@ pub const DestLot = union(enum) { defer allocator.free(buf); var out = try allocator.alloc(u8, buf.len + 10); @memcpy(out[0..buf.len], buf); - _ = l.open_date.format(out[buf.len..][0..10]); + _ = std.fmt.bufPrint(out[buf.len..][0..10], "{f}", .{l.open_date}) catch unreachable; break :blk .{ .string = out }; }, }; diff --git a/src/padded.zig b/src/padded.zig new file mode 100644 index 0000000..edfd181 --- /dev/null +++ b/src/padded.zig @@ -0,0 +1,187 @@ +//! Generic padding wrapper for any type with a `format(self, *Writer)` +//! method. +//! +//! Wraps an inner value plus a target width and an alignment side. +//! Its own `format` method renders the inner value into a stack +//! buffer to measure its length, then writes the padded output to +//! the caller's writer. +//! +//! Comptime instantiation per inner type is essentially free: the +//! compiler emits one body per `Padded(T)` actually used, with the +//! inner `format` call inlined. +//! +//! Used by `Money.padRight`/`padLeft` and `Date.padRight`/`padLeft`, +//! plus any future format-bearing type that needs column alignment. + +const std = @import("std"); + +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 covers + // every realistic format-method output in this codebase + // (Money's worst case is ~14 chars; Date is exactly 10). + 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); + }, + } + } + }; +} + +// ── Tests ────────────────────────────────────────────────────── + +const testing = std.testing; + +/// Simple type with a format method, used to verify Padded(T) works +/// for arbitrary inner types (not just Money / Date). +const Tag = struct { + label: []const u8, + + pub fn format(self: Tag, w: *std.Io.Writer) std.Io.Writer.Error!void { + try w.writeAll(self.label); + } +}; + +/// Helper: render `value` via `{f}` and return an owned string. Caller frees. +fn renderAlloc(value: anytype) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try aw.writer.print("{f}", .{value}); + return aw.toOwnedSlice(); +} + +test "Padded right-aligns shorter inner" { + const padded: Padded(Tag) = .{ .inner = .{ .label = "x" }, .width = 5, .alignment = .right }; + const s = try renderAlloc(padded); + defer testing.allocator.free(s); + try testing.expectEqualStrings(" x", s); +} + +test "Padded left-aligns shorter inner" { + const padded: Padded(Tag) = .{ .inner = .{ .label = "x" }, .width = 5, .alignment = .left }; + const s = try renderAlloc(padded); + defer testing.allocator.free(s); + try testing.expectEqualStrings("x ", s); +} + +test "Padded leaves inner unchanged when wider than width" { + const padded: Padded(Tag) = .{ .inner = .{ .label = "abcdef" }, .width = 3, .alignment = .right }; + const s = try renderAlloc(padded); + defer testing.allocator.free(s); + try testing.expectEqualStrings("abcdef", s); +} + +test "Padded equal width emits exactly the inner with no padding" { + const padded: Padded(Tag) = .{ .inner = .{ .label = "abc" }, .width = 3, .alignment = .right }; + const s = try renderAlloc(padded); + defer testing.allocator.free(s); + try testing.expectEqualStrings("abc", s); +} + +test "Padded width=0 emits inner unchanged regardless of alignment" { + const right: Padded(Tag) = .{ .inner = .{ .label = "abc" }, .width = 0, .alignment = .right }; + const left: Padded(Tag) = .{ .inner = .{ .label = "abc" }, .width = 0, .alignment = .left }; + const sr = try renderAlloc(right); + defer testing.allocator.free(sr); + const sl = try renderAlloc(left); + defer testing.allocator.free(sl); + try testing.expectEqualStrings("abc", sr); + try testing.expectEqualStrings("abc", sl); +} + +test "Padded with empty inner emits pure spaces" { + const right: Padded(Tag) = .{ .inner = .{ .label = "" }, .width = 4, .alignment = .right }; + const left: Padded(Tag) = .{ .inner = .{ .label = "" }, .width = 4, .alignment = .left }; + const sr = try renderAlloc(right); + defer testing.allocator.free(sr); + const sl = try renderAlloc(left); + defer testing.allocator.free(sl); + try testing.expectEqualStrings(" ", sr); + try testing.expectEqualStrings(" ", sl); +} + +test "Padded composes inside a larger format string" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + const left: Padded(Tag) = .{ .inner = .{ .label = "id" }, .width = 4, .alignment = .left }; + const right: Padded(Tag) = .{ .inner = .{ .label = "42" }, .width = 4, .alignment = .right }; + try aw.writer.print("[{f}|{f}]", .{ left, right }); + const s = try aw.toOwnedSlice(); + defer testing.allocator.free(s); + try testing.expectEqualStrings("[id | 42]", s); +} + +test "Padded counts bytes, not codepoints (multi-byte content overflows visually)" { + // "é" is 2 bytes in UTF-8. With width=3 and 2-byte content, only + // 1 byte of padding is added — correct for fixed-width terminal + // output where the user supplies a column count, but worth pinning + // so a future "fix" using grapheme width doesn't silently change + // existing alignment. + const padded: Padded(Tag) = .{ .inner = .{ .label = "é" }, .width = 3, .alignment = .right }; + const s = try renderAlloc(padded); + defer testing.allocator.free(s); + try testing.expectEqual(@as(usize, 3), s.len); + try testing.expectEqualStrings(" é", s); +} + +test "Padded works with Date.padLeft / Date.padRight constructors" { + const Date = @import("Date.zig"); + const d = Date.fromYmd(2024, 1, 5); + + const right_s = try renderAlloc(d.padRight(12)); + defer testing.allocator.free(right_s); + try testing.expectEqualStrings(" 2024-01-05", right_s); + + const left_s = try renderAlloc(d.padLeft(12)); + defer testing.allocator.free(left_s); + try testing.expectEqualStrings("2024-01-05 ", left_s); +} + +test "Padded works with Money.padLeft / Money.padRight constructors" { + const Money = @import("Money.zig"); + const m = Money.from(1234.5); + + const right_s = try renderAlloc(m.padRight(12)); + defer testing.allocator.free(right_s); + try testing.expectEqualStrings(" $1,234.50", right_s); + + const left_s = try renderAlloc(m.padLeft(12)); + defer testing.allocator.free(left_s); + try testing.expectEqualStrings("$1,234.50 ", left_s); +} + +test "Padded composes with Money variants (whole / trim / signed)" { + const Money = @import("Money.zig"); + + const whole = try renderAlloc(Money.from(1234.0).whole().padRight(10)); + defer testing.allocator.free(whole); + try testing.expectEqualStrings(" $1,234", whole); + + const trim = try renderAlloc(Money.from(1234.0).trim().padRight(10)); + defer testing.allocator.free(trim); + try testing.expectEqualStrings(" $1,234", trim); + + const signed_pos = try renderAlloc(Money.from(50.0).signed().padRight(10)); + defer testing.allocator.free(signed_pos); + try testing.expectEqualStrings(" +$50.00", signed_pos); +} diff --git a/src/providers/alphavantage.zig b/src/providers/alphavantage.zig index 77dbae8..1a96c7d 100644 --- a/src/providers/alphavantage.zig +++ b/src/providers/alphavantage.zig @@ -9,7 +9,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; const Holding = @import("../models/etf_profile.zig").Holding; const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; diff --git a/src/providers/cboe.zig b/src/providers/cboe.zig index f6ff13d..8132a6b 100644 --- a/src/providers/cboe.zig +++ b/src/providers/cboe.zig @@ -7,7 +7,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const OptionContract = @import("../models/option.zig").OptionContract; const OptionsChain = @import("../models/option.zig").OptionsChain; const ContractType = @import("../models/option.zig").ContractType; diff --git a/src/providers/fmp.zig b/src/providers/fmp.zig index 60d6f53..86ebec0 100644 --- a/src/providers/fmp.zig +++ b/src/providers/fmp.zig @@ -30,7 +30,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; const json_utils = @import("json_utils.zig"); const optFloat = json_utils.optFloat; diff --git a/src/providers/polygon.zig b/src/providers/polygon.zig index 52f3584..0a7bf66 100644 --- a/src/providers/polygon.zig +++ b/src/providers/polygon.zig @@ -7,7 +7,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Dividend = @import("../models/dividend.zig").Dividend; const DividendType = @import("../models/dividend.zig").DividendType; const Split = @import("../models/split.zig").Split; @@ -72,11 +72,11 @@ pub const Polygon = struct { var to_buf: [10]u8 = undefined; if (from) |f| { - params[n] = .{ "ex_dividend_date.gte", f.format(&from_buf) }; + params[n] = .{ "ex_dividend_date.gte", std.fmt.bufPrint(&from_buf, "{f}", .{f}) catch unreachable }; n += 1; } if (to) |t| { - params[n] = .{ "ex_dividend_date.lte", t.format(&to_buf) }; + params[n] = .{ "ex_dividend_date.lte", std.fmt.bufPrint(&to_buf, "{f}", .{t}) catch unreachable }; n += 1; } diff --git a/src/providers/tiingo.zig b/src/providers/tiingo.zig index aed9c6f..3be083a 100644 --- a/src/providers/tiingo.zig +++ b/src/providers/tiingo.zig @@ -8,7 +8,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const json_utils = @import("json_utils.zig"); const optFloat = json_utils.optFloat; @@ -44,8 +44,8 @@ pub const Tiingo = struct { ) ![]Candle { var from_buf: [10]u8 = undefined; var to_buf: [10]u8 = undefined; - const from_str = from.format(&from_buf); - const to_str = to.format(&to_buf); + const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable; + const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable; const symbol_url = try std.fmt.allocPrint(allocator, base_url ++ "/{s}/prices", .{symbol}); defer allocator.free(symbol_url); diff --git a/src/providers/twelvedata.zig b/src/providers/twelvedata.zig index 5306f8c..576092b 100644 --- a/src/providers/twelvedata.zig +++ b/src/providers/twelvedata.zig @@ -10,7 +10,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); const RateLimiter = @import("../net/RateLimiter.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Quote = @import("../models/quote.zig").Quote; const json_utils = @import("json_utils.zig"); @@ -52,8 +52,8 @@ pub const TwelveData = struct { var from_buf: [10]u8 = undefined; var to_buf: [10]u8 = undefined; - const from_str = from.format(&from_buf); - const to_str = to.format(&to_buf); + const from_str = std.fmt.bufPrint(&from_buf, "{f}", .{from}) catch unreachable; + const to_str = std.fmt.bufPrint(&to_buf, "{f}", .{to}) catch unreachable; // TwelveData's max outputsize is 5000 data points per request. // For daily candles this covers ~20 years of trading days (~252/year), diff --git a/src/providers/yahoo.zig b/src/providers/yahoo.zig index 0b682e4..5faf6e9 100644 --- a/src/providers/yahoo.zig +++ b/src/providers/yahoo.zig @@ -9,7 +9,7 @@ const std = @import("std"); const http = @import("../net/http.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Candle = @import("../models/candle.zig").Candle; const Quote = @import("../models/quote.zig").Quote; const parseJsonFloat = @import("json_utils.zig").parseJsonFloat; diff --git a/src/root.zig b/src/root.zig index c641569..23004c5 100644 --- a/src/root.zig +++ b/src/root.zig @@ -22,7 +22,7 @@ // ── Data Models ────────────────────────────────────────────── /// Calendar date with financial-market helpers (trading days, expiry rules). -pub const Date = @import("models/date.zig").Date; +pub const Date = @import("Date.zig"); /// OHLCV price bar (open, high, low, close, volume) for a single trading day. pub const Candle = @import("models/candle.zig").Candle; diff --git a/src/service.zig b/src/service.zig index 1a4c809..9a0bbb8 100644 --- a/src/service.zig +++ b/src/service.zig @@ -11,7 +11,7 @@ const std = @import("std"); const builtin = @import("builtin"); const log = std.log.scoped(.service); -const Date = @import("models/date.zig").Date; +const Date = @import("Date.zig"); const Candle = @import("models/candle.zig").Candle; const Dividend = @import("models/dividend.zig").Dividend; const Split = @import("models/split.zig").Split; diff --git a/src/tui.zig b/src/tui.zig index 897b5f8..93e3a6f 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -845,10 +845,8 @@ pub const App = struct { } self.projections_as_of = d; self.projections_as_of_requested = null; - var date_buf: [10]u8 = undefined; var status_buf: [64]u8 = undefined; - const date_str = d.format(&date_buf); - const msg = std.fmt.bufPrint(&status_buf, "As-of: {s}", .{date_str}) catch "As-of set"; + const msg = std.fmt.bufPrint(&status_buf, "As-of: {f}", .{d}) catch "As-of set"; self.setStatus(msg); } else { // `null` parse result = live. diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 8cf44a7..48a4bce 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -1166,9 +1166,7 @@ fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const .{ lead, lbl }, ) catch lbl; } else blk: { - var iso_buf: [10]u8 = undefined; - const iso = row.date.format(&iso_buf); - break :blk std.fmt.bufPrint(&date_buf, " {s}", .{iso}) catch iso; + break :blk std.fmt.bufPrint(&date_buf, " {f}", .{row.date}) catch "????-??-??"; }; const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width); @@ -1247,7 +1245,7 @@ pub fn renderCompareLines( // ── Header ── var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; - const then_iso = cv.then_date.format(&then_buf); + const then_iso = std.fmt.bufPrint(&then_buf, "{f}", .{cv.then_date}) catch "????-??-??"; const now_iso = compare_view.nowLabel(cv, &now_buf); // Prefer bucketed labels (e.g. "Q1 2025 (ended 2025-03-28)") diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 58b2dfd..da7bd66 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -83,13 +83,12 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine .expiration => { if (row.exp_idx < chains.len) { const chain = chains[row.exp_idx]; - var db: [10]u8 = undefined; const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx]; const is_monthly = fmt.isMonthlyExpiration(chain.expiration); const arrow: []const u8 = if (is_expanded) "v " else "> "; - const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{ + const text = try std.fmt.allocPrint(arena, " {s}{f} ({d} calls, {d} puts)", .{ arrow, - chain.expiration.format(&db), + chain.expiration, chain.calls.len, chain.puts.len, }); diff --git a/src/tui/perf_tab.zig b/src/tui/perf_tab.zig index 3a62394..393c9b7 100644 --- a/src/tui/perf_tab.zig +++ b/src/tui/perf_tab.zig @@ -83,8 +83,7 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine } if (app.candle_last_date) |d| { - var pdate_buf: [10]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ app.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() }); } @@ -98,10 +97,8 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine if (app.candle_count > 0) { if (app.candle_first_date) |first| { if (app.candle_last_date) |last| { - var fb: [10]u8 = undefined; - var lb: [10]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{ - app.candle_count, first.format(&fb), last.format(&lb), + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({f} to {f})", .{ + app.candle_count, first, last, }), .style = th.mutedStyle() }); } } @@ -116,18 +113,16 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine const has_total = app.trailing_total != null; if (app.candle_last_date) |last| { - var db: [10]u8 = undefined; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {f}:", .{last}), .style = th.headerStyle() }); } try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th); { const today = app.today; const month_end = today.lastDayOfPriorMonth(); - var db: [10]u8 = undefined; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({f}):", .{month_end}), .style = th.headerStyle() }); } if (app.trailing_me_price) |me_price| { try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 3ccba0b..223d123 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -799,8 +799,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width try lines.append(arena, .{ .text = summary_text, .style = summary_style }); if (app.candle_last_date) |d| { - var asof_buf: [10]u8 = undefined; - const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)}); + const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); } // No historical snapshots or net worth when filtered @@ -819,8 +818,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width // "as of" date indicator if (app.candle_last_date) |d| { - var asof_buf: [10]u8 = undefined; - const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)}); + const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); } @@ -946,7 +944,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width for (pf.lots) |lot| { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (matchesAccountFilter(app, lot.account)) { - const ds = lot.open_date.format(&pos_date_buf); + const ds = std.fmt.bufPrint(&pos_date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; const indicator = fmt.capitalGainsIndicator(app.today, lot.open_date); date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; acct_col = lot.account orelse ""; @@ -988,7 +986,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width .lot => { if (row.lot) |lot| { var date_buf: [10]u8 = undefined; - const date_str = lot.open_date.format(&date_buf); + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{lot.open_date}) catch "????-??-??"; // Compute lot gain/loss and market value if we have a price var lot_gl_str: []const u8 = ""; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index b49afcc..a85d9c8 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -158,10 +158,8 @@ fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Da const resolved = history.resolveSnapshotDate(app.io, arena, hist_dir, requested) catch |err| switch (err) { error.NoSnapshotAtOrBefore => { - var date_buf: [10]u8 = undefined; var status_buf: [128]u8 = undefined; - const date_str = requested.format(&date_buf); - const msg = std.fmt.bufPrint(&status_buf, "No snapshot at or before {s}", .{date_str}) catch "No snapshot at or before requested date"; + const msg = std.fmt.bufPrint(&status_buf, "No snapshot at or before {f}", .{requested}) catch "No snapshot at or before requested date"; app.setStatus(msg); return null; }, @@ -816,20 +814,16 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine // with the main content. If the user asked for a date that had no // exact snapshot, a second muted line explains the auto-snap. if (app.projections_as_of) |actual| { - var actual_buf: [10]u8 = undefined; - const actual_str = actual.format(&actual_buf); - const header = try std.fmt.allocPrint(arena, " As-of: {s} (snapshot)", .{actual_str}); + const header = try std.fmt.allocPrint(arena, " As-of: {f} (snapshot)", .{actual}); try lines.append(arena, .{ .text = header, .style = th.mutedStyle() }); if (app.projections_as_of_requested) |requested| { if (!requested.eql(actual)) { - var req_buf: [10]u8 = undefined; - const req_str = requested.format(&req_buf); const diff = requested.days - actual.days; const note = try std.fmt.allocPrint( arena, - " (requested {s}; snapped back {d} day{s})", - .{ req_str, diff, fmt.dayPlural(diff) }, + " (requested {f}; snapped back {d} day{s})", + .{ requested, diff, fmt.dayPlural(diff) }, ); try lines.append(arena, .{ .text = note, .style = th.mutedStyle() }); } diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index 179374d..539549c 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -374,8 +374,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine { const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp, now_s); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() }); } else if (app.candle_last_date) |d| { - var cdate_buf: [10]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ app.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {f})", .{ app.symbol, d }), .style = th.headerStyle() }); } else { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() }); } @@ -466,13 +465,12 @@ fn buildDetailColumns( prev_close: f64, ) !void { const th = app.theme; - var date_buf: [10]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, " Date: {f}", .{latest.date}), 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()); diff --git a/src/views/compare.zig b/src/views/compare.zig index ed9bb5e..73bbb80 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -49,7 +49,7 @@ const std = @import("std"); const fmt = @import("../format.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const Money = @import("../Money.zig"); const timeline = @import("../analytics/timeline.zig"); const view_hist = @import("history.zig"); @@ -201,7 +201,7 @@ pub fn buildBucketLabel( if (t == .daily) return null; var iso_buf: [10]u8 = undefined; - const iso = date.format(&iso_buf); + const iso = std.fmt.bufPrint(&iso_buf, "{f}", .{date}) catch "????-??-??"; // No bucket origin → return ISO alone (caller may have wanted // a label for some surface-specific reason; better than null @@ -832,7 +832,7 @@ pub fn buildTotalsCells( /// date case; caller must keep it alive. pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 { if (cv.now_is_live) return "today"; - return cv.now_date.format(buf); + return std.fmt.bufPrint(buf, "{f}", .{cv.now_date}) catch "????-??-??"; } /// Re-export of `format.dayPlural` so callers keep a single import. diff --git a/src/views/history.zig b/src/views/history.zig index 582bf77..b673397 100644 --- a/src/views/history.zig +++ b/src/views/history.zig @@ -254,7 +254,7 @@ pub fn fmtResolutionLabel( // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); fn makeWindowStat( period: ?@import("../analytics/valuation.zig").HistoricalPeriod, diff --git a/src/views/portfolio_sections.zig b/src/views/portfolio_sections.zig index 9cd3e79..26ea11a 100644 --- a/src/views/portfolio_sections.zig +++ b/src/views/portfolio_sections.zig @@ -6,7 +6,7 @@ const std = @import("std"); const Lot = @import("../models/portfolio.zig").Lot; -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); @@ -190,7 +190,7 @@ pub const CDs = struct { var face_buf: [24]u8 = undefined; var mat_buf: [10]u8 = undefined; - const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--"; + const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--"; var rate_buf: [10]u8 = undefined; const rate_str: []const u8 = if (lot.rate) |r| std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--" diff --git a/src/views/projections.zig b/src/views/projections.zig index d95f489..3a1d488 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -12,7 +12,7 @@ const valuation = @import("../analytics/valuation.zig"); const zfin = @import("../root.zig"); const snapshot_model = @import("../models/snapshot.zig"); const history = @import("../history.zig"); -const Date = @import("../models/date.zig").Date; +const Date = @import("../Date.zig"); pub const StyleIntent = fmt.StyleIntent; @@ -673,7 +673,7 @@ pub fn splitRetirementLine(buf: []u8, resolved: projections.ResolvedRetirement, } var date_buf: [10]u8 = undefined; - const date_str = if (resolved.date) |d| d.format(&date_buf) else "????-??-??"; + const date_str = if (resolved.date) |d| (std.fmt.bufPrint(&date_buf, "{f}", .{d}) catch "????-??-??") else "????-??-??"; // Build the optional ", ages A/B/..." suffix when birthdates are // configured and we have a retirement date. Skipping is safe and @@ -787,7 +787,7 @@ pub fn fmtEarliestCell(arena: std.mem.Allocator, er: projections.EarliestRetirem // "N years from the reference date". const ret_date = Date.fromYmd(as_of.year() + @as(i16, @intCast(n)), as_of.month(), as_of.day()); var dbuf: [10]u8 = undefined; - const dstr = ret_date.format(&dbuf); + const dstr = std.fmt.bufPrint(&dbuf, "{f}", .{ret_date}) catch "????-??-??"; return .{ .text = try arena.dupe(u8, dstr), .style = .normal }; }