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
JavaScript
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 };