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
text/typescript
/**
* [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 };