land date refactor (similar to money)
This commit is contained in:
parent
e62eb5f0a7
commit
0987a61634
67 changed files with 665 additions and 488 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -126,7 +126,14 @@ already exist and have caught me out:
|
|||
- `Date.wholeYearsBetween` — floored, returns u16.
|
||||
- `Date.ageOn` — calendar-precise age (handles "birthday hasn't
|
||||
occurred this year yet"). Distinct from `wholeYearsBetween`.
|
||||
- `Date.format` — "YYYY-MM-DD".
|
||||
- `Date.format` — Zig 0.15+ writer-style format method. Use `{f}`
|
||||
to render "YYYY-MM-DD" directly into a writer.
|
||||
- `Date.padRight(N)` / `Date.padLeft(N)` — column-aligned wrappers
|
||||
for `{f}` rendering. Use these instead of `{s:>N}` when you
|
||||
previously would have called a buffer-into-slice formatter and
|
||||
passed the slice to a width-spec.
|
||||
- For cases that need a `[]const u8` (URL params, struct fields),
|
||||
call `std.fmt.bufPrint(&buf, "{f}", .{my_date})` into a `[10]u8`.
|
||||
- `Money.from(amount)` with `{f}` — "$1,234.56" with commas,
|
||||
always 2 dp. Standard format method (Zig 0.15+ format-method
|
||||
protocol) — no buffer ceremony.
|
||||
|
|
@ -173,7 +180,7 @@ and theirs.
|
|||
If the search confirms nothing exists, add the new helper to the
|
||||
right module:
|
||||
|
||||
- Date / calendar math → `src/models/date.zig`, as a `Date` method
|
||||
- Date / calendar math → `src/Date.zig`, as a `Date` method
|
||||
when the receiver is natural.
|
||||
- Money formatting → `src/Money.zig`. New variants are wrapper
|
||||
structs returned from `Money` methods; each implements
|
||||
|
|
@ -325,7 +332,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
|||
|
||||
### Key design decisions
|
||||
|
||||
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("models/date.zig")`. This is intentional — it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
|
||||
- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("Date.zig")` or `@import("models/portfolio.zig")`. This is intentional — it lets `refAllDecls` in the test binary discover all tests across the entire source tree.
|
||||
|
||||
- **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs.
|
||||
|
||||
|
|
@ -343,7 +350,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
|||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/models/` | Data types: `Date` (days since epoch), `Candle`, `Dividend`, `Split`, `Lot`, `Portfolio`, `OptionContract`, `EarningsEvent`, `EtfProfile`, `Quote` |
|
||||
| `src/models/` | Data types: `Candle`, `Dividend`, `Split`, `Lot`, `Portfolio`, `OptionContract`, `EarningsEvent`, `EtfProfile`, `Quote`. (`Date` and `Money` are top-level types in `src/Date.zig` and `src/Money.zig`.) |
|
||||
| `src/providers/` | API clients: each provider has its own struct with `init(allocator, api_key)` + fetch methods. `json_utils.zig` has shared JSON parsing helpers. |
|
||||
| `src/analytics/` | Pure computation: `performance.zig` (Morningstar-style trailing returns), `risk.zig` (Sharpe, drawdown), `valuation.zig` (portfolio summary), `analysis.zig` (breakdowns by class/sector/geo) |
|
||||
| `src/commands/` | CLI command handlers: each has a `run()` function taking `(allocator, *DataService, symbol, color, *Writer)`. `common.zig` has shared CLI helpers and color constants. |
|
||||
|
|
@ -364,7 +371,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
|
|||
|
||||
### The `Date` type
|
||||
|
||||
`Date` is an `i32` of days since Unix epoch. It is used everywhere instead of timestamps. Construction: `Date.fromYmd(2024, 1, 15)` or `Date.parse("2024-01-15")`. Formatting: `date.format(&buf)` writes `YYYY-MM-DD` into a `*[10]u8`. The type has SRF serialization hooks (`srfParse`, `srfFormat`).
|
||||
`Date` is an `i32` of days since Unix epoch. It is used everywhere instead of timestamps. Construction: `Date.fromYmd(2024, 1, 15)` or `Date.parse("2024-01-15")`. Formatting: `try writer.print("{f}", .{date})` writes `YYYY-MM-DD` directly to a writer (Zig 0.15+ format method). For column-aligned output, use `date.padLeft(12)` / `date.padRight(12)` with `{f}`. For cases that need a `[]const u8` (URL params, struct fields), call `std.fmt.bufPrint(&buf, "{f}", .{date})` into a `[10]u8` buffer. The type has SRF serialization hooks (`srfParse`, `srfFormat`).
|
||||
|
||||
### Formatting pattern
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 .{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "?",
|
||||
|
|
|
|||
|
|
@ -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
2
src/cache/store.zig
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}", .{"--"});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 "$?";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const std = @import("std");
|
||||
const Date = @import("date.zig").Date;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
pub const DividendType = enum {
|
||||
regular,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const Date = @import("date.zig").Date;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
pub const ReportTime = enum {
|
||||
bmo, // before market open
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const Date = @import("date.zig").Date;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
/// Top holding in an ETF.
|
||||
pub const Holding = struct {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const Date = @import("date.zig").Date;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
pub const ContractType = enum {
|
||||
call,
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const Date = @import("date.zig").Date;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
/// A stock split event.
|
||||
pub const Split = struct {
|
||||
|
|
|
|||
|
|
@ -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
187
src/padded.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "--"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue