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.
158 lines (144 loc) • 7.92 kB
text/typescript
/**
* # 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",
});
interface EtParts {
readonly y: number;
readonly mo: number;
readonly d: number;
readonly h: number;
readonly mi: number;
readonly s: number;
}
/** The ET wall-clock parts of an epoch-ms instant. */
function etParts(nowMs: number): EtParts {
const parts = ET_FORMAT.formatToParts(new Date(nowMs));
const get = (t: string): number => {
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: number): number {
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: number): number {
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: string, hour: number, minute: number): number {
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: string, expiry: string | undefined): number | null {
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: string): FairTauProvider {
return (now: number, expiry?: string): number | null => {
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);
};
}
/**
* The τ-provider seam every `@fair` caller injects (RUNTIME §4). `now` is the tape instant being
* priced; `expiry` is the valued contract's own expiry token. Returns `null` when τ is unknowable
* — the caller MUST then treat `@fair` as unbuildable and fail closed, never substitute a default.
*
* The CLI's `--tau-hours` override is simply a provider that ignores both arguments and returns
* its constant — an explicit operator assertion of the tenor, which is why it is allowed to.
*/
export type FairTauProvider = (now: number, expiry?: string) => number | null;
export { MS_PER_MINUTE, MS_PER_YEAR };