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.
621 lines (620 loc) • 34.6 kB
JavaScript
/**
* # adapters/broker/ibkr/contract — the CONTRACT-RESOLUTION layer (kestrel-7o2.6)
*
* The one place a **Kestrel identity** (an already-resolved {@link OrderIntent}: an instrument, and
* — for an option — a strike + a right, ADR-0017) is mapped onto the **broker's own definition** of
* that instrument. It answers exactly one question: *which contract at the venue IS this leg?*
*
* ## Two entry points, and the type is what keeps them apart (kestrel-7o2.24)
* {@link resolveContract} is the ORDER path: it returns an {@link IbkrContract}, the TRADABLE union
* (`STK` | `OPT`). {@link resolveUnderlier} is the READ path: it returns an
* {@link IbkrUnderlierContract} (`STK` | `IND`) — what a chain hangs off and what a feed's spot line
* reads. An INDEX (`IND` — the NDX/XND/SPX class of cash-settled index options) is a number, not an
* instrument: it lives only in the second union, so the order path cannot be handed one and `tsc`
* says so. A structural exclusion, not a runtime guard that could go un-wired.
*
* ## TRANSMITTER, never a re-pricer (the core doctrine)
* The engine has already resolved the price before the intent ever reaches a venue face (RUNTIME §4:
* a silent mid is forbidden; the intent carries its price-resolution receipt). So this layer NEVER
* reads, derives, rounds, or improves a price — it does not touch `intent.px`, and the
* {@link IbkrContract} it returns carries **no price field of any kind** (no px / limit / bid / ask /
* minTick). That is a TYPE-level guarantee, asserted structurally in `tests/ibkr.contract.test.ts`:
* a contract is an IDENTITY, and a price that rode in on an identity is a price nobody authored.
*
* ## FAIL-CLOSED (never a guessed contract)
* A half-specified leg (strike xor right), an option with no supplied expiry, no matching definition,
* an incomplete definition, or — the trap this layer exists to catch — MORE THAN ONE matching
* definition, all raise a LOUD typed {@link IbkrContractError} with a logged reason, which the caller
* routes to STAND_DOWN. **Never a pick.** Two definitions that both "look right" (an a.m.- vs a
* p.m.-settled class, two trading classes, two primary exchanges) are two different instruments; a
* layer that silently chooses one has silently authored a trade the author never wrote. The gateway's
* definition is the authority — an OCC local symbol, an expiry, an exchange are taken FROM it, never
* hand-rolled toward it.
*
* ## Places NO orders
* This module issues READ-ONLY definition lookups (`reqContractDetails`, `reqSecDefOptParams`). It
* contains no order-submission path whatsoever, and the narrow {@link IbContractClient} surface it
* speaks through declares none — so it cannot submit one even by reaching through the client. Order
* submission is a later bead (7o2.8 paper / 7o2.10 live), behind the human-signed arm envelope
* (7o2.9). The tests assert this at the source level and with a runtime spy.
*
* ## One socket, determinism at the edge
* The IB client is INJECTED (as the transport injects it), so unit tests drive an in-memory double
* with no real socket and no real timer; production passes {@link IbkrTransport}'s ONE shared client
* via {@link contractClientOf} — never a second connection. The request-id minter and the deadline
* scheduler are injected for the same reason.
*
* ## KNOWN INTERACTION with the transport's fatal-error rule (observed against the LIVE gateway)
* The transport (7o2.5) degrades the shared session on ANY IB error `isNonFatalError` does not
* whitelist — including **200 ("no security definition")**, which is REQUEST-scoped and entirely
* expected on this layer's fail-closed path (a bogus strike is *supposed* to be refused). So an
* `unresolvable` refusal ALSO degrades the transport, and the next lookup throws
* {@link IbkrConnectionError} until the session reconnects. The direction is safe — it fails MORE
* closed, never less, and STAND_DOWN is exactly where both paths route — but a request-scoped error
* should not fell the socket. Tuning that classification is the transport's call (the IBKR hardening
* bead), NOT this layer's: nothing here may quietly re-open or re-classify a session the transport
* has declared dead.
*/
import { EventName, SecType, OptionType, isNonFatalError } from "@stoqey/ib";
import { redactAccount } from "./config.js";
import { IbkrContractError } from "./errors.js";
/** IB's "No security definition has been found for the request" (error 200) — the venue's own way of
* saying the identity does not exist. `@stoqey/ib`'s {@link import("@stoqey/ib").ErrorCode} enum has
* no member for it, so it is named here rather than left as a bare literal at the branch. */
export const IB_NO_SECURITY_DEFINITION = 200;
/** Publication-safe routing defaults (ARCHITECTURE §7: nothing here reveals an application). SMART
* is IB's own documented default router and USD the listing currency of the generic US tickers the
* package ships fixtures for; both are always overridable per-call. Never a guessed EXPIRY, though —
* an expiry has no safe default, so it is required from the caller (see {@link IbkrContractDeps}). */
export const IBKR_DEFAULT_EXCHANGE = "SMART";
export const IBKR_DEFAULT_CURRENCY = "USD";
const DEFAULT_LOOKUP_TIMEOUT_MS = 10_000;
// ─────────────────────────────────────────────────────────────────────────────
// resolveContract — the seam.
// ─────────────────────────────────────────────────────────────────────────────
/**
* Map a resolved {@link OrderIntent} onto exactly ONE broker contract (kestrel-7o2.6).
*
* Routing is ADR-0017's rule and nothing else: **strike AND right both present ⇒ an OPTION; both
* absent ⇒ a SPOT/EQUITY**. Anything in between is not an instrument identity and is refused before
* the gateway is even asked (a right is never guessed from a side; a strike is never guessed from a
* price — indeed no price is ever read).
*
* The resolved contract is the GATEWAY's own definition: its conId, its OCC local symbol, its
* multiplier, its exchange, its currency, its expiry. Zero, several, incomplete, or unanswered ⇒ a
* LOUD {@link IbkrContractError} the caller logs and STANDs DOWN on.
*
* @throws {IbkrContractError} `unsupported` | `unresolvable` | `ambiguous` | `lookup-failed`.
*/
export async function resolveContract(intent, deps) {
const hasStrike = intent.strike !== undefined;
const hasRight = intent.right !== undefined;
const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
const currency = deps.currency ?? IBKR_DEFAULT_CURRENCY;
// ADR-0017's routing rule, enforced as a GATE. A half-specified leg is not an instrument.
if (hasStrike !== hasRight) {
throw refuse(deps, "unsupported", `leg ${describeLeg(intent)} specifies ${hasStrike ? "a strike with NO right" : "a right with NO strike"} — an option identity needs BOTH (ADR-0017), and neither is ever invented`);
}
// A spot ORDER leg is an EQUITY and only ever an equity. An index (`IND`) is not tradable, so the
// order path does not route to it — its resolver is {@link resolveUnderlier}, whose return type
// this function's does not include. Nothing to guard: the type is the guard.
if (!hasStrike)
return resolveEquity(intent.instrument, describeLeg(intent), deps, exchange, currency);
const expiry = nonEmpty(deps.expiry);
if (expiry === undefined) {
throw refuse(deps, "unsupported", `option leg ${describeLeg(intent)} has NO supplied expiry — the contract layer never guesses an expiry (a "nearest" one is a trade nobody authored)`);
}
const strike = intent.strike;
const right = intent.right;
const query = {
symbol: intent.instrument,
secType: SecType.OPT,
exchange,
currency,
lastTradeDateOrContractMonth: expiry,
strike,
right: right === "C" ? OptionType.Call : OptionType.Put,
...(deps.tradingClass === undefined ? {} : { tradingClass: deps.tradingClass }),
};
const details = await requestDefinitions(deps, query, describeLeg(intent, expiry));
const matches = details.filter((d) => matchesOption(d.contract, intent.instrument, expiry, strike, right, deps.tradingClass));
const only = exactlyOne(deps, matches, describeLeg(intent, expiry));
return buildOption(deps, only.contract, describeLeg(intent, expiry));
}
async function resolveEquity(symbol, label, deps, exchange, currency) {
// A spot leg writes NO fictional strike/right onto the wire (ADR-0017) and needs no expiry.
const query = {
symbol,
secType: SecType.STK,
exchange,
currency,
};
const details = await requestDefinitions(deps, query, label);
const matches = details.filter((d) => d.contract.secType === SecType.STK && d.contract.symbol === symbol);
const only = exactlyOne(deps, matches, label);
const c = only.contract;
const conId = c.conId;
const localSymbol = nonEmpty(c.localSymbol);
const venueExchange = nonEmpty(c.exchange) ?? exchange;
const venueCurrency = nonEmpty(c.currency);
if (conId === undefined || localSymbol === undefined || venueCurrency === undefined) {
throw refuse(deps, "unresolvable", `the gateway's definition for ${label} is incomplete (conId/localSymbol/currency missing) — a partial definition is never completed by hand`);
}
return {
kind: "equity",
secType: "STK",
conId,
symbol,
exchange: venueExchange,
primaryExchange: nonEmpty(c.primaryExch),
currency: venueCurrency,
localSymbol,
tradingClass: nonEmpty(c.tradingClass),
multiplier: 1,
};
}
/**
* Resolve the UNDERLIER a chain hangs off / a feed's spot line reads (kestrel-7o2.24) — an equity/ETF
* (`STK`) or an **index** (`IND`, the NDX/XND/SPX class of cash-settled index options).
*
* `secType` is REQUIRED and never inferred. A symbol does not say what it is: `XND` is an index at
* Nasdaq and could be an ETF's ticker anywhere else, and an adapter that sniffed the class would be
* guessing an identity — precisely what this layer exists not to do. The caller states it; the
* gateway's definition confirms it, or the lookup refuses.
*
* The result is an {@link IbkrUnderlierContract}, which is NOT an {@link IbkrContract}: an index is
* not tradable and the type says so, so nothing resolved here can be handed to the order path.
*
* ## EXCHANGE: pinned, or left OPEN — never defaulted to SMART for an index
* An equity is SMART-routed and defaults to {@link IBKR_DEFAULT_EXCHANGE} as it always has. An index
* is NOT: `SMART` is a router, and indices are published by their listing venue (NDX on `NASDAQ`, SPX
* on `CBOE`). So for `IND` the query's exchange is the caller's pin if there is one, and otherwise
* LEFT OPEN — asking the venue "which listings of this index do you have?" rather than asserting a
* wrong one. Open + exactly one listing ⇒ resolved. Open + several ⇒ a LOUD `ambiguous` refusal that
* NAMES the exchanges, so the operator learns exactly what to pin. Both are fail-closed; the open
* query is merely the one that fails INFORMATIVELY instead of as a flat `unresolvable`.
*
* @throws {IbkrContractError} `unresolvable` | `ambiguous` | `lookup-failed`.
*/
export async function resolveUnderlier(query, deps) {
const currency = deps.currency ?? IBKR_DEFAULT_CURRENCY;
if (query.secType === "STK") {
const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
return resolveEquity(query.symbol, `${query.symbol} (spot/equity)`, deps, exchange, currency);
}
return resolveIndex(query.symbol, deps, currency);
}
/** The `IND` half of {@link resolveUnderlier}. Same fail-closed spine as every other lookup: the
* gateway's OWN definition, exactly one match or a typed refusal, no field hand-completed. */
async function resolveIndex(symbol, deps, currency) {
// Pinned, or OPEN — never SMART (see resolveUnderlier's docblock). `tradingClass` is deliberately
// NOT forwarded: a trading class names an OPTION series (`XND`, `SPXW`), not an index, and putting
// an option's class on an index query asks for a contract that does not exist.
const pinned = nonEmpty(deps.exchange);
const label = `${symbol} (index/IND)${pinned === undefined ? " [exchange OPEN]" : `@${pinned}`}`;
const query = {
symbol,
secType: SecType.IND,
currency,
...(pinned === undefined ? {} : { exchange: pinned }),
};
const details = await requestDefinitions(deps, query, label);
// The secType filter is load-bearing, not decoration: an `IND` question answered with a `STK`
// definition (a same-ticker ETF) is a DIFFERENT instrument, and accepting it would silently swap
// the thing the strike window centres on.
const matches = details.filter((d) => d.contract.secType === SecType.IND &&
nonEmpty(d.contract.symbol) === symbol &&
(pinned === undefined || nonEmpty(d.contract.exchange) === pinned));
const only = exactlyOne(deps, matches, label);
const c = only.contract;
const conId = c.conId;
const localSymbol = nonEmpty(c.localSymbol);
const venueExchange = nonEmpty(c.exchange);
const venueCurrency = nonEmpty(c.currency);
if (conId === undefined ||
localSymbol === undefined ||
venueExchange === undefined ||
venueCurrency === undefined) {
throw refuse(deps, "unresolvable", `the gateway's definition for ${label} is incomplete (conId/localSymbol/exchange/currency missing) — a partial definition is never completed by hand`);
}
return {
kind: "index",
secType: "IND",
conId,
symbol,
exchange: venueExchange,
currency: venueCurrency,
localSymbol,
tradingClass: nonEmpty(c.tradingClass),
};
}
/** Build the option contract from the gateway's ONE matching definition. Every field is READ from
* the venue — an incomplete definition is refused, never hand-completed (no hand-rolled OCC symbol,
* no assumed 100x multiplier). No price is read: `minTick`, `marketName`, and every other decoration
* on the {@link ContractDetails} is deliberately dropped on the floor. */
function buildOption(deps, c, leg) {
const conId = c.conId;
const localSymbol = nonEmpty(c.localSymbol);
const multiplier = numberish(c.multiplier);
const expiry = expiryOf(c); // the venue decorates its own date — normalise, never re-format by hand
const strike = numberish(c.strike);
const right = rightOf(c.right);
const currency = nonEmpty(c.currency);
const exchange = nonEmpty(c.exchange);
if (conId === undefined ||
localSymbol === undefined ||
multiplier === undefined ||
expiry === undefined ||
strike === undefined ||
right === undefined ||
currency === undefined ||
exchange === undefined) {
throw refuse(deps, "unresolvable", `the gateway's definition for ${leg} is incomplete (missing conId/localSymbol/multiplier/expiry/strike/right/currency/exchange) — a partial definition is never completed by hand`);
}
return {
kind: "option",
secType: "OPT",
conId,
symbol: nonEmpty(c.symbol) ?? "",
exchange,
currency,
localSymbol,
tradingClass: nonEmpty(c.tradingClass),
multiplier,
expiry,
strike,
right,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// resolveOptionChain — the gateway's own strike/expiry grid (a definition lookup, not a quote).
// ─────────────────────────────────────────────────────────────────────────────
/**
* Pull the gateway's option-chain parameters for an underlier (kestrel-7o2.6) — the expirations, the
* strike grid, and the multiplier IB itself publishes for one exchange + trading class. This is a
* DEFINITION lookup (`reqSecDefOptParams`), not market data: it carries no quote and no price.
*
* A caller uses it to pick a strike/expiry from what the venue actually lists (rather than inventing
* one) and then hands that identity back to {@link resolveContract}. Fail-closed: an empty chain is
* `unresolvable`; several trading classes on the requested exchange with none pinned is `ambiguous`
* (never a pick — two classes are two instruments).
*
* @throws {IbkrContractError} `unresolvable` | `ambiguous` | `lookup-failed`.
*/
export async function resolveOptionChain(query, deps) {
const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
const wanted = query.tradingClass ?? deps.tradingClass;
const label = `option chain for ${query.symbol}@${exchange}${wanted === undefined ? "" : ` class=${wanted}`}`;
const rows = await requestChain(deps, query, label);
const onExchange = rows.filter((r) => r.exchange === exchange && (wanted === undefined || r.tradingClass === wanted));
if (onExchange.length === 0) {
throw refuse(deps, "unresolvable", `the gateway lists NO ${label} — an expiry/strike grid is never invented`, { candidates: 0 });
}
if (onExchange.length > 1) {
throw refuse(deps, "ambiguous", `the gateway lists ${onExchange.length} trading classes for ${label} (${onExchange.map((r) => r.tradingClass).join(", ")}) — ambiguous; pin one with \`tradingClass\` rather than have the layer pick`, { candidates: onExchange.length });
}
const row = onExchange[0];
const multiplier = numberish(row.multiplier);
if (multiplier === undefined) {
throw refuse(deps, "unresolvable", `the ${label} came back with no multiplier — incomplete`);
}
return {
symbol: query.symbol,
exchange: row.exchange,
tradingClass: row.tradingClass,
multiplier,
// Sorted so the same gateway reply always yields the same grid, whatever order the socket
// delivered it in (determinism at the edge — a caller's "nearest expiry" pick must be stable).
expirations: [...row.expirations].sort(),
strikes: [...row.strikes].sort((a, b) => a - b),
};
}
/**
* The strikes the gateway ACTUALLY LISTS for one expiry + right (kestrel-7o2.6) — a definition
* lookup (`reqContractDetails` with the strike left OPEN), not market data: no quote, no price.
*
* Why it exists beside {@link resolveOptionChain}: `reqSecDefOptParams` returns the UNION of strikes
* across every expiry of an underlier, so a strike from that grid may not be listed on the expiry a
* caller actually wants (the near-dated series is far narrower than the union — verified against the
* live gateway). Picking from the union would therefore invent an identity the venue does not list.
* This asks the venue what it lists for THAT expiry, so a caller picks from the truth.
*
* @throws {IbkrContractError} `unresolvable` (the venue lists no strike for it) | `lookup-failed`.
*/
export async function listOptionStrikes(query, deps) {
const exchange = deps.exchange ?? IBKR_DEFAULT_EXCHANGE;
const currency = deps.currency ?? IBKR_DEFAULT_CURRENCY;
const tradingClass = query.tradingClass ?? deps.tradingClass;
const label = `listed ${query.symbol} ${query.expiry} ${query.right} strikes@${exchange}`;
// The strike is deliberately LEFT OPEN — that is the whole request: "which strikes do you list?"
const open = {
symbol: query.symbol,
secType: SecType.OPT,
exchange,
currency,
lastTradeDateOrContractMonth: query.expiry,
right: query.right === "C" ? OptionType.Call : OptionType.Put,
...(tradingClass === undefined ? {} : { tradingClass }),
};
const details = await requestDefinitions(deps, open, label);
const strikes = new Set();
for (const d of details) {
const c = d.contract;
if (c.secType !== SecType.OPT)
continue;
if (nonEmpty(c.symbol) !== query.symbol)
continue;
if (expiryOf(c) !== query.expiry)
continue;
if (rightOf(c.right) !== query.right)
continue;
if (tradingClass !== undefined && nonEmpty(c.tradingClass) !== tradingClass)
continue;
const s = numberish(c.strike);
if (s !== undefined)
strikes.add(s);
}
if (strikes.size === 0) {
throw refuse(deps, "unresolvable", `the gateway lists NO strikes for ${label} — a strike grid is never invented`, { candidates: 0 });
}
return [...strikes].sort((a, b) => a - b);
}
// ─────────────────────────────────────────────────────────────────────────────
// The shared-session bridge.
// ─────────────────────────────────────────────────────────────────────────────
/**
* The ONE shared IB session, viewed through the READ-ONLY definition surface (kestrel-7o2.6). 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 contract can never be resolved over a dead socket);
* this re-views it as an {@link IbContractClient}. The cast NARROWS toward the two definition
* requests — the real `IBApi` is a structural superset of both views, and neither view can submit an
* order. There is never a second socket.
*/
export function contractClientOf(transport) {
return transport.client();
}
/** Ask the gateway for every definition matching `query`, collecting until `contractDetailsEnd`. A
* fatal IB error on OUR request id, or a deadline lapse, is a typed refusal — never a hang and never
* an empty-result-by-silence (which would look like "no such contract" when the socket merely died). */
function requestDefinitions(deps, query, label) {
return request(deps, label, (reqId, ctx) => {
const collected = [];
ctx.listen(EventName.contractDetails, (id, details) => {
if (id === reqId)
collected.push(details);
});
ctx.listen(EventName.contractDetailsEnd, (id) => {
if (id === reqId)
ctx.done(collected);
});
deps.client.reqContractDetails(reqId, query);
});
}
/** Ask the gateway for an underlier's chain parameters, collecting until the end marker. */
function requestChain(deps, query, label) {
return request(deps, label, (reqId, ctx) => {
const rows = [];
ctx.listen(EventName.securityDefinitionOptionParameter, (id, exchange, _underlyingConId, tradingClass, multiplier, expirations, strikes) => {
if (id === reqId)
rows.push({ exchange, tradingClass, multiplier, expirations, strikes });
});
ctx.listen(EventName.securityDefinitionOptionParameterEnd, (id) => {
if (id === reqId)
ctx.done(rows);
});
// `futFopExchange` is empty for an equity/ETF **and** an index underlier (IB's own convention —
// it names a FUT/FOP exchange, which neither is). The underlier's secType is the caller's
// (`STK` for an equity/ETF, `IND` for an index — an index asked about as STK returns NOTHING),
// and its conId is the caller's already-resolved underlier contract.
const underlyingSecType = query.underlyingSecType === "IND" ? SecType.IND : SecType.STK;
deps.client.reqSecDefOptParams(reqId, query.symbol, "", underlyingSecType, query.underlyingConId);
});
}
/**
* The ONE request/response shape both definition lookups share: mint a request id, wire the reply +
* error + deadline listeners, run the request, and ALWAYS tear every listener back off (on success
* and on failure alike — a leaked listener on a shared socket is a cross-request bug). Non-fatal IB
* info codes (2100–2999, market-data-farm chatter) are ignored, exactly as the transport ignores
* them; a FATAL error on our own request id refuses, typed.
*/
function request(deps, label, body) {
const client = deps.client;
const scheduler = deps.scheduler ?? defaultScheduler;
const timeoutMs = deps.timeoutMs ?? DEFAULT_LOOKUP_TIMEOUT_MS;
const reqId = (deps.nextReqId ?? defaultNextReqId)();
return new Promise((resolve, reject) => {
const registered = [];
let settled = false;
let deadline;
const teardown = () => {
for (const [event, listener] of registered)
client.removeListener(event, listener);
registered.length = 0;
if (deadline !== undefined) {
scheduler.clearInterval(deadline);
deadline = undefined;
}
};
const settle = (fn) => {
if (settled)
return;
settled = true;
teardown();
fn();
};
const ctx = {
listen: (event, listener) => {
const wrapped = listener;
client.on(event, wrapped);
registered.push([event, wrapped]);
},
done: (value) => {
settle(() => {
resolve(value);
});
},
};
ctx.listen(EventName.error, (err, code, id) => {
if (id !== reqId)
return; // another face's request on the shared socket — not ours to judge
if (isNonFatalError(code, err)) {
deps.log?.(`ib info during ${label} (code=${code})`);
return;
}
settle(() => {
reject(code === IB_NO_SECURITY_DEFINITION
? refuse(deps, "unresolvable", `the gateway has NO security definition for ${label} (IB 200) — an unknown strike/right/expiry/symbol is never rounded toward a nearby one`, { code, cause: err, candidates: 0 })
: refuse(deps, "lookup-failed", `the definition lookup for ${label} failed at the gateway (IB ${code})`, {
code,
cause: err,
}));
});
});
// The deadline: a gateway that never answers is a FAILED lookup, not an empty one. Fired through
// the injected scheduler so tests trip it by hand (no real timer, no flake).
deadline = scheduler.setInterval(() => {
settle(() => {
reject(refuse(deps, "lookup-failed", `the definition lookup for ${label} timed out after ${timeoutMs}ms`));
});
}, timeoutMs);
try {
body(reqId, ctx);
}
catch (cause) {
settle(() => {
reject(refuse(deps, "lookup-failed", `the definition request for ${label} threw at the socket`, { cause }));
});
}
});
}
/** Exactly one definition, or a typed refusal. The heart of the fail-closed doctrine: ZERO is
* `unresolvable`, MORE THAN ONE is `ambiguous` — and the layer NEVER breaks the tie itself. */
function exactlyOne(deps, matches, label) {
if (matches.length === 0) {
throw refuse(deps, "unresolvable", `the gateway matched NO definition for ${label} — an unknown strike/right/expiry/symbol never becomes a nearby guess`, { candidates: 0 });
}
if (matches.length > 1) {
throw refuse(deps, "ambiguous", `the gateway matched ${matches.length} definitions for ${label} (${matches
.map((m) => describeCandidate(m.contract))
.join(" | ")}) — ambiguous, and picking one of several would route an order to a contract nobody authored; pin \`tradingClass\`/\`exchange\` instead`, { candidates: matches.length });
}
return matches[0];
}
/** Build + log the typed refusal. The reason is already secret-free; the account (if any) is stamped
* REDACTED, as the transport stamps it, so an operator recognises their own session without the id
* ever reaching a log. */
function refuse(deps, failure, reason, options = {}) {
const stamped = `${reason} [account=${redactAccount(deps.account)}]`;
deps.log?.(`contract refused [${failure}]: ${stamped}`);
return new IbkrContractError(failure, stamped, options);
}
/** A secret-free, publication-safe description of the leg being resolved (generic symbol + identity
* only — no price, ever). */
function describeLeg(intent, expiry) {
if (intent.strike === undefined || intent.right === undefined) {
return intent.strike === undefined && intent.right === undefined
? `${intent.instrument} (spot/equity)`
: `${intent.instrument} strike=${intent.strike ?? "-"} right=${intent.right ?? "-"}`;
}
return `${intent.instrument} ${expiry ?? "(no expiry)"} ${intent.strike}${intent.right}`;
}
/** How a single ambiguous candidate is named in the refusal — enough for an operator to see WHY the
* two differ, never enough to constitute a pick. */
function describeCandidate(c) {
return `conId=${c.conId ?? "?"} class=${c.tradingClass ?? "?"} exch=${c.exchange ?? "?"}/${c.primaryExch ?? "-"} local=${c.localSymbol ?? "?"}`;
}
/**
* Does the gateway's definition actually ANSWER the identity we asked about? A defence-in-depth
* filter: a definition that drifts from the requested strike/right/expiry/symbol is not a match, and
* a near-miss is never quietly accepted as one.
*
* ## The tradingClass pin is ENFORCED here, not merely REQUESTED on the wire
* A pinned `tradingClass` rides on the query, but a pin that only rides on the query is a pin the
* GATEWAY may ignore — and it does: `reqContractDetails` is a match-what-you-can lookup, and for a
* multi-class underlier (`NDX` with `NDX` a.m.- and `NDXP` p.m.-settled; `SPX` with `SPXW`) IB has
* been observed answering with every class on the strike. Filtering here is what makes the pin the
* disambiguator {@link IbkrContractDeps.tradingClass} claims to be. Without it the pin is INERT: the
* two classes both survive to {@link exactlyOne} and the lookup refuses `ambiguous` — fail-closed,
* but the caller did everything right and still cannot resolve the leg, so XND/SPXW is unreachable.
* (This mirrors {@link listOptionStrikes}, which has always filtered on the class.)
*
* Note the direction: this only ever REMOVES candidates. It cannot turn an ambiguity into a pick —
* two definitions sharing the pinned class are still two definitions, and still refuse.
*/
function matchesOption(c, symbol, expiry, strike, right, tradingClass) {
if (c.secType !== SecType.OPT)
return false;
if (nonEmpty(c.symbol) !== symbol)
return false;
if (expiryOf(c) !== expiry)
return false;
if (tradingClass !== undefined && nonEmpty(c.tradingClass) !== tradingClass)
return false;
const gotStrike = numberish(c.strike);
if (gotStrike === undefined || Math.abs(gotStrike - strike) > 1e-9)
return false;
return rightOf(c.right) === right;
}
/**
* The venue's LAST TRADING DAY as a bare `YYYYMMDD` (kestrel-7o2.6).
*
* A real-gateway fact the live acceptance caught: IB ANSWERS with a decorated
* `lastTradeDateOrContractMonth` — `"20260714 16:15:00 US/Eastern"` — even though it is QUERIED with
* the bare `"20260714"`. It also carries the clean date separately as `lastTradeDate`. Normalising
* here is what lets the identity check stay STRICT (a definition whose expiry drifts from the one
* asked for is not a match) without a string-format artefact rejecting every real definition.
*
* A 6-digit contract MONTH (`YYYYMM`, futures-style) is deliberately NOT accepted: we asked for a
* day, so a month is not an answer to the question — it fails closed as an incomplete definition.
*/
function expiryOf(c) {
const raw = nonEmpty(c.lastTradeDate) ?? nonEmpty(c.lastTradeDateOrContractMonth);
if (raw === undefined)
return undefined;
const head = raw.split(/\s+/)[0];
return head !== undefined && /^\d{8}$/.test(head) ? head : undefined;
}
/** IB delivers the right as `C`/`P` (and, on some paths, `CALL`/`PUT`). Anything else is UNKNOWN —
* `undefined`, which fails closed upstream rather than defaulting to a call. */
function rightOf(right) {
if (typeof right !== "string")
return undefined;
const r = right.trim().toUpperCase();
if (r === "C" || r === "CALL")
return "C";
if (r === "P" || r === "PUT")
return "P";
return undefined;
}
/** IB's wire types are loose (a multiplier arrives as `"100"` on some paths and `100` on others).
* Normalise to a finite number, or `undefined` — never `NaN`, never a coerced zero. */
function numberish(v) {
if (typeof v === "number")
return Number.isFinite(v) ? v : undefined;
if (typeof v === "string") {
const t = v.trim();
if (t.length === 0)
return undefined;
const n = Number(t);
return Number.isFinite(n) ? n : undefined;
}
return undefined;
}
function nonEmpty(v) {
if (v === undefined)
return undefined;
const t = v.trim();
return t.length > 0 ? t : undefined;
}
/** The default request-id minter — a process-local monotone counter well clear of the transport's
* order-id space. A counter, not an RNG: the edge may be async, but it is never random. */
let reqIdCounter = 9_000;
const defaultNextReqId = () => ++reqIdCounter;
/** The real deadline scheduler over global timers (the transport's own default, mirrored). Kept off
* the deterministic core — only the edge adapter uses it. */
const defaultScheduler = {
setInterval: (fn, ms) => setInterval(fn, ms),
clearInterval: (handle) => {
clearInterval(handle);
},
};