show better timing on refresh
This commit is contained in:
parent
72e874f052
commit
5a2b29fdd4
6 changed files with 326 additions and 98 deletions
32
TODO.md
32
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 <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.)
|
||||
|
|
|
|||
98
src/Date.zig
98
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
186
src/market.zig
186
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));
|
||||
}
|
||||
|
|
|
|||
37
src/tui.zig
37
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 <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,
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue