//! Market calendar: trading-day awareness and close-anchored cache
//! freshness boundaries for daily candles.
//!
//! Daily candle data only becomes meaningful once the market settles.
//! Two distinct deadlines matter:
//!
//!   - **Equities / ETFs** settle shortly after the 16:00 ET close.
//!   - **Mutual-fund NAVs** strike once per day and are not reliably
//!     published until the next morning (~03:30 ET).
//!
//! The old candle TTL was a rolling 23h45m window, so the expiry
//! boundary drifted against the market clock and could fall during
//! trading hours - causing mid-session refetches and risking caching a
//! not-yet-finalized bar. This module computes the next moment fresh
//! data should be available, so the candle cache is keyed to the market
//! clock instead. It is an optimization, not a correctness fix: a
//! slightly-wrong boundary costs at most one cheap no-op fetch (see the
//! direction-of-error note on `isHoliday`).
//!
//! Timezone handling uses `zeit` with a hardcoded US Eastern POSIX TZ
//! spec, so there is no dependency on system zoneinfo files, no
//! allocator, and no I/O. The fixed DST rule is correct for current and
//! near-future dates, which is all cache-freshness math ever deals with.

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

/// US Eastern timezone, parsed at comptime from `eastern_posix_spec`.
/// `Posix.parse` allocates nothing - the parsed struct borrows slices
/// into the (static) spec string - so this is a zero-cost const.
const eastern: zeit.TimeZone = .{
    .posix = zeit.timezone.Posix.parse(eastern_posix_spec) catch
        @compileError("invalid eastern POSIX TZ spec: " ++ eastern_posix_spec),
};

/// How candle data for a symbol becomes available.
pub const InstrumentKind = enum {
    /// Continuously-quoted equities and ETFs: the day's bar settles
    /// shortly after the 16:00 ET close.
    equity,
    /// Mutual funds: a single daily NAV that isn't reliably published
    /// until the next morning.
    mutual_fund,
};

/// Classify a symbol for candle-freshness purposes. Mutual funds use
/// 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX); everything
/// else is treated as an equity/ETF. Imperfect, but covers the common
/// case.
///
/// This is the single home for the heuristic: it was consolidated here
/// from the former `DataService.isMutualFund` (removed) when candle
/// freshness timing moved into this module. It is deliberately distinct
/// from `portfolio.isMoneyMarketSymbol`, which answers a different
/// question - "is this a fixed-$1-NAV cash equivalent?" - via a curated
/// whitelist. (Every symbol on that whitelist also matches this
/// heuristic, so freshness callers only need `classify`.)
pub fn classify(symbol: []const u8) InstrumentKind {
    if (symbol.len == 5 and symbol[4] == 'X') return .mutual_fund;
    return .equity;
}

// ── Freshness target times (seconds since ET-local midnight) ─────────
//
// These double as the recommended refresh-cron schedule: each sits a
// couple minutes BEFORE its intended cron run so cron-timing jitter
// reliably sees the cache already expired (a boundary exactly at the
// cron time could be missed by an early tick). The "expected-but-
// missing" short retry (see `short_retry_s`) covers the case where the
// provider hasn't posted the just-closed bar by then.

/// Equity/ETF boundary: 16:55 ET (just after the 16:00 close + settle
/// margin; pairs with a ~17:00 ET refresh cron).
const equity_target_s: i64 = 16 * std.time.s_per_hour + 55 * std.time.s_per_min;

/// Mutual-fund boundary: 03:25 ET the morning after the trading day
/// (pairs with a ~03:30 ET NAV-refresh cron).
const mf_target_s: i64 = 3 * std.time.s_per_hour + 25 * std.time.s_per_min;

/// Retry interval when an expected bar wasn't returned yet (the provider
/// hasn't posted the just-closed bar, or a transient fetch failure).
/// Short enough to pick the bar up within the same session, long enough
/// to avoid hammering a rate-limited provider.
pub const short_retry_s: i64 = 30 * std.time.s_per_min;

/// How long past the moment data is *due* we keep doing `short_retry_s`
/// retries before concluding the calendar's "trading day" was actually
/// an un-modeled market closure (e.g. Good Friday, which needs the Easter
/// computus and is deliberately not modeled - see `isHoliday`) or an
/// ad-hoc halt, rather than mere provider lag. Once an expected bar has
/// been overdue this long it is almost certainly never coming, so
/// `staleCandleExpiry` stops the retry loop and falls back to the normal
/// next-boundary expiry. That bounds an un-modeled closure to a few
/// no-op fetches that session instead of thrashing every `short_retry_s`
/// until the next real session - for a Friday closure, the entire long
/// weekend.
///
/// 90 minutes comfortably covers normal posting lag for both the equity
/// close and the overnight mutual-fund NAV (data that is going to appear
/// at all is essentially always up well inside that window) while still
/// giving up promptly. At the 30-minute `short_retry_s` cadence that is
/// three retries (at +30, +60, +90 min); the +90 retry is the one that
/// crosses the window and trips the fallback.
const provider_lag_grace_s: i64 = 90 * std.time.s_per_min;

fn targetSeconds(kind: InstrumentKind) i64 {
    return switch (kind) {
        .equity => equity_target_s,
        .mutual_fund => mf_target_s,
    };
}

// ── Trading-day calendar ─────────────────────────────────────────────

// Weekday constants in zfin's 0=Mon .. 6=Sun scheme (see Date.dayOfWeek).
const mon: u8 = 0;
const thu: u8 = 3;
const sat: u8 = 5;
const sun: u8 = 6;

/// True if `d` is a US equity-market trading day: a weekday that isn't a
/// modeled NYSE holiday.
pub fn isTradingDay(d: Date) bool {
    if (d.dayOfWeek() >= sat) return false; // Saturday or Sunday
    return !isHoliday(d);
}

/// True if `d` is a (modeled) NYSE market holiday.
///
/// Direction-of-error policy: a *false holiday* (marking a real trading
/// day closed) would let the cache stay fresh too long and miss a day's
/// data - a staleness bug. A *missed holiday* (treating a closed day as
/// open) only costs one harmless no-op fetch. So we model only the
/// well-defined, confidently-computable holidays and lean toward "open"
/// when unsure. Good Friday is deliberately omitted (it needs the Easter
/// computus); an un-modeled Good Friday simply costs one no-op fetch.
/// Ad-hoc closures (national mourning, weather) are likewise unmodeled.
pub fn isHoliday(d: Date) bool {
    const y = d.year();

    // Floating Monday/Thursday holidays - these always land on a weekday,
    // so no weekend-observance adjustment is needed.
    if (d.eql(nthWeekday(y, 1, mon, 3))) return true; // MLK Day: 3rd Mon Jan
    if (d.eql(nthWeekday(y, 2, mon, 3))) return true; // Washington's Birthday: 3rd Mon Feb
    if (d.eql(lastWeekday(y, 5, mon))) return true; // Memorial Day: last Mon May
    if (d.eql(nthWeekday(y, 9, mon, 1))) return true; // Labor Day: 1st Mon Sep
    if (d.eql(nthWeekday(y, 11, thu, 4))) return true; // Thanksgiving: 4th Thu Nov

    // New Year's Day: Sunday -> observed Monday. NYSE does NOT close the
    // preceding Friday when Jan 1 falls on a Saturday (that Friday is in
    // the prior year and stays a normal trading day).
    if (observedSundayOnly(d, 1, 1)) return true;

    // Juneteenth: NYSE first observed it in 2022. Sat -> Fri, Sun -> Mon.
    if (y >= 2022 and observedFixed(d, 6, 19)) return true;

    // Independence Day and Christmas: Sat -> Fri, Sun -> Mon.
    if (observedFixed(d, 7, 4)) return true;
    if (observedFixed(d, 12, 25)) return true;

    return false;
}

/// The `n`-th occurrence (1-based) of weekday `wd` (0=Mon..6=Sun) in
/// month `mo` of `y`.
fn nthWeekday(y: i16, mo: u8, wd: u8, n: u8) Date {
    const first = Date.fromYmd(y, mo, 1);
    const offset = @mod(@as(i32, wd) - @as(i32, first.dayOfWeek()), 7);
    const day: u8 = @intCast(1 + offset + (@as(i32, n - 1) * 7));
    return Date.fromYmd(y, mo, day);
}

/// The last occurrence of weekday `wd` (0=Mon..6=Sun) in month `mo` of `y`.
fn lastWeekday(y: i16, mo: u8, wd: u8) Date {
    const last = Date.lastDayOfMonth(y, mo);
    const back = @mod(@as(i32, last.dayOfWeek()) - @as(i32, wd), 7);
    return last.addDays(-back);
}

