diff --git a/src/market.zig b/src/market.zig index e66dcc0..2b0c1c0 100644 --- a/src/market.zig +++ b/src/market.zig @@ -275,33 +275,60 @@ fn latestAvailability(now_s: i64, kind: InstrumentKind) Availability { }; } -/// Expiry to stamp on candle meta after an *incremental* fetch on a stale -/// entry returned zero new bars. Three cases: +/// 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. **Nothing newer is due** - a genuine non-trading-day gap -/// (weekend/holiday) or the cache is already caught up: use the -/// normal next-boundary expiry. -/// 2. **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, so retry soon (`short_retry_s`). -/// 3. **Newer data has been due for the full grace window** -/// (`provider_lag_grace_s`): 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. Stop the short-retry thrash -/// and fall back to the normal next-boundary expiry, so we wait for -/// the next real session instead of refetching every `short_retry_s` -/// all evening (and, for a Friday closure, all weekend). +/// 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; +} + +/// 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 { - const avail = latestAvailability(now_s, kind); - // Case 1: nothing newer than the cache is due yet. - if (!last_cached.lessThan(avail.data_date)) return nextCandleExpiry(now_s, kind); - // Case 2: due data is merely late (still inside the grace window) -> short retry. - if (now_s - avail.instant_s < provider_lag_grace_s) return now_s + short_retry_s; - // Case 3: overdue for the whole grace window -> assume closure, wait for next boundary. - return nextCandleExpiry(now_s, kind); + 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 @@ -536,3 +563,43 @@ test "staleCandleExpiry mutual_fund: late NAV within grace retries soon" { // 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))); +} diff --git a/src/root.zig b/src/root.zig index 559dab9..4ec5acb 100644 --- a/src/root.zig +++ b/src/root.zig @@ -84,6 +84,13 @@ pub const indicators = @import("analytics/indicators.zig"); /// Fundamental analysis: valuation, momentum, quality, and yield scoring. pub const analysis = @import("analytics/analysis.zig"); +// ── Market calendar ────────────────────────────────────────── + +/// Trading-day calendar (NYSE holidays, weekends) and market-aware +/// candle-freshness boundaries: `classify`, `candleFreshness`, +/// `nextCandleExpiry`, `staleCandleExpiry`. +pub const market = @import("market.zig"); + // ── Classification ─────────────────────────────────────────── /// Sector/industry/country classification for enriched securities.