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.
339 lines (314 loc) • 17.3 kB
text/typescript
/**
* [NEEDS-LIVE-GATEWAY] LIVE PERCEPT RENDERING for the IBKR feed face (kestrel-7o2.7).
*
* The owner acceptance, run against a RUNNING IB **PAPER** gateway. It is NOT a unit test and is
* NEVER exercised by `bun test` (it lives under `src/`, not `*.test.ts`, and only runs when invoked
* directly with the env gate set), so CI stays green with no gateway.
*
* It **PLACES NO ORDERS.** It is market data only — `reqMktData` / `cancelMktData` and nothing else.
* The narrow client surface the feed speaks through declares no order method at all.
*
* What it proves, end to end, on a LIVE tape:
* 1. the shared TWS transport (7o2.5/.16) connects and hands out ONE client — never a second socket;
* 2. the 7o2.6 contract layer resolves the venue's OWN definitions (equity + a listed chain slice);
* 3. the feed face (7o2.7) turns IB's tick stream into the EXISTING BusEvent union;
* 4. those BusEvents drive the EXISTING canonical state and materialize a Frame;
* 5. the Frame RENDERS — the agent-facing, token-efficient percept, printed below;
* 6. `@fair` prints WITH its receipt (model / fit quality / freshness);
* 7. the staleness watermark prints — including the frozen-re-print fingerprint.
*
* Nothing under `src/session/**` or `src/frame/**` is edited: the renderer and the canonical state
* are IMPORTED and driven.
*
* Run (paper only):
* KESTREL_IBKR_FEED_SMOKE=1 KESTREL_IBKR_MODE=paper KESTREL_IBKR_PORT=4002 \
* KESTREL_IBKR_CLIENT_ID=13 bun run src/adapters/broker/ibkr/feed-smoke.ts
*/
import { describeIbkrConfig, resolveIbkrConfig } from "./config.ts";
import { loadEnvFallback } from "../../../cli/credentials.ts";
import { IbkrTransport } from "./transport.ts";
import { contractClientOf } from "./contract.ts";
import type { IbkrEquityContract } from "./contract.ts";
import { feedClientOf, ibkrFeed, resolveFeedContracts, stalenessGatedProvider, IbkrFeedError } from "./feed.ts";
import type { FeedWatermark } from "./feed.ts";
import { DEFAULT_SURFACE_HALF_WIDTH } from "./surface-window.ts";
import { CanonicalSeriesProvider, CanonicalState, FakeOrgFacts, isUnknown } from "../../../series/index.ts";
import { projectAuthorFrame, machineSummary } from "../../../session/index.ts";
import type { DaySnapshot } from "../../../session/index.ts";
// The ONE canonical renderer (kestrel-wa0j.2) + the shared AuthorFrame → frame/types mappers — the
// live screen is the SAME `renderBriefing` Rendering an in-process agent perceives, never a fork.
import { kernelOf, marketPaneOf } from "../../../session/simulate.ts";
import { renderBriefing } from "../../../frame/render.ts";
import type { BriefingInput } from "../../../frame/types.ts";
import type { BusEvent, BookEvent, SpotEvent } from "../../../bus/index.ts";
/** A publication-safe generic ticker (ARCHITECTURE §7 — nothing here reveals an application). */
const SYMBOL = process.env.KESTREL_IBKR_FEED_SYMBOL ?? "SPY";
/** How many pump cycles to watch the live tape for. */
const CYCLES = Number(process.env.KESTREL_IBKR_FEED_CYCLES ?? "12");
const PUMP_MS = 1_000;
/** How many cycles to watch the UNDERLIER alone for, before a chain slice is chosen — the money must
* be OBSERVED before a window can be centred on it (kestrel-7o2.19). */
const SPOT_PROBE_CYCLES = 10;
const SPOT_PROBE_MS = 500;
const log = (line: string): void => {
console.log(`[feed] ${line}`);
};
/** Fail closed, loudly and typed — never a smoke that carries on with a percept it cannot vouch for. */
function feedFailClosed(reason: string): never {
throw new IbkrFeedError(reason);
}
/** The chain expiry to watch: the caller's, or the venue's own nearest listed one. NEVER guessed off
* a clock — it is READ from the gateway's chain (7o2.6), which is the only honest source. */
async function pickExpiry(transport: IbkrTransport, conId: number): Promise<string | undefined> {
const override = process.env.KESTREL_IBKR_FEED_EXPIRY;
if (override !== undefined && override.trim().length > 0) return override.trim();
const { resolveOptionChain } = await import("./contract.ts");
const chain = await resolveOptionChain(
{ symbol: SYMBOL, underlyingConId: conId, tradingClass: SYMBOL },
{ client: contractClientOf(transport), account: transport.config.account, log },
);
const soonest = chain.expirations[0];
log(`chain lists ${chain.expirations.length} expirations, ${chain.strikes.length} strikes — taking the soonest LISTED: ${soonest ?? "(none)"}`);
return soonest;
}
/**
* OBSERVE THE MONEY before slicing the chain (kestrel-7o2.19). A strike window is centred on the
* OBSERVED spot, so the spot must exist first: an underlier-only feed is opened on the SAME shared
* session, pumped until the venue PRINTS a trade, and closed. No mid is ever synthesized into a spot —
* if nothing trades, nothing is known, and the smoke fails closed rather than slicing a chain around a
* price nobody made. (`KESTREL_IBKR_FEED_SPOT` lets an operator pin the centre explicitly — an
* INJECTED observation, which is exactly what determinism at the edge means.)
*/
async function observeSpot(
transport: IbkrTransport,
underlier: IbkrEquityContract,
sessionDate: string,
): Promise<number | undefined> {
const pinned = Number(process.env.KESTREL_IBKR_FEED_SPOT ?? "");
if (Number.isFinite(pinned) && pinned > 0) {
log(`spot PINNED by the operator: ${pinned} (the window centres on this)`);
return pinned;
}
const probe = ibkrFeed(
{
instrument: SYMBOL,
sessionDate,
mode: "paper",
phase: "regular",
...(process.env.KESTREL_IBKR_FEED_DELAYED === "1" ? { marketDataType: 3 } : {}),
...(transport.config.account === undefined ? {} : { account: transport.config.account }),
},
{ client: feedClientOf(transport), underlier, now: Date.now, log },
);
await probe.open();
let spot: number | undefined;
for (let i = 0; i < SPOT_PROBE_CYCLES && spot === undefined; i++) {
await new Promise((r) => setTimeout(r, SPOT_PROBE_MS));
transport.pulse();
probe.pump();
for (const ev of probe.drain()) {
if (ev.stream === "TICK" && ev.type === "SPOT") spot = ev.px;
}
}
probe.close();
log(
spot === undefined
? `the underlier PRINTED NO TRADE in ${SPOT_PROBE_CYCLES * SPOT_PROBE_MS}ms — there is no money to centre a strike window on (a mid is never a spot; fail-closed)`
: `observed spot: ${spot} (the OBSERVED last trade — the window centres on this, never on the grid's middle)`,
);
return spot;
}
/** Build the DaySnapshot the EXISTING Frame renderer consumes — from the feed's own BusEvents folded
* through the EXISTING canonical state. No session/frame file is edited; both are driven. */
function snapshotOf(state: CanonicalState, events: readonly BusEvent[], nowTs: number): DaySnapshot {
const spots = events.filter((e): e is SpotEvent => e.stream === "TICK" && e.type === "SPOT");
const lastBook = [...events].reverse().find((e): e is BookEvent => e.stream === "TICK" && e.type === "BOOK");
const num = (v: number | symbol): number | null => (isUnknown(v) ? null : (v as number));
return {
kind: "keyframe",
n: 0,
label: "LIVE",
nowTs,
instruments: [SYMBOL],
phase: "regular",
spot: num(state.spot),
priorClose: num(state.priorClose),
hod: num(state.hod),
lod: num(state.lod),
vwap: num(state.vwap),
// kestrel-wa0j.58: carry the frozen opening range through the live-feed snapshot too, consistent with
// the sim driver — the canonical state computes it, the projection must not drop it.
orHigh: num(state.orHigh),
orLow: num(state.orLow),
chain:
lastBook === undefined
? null
: {
underlierPx: lastBook.underlier_px,
...(lastBook.expiry === undefined ? {} : { expiry: lastBook.expiry }),
legs: lastBook.legs,
},
tape: spots.map((s) => ({ ts: s.ts, px: s.px })),
plans: [],
resting: [],
fills: [],
committedUsd: 0,
rUsd: 1_000,
};
}
function printWatermark(wm: FeedWatermark): void {
console.log(`\n── STALENESS WATERMARK ─────────────────────────────────────────────`);
console.log(`health: ${wm.health}${wm.reason === null ? "" : ` reason: ${wm.reason}`}`);
console.log(`staleAfterMs: ${wm.staleAfterMs} seq: ${wm.seq} underlierStale: ${wm.underlierStale}`);
for (const s of wm.series) {
console.log(
` ${s.key.padEnd(12)} kind=${s.kind.padEnd(6)} asOfSeq=${String(s.asOfSeq).padStart(4)} ageMs=${String(s.ageMs).padStart(6)} reprints=${String(s.reprints).padStart(3)} stale=${s.stale} dark=${s.dark} failed=${s.failed}`,
);
}
}
async function runFeedSmoke(): Promise<void> {
const config = resolveIbkrConfig();
log(`resolving transport → ${describeIbkrConfig(config)} (MARKET DATA ONLY — PLACES NO ORDERS)`);
if (config.mode !== "paper") {
throw new Error("feed smoke refuses any mode but `paper` (fail-closed) — set KESTREL_IBKR_MODE=paper");
}
const sessionDate = new Date().toISOString().slice(0, 10);
const transport = new IbkrTransport(config, { heartbeatIntervalMs: 5_000, heartbeatStalenessMs: 20_000, log });
const status = await transport.connect();
log(`connected: account=${status.account} (the ONE shared session — the feed multiplexes it)`);
// (2) The 7o2.6 contract layer — the gateway's OWN definitions, never hand-rolled.
const contractDeps = { client: contractClientOf(transport), account: config.account, log };
const { underlier: resolvedUnderlier } = await resolveFeedContracts({ symbol: SYMBOL }, contractDeps);
// This script drives the EQUITY path (SYMBOL is a generic ETF and no `underlierSecType` is stated,
// so `resolveUnderlier` asked for `STK`). The narrowing is a fail-closed assertion of that, not a
// cast: if the union ever widens under it, this refuses loudly rather than reading fields off the
// wrong asset class.
if (resolvedUnderlier.kind !== "equity") {
feedFailClosed(`${SYMBOL} resolved to a ${resolvedUnderlier.kind} contract, but this smoke script drives the equity/ETF path — refusing to probe an identity it did not ask for`);
}
const equityOnly = resolvedUnderlier;
log(`resolved equity: conId=${equityOnly.conId} ${equityOnly.localSymbol}@${equityOnly.exchange}/${equityOnly.primaryExchange ?? "-"}`);
const expiry = await pickExpiry(transport, equityOnly.conId);
// THE MONEY FIRST (kestrel-7o2.19). The window is centred on the OBSERVED spot — never on the middle
// of whatever grid the venue happened to answer with, which is how a percept ends up showing five
// deep-ITM calls and five 1-cent puts while the money sits eleven points away.
const spot = expiry === undefined ? undefined : await observeSpot(transport, equityOnly, sessionDate);
if (expiry !== undefined && spot === undefined) {
feedFailClosed(
`no observed spot for ${SYMBOL} — a chain slice with no money at its centre is the kestrel-7o2.19 defect. Pin one with KESTREL_IBKR_FEED_SPOT, or run while the tape is printing.`,
);
}
const { underlier, legs } = await resolveFeedContracts(
{
symbol: SYMBOL,
...(expiry === undefined ? {} : { expiry }),
...(spot === undefined ? {} : { spot }),
halfWidth: DEFAULT_SURFACE_HALF_WIDTH, // 4 each side ⇒ 9 strikes ⇒ 18 two-sided legs (the order path's window)
tradingClass: SYMBOL,
},
contractDeps,
);
log(`resolved ${legs.length} option legs @ ${expiry ?? "(equity-only)"}: ${legs.map((l) => `${l.strike}${l.right}`).join(" ")}`);
if (spot !== undefined && legs.length > 0) {
const strikes = [...new Set(legs.map((l) => l.strike))].sort((a, b) => a - b);
const below = strikes.filter((s) => s <= spot).length;
const above = strikes.filter((s) => s >= spot).length;
log(
`the window BRACKETS the money: ${below} listed strike(s) at/below ${spot}, ${above} at/above — ATM-centred, off the venue's LISTED grid for ${expiry ?? "-"} (never the union)`,
);
}
// (3) The FEED FACE over the SAME shared client — never a second market-data connection.
const feed = ibkrFeed(
{
instrument: SYMBOL,
sessionDate,
mode: "paper",
...(expiry === undefined ? {} : { expiry }),
tauYears: 1 / 365, // INJECTED τ — the smoke's own input, never read off the runtime path
staleAfterMs: 8_000,
phase: "regular",
...(process.env.KESTREL_IBKR_FEED_DELAYED === "1" ? { marketDataType: 3 } : {}),
...(config.account === undefined ? {} : { account: config.account }),
},
{ client: feedClientOf(transport), underlier, legs, now: Date.now, log },
);
const opened = await feed.open();
log(`subscribed: 1 underlier + ${opened.legs.length} legs; ${opened.failed.length} refused`);
for (const f of opened.failed) log(`REFUSED (surfaced to the requester, session unharmed): ${f.message}`);
// (4) Drive the EXISTING canonical state with the feed's OWN BusEvents (the real ingress).
const state = new CanonicalState({ instrument: SYMBOL });
const all: BusEvent[] = [];
for (let i = 0; i < CYCLES; i++) {
await new Promise((r) => setTimeout(r, PUMP_MS));
transport.pulse();
feed.pump();
for (const ev of feed.drain()) {
state.applyEvent(ev);
all.push(ev);
}
}
const counts = new Map<string, number>();
for (const e of all) {
const k = e.stream === "TICK" ? `TICK/${e.type}` : e.stream;
counts.set(k, (counts.get(k) ?? 0) + 1);
}
log(`produced ${all.length} BusEvents: ${[...counts].map(([k, v]) => `${k}=${v}`).join(" ")}`);
// (5) LIVE PERCEPT RENDERING — the agent-facing Frame, materialized and rendered through THE
// canonical `renderBriefing` (one renderer, kestrel-wa0j.2) over the same date-blind projection.
const snap = snapshotOf(state, all, Date.now());
const openTs = all[0]?.ts ?? Date.now();
const frame = projectAuthorFrame(snap, openTs, sessionDate);
const briefing: BriefingInput = {
timeToCloseMin: null, // a live smoke pins no session horizon — rendered `T-— to close`, never a guess
phase: "regular",
instruments: [{ symbol: SYMBOL, assetClass: "equity" }],
market: marketPaneOf(frame, SYMBOL),
kernel: kernelOf(frame, SYMBOL),
};
console.log(`\n══ LIVE PERCEPT — RENDERED FRAME ═══════════════════════════════════`);
console.log(renderBriefing(briefing));
console.log(`── machine summary (the token-efficient agent read) ─────────────────`);
console.log(machineSummary(frame));
// (6) @fair WITH its receipt — INCLUDING the ATM-coverage receipt (kestrel-7o2.19): a surface with
// no liquid strike near the money does not get to vouch for an at-the-money valuation.
console.log(`── @fair (off IB's two-sided quote, with its RECEIPT) ───────────────`);
for (const leg of opened.legs.slice(0, 6)) {
for (const side of ["buy", "sell"] as const) {
const f = feed.fair({ strike: leg.strike, right: leg.right, side });
const receipt =
f.receipt === null
? "receipt=—"
: `receipt={model=${f.receipt.model} nLiquid=${f.receipt.nLiquid} iv=${f.receipt.ivAtStrike.toFixed(4)} asof=${f.receipt.asof ?? "—"}}`;
const value = f.value === null ? `fair=NULL fallback={px=${f.fallback?.px ?? "—"} "${f.fallback?.annotation ?? ""}"}` : `fair=${f.value.toFixed(4)}`;
const c = f.coverage;
const cov = `atm={covers=${c.supportsAtm} brackets=${c.bracketsSpot} nNear=${c.nNearMoney}/${c.nLiquid} d=${c.distanceUsd === null ? "—" : c.distanceUsd.toFixed(2)}}`;
console.log(
` ${String(leg.strike).padStart(6)}${leg.right} ${side.padEnd(4)} ${value.padEnd(52)} ${receipt} ${cov} health={${f.health.spreadRegime} spread=${f.health.spread ?? "—"}}`,
);
if (f.taint !== null) console.log(` TAINTED: ${f.taint}`);
}
}
// (7) The watermark — and the taint it puts on canonical state.
printWatermark(feed.watermark());
const gated = stalenessGatedProvider(new CanonicalSeriesProvider(state, new FakeOrgFacts()), feed, { log });
const spotRead = gated.resolve({ kind: "series", segments: [{ name: "spot" }] }, Date.now());
console.log(
`\ngated read of \`spot\`: ${isUnknown(spotRead) ? "UNKNOWN (the feed is DEGRADED — dependents tainted, plans cannot fire)" : String(spotRead)}`,
);
feed.close();
transport.disconnect();
console.log(`\n[feed] done — subscriptions closed, transport closed. NO ORDERS WERE PLACED.`);
}
if (import.meta.main) {
loadEnvFallback(); // KESTREL_IBKR_* identifiers from the shared secrets home (OSS-ADR-0054)
if (process.env.KESTREL_IBKR_FEED_SMOKE !== "1") {
console.log("[feed] skipped: set KESTREL_IBKR_FEED_SMOKE=1 (and run a paper IB Gateway) to exercise this. Places no orders.");
} else {
runFeedSmoke().catch((e: unknown) => {
const err = e as Error;
console.error(`[feed] FAILED (fail-closed): ${err.name}: ${err.message}`);
process.exitCode = 1;
});
}
}
export { runFeedSmoke };