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.

1,049 lines (962 loc) 75.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 type { Contract, TickType } from "@stoqey/ib"; import { redactAccount } from "./config.ts"; import { IbRequestRouter, IbkrRequestError } from "./requests.ts"; import { atmCoverage, atmTaint, surfaceWindow, DEFAULT_ATM_BAND_FRACTION, DEFAULT_SURFACE_HALF_WIDTH, } from "./surface-window.ts"; import type { AtmCoverage, AtmPolicy } from "./surface-window.ts"; import { resolveContract, resolveUnderlier, listOptionStrikes, IBKR_DEFAULT_EXCHANGE } from "./contract.ts"; import type { IbkrContractDeps, IbkrIndexContract, IbkrOptionContract, IbkrQuotedContract, IbkrUnderlierContract, UnderlierSecType, } from "./contract.ts"; import type { IbkrTransport, NowFn, Scheduler, TimerHandle } from "./transport.ts"; import type { BusEvent, BookEvent, HeartbeatEvent, MetaEvent, Mode, OptionQuote, Right, SessionInstrument, SessionPhase, SpotEvent, } from "../../../bus/index.ts"; import { BUS_SCHEMA } from "../../../bus/index.ts"; import { executionFair, intrinsic, EXEC_FAIR_MODEL } from "../../../fair/index.ts"; import type { FairReceipt } from "../../../fair/index.ts"; import type { FeedSource } from "../../broker.ts"; import { UNKNOWN, defaultSeriesRegistry, isUnknown, type Resolved, type SeriesProvider, type SeriesRegistry, type TriState, type Unknown, } from "../../../series/index.ts"; import type { SeriesRef, Baseline, StructuralEvent, FillEvent } from "../../../lang/index.ts"; // ───────────────────────────────────────────────────────────────────────────── // 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 narrow, order-free market-data surface of the shared IB client // ───────────────────────────────────────────────────────────────────────────── /** A listener over the IB event bus (the transport's convention). */ type IbListener = (...args: never[]) => void; /** * The NARROW structural surface of the shared IB client the feed speaks through: the streaming * market-data requests and the event bus. It declares NO order method of any kind — the feed cannot * reach one even through the client. A test injects a double implementing exactly this; production * passes {@link IbkrTransport}'s ONE shared client via {@link feedClientOf} — never a second socket, * and never a second market-data connection. */ export interface IbFeedClient { /** Open a streaming market-data subscription for one contract. */ reqMktData( reqId: number, contract: Contract, genericTickList: string | null, snapshot: boolean, regulatorySnapshot: boolean, ): unknown; /** Close a streaming market-data subscription. */ cancelMktData(reqId: number): unknown; /** Choose the market-data flavour (1 real-time, 2 frozen, 3 delayed, 4 delayed-frozen). OPTIONAL: * a client that cannot switch simply omits it, and the feed never pretends it did. */ reqMarketDataType?(marketDataType: number): unknown; on(event: EventName, listener: IbListener): unknown; removeListener(event: EventName, listener: IbListener): unknown; } /** * 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: IbkrTransport): IbFeedClient { return transport.client() as unknown as IbFeedClient; } // ───────────────────────────────────────────────────────────────────────────── // 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 { override readonly name = "IbkrFeedError"; readonly reason: string; constructor(reason: string, options: { cause?: unknown } = {}) { super(`IBKR feed refused: ${reason} (fail-closed; STAND_DOWN)`, options.cause === undefined ? undefined : { cause: options.cause }); this.reason = reason; } } // ───────────────────────────────────────────────────────────────────────────── // @fair — the two-sided quote, with a receipt; the mid only ever a health signal // ───────────────────────────────────────────────────────────────────────────── /** The spread REGIME of an observed book — the mid's ONLY legitimate role (a fingerprint, never a * price). `dark` = both sides gone; `one-sided` = a maker pulled one side (the real-move fingerprint); * `wide` = two-sided but the spread is a large fraction of the book (a book you cannot trust to a * cent); `tight` = a book worth reading. */ export type SpreadRegime = "tight" | "wide" | "one-sided" | "dark"; /** * The health of one observed book (kestrel-7o2.7). This is where — and ONLY where — the observed mid * is allowed to matter, and even here it never escapes as a number: it appears solely as the * denominator of a dimensionless {@link SpreadRegime}. There is deliberately NO `mid` field: a caller * cannot lift a mid off this surface and price against it, because the surface does not carry one. */ export interface QuoteHealth { /** Both sides present ⇒ a real, two-sided book. */ readonly twoSided: boolean; /** Both sides gone ⇒ dark. */ readonly dark: boolean; /** The spread WIDTH in dollars (`ask − bid`) — an observed distance, not a price. `null` unless * the book is two-sided. */ readonly spread: number | null; /** The dimensionless spread regime — the fingerprint the mid is reduced to. */ readonly spreadRegime: SpreadRegime; } /** * The fail-closed ANNOTATED book value a caller falls back to when `@fair` is unbuildable (RUNTIME * §4). The `annotation` ALWAYS names the fallback — that is the whole point: **a silent mid is * forbidden**, so a number that did not come from the model arrives wearing a label that says so. * `px` is `null` when there is no honest number at all (a BUY into a dark book) — an annotated * refusal, never a guess. */ export interface FairFallback { readonly px: number | null; readonly annotation: string; } /** * `@fair` for one option leg (kestrel-7o2.7) — the engine's value, with its receipt, plus the honest * fail-closed alternative when it cannot be built. Exactly one of `value` / `fallback` is present. * There is no `mid` on this object, and there never will be. */ export interface FeedFair { /** The ExecutionFair value — Black-76 at the interpolated IV, FLOORED AT INTRINSIC. `null` when * unbuildable (no usable underlier, or a book with zero liquid strikes to back a vol out of). */ readonly value: number | null; /** The trust envelope: model VERSION + FIT QUALITY (`nLiquid`, `ivAtStrike`) + FRESHNESS (`asof`). * `null` exactly when `value` is — a receipt is never fabricated for a number that does not exist. */ readonly receipt: FairReceipt | null; /** The annotated book value when `value` is `null`; `null` when fair resolved. */ readonly fallback: FairFallback | null; /** The observed book's health — the mid's only role. */ readonly health: QuoteHealth; /** * THE FIT QUALITY THE RECEIPT COULD NOT EXPRESS (kestrel-7o2.19): does the surface this value came * off actually KNOW WHERE THE MONEY IS? `nLiquid` is a COUNT, not a LOCATION — five deep-ITM strikes * are five liquid strikes and zero evidence about an at-the-money price. Always present, whether or * not a value resolved. */ readonly coverage: AtmCoverage; /** Non-`null` exactly when a value is returned off a surface that does NOT cover the money (the * `taint` {@link AtmPolicy}) — the number then travels WEARING the reason it cannot be trusted. * `null` when the surface covers the money, and `null` when the feed failed closed instead (there is * then no number to taint, and the fallback's own annotation carries the reason). */ readonly taint: string | null; } /** The inputs to one `@fair` resolution off a recorded/observed book. Pure: no clock, no socket. */ export interface FeedFairInput { /** The OBSERVED underlier price (a real print). `null` ⇒ no usable underlying ⇒ fair is unbuildable * (never a spot synthesized out of a quote). */ readonly underlierPx: number | null; readonly strike: number; readonly right: Right; /** The side decides the fallback SHAPE: a SELL degrades to its intrinsic floor (never naked); a BUY * with no two-sided mid is genuinely unresolvable. */ readonly side: "buy" | "sell"; /** Time to expiry in YEARS — injected, never read off a clock (RUNTIME §0). */ readonly tauYears: number; /** The chain slice the vol surface is backed out of. One-sided/dark legs contribute nothing (their * mid is a health signal, never a price) — that is `buildSurface`'s own rule, reused verbatim. */ readonly legs: readonly OptionQuote[]; /** The observation time of the quotes being valued (epoch ms) — stamped onto the receipt. */ readonly asof: number; /** What "NEAR the money" means, as a fraction of spot (default {@link DEFAULT_ATM_BAND_FRACTION}) — * a named, injectable parameter, never a magic number at a call site. */ readonly bandFraction?: number | undefined; /** What to do when the surface does NOT know the money (default `fail-closed`). */ readonly atmPolicy?: AtmPolicy | undefined; } /** * 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: FeedFairInput): FeedFair { const { underlierPx, strike, right, side, tauYears, legs, asof } = input; const policy: AtmPolicy = 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: string): string => 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: OptionQuote | undefined): QuoteHealth { 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: OptionQuote | undefined): number | null { 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); } // ───────────────────────────────────────────────────────────────────────────── // The watermark // ───────────────────────────────────────────────────────────────────────────── /** The feed's overall verdict on itself. `DEGRADED` is never silent — it always carries a reason. */ export type FeedHealth = "LIVE" | "DEGRADED"; /** * The staleness watermark of ONE subscribed line (kestrel-7o2.7). The distinction the whole bead * turns on lives here: `lastPrintTs` is when the LINE last spoke (IB re-sent something, anything), * while `asOfSeq`/`asOfTs` are when the VALUE last CHANGED. A frozen line re-printing the same * bid/ask forever keeps `lastPrintTs` moving and `asOfSeq` PINNED — which is exactly how a dead tape * is caught rendering as a live one. */ export interface FeedSeriesWatermark { /** `SPY` for the underlier, `SPY|445C` for an option leg. */ readonly key: string; readonly kind: "spot" | "option"; /** The emitted `seq` at which this line's VALUE last CHANGED. A frozen re-print does NOT advance * it. `null` before the line has ever carried a value. */ readonly asOfSeq: number | null; /** The injected-clock `ts` at which this line's VALUE last CHANGED. */ readonly asOfTs: number | null; /** The injected-clock `ts` at which IB last PRINTED anything on this line, changed or not — proof * the line is talking, which is NOT proof the market is moving. */ readonly lastPrintTs: number | null; /** Identical re-prints since the last value CHANGE — the frozen-quote fingerprint. */ readonly reprints: number; /** `now − asOfTs` — how old the VALUE is. `null` before the first value. */ readonly ageMs: number | null; /** `ageMs > staleAfterMs`, or no value has ever landed ⇒ STALE (fail-closed on absence). */ readonly stale: boolean; /** Both sides gone. */ readonly dark: boolean; /** A refused subscription (a request-scoped IB error / a deadline lapse) — the line is dead by * refusal rather than by silence. */ readonly failed: boolean; } /** The feed's staleness watermark (kestrel-7o2.7) — the canonical freshness truth a consumer gates * on. Read it, do not infer it: canonical state cannot tell you a price is stale, only what it last * saw. */ export interface FeedWatermark { /** The injected clock at the moment the watermark was read. */ readonly now: number; /** The last `seq` the feed emitted. */ readonly seq: number; readonly staleAfterMs: number; readonly health: FeedHealth; /** The logged reason for DEGRADED, or `null` while LIVE. Never a silent degrade. */ readonly reason: string | null; readonly series: readonly FeedSeriesWatermark[]; /** Is the UNDERLIER line stale/dead? It is the canonical coordinate every market series hangs off, * so its death taints all of them. */ readonly underlierStale: boolean; } // ───────────────────────────────────────────────────────────────────────────── // Config / deps / the FeedSource surface // ───────────────────────────────────────────────────────────────────────────── export interface IbkrFeedConfig { /** The underlier symbol (generic tickers only in anything shipped — ARCHITECTURE §7). */ readonly instrument: string; /** The session header's opaque calendar token (`YYYY-MM-DD`). */ readonly sessionDate: string; /** The Kestrel mode this tape is stamped with. */ readonly mode: Mode; /** The chain's expiry, carried onto every `TICK/BOOK` (the venue's own `YYYYMMDD`, or a tag). */ readonly expiry?: string | undefined; /** Time to expiry in YEARS for `@fair` — INJECTED, never derived from a clock (RUNTIME §0). */ readonly tauYears?: number | undefined; /** How long a line's VALUE may go unchanged before it is STALE (default {@link DEFAULT_STALE_AFTER_MS}). */ readonly staleAfterMs?: number | undefined; /** How long one subscription may go unanswered before it FAILS (default {@link DEFAULT_SUBSCRIBE_TIMEOUT_MS}). */ readonly subscribeTimeoutMs?: number | undefined; /** What "NEAR the money" means for the fit-quality receipt, as a fraction of spot (default * {@link DEFAULT_ATM_BAND_FRACTION}) — a named, injectable parameter (kestrel-7o2.19). */ readonly atmBandFraction?: number | undefined; /** What `@fair` does when the surface does NOT know the money: FAIL CLOSED (`fair => null` + the * annotated fallback — the default) or return the value TAINTED. Never a silent confident number. */ readonly atmPolicy?: AtmPolicy | undefined; /** The session phase stamped on the proof-of-life heartbeats, when the caller pins one. */ readonly phase?: SessionPhase | undefined; /** IB market-data flavour (1 real-time, 3 delayed). Absent ⇒ the gateway's own default is left * alone — the feed never silently downgrades a caller to delayed data. */ readonly marketDataType?: number | undefined; /** The session account — used ONLY to stamp a REDACTED marker on diagnostics. */ readonly account?: string | undefined; } export interface IbkrFeedDeps { /** The shared IB client — the transport's ONE session in production ({@link feedClientOf}). */ readonly client: IbFeedClient; /** The resolved UNDERLIER contract (kestrel-7o2.6) — an equity/ETF (`STK`) or an INDEX (`IND`, * the underlier of a cash-settled index option, kestrel-7o2.24). The gateway's OWN definition, * never hand-rolled: the feed does not resolve identities, it subscribes to resolved ones. Typed as * the UNDERLIER union, not the TRADABLE one — a feed quotes an index, it never orders one. */ readonly underlier: IbkrUnderlierContract; /** The resolved OPTION legs (kestrel-7o2.6). Empty/absent ⇒ an equity-only tape: no `TICK/BOOK` is * ever emitted, which is a first-class shape on this bus. */ readonly legs?: readonly IbkrOptionContract[] | undefined; /** The injected clock (epoch ms). */ readonly now: NowFn; /** Mints a unique IB request id per subscription — a counter, never an RNG. */ readonly nextReqId?: (() => number) | undefined; /** The deadline scheduler — injected so tests trip it by hand (no real timer, no flake). */ readonly scheduler?: Scheduler | undefined; /** Redacted-diagnostic sink (default no-op). NOTE: failures never depend on this — that was the * 7o2.16 gap. A refused subscription reaches the CALLER through {@link FeedSubscription.ready} and * {@link FeedOpenResult.failed}, whether or not anyone is listening to the log. */ readonly log?: ((line: string) => void) | undefined; } /** The lifecycle of one `reqMktData` subscription. */ export type SubscriptionState = "pending" | "live" | "failed"; /** * One market-data subscription on the shared socket (kestrel-7o2.7). {@link ready} is the * caller-visible channel the 7o2.16 follow-up demanded: it RESOLVES on the line's first print and * REJECTS with a typed {@link IbkrRequestError} when the gateway refuses the request (IB 200 / 321 / * 354 / 10197 …) or when the deadline lapses. It never simply hangs. */ export interface FeedSubscription { readonly reqId: number; /** `SPY` for the underlier, `SPY|445C` for an option leg. */ readonly key: string; readonly kind: "spot" | "option"; readonly contract: IbkrQuotedContract; readonly state: SubscriptionState; /** The typed refusal, once this subscription has failed. `null` otherwise. */ readonly error: IbkrRequestError | null; /** Resolves on the first print; REJECTS on a request-scoped refusal or a deadline lapse. */ readonly ready: Promise<void>; } /** What {@link IbkrFeed.open} reports. Failures are SURFACED, never swallowed — but a refused LEG is * not a refused feed: the leg is dropped (it will never be quoted as a phantom) and the tape carries * on, while a refused UNDERLIER throws {@link IbkrFeedError} because there is then no tape at all. */ export interface FeedOpenResult { readonly underlier: IbkrUnderlierContract; /** The legs that actually subscribed. */ readonly legs: readonly IbkrOptionContract[]; /** Every per-subscription refusal, surfaced to the caller. */ readonly failed: readonly IbkrRequestError[]; } /** What a `@fair` is asked for. */ export interface FeedFairQuery { readonly strike: number; readonly right: Right; readonly side: "buy" | "sell"; } /** * The IBKR feed face (kestrel-7o2.7) — a {@link FeedSource} producing the EXISTING {@link BusEvent} * union off the ONE shared IB session. Market data only; it exposes no order path. */ export interface IbkrFeed extends FeedSource { /** Emit `META`, open every subscription, and settle. Never hangs: each subscription carries its own * deadline, and a refusal surfaces as a typed error rather than silence. */ open(): Promise<FeedOpenResult>; /** Fold whatever IB has said since the last pump into BusEvents. A value CHANGE produces a * `TICK/SPOT` / `TICK/BOOK`; a quiet interval produces a `TICK/HEARTBEAT` (proof of life on a quiet * tape) — and a frozen re-print produces the heartbeat too, because a re-print is not news. */ pump(): void; /** The bus events produced so far, in order — the {@link FeedSource} face. Re-iterable and * byte-stable (`META` first, then causally-ordered ticks). */ events(): Iterable<BusEvent>; /** The events produced since the previous drain (the live loop's incremental read). */ drain(): readonly BusEvent[]; /** The staleness watermark — read it rather than inferring freshness from canonical state. */ watermark(): FeedWatermark; /** `@fair` for one leg off the CURRENT observed book, with its receipt (or the annotated * fail-closed fallback). */ fair(query: FeedFairQuery): FeedFair; /** Every subscription, live and failed. */ subscriptions(): readonly FeedSubscription[]; /** Close every subscription and detach every listener from the shared socket. Idempotent. */ close(): void; } // ───────────────────────────────────────────────────────────────────────────── // Internals — one subscribed line // ───────────────────────────────────────────────────────────────────────────── /** The raw IB ticks for one line, as they land (price and size arrive on separate events, in either * order — so the QUOTE is derived on read, never assembled half-formed on write). */ interface RawTicks { bid?: number; ask?: number; bsz?: number; asz?: number; last?: number; } /** The derived quote of one line — the shape that actually reaches the bus. */ interface LineQuote { readonly bid: number | null; readonly ask: number | null; readonly bsz: number | null; readonly asz: number | null; readonly last: number | undefined; } class Line { readonly raw: RawTicks = {}; /** 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: LineQuote | null = null; asOfSeq: number | null = null; asOfTs: number | null = null; lastPrintTs: number | null = null; reprints = 0; dirty = false; state: SubscriptionState = "pending"; error: IbkrRequestError | null = null; deadline: TimerHandle | undefined; settle!: () => void; refuse!: (e: IbkrRequestError) => void; readonly ready: Promise<void>; constructor( readonly reqId: number, readonly key: string, readonly kind: "spot" | "option", readonly contract: IbkrQuotedContract, ) { this.ready = new Promise<void>((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: RawTicks): LineQuote { const side = (px: number | undefined, sz: number | undefined): number | null => { 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: number | undefined, present: number | null): number | null => 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: LineQuote, b: LineQuote): boolean { 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: TickType): keyof RawTicks | null { 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 implements IbkrFeed { readonly #cfg: IbkrFeedConfig; readonly #client: IbFeedClient; readonly #now: NowFn; readonly #nextReqId: () => number; readonly #scheduler: Scheduler; readonly #log: (line: string) => void; readonly #router: IbRequestRouter; readonly #staleAfterMs: number; readonly #timeoutMs: number; readonly #tauYears: number; readonly #underlier: IbkrUnderlierContract; readonly #legContracts: readonly IbkrOptionContract[]; /** reqId → line. The single dispatch table for every tick and every refusal. */ readonly #lines = new Map<number, Line>(); /** The underlier's line — the canonical coordinate everything else hangs off. */ #spotLine!: Line; readonly #emitted: BusEvent[] = []; #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: string | null = null; readonly #registered: Array<[EventName, IbListener]> = []; constructor(cfg: IbkrFeedConfig, deps: IbkrFeedDeps) { 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(): Promise<FeedOpenResult> { 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: number, field: TickType, value: number) => { this.onTick(reqId, field, value); }); this.#listen(EventName.tickSize, (reqId: number, field?: TickType, value?: number) => { 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 as IbkrRequestError); 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 }; } private subscribeOne(contract: IbkrQuotedContract, key: string, kind: "spot" | "option"): Line { 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; } private failLine(line: Line, err: IbkrRequestError): void { 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); } private liveLine(line: Line): void { if (line.state !== "pending") return; line.state = "live"; this.clearDeadline(line); line.settle(); } private clearDeadline(line: Line): void { 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. */ private onTick(reqId: number, field: TickType, value: number): void { 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(): void { 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<SpotEvent>({ 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<BookEvent>({ 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<HeartbeatEvent>({ 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. */ private observeHealth(now: number): void { 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(): Iterable<BusEvent> { return this.#emitted; } drain(): readonly BusEvent[] { const fresh = this.#emitted.slice(this.#drained); this.#drained = this.#emitted.length; return fresh; } subscriptions(): readonly FeedSubscription[] { 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(): FeedWatermark { return this.watermarkAt(this.#now()); } private watermarkAt(now: number): FeedWatermark { 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: string[] = []; 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-p