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.
181 lines (180 loc) • 9.3 kB
JavaScript
/**
* # frame/options-analytics — the per-strike options surface projection (pane-library-spec §5)
*
* The **options-analytics projection**: the single context extension that turns the owner's GEX/IV
* hypothesis from unanswerable to measurable. It rides on {@link ../frame/types.ts MarketPane} as
* an optional `options` view and carries, per strike / expiry:
* - **NBBO bid / ask + mid** — OBSERVED (from the OPRA book), the two-sided liquid mid or `null`.
* - **implied vol** — a **MODEL** output, Black-Scholes (r = 0) back-out from the option mid +
* underlier spot + time-to-expiry `tau` + rate. Never observed; `null` when there is no
* positive-vol solution (a strike with no two-sided quote reads UNKNOWN, never a guessed IV).
* - **greeks** (delta, gamma, vega) — **MODEL** outputs from the same Black-Scholes read.
* - **open interest** — OBSERVED, from the OI sidecar (OPRA `statistics`), `null` when absent.
*
* ## The honesty split (CONTEXT.md — OBS vs MODEL)
* The projection delivers OBSERVED NBBO + OI and computes the MODEL (IV/greeks) at build time from
* that frozen input. IV and greeks are **computed, never sourced** — so every pane that renders one
* is a MODEL pane and MUST carry the Black-Scholes receipt: {@link OptionsAnalytics.method} names
* the method (Black-Scholes) and the rate / `tau` assumptions, the receipt *basis* every dependent
* pane cites. The construct-time honesty guard ({@link ../frame/types.ts makeField}) refuses a MODEL
* Field without its receipt + confidence.
*
* ## Purity + the T-5m guard (ADR-0009)
* The projection is a **pure function of the frozen frame input** — no wall clock, no RNG, no
* cursor. `tau` is computed from the **frozen session time at the cutoff sequence** (`cutoffTs`, the
* ts of the last event ≤ `throughSeq`), never from a wall clock and never with look-ahead: the
* surface folds only events at or before the cutoff (the T-5m no-look-ahead discipline). `asOfSeq`
* is the engine's monotonic ordinal, never a date. A stale/frozen underlier is flagged
* ({@link OptionsAnalytics.spotStale}) so a dependent pane can fail closed — a GEX read off a dead
* spot is a lie, not a number.
*/
import { MS_PER_YEAR, greeks as bsGreeks, impliedVol } from "../fair/black76.js";
/** The leg key an NBBO folds into: expiry + strike + right (this tape interleaves two expiries). */
function legKey(expiry, strike, right) {
return `${expiry}:${strike}:${right}`;
}
/**
* Fold the OPRA SPOT + BOOK tape to the causal surface at `throughSeq` — latest NBBO per
* `(expiry, strike, right)` and the latest underlier spot, considering ONLY events at/before the
* cutoff (no look-ahead — the T-5m guard). Pure over the events; reads only `ts` (for aging/`tau`),
* never a wall clock. `throughSeq` defaults to the last event's seq (the whole tape).
*/
export function reduceOptionSurface(events, throughSeq) {
const legs = new Map();
let underlier = "";
let spot = null;
let spotTs = null;
let cutoffSeq = -1;
let cutoffTs = 0;
for (const e of events) {
if (throughSeq !== undefined && e.seq > throughSeq)
break;
cutoffSeq = e.seq;
cutoffTs = e.ts;
if (e.stream !== "TICK")
continue;
if (e.type === "SPOT") {
spot = e.px;
spotTs = e.ts;
if (underlier === "")
underlier = e.instrument;
}
else if (e.type === "BOOK") {
const expiry = e.expiry ?? "";
if (underlier === "")
underlier = e.instrument;
for (const l of e.legs) {
legs.set(legKey(expiry, l.strike, l.right), {
expiry,
strike: l.strike,
right: l.right,
bid: l.bid,
ask: l.ask,
});
}
}
}
return { underlier, legs, spot, spotTs, cutoffSeq, cutoffTs };
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 2 — build the projection (IV/greeks computed here, MODEL) from the frozen surface
// ─────────────────────────────────────────────────────────────────────────────
/**
* The declared default age (ms) past which an underlier spot is STALE — the feed stopped printing,
* whatever the market did. Named ONCE (kestrel-rs4) so the two surfaces that age the SAME spot read
* the SAME backstop and can never disagree about whether it is alive: this projection (a GEX/skew
* read off a dead spot taints to UNKNOWN) and the levels pane (which tags the rendered value
* `[STALE <age>]` rather than present a dead price with live confidence).
*/
export const SPOT_STALE_AFTER_MS = 120_000;
/** The two-sided liquid mid, or `null` when a side is dark/absent or the mid is non-positive. */
function liquidMid(bid, ask) {
if (bid === null || ask === null)
return null;
const mid = 0.5 * (bid + ask);
return mid > 0 ? mid : null;
}
/** Whole days between two epoch-ms instants (floored, non-negative). Date-blind: a duration. */
function daysBetween(fromTs, toTs) {
return Math.max(0, Math.floor((toTs - fromTs) / 86_400_000));
}
/**
* Build the {@link OptionsAnalytics} projection from a frozen {@link ReducedSurface} — computing
* the MODEL IV/greeks per leg via Black-Scholes (r = 0) from the observed mid + spot + `tau`. Pure:
* a total function of `surface` + `config`. A leg with no two-sided mid, an unknown `tau`, or an
* unknown/stale spot yields `iv = null` (UNKNOWN — never a guessed IV); its greeks follow.
*/
export function buildOptionsAnalytics(surface, config) {
const multiplier = config.multiplier ?? 100;
const rate = config.rate ?? 0;
const staleMs = config.staleMs ?? SPOT_STALE_AFTER_MS;
const { spot, spotTs, cutoffTs, cutoffSeq } = surface;
const spotStaleSeconds = spotTs === null ? null : Math.max(0, Math.floor((cutoffTs - spotTs) / 1000));
const spotStale = spot === null || spotTs === null || cutoffTs - spotTs > staleMs;
// Group the reduced legs by expiry.
const byExpiry = new Map();
for (const l of surface.legs.values()) {
const arr = byExpiry.get(l.expiry) ?? [];
arr.push({ strike: l.strike, right: l.right, bid: l.bid, ask: l.ask });
byExpiry.set(l.expiry, arr);
}
const expiries = [];
for (const [expiry, rawLegs] of byExpiry) {
const closeTs = config.expiryCloseTs.get(expiry);
const tauYears = closeTs === undefined ? null : Math.max(0, (closeTs - cutoffTs) / MS_PER_YEAR);
const daysToExpiry = closeTs === undefined ? null : daysBetween(cutoffTs, closeTs);
const legs = rawLegs
.map((r) => {
const mid = liquidMid(r.bid, r.ask);
const oi = config.openInterest?.get(legKey(expiry, r.strike, r.right)) ?? null;
// IV/greeks are UNKNOWN unless we have a real mid, a real forward, and a positive tau.
let iv = null;
let delta = null;
let gamma = null;
let vega = null;
if (mid !== null && spot !== null && !spotStale && tauYears !== null && tauYears > 0) {
iv = impliedVol({ forward: spot, strike: r.strike, tauYears, price: mid, right: r.right });
if (iv !== null) {
const g = bsGreeks({ forward: spot, strike: r.strike, tauYears, sigma: iv, right: r.right });
delta = g.delta;
gamma = g.gamma;
vega = g.vega;
}
}
return { strike: r.strike, right: r.right, bid: r.bid, ask: r.ask, mid, iv, delta, gamma, vega, oi };
})
.sort((a, b) => (a.strike - b.strike) || (a.right < b.right ? -1 : a.right > b.right ? 1 : 0));
expiries.push({ expiry, tauYears, daysToExpiry, legs });
}
// Front-first (earliest close first; unknown-close expiries sort last, stably).
expiries.sort((a, b) => {
const ca = config.expiryCloseTs.get(a.expiry);
const cb = config.expiryCloseTs.get(b.expiry);
if (ca === undefined && cb === undefined)
return a.expiry < b.expiry ? -1 : 1;
if (ca === undefined)
return 1;
if (cb === undefined)
return -1;
return ca - cb;
});
const method = `Black-Scholes r=${rate}; tau=(expiry-close − cutoff)/365d; IV from two-sided NBBO mid`;
return {
underlier: surface.underlier,
spot,
spotStale,
spotStaleSeconds,
asOfSeq: cutoffSeq,
multiplier,
rate,
method,
expiries,
};
}
/** Fold OI sidecar rows into the `${expiry}:${strike}:${right}` → OI map the projection reads. */
export function openInterestMap(rows) {
const m = new Map();
for (const r of rows)
m.set(legKey(r.expiry, r.strike, r.right), r.oi);
return m;
}