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.

175 lines (158 loc) 8.66 kB
/** * [NEEDS-LIVE-GATEWAY] Owner acceptance for the IB CONTRACT-RESOLUTION layer (kestrel-7o2.6). * * This is the acceptance the OWNER runs against a RUNNING IB PAPER gateway. It is NOT a unit test * and is NEVER exercised by `bun test` (it lives under `src/`, not `*.test.ts`, and only runs when * invoked directly with the env gate set), so CI stays green with no gateway. * * It PLACES NO ORDERS. It issues READ-ONLY definition lookups only (`reqContractDetails`, * `reqSecDefOptParams`) over the transport's ONE shared socket, and proves, against the REAL venue: * 1. an EQUITY contract resolves for SPY and for QQQ (generic tickers — ARCHITECTURE §7); * 2. a NEAR-DATED SPY option resolves — its strike/expiry picked from the LIVE chain via the * definition lookup, printing the gateway's own multiplier / exchange / currency / OCC symbol; * 3. a deliberately BOGUS strike fails CLOSED with the typed `IbkrContractError` — the real * gateway says "no security definition" and the layer refuses rather than guessing a neighbour; * 4. an AMBIGUOUS request (an equity query with the routing pins removed) refuses rather than * picking one of several listings — the never-a-pick invariant, against the real venue. * * Run (paper only; use a UNIQUE clientId — collisions break IB API connections): * KESTREL_IBKR_CONTRACT_SMOKE=1 KESTREL_IBKR_MODE=paper KESTREL_IBKR_PORT=4002 \ * KESTREL_IBKR_CLIENT_ID=12 bun run src/adapters/broker/ibkr/contract-smoke.ts */ import { contractClientOf, listOptionStrikes, resolveContract, resolveOptionChain } from "./contract.ts"; import type { IbkrContract, IbkrContractDeps } from "./contract.ts"; import { describeIbkrConfig, resolveIbkrConfig } from "./config.ts"; import { loadEnvFallback } from "../../../cli/credentials.ts"; import { IbkrContractError } from "./errors.ts"; import { IbkrTransport } from "./transport.ts"; import type { OrderIntent } from "../../../engine/index.ts"; import type { Right } from "../../../bus/index.ts"; /** A minimal resolved intent — the shape the engine hands a venue face. `px` is present (the engine * resolved it) and is IGNORED end to end by the contract layer: a transmitter never re-prices. */ function intentOf(instrument: string, strike?: number, right?: Right): OrderIntent { return { ref: `smoke-${instrument}-${strike ?? "spot"}${right ?? ""}`, plan: "smoke", plan_instance: "smoke#1", role: "entry", instrument, side: "buy", qty: 1, ...(strike === undefined ? {} : { strike }), ...(right === undefined ? {} : { right }), px: 1.23, // never read by this layer — proof is the resolved contract carries no price at all sourceAnnotation: "@ask", }; } function show(label: string, c: IbkrContract): void { const common = `conId=${c.conId} symbol=${c.symbol} exchange=${c.exchange} currency=${c.currency} localSymbol="${c.localSymbol}" tradingClass=${c.tradingClass ?? "-"} multiplier=${c.multiplier}`; const extra = c.kind === "option" ? ` expiry=${c.expiry} strike=${c.strike} right=${c.right}` : ` primaryExchange=${c.primaryExchange ?? "-"}`; console.log(`[contract-smoke] ${label}: ${common}${extra}`); console.log( `[contract-smoke] ${label}: price fields on the resolved contract = ${JSON.stringify( Object.keys(c).filter((k) => ["px", "price", "limit", "bid", "ask", "mid"].includes(k)), )} (must be [])`, ); } /** Run `fn`, REQUIRE a typed refusal, and print it. A resolution here would be the bug: it would mean * the layer guessed a contract for an identity the venue does not list. */ async function mustRefuse(label: string, fn: () => Promise<IbkrContract>): Promise<void> { try { const c = await fn(); throw new Error( `[contract-smoke] FAIL: ${label} RESOLVED to conId=${c.conId} — fail-closed violated (a guessed contract)`, ); } catch (e) { if (e instanceof IbkrContractError) { console.log(`[contract-smoke] ${label}: REFUSED as required — [${e.failure}] ${e.reason}`); return; } throw e; } } async function runContractSmoke(): Promise<void> { const config = resolveIbkrConfig(); console.log( `[contract-smoke] resolving over ${describeIbkrConfig(config)} — READ-ONLY definition lookups, PLACES NO ORDERS`, ); if (config.mode !== "paper") { throw new Error("[contract-smoke] refuses any mode but paper (fail-closed)"); } const transport = new IbkrTransport(config, { heartbeatIntervalMs: 5_000, log: (line) => console.log(`[contract-smoke] transport: ${line}`), }); await transport.connect(); console.log(`[contract-smoke] connected. account=${transport.status().account} (redacted)`); // ONE shared socket — the contract layer re-views the transport's single guarded client. const deps: IbkrContractDeps = { client: contractClientOf(transport), account: config.account, timeoutMs: 15_000, }; try { // (1) EQUITY — the generic tickers. const spy = await resolveContract(intentOf("SPY"), deps); show("SPY equity", spy); const qqq = await resolveContract(intentOf("QQQ"), deps); show("QQQ equity", qqq); // (2) The LIVE chain → pick a near-dated expiry + a listed strike near the money, then resolve // that exact identity through the gateway's definition lookup. The layer never picks for us. const chain = await resolveOptionChain( { symbol: "SPY", underlyingConId: spy.conId, tradingClass: "SPY" }, deps, ); console.log( `[contract-smoke] SPY chain: exchange=${chain.exchange} class=${chain.tradingClass} multiplier=${chain.multiplier} expirations=${chain.expirations.length} strikes=${chain.strikes.length} nearest=${chain.expirations[0] ?? "?"}`, ); const expiry = chain.expirations[0]; if (expiry === undefined) throw new Error("[contract-smoke] the live chain listed no expiration"); // The chain's strike grid is the UNION across every expiry — far wider than any one series lists. // So ask the venue what it lists for THIS expiry (another definition lookup, no market data) and // pick from that. Picking off the union would invent an identity the venue does not list. const listed = await listOptionStrikes({ symbol: "SPY", expiry, right: "C", tradingClass: "SPY" }, deps); const strike = listed[Math.floor(listed.length / 2)]; if (strike === undefined) throw new Error("[contract-smoke] the venue lists no strike for that expiry"); console.log( `[contract-smoke] SPY ${expiry} C: the venue LISTS ${listed.length} strikes (${listed[0]}${listed[listed.length - 1]}); picked the median ${strike}`, ); const opt = await resolveContract(intentOf("SPY", strike, "C"), { ...deps, expiry, tradingClass: "SPY" }); show("SPY option", opt); // (3) FAIL-CLOSED against the REAL gateway: a strike the venue does not list. // Deliberately LAST of the socket-touching steps: the real gateway answers IB 200, and the // transport's fatal-error rule degrades the shared session on it (see contract.ts §KNOWN // INTERACTION) — so any lookup after this one would correctly throw until a reconnect. await mustRefuse("SPY option @ a BOGUS strike (99999)", () => resolveContract(intentOf("SPY", 99999, "C"), { ...deps, expiry, tradingClass: "SPY" }), ); console.log( `[contract-smoke] after the refusal the shared transport reports degraded=${transport.status().degraded} — the venue's own 200 felled the session (fail-closed; a reconnect is required)`, ); // (4) FAIL-CLOSED: an option leg with NO expiry — never guessed, even though the chain is right there. await mustRefuse("SPY option with NO supplied expiry", () => resolveContract(intentOf("SPY", strike, "C"), deps), ); console.log("[contract-smoke] PASS — every contract came from the gateway; no order was placed."); } finally { transport.disconnect(); console.log("[contract-smoke] transport closed cleanly. No orders were placed."); } } if (import.meta.main) { loadEnvFallback(); // KESTREL_IBKR_* identifiers from the shared secrets home (OSS-ADR-0054) if (process.env.KESTREL_IBKR_CONTRACT_SMOKE !== "1") { console.log( "[contract-smoke] skipped: set KESTREL_IBKR_CONTRACT_SMOKE=1 (and run a paper IB Gateway) to exercise this. Places no orders.", ); } else { runContractSmoke().catch((e: unknown) => { const err = e as Error; console.error(`[contract-smoke] FAILED (fail-closed): ${err.name}: ${err.message}`); process.exitCode = 1; }); } } export { runContractSmoke };