UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

133 lines (132 loc) 7.09 kB
/** * # session/clock — the session calendar (ET wall-clock ⇆ epoch-ms, dynamic tau) * * Pure calendar arithmetic over the IANA `America/New_York` zone (the US options session's * clock). **No wall clock is read** — every function is a deterministic function of an injected * epoch-ms instant and/or the session date, so replay is byte-stable (RUNTIME §0). The tz * database (EST/EDT transitions) is supplied by the runtime's ICU; a given (date, instant) maps * to the same wall time on every machine. * * Three jobs: * - {@link etMinuteOfDay} — minutes-since-ET-midnight for an instant (0..1439). Drives the * `time HH:MM` trigger window and the absolute `ttl 16:00` deadline (RUNTIME §5, provider * hook `timeOfDayMinutes`). * - {@link etWallClockMs} — the epoch-ms that reads as a given ET wall time on a session date * (e.g. 16:00 ET on `2026-07-10` = the settle instant). * - {@link expiryTauYears} — the per-event time-to-expiry in YEARS, `max(0, (16:00 ET on the * CONTRACT's expiry date − now)) / year` — the injection `@fair` (and the maker-fair hazard) * decays from, instead of a static `--tau-hours` constant (RUNTIME §4). * * ## τ is EXPIRY-driven, never session-driven (kestrel-wcnd) * The predecessor (`dynamicTauYears(sessionDate)`) pinned the expiry instant to 16:00 ET **on the * session date** and never received the contract's expiry at all — a baked-in 0DTE identity * (`session_date == expiry`). On a multi-day (swing) tape that computes time-to-CLOSE, not * time-to-EXPIRY: on the 4-calendar-day SPY tape it returns ≈6.5h/8760h ≈ 0.00074 yr where the * truth is ≈4.27/365 ≈ 0.0117 yr — **~16× too small**, collapsing every OTM `@fair` toward zero. * {@link expiryTauYears} therefore takes the per-book `expiry` and derives the settle instant from * IT. The session date survives for exactly ONE job: resolving the relative `0dte` tag (the only * tag resolvable without a trading calendar). * * **Fail closed** (RUNTIME §8): an absent, unparseable, or unresolvable-tag expiry returns `null` * — `@fair` is then unbuildable ⇒ UNKNOWN ⇒ the caller de-arms with a logged reason. It NEVER * silently assumes same-day expiry; that unchecked assumption is the bug this replaced. */ const MS_PER_MINUTE = 60_000; /** Hours in a (365-day) year — matches `src/fair`'s `MS_PER_YEAR` convention exactly. */ const MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000; const ET_ZONE = "America/New_York"; const ET_FORMAT = new Intl.DateTimeFormat("en-US", { timeZone: ET_ZONE, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); /** The ET wall-clock parts of an epoch-ms instant. */ function etParts(nowMs) { const parts = ET_FORMAT.formatToParts(new Date(nowMs)); const get = (t) => { const v = parts.find((p) => p.type === t)?.value; return v === undefined ? 0 : Number(v); }; return { y: get("year"), mo: get("month"), d: get("day"), h: get("hour"), mi: get("minute"), s: get("second") }; } /** Minutes since ET midnight for `nowMs` (0..1439) — the `timeOfDayMinutes` provider hook. */ export function etMinuteOfDay(nowMs) { const p = etParts(nowMs); return p.h * 60 + p.mi; } /** The ET UTC-offset (ms) at `utcMs`: `(ET-wall-time read as UTC) − utcMs`, ≈ `-4h`/`-5h`. */ function etOffsetMs(utcMs) { const p = etParts(utcMs); const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s); return asUtc - utcMs; } /** * The epoch-ms that reads as `hour:minute` ET on `sessionDate` (`YYYY-MM-DD`). One offset * correction suffices for session-hour times — the EST/EDT transition is at 02:00 local, far * from any trading-session wall time, so the offset at the naïve guess equals the true offset. */ export function etWallClockMs(sessionDate, hour, minute) { const [y, mo, d] = sessionDate.split("-").map(Number); const guess = Date.UTC(y ?? 1970, (mo ?? 1) - 1, d ?? 1, hour, minute); return guess - etOffsetMs(guess); } /** An ISO `YYYY-MM-DD` expiry token, calendar-validated (`2024-13-45` is refused). */ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; /** * The 16:00 ET settle instant of an `expiry` token, or `null` when it names no resolvable date * (fail-closed — NEVER a same-day guess). * * Resolvable tokens are exactly two: * - an absolute ISO date (`2024-06-21`) — the contract's own expiry, resolved directly; * - the relative tag `0dte` — resolvable without a trading calendar, since it BY DEFINITION names * this session's close (this is the identity that keeps a 0DTE tape byte-identical to before). * * Everything else returns `null`: an absent expiry, a malformed date, a real-looking but * non-calendar date (`2024-02-31`), and every other tag (`1dte`, `weekly`, `monthly`). A relative * `Ndte` with `N > 0` is NOT calendar arithmetic — a Friday `1dte` expires Monday — and this * module holds no trading calendar, so guessing `sessionDate + N` days would be exactly the class * of unchecked assumption that produced the bug. It fails closed instead. */ function expiryCloseMs(sessionDate, expiry) { if (expiry === undefined) return null; const token = expiry.trim(); if (token.toLowerCase() === "0dte") return etWallClockMs(sessionDate, 16, 0); const iso = ISO_DATE.exec(token); if (iso === null) return null; const [y, mo, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])]; // Round-trip the parse to refuse a non-calendar date (`2024-02-31` would otherwise roll to Mar 2). const probe = new Date(Date.UTC(y, mo - 1, d)); if (probe.getUTCFullYear() !== y || probe.getUTCMonth() !== mo - 1 || probe.getUTCDate() !== d) return null; return etWallClockMs(token, 16, 0); } /** * The per-event time-to-expiry provider (τ, in YEARS): `(now, expiry) => max(0, (16:00 ET on the * CONTRACT expiry − now)) / year`, or **`null`** when `expiry` names no resolvable date. * * `expiry` is the per-book token (`BookEvent`/`BookState` `expiry` — an ISO date or the `0dte` * tag), threaded from the SAME book the surface is built from, so a multi-day contract prices on * its own tenor. Pure in `(now, expiry)` (RUNTIME §0): the only clock read is the injected `now` * (the tape's), and `etWallClockMs` is pure calendar arithmetic — replay stays byte-stable. * * `sessionDate` is bound ONCE and used for exactly one thing: resolving the `0dte` tag. On a 0DTE * tape this returns `max(0, (16:00 ET on session_date − now))/year` — bit-for-bit what the * predecessor `dynamicTauYears(sessionDate)` returned, so 0DTE goldens do not move. */ export function expiryTauYears(sessionDate) { return (now, expiry) => { const settleMs = expiryCloseMs(sessionDate, expiry); if (settleMs === null) return null; // fail closed: @fair is UNKNOWN, never same-day-by-default return Math.max(0, (settleMs - now) / MS_PER_YEAR); }; } export { MS_PER_MINUTE, MS_PER_YEAR };