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.

292 lines (260 loc) 15 kB
/** * # The IBKR PAPER **EQUITY ORDER** (kestrel-7o2.8) — env-gated; DRY-RUN by default * * The OPTION ticket is BLOCKED: `@fair` for options is corrupted (kestrel-ku99 — `buildSurface` * averages a call-IV and a put-IV across an UNCHECKED put-call-parity assumption, and live IB data * violates parity, producing an `@fair` 28% ABOVE the ask on an ATM leg). We do not trade on a price * we know is a lie. * * The EQUITY path has NO vol surface and NO parity dependency: `@fair(spot)` is `exec-fair-quote-v1` * off a clean, tight, two-sided quote. That anchor is honest, so THIS is the path we validate live. * * It proves the whole order chain end-to-end on real infrastructure: * resolve contract → two-sided quote → @fair(spot) + receipt → floor-snapped limit * → the FOUR L0 WALLS (kill-switch, order-size, position/never-naked, notional) * → placeOrder → the venue's own ORDER callbacks → the reconciliation ledger → broker-truth positions. * * ## THE HARD RULE * The default path TRANSMITS NOTHING. `preflight()` runs the full guard chain WITHOUT `placeOrder`. * Transmission happens ONLY under an explicit human `KESTREL_IBKR_TRANSMIT=1`. * * ## Also: the DATA PROBE (kestrel-ku99 root cause) * It logs the RAW IB tick field ids that arrive. 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 — which is exactly how put-call parity breaks and how the vol * surface got poisoned. This prints the evidence rather than guessing. * * ## Run it * ```bash * # DRY RUN (default — transmits nothing): * KESTREL_IBKR_EQUITY=1 KESTREL_IBKR_MODE=paper KESTREL_IBKR_PORT=4002 KESTREL_IBKR_CLIENT_ID=15 \ * bun run src/adapters/broker/ibkr/equity-order.ts * * # TRANSMIT (a separate, deliberate, HUMAN act): * KESTREL_IBKR_TRANSMIT=1 KESTREL_IBKR_EQUITY=1 ... bun run src/adapters/broker/ibkr/equity-order.ts * ``` */ import { EventName, SecType, type Contract } from "@stoqey/ib"; import { IbkrTransport } from "./transport.ts"; import { resolveIbkrConfig, describeIbkrConfig } from "./config.ts"; import { loadEnvFallback } from "../../../cli/credentials.ts"; import { resolveContract, contractClientOf } from "./contract.ts"; import type { IbkrEquityContract } from "./contract.ts"; import { ibkrBroker, contractBook, orderClientOf } from "./broker.ts"; import { positionKeyOf } from "../../broker.ts"; import { reqMktDataQuotes, priceAnchorFrom } from "./order-dryrun.ts"; import { executionFairSpot } from "../../../fair/index.ts"; import type { OrderIntent } from "../../../engine/index.ts"; import type { RiskLimits } from "../../broker.ts"; /** A BUY never bids ABOVE fair (engine/pricing.ts's own direction). */ const TICK_SIZE = 0.01; const floorSnap = (px: number, tick: number): number => Math.floor(px / tick + 1e-9) * tick; /** The L0 pre-transmit ceilings. Deliberately tiny — this is a proof, not a position. */ const LIMITS: RiskLimits = { maxOrderQty: 1, maxPositionQty: 1, maxNotionalUsd: 2_000 }; /** IB tick field ids: REAL-TIME vs their DELAYED mirrors. The whole ku99 question in six numbers. */ 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)", }; function intentOf(over: Partial<OrderIntent> & Pick<OrderIntent, "ref" | "side" | "qty" | "px">): OrderIntent { return { plan: "equity-proof", plan_instance: "equity-proof#1", role: "entry", instrument: "SPY", sourceAnnotation: "equity-proof", ...over, } as OrderIntent; } function mintOrderId(client: { on(event: EventName, listener: (...args: never[]) => void): unknown; reqIds(n?: number): unknown; }): () => number { let next: number | undefined; client.on(EventName.nextValidId, ((id: number) => { if (next === undefined || id > next) next = id; }) as never); client.reqIds(1); return () => { if (next === undefined) { throw new Error("the gateway issued no nextValidId — refusing to invent an order id. Fail-closed."); } const id = next; next += 1; return id; }; } const ibEquityQuery = (e: IbkrEquityContract): Contract => ({ conId: e.conId, symbol: e.symbol, secType: SecType.STK, exchange: e.exchange, currency: e.currency, }); const settle = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); /** Subscribe raw and record WHICH tick ids the venue actually sends. Read-only. Places no orders. */ function probeTickIds(transport: IbkrTransport, contract: Contract, label: string, ms = 4_000): Promise<void> { 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 reqId = 9000 + Math.floor(label.length); // deterministic-ish, unique per label const seen = new Map<number, number>(); client.on(EventName.tickPrice, ((id: number, field: number, price: number) => { if (id !== reqId) return; if (price === undefined || price === null || price < 0) return; seen.set(field, price); }) as never); client.reqMktData(reqId, contract, "", false, false, []); return settle(ms).then(() => { const rows = [...seen.entries()] .filter(([f]) => TICK_NAMES[f] !== undefined) .map(([f, p]) => ` ${String(f).padStart(2)} ${TICK_NAMES[f]!.padEnd(18)} = ${p}`); const delayed = [...seen.keys()].some((f) => f >= 66 && f <= 68); const realtime = [...seen.keys()].some((f) => f >= 1 && f <= 4); console.log(` [probe] ${label}:`); console.log(rows.length ? rows.join("\n") : " (no price ticks)"); console.log(` => ${realtime ? "REAL-TIME" : ""}${realtime && delayed ? " + " : ""}${delayed ? "DELAYED" : ""}${!realtime && !delayed ? "NEITHER" : ""}`); }); } async function run(): Promise<void> { const transmit = process.env["KESTREL_IBKR_TRANSMIT"] === "1"; const cfg = resolveIbkrConfig(); console.log(`\n[equity] ${describeIbkrConfig(cfg)} ${transmit ? "*** TRANSMIT ARMED ***" : "(DRY RUN — transmits nothing)"}\n`); const transport = new IbkrTransport(cfg, { log: (l) => console.log(` [t] ${l}`) }); const status = await transport.connect(); console.log(` connected: account=${status.account}\n`); try { const cdeps = { client: contractClientOf(transport), log: (l: string) => console.log(` [contract] ${l}`) }; const quotes = reqMktDataQuotes(transport); const nowTs = status.serverTime ?? Date.now(); // ── 1. Resolve the SPY equity contract (the venue's own definition). const equity = (await resolveContract(intentOf({ ref: "eq-resolve", side: "buy", qty: 1, px: 0 }), cdeps)) as IbkrEquityContract; console.log(`\n SPY equity: conId=${equity.conId} ${equity.exchange}/${equity.primaryExchange ?? "?"} ${equity.currency}\n`); // ── 2. DATA PROBE (ku99 root cause): which tick ids does the venue actually send? console.log(" ── DATA PROBE — real-time vs delayed (the ku99 parity question) ──"); await probeTickIds(transport, ibEquityQuery(equity), "SPY equity"); // ── 3. The two-sided quote → @fair(spot). exec-fair-quote-v1. No surface, no parity dependency. const q = await quotes.quote(ibEquityQuery(equity)); // A PRICE ANCHOR never accepts delayed/frozen input (kestrel-7o2.8, blocker 4a). The venue // SUBSTITUTES stale data rather than refusing, so the substitution is caught here — before a // single number reaches @fair — and the anchor is UNRESOLVABLE rather than quietly wrong. if (q.freshness !== "live") { throw new Error( `the PRICE ANCHOR requires LIVE market data and the venue served ${q.freshness.toUpperCase()} — delayed/frozen data is a HEALTH SIGNAL, never a price. @fair(spot) is UNRESOLVABLE. Fail-closed; NOTHING transmitted.`, ); } const fair = executionFairSpot({ bid: q.bid, ask: q.ask, asof: nowTs }); if (fair === null) { throw new Error( `SPY's book is one-sided/dark/crossed (bid=${q.bid} ask=${q.ask}) — @fair(spot) is UNRESOLVABLE and a mid is NEVER substituted. Fail-closed.`, ); } const limitPx = floorSnap(fair.value, TICK_SIZE); // ── 4. The order face + the L0 walls — INCLUDING the fair-vs-book bound, which now lives in the // MODULE (broker.ts wall 6), not in this script. It was a script-local check here, which means // every OTHER order path crossed no such wall at all: the option dry-run printed an // authorizable ticket at @fair 0.9229 against a 0.73 offer. A bound only one caller performs is // not a bound. preflight() below refuses on a fair outside the observed book, for everyone. const orderClient = orderClientOf(transport); const broker = ibkrBroker(cfg, { client: orderClient, contracts: contractBook([equity]), limits: LIMITS, intrinsicOf: () => undefined, // equity has no strike/right; the intrinsic floor is a SELL doctrine // Key the anchor by the LEG, returning it ONLY for the equity we actually priced — undefined for // anything else. A priceAnchorOf that IGNORED its leg and returned the same anchor for every key // is exactly the shape of bug the price-anchor wall exists to prevent (kestrel-7o2.8 minor). priceAnchorOf: (leg) => positionKeyOf(leg) === positionKeyOf({ instrument: equity.symbol }) ? priceAnchorFrom(q, fair.value) : undefined, expectedPositions: () => ({}), nextOrderId: mintOrderId(orderClient), drain: () => {}, }); // The gateway's `nextValidId` is ASYNC: the listener above registers after connect (so it misses // IB's automatic connect-time broadcast) and `reqIds(1)` must round-trip. Wait for it — the broker // will (correctly) refuse to invent an order id, so give the venue time to issue one. await settle(2_500); const intent = intentOf({ ref: "eq-1", side: "buy", qty: 1, px: limitPx, sourceAnnotation: `fair=${fair.receipt.model}` }); const ticket = broker.preflight(intent); // ← the guard chain. TRANSMITS NOTHING. // ── 6. The ticket. console.log(` ╔══════════════════════════════════════════════════════════════════════════════╗ ║ ORDER TICKET — kestrel-7o2.8 · IBKR PAPER · EQUITY ║ ║ ${transmit ? "*** TRANSMIT ARMED — a human authorized this *** " : "*** DRY RUN — NO ORDER TRANSMITTED *** "}║ ╚══════════════════════════════════════════════════════════════════════════════╝ CONTRACT (the gateway's OWN definition) symbol / conId ${equity.symbol} ${equity.conId} secType STK (equity — no vol surface, no parity dependency) exchange / currency ${equity.exchange} / ${equity.currency} multiplier x${ticket.multiplier} ORDER side / qty ${ticket.side.toUpperCase()} ${ticket.qty} type LIMIT (DAY) limit price $${ticket.limitPx.toFixed(2)} PRICE — @fair is the ANCHOR (the mid is NEVER a price and NEVER a value) @fair(spot) $${fair.value.toFixed(4)} receipt ${fair.receipt.model}(bid=${fair.receipt.bid},ask=${fair.receipt.ask}) limit = floor-snap $${limitPx.toFixed(2)} (a BUY never bids ABOVE fair) THE OBSERVED BOOK — a health signal that also BOUNDS the anchor (broker.ts wall 6) bid / ask $${q.bid ?? "-"} / $${q.ask ?? "-"} [${q.freshness.toUpperCase()}] PRICE ANCHOR @fair ${ticket.anchor.fair.toFixed(4)} INSIDE the LIVE book ${ticket.anchor.bid} / ${ticket.anchor.ask} ✓ FAIR-VS-BOOK GUARD limit ${limitPx.toFixed(2)} <= ask ${q.ask ?? "-"} ✓ (enforced in the MODULE, for every order path) RISK — DEFINED-RISK LONG notional $${ticket.notionalUsd.toFixed(2)} budget / R $${LIMITS.maxNotionalUsd.toFixed(2)} position before/after 0 -> ${ticket.qty} (never negative — never-naked) L0 CLAMP (7o2.9 — checked BEFORE the wire) maxOrderQty ${ticket.qty} <= ${LIMITS.maxOrderQty} maxPositionQty ${ticket.qty} <= ${LIMITS.maxPositionQty} maxNotionalUsd ${ticket.notionalUsd.toFixed(2)} <= ${LIMITS.maxNotionalUsd} VERDICT CLEARED `); // ── 7. THE WALL. if (!transmit) { console.log("KESTREL_IBKR_TRANSMIT is NOT set. placeOrder was NEVER called. This is the default.\n"); return; } console.log("KESTREL_IBKR_TRANSMIT=1 — a human authorized the ticket above. TRANSMITTING...\n"); broker.submit(intent); // The order rests PASSIVELY at fair (a BUY never bids above fair), so give the venue a real // window to fill it rather than declaring failure on a 6s stopwatch. for (let i = 0; i < 10; i++) { await settle(3_000); const rec = broker.ledger()[0]; if (rec !== undefined) { console.log(` t+${(i + 1) * 3}s phase=${rec.phase} filled=${rec.filledQty}/${rec.submittedQty}`); if (rec.phase === "filled" || rec.phase === "cancelled" || rec.phase === "rejected") break; } } console.log(" ── THE VENUE'S OWN REPORT (broker truth, never our expectation) ──"); for (const rec of broker.ledger()) { console.log( ` ledger: ${rec.ref} [ibOrderId=${rec.ibOrderId}] phase=${rec.phase} filled=${rec.filledQty}/${rec.submittedQty} avgPx=${rec.avgFillPx ?? "-"} commission=$${rec.commissionUsd}`, ); } console.log(` positions (broker truth): ${JSON.stringify(broker.positions())}`); const trip = broker.reconcile(); console.log(` reconcile: ${trip === undefined || trip === null ? "OK — engine and broker agree" : JSON.stringify(trip)}`); console.log(` kill-switch: tripped=${broker.killSwitch.tripped}${broker.killSwitch.reason ? ` (${broker.killSwitch.reason})` : ""}\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_EQUITY"] !== "1") { console.log("[equity] skipped: set KESTREL_IBKR_EQUITY=1 (and run a paper IB Gateway)."); } else { run().catch((e: unknown) => { const err = e as Error; console.error(`\n[equity] FAILED (fail-closed): ${err.name}: ${err.message}\n`); process.exitCode = 1; }); } } export { run };