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