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.
534 lines (526 loc) • 31.3 kB
JavaScript
/**
* # 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 { IbkrTransport } from "./transport.js";
import { resolveIbkrConfig, describeIbkrConfig } from "./config.js";
import { loadEnvFallback } from "../../../cli/credentials.js";
import { resolveContract, resolveOptionChain, listOptionStrikes, contractClientOf } from "./contract.js";
import { ibkrBroker, contractBook, orderClientOf } from "./broker.js";
import { executionFair, executionFairSpot, intrinsic, tauYearsToClose } from "../../../fair/index.js";
/**
* 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) {
switch (marketDataType) {
case 1:
return "live";
case 2:
return "frozen";
case 3:
return "delayed";
case 4:
return "delayed-frozen";
default:
return "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, timeoutMs = 6_000) {
const client = transport.client();
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) {
const id = ++reqId;
return new Promise((resolve) => {
let bid = null;
let ask = null;
let last = null;
// Until the venue tells us otherwise, its provenance is UNKNOWN — never assumed live.
let freshness = "unknown";
let done = false;
const finish = () => {
if (done)
return;
done = true;
clearTimeout(deadline);
client.removeListener(EventName.tickPrice, onTick);
client.removeListener(EventName.marketDataType, onType);
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, marketDataType) => {
if (rid !== id)
return;
freshness = freshnessOf(marketDataType);
};
const onTick = (rid, field, value) => {
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);
client.on(EventName.marketDataType, onType);
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, fair) {
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 = { 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;
/** 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) {
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, tick) {
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) {
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) => 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));
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" }));
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 = [];
let staleLegs = 0;
for (const strike of window) {
for (const right of ["C", "P"]) {
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],
[`${atm}C`, optionQuote.freshness],
].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 = 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 = {
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) => new Promise((r) => setTimeout(r, ms));
// ─────────────────────────────────────────────────────────────────────────────
// Printing.
// ─────────────────────────────────────────────────────────────────────────────
function printTicket(r) {
const t = r.ticket;
const c = t.contract;
const w = (label, value) => ` ${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) => (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) {
return {
plan: "dry-run",
plan_instance: "dry-run#1",
role: "entry",
instrument: "SPY",
sourceAnnotation: "dry-run",
...over,
};
}
/**
* 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) {
let next;
client.on(EventName.nextValidId, ((id) => {
// 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;
}));
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) {
return { conId: e.conId, symbol: e.symbol, secType: SecType.STK, exchange: e.exchange, currency: e.currency };
}
function ibOptionQuery(o, strike, right) {
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) => new Date(ts).toISOString().slice(0, 10).replace(/-/g, "");
function nearest(xs, target) {
let best = xs[0];
for (const x of xs)
if (Math.abs(x - target) < Math.abs(best - target))
best = x;
return best;
}
function atmWindow(listed, atm, half) {
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) => {
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.");
}
}