land date refactor (similar to money)

This commit is contained in:
Emil Lerch 2026-05-12 21:40:32 -07:00
parent e62eb5f0a7
commit 0987a61634
Signed by: lobo
GPG key ID: A7B62D657EF764F8
67 changed files with 665 additions and 488 deletions

View file

@ -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

View file

@ -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);
}

View file

@ -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

View file

@ -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 {

View file

@ -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 .{

View file

@ -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);

View file

@ -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

View file

@ -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");

View file

@ -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,

View file

@ -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;

View file

@ -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 "?",

View file

@ -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.

2
src/cache/store.zig vendored
View file

@ -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;

View file

@ -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);

View file

@ -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,

View file

@ -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 {};

View file

@ -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);

View file

@ -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,

View file

@ -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}", .{"--"});
}

View file

@ -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", .{});

View file

@ -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));

View file

@ -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 "$?";

View file

@ -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);

View file

@ -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) {

View file

@ -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

View file

@ -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",

View file

@ -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});

View file

@ -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);
},

View file

@ -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});
}

View file

@ -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);
}

View file

@ -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

View file

@ -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;

View file

@ -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 },
);
}
}

View file

@ -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 "";
}

View file

@ -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;

View file

@ -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 });

View file

@ -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);

View file

@ -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 {

View file

@ -1,5 +1,5 @@
const std = @import("std");
const Date = @import("date.zig").Date;
const Date = @import("../Date.zig");
pub const DividendType = enum {
regular,

View file

@ -1,4 +1,4 @@
const Date = @import("date.zig").Date;
const Date = @import("../Date.zig");
pub const ReportTime = enum {
bmo, // before market open

View file

@ -1,4 +1,4 @@
const Date = @import("date.zig").Date;
const Date = @import("../Date.zig");
/// Top holding in an ETF.
pub const Holding = struct {

View file

@ -1,4 +1,4 @@
const Date = @import("date.zig").Date;
const Date = @import("../Date.zig");
pub const ContractType = enum {
call,

View file

@ -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

View file

@ -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 = "",

View file

@ -1,4 +1,4 @@
const Date = @import("date.zig").Date;
const Date = @import("../Date.zig");
/// A stock split event.
pub const Split = struct {

View file

@ -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 };
},
};

187
src/padded.zig Normal file
View file

@ -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);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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);

View file

@ -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),

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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.

View file

@ -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)")

View file

@ -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,
});

View file

@ -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);

View file

@ -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 = "";

View file

@ -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() });
}

View file

@ -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());

View file

@ -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.

View file

@ -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,

View file

@ -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 "--"

View file

@ -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 };
}