/// True if `d` is the observed date of the fixed (mo/day) holiday in
/// `d`'s year, using the full weekend-observance rule: Saturday holidays
/// observed the preceding Friday, Sunday holidays the following Monday.
fn observedFixed(d: Date, mo: u8, day: u8) bool {
    const actual = Date.fromYmd(d.year(), mo, day);
    return switch (actual.dayOfWeek()) {
        sat => d.eql(actual.addDays(-1)), // Sat -> preceding Fri
        sun => d.eql(actual.addDays(1)), // Sun -> following Mon
        else => d.eql(actual),
    };
}

/// Like `observedFixed` but Saturday is NOT observed (used for New
/// Year's Day - see `isHoliday`).
fn observedSundayOnly(d: Date, mo: u8, day: u8) bool {
    const actual = Date.fromYmd(d.year(), mo, day);
    return switch (actual.dayOfWeek()) {
        sat => false,
        sun => d.eql(actual.addDays(1)),
        else => d.eql(actual),
    };
}

// ── Close-anchored freshness boundaries ──────────────────────────────

/// True if fresh candle data for `kind` becomes available on calendar
/// day `d` (in ET). For equities, that's any trading day (the bar
/// settles that afternoon). For mutual funds, it's the morning AFTER a
/// trading day (the prior session's NAV posts overnight) - which also
/// makes holidays fall out for free: if the prior day wasn't a trading
/// day, no NAV is available this morning.
fn isAvailabilityDay(d: Date, kind: InstrumentKind) bool {
    return switch (kind) {
        .equity => isTradingDay(d),
        .mutual_fund => isTradingDay(d.addDays(-1)),
    };
}

/// The trading day whose data an availability day carries.
fn dataDateFor(availability_day: Date, kind: InstrumentKind) Date {
    return switch (kind) {
        .equity => availability_day,
        .mutual_fund => availability_day.addDays(-1),
    };
}

/// Absolute Unix-seconds expiry for a freshly-written candle cache
/// entry: the next moment after `now_s` at which new data for `kind`
/// should be available. Pure given `now_s` (the wall clock is read by
/// the caller and threaded in), so it is fully deterministic for tests.
pub fn nextCandleExpiry(now_s: i64, kind: InstrumentKind) i64 {
    const target = targetSeconds(kind);
    const now_local = eastern.adjust(now_s).timestamp;
    const today_et = Date.fromEpoch(now_local);
    const now_tod = now_local - today_et.toEpoch(); // seconds since ET midnight

    // Start at today's target; if it has already passed, move to tomorrow.
    var cand = today_et;
    if (now_tod >= target) cand = cand.addDays(1);
    // Skip forward to the next day that actually carries new data.
    while (!isAvailabilityDay(cand, kind)) cand = cand.addDays(1);

    return etLocalToUtc(cand, target);
}

/// The most recent data availability as of `now_s`: which trading day's
/// candle should already be published, and the exact instant it went
/// live (its target time on the availability day, in Unix seconds).
const Availability = struct {
    /// Trading day whose data the latest availability carries.
    data_date: Date,
    /// UTC seconds at which that data became available.
    instant_s: i64,
};

/// Walk backward from `now_s` to the most recent availability day whose
/// target time has already passed, reporting both the trading day it
/// carries and the instant its data went live. Pure given `now_s`.
fn latestAvailability(now_s: i64, kind: InstrumentKind) Availability {
    const target = targetSeconds(kind);
    const now_local = eastern.adjust(now_s).timestamp;
    const today_et = Date.fromEpoch(now_local);
    const now_tod = now_local - today_et.toEpoch();

    var cand = today_et;
    if (now_tod < target) cand = cand.addDays(-1);
    while (!isAvailabilityDay(cand, kind)) cand = cand.addDays(-1);
    return .{
        .data_date = dataDateFor(cand, kind),
        .instant_s = etLocalToUtc(cand, target),
    };
}

