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.

327 lines (323 loc) 13.2 kB
import { require_dist } from "./bin-ah4h5nq5.js"; import { IbkrConnectionError, classifyIbkrAccount, describeIbkrConfig, redactAccount, resolveIbkrConfig } from "./bin-ff04wpv3.js"; import { __toESM } from "./bin-wckvcay0.js"; // src/adapters/broker/ibkr/transport.ts var import_ib = __toESM(require_dist(), 1); var DEFAULT_HEARTBEAT_INTERVAL_MS = 5000; var DEFAULT_HEARTBEAT_STALENESS_MS = 15000; class IbkrTransport { config; #makeClient; #now; #scheduler; #heartbeatIntervalMs; #heartbeatStalenessMs; #log; #client; #phase = "idle"; #fullAccount; #reportedAccounts = []; #lastHeartbeat; #lastHeartbeatWall; #serverTime; #reason = null; #failure; #failureCode; #failureCause; #heartbeatTimer; #registered = []; constructor(config, deps = {}) { this.config = config; this.#makeClient = deps.makeClient ?? defaultMakeClient; this.#now = deps.now ?? Date.now; this.#scheduler = deps.scheduler ?? defaultScheduler; this.#heartbeatIntervalMs = deps.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; this.#heartbeatStalenessMs = deps.heartbeatStalenessMs ?? DEFAULT_HEARTBEAT_STALENESS_MS; this.#log = deps.log ?? (() => {}); this.#fullAccount = config.account; } static resolve(input = {}, deps = {}) { return new IbkrTransport(resolveIbkrConfig(input), deps); } status() { return { connected: this.#phase === "connected", degraded: this.#phase === "degraded", account: redactAccount(this.#fullAccount), lastHeartbeat: this.#lastHeartbeat, serverTime: this.#serverTime, reason: this.#reason }; } get connected() { return this.#phase === "connected"; } client() { this.assertReady(); return this.#client; } assertReady() { if (this.#phase === "connected" && this.#client !== undefined) return; if (this.#phase === "degraded") { throw new IbkrConnectionError(this.#failure ?? "disconnected", `IB Gateway transport is degraded: ${this.#reason ?? "(no reason)"} (fail-closed; STAND_DOWN)`, { code: this.#failureCode, cause: this.#failureCause }); } throw new IbkrConnectionError("not-connected", `IB Gateway transport is not connected (phase=${this.#phase}) — ${describeIbkrConfig(this.config)} (fail-closed)`); } connect(timeoutMs = 1e4) { if (this.config.mode === "live") { return Promise.reject(new IbkrConnectionError("mode-gate", "IB Gateway transport refuses live mode — this bead is transport-only and ships no live routing; live requires the human-signed arm envelope (kestrel-7o2.9/7o2.10). Fail-closed.")); } if (this.#phase === "connecting" || this.#phase === "connected") { return Promise.reject(new IbkrConnectionError("connect-failed", `IB Gateway transport already ${this.#phase} — refusing a second connection on the shared session (fail-closed)`)); } this.#phase = "connecting"; this.#reason = null; this.#failure = undefined; this.#failureCode = undefined; this.#failureCause = undefined; this.stopHeartbeat(); this.closeClient(); const client = this.#makeClient(this.config); this.#client = client; return new Promise((resolve, reject) => { let settled = false; let sawHandshakeId = false; let sawAccounts = false; let sawFirstBeat = false; const finish = (fn) => { if (settled) return; settled = true; this.#scheduler.clearInterval(deadline); fn(); }; const fail = (err) => { finish(() => { this.degrade(err.failure, err.message, { code: err.code, cause: err.cause }); reject(err); }); }; const drop = (failure, message, options = {}) => { if (settled) { this.degrade(failure, message, options); return; } fail(new IbkrConnectionError(failure, message, options)); }; const maybeReady = () => { if (sawHandshakeId && sawAccounts && sawFirstBeat && !settled) { const reported = this.#reportedAccounts; const allPaper = reported.length > 0 && reported.every((a) => classifyIbkrAccount(a) === "paper"); const anyLive = reported.some((a) => classifyIbkrAccount(a) === "live"); const refuse = (message) => { finish(() => { const err = new IbkrConnectionError("live-account", message); this.degrade(err.failure, err.message, {}); this.disconnect(); reject(err); }); }; if (!allPaper) { refuse(anyLive ? `IB Gateway handshake reported a LIVE, REAL-MONEY account (a U-prefixed id) among the accounts at ${describeIbkrConfig(this.config)} — Kestrel is NOT armed for live (live routing is owner-gated, kestrel-7o2.10), so this socket must not be used. EVERY account the gateway reports must be a PAPER account (DU/DF) for a paper session; a report that mixes paper and live accounts is refused just as loudly as an all-live one (a paper-first entry never vouches for the rest). Check the port (IB Gateway paper is 4002, TWS paper 7497) and which account(s) the gateway is logged into. Fail-closed; the socket has been closed.` : `IB Gateway handshake reported NO account, or one Kestrel CANNOT CLASSIFY as paper, at ${describeIbkrConfig(this.config)} — an absent or unclassifiable account is not evidence of a paper account, so it is refused exactly as loudly as a live one (fail-closed; never assume paper). EVERY reported account must be DU/DF-prefixed. The socket has been closed.`); return; } const pinned = this.config.account; if (pinned !== undefined && !reported.some((a) => a === pinned.trim())) { refuse(`IB Gateway handshake: the PINNED account does NOT match ANY account the gateway ITSELF reported at ${describeIbkrConfig(this.config)} — the pin is only a CLAIM about the endpoint, while the gateway's report is what the socket actually reached, and Kestrel never trusts the claim over the report (ADR-0034 §3). Both may be paper-shaped and this is STILL refused: a mismatch means the operator cannot vouch for which account this socket is on. Fail-closed; the socket has been closed.`); return; } finish(() => { this.#phase = "connected"; this.startHeartbeat(); this.#log(`connected: ${describeIbkrConfig(this.config)}`); resolve(this.status()); }); } }; this.register(client, import_ib.EventName.connected, () => { sawHandshakeId = true; maybeReady(); }); this.register(client, import_ib.EventName.nextValidId, () => { sawHandshakeId = true; maybeReady(); }); this.register(client, import_ib.EventName.managedAccounts, (accountsList) => { const reported = accountsList.split(",").map((s) => s.trim()).filter((s) => s.length > 0); this.#reportedAccounts = reported; const first = reported[0]; if (this.config.account === undefined && first !== undefined) { this.#fullAccount = first; } sawAccounts = true; maybeReady(); }); this.register(client, import_ib.EventName.currentTime, (time) => { this.recordHeartbeat(time); sawFirstBeat = true; maybeReady(); }); this.register(client, import_ib.EventName.error, (err, code, reqId) => { if (import_ib.isNonFatalError(code, err)) { this.#log(`ib info code=${code}`); return; } const scope = reqId ?? IB_CONNECTION_REQ_ID; if (settled && !isConnectionLevelError(code, scope)) { this.#log(`ib request-scoped error code=${code} reqId=${scope} — surfaced to the requesting caller; the shared session is unaffected`); return; } drop(failureForErrorCode(code, settled ? "disconnected" : "connect-failed"), settled ? `IB Gateway FATAL connection-level error on a live session (code=${code}, reqId=${scope}) — the socket is no longer trustworthy; fail-closed` : `IB Gateway error during handshake (code=${code}) — fail-closed`, { code, cause: err }); }); this.register(client, import_ib.EventName.disconnected, () => { drop("disconnected", settled ? "IB Gateway socket disconnected after the handshake (fail-closed)" : "IB Gateway socket disconnected during handshake (fail-closed)"); }); this.register(client, import_ib.EventName.connectionClosed, () => { drop("disconnected", settled ? "IB Gateway closed the connection after the handshake (fail-closed)" : "IB Gateway connection closed during handshake (fail-closed)"); }); const deadline = this.#scheduler.setInterval(() => { fail(new IbkrConnectionError("connect-failed", `IB Gateway handshake timed out after ${timeoutMs}ms (connected=${sawHandshakeId} account=${sawAccounts} heartbeat=${sawFirstBeat}) — fail-closed`)); }, timeoutMs); try { client.connect(this.config.clientId); client.reqManagedAccts(); client.reqCurrentTime(); } catch (cause) { fail(new IbkrConnectionError("connect-failed", "IB Gateway connect() threw (fail-closed)", { cause })); } }); } recordHeartbeat(serverTimeSeconds) { this.#serverTime = serverTimeSeconds * 1000; this.#lastHeartbeat = this.#serverTime; this.#lastHeartbeatWall = this.#now(); } startHeartbeat() { this.stopHeartbeat(); this.#heartbeatTimer = this.#scheduler.setInterval(() => { this.pulse(); }, this.#heartbeatIntervalMs); } stopHeartbeat() { if (this.#heartbeatTimer !== undefined) { this.#scheduler.clearInterval(this.#heartbeatTimer); this.#heartbeatTimer = undefined; } } pulse() { if (this.#phase !== "connected") return; const client = this.#client; if (client === undefined || !client.isConnected) { this.degrade("disconnected", "IB Gateway socket is no longer connected (heartbeat)"); return; } const anchor = this.#lastHeartbeatWall; if (anchor !== undefined && this.#now() - anchor > this.#heartbeatStalenessMs) { this.degrade("heartbeat-lost", `no IB Gateway heartbeat for ${this.#now() - anchor}ms (> ${this.#heartbeatStalenessMs}ms staleness bound)`); return; } try { client.reqCurrentTime(); } catch (cause) { this.degrade("disconnected", "reqCurrentTime threw on the IB Gateway socket (heartbeat)", { cause }); } } degrade(failure, reason, options = {}) { if (this.#phase === "degraded" || this.#phase === "closed") return; this.#phase = "degraded"; this.#reason = reason; this.#failure = failure; this.#failureCode = options.code; this.#failureCause = options.cause; this.stopHeartbeat(); this.#log(`degraded: ${reason} [${failure}] (${describeIbkrConfig(this.config)})`); } disconnect() { this.stopHeartbeat(); this.closeClient(); this.#phase = "closed"; } closeClient() { const client = this.#client; this.detachListeners(); this.#client = undefined; if (client === undefined) return; try { client.removeAllListeners(); client.disconnect(); } catch {} } detachListeners() { const client = this.#client; if (client !== undefined) { for (const [event, listener] of this.#registered) { client.removeListener?.(event, listener); } } this.#registered = []; } register(client, event, listener) { const wrapped = listener; client.on(event, wrapped); this.#registered.push([event, wrapped]); } } var IB_CONNECTION_REQ_ID = -1; var IB_CONNECTIVITY_RESTORED_DATA_LOST = 1101; var IB_CONNECTIVITY_RESTORED_DATA_MAINTAINED = 1102; var CONNECTION_LEVEL_ERROR_CODES = new Set([ import_ib.ErrorCode.CONNECT_FAIL, import_ib.ErrorCode.NOT_CONNECTED, import_ib.ErrorCode.FAIL_READ_MESSAGE, import_ib.ErrorCode.FAIL_CONNECTION_LOST_BETWEEN_SERVER_AND_TWS, IB_CONNECTIVITY_RESTORED_DATA_LOST, IB_CONNECTIVITY_RESTORED_DATA_MAINTAINED ]); function isConnectionLevelError(code, reqId) { return reqId === IB_CONNECTION_REQ_ID || CONNECTION_LEVEL_ERROR_CODES.has(code); } function failureForErrorCode(code, fallback) { switch (code) { case import_ib.ErrorCode.CONNECT_FAIL: return "connect-failed"; case import_ib.ErrorCode.NOT_CONNECTED: case import_ib.ErrorCode.FAIL_READ_MESSAGE: case import_ib.ErrorCode.FAIL_CONNECTION_LOST_BETWEEN_SERVER_AND_TWS: case IB_CONNECTIVITY_RESTORED_DATA_LOST: case IB_CONNECTIVITY_RESTORED_DATA_MAINTAINED: return "disconnected"; case import_ib.ErrorCode.NO_TRADING_PERMISSIONS: return "auth-failed"; default: return fallback; } } function defaultMakeClient(config) { const api = new import_ib.IBApi({ host: config.host, port: config.port }); return api; } var defaultScheduler = { setInterval: (fn, ms) => setInterval(fn, ms), clearInterval: (handle) => { clearInterval(handle); } }; export { IbkrTransport };