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.

339 lines (314 loc) 17.3 kB
/** * [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 };