/// Classification of candle-cache freshness; see `candleFreshness`.
pub const CandleFreshness = enum {
    /// The cache already holds the latest available bar, or nothing newer
    /// than it is due yet (a non-trading-day weekend/holiday gap).
    current,
    /// A newer session's bar is due but not yet posted by the provider,
    /// still within the provider-lag grace window - worth retrying soon.
    lagging,
    /// A newer bar has been overdue for the full grace window: almost
    /// certainly an un-modeled closure or ad-hoc halt, not mere lag, so
    /// retrying is pointless until the next real session.
    overdue,
};

/// Candle-cache freshness for `kind` as of `now_s`, given `last_cached`
/// (the newest bar date already in the cache). Names the three cases an
/// incremental fetch returning zero new bars can be in, so callers (e.g.
/// a refresh cron) can react to provider lag without re-deriving the
/// market calendar:
///
///   1. `.current` - **nothing newer is due**: a genuine non-trading-day
///      gap (weekend/holiday) or the cache is already caught up.
///   2. `.lagging` - **newer data is due, still within the provider-lag
///      grace window** (`provider_lag_grace_s`): the provider just hasn't
///      posted the just-closed bar yet.
///   3. `.overdue` - **newer data has been due for the full grace
///      window**: the calendar's "trading day" was almost certainly an
///      un-modeled closure (e.g. Good Friday) or ad-hoc halt - the bar is
///      never going to appear.
///
/// Pure given `now_s`, so it is fully deterministic for tests.
pub fn candleFreshness(now_s: i64, kind: InstrumentKind, last_cached: Date) CandleFreshness {
    const avail = latestAvailability(now_s, kind);
    if (!last_cached.lessThan(avail.data_date)) return .current;
    if (now_s - avail.instant_s < provider_lag_grace_s) return .lagging;
    return .overdue;
}

/// Whether a candle fetch is warranted for `kind` as of `now_s`, given
/// `last_cached` (the newest bar already in the cache). True when a
/// newer bar is due (`candleFreshness` is `.lagging` or `.overdue`);
/// false when the cache already holds the latest *available* bar - a
/// weekend/holiday/pre-close gap, or genuinely caught up - so the caller
/// should just bump the TTL to the next boundary without hitting the
/// network.
///
/// This is the market-aware gate for getCandles' "do I need to fetch?"
/// decision. It deliberately shares `candleFreshness`'s availability
/// math so the fetch decision and the lag report cannot disagree. A
/// naive `last_cached + 1 >= today` calendar check would skip the fetch
/// for a just-closed session whose bar is due (last_cached == yesterday)
/// while `candleFreshness` simultaneously flagged it `.lagging`, freezing
/// the cache on the stale bar until the next boundary. Pure given
/// `now_s`, so it is fully deterministic for tests.
pub fn shouldRefresh(now_s: i64, kind: InstrumentKind, last_cached: Date) bool {
    return candleFreshness(now_s, kind, last_cached) != .current;
}

/// Expiry to stamp on candle meta after an *incremental* fetch on a
/// stale entry returned zero new bars. Maps `candleFreshness` to a
/// boundary: a `.lagging` bar retries soon (`short_retry_s`); `.current`
/// and `.overdue` both fall back to the normal next-boundary expiry.
/// Routing `.overdue` to the long boundary stops the short-retry thrash
/// so an un-modeled closure (e.g. Good Friday) waits for the next real
/// session instead of refetching every `short_retry_s` all evening (and,
/// for a Friday closure, all weekend).
///
/// `last_cached` is the newest date already in the candle cache. Pure
/// given `now_s`, so it is fully deterministic for tests.
pub fn staleCandleExpiry(now_s: i64, kind: InstrumentKind, last_cached: Date) i64 {
    return switch (candleFreshness(now_s, kind, last_cached)) {
        .current, .overdue => nextCandleExpiry(now_s, kind),
        .lagging => now_s + short_retry_s,
    };
}

