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
text/typescript
/**
* # 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