show better timing on refresh

This commit is contained in:
Emil Lerch 2026-06-26 10:10:38 -07:00
parent 72e874f052
commit 5a2b29fdd4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 326 additions and 98 deletions

32
TODO.md
View file

@ -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 <clock time>" 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.)

View file

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

View file

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

View file

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

View file

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

View file

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