/// Convert an ET local wall-clock instant (`d` at `tod_s` seconds since
/// ET midnight) to Unix seconds, resolving the correct EST/EDT offset
/// for that date. Treats the wall clock as a UTC instant for a first
/// guess, then corrects by the offset zeit reports at that moment; two
/// iterations converge because the offset changes by at most one hour
/// and the target times never sit inside the ~02:00 ET DST-transition
/// window.
fn etLocalToUtc(d: Date, tod_s: i64) i64 {
    const wall = d.toEpoch() + tod_s;
    var utc = wall;
    var i: usize = 0;
    while (i < 2) : (i += 1) {
        // adjust(utc).timestamp = utc - west_offset(utc); recover the
        // westward offset and re-anchor the wall clock to true UTC.
        const west = utc - eastern.adjust(utc).timestamp;
        utc = wall + west;
    }
    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;

/// Helper: Unix seconds for a UTC wall clock, for constructing test inputs.
fn utcSeconds(y: i16, mo: u8, d: u8, h: i64, mi: i64) i64 {
    return Date.fromYmd(y, mo, d).toEpoch() + h * std.time.s_per_hour + mi * std.time.s_per_min;
}

/// Helper: decompose an expiry back into ET wall-clock components.
const EtParts = struct { date: Date, hour: u8, minute: u8 };
fn etParts(unix_s: i64) EtParts {
    const local = eastern.adjust(unix_s).timestamp;
    const d = Date.fromEpoch(local);
    const tod = local - d.toEpoch();
    return .{
        .date = d,
        .hour = @intCast(@divFloor(tod, std.time.s_per_hour)),
        .minute = @intCast(@divFloor(@mod(tod, std.time.s_per_hour), std.time.s_per_min)),
    };
}

test "classify: mutual funds are 5-letter X-suffixed tickers" {
    try testing.expectEqual(InstrumentKind.mutual_fund, classify("FDSCX"));
    try testing.expectEqual(InstrumentKind.mutual_fund, classify("VSTCX"));
    try testing.expectEqual(InstrumentKind.mutual_fund, classify("FAGIX"));
    try testing.expectEqual(InstrumentKind.mutual_fund, classify("VFINX"));
    try testing.expectEqual(InstrumentKind.equity, classify("AAPL"));
    try testing.expectEqual(InstrumentKind.equity, classify("VTI"));
    try testing.expectEqual(InstrumentKind.equity, classify("SPY"));
    try testing.expectEqual(InstrumentKind.equity, classify("GOOGL"));
    try testing.expectEqual(InstrumentKind.equity, classify("VOOX")); // 4 chars, not a fund
    try testing.expectEqual(InstrumentKind.equity, classify("FDSCA")); // 5 chars, not X-suffixed
    try testing.expectEqual(InstrumentKind.equity, classify("FDSCXA")); // 6 chars ending in A
    try testing.expectEqual(InstrumentKind.equity, classify("X")); // too short
    try testing.expectEqual(InstrumentKind.equity, classify("")); // empty
}

test "etLocalToUtc: EST and EDT offsets" {
    // Winter (EST, UTC-5): 16:55 ET == 21:55 UTC.
    const est = etLocalToUtc(Date.fromYmd(2025, 1, 15), equity_target_s);
    try testing.expectEqual(utcSeconds(2025, 1, 15, 21, 55), est);

    // Summer (EDT, UTC-4): 16:55 ET == 20:55 UTC.
    const edt = etLocalToUtc(Date.fromYmd(2025, 7, 15), equity_target_s);
    try testing.expectEqual(utcSeconds(2025, 7, 15, 20, 55), edt);
}

test "etLocalToUtc: across a spring-forward boundary" {
    // 2025 DST begins Sun Mar 9. A Monday Mar 10 target is firmly EDT
    // even if "now" were the preceding (EST) week. 16:55 EDT == 20:55 UTC.
    const mon_after = etLocalToUtc(Date.fromYmd(2025, 3, 10), equity_target_s);
    try testing.expectEqual(utcSeconds(2025, 3, 10, 20, 55), mon_after);
}

test "nextCandleExpiry equity: intraday -> today's close boundary" {
    // Wed 2025-06-11, 10:00 ET (14:00 UTC, EDT). Expiry is today 16:55 ET.
    const now = utcSeconds(2025, 6, 11, 14, 0);
    const exp = nextCandleExpiry(now, .equity);
    const p = etParts(exp);
    try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 11)));
    try testing.expectEqual(@as(u8, 16), p.hour);
    try testing.expectEqual(@as(u8, 55), p.minute);
}

test "nextCandleExpiry equity: after close -> next trading day" {
    // Wed 2025-06-11, 18:00 ET (22:00 UTC). Past today's 16:55 -> Thu.
    const now = utcSeconds(2025, 6, 11, 22, 0);
    const p = etParts(nextCandleExpiry(now, .equity));
    try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 12)));
    try testing.expectEqual(@as(u8, 16), p.hour);
}

test "nextCandleExpiry equity: Friday evening skips the weekend" {
    // Fri 2025-06-13, 18:00 ET. Next equity boundary is Mon 2025-06-16.
    const now = utcSeconds(2025, 6, 13, 22, 0);
    const p = etParts(nextCandleExpiry(now, .equity));
    try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 16)));
}

test "nextCandleExpiry equity: skips a holiday (Thanksgiving)" {
    // Wed 2025-11-26 evening. Thu 11-27 is Thanksgiving -> boundary is
    // Fri 2025-11-28.
    const now = utcSeconds(2025, 11, 26, 23, 0);
    const p = etParts(nextCandleExpiry(now, .equity));
    try testing.expect(p.date.eql(Date.fromYmd(2025, 11, 28)));
}

test "nextCandleExpiry mutual_fund: Friday evening -> Saturday morning" {
    // Fri 2025-06-13, 20:00 ET (past 03:25). Friday's NAV posts Sat AM.
    const now = utcSeconds(2025, 6, 14, 0, 0); // 2025-06-13 20:00 EDT
    const p = etParts(nextCandleExpiry(now, .mutual_fund));
    try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 14))); // Saturday
    try testing.expectEqual(@as(u8, 3), p.hour);
    try testing.expectEqual(@as(u8, 25), p.minute);
}

test "nextCandleExpiry mutual_fund: Saturday morning -> Tuesday morning" {
    // Sat 2025-06-14, 05:00 ET. No new NAV Sun/Mon AM; next is Tue (Mon's NAV).
    const now = utcSeconds(2025, 6, 14, 9, 0); // 2025-06-14 05:00 EDT
    const p = etParts(nextCandleExpiry(now, .mutual_fund));
    try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 17))); // Tuesday
}

test "nextCandleExpiry mutual_fund: Monday morning before NAV -> Tuesday" {
    // Mon 2025-06-16, 01:00 ET (before 03:25). Monday's NAV not out yet,
    // and there's no new NAV Monday AM either -> Tue 03:25.
    const now = utcSeconds(2025, 6, 16, 5, 0); // 2025-06-16 01:00 EDT
    const p = etParts(nextCandleExpiry(now, .mutual_fund));
    try testing.expect(p.date.eql(Date.fromYmd(2025, 6, 17)));
}

test "isHoliday: 2025 NYSE holidays" {
    // Fixed-date with observance.
    try testing.expect(isHoliday(Date.fromYmd(2025, 1, 1))); // New Year (Wed)
    try testing.expect(isHoliday(Date.fromYmd(2025, 6, 19))); // Juneteenth (Thu)
    try testing.expect(isHoliday(Date.fromYmd(2025, 7, 4))); // Independence (Fri)
    try testing.expect(isHoliday(Date.fromYmd(2025, 12, 25))); // Christmas (Thu)
    // Floating.
    try testing.expect(isHoliday(Date.fromYmd(2025, 1, 20))); // MLK (3rd Mon)
    try testing.expect(isHoliday(Date.fromYmd(2025, 2, 17))); // Washington (3rd Mon)
    try testing.expect(isHoliday(Date.fromYmd(2025, 5, 26))); // Memorial (last Mon)
    try testing.expect(isHoliday(Date.fromYmd(2025, 9, 1))); // Labor (1st Mon)
    try testing.expect(isHoliday(Date.fromYmd(2025, 11, 27))); // Thanksgiving (4th Thu)
    // Non-holidays.
    try testing.expect(!isHoliday(Date.fromYmd(2025, 7, 3)));
    try testing.expect(!isHoliday(Date.fromYmd(2025, 12, 24)));
    try testing.expect(!isHoliday(Date.fromYmd(2025, 6, 11)));
}

test "isHoliday: weekend observance rules" {
    // Independence Day 2026 falls on Saturday -> observed Fri 2026-07-03.
    try testing.expect(isHoliday(Date.fromYmd(2026, 7, 3)));
    try testing.expect(!isHoliday(Date.fromYmd(2026, 7, 4))); // the Saturday itself

    // New Year's Day 2022 was a Saturday: NOT observed on Fri 2021-12-31.
    try testing.expect(!isHoliday(Date.fromYmd(2021, 12, 31)));

    // New Year's Day 2023 was a Sunday -> observed Mon 2023-01-02.
    try testing.expect(isHoliday(Date.fromYmd(2023, 1, 2)));

    // Juneteenth not modeled before 2022.
    try testing.expect(!isHoliday(Date.fromYmd(2021, 6, 18)));
}

test "isTradingDay: weekdays, weekends, holidays" {
    try testing.expect(isTradingDay(Date.fromYmd(2025, 6, 11))); // Wed
    try testing.expect(!isTradingDay(Date.fromYmd(2025, 6, 14))); // Sat
    try testing.expect(!isTradingDay(Date.fromYmd(2025, 6, 15))); // Sun
    try testing.expect(!isTradingDay(Date.fromYmd(2025, 11, 27))); // Thanksgiving
}

test "staleCandleExpiry equity: due bar merely late retries soon" {
    // Thu 2025-06-12, 17:30 ET: Thursday's bar is due (16:55 ET passed)
    // but only ~35m overdue, well inside the lag grace window.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 12), 17 * std.time.s_per_hour + 30 * std.time.s_per_min);
    // Cache last has Wed -> Thursday's bar is due but unposted (lag) -> short retry.
    try testing.expectEqual(now + short_retry_s, staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 11)));
    // Cache already has Thu -> nothing newer is due -> normal next boundary.
    try testing.expectEqual(nextCandleExpiry(now, .equity), staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 12)));
}

test "staleCandleExpiry equity: weekend gap is not missing" {
    // Sat 2025-06-14, 10:00 ET: latest available equity data is still
    // Friday, which the cache already has - nothing is overdue.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 10 * std.time.s_per_hour);
    const exp = staleCandleExpiry(now, .equity, Date.fromYmd(2025, 6, 13));
    try testing.expectEqual(nextCandleExpiry(now, .equity), exp);
    // The long boundary (Mon), not a 30-minute retry.
    try testing.expect(exp > now + short_retry_s);
    try testing.expect(etParts(exp).date.eql(Date.fromYmd(2025, 6, 16))); // Monday
}

test "staleCandleExpiry equity: un-modeled Good Friday closure gives up after grace" {
    // Good Friday 2025-04-18 is NOT a modeled holiday (it needs the Easter
    // computus - see isHoliday), so the calendar treats it as a trading
    // day and an incremental fetch keeps coming back empty all day. The
    // Friday bar is due at 16:55 ET; the grace window ends 90m later.
    try testing.expect(isTradingDay(Date.fromYmd(2025, 4, 18)));
    const last_cached = Date.fromYmd(2025, 4, 17); // Thursday

    // Just inside the window (+89m): keep retrying in case the bar shows
    // up late.
    const inside = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 24 * std.time.s_per_min);
    try testing.expectEqual(inside + short_retry_s, staleCandleExpiry(inside, .equity, last_cached));

    // At the 90-minute window (16:55 + 90m = 18:25 ET) we conclude the
    // market was closed and wait for the next real session (Mon
    // 2025-04-21) instead of thrashing every 30 minutes all weekend. The
    // boundary is strict: the +89m case above still retries, so the
    // retries land at +30/+60/+90 and the +90 one trips this fallback.
    const at_window = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 25 * std.time.s_per_min);
    const exp = staleCandleExpiry(at_window, .equity, last_cached);
    try testing.expectEqual(nextCandleExpiry(at_window, .equity), exp);
    try testing.expect(exp > at_window + short_retry_s); // not a short retry
    const p = etParts(exp);
    try testing.expect(p.date.eql(Date.fromYmd(2025, 4, 21))); // Monday
    try testing.expectEqual(@as(u8, 16), p.hour);
    try testing.expectEqual(@as(u8, 55), p.minute);
}

test "staleCandleExpiry mutual_fund: late NAV within grace retries soon" {
    // Tue 2025-06-17, 04:30 ET: Monday's NAV (data_date 2025-06-16) is due
    // (posts ~03:25 ET) but the provider is lagging by ~65m, still inside
    // the grace window. Also exercises the morning -> prior-session mapping
    // in latestAvailability.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 17), 4 * std.time.s_per_hour + 30 * std.time.s_per_min);
    // Cache has through Friday's NAV -> Monday's is missing -> short retry.
    try testing.expectEqual(now + short_retry_s, staleCandleExpiry(now, .mutual_fund, Date.fromYmd(2025, 6, 13)));
    // Cache already has Monday -> nothing newer is due -> normal boundary.
    try testing.expectEqual(nextCandleExpiry(now, .mutual_fund), staleCandleExpiry(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
}

test "candleFreshness equity: lagging vs current" {
    // Thu 2025-06-12, 17:30 ET: Thursday's bar is due (16:55 passed) but
    // only ~35m overdue, well inside the lag grace window.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 12), 17 * std.time.s_per_hour + 30 * std.time.s_per_min);
    // Cache last has Wed -> Thursday's bar is due but unposted -> lagging.
    try testing.expectEqual(CandleFreshness.lagging, candleFreshness(now, .equity, Date.fromYmd(2025, 6, 11)));
    // Cache already has Thu -> nothing newer is due -> current.
    try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .equity, Date.fromYmd(2025, 6, 12)));
}

test "candleFreshness equity: weekend gap is current" {
    // Sat 2025-06-14, 10:00 ET: latest available equity data is still
    // Friday, which the cache already has - nothing is overdue.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 10 * std.time.s_per_hour);
    try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .equity, Date.fromYmd(2025, 6, 13)));
}

test "candleFreshness equity: un-modeled closure goes overdue after grace" {
    // Good Friday 2025-04-18 is NOT a modeled holiday (see isHoliday), so
    // the calendar treats it as a trading day and the bar never appears.
    const last_cached = Date.fromYmd(2025, 4, 17); // Thursday
    // Just inside the window (+89m): still lagging in case the bar is late.
    const inside = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 24 * std.time.s_per_min);
    try testing.expectEqual(CandleFreshness.lagging, candleFreshness(inside, .equity, last_cached));
    // At the 90-minute window: conclude closure -> overdue.
    const at_window = etLocalToUtc(Date.fromYmd(2025, 4, 18), 18 * std.time.s_per_hour + 25 * std.time.s_per_min);
    try testing.expectEqual(CandleFreshness.overdue, candleFreshness(at_window, .equity, last_cached));
}

test "candleFreshness mutual_fund: late NAV is lagging" {
    // Tue 2025-06-17, 04:30 ET: Monday's NAV (data_date 2025-06-16) is due
    // (posts ~03:25 ET) but the provider is lagging ~65m, inside the grace
    // window.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 17), 4 * std.time.s_per_hour + 30 * std.time.s_per_min);
    // Cache has through Friday's NAV -> Monday's is missing -> lagging.
    try testing.expectEqual(CandleFreshness.lagging, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 13)));
    // Cache already has Monday -> nothing newer is due -> current.
    try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
}

test "shouldRefresh equity: just-closed session with only yesterday's bar -> refresh" {
    // Fri 2025-06-13, 17:00 ET: Friday's bar is due (past the 16:55
    // boundary) but the cache only holds Thursday 06-12. The old naive
    // `last_cached + 1 >= today` check skipped this fetch and froze the
    // cache until Monday; shouldRefresh must say yes.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 17 * std.time.s_per_hour);
    try testing.expect(shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 12)));
}

test "shouldRefresh equity: already holding the just-closed bar -> no refresh" {
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 17 * std.time.s_per_hour);
    try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 13)));
}

test "shouldRefresh equity: pre-close, yesterday's bar is still the latest -> no refresh" {
    // Fri 2025-06-13, 10:00 ET: before the 16:55 boundary, Thursday's bar
    // is still the latest available. (The case the old check got right.)
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 13), 10 * std.time.s_per_hour);
    try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 12)));
}

test "shouldRefresh equity: weekend gap holding Friday's bar -> no refresh" {
    // Sun 2025-06-15: nothing newer than Friday is due over the weekend,
    // so no wasteful fetch (the old calendar check would have fetched).
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 15), 12 * std.time.s_per_hour);
    try testing.expect(!shouldRefresh(now, .equity, Date.fromYmd(2025, 6, 13)));
}

test "shouldRefresh mutual_fund: weekend morning holding latest NAV -> no refresh" {
    // Sat 2025-06-14, 05:00 ET: Friday's NAV (data_date 06-13) posted this
    // morning; holding it means nothing newer is due.
    const now = etLocalToUtc(Date.fromYmd(2025, 6, 14), 5 * std.time.s_per_hour);
    try testing.expect(!shouldRefresh(now, .mutual_fund, Date.fromYmd(2025, 6, 13)));
}

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