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.

202 lines (185 loc) 10.4 kB
/** * # adapters/broker/ibkr/requests — the per-reqId REQUEST-ERROR CHANNEL (kestrel-7o2.7) * * The gap this closes — a known kestrel-7o2.16 follow-up. IB multiplexes REQUEST-scoped and * CONNECTION-scoped errors onto ONE `error` event, telling them apart by `reqId` (`-1` ⇒ "about the * CONNECTION"). 7o2.16 correctly stopped a request-scoped error from felling the SHARED session: one * bad contract query (IB 200) no longer STANDs DOWN the whole live socket. But it routed that error * to a **diagnostic log sink that DEFAULTS TO A NO-OP** — so the error reached *nobody*. For a * request/response lookup that was survivable (the contract layer registers its own `error` listener * and rejects its own promise). For a **streaming market-data subscription** it is not: `reqMktData` * has no reply-then-end shape, so a refused subscription would simply never tick, and a caller * waiting on its first quote would **hang forever** on a silent failure. A dead line rendering as a * merely-quiet one is precisely the bug class this runtime treats as first-class. * * So a request-scoped error needs a real, CALLER-VISIBLE channel — not a log. {@link IbRequestRouter} * is it: ONE `error` listener over the shared socket, dispatching by `reqId` to the OWNER of that * request. An owned reqId gets a typed {@link IbkrRequestError}; an unowned one (another face's * request, or a connection-level message) is left alone — it is not this router's to judge, and the * transport still owns the connection-level walls (a router must never quietly re-classify a session * the transport declared dead). * * Fail-closed and LOUD: every routed error carries the IB code + the reqId + the human label of what * was being asked for, and the owner is told exactly once. Non-fatal IB info codes (2100–2999 — the * market-data-farm chatter every real gateway emits constantly) are NOT failures and are passed to * the optional log, exactly as the transport and the contract layer treat them. * * Determinism at the edge: no clock, no RNG, no timer here — the router is a pure dispatcher over an * injected client. The subscription/deadline machinery lives in `./feed.ts`, which injects both. */ import { EventName, isNonFatalError } from "@stoqey/ib"; /** A listener over the IB event bus (the transport's convention — concrete closures are wrapped at * the registration site, so the `never[]` never escapes into a handler body). */ type IbListener = (...args: never[]) => void; /** IB's own marker for "this message is about the CONNECTION, not a request" (`ErrorCode.NO_VALID_ID`). */ export const IB_CONNECTION_REQ_ID = -1; /** * The NARROW surface the router needs of the shared IB client: the event bus, nothing else. It * declares no request method at all — the router only *listens*; the caller issues its own requests. */ export interface IbErrorBus { on(event: EventName, listener: IbListener): unknown; removeListener(event: EventName, listener: IbListener): unknown; } /** * A REQUEST-scoped IB error, delivered to the caller that OWNS the request (kestrel-7o2.7) — the * typed, fail-closed refusal that replaces the old silent no-op log. It is deliberately DISTINCT from * `IbkrConnectionError` (the session is fine) and from `IbkrContractError` (no identity was being * resolved): it says "the gateway refused THIS request", so the caller can drop THAT subscription and * carry on, or STAND_DOWN, without ever mistaking it for a dead socket. * * Carries the IB `code` (200 no-security-definition, 321 invalid request, 10197 no market data …), * the owning `reqId`, and the secret-free `label` naming what was being asked for. */ export class IbkrRequestError extends Error { override readonly name = "IbkrRequestError"; /** The IB request id the gateway refused — the key the router dispatched on. */ readonly reqId: number; /** The IB error code, when the venue named one; `undefined` for a locally-raised deadline lapse. */ readonly code: number | undefined; /** A secret-free description of what was being asked for (e.g. `market data for SPY 445C`). */ readonly label: string; constructor( reqId: number, label: string, reason: string, options: { code?: number | undefined; cause?: unknown } = {}, ) { super( `IBKR request refused [reqId=${reqId}${options.code === undefined ? "" : ` code=${options.code}`}]: ${label}${reason} (fail-closed)`, options.cause === undefined ? undefined : { cause: options.cause }, ); this.reqId = reqId; this.label = label; this.code = options.code; } } /** What the router hands the owner of a reqId when the gateway refuses that request. */ export type RequestErrorSink = (err: IbkrRequestError) => void; interface Owner { readonly label: string; readonly onError: RequestErrorSink; } /** * The per-reqId error router over ONE shared IB socket (kestrel-7o2.7). * * Registers a SINGLE `error` listener and dispatches by `reqId`: * * - a **non-fatal** info code (2100–2999) ⇒ logged, never a failure (the farm chatter a real gateway * emits constantly is not a refusal); * - an **owned** reqId ⇒ a typed {@link IbkrRequestError} handed to that owner's sink — the channel * that did not exist before, and the reason a failed subscription can no longer hang; * - an **unowned** reqId (another face's request) or a **connection-level** message (`reqId === -1`) * ⇒ left strictly alone. The router never degrades, re-opens, or re-classifies the shared session: * connection-level walls belong to the transport (kestrel-7o2.5/7o2.16), and this router's whole * purpose is that a request-scoped error must NOT become a session-scoped one. * * Pure dispatch — no clock, no RNG, no timer. */ export class IbRequestRouter { readonly #bus: IbErrorBus; readonly #log: (line: string) => void; readonly #owners = new Map<number, Owner>(); readonly #listener: IbListener; #disposed = false; constructor(bus: IbErrorBus, opts: { log?: ((line: string) => void) | undefined } = {}) { this.#bus = bus; this.#log = opts.log ?? (() => {}); const onError = (err: Error, code: number, reqId?: number): void => { this.dispatch(err, code, reqId); }; this.#listener = onError as unknown as IbListener; this.#bus.on(EventName.error, this.#listener); } /** Claim a reqId: from now until {@link close}, an IB error on it reaches `onError`. Claiming a * reqId twice is a programming error, not a runtime guess — the LAST owner wins and the collision * is logged loudly (a shared request-id space with two owners is never silently tolerated). */ open(reqId: number, label: string, onError: RequestErrorSink): void { if (this.#owners.has(reqId)) { this.#log(`request-router: reqId ${reqId} claimed twice (${label}) — the id space must be unique`); } this.#owners.set(reqId, { label, onError }); } /** Release a reqId. A later error on it becomes an unowned message (logged, never a failure of * something that already finished — and never a session kill). Idempotent. */ close(reqId: number): void { this.#owners.delete(reqId); } /** Is this reqId currently owned? (Used by the feed to tell its own lines from another face's.) */ owns(reqId: number): boolean { return this.#owners.has(reqId); } /** Detach the single `error` listener from the shared socket and forget every owner. A leaked * listener on a shared socket is a cross-request bug; teardown is therefore total and idempotent. */ dispose(): void { if (this.#disposed) return; this.#disposed = true; this.#owners.clear(); this.#bus.removeListener(EventName.error, this.#listener); } private dispatch(err: Error, code: number, reqId?: number): void { // A non-fatal IB info code is the farm chatter every live gateway emits; it is not a refusal. // (Checked FIRST, so an info code on an owned reqId never fails that subscription.) if (isNonFatalError(code, err)) { this.#log(`ib info code=${code}${reqId === undefined ? "" : ` reqId=${reqId}`}`); return; } // Connection-level (`reqId === -1`, or absent — fail-closed, treated as connection-level): the // TRANSPORT owns this wall. The router must not touch it: it neither degrades the session itself // nor quietly re-opens one the transport has already declared dead. const id = reqId ?? IB_CONNECTION_REQ_ID; if (id === IB_CONNECTION_REQ_ID) return; const owner = this.#owners.get(id); if (owner === undefined) { // Another face's request on the shared socket — not ours to judge (the contract layer, say, // rejects its own lookup). Logged, never re-routed onto one of our subscriptions. this.#log(`ib request-scoped error code=${code} reqId=${id} — not this router's request`); return; } // The channel the 7o2.16 follow-up asked for: the REQUESTING caller learns of its own failure. this.#log(`ib request-scoped error code=${code} reqId=${id} (${owner.label}) — surfaced to the requester`); owner.onError( new IbkrRequestError(id, owner.label, describeIbErrorCode(code), { code, cause: err }), ); } } /** A human, secret-free gloss on the IB error codes a market-data request actually meets. An * unrecognised code still FAILS the request (fail-closed — an unknown refusal is never shrugged off); * it is simply named honestly as unrecognised rather than mislabelled as something the adapter cannot * actually vouch for. */ export function describeIbErrorCode(code: number): string { switch (code) { case 200: return "the gateway has NO security definition for this contract (IB 200) — the identity does not exist at the venue"; case 321: return "the gateway rejected the request as invalid (IB 321)"; case 354: return "this account is NOT subscribed to the requested market data (IB 354) — no data will ever arrive"; case 10197: return "no market data during a competing live session (IB 10197) — the gateway will send no ticks"; case 10167: return "requested real-time market data is not available; delayed data may be (IB 10167)"; default: return `the gateway refused the request (IB ${code}) — an unrecognised fatal code, refused rather than assumed harmless`; } }