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