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.

307 lines 20.4 kB
/** * # 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 } from "@stoqey/ib"; import type { Contract } from "@stoqey/ib"; import type { IbkrTransport, Scheduler } from "./transport.ts"; import type { OrderIntent } from "../../../engine/index.ts"; import type { Right } from "../../../bus/index.ts"; /** 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 declare 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 declare const IBKR_DEFAULT_EXCHANGE = "SMART"; export declare const IBKR_DEFAULT_CURRENCY = "USD"; /** The fields every resolved contract carries, whatever the asset class. Note what is ABSENT and * always will be: any price. The contract layer is a transmitter, never a re-pricer. */ interface IbkrContractBase { /** IB's unique contract id — the authoritative handle a later order face transmits against. */ readonly conId: number; readonly symbol: string; /** The destination exchange the gateway's definition names (e.g. `SMART`). */ readonly exchange: string; readonly currency: string; /** The gateway's OWN local symbol (for an option, the OCC symbol) — never hand-rolled. */ readonly localSymbol: string; /** The gateway's own trading class, when it named one (e.g. `SPY`) — the disambiguator. */ readonly tradingClass: string | undefined; } /** A resolved SPOT/EQUITY contract (ADR-0017: strike AND right both absent on the leg). */ export interface IbkrEquityContract extends IbkrContractBase { readonly kind: "equity"; readonly secType: "STK"; /** Shares are 1:1 — the multiplier is carried for a uniform sizing surface, not derived. */ readonly multiplier: 1; /** The listing exchange the gateway names for a SMART-routed equity (e.g. `ARCA`), when known. */ readonly primaryExchange: string | undefined; } /** * A resolved INDEX contract (`secType: IND`) — the underlier of a **cash-settled index option** * (NDX/XND/SPX class), and the thing a chain hangs off. * * ## It is NOT a member of {@link IbkrContract}, and that is the whole point * An index is a NUMBER, not an instrument: it cannot be bought, sold, held, or delivered. So it is * deliberately kept OUT of the tradable {@link IbkrContract} union rather than admitted and then * guarded. {@link resolveContract} — the ORDER path's entry point — can never return one, and the * order face's {@link IbkrContract}-typed surfaces (the contract book, `submit`, `preflight`) cannot * be handed one: `tsc` refuses. That is a STRUCTURAL guarantee, not a runtime check that could be * forgotten, mis-wired, or deleted. A feed subscribes to an index (that is where the spot the strike * window centres on comes from — see {@link IbkrUnderlierContract}); an order path never touches one. * * Note what is ABSENT: no `multiplier`, no `primaryExchange`. An index has no contract multiplier * (there is no contract), so the field that would let it be SIZED does not exist to be read. */ export interface IbkrIndexContract extends IbkrContractBase { readonly kind: "index"; readonly secType: "IND"; } /** * What an option chain can hang off, and what a feed's SPOT line can be: an equity/ETF underlier * (`STK`) or an index underlier (`IND`). Which one a symbol IS is never inferred from the symbol — * `XND` could be anything — it is stated by the caller ({@link resolveUnderlier}'s `secType`). */ export type IbkrUnderlierContract = IbkrEquityContract | IbkrIndexContract; /** Anything the FEED may subscribe to for a quote — an underlier (equity or index) or an option leg. * Strictly wider than {@link IbkrContract} (the TRADABLE union): a quote is not a trade. */ export type IbkrQuotedContract = IbkrUnderlierContract | IbkrOptionContract; /** Which asset class an underlier IS. Never guessed from the symbol: stated by the caller. */ export type UnderlierSecType = "STK" | "IND"; /** A resolved OPTION contract (ADR-0017: strike AND right both present on the leg). Every field is * the GATEWAY's, taken from its definition — the expiry, the multiplier, the OCC local symbol. */ export interface IbkrOptionContract extends IbkrContractBase { readonly kind: "option"; readonly secType: "OPT"; /** Contract multiplier as the venue defines it (dollars per point). Read, never assumed. */ readonly multiplier: number; /** The venue's last-trading-day, `YYYYMMDD`. Never guessed — echoed from the definition. */ readonly expiry: string; readonly strike: number; readonly right: Right; } /** * A **TRADABLE** broker contract resolved from a Kestrel identity (kestrel-7o2.6) — the discriminated * union a venue face transmits against. It carries NO price: not `px`, not a limit, not a quote, not * a tick size. The price on the {@link OrderIntent} is the engine's, already resolved with a receipt; * this layer never reads it and never lets a venue-side number impersonate one. * * {@link IbkrIndexContract} is NOT a member: an index is not tradable, so the order path's own type * excludes it and no guard is needed to keep it out (see that type's docblock). Widen to * {@link IbkrQuotedContract} for the read-only feed side, never here. */ export type IbkrContract = IbkrEquityContract | IbkrOptionContract; /** The gateway's option chain for one underlier + trading class — the definition source a caller * picks a strike/expiry FROM (it does not pick for them). Carries no price. */ export interface IbkrOptionChain { readonly symbol: string; readonly exchange: string; readonly tradingClass: string; readonly multiplier: number; /** Sorted ascending — the same gateway reply always yields the same order (determinism at the edge). */ readonly expirations: readonly string[]; /** Sorted ascending. */ readonly strikes: readonly number[]; } /** 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; /** * The NARROW structural surface of the shared IB client this layer speaks through: the two READ-ONLY * contract-definition requests and the event bus. It declares NO order-submission method at all — the * contract layer cannot transmit an order even by reaching through the client (order submission is * 7o2.8/7o2.10, behind the 7o2.9 arm envelope). A test injects a double implementing exactly this; * production passes the transport's ONE shared client (a structural superset) via * {@link contractClientOf} — never a second socket. */ export interface IbContractClient { /** Ask the gateway for every definition matching a (possibly partial) contract query. */ reqContractDetails(reqId: number, contract: Contract): unknown; /** Ask the gateway for an underlier's option-chain parameters (expirations/strikes/multiplier). */ reqSecDefOptParams(reqId: number, underlyingSymbol: string, futFopExchange: string, underlyingSecType: string, underlyingConId: number): unknown; on(event: EventName, listener: IbListener): unknown; removeListener(event: EventName, listener: IbListener): unknown; } /** Everything {@link resolveContract} is wired with. Every non-determinism source (the socket, the * request-id minter, the deadline timer) is injected, so unit tests run with none of them. */ export interface IbkrContractDeps { /** The shared IB client — the transport's ONE session in production (see {@link contractClientOf}). */ readonly client: IbContractClient; /** Mints a unique IB request id per lookup. Defaults to a module-local monotone counter (a counter, * never an RNG — this is the edge, and it still must not be a source of nondeterminism). */ readonly nextReqId?: (() => number) | undefined; /** The OPTION expiry to resolve (`YYYYMMDD`). REQUIRED for an option leg and REFUSED-if-absent — * an expiry is never inferred from a clock or a chain, because "the nearest one" is a trade the * author did not write. Ignored (and unnecessary) for a spot/equity leg. */ readonly expiry?: string | undefined; /** Destination exchange for the query (default {@link IBKR_DEFAULT_EXCHANGE}). */ readonly exchange?: string | undefined; /** Trading currency for the query (default {@link IBKR_DEFAULT_CURRENCY}). */ readonly currency?: string | undefined; /** Trading class to pin — the ONLY sanctioned way to disambiguate two classes on one underlier * (e.g. an a.m.- vs a p.m.-settled index class). Absent + two matches ⇒ a typed refusal, never a pick. */ readonly tradingClass?: string | undefined; /** Lookup deadline (ms). A lapse is a typed `lookup-failed`, never a hang. */ readonly timeoutMs?: number | undefined; /** Deadline scheduler — injected so tests fire it by hand (no real timer). */ readonly scheduler?: Scheduler | undefined; /** The session account, used ONLY to stamp a REDACTED marker on diagnostics (as the transport * redacts it). The full id never reaches a message. */ readonly account?: string | undefined; /** Redacted-diagnostic sink (default no-op). Receives only already-redacted strings. */ readonly log?: ((line: string) => void) | undefined; } /** Which underlier + class an option chain is being pulled for. */ export interface IbkrChainQuery { readonly symbol: string; /** The underlier's IB conId — resolve the underlier ({@link resolveUnderlier}) first, then hand its * conId here. */ readonly underlyingConId: number; /** What the underlier IS. `reqSecDefOptParams` takes the underlier's own secType, and an INDEX * underlier asked about as `STK` returns NO chain at all — the NDX/XND blocker. Defaults to `STK`, * which is what every equity/ETF caller has always meant; an index caller must say `IND`. Not a * guess in either direction: the caller states it (same rule as {@link resolveUnderlier}). */ readonly underlyingSecType?: UnderlierSecType | undefined; /** Pin a trading class; absent + several classes on the exchange ⇒ a typed `ambiguous` refusal. */ readonly tradingClass?: string | undefined; } /** * 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 declare function resolveContract(intent: OrderIntent, deps: IbkrContractDeps): Promise<IbkrContract>; /** * 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 declare function resolveUnderlier(query: { readonly symbol: string; readonly secType: UnderlierSecType; }, deps: IbkrContractDeps): Promise<IbkrUnderlierContract>; /** * 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 declare function resolveOptionChain(query: IbkrChainQuery, deps: IbkrContractDeps): Promise<IbkrOptionChain>; /** * 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 declare function listOptionStrikes(query: { symbol: string; expiry: string; right: Right; tradingClass?: string | undefined; }, deps: IbkrContractDeps): Promise<readonly number[]>; /** * 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 declare function contractClientOf(transport: IbkrTransport): IbContractClient; export {}; //# sourceMappingURL=contract.d.ts.map