expose candle freshness data to downstream
All checks were successful
Generic zig build / build (push) Successful in 4m40s
Generic zig build / publish-macos (push) Successful in 10s
Generic zig build / deploy (push) Successful in 1m42s

This commit is contained in:
Emil Lerch 2026-06-25 15:25:20 -07:00
parent 75252c5beb
commit 3cedde20eb
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 96 additions and 22 deletions

View file

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

View file

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