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.

645 lines (578 loc) 33.7 kB
/** * # The IBKR PAPER **DRY-RUN ORDER TICKET** (kestrel-7o2.8) — env-gated, TRANSMITS NOTHING * * Not a test (it is not a `*.test.ts` and never runs under `bun test`): a hand-run script that drives * the whole paper order face against a LIVE, client-launched IB Gateway and **prints the order ticket * a human then reviews** — the contract identity, the side/qty, the `@fair`-anchored limit price WITH * ITS RECEIPT, the observed bid/ask (a HEALTH SIGNAL, never a price), the max loss, the budget, and * the L0 clamp verdict. * * ## THE HARD RULE: THE DEFAULT PATH TRANSMITS NOTHING * It resolves the contract, pulls the quotes, computes the price, runs the L0 clamp, prints the * ticket — and STOPS. `IbkrBroker.preflight` is what it calls: the full guard chain WITHOUT the * `placeOrder`. Transmission happens ONLY if `KESTREL_IBKR_TRANSMIT=1` is explicitly set, which is a * separate, deliberate, HUMAN act. The dry run is the default and says so loudly. * * ## What it is (and is not) allowed to be * - The instrument is **SPY** — a generic, publication-safe ticker. Nothing here reveals an * application or a strategy (ARCHITECTURE §7). * - The action is a **DEFINED-RISK LONG**: BUY 1 option contract. Max loss = the premium, so * never-naked holds trivially and the ticket can be checked by eye. * - `mode` is **paper**. The order face refuses `live` at construction. * * ## The quote source is INJECTED * kestrel-7o2.7 (the `ibkrFeed` FeedSource) is a parallel bead and does not exist on this branch, so * this script pulls its two-sided quotes directly through the transport's shared socket * ({@link reqMktDataQuotes}). That path is behind an injected {@link QuoteSource}, so the real feed * drops in later without touching this script. * * ## Run it * ```bash * KESTREL_IBKR_PAPER_E2E=1 KESTREL_IBKR_CLIENT_ID=14 bun run src/adapters/broker/ibkr/order-dryrun.ts * ``` */ import { EventName, SecType, OptionType } from "@stoqey/ib"; import 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 { IbkrContract, IbkrOptionContract, IbkrEquityContract } from "./contract.ts"; import { ibkrBroker, contractBook, orderClientOf } from "./broker.ts"; import type { OrderTicket, PriceAnchor, QuoteFreshness } from "./broker.ts"; import { executionFair, executionFairSpot, intrinsic, tauYearsToClose } from "../../../fair/index.ts"; import type { OptionQuote } from "../../../bus/index.ts"; import type { OrderIntent } from "../../../engine/index.ts"; import type { RiskLimits } from "../../broker.ts"; // ───────────────────────────────────────────────────────────────────────────── // The injected quote seam — a two-sided quote, and nothing more. // ───────────────────────────────────────────────────────────────────────────── /** * A two-sided quote for one contract, WITH ITS PROVENANCE. * * `bid`/`ask` are a HEALTH SIGNAL (is this thing tradeable?) — never a value, and never a price on * their own. But they are not decoration either: they BOUND `@fair`, which is the only thing that * prices an order. And `@fair` may only be computed from LIVE input, so `freshness` travels WITH the * numbers — a quote that cannot say where it came from cannot anchor anything (kestrel-7o2.8). */ export interface TwoSidedQuote { readonly bid: number | null; readonly ask: number | null; readonly last: number | null; /** What the VENUE served. Only `live` may ANCHOR a price; the rest are health signals only. */ readonly freshness: QuoteFreshness; } /** * Where the dry run gets its quotes (kestrel-7o2.8). INJECTED so the real `ibkrFeed` FeedSource * (kestrel-7o2.7, a parallel bead) substitutes for {@link reqMktDataQuotes} later without this script * changing at all. */ export interface QuoteSource { quote(contract: Contract): Promise<TwoSidedQuote>; } /** * IB's `tickPrice` field ids. `@stoqey/ib` exports its root `TickType` as a TYPE union, not as an enum * VALUE, so the ids are named here rather than deep-imported or left as bare literals at the branch — * the same discipline `contract.ts` applies to `IB_NO_SECURITY_DEFINITION`. */ const IB_TICK_BID = 1; const IB_TICK_ASK = 2; const IB_TICK_LAST = 4; /** The DELAYED mirrors of the three above. Their ARRIVAL is itself proof the data is not live. */ const IB_TICK_DELAYED_BID = 66; const IB_TICK_DELAYED_ASK = 67; const IB_TICK_DELAYED_LAST = 68; /** IB market-data types: 1 live · 2 frozen · 3 delayed · 4 delayed-frozen. */ const IB_MARKET_DATA_LIVE = 1; /** IB's `marketDataType` id → what it means for a PRICE ANCHOR. An unrecognised id is `unknown`, and * `unknown` is refused just as loudly as `delayed` — provenance we cannot establish is not provenance. */ function freshnessOf(marketDataType: number): QuoteFreshness { switch (marketDataType) { case 1: return "live"; case 2: return "frozen"; case 3: return "delayed"; case 4: return "delayed-frozen"; default: return "unknown"; } } /** The narrow MARKET-DATA surface of the shared IB client. Read-only — it has no order method at all. */ interface IbQuoteClient { reqMktData(reqId: number, contract: Contract, genericTickList: string | null, snapshot: boolean, regulatorySnapshot: boolean): unknown; cancelMktData(reqId: number): unknown; reqMarketDataType(marketDataType: number): unknown; on(event: EventName, listener: (...args: never[]) => void): unknown; removeListener(event: EventName, listener: (...args: never[]) => void): unknown; } /** * The default {@link QuoteSource}: `reqMktData` over the transport's ONE shared socket. It collects * `tickPrice` until BOTH sides are present or the deadline lapses, then cancels the subscription. * * ## It asks for LIVE, and it REPORTS what it actually got (kestrel-7o2.8, blocker 4a) * The shipped version asked for `DELAYED_FROZEN` and fed the result to `@fair` as a PRICE ANCHOR. That * is the defect: a price anchor must never accept delayed/frozen input. So we request **LIVE** (type * 1) — and because IB SUBSTITUTES rather than refusing when the account lacks a live subscription, we * also LISTEN: the venue's own `marketDataType` callback, and the arrival of the DELAYED tick ids * (66/67/68), each stamp the quote's {@link TwoSidedQuote.freshness}. A delayed quote is still returned * — it is an honest health signal, and a MISSING one would look like a dark book — but it is returned * WEARING ITS LABEL, and everything that PRICES an order refuses it (the broker's price-anchor wall). */ export function reqMktDataQuotes(transport: IbkrTransport, timeoutMs = 6_000): QuoteSource { const client = transport.client() as unknown as IbQuoteClient; let reqId = 9_000; // LIVE. Anything that prices an order requires it; if the venue serves something else, the quote // below says so and the price anchor fails closed rather than quietly pricing off stale data. client.reqMarketDataType(IB_MARKET_DATA_LIVE); return { quote(contract: Contract): Promise<TwoSidedQuote> { const id = ++reqId; return new Promise<TwoSidedQuote>((resolve) => { let bid: number | null = null; let ask: number | null = null; let last: number | null = null; // Until the venue tells us otherwise, its provenance is UNKNOWN — never assumed live. let freshness: QuoteFreshness = "unknown"; let done = false; const finish = (): void => { if (done) return; done = true; clearTimeout(deadline); client.removeListener(EventName.tickPrice, onTick as never); client.removeListener(EventName.marketDataType, onType as never); try { client.cancelMktData(id); } catch { // A cancel on a subscription the gateway already dropped is a no-op, never a crash. } resolve({ bid, ask, last, freshness }); }; /** The venue's own word on what it is serving THIS request. */ const onType = (rid: number, marketDataType: number): void => { if (rid !== id) return; freshness = freshnessOf(marketDataType); }; const onTick = (rid: number, field: number, value: number): void => { if (rid !== id) return; // IB reports "no quote" as -1 (and sometimes 0 for an untraded option). Treat both as DARK // rather than as a price — a fabricated level is exactly what fail-closed forbids. const v = value > 0 ? value : null; switch (field) { case IB_TICK_BID: bid = v; break; case IB_TICK_ASK: ask = v; break; case IB_TICK_LAST: last = v; break; // A DELAYED tick id is ITSELF the evidence: whatever the venue said (or did not say), data // arriving on 66/67/68 is not live, and it can never be laundered into a price anchor. case IB_TICK_DELAYED_BID: bid = v; freshness = "delayed"; break; case IB_TICK_DELAYED_ASK: ask = v; freshness = "delayed"; break; case IB_TICK_DELAYED_LAST: last = v; freshness = "delayed"; break; default: return; } if (bid !== null && ask !== null) finish(); }; const deadline = setTimeout(finish, timeoutMs); client.on(EventName.tickPrice, onTick as never); client.on(EventName.marketDataType, onType as never); client.reqMktData(id, contract, null, false, false); }); }, }; } /** * Build the broker's {@link PriceAnchor} from an OBSERVED quote and the `@fair` derived from it — * fail-closed: a quote that is not LIVE and two-sided yields NO anchor at all, and the broker's * price-anchor wall then refuses the order. This is the ONE place a script turns a quote into * something that may price an order, and it is deliberately unable to launder a stale one. */ export function priceAnchorFrom(quote: TwoSidedQuote, fair: number): PriceAnchor | undefined { if (quote.bid === null || quote.ask === null) return undefined; // dark / one-sided: not a book if (quote.freshness !== "live") return undefined; // a HEALTH SIGNAL, never a price anchor // A LIVE two-sided quote for the EQUITY path (a fresh, tight SPY-spot book) VOUCHES for the fair the // anchor carries (kestrel-ltrf): the fair is derived from exactly this live book, so its receipt is // trusted. The precise per-instrument trust for the (index-option) fair belongs to the fair redesign // in src/fair (kestrel-ku99/ltrf) — this script builds only from a fresh live book. return { bid: quote.bid, ask: quote.ask, fair, fairTrusted: true, freshness: quote.freshness }; } // ───────────────────────────────────────────────────────────────────────────── // The ticket. // ───────────────────────────────────────────────────────────────────────────── /** How wide an ATM window of strikes the vol surface is built from. A `@fair` with a receipt needs * real liquid neighbours — one leg cannot imply its own surface. */ const SURFACE_HALF_WIDTH = 4; /** SPY options quote in pennies. Used ONLY to floor-snap a BUY (a BUY never bids ABOVE fair). */ const TICK_SIZE = 0.01; /** The L0 ceilings this dry run is bounded by. One contract, a few hundred dollars — small enough * that a human can check the arithmetic by eye. */ const LIMITS: RiskLimits = { maxOrderQty: 1, maxPositionQty: 1, maxNotionalUsd: 2_000 }; /** The dry run's budget/R — what `size x max_loss <= budget` is checked against. */ const BUDGET_USD = 2_000; interface DryRunResult { readonly ticket: OrderTicket; /** The guarded face. `preflight` has already run; `submit` is the ONLY thing that would transmit, * and the entrypoint calls it ONLY under an explicit human `KESTREL_IBKR_TRANSMIT=1`. */ readonly broker: ReturnType<typeof ibkrBroker>; readonly intent: OrderIntent; readonly fair: number; readonly fairReceipt: string; readonly spot: number; readonly spotReceipt: string; readonly optionQuote: TwoSidedQuote; readonly underlierQuote: TwoSidedQuote; readonly intrinsicUsd: number; readonly expiry: string; readonly tauYears: number; readonly surfaceLegs: number; } /** The gateway's own last-trading-day → the epoch-ms of that session's 16:00 ET close. `tau` is * computed against the VENUE's clock (its `currentTime` heartbeat), never this machine's. */ function closeTsOf(expiry: string): number { const y = Number(expiry.slice(0, 4)); const m = Number(expiry.slice(4, 6)); const d = Number(expiry.slice(6, 8)); // 16:00 ET == 20:00 UTC (EDT) — the half-hour of DST error this can carry is far below the // precision `tau` needs for a same-week option, and it is EXPLICIT rather than hidden. return Date.UTC(y, m - 1, d, 20, 0, 0); } /** Floor-snap a BUY to the tick — a BUY never bids ABOVE fair (engine/pricing.ts's own direction). */ function floorSnap(px: number, tick: number): number { return Math.floor(px / tick + 1e-9) * tick; } /** * Resolve → quote → price → clamp → PRINT. Transmits NOTHING unless `transmit` is `true`, and the * ONLY caller that can pass `true` is the entrypoint below, under an explicit human * `KESTREL_IBKR_TRANSMIT=1`. `preflight` (never `submit`) is what the dry path calls. */ async function dryRun(transmit: boolean): Promise<DryRunResult> { const cfg = resolveIbkrConfig({}); if (cfg.mode !== "paper") { throw new Error(`the dry run is PAPER-ONLY (got mode=${cfg.mode}) — fail-closed`); } const transport = new IbkrTransport(cfg, { log: (l) => console.log(` [transport] ${l}`) }); console.log(`\nconnecting: ${describeIbkrConfig(cfg)}`); const status = await transport.connect(); console.log(` connected. account=${status.account} serverTime=${new Date(status.serverTime ?? 0).toISOString()}`); try { const cdeps = { client: contractClientOf(transport), log: (l: string) => console.log(` [contract] ${l}`) }; const quotes = reqMktDataQuotes(transport); // The VENUE's clock, not this machine's — the same discipline the transport's heartbeat holds to. const nowTs = status.serverTime ?? Date.now(); // ── 1. The underlier: SPY equity → its conId, and its own two-sided quote → @fair(spot). const spotIntent = intentOf({ ref: "dry-spot", side: "buy", qty: 1, px: 0 }); const equity = (await resolveContract(spotIntent, cdeps)) as IbkrEquityContract; console.log(` SPY equity: conId=${equity.conId} ${equity.exchange}/${equity.primaryExchange ?? "?"}`); const underlierQuote = await quotes.quote(ibEquityQuery(equity)); const spotFair = executionFairSpot({ bid: underlierQuote.bid, ask: underlierQuote.ask, asof: nowTs }); if (spotFair === null) { throw new Error( `the underlier's book is one-sided/dark/crossed (bid=${underlierQuote.bid} ask=${underlierQuote.ask}) — @fair(spot) is UNRESOLVABLE and a mid is NEVER substituted. Fail-closed.`, ); } const spot = spotFair.value; const spotReceipt = `${spotFair.receipt.model}(bid=${spotFair.receipt.bid},ask=${spotFair.receipt.ask})`; console.log(` @fair(spot) = ${spot.toFixed(4)} [${spotReceipt}]`); // ── 2. The chain: pick a NEAR-DATED expiry from what the venue actually lists. const chain = await resolveOptionChain({ symbol: "SPY", underlyingConId: equity.conId, tradingClass: "SPY" }, cdeps); const today = ymd(nowTs); const expiry = chain.expirations.find((e) => e >= today); if (expiry === undefined) { throw new Error(`the venue lists no expiry on/after ${today} — an expiry is NEVER invented. Fail-closed.`); } console.log(` chain: ${chain.expirations.length} expiries, ${chain.strikes.length} strikes (union). near-dated = ${expiry}`); // ── 3. The strike: the ATM one the venue LISTS for THAT expiry (the union grid would invent one). const listed = await listOptionStrikes({ symbol: "SPY", expiry, right: "C", tradingClass: "SPY" }, cdeps); const atm = nearest(listed, spot); console.log(` listed strikes for ${expiry}C: ${listed.length}. ATM = ${atm}`); // ── 4. The contract: the gateway's OWN definition (conId, OCC localSymbol, MULTIPLIER, expiry). const legIntent = intentOf({ ref: "dry-1", side: "buy", qty: 1, px: 0, strike: atm, right: "C" }); const option = (await resolveContract(legIntent, { ...cdeps, expiry, tradingClass: "SPY" })) as IbkrOptionContract; console.log(` option: ${option.localSymbol} conId=${option.conId} x${option.multiplier}`); // ── 5. The surface: an ATM window of LISTED strikes, quoted. `@fair` needs liquid neighbours — // one leg cannot imply its own vol. const window = atmWindow(listed, atm, SURFACE_HALF_WIDTH); const tauYears = tauYearsToClose(nowTs, closeTsOf(expiry)); const legs: OptionQuote[] = []; let staleLegs = 0; for (const strike of window) { for (const right of ["C", "P"] as const) { const q = await quotes.quote(ibOptionQuery(option, strike, right)); // ONE-SIDED / DARK legs are DROPPED, never mid-filled — a fabricated level would poison the // surface, and the whole point of the receipt is that it cannot be. if (q.bid === null || q.ask === null) continue; // …and so are DELAYED/FROZEN ones. The surface PRICES the order; every input to it is a price // input, and a price input is never stale (kestrel-7o2.8). A stale leg is not a cheap leg. if (q.freshness !== "live") { staleLegs += 1; continue; } legs.push({ strike, right, bid: q.bid, ask: q.ask, bsz: null, asz: null, ...(q.last === null ? {} : { last: q.last }) }); } } console.log( ` surface: ${legs.length} LIVE two-sided legs across ${window.length} strikes (tau=${tauYears.toFixed(6)}y)` + (staleLegs > 0 ? ` [${staleLegs} leg(s) DROPPED as delayed/frozen — never a price input]` : ""), ); const optionQuote = await quotes.quote(ibOptionQuery(option, atm, "C")); // ── 5b. THE PRICE-ANCHOR GATE (kestrel-7o2.8, blocker 4a). Everything that PRICES an order must // come from LIVE data. The venue SUBSTITUTES delayed/frozen data rather than refusing, so the // substitution is checked HERE, before a single number reaches `@fair`. Delayed/frozen input // is a HEALTH SIGNAL — it may be printed, it may never anchor — so the anchor is UNRESOLVABLE // and the run fails closed rather than producing an authorizable ticket off stale prices. const staleInputs = [ ["underlier", underlierQuote.freshness] as const, [`${atm}C`, optionQuote.freshness] as const, ].filter(([, f]) => f !== "live"); if (staleInputs.length > 0) { throw new Error( `the PRICE ANCHOR requires LIVE market data, and the venue served ${staleInputs .map(([what, f]) => `${what}=${f.toUpperCase()}`) .join(", ")} — delayed/frozen data is a HEALTH SIGNAL, never a price anchor. @fair is UNRESOLVABLE. Fail-closed; NOTHING transmitted.`, ); } // ── 6. @fair — Black-76 at the interpolated strike IV, floored at intrinsic, WITH ITS RECEIPT. const fair = executionFair({ underlyingSpot: spot, strike: atm, right: "C", tauYears, liquidQuotes: legs, asof: nowTs, }); if (fair === null) { throw new Error( `@fair is UNRESOLVABLE (no usable underlier, or ZERO liquid strikes backed the surface) — and a mid is NEVER a price anchor, so there is nothing to fall back to here. Fail-closed.`, ); } const fairReceipt = `${fair.receipt.model}(nLiquid=${fair.receipt.nLiquid},ivAtStrike=${fair.receipt.ivAtStrike.toFixed(4)},asof=${fair.receipt.asof ?? "-"})`; console.log(` @fair(${atm}C) = ${fair.value.toFixed(4)} [${fairReceipt}]`); // ── 7. The limit price: @fair, floor-snapped. A BUY NEVER bids above fair. const limitPx = floorSnap(fair.value, TICK_SIZE); const intrinsicUsd = intrinsic(spot, atm, "C"); // ── 8. The L0 clamp — via preflight(), which runs ALL FOUR WALLS and TRANSMITS NOTHING. const orderClient = orderClientOf(transport); const broker = ibkrBroker(cfg, { client: orderClient, contracts: contractBook([option, equity]), limits: LIMITS, intrinsicOf: (leg) => (leg.strike === undefined || leg.right === undefined ? undefined : intrinsic(spot, leg.strike, leg.right)), // THE PRICE ANCHOR (wall 6). The book we OBSERVED, beside the `@fair` we derived from it — the // broker refuses outright if fair falls outside it, or if the book was not live. A leg we never // quoted has NO anchor, so it cannot be ordered at all: fail-closed, never an assumed book. priceAnchorOf: (leg) => leg.strike === undefined || leg.right === undefined ? priceAnchorFrom(underlierQuote, spot) : leg.strike === atm && leg.right === "C" ? priceAnchorFrom(optionQuote, fair.value) : undefined, expectedPositions: () => ({}), // The id sequence is the GATEWAY's (`nextValidId`), never ours to invent. It is only ever drawn // on the transmit path — the dry run never reaches it, and if the gateway never issued one, a // transmit fails closed rather than guessing an id. nextOrderId: mintOrderId(orderClient), drain: () => {}, }); const intent: OrderIntent = intentOf({ ref: "dry-1", side: "buy", qty: 1, px: limitPx, strike: atm, right: "C", sourceAnnotation: `fair=${fairReceipt}`, }); const ticket = broker.preflight(intent); // ← the guard chain. NOTHING is transmitted. const result: DryRunResult = { ticket, broker, intent, fair: fair.value, fairReceipt, spot, spotReceipt, optionQuote, underlierQuote, intrinsicUsd, expiry, tauYears, surfaceLegs: legs.length, }; printTicket(result); // ── THE WALL. `submit` is the ONE call that reaches `placeOrder`, and it is reached ONLY here, // ONLY when a human set KESTREL_IBKR_TRANSMIT=1. The default path below simply does not call it. if (transmit) { console.log("KESTREL_IBKR_TRANSMIT=1 — a human authorized the ticket above. Transmitting...\n"); broker.submit(intent); await settle(3_000); // let the venue's own ORDER callbacks land before we drop the socket 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}`); } } else { console.log("KESTREL_IBKR_TRANSMIT is NOT set. placeOrder was NEVER called. This is the default and correct outcome.\n"); } return result; } finally { transport.disconnect(); } } const settle = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); // ───────────────────────────────────────────────────────────────────────────── // Printing. // ───────────────────────────────────────────────────────────────────────────── function printTicket(r: DryRunResult): void { const t = r.ticket; const c = t.contract as IbkrOptionContract; const w = (label: string, value: string): string => ` ${label.padEnd(22)} ${value}`; console.log(` ╔══════════════════════════════════════════════════════════════════════════════╗ ║ ORDER TICKET — kestrel-7o2.8 · IBKR PAPER ║ ║ *** DRY RUN — NO ORDER TRANSMITTED *** ║ ╚══════════════════════════════════════════════════════════════════════════════╝ CONTRACT (the gateway's OWN definition — nothing hand-rolled) ${w("localSymbol", c.localSymbol)} ${w("conId", String(c.conId))} ${w("secType / kind", `${c.secType} (${c.kind})`)} ${w("strike / right", `${c.strike} ${c.right}`)} ${w("expiry", `${c.expiry} (tau = ${r.tauYears.toFixed(6)}y)`)} ${w("multiplier", `x${c.multiplier} (the VENUE's, never an assumed 100)`)} ${w("exchange / currency", `${c.exchange} / ${c.currency}`)} ${w("tradingClass", c.tradingClass ?? "(none)")} ORDER ${w("side / qty", `${t.side.toUpperCase()} ${t.qty}`)} ${w("type", "LIMIT (LMT, DAY)")} ${w("limit price", `$${t.limitPx.toFixed(2)}`)} PRICE — @fair is the ANCHOR (the mid is NEVER a price and NEVER a value) ${w("@fair", `$${r.fair.toFixed(4)}`)} ${w(" receipt", r.fairReceipt)} ${w(" surface", `${r.surfaceLegs} two-sided legs`)} ${w("limit = floor-snap(fair)", `$${t.limitPx.toFixed(2)} (a BUY never bids ABOVE fair)`)} ${w("@fair(spot)", `$${r.spot.toFixed(4)}`)} ${w(" receipt", r.spotReceipt)} ${w("intrinsic", `$${r.intrinsicUsd.toFixed(4)} (the SELL floor; not binding on a BUY)`)} THE OBSERVED BOOK — a health signal that also BOUNDS the anchor (wall 6, kestrel-7o2.8) ${w("option bid / ask", `${fmt(r.optionQuote.bid)} / ${fmt(r.optionQuote.ask)} [${r.optionQuote.freshness.toUpperCase()}]`)} ${w("underlier bid / ask", `${fmt(r.underlierQuote.bid)} / ${fmt(r.underlierQuote.ask)} [${r.underlierQuote.freshness.toUpperCase()}]`)} ${w("PRICE ANCHOR", `@fair ${t.anchor.fair.toFixed(4)} INSIDE the LIVE book ${t.anchor.bid} / ${t.anchor.ask} ✓`)} ${w(" (a stale book, or a fair outside it, produces NO ticket at all — the run fails closed)", "")} RISK — DEFINED-RISK LONG (never-naked holds trivially: max loss = the premium) ${w("notional", `$${t.notionalUsd.toFixed(2)} = ${t.limitPx.toFixed(2)} x ${t.qty} x ${t.multiplier}`)} ${w("MAX LOSS", `$${t.maxLossUsd.toFixed(2)}`)} ${w("budget / R", `$${BUDGET_USD.toFixed(2)}`)} ${w("size x max_loss <= R", `$${t.maxLossUsd.toFixed(2)} <= $${BUDGET_USD.toFixed(2)} ${t.maxLossUsd <= BUDGET_USD ? "OK" : "VIOLATED"}`)} ${w("position before/after", `${t.heldBefore} -> ${t.heldAfter} (never negative — never-naked)`)} L0 CLAMP (7o2.9 — the pre-transmit ceilings, checked BEFORE the wire) ${w("maxOrderQty", `${t.qty} <= ${LIMITS.maxOrderQty}`)} ${w("maxPositionQty", `${Math.abs(t.heldAfter)} <= ${LIMITS.maxPositionQty}`)} ${w("maxNotionalUsd", `${t.notionalUsd.toFixed(2)} <= ${LIMITS.maxNotionalUsd}`)} ${w("VERDICT", t.clamp.toUpperCase())} ╔══════════════════════════════════════════════════════════════════════════════╗ ║ DRY RUN — NO ORDER TRANSMITTED. placeOrder was NEVER called. ║ ║ To transmit, a human sets KESTREL_IBKR_TRANSMIT=1 and re-runs. ║ ╚══════════════════════════════════════════════════════════════════════════════╝ `); } const fmt = (v: number | null): string => (v === null ? "DARK" : `$${v.toFixed(2)}`); // ───────────────────────────────────────────────────────────────────────────── // Helpers. // ───────────────────────────────────────────────────────────────────────────── /** A SPY intent skeleton. The dry run never authors a strategy — this is a bare, publication-safe leg. */ function intentOf(over: Partial<OrderIntent> & Pick<OrderIntent, "ref" | "side" | "qty" | "px">): OrderIntent { return { plan: "dry-run", plan_instance: "dry-run#1", role: "entry", instrument: "SPY", sourceAnnotation: "dry-run", ...over, } as OrderIntent; } /** * The order-id minter. The sequence is the GATEWAY's (`nextValidId`) — never ours to invent, and never * an RNG. It is drawn ONLY on the transmit path; the dry run never reaches it. If the gateway never * issued a seed, a transmit FAILS CLOSED rather than guessing an id that might collide with a live one. * * EXPORTED (kestrel-7o2.12) so the paper session driver's venue composition draws its order ids from * this ONE rule rather than a second copy of it — a rule that exists twice is a rule that diverges. */ export 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) => { // IB re-issues this on connect and after `reqIds`; only ever move FORWARD (an id is never reused). if (next === undefined || id > next) next = id; }) as never); client.reqIds(1); return () => { if (next === undefined) { throw new Error( "the IB Gateway has issued no nextValidId — refusing to invent an order id (it could collide with a live order). Fail-closed.", ); } const id = next; next += 1; return id; }; } function ibEquityQuery(e: IbkrEquityContract): Contract { return { conId: e.conId, symbol: e.symbol, secType: SecType.STK, exchange: e.exchange, currency: e.currency }; } function ibOptionQuery(o: IbkrOptionContract, strike: number, right: "C" | "P"): Contract { return { symbol: o.symbol, secType: SecType.OPT, exchange: o.exchange, currency: o.currency, lastTradeDateOrContractMonth: o.expiry, strike, right: right === "C" ? OptionType.Call : OptionType.Put, ...(o.tradingClass === undefined ? {} : { tradingClass: o.tradingClass }), multiplier: o.multiplier, }; } const ymd = (ts: number): string => new Date(ts).toISOString().slice(0, 10).replace(/-/g, ""); function nearest(xs: readonly number[], target: number): number { let best = xs[0] as number; for (const x of xs) if (Math.abs(x - target) < Math.abs(best - target)) best = x; return best; } function atmWindow(listed: readonly number[], atm: number, half: number): readonly number[] { const i = listed.indexOf(atm); if (i < 0) return [atm]; return listed.slice(Math.max(0, i - half), Math.min(listed.length, i + half + 1)); } // ───────────────────────────────────────────────────────────────────────────── // Entrypoint — env-gated, dry by default. // ───────────────────────────────────────────────────────────────────────────── // `import.meta.main` first: this module EXPORTS the quote source, so a unit test (or the equity // script) importing it must never dial a gateway or print a banner — only running it as a script does. if (import.meta.main) { loadEnvFallback(); // KESTREL_IBKR_* identifiers from the shared secrets home (OSS-ADR-0054) if (process.env.KESTREL_IBKR_PAPER_E2E === "1") { // THE WALL. Transmission is a separate, deliberate, HUMAN act — never a default, and never a thing // a script decides for itself. DRY is the default: absent this env var, `submit` is never called. const transmit = process.env.KESTREL_IBKR_TRANSMIT === "1"; if (!transmit) console.log("\n*** DRY RUN — no order will be transmitted (KESTREL_IBKR_TRANSMIT is not set) ***"); dryRun(transmit).catch((e: unknown) => { console.error(`\nDRY RUN FAILED (nothing transmitted): ${e instanceof Error ? e.message : String(e)}\n`); process.exitCode = 1; }); } else { console.log("kestrel-7o2.8 dry-run ticket: set KESTREL_IBKR_PAPER_E2E=1 to run it against a live PAPER gateway."); } }