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.

980 lines 52.9 kB
/** * # adapters/broker/ibkr/feed — the FEED FACE (kestrel-7o2.7) * * The {@link FeedSource} reference implementation over the ONE shared IB session: it opens * `reqMktData` subscriptions on the contracts the 7o2.6 layer resolved (the spot/equity underlier + * an option-chain slice) and turns the venue's tick stream into the **EXISTING** {@link BusEvent} * union — a `META` header, `TICK/SPOT` (`px`/`bid`/`ask`), `TICK/BOOK` (real `OptionQuote` legs + * `underlier_px`), and `TICK/HEARTBEAT` proof-of-life. No new event kind is invented, so * `SessionCore.step` folds this feed with zero changes and the deterministic core downstream is * untouched. * * It is MARKET DATA ONLY. There is no order path here — not in the module, and not even reachable * through the client: the narrow {@link IbFeedClient} surface declares nothing but `reqMktData` / * `cancelMktData` / `reqMarketDataType` and the event bus. Orders are 7o2.8 (paper) / 7o2.10 (live), * behind the human-signed arm envelope (7o2.9). * * ## The price doctrine (ARCHITECTURE §4, RUNTIME §4) — the heart of this face * * **The observed MID is a HEALTH SIGNAL, never a value and never a price this feed publishes.** An * option's book can be fictional: a maker quoting 1.00 × 3.00 on a contract with 5.10 of intrinsic is * not offering you 2.00, and a feed that carried that 2.00 forward as a number would have authored a * naked sale nobody wrote. So: * * - `TICK/SPOT.px` is the **observed last trade** the venue actually printed. When no trade has * printed, the feed emits **no SPOT at all** (fail-closed) — it never synthesizes a spot out of a * quote mid. * - `TICK/BOOK` legs carry the **observed sides verbatim** (`bid`/`ask`/`bsz`/`asz`), with a dark * side as `null` — never `0`, never omitted — so a downstream reducer can tell a genuinely * one-sided book (the market-maker-pull fingerprint of a real move) from a two-sided one. * - `@fair` ({@link feedFair}) is the ENGINE's `executionFair` — underlying-anchored Black-76 backed * out of the LIQUID two-sided quotes and **floored at intrinsic always** — and it carries a * {@link FairReceipt}: the model VERSION, the FIT QUALITY (how many liquid strikes backed the * surface, and the IV it priced at), and the FRESHNESS (`asof`). Belief is the author's; fair is * the engine's, and it says how it was earned. * - A **one-sided or dark book** yields `fair === null` and FAILS CLOSED to an **annotated** book * value — a SELL degrades to its intrinsic floor (`fair=fallback(intrinsic;mid-dark)`, never * naked), a BUY with no two-sided mid is genuinely unresolvable. The annotation ALWAYS names the * fallback, so a silent mid can never slip through (RUNTIME §4). * - The mid survives ONLY inside {@link QuoteHealth}: a spread WIDTH and a dimensionless spread * REGIME (`tight | wide | one-sided | dark`). There is deliberately no `mid` field anywhere on * this module's surface, so a caller cannot read one off it as a price even by accident. * * ## Staleness — the watermark (the rs4/8kc bug class, first-class here) * * A **frozen, re-printed quote does NOT advance `asOfSeq`.** IB will happily re-send an identical * bid/ask forever while the market underneath it is gone; a feed that treated each re-print as news * would keep a dead tape looking alive. So the watermark advances on a **value CHANGE**, never on a * print: a re-print increments a `reprints` counter (the frozen fingerprint) and refreshes only * `lastPrintTs` (the LINE is talking), while `asOfSeq`/`asOfTs` (the VALUE) stay pinned. Past the * staleness threshold the line is STALE and the feed's health goes **DEGRADED** with a logged reason * — never a silent one. * * That degradation has TEETH, and this is the part that matters: canonical state cannot un-know a * price it has already folded, so the frozen 445.10 sits in {@link CanonicalState} looking perfectly * alive and would happily fire an armed plan. {@link stalenessGatedProvider} wraps the real * {@link SeriesProvider} and, while the underlier line is dead, resolves **every market-side series to * UNKNOWN** rather than to its last-known value. Only a definite `true` fires (RUNTIME §3), so an * UNKNOWN operand taints every dependent trigger and the affected plans **cannot fire off a dead * feed**. A dead feed must never read as live. * * ## Determinism at the edge * * The IB client, the clock, the request-id minter and the deadline scheduler are all INJECTED (as the * transport and the contract layer inject them), so unit tests drive an in-memory double with no * socket and no timer. Events carry a monotone `seq` from 0 and the INJECTED `ts` — so the BusEvents * this edge produces are causally ordered and replay-stable, which is exactly what the deterministic * core downstream requires of them. */ // `TickType` is a TYPE-only alias in `@stoqey/ib` (a union over the two tick vocabularies); the // runtime enum is `IBApiTickType`. Import each in its own register rather than conflating them. import { EventName, OptionType, SecType, IBApiTickType } from "@stoqey/ib"; import { redactAccount } from "./config.js"; import { IbRequestRouter, IbkrRequestError } from "./requests.js"; import { atmCoverage, atmTaint, surfaceWindow, DEFAULT_ATM_BAND_FRACTION, DEFAULT_SURFACE_HALF_WIDTH, } from "./surface-window.js"; import { resolveContract, resolveUnderlier, listOptionStrikes, IBKR_DEFAULT_EXCHANGE } from "./contract.js"; import { BUS_SCHEMA } from "../../../bus/index.js"; import { executionFair, intrinsic, EXEC_FAIR_MODEL } from "../../../fair/index.js"; import { UNKNOWN, defaultSeriesRegistry, isUnknown, } from "../../../series/index.js"; // ───────────────────────────────────────────────────────────────────────────── // Defaults // ───────────────────────────────────────────────────────────────────────────── /** How long a line's VALUE may go unchanged before it is STALE. A quote that has not MOVED in this * long is not evidence of a calm market — it is a line we can no longer vouch for. */ export const DEFAULT_STALE_AFTER_MS = 15_000; /** How long one `reqMktData` may go completely unanswered before the subscription FAILS. A gateway * that never answers is a FAILED request, never an infinite wait (fail-closed). */ export const DEFAULT_SUBSCRIBE_TIMEOUT_MS = 10_000; /** Spread-to-mid ratio above which a two-sided book is a WIDE one — the dimensionless regime * boundary. A RATIO, never a price: the mid appears here only as a denominator. */ export const WIDE_SPREAD_RATIO = 0.15; /** * The ONE shared IB session, viewed through the READ-ONLY market-data surface (kestrel-7o2.7). The * transport hands out its single guarded client (throwing the typed connection error if the session * is not connected or has gone degraded, so a quote can never be read over a dead socket); this * re-views it as an {@link IbFeedClient}. The cast NARROWS toward the market-data requests — the real * `IBApi` is a structural superset, and this view can place nothing. There is never a second socket. */ export function feedClientOf(transport) { return transport.client(); } // ───────────────────────────────────────────────────────────────────────────── // Typed failures // ───────────────────────────────────────────────────────────────────────────── /** Why the FEED itself failed (kestrel-7o2.7) — distinct from a single refused subscription * ({@link IbkrRequestError}) and from a dead socket (`IbkrConnectionError`). A feed with no underlier * has no tape at all: there is no `underlier_px` to quote a book against and no spot to drive * canonical state, so it fails CLOSED rather than emitting a chain floating in a vacuum. */ export class IbkrFeedError extends Error { name = "IbkrFeedError"; reason; constructor(reason, options = {}) { super(`IBKR feed refused: ${reason} (fail-closed; STAND_DOWN)`, options.cause === undefined ? undefined : { cause: options.cause }); this.reason = reason; } } /** * Resolve `@fair` for one option leg off an observed two-sided book (kestrel-7o2.7). * * Delegates the VALUE to the engine's own {@link executionFair} — this module does not own a second * pricing model, and it does not re-price. It owns exactly the fail-closed half the price doctrine * demands: when fair is unbuildable, produce the **annotated** book value (mirroring * `engine/pricing.ts` `resolveFair` field-for-field, including its annotation strings — those strings * ARE the audit trail RUNTIME §4 requires), and reduce the observed mid to a health signal so it can * never be mistaken for a price. * * **AND IT REFUSES TO VOUCH FOR A SURFACE THAT DOES NOT KNOW THE MONEY (kestrel-7o2.19).** `fair` is * receipt-gated — it CARRIES WHETHER IT CAN BE TRUSTED (ARCHITECTURE §4) — and a receipt reporting * `nLiquid=5` off five deep-ITM strikes while the money sits eleven points higher is a receipt that * does not know it is lying. So {@link atmCoverage} is measured on EVERY resolution, off the same legs * and the same liquidity rule the surface itself uses; a surface with no liquid strike NEAR the money * (or none BRACKETING it, which makes an at-the-money read a flat extrapolation of a wing's IV) either * FAILS CLOSED to the annotated fallback — the default — or returns its value TAINTED. Never a * confident number off a surface that does not know the money. */ export function feedFair(input) { const { underlierPx, strike, right, side, tauYears, legs, asof } = input; const policy = input.atmPolicy ?? "fail-closed"; const target = legs.find((l) => l.strike === strike && l.right === right); const health = quoteHealthOf(target); // The one usable-underlier test, computed once (and narrowing, so no cast is needed below): a spot // is a real, finite, positive print, or it is not a spot at all. const px = underlierPx !== null && Number.isFinite(underlierPx) && underlierPx > 0 ? underlierPx : null; // The HONEST fit quality, measured off the SAME legs the model is handed, with the SAME liquidity // rule the surface is built by — always, so a caller can read WHY even when a value did resolve. const coverage = atmCoverage({ legs, spot: px, tauYears, ...(input.bandFraction === undefined ? {} : { bandFraction: input.bandFraction }), }); const built = px === null ? null : executionFair({ underlyingSpot: px, strike, right, tauYears, liquidQuotes: legs, asof }); if (built !== null && coverage.supportsAtm) { // The model spoke, floored at intrinsic, with a receipt — off a surface that KNOWS THE MONEY. Note // what this value is NOT: it is not the target leg's mid — a wide/crushed book's mid is exactly the // number the floor exists to refuse (a maker will not sell you 5.10 of intrinsic for a 2.00 mid). return { value: built.value, receipt: built.receipt, fallback: null, health, coverage, taint: null }; } if (built !== null && policy === "taint") { // The number survives, but it travels WEARING the reason it cannot be trusted. A caller that reads // `value` and ignores `taint` has been told, in words, that it is reading an unvouched-for number. return { value: built.value, receipt: built.receipt, fallback: null, health, coverage, taint: atmTaint(coverage) }; } // FAIL CLOSED to an ANNOTATED book value. The annotation always names the fallback — and, when the // model DID speak and we refused it anyway, it names THAT too (`atm-uncovered`): the value was not // MISSING, it was UNTRUSTWORTHY, and those are different failures with different remedies. const uncovered = built !== null; // reaching here WITH a model value ⇒ the refusal is the coverage one const because = uncovered ? ` — ${atmTaint(coverage)}` : ""; const tag = (base) => uncovered ? `fair=fallback(${base};atm-uncovered)${because}` : `fair=fallback(${base})`; const mid = midOf(target); // used ONLY to decide the fallback SHAPE — see the annotations below const floor = px === null ? null : intrinsic(px, strike, right); if (side === "buy") { if (mid === null) { return { value: null, receipt: null, fallback: { px: null, annotation: "fair unbuildable: the book is one-sided/dark and a BUY has no two-sided mid to fall back to — price off @bid/@ask/@last (fail-closed, RUNTIME §4)" + (uncovered ? ` [atm-uncovered]${because}` : ""), }, health, coverage, taint: null, }; } return { value: null, receipt: null, fallback: { px: mid, annotation: tag("mid") }, health, coverage, taint: null, }; } // SELL — NEVER NAKED. Floored at intrinsic, whatever the book says. if (floor === null) { return { value: null, receipt: null, fallback: { px: null, annotation: "fair unbuildable: no usable underlier, so even the intrinsic floor is unknowable — a SELL is refused rather than sent naked (fail-closed, RUNTIME §4)", }, health, coverage, taint: null, }; } if (mid === null) { return { value: null, receipt: null, fallback: { px: floor, annotation: tag("intrinsic;mid-dark") }, health, coverage, taint: null, }; } return { value: null, receipt: null, fallback: { px: Math.max(mid, floor), annotation: tag("max(mid,intrinsic)") }, health, coverage, taint: null, }; } /** The observed book's health. The mid enters ONLY as the denominator of a dimensionless ratio and * never leaves this function as a number. */ function quoteHealthOf(q) { const bid = q?.bid ?? null; const ask = q?.ask ?? null; const dark = bid === null && ask === null; const twoSided = bid !== null && ask !== null; if (!twoSided) { return { twoSided: false, dark, spread: null, spreadRegime: dark ? "dark" : "one-sided" }; } const spread = ask - bid; const denom = 0.5 * (bid + ask); const wide = denom <= 0 || spread / denom > WIDE_SPREAD_RATIO; return { twoSided: true, dark: false, spread, spreadRegime: wide ? "wide" : "tight" }; } /** The two-sided mid — the ONE internal use the doctrine allows: deciding the SHAPE of an annotated * fallback (RUNTIME §4 defines the fallback in terms of it). It never becomes a `@fair` value, never * reaches a BusEvent, and never appears on this module's public surface. */ function midOf(q) { if (q === undefined || q.bid === null || q.ask === null) return null; if (!Number.isFinite(q.bid) || !Number.isFinite(q.ask) || q.ask < q.bid) return null; return 0.5 * (q.bid + q.ask); } class Line { reqId; key; kind; contract; raw = {}; /** The last DERIVED quote. `null` until the line's first print — which is why a first print that is * itself DARK still counts as news (the darkness is the news). */ quote = null; asOfSeq = null; asOfTs = null; lastPrintTs = null; reprints = 0; dirty = false; state = "pending"; error = null; deadline; settle; refuse; ready; constructor(reqId, key, kind, contract) { this.reqId = reqId; this.key = key; this.kind = kind; this.contract = contract; this.ready = new Promise((resolve, reject) => { this.settle = resolve; this.refuse = reject; }); // A refusal is delivered to whoever awaits `ready`; nobody is REQUIRED to await it (the feed's // own `open()` collects it either way), so pre-empt Node's unhandled-rejection noise. this.ready.catch(() => { }); } } /** Derive the honest quote from the raw ticks. IB's OWN no-data convention is honoured verbatim: a * price of −1 (or a 0 with a 0 size) means "there is no data for this field", which is a DARK side — * `null`, never `0`. Confusing IB's "no data" sentinel for a real 0.00 bid would fabricate a book. */ function deriveQuote(raw) { const side = (px, sz) => { if (px === undefined || !Number.isFinite(px)) return null; if (px < 0) return null; // IB's explicit no-data sentinel if (px === 0 && (sz === undefined || sz <= 0)) return null; // "0 price with 0 size" ⇒ no data return px; }; const bid = side(raw.bid, raw.bsz); const ask = side(raw.ask, raw.asz); const sizeOf = (v, present) => present === null || v === undefined || !Number.isFinite(v) || v < 0 ? null : v; const last = raw.last !== undefined && Number.isFinite(raw.last) && raw.last > 0 ? raw.last : undefined; return { bid, ask, bsz: sizeOf(raw.bsz, bid), asz: sizeOf(raw.asz, ask), last }; } function sameQuote(a, b) { return a.bid === b.bid && a.ask === b.ask && a.bsz === b.bsz && a.asz === b.asz && a.last === b.last; } /** IB delivers a delayed line on a PARALLEL set of tick types (66–71). They mean exactly the same * things — a paper account without a real-time subscription sees only these — so they are folded onto * the same fields. The feed never pretends delayed data is real-time; the FRESHNESS it vouches for is * the `asof` on the receipt and the watermark, which are honest either way. */ function fieldOf(t) { switch (t) { case IBApiTickType.BID: case IBApiTickType.DELAYED_BID: return "bid"; case IBApiTickType.ASK: case IBApiTickType.DELAYED_ASK: return "ask"; case IBApiTickType.BID_SIZE: case IBApiTickType.DELAYED_BID_SIZE: return "bsz"; case IBApiTickType.ASK_SIZE: case IBApiTickType.DELAYED_ASK_SIZE: return "asz"; case IBApiTickType.LAST: case IBApiTickType.DELAYED_LAST: return "last"; default: return null; // every other tick type (volume, greeks, 13-week highs …) is not a book fact } } // ───────────────────────────────────────────────────────────────────────────── // The feed // ───────────────────────────────────────────────────────────────────────────── class IbkrFeedImpl { #cfg; #client; #now; #nextReqId; #scheduler; #log; #router; #staleAfterMs; #timeoutMs; #tauYears; #underlier; #legContracts; /** reqId → line. The single dispatch table for every tick and every refusal. */ #lines = new Map(); /** The underlier's line — the canonical coordinate everything else hangs off. */ #spotLine; #emitted = []; #drained = 0; #seq = 0; #opened = false; #closed = false; /** DEGRADED is latched with its FIRST reason (the root cause), and logged exactly once on the * rising edge — never a per-pump storm, and never silent. */ #degradedReason = null; #registered = []; constructor(cfg, deps) { this.#cfg = cfg; this.#client = deps.client; this.#now = deps.now; this.#nextReqId = deps.nextReqId ?? defaultNextReqId; this.#scheduler = deps.scheduler ?? defaultScheduler; this.#log = deps.log ?? (() => { }); this.#staleAfterMs = cfg.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; this.#timeoutMs = cfg.subscribeTimeoutMs ?? DEFAULT_SUBSCRIBE_TIMEOUT_MS; this.#tauYears = cfg.tauYears ?? 0; this.#underlier = deps.underlier; this.#legContracts = deps.legs ?? []; this.#router = new IbRequestRouter(deps.client, { log: deps.log }); } // ── open ─────────────────────────────────────────────────────────────────── async open() { if (this.#opened) throw new IbkrFeedError("the feed is already open — a second open on one feed would double-subscribe the shared socket"); this.#opened = true; // META FIRST — always. A tape whose header does not lead it is not a bus. this.emitMeta(); // The market-data flavour, when the caller pinned one. Never chosen for them: a feed that // silently downgraded a caller to DELAYED data would be a feed lying about its own freshness. if (this.#cfg.marketDataType !== undefined) { this.#client.reqMarketDataType?.(this.#cfg.marketDataType); } this.#listen(EventName.tickPrice, (reqId, field, value) => { this.onTick(reqId, field, value); }); this.#listen(EventName.tickSize, (reqId, field, value) => { if (field === undefined || value === undefined) return; this.onTick(reqId, field, value); }); // Every subscription is issued SYNCHRONOUSLY here (deadline armed with it), so a caller that // trips the injected scheduler by hand — or a gateway that answers instantly — is never racing us. this.#spotLine = this.subscribeOne(this.#underlier, this.#underlier.symbol, "spot"); for (const leg of this.#legContracts) { this.subscribeOne(leg, legKey(this.#underlier.symbol, leg.strike, leg.right), "option"); } const lines = [...this.#lines.values()]; await Promise.all(lines.map((l) => l.ready.then(() => undefined, () => undefined))); // FAIL CLOSED on the underlier: with no spot line there is no `underlier_px` to quote a book // against and no price to drive canonical state — a chain floating in a vacuum is not a tape. if (this.#spotLine.state === "failed") { const cause = this.#spotLine.error; this.close(); throw new IbkrFeedError(`the UNDERLIER subscription (${this.#underlier.symbol}) was refused — no underlier means no tape at all${cause === null ? "" : `: ${cause.message}`} [account=${redactAccount(this.#cfg.account)}]`, { cause: cause ?? undefined }); } const failed = lines.filter((l) => l.state === "failed" && l.error !== null).map((l) => l.error); for (const err of failed) { // A refused LEG is dropped, loudly. It is never quoted as a phantom — a leg the venue refused // to quote has no book, and an empty book is not a zero book. this.#log(`feed: dropping refused leg [reqId=${err.reqId}] ${err.label} — ${err.message}`); } const live = this.#legContracts.filter((c) => this.lineOf(c)?.state === "live"); return { underlier: this.#underlier, legs: live, failed }; } subscribeOne(contract, key, kind) { const reqId = this.#nextReqId(); const line = new Line(reqId, key, kind, contract); this.#lines.set(reqId, line); const label = `market data for ${key}`; // THE CHANNEL (the kestrel-7o2.16 follow-up): a request-scoped IB error on THIS reqId lands on // THIS subscription — not on a log sink that defaults to a no-op, and never on the shared session. this.#router.open(reqId, label, (err) => { this.failLine(line, err); }); // The deadline: a gateway that never answers is a FAILED subscription, not an infinite wait. line.deadline = this.#scheduler.setInterval(() => { this.failLine(line, new IbkrRequestError(reqId, label, `the subscription timed out after ${this.#timeoutMs}ms — the gateway sent no tick at all, and a silent subscription is a FAILED one, never an infinite wait`)); }, this.#timeoutMs); try { this.#client.reqMktData(reqId, ibContractOf(contract), "", false, false); } catch (cause) { this.failLine(line, new IbkrRequestError(reqId, label, "the market-data request threw at the socket", { cause })); } return line; } failLine(line, err) { if (line.state === "failed") return; // first refusal wins (a latch — never a cascade) line.state = "failed"; line.error = err; this.clearDeadline(line); this.#router.close(line.reqId); line.refuse(err); } liveLine(line) { if (line.state !== "pending") return; line.state = "live"; this.clearDeadline(line); line.settle(); } clearDeadline(line) { if (line.deadline !== undefined) { this.#scheduler.clearInterval(line.deadline); line.deadline = undefined; } } // ── ticks ────────────────────────────────────────────────────────────────── /** * One IB tick. THE STALENESS RULE LIVES HERE: a print refreshes `lastPrintTs` (the LINE spoke) but * only a CHANGED value marks the line dirty (the VALUE moved). An identical re-print increments * `reprints` and moves NOTHING else — which is why a frozen line's `asOfSeq` cannot advance, and * why a dead tape cannot pass itself off as a live one. */ onTick(reqId, field, value) { const line = this.#lines.get(reqId); if (line === undefined) return; // another face's request on the shared socket — not ours if (line.state === "failed") return; // a refused line is dead; a late tick does not revive it const key = fieldOf(field); if (key === null) return; // not a book fact const now = this.#now(); line.raw[key] = value; line.lastPrintTs = now; // the LINE spoke — which is not the same as the market having moved this.liveLine(line); const next = deriveQuote(line.raw); if (line.quote !== null && sameQuote(next, line.quote)) { line.reprints++; // the frozen fingerprint — recorded, never mistaken for news return; } // The VALUE moved. Its as-of is the moment the venue DELIVERED it — not the moment we happened // to fold it onto the bus. (`asOfSeq` is stamped at emission, below: the two answer different // questions — "how old is this observation" vs "which emitted event last carried it".) line.quote = next; line.asOfTs = now; line.reprints = 0; line.dirty = true; } // ── pump ─────────────────────────────────────────────────────────────────── pump() { if (this.#closed) return; const now = this.#now(); let emitted = false; // SPOT first — a BOOK is quoted AGAINST an underlier context, so that context must already be on // the bus when the book lands (causal order, not merely tidy order). if (this.#spotLine.dirty) { const q = this.#spotLine.quote; const px = q?.last; if (px === undefined) { // NO OBSERVED TRADE ⇒ NO SPOT. The feed will not synthesize a price out of a quote mid; a // spot that nobody traded at is a spot nobody authored. Fail-closed, with a reason. this.#log(`feed: ${this.#spotLine.key} has a quote but NO observed last trade — no SPOT emitted (a mid is never a price; fail-closed)`); } else { this.emit({ ts: now, stream: "TICK", type: "SPOT", instrument: this.#cfg.instrument, px, // the OBSERVED last trade — never a synthesized mid bid: q?.bid ?? null, ask: q?.ask ?? null, }); this.#spotLine.asOfSeq = this.#seq - 1; this.#spotLine.dirty = false; emitted = true; } } // BOOK — emitted when ANY live leg's value changed. It carries the whole current slice (the bus's // own shape: a BOOK is a snapshot of the chain at one moment), but ONLY the legs that actually // moved advance their watermark. A clean leg riding along in a snapshot is not a fresh leg. const legLines = this.liveLegs(); const movedLegs = legLines.filter((l) => l.dirty); if (movedLegs.length > 0) { const underlierPx = this.#spotLine.quote?.last; if (underlierPx === undefined) { // A book with no underlier context cannot be honestly written (`underlier_px` is not optional // on this bus, and inventing one would invent the context every downstream read depends on). this.#log(`feed: ${movedLegs.length} leg(s) moved but the underlier has NO observed price — no BOOK emitted (underlier_px is never invented; fail-closed)`); } else { this.emit({ ts: now, stream: "TICK", type: "BOOK", instrument: this.#cfg.instrument, underlier_px: underlierPx, ...(this.#cfg.expiry === undefined ? {} : { expiry: this.#cfg.expiry }), legs: legLines.map((l) => optionQuoteOf(l)), }); for (const l of movedLegs) { l.asOfSeq = this.#seq - 1; l.dirty = false; } emitted = true; } } // A quiet interval — including one full of FROZEN RE-PRINTS — is proof of life, not news. The // heartbeat advances `seq`/`ts` and NO line's `asOfSeq`, which is precisely what lets the // watermark age a frozen line out while the tape keeps ticking. if (!emitted) { this.emit({ ts: now, stream: "TICK", type: "HEARTBEAT", ...(this.#cfg.phase === undefined ? {} : { phase: this.#cfg.phase }), }); } this.observeHealth(now); } /** Latch + LOG the degradation on its rising edge. A dead feed is never a silent feed. */ observeHealth(now) { const wm = this.watermarkAt(now); if (wm.health === "DEGRADED" && this.#degradedReason === null) { this.#degradedReason = wm.reason; this.#log(`feed DEGRADED (fail-closed): ${wm.reason ?? "(no reason)"} [account=${redactAccount(this.#cfg.account)}]`); } } // ── the FeedSource face ──────────────────────────────────────────────────── events() { return this.#emitted; } drain() { const fresh = this.#emitted.slice(this.#drained); this.#drained = this.#emitted.length; return fresh; } subscriptions() { return [...this.#lines.values()].map((l) => ({ reqId: l.reqId, key: l.key, kind: l.kind, contract: l.contract, state: l.state, error: l.error, ready: l.ready, })); } watermark() { return this.watermarkAt(this.#now()); } watermarkAt(now) { const series = [...this.#lines.values()].map((l) => this.seriesWatermark(l, now)); const spot = series.find((s) => s.kind === "spot"); const underlierStale = spot === undefined || spot.stale || spot.failed; // The underlier's death is the one that taints everything: every market series is a read off that // one causal coordinate. A dead leg degrades too — a chain we cannot vouch for is not a chain. const reasons = []; if (spot === undefined || spot.failed) { reasons.push(`the underlier line ${this.#underlier.symbol} is REFUSED/absent — there is no canonical coordinate`); } else if (spot.stale) { reasons.push(`the underlier line ${this.#underlier.symbol} is stale: its VALUE has not changed in ${spot.ageMs ?? "∞"}ms (> ${this.#staleAfterMs}ms), across ${spot.reprints} identical re-print(s) — the line is talking, the market is not`); } const staleLegs = series.filter((s) => s.kind === "option" && s.stale && !s.failed); if (staleLegs.length > 0) { reasons.push(`${staleLegs.length} option leg(s) are stale (${staleLegs.map((s) => s.key).join(", ")})`); } const health = reasons.length > 0 ? "DEGRADED" : "LIVE"; return { now, seq: this.#seq - 1, staleAfterMs: this.#staleAfterMs, health, reason: health === "DEGRADED" ? reasons.join("; ") : null, series, underlierStale, }; } seriesWatermark(l, now) { const ageMs = l.asOfTs === null ? null : now - l.asOfTs; // FAIL CLOSED ON ABSENCE: a line that has never carried a value is STALE, not "fresh by default". const stale = l.state === "failed" || l.asOfTs === null || (ageMs !== null && ageMs > this.#staleAfterMs); return { key: l.key, kind: l.kind, asOfSeq: l.asOfSeq, asOfTs: l.asOfTs, lastPrintTs: l.lastPrintTs, reprints: l.reprints, ageMs, stale, dark: l.quote === null || (l.quote.bid === null && l.quote.ask === null), failed: l.state === "failed", }; } fair(query) { const legs = this.liveLegs().map((l) => optionQuoteOf(l)); const underlierPx = this.#spotLine.quote?.last ?? null; return feedFair({ underlierPx, strike: query.strike, right: query.right, side: query.side, tauYears: this.#tauYears, legs, // The ATM-coverage policy the caller pinned (kestrel-7o2.19) — the receipt refuses to vouch for // an at-the-money valuation off a surface with no liquid strikes near the money. ...(this.#cfg.atmBandFraction === undefined ? {} : { bandFraction: this.#cfg.atmBandFraction }), ...(this.#cfg.atmPolicy === undefined ? {} : { atmPolicy: this.#cfg.atmPolicy }), // FRESHNESS, honestly: the OLDEST observation backing this valuation. A receipt that claimed the // freshest input would vouch for a freshness the surface as a whole does not have. asof: this.oldestAsOf(), }); } oldestAsOf() { const stamps = [this.#spotLine, ...this.liveLegs()] .map((l) => l.asOfTs) .filter((t) => t !== null); return stamps.length === 0 ? this.#now() : Math.min(...stamps); } /** The live option lines, in a STABLE order (strike, then right). Sorted rather than taken in * subscription/arrival order: the socket's delivery order is not a fact about the market, and if it * leaked into the `legs` array the emitted bus would stop being replay-stable. */ liveLegs() { return [...this.#lines.values()] .filter((l) => l.kind === "option" && l.state === "live") .sort((a, b) => { const ac = a.contract; const bc = b.contract; return ac.strike - bc.strike || ac.right.localeCompare(bc.right); }); } lineOf(c) { return [...this.#lines.values()].find((l) => l.contract.conId === c.conId); } close() { if (this.#closed) return; this.#closed = true; for (const line of this.#lines.values()) { this.clearDeadline(line); if (line.state === "failed") continue; // the venue already refused it; nothing rests there try { this.#client.cancelMktData(line.reqId); } catch { // Closing an already-dead subscription is a no-op, never a crash (fail-closed). } } this.#router.dispose(); for (const [event, listener] of this.#registered) this.#client.removeListener(event, listener); this.#registered.length = 0; } // ── emission ─────────────────────────────────────────────────────────────── emitMeta() { const instrument = { symbol: this.#cfg.instrument, assetClass: this.#legContracts.length > 0 ? "option-underlier" : "equity", role: "exec", }; this.emit({ ts: this.#now(), stream: "META", type: "session", session_date: this.#cfg.sessionDate, instruments: [instrument], mode: this.#cfg.mode, bus_schema: BUS_SCHEMA, }); } /** Stamp `seq` and append. META must be first — a tape whose header does not lead it is not a bus, * and this is enforced rather than merely intended. */ emit(ev) { if (this.#seq === 0 && ev.stream !== "META") { throw new IbkrFeedError(`the first bus event must be META (got ${ev.stream}) — a tape without its header is not a bus`); } // `E` is pinned to a concrete variant at every call site (MetaEvent / SpotEvent / BookEvent / // HeartbeatEvent) and `seq` is the one field `Omit` removed, so re-adding it reconstitutes exactly // that variant. TS cannot see that through the distributed Omit, hence the widening step. this.#emitted.push({ ...ev, seq: this.#seq++ }); } #listen(event, listener) { const wrapped = listener; this.#client.on(event, wrapped); this.#registered.push([event, wrapped]); } } /** The bus-shaped quote of one line. A dark side is `null` — NEVER `0`, never omitted — so the * reducer downstream can tell a genuinely one-sided book from a two-sided one. */ function optionQuoteOf(l) { const c = l.contract; const q = l.quote; const last = q?.last; return { strike: c.strike, right: c.right, bid: q?.bid ?? null, ask: q?.ask ?? null, bsz: q?.bsz ?? null, asz: q?.asz ?? null, ...(last === undefined ? {} : { last }), }; } /** `SPY|445C` — the stable per-leg watermark key. */ function legKey(symbol, strike, right) { return `${symbol}|${strike}${right}`; } /** The gateway's OWN definition, re-formed as an IB `Contract` for the subscription. Every field is * the one 7o2.6 read off the venue — the conId alone would do, but naming the full identity keeps the * request self-describing and impossible to confuse with a hand-rolled one. */ function ibContractOf(c) { if (c.kind === "equity") { return { conId: c.conId, symbol: c.symbol, secType: SecType.STK, exchange: c.exchange, currency: c.currency, ...(c.primaryExchange === undefined ? {} : { primaryExch: c.primaryExchange }), }; } if (c.kind === "index") { // An INDEX line (kestrel-7o2.24) — the spot a cash-settled index option's strike window centres // on. `exchange` is the LISTING venue the gateway named (NASDAQ/CBOE), never SMART: SMART is an // order router and an index is not routed anywhere. No multiplier and no primaryExch exist on an // index to put on the wire. return { conId: c.conId, symbol: c.symbol, secType: SecType.IND, exchange: c.exchange, currency: c.currency, }; } // EXHAUSTIVENESS, enforced by the compiler: this annotated binding only typechecks while every // non-OPTION member of IbkrQuotedContract has been branched away above. Add a member and forget a // branch, and `tsc` reds HERE — rather than the member silently falling through and being // subscribed to as an OPTION. (Before 7o2.24 this tail was a bare `else`, so an index underlier // would have gone onto the wire as an option with no strike and no expiry.) const opt = c; return { conId: opt.conId, symbol: opt.symbol, secType: SecType.OPT, exchange: opt.exchange, currency: opt.currency, lastTradeDateOrContractMonth: opt.expiry, strike: opt.strike, right: opt.right === "C" ? OptionType.Call : OptionType.Put, multiplier: opt.multiplier, ...(opt.tradingClass === undefined ? {} : { tradingClass: opt.tradingClass }), }; } /** * Build the IBKR feed face (kestrel-7o2.7) — a {@link FeedSource} over the ONE shared IB session, * producing the EXISTING {@link BusEvent} union. Market data only: no order path exists here, and the * narrow client surface it speaks through declares none. */ export function ibkrFeed(cfg, deps) { return new IbkrFeedImpl(cfg, deps); } /** * Wrap a {@link SeriesProvider} so that a DEAD FEED can never read as a live one (kestrel-7o2.7). * * The problem this exists to solve is not hypothetical, and it cannot be fixed inside canonical * state: `CanonicalState` folds every SPOT it is given and then *knows* that price forever. When the * line freezes — IB re-printing an identical quote while the market underneath it is gone — the last * value sits there looking perfectly healthy. An armed plan reading `spot` gets a number, the trigger * evaluates `true`, and the engine fires off a tape that died minutes ago. The frozen-feed forensics * (kestrel-rs4/8kc) are exactly this. * * So freshness is applied at the READ, from the one place that actually knows it — the feed's * {@link FeedWatermark}. While the feed is DEGRADED, every **market-side** series (the registry's own * market-vs-org routing decides which: `spot`, `vwap`, `hod`/`lod`, the opening range, every windowed * metric) resolves to **UNKNOWN** instead of to its last-known value. Only a definite `true` fires * (RUNTIME §3), so an UNKNOWN operand propagates through the trigger algebra and the affected plans * simply cannot fire. The taint is precise: **org** facts (the session's own bookkeeping — `pnl`, a * plan's state) are not market observations and are passed through untouched; the calendar is not a * market observation either. * * Every refusal is LOGGED with its reason (once per series while degraded — a reason, never a storm). */ export function stalenessGatedProvider(inner, feed, opts = {}) { const registry = opts.registry ?? defaultSeriesRegistry; const log = opts.log ?? (() => { }); /** Series already refused during the CURRENT degradation — so the reason is logged once, not once * per sweep. Cleared when the feed comes back. */ const announced = new Set(); const taint = (ref) => { const wm = feed.watermark(); if (wm.health === "LIVE") { announced.clear(); return null; } const rec = registry.classify(ref); // An UNREGISTERED name is an org path (the phonebook does not hold org facts) — not a market read. if (isUnknown(rec) || rec.kind !== "market") return null; return wm.reason ?? "the feed is DEGRADED"; }; const refuse = (ref, reason) => { const name = ref.segments[0]?.name ?? "(empty path)"; if (!announced.has(name)) { announced.add(name); log(`series "${name}" reads UNKNOWN — the feed is DEGRADED and a market series is only as alive as its line: ${reason} (fail-closed; the dependent trigger cannot fire, RUNTIME §3/§8)`); } return UNKNOWN; }; return { resolve(ref, now) { const reason = taint(ref); return reason === null ? inner.resolve(ref, now) : refuse(ref, reason); }, baseline(ref, stat) { const reason = taint(ref); return reason === null ? inner.baseline(ref, stat) : refuse(ref, reason); }, phase() { // The session calendar is not a market observation — a dead line does not stop the clock. return inner.phase(); }, structural(ev, now) { // A structural event (a failed break of HOD …) IS a market read — it is derived off the very // levels the dead line stopped feeding. It taints with them. if (feed.watermark().health === "DEGRADED") { log(`structural event reads UNKNOWN — the feed is DEGRADED (a level derived off a dead line is not a level)`); return UNKNOWN; } return inner.structural?.(ev, now) ?? UNKNOWN; }, fillEvent(ev) { // The session's OWN fills are its own bookkeeping, not a market observation — never tainted. return inner.fillEvent?.(ev) ?? UNKNOWN; }, timeOfDayMinutes(now) { return inner.timeOfDayMinutes?.(now) ?? UNKNOWN; }, quantify(ref, now) { return inner.quantify?.(ref, now) ?? UNKNOWN; }, }; } /** * Resolve the contracts a feed subscribes to, by DRIVING the 7o2.6 contract layer (kestrel-7o2.7). * Nothing about identity resolution is re-implemented here: the equity comes from * {@link resolveUnderlier} (an equity/ETF or, since kestrel-7o2.24, an INDEX), the listed strikes * from {@link listOptionStrikes} (the venue's truth for THAT expiry, not the union across expiries), * and each leg from {@link resolveContract}. An * ambiguous or unresolvable identity raises the 7o2.6 typed refusal and reaches STAND_DOWN — the feed * never subscribes to a guessed contract. * * The WINDOW is {@link surfaceWindow} — the ONE rule (kestrel-7o2.19), the same one the order path * selects its vol surface with. It is centred on the ATM strike **the venue LISTS for this expiry**, * read from {@link listOptionStrikes} rather than from the chain's UNION grid: `reqSecDefOptParams` * returns the union of strikes across every expiry, and a strike in the union may simply not be listed * on the expiry asked for — selecting off it invents an identity the venue does not list. * * @throws {IbkrFeedError} when an option chain is asked for with no observed spot (fail-closed: no * money, no window). */ export async function resolveFeedContracts(query, deps) { // The underlier goes through the READ-path resolver (kestrel-7o2.24), which is the only entry // point that can return an INDEX. Its `secType` is the caller's statement of what the symbol IS — // defaulting to `STK`, which is what every pre-7o2.24 caller meant and still gets. const underlier = await resolveUnderlier({ symbol: query.symbol, secType: query.underlierSecType ?? "STK" }, deps); if (query.expiry === undefined) return { underlier, legs: [] }; const spot = query.spot; if (spot === undefined || !Number.isFinite(spot) || spot <= 0) { // FAIL CLOSED. This is the kestrel-7o2.19 defect at its root: a chain slice chosen with no idea // where the money is degrades to "whatever the grid happens to start with" — and the Frame is the // agent's screen, its token budget scarce. Strikes it cannot trade are worse than no strikes. throw new IbkrFeedError(`a chain slice for ${query.symbol} ${query.expiry} was asked for with NO observed spot (got ${String(spot)}) — the strike window has no money to centre on, and a centre-less pick is the 741–745-at-751.94 defect. Subscribe to the underlier first and centre the window on the price it PRINTS.`); } const legs = []; for (const right of ["C", "P"]) { // The venue's truth for THIS expiry — never the union grid across expiries. const listed = await listOptionStrikes({ symbol: query.symbol, expiry: query.expiry, right, ...(query.tradingClass === undefined ? {} : { tradingClass: query.tradingClass }) }, { ...deps, expiry: query.expiry }); // THE ONE RULE (shared with the order path — a rule that exists twice is a rule that diverges). const window = surfaceWindow({ listed, spot, ...(query.halfWidth === undefined ? {} : { halfWidth: query.halfWidth }), }); deps.log?.(`feed: ${right} surface window @${query.expiry}: ATM=${window.atm} (spot=${spot}), ±${window.halfWidth} LISTED strikes ⇒ [${window.strikes.join(", ")}]`); for (const strike of window.strikes) { const leg = await resolveContract(lookupIdentity(query.symbol, strike, right), { ...deps, expiry: query.expiry, ...(query.tradingClass === undefined ? {} : { tradingClass: query.tradingClass }), }); if (leg.kind === "option") legs.push(leg); } } legs.sort((a, b) => a.strike - b.strike || a.right.localeCompare(b.right)); return { underlier, legs }; } /** * A contract-LOOKUP IDENTITY — deliberately NOT an order, and structurally incapable of becoming one. * * The 7o2.6 layer's entry point is typed on `OrderIntent` because an intent is the thing that carries * an instrument + (strike, right) identity in this runtime. It is PROVEN (7o2.6's own tests, at the * type level and with a runtime spy) never to READ a price, a size, or a side: it maps an identity * onto the gateway's definition and returns a contract carrying no price field of any kind. This feed * has no gate, no venue write path, and no order code — nothing it builds can reach one. So the * price/size/side fields below are structural filler for a type, never a trade: `0` × `0` at `0`, * annotated as what it is. */ function lookupIdentity(instrument, strike, right) { return { ref: "ibkr-feed:lookup", plan: "ibkr-feed", plan_instance: "ibkr-feed#0", role: "entry", instrument, side: "buy", qty: 0, px: 0, sourceAnnotation: "ibkr-feed: a CONTRACT-LOOKUP IDENTITY, not an order — market data only (no gate, no venue write path, no order code anywhere in this face)", ...(strike === undefined ? {} : { strike }), ...(right === undefined ? {} : { right }), }; } // ────────────────────────────────