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