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