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.

158 lines (140 loc) 7.96 kB
/** * # DATA PROBE (kestrel-ku99) — is the OPTION feed delayed while the EQUITY is real-time? * * READ-ONLY. Subscribes to the SPY equity and to SPY option legs, and reports WHICH IB tick field ids * the venue actually sends. IB uses 1/2/4 for REAL-TIME bid/ask/last and 66/67/68 for their DELAYED * mirrors. If the option legs arrive DELAYED while the equity is REAL-TIME, the two are snapshots of * DIFFERENT MOMENTS — and that is exactly how put-call parity breaks and how buildSurface got poisoned * (an @fair 28% above the ask on an ATM leg). * * It also computes the parity residual directly from the observed legs: * at r = 0, C - P must equal S - K. * A non-zero residual is the smoking gun, and solving it for S gives the OPTION-IMPLIED spot, which we * compare against the EQUITY-quoted spot. * * PLACES NO ORDERS. * * KESTREL_IBKR_PROBE=1 KESTREL_IBKR_MODE=paper KESTREL_IBKR_PORT=4002 KESTREL_IBKR_CLIENT_ID=17 \ * bun run src/adapters/broker/ibkr/tick-probe.ts */ import { EventName, SecType, OptionType, type Contract } from "@stoqey/ib"; import { IbkrTransport } from "./transport.ts"; import { resolveIbkrConfig, describeIbkrConfig } from "./config.ts"; import { loadEnvFallback } from "../../../cli/credentials.ts"; import { resolveContract, resolveOptionChain, listOptionStrikes, contractClientOf } from "./contract.ts"; import type { IbkrEquityContract } from "./contract.ts"; import type { OrderIntent } from "../../../engine/index.ts"; const TICK_NAMES: Record<number, string> = { 1: "BID real-time", 2: "ASK real-time", 4: "LAST real-time", 66: "BID *DELAYED*", 67: "ASK *DELAYED*", 68: "LAST *DELAYED*", }; const settle = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); interface Probe { readonly label: string; readonly ticks: Map<number, number>; } function subscribe(transport: IbkrTransport, reqId: number, contract: Contract, label: string): Probe { const client = transport.client() as unknown as { on(e: EventName, l: (...a: never[]) => void): unknown; reqMktData(id: number, c: Contract, g: string, snap: boolean, regSnap: boolean, opts: unknown[]): unknown; }; const ticks = new Map<number, number>(); client.on(EventName.tickPrice, ((id: number, field: number, price: number) => { if (id !== reqId) return; if (typeof price !== "number" || price < 0) return; ticks.set(field, price); }) as never); client.reqMktData(reqId, contract, "", false, false, []); return { label, ticks }; } function report(p: Probe): { delayed: boolean; realtime: boolean; bid: number | null; ask: number | null } { const rows = [...p.ticks.entries()].filter(([f]) => TICK_NAMES[f] !== undefined).sort((a, b) => a[0] - b[0]); console.log(` ${p.label}`); for (const [f, px] of rows) console.log(` ${String(f).padStart(2)} ${TICK_NAMES[f]} = ${px}`); const realtime = rows.some(([f]) => f <= 4); const delayed = rows.some(([f]) => f >= 66); console.log(` => ${realtime ? "REAL-TIME" : ""}${realtime && delayed ? " + " : ""}${delayed ? "*** DELAYED ***" : ""}${!realtime && !delayed ? "no price ticks" : ""}\n`); const bid = p.ticks.get(1) ?? p.ticks.get(66) ?? null; const ask = p.ticks.get(2) ?? p.ticks.get(67) ?? null; return { delayed, realtime, bid, ask }; } async function run(): Promise<void> { const cfg = resolveIbkrConfig(); console.log(`\n[probe] ${describeIbkrConfig(cfg)} — READ-ONLY, PLACES NO ORDERS\n`); const transport = new IbkrTransport(cfg, { log: () => {} }); const status = await transport.connect(); console.log(` connected: account=${status.account}\n`); try { const cdeps = { client: contractClientOf(transport), log: () => {} }; const base = { plan: "probe", plan_instance: "probe#1", role: "entry", instrument: "SPY", sourceAnnotation: "probe" }; const equity = (await resolveContract({ ...base, ref: "p-eq", side: "buy", qty: 1, px: 0 } as OrderIntent, cdeps)) as IbkrEquityContract; const chain = await resolveOptionChain({ symbol: "SPY", underlyingConId: equity.conId, tradingClass: "SPY" }, cdeps); const expiry = [...chain.expirations].sort()[0]!; const listed = await listOptionStrikes({ symbol: "SPY", expiry, right: "C", tradingClass: "SPY" }, cdeps); const eqQ: Contract = { conId: equity.conId, symbol: "SPY", secType: SecType.STK, exchange: equity.exchange, currency: equity.currency }; const optQ = (strike: number, right: "C" | "P"): Contract => ({ symbol: "SPY", secType: SecType.OPT, exchange: "SMART", currency: "USD", lastTradeDateOrContractMonth: expiry, strike, right: right === "C" ? OptionType.Call : OptionType.Put, tradingClass: "SPY", multiplier: 100, }); // Subscribe equity first so we can pick the ATM strike from a real spot. const eqP = subscribe(transport, 7001, eqQ, "SPY equity (STK)"); await settle(4_000); const eq = report(eqP); const spot = eq.bid !== null && eq.ask !== null ? (eq.bid + eq.ask) / 2 : null; if (spot === null) throw new Error("no two-sided equity quote — cannot pick an ATM strike"); const atm = listed.reduce((best, s) => (Math.abs(s - spot) < Math.abs(best - spot) ? s : best), listed[0]!); console.log(` spot (from equity quote) = ${spot.toFixed(2)} ATM strike = ${atm} expiry = ${expiry}\n`); const cP = subscribe(transport, 7002, optQ(atm, "C"), `SPY ${expiry} ${atm}C (OPT)`); const pP = subscribe(transport, 7003, optQ(atm, "P"), `SPY ${expiry} ${atm}P (OPT)`); await settle(6_000); const c = report(cP); const p = report(pP); // ── THE PARITY TEST. At r = 0: C - P must equal S - K. console.log(" ── PUT-CALL PARITY (the ku99 smoking gun) ──"); if (c.bid === null || c.ask === null || p.bid === null || p.ask === null) { console.log(" one of the legs is one-sided/dark — cannot test parity.\n"); } else { const cMid = (c.bid + c.ask) / 2; const pMid = (p.bid + p.ask) / 2; const lhs = cMid - pMid; const rhs = spot - atm; const residual = lhs - rhs; const impliedSpot = atm + lhs; console.log(` C mid = ${cMid.toFixed(4)} P mid = ${pMid.toFixed(4)} K = ${atm}`); console.log(` C - P = ${lhs.toFixed(4)}`); console.log(` S - K = ${rhs.toFixed(4)} (S = ${spot.toFixed(4)} from the EQUITY quote)`); console.log(` PARITY RESIDUAL = ${residual.toFixed(4)} ${Math.abs(residual) > 0.05 ? "*** VIOLATED ***" : "(ok)"}`); console.log(` spot implied by the OPTION legs = ${impliedSpot.toFixed(4)}`); console.log(` spot quoted by the EQUITY = ${spot.toFixed(4)}`); console.log(` DISAGREEMENT = ${(spot - impliedSpot).toFixed(4)}\n`); } const mixed = eq.realtime && (c.delayed || p.delayed); console.log(" ── VERDICT ──"); console.log(` equity: ${eq.realtime ? "REAL-TIME" : "delayed"} options: ${c.delayed || p.delayed ? "*** DELAYED ***" : "real-time"}`); console.log( mixed ? " MIXED ENTITLEMENTS — the equity and the option legs are snapshots of DIFFERENT MOMENTS.\n" + " That is the ku99 root cause: buildSurface averaged a call-IV and a put-IV across quotes\n" + " that do not describe the same instant, and put-call parity broke.\n" : " Entitlements match — the parity break (if any) is NOT a delayed/real-time mismatch.\n", ); } finally { transport.disconnect(); } } if (import.meta.main) { loadEnvFallback(); // KESTREL_IBKR_* identifiers from the shared secrets home (OSS-ADR-0054) if (process.env["KESTREL_IBKR_PROBE"] !== "1") console.log("[probe] set KESTREL_IBKR_PROBE=1 to run."); else run().catch((e: unknown) => { console.error(`[probe] FAILED: ${(e as Error).message}`); process.exitCode = 1; }); }