diff --git a/TODO.md b/TODO.md index c94e353..83c4a3f 100644 --- a/TODO.md +++ b/TODO.md @@ -260,39 +260,9 @@ Implementation notes: (`RateLimiter.perHour`, wired into the provider). A batched quote call is 1 request, but heavy `r` use plus candle refreshes draw from the same hourly budget, so watch for contention. - - Tiingo websocket streaming would be the natural follow-on for true + - Tiingo websocket streaming would be the natural follow-on for true push-based real-time, replacing poll-on-`r` entirely. -## Precise "as of " via a datetime/timezone lib (zeit) - priority LOW - -The portfolio tab's live-price footer is deliberately vague static -text: "(as of intraday quote today)" after a live refresh, falling -back to "(as of close on YYYY-MM-DD)" otherwise. We can't do better -today because the codebase has no wall-clock-to-local-time machinery - -`Date` is days-only, and every time display is either a date or a -relative "X ago" (`fmt.fmtTimeAgo`). There's no way to render an -absolute local clock time like "2:34 PM ET". - -Pulling in a datetime/timezone library (e.g. [zeit](https://github.com/rockorager/zeit), -already by the libvaxis author) would let us: - -- Show a precise, honest stamp: "(as of 2:34 PM ET)" / "refreshed - 2:34 PM" instead of "today" / "Xs ago". -- Fix the current label's weekend/after-hours imprecision. Right now a - refresh when the market is closed flips the footer to "intraday quote - today" even though Yahoo returned the last close (which on a Saturday - is Friday's). With real clock + market-calendar awareness, the label - could say "as of Fri close" or "(market closed, last quote Fri 4:00 - PM ET)" instead of implying live intraday data. -- Replace `last_refresh_s` "refreshed Xs ago" in the TUI status bar - (and the quote/earnings/options "data Xs ago" readouts) with absolute - times where that reads better. - -Scope is a judgment call: a new dependency for what's currently a -cosmetic label. Worth it once we want trustworthy timestamps (e.g. for -screenshots, or to stop conflating "live" with "last close"); not -before. - ## Analysis: dividend equity / income-shaped equity - think about it Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.) diff --git a/src/Date.zig b/src/Date.zig index 44a36db..841e5e7 100644 --- a/src/Date.zig +++ b/src/Date.zig @@ -5,6 +5,19 @@ //! and use it as the type directly - no `.Date` field //! extraction. //! +//! ## Calendar engine +//! +//! `zeit.Days` is `i32` days-since-epoch - the exact same +//! representation as this type's `days` field - so the civil +//! conversions (`epochDaysToYmd` / `ymdToEpochDays`), leap-year +//! test, day-of-week, and month tables all delegate to zeit +//! rather than carrying a parallel hand-rolled implementation. +//! The compact `i32` representation, the SRF hooks, and the +//! zfin-specific domain helpers (`ageOn`, `yearMonth`, +//! `monthsBetween`, the 365.25-day `yearsBetween`, the Feb-29 +//! clamp on `addYears`/`subtractYears`) stay here - zeit has no +//! equivalent. +//! //! ## Format methods //! //! - `Date.format(self, *std.Io.Writer) !void` - Zig 0.15+ @@ -27,6 +40,7 @@ const std = @import("std"); const srf = @import("srf"); +const zeit = @import("zeit"); /// Days since 1970-01-01. days: i32, @@ -107,9 +121,10 @@ pub fn padLeft(self: Date, width: usize) Padded(Date) { /// 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); + // zeit's Weekday is sun=0..sat=6; remap to this type's + // mon=0..sun=6 scheme with (w + 6) % 7. + const w: u8 = @intFromEnum(zeit.weekdayFromDays(self.days)); + return (w + 6) % 7; } pub fn eql(a: Date, b: Date) bool { @@ -138,7 +153,7 @@ pub fn fromEpoch(epoch_secs: i64) Date { 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; + const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !zeit.isLeapYear(new_year)) 28 else ymd.day; return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; } @@ -149,7 +164,7 @@ pub fn subtractYears(self: Date, n: u16) Date { 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; + const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !zeit.isLeapYear(new_year)) 28 else ymd.day; return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; } @@ -187,9 +202,8 @@ pub fn lastDayOfMonth(y: i16, m: u8) Date { } 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]; + const mon: zeit.Month = @enumFromInt(m); + return mon.lastDay(y); } /// Three-letter English abbreviation of a month number @@ -197,12 +211,12 @@ fn daysInMonth(y: i16, m: u8) u8 { /// 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", - }; + // Range-guard before the enum cast: zeit.Month is enum(u4) + // jan=1..dec=12, so @enumFromInt on an out-of-range value + // would be illegal behavior. Display callers want "???". if (m < 1 or m > 12) return "???"; - return table[m - 1]; + const mon: zeit.Month = @enumFromInt(m); + return mon.shortName(); } /// Returns approximate number of years between two dates. @@ -280,39 +294,29 @@ pub fn ageOn(self: Date, on: Date) u16 { 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); -} - const Ymd = struct { year: i16, month: u8, day: u8 }; +/// Decompose epoch-days into civil year/month/day. `zeit.Days` is +/// also `i32` days-since-epoch, so this is a direct delegation to +/// `zeit.civilFromDays` (no parallel hand-rolled algorithm). fn epochDaysToYmd(days: i32) Ymd { - // Algorithm from http://howardhinnant.github.io/date_algorithms.html - // Using i64 throughout to avoid overflow on unsigned intermediate values. - const z: i64 = @as(i64, days) + 719468; - const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097); - const doe_i: i64 = z - era * 146097; // [0, 146096] - const doe: u64 = @intCast(doe_i); - const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] - const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400; - const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100); - const mp: u64 = (5 * doy + 2) / 153; - const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1); - const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9; - const m: u8 = @intCast(m_raw); - const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y); - return .{ .year = y_adj, .month = m, .day = d }; + const zd = zeit.civilFromDays(days); + return .{ + .year = @intCast(zd.year), + .month = @intFromEnum(zd.month), + .day = zd.day, + }; } +/// Compose epoch-days from civil year/month/day via +/// `zeit.daysFromCivil`. `m` must be 1..12 (callers within this +/// file guarantee it; `zeit.Month` is `enum(u4) { jan = 1, .. }`). fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 { - const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0)); - const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400); - const yoe: u64 = @intCast(y_adj - era * 400); - const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9; - const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1; - const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy; - return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468); + return zeit.daysFromCivil(.{ + .year = y, + .month = @enumFromInt(m), + .day = @intCast(d), + }); } // ── Tests ──────────────────────────────────────────────────── @@ -480,6 +484,10 @@ test "dayOfWeek" { try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek()); // 2024-01-01 (Monday) try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek()); + // 2024-01-16 (Tuesday) + try std.testing.expectEqual(@as(u8, 1), Date.fromYmd(2024, 1, 16).dayOfWeek()); + // 2024-01-17 (Wednesday) + try std.testing.expectEqual(@as(u8, 2), Date.fromYmd(2024, 1, 17).dayOfWeek()); // 2024-01-19 (Friday) try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek()); // 2024-01-20 (Saturday) @@ -488,6 +496,16 @@ test "dayOfWeek" { try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek()); } +test "monthShort" { + try std.testing.expectEqualStrings("Jan", Date.monthShort(1)); + try std.testing.expectEqualStrings("Feb", Date.monthShort(2)); + try std.testing.expectEqualStrings("Dec", Date.monthShort(12)); + // Out-of-range returns the placeholder rather than panicking on + // the enum cast. + try std.testing.expectEqualStrings("???", Date.monthShort(0)); + try std.testing.expectEqualStrings("???", Date.monthShort(13)); +} + test "eql and lessThan" { const a = Date.fromYmd(2024, 6, 15); const b = Date.fromYmd(2024, 6, 15); diff --git a/src/PortfolioData.zig b/src/PortfolioData.zig index e4d88c2..bdf0130 100644 --- a/src/PortfolioData.zig +++ b/src/PortfolioData.zig @@ -191,6 +191,13 @@ pub const LoadOptions = struct { /// portfolio with current intraday quotes rather than the prior /// daily close. Borrowed; pd does not take ownership. live_quotes: ?*const std.StringHashMap(f64) = null, + /// Wall-clock instant (Unix seconds) at which `live_quotes` were + /// fetched. Stored on pd as `live_quotes_at_s` when the overlay + /// actually re-prices a held position, so the renderer can show a + /// precise "(as of H:MM PM ET)" stamp. Ignored when `live_quotes` + /// is null. Caller captures it once via + /// `std.Io.Timestamp.now(io, .real).toSeconds()`. + live_quotes_at_s: ?i64 = null, /// Per-worker start delays. Each background worker sleeps for /// its delay before doing any work, letting the caller /// deprioritize a specific worker (e.g. push it later so a @@ -276,6 +283,12 @@ latest_quote_date: ?Date = null, /// Drives the portfolio "as of" label wording. live_prices_applied: bool = false, +/// Unix-seconds instant the live intraday quotes were fetched, when +/// `live_prices_applied` is true (else null). Threaded in from the +/// TUI refresh path via `LoadOptions.live_quotes_at_s`; drives the +/// precise "(as of H:MM PM ET)" portfolio footer stamp. +live_quotes_at_s: ?i64 = null, + /// Cached prices for watchlist symbols (no live fetching during /// render). Allocated in pd's arena. watchlist_prices: ?std.StringHashMap(f64) = null, @@ -516,6 +529,7 @@ pub fn load( self.summary = null; self.latest_quote_date = null; self.live_prices_applied = false; + self.live_quotes_at_s = null; self.watchlist_prices = null; self.snapshots_data = null; self.dividends_data = null; @@ -663,6 +677,10 @@ pub fn load( // so the summary reflects today's intraday prices rather than // the candle close. Drives the portfolio "as of" label. self.live_prices_applied = held_overrides > 0; + // Stamp the fetch instant only when the overlay actually took + // effect, so the footer's precise "(as of H:MM PM ET)" reflects + // a real re-price (not a no-op refresh). + self.live_quotes_at_s = if (held_overrides > 0) opts.live_quotes_at_s else null; } self.watchlist_prices = wp; diff --git a/src/market.zig b/src/market.zig index 2b0c1c0..19246c4 100644 --- a/src/market.zig +++ b/src/market.zig @@ -26,6 +26,8 @@ const std = @import("std"); const zeit = @import("zeit"); const Date = @import("Date.zig"); +const log = std.log.scoped(.market); + /// POSIX TZ spec for US Eastern: EST (UTC-5) / EDT (UTC-4) with the /// current US DST rule (2nd Sunday March -> 1st Sunday November). const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0"; @@ -351,6 +353,137 @@ fn etLocalToUtc(d: Date, tod_s: i64) i64 { return utc; } +// ── Wall-clock display + intraday session ──────────────────────────── + +/// Re-export so callers can name the resolved-zone type without +/// importing zeit directly - the zeit dependency stays an +/// implementation detail behind this module. +pub const TimeZone = zeit.TimeZone; + +/// Resolve the user's local timezone, for rendering user-action +/// timestamps (e.g. the TUI "refreshed at" stamp). Honors a `$TZ` +/// override when `tz_override` is a non-empty string (a POSIX spec, +/// `:/abs/path`, or `:Area/City`); otherwise reads the system default +/// (`/etc/localtime`). Does I/O and may allocate - call once at the +/// top of a unit of work (App init) and thread the result as a value. +/// The returned zone owns memory when it resolves from tzinfo; call +/// `.deinit()` on it at teardown (a no-op for the Eastern fallback, +/// which borrows a static spec). +/// +/// Lifetime note: when `tz_override` is a POSIX spec, the returned +/// zone *borrows* `tz_override`, so the caller must keep it alive for +/// the zone's lifetime. Passing `config.environ_map.get("TZ")` +/// satisfies this - the environ map is owned by main and outlives the +/// App. +/// +/// On failure (no zoneinfo on the system, unparseable tz, etc.) we +/// fall back to US Eastern - the app's market zone - so a stamp still +/// renders rather than the whole readout failing. +pub fn localTimeZone(alloc: std.mem.Allocator, io: std.Io, tz_override: ?[]const u8) TimeZone { + // An empty $TZ ("TZ=") is not a valid spec for zeit (it asserts + // non-empty); treat it as unset and fall through to the system + // default. + const tz: ?[]const u8 = if (tz_override) |t| (if (t.len > 0) t else null) else null; + return zeit.local(alloc, io, .{ .tz = tz }) catch |err| { + log.debug("local timezone resolve failed ({t}); falling back to ET", .{err}); + return eastern; + }; +} + +/// 12-hour clock components decomposed from a count of seconds since +/// local midnight (`tod`, expected in [0, 86400)). +const Clock12 = struct { + hour: u8, // 1..12 + minute: u8, // 0..59 + am: bool, + + fn ampm(self: Clock12) []const u8 { + return if (self.am) "AM" else "PM"; + } +}; + +fn clock12FromTod(tod: i64) Clock12 { + const hour24 = @divFloor(tod, std.time.s_per_hour); + // tod is non-negative, so hour/minute are in range; cast to + // unsigned so `{d:0>2}` doesn't emit a sign placeholder. + const minute: u8 = @intCast(@divFloor(@mod(tod, std.time.s_per_hour), std.time.s_per_min)); + const h: u8 = @intCast(@mod(hour24, 12)); + return .{ .hour = if (h == 0) 12 else h, .minute = minute, .am = hour24 < 12 }; +} + +/// Render a Unix-seconds instant as a US Eastern wall-clock time, +/// 12-hour with an "ET" suffix: "2:34 PM ET", "9:05 AM ET", +/// "12:00 PM ET" (noon), "12:00 AM ET" (midnight). Writes into `buf` +/// (needs >= 11 bytes) and returns the slice. +/// +/// Uses the same hardcoded Eastern TZ as the rest of this module, so +/// the label is always "ET" (market time) regardless of the caller's +/// locale - the honest stamp for market data. Pure given `unix_s`. +pub fn fmtClockET(buf: []u8, unix_s: i64) []const u8 { + const local = eastern.adjust(unix_s).timestamp; + const d = Date.fromEpoch(local); + const c = clock12FromTod(local - d.toEpoch()); + return std.fmt.bufPrint(buf, "{d}:{d:0>2} {s} ET", .{ c.hour, c.minute, c.ampm() }) catch "?"; +} + +/// Render a "refreshed at" wall-clock stamp for instant `at_s` in +/// timezone `tz`, using the zone's own abbreviation (e.g. "PST", +/// "EDT"). When `at_s` falls on the same local calendar day as +/// `now_s`, only the time is shown ("2:34 PM PST"); otherwise a short +/// date is prefixed ("Jun 25, 2:34 PM PST") so a stamp left on an +/// idle screen overnight stays unambiguous. Writes into `buf` (needs +/// >= 24 bytes) and returns the slice. +/// +/// Unlike `fmtClockET`, this renders in the caller-supplied zone +/// (typically the user's local zone from `localTimeZone`) because a +/// "when did I refresh" readout is about the user's wall clock, not +/// market time. +pub fn fmtStamp(buf: []u8, tz: TimeZone, at_s: i64, now_s: i64) []const u8 { + const at = tz.adjust(at_s); + const d = Date.fromEpoch(at.timestamp); + const c = clock12FromTod(at.timestamp - d.toEpoch()); + const now_day = Date.fromEpoch(tz.adjust(now_s).timestamp); + if (d.eql(now_day)) { + return std.fmt.bufPrint(buf, "{d}:{d:0>2} {s} {s}", .{ c.hour, c.minute, c.ampm(), at.designation }) catch "?"; + } + return std.fmt.bufPrint(buf, "{s} {d}, {d}:{d:0>2} {s} {s}", .{ + Date.monthShort(d.month()), d.day(), c.hour, c.minute, c.ampm(), at.designation, + }) catch "?"; +} + +/// The US equity market session at a given instant. +pub const MarketSession = enum { + /// Regular trading hours: 09:30-16:00 ET on a trading day. + open, + /// Before the open on a trading day (00:00-09:30 ET). + premarket, + /// At/after the close on a trading day (16:00-24:00 ET). + afterhours, + /// Not a trading day at all - weekend or modeled holiday. + closed, +}; + +/// Regular trading-session bounds (seconds since ET-local midnight): +/// 09:30 open, 16:00 close. Distinct from the freshness `*_target_s` +/// boundaries above, which sit after the close for cache timing. +const regular_open_s: i64 = 9 * std.time.s_per_hour + 30 * std.time.s_per_min; +const regular_close_s: i64 = 16 * std.time.s_per_hour; + +/// Classify the US equity market session at `now_s`. Pure given +/// `now_s` (the wall clock is read by the caller and threaded in), +/// so it is fully deterministic for tests. Holidays follow the same +/// modeled-only policy as `isHoliday` (an un-modeled closure like +/// Good Friday reports `.open`). +pub fn marketSession(now_s: i64) MarketSession { + const local = eastern.adjust(now_s).timestamp; + const today_et = Date.fromEpoch(local); + if (!isTradingDay(today_et)) return .closed; + const tod = local - today_et.toEpoch(); + if (tod < regular_open_s) return .premarket; + if (tod >= regular_close_s) return .afterhours; + return .open; +} + // ── Tests ──────────────────────────────────────────────────────────── const testing = std.testing; @@ -603,3 +736,56 @@ test "candleFreshness mutual_fund: late NAV is lagging" { // Cache already has Monday -> nothing newer is due -> current. try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16))); } + +test "fmtClockET: 12-hour rendering with EST/EDT and AM/PM" { + var buf: [16]u8 = undefined; + // Afternoon (EST, winter): 14:34 ET. + try testing.expectEqualStrings("2:34 PM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 1, 15), 14 * std.time.s_per_hour + 34 * std.time.s_per_min))); + // Morning (EDT, summer): 09:05 ET, minute zero-padded. + try testing.expectEqualStrings("9:05 AM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 9 * std.time.s_per_hour + 5 * std.time.s_per_min))); + // Noon and midnight are the 12-hour edge cases. + try testing.expectEqualStrings("12:00 PM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 12 * std.time.s_per_hour))); + try testing.expectEqualStrings("12:00 AM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 0))); +} + +test "marketSession: trading-day hours, pre/after, weekend, holiday" { + const wed = Date.fromYmd(2025, 6, 11); // a regular trading Wednesday + // 10:00 ET -> open. + try testing.expectEqual(MarketSession.open, marketSession(etLocalToUtc(wed, 10 * std.time.s_per_hour))); + // 09:30 ET exactly -> open (inclusive lower bound). + try testing.expectEqual(MarketSession.open, marketSession(etLocalToUtc(wed, regular_open_s))); + // 09:29 ET -> premarket. + try testing.expectEqual(MarketSession.premarket, marketSession(etLocalToUtc(wed, regular_open_s - std.time.s_per_min))); + // 16:00 ET exactly -> afterhours (inclusive upper bound). + try testing.expectEqual(MarketSession.afterhours, marketSession(etLocalToUtc(wed, regular_close_s))); + // Saturday -> closed regardless of clock. + try testing.expectEqual(MarketSession.closed, marketSession(etLocalToUtc(Date.fromYmd(2025, 6, 14), 12 * std.time.s_per_hour))); + // Thanksgiving 2025-11-27 -> closed (modeled holiday). + try testing.expectEqual(MarketSession.closed, marketSession(etLocalToUtc(Date.fromYmd(2025, 11, 27), 12 * std.time.s_per_hour))); +} + +test "fmtStamp: time-only same local day, date-prefixed across days" { + var buf: [32]u8 = undefined; + // Tested against the Eastern zone so the designation is deterministic. + const at = etLocalToUtc(Date.fromYmd(2025, 1, 15), 14 * std.time.s_per_hour + 34 * std.time.s_per_min); + // Same local day as `at` -> time + zone abbreviation only. + const same_day_now = etLocalToUtc(Date.fromYmd(2025, 1, 15), 18 * std.time.s_per_hour); + try testing.expectEqualStrings("2:34 PM EST", fmtStamp(&buf, eastern, at, same_day_now)); + // `now` is the following day -> short date prefix keeps an + // overnight-idle stamp unambiguous. + const next_day_now = etLocalToUtc(Date.fromYmd(2025, 1, 16), 9 * std.time.s_per_hour); + try testing.expectEqualStrings("Jan 15, 2:34 PM EST", fmtStamp(&buf, eastern, at, next_day_now)); +} + +test "localTimeZone: honors a POSIX $TZ override" { + // A POSIX TZ spec resolves without touching the filesystem (zeit + // parses it directly), so this exercises the $TZ path + // deterministically. Pacific in January -> PST. + const tz = localTimeZone(testing.allocator, testing.io, "PST8PDT,M3.2.0,M11.1.0"); + defer tz.deinit(); + var buf: [32]u8 = undefined; + // 2025-01-15 20:00 UTC == 12:00 PST. Same instant for `now`, so the + // stamp is time-only and must carry the override zone's "PST", not ET. + const at = utcSeconds(2025, 1, 15, 20, 0); + try testing.expectEqualStrings("12:00 PM PST", fmtStamp(&buf, tz, at, at)); +} diff --git a/src/tui.zig b/src/tui.zig index e73b90b..9cd7da1 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -494,6 +494,12 @@ pub const App = struct { /// deterministic within a single frame and avoids threading `io` /// through pure date-consuming helpers like `positions()`. today: zfin.Date, + /// The user's local timezone, resolved once at App init (reads the + /// system tz database) and threaded as a value. Used by `getStatus` + /// to render the "refreshed at" stamp in local wall-clock time - + /// distinct from the portfolio footer's ET market-time stamp. + /// Owns memory when resolved from tzinfo; `deinit`'d in `deinitData`. + local_tz: zfin.market.TimeZone, config: zfin.Config, svc: *zfin.DataService, keymap: keybinds.KeyMap, @@ -1417,8 +1423,8 @@ pub const App = struct { /// flight, shows the in-progress indicator. Otherwise: a /// user-set message if present, else a dynamic default hint /// (global keys + the active tab's `status_hints`), prefixed - /// with a "refreshed Xs ago" readout once a refresh has run. - /// Allocated in `arena` for the dynamic forms; the user-set + /// with a "refreshed at " stamp once a refresh has + /// run. Allocated in `arena` for the dynamic forms; the user-set /// buffer is returned by reference. fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 { if (self.refresh_pending) return "Refreshing..."; @@ -1426,14 +1432,17 @@ pub const App = struct { const hint = self.buildDefaultStatusHint(arena) catch "h/l tabs | j/k select | / symbol | ? help"; if (self.last_refresh_s > 0) { - // wall-clock required: per-frame "now" for the relative - // "refreshed Xs ago" readout. + // wall-clock required: per-frame "now" to decide whether + // the refresh stamp needs a date prefix (refreshed on an + // earlier local day - the TUI doesn't auto-refresh, so a + // stamp can sit on screen across midnight). const now_s = std.Io.Timestamp.now(self.io, .real).toSeconds(); - var ago_buf: [24]u8 = undefined; - const ago = fmt.fmtTimeAgo(&ago_buf, self.last_refresh_s, now_s); - if (ago.len > 0) { - return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ ago, hint }) catch hint; - } + // Absolute local-time stamp rather than a relative "Xs + // ago": with no auto-refresh, a relative readout silently + // goes stale while the screen sits idle. + var stamp_buf: [32]u8 = undefined; + const stamp = zfin.market.fmtStamp(&stamp_buf, self.local_tz, self.last_refresh_s, now_s); + return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ stamp, hint }) catch hint; } return hint; } @@ -1507,6 +1516,9 @@ pub const App = struct { Module.tab.deinit(state_ptr, self); } self.portfolio.deinit(); + // Release the resolved local timezone (no-op for the ET + // fallback, which borrows a static spec). + self.local_tz.deinit(); } fn reloadPortfolioFile(self: *App) void { @@ -2509,10 +2521,17 @@ pub fn run( var app_inst = try allocator.create(App); defer allocator.destroy(app_inst); + // Resolve the user's local timezone once here at the top of the + // TUI's unit of work, honoring $TZ from the environ map (owned by + // main, so it outlives the App and any zone that borrows it). + // Falls back to the system default, then ET. deinit'd in deinitData. + const tz_override = if (config.environ_map) |em| em.get("TZ") else null; + const local_tz = zfin.market.localTimeZone(allocator, io, tz_override); app_inst.* = .{ .allocator = allocator, .io = io, .today = today, + .local_tz = local_tz, .config = config, .svc = svc, .keymap = keymap, diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index ea8baa6..fabe198 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -377,10 +377,15 @@ pub const tab = struct { // falls back to the candle last close for those. var live = app.svc.loadLiveQuotes(quote_syms.items); defer live.deinit(); + // wall-clock required: stamp the instant the live quotes were + // fetched so the portfolio footer can render a precise + // "(as of H:MM PM ET)" stamp via market.fmtClockET. + const live_at_s = std.Io.Timestamp.now(app.io, .real).toSeconds(); _ = app.portfolio.reload(app.today, .{ .watchlist_syms = watch_syms.items, .live_quotes = &live, + .live_quotes_at_s = live_at_s, }) catch |err| { app.setStatus("Error refreshing portfolio data"); std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err}); @@ -1340,6 +1345,34 @@ fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals { // ── Rendering ───────────────────────────────────────────────── +/// Append the "(as of ...)" footer line describing how current the +/// portfolio valuation is. When live intraday quotes were overlaid +/// (the TUI refresh path), show a precise ET wall-clock stamp of the +/// fetch instant - and flag it when the market was closed at that +/// moment, so a weekend/after-hours refresh no longer implies live +/// intraday data. Otherwise fall back to the candle close date. +fn appendAsOfLine(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), app: *App) !void { + const th = app.theme; + if (app.portfolio.live_prices_applied) { + if (app.portfolio.live_quotes_at_s) |ts| { + var clk: [16]u8 = undefined; + const stamp = zfin.market.fmtClockET(&clk, ts); + const text = if (zfin.market.marketSession(ts) != .open) + try std.fmt.allocPrint(arena, " (as of {s}, market closed)", .{stamp}) + else + try std.fmt.allocPrint(arena, " (as of {s})", .{stamp}); + try lines.append(arena, .{ .text = text, .style = th.mutedStyle() }); + } else { + // Live overlay applied but no fetch timestamp was threaded + // (defensive fallback). + try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() }); + } + } else if (app.portfolio.latest_quote_date) |d| { + const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); + try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); + } +} + pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { // Modal sub-state takes over the content surface entirely. // Picker overlay replaces the portfolio table while open. @@ -1382,15 +1415,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = summary_text, .style = summary_style }); - if (app.portfolio.live_prices_applied) { - // Live overlay applied: values reflect today's intraday - // quotes, not the candle close. Static text for now; a - // precise "as of HH:MM ET" awaits real tz handling. - try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() }); - } else if (app.portfolio.latest_quote_date) |d| { - const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); - try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); - } + try appendAsOfLine(arena, &lines, app); // No historical snapshots or net worth when filtered } else { // Unfiltered mode: use portfolio_summary totals directly @@ -1406,15 +1431,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va try lines.append(arena, .{ .text = summary_text, .style = summary_style }); // "as of" date indicator - if (app.portfolio.live_prices_applied) { - // Live overlay applied: values reflect today's intraday - // quotes, not the candle close. Static text for now; a - // precise "as of HH:MM ET" awaits real tz handling. - try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() }); - } else if (app.portfolio.latest_quote_date) |d| { - const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d}); - try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); - } + try appendAsOfLine(arena, &lines, app); // Net Worth line (only if portfolio has illiquid assets) if (app.portfolio.file) |pf| {