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.

209 lines (192 loc) 10.9 kB
/** * IB Gateway transport configuration — **entirely env-driven, zero secret hardcoding**, mirroring * the lake accessor's discipline (`src/adapters/lake/config.ts`): no credential is ever baked into * source; the account id (a sensitive identifier) is held in memory only, never logged, and is * REDACTED in every surfaced diagnostic (as the lake redacts secrets from surfaced SQL). * * The IB TWS socket API has NO in-band password — a human logs the IB Gateway in out-of-band; the * transport only speaks to that CLIENT-LAUNCHED local gateway. Host/port therefore default to the * loopback + IB's own publicly-documented paper-gateway port (4002) — these reveal nothing about * any strategy, infrastructure, or account, so they are publication-safe defaults, not a baked * endpoint. The account id has NO default: it is either supplied or discovered from the gateway's * `managedAccounts` handshake. * * ## Resolution precedence (first present wins) * 1. explicit {@link IbkrConfigInput} override passed to {@link resolveIbkrConfig} * 2. process environment * 3. a publication-safe default (loopback host, IB paper port, clientId 0, paper mode) * * ## Environment * - `KESTREL_IBKR_HOST` — gateway host (default `127.0.0.1`; the client-launched local gateway) * - `KESTREL_IBKR_PORT` — gateway API port (default `4002`, IB Gateway paper; live/TWS differ) * - `KESTREL_IBKR_CLIENT_ID` — unique API client id per gateway instance (default `0`) * - `KESTREL_IBKR_ACCOUNT` — account id to pin (default: discovered from `managedAccounts`) * - `KESTREL_IBKR_MODE` — `paper` | `live` Kestrel mode gate (default `paper`; live refused here) * * ## Mode gate (ADR-0034 §a, owner-reviewed) * `paper` is a **Kestrel-enforced mode gate**, not merely a port/account fact. This transport bead * places NO orders and ships NO live routing, so a `live` mode resolves but the transport REFUSES * to connect in it (fail-closed, {@link IbkrConnectionError} `mode-gate`). Live authority is a * human-signed arm behind a separate bead (kestrel-7o2.9/7o2.10), never a config flag. */ import { IbkrConfigError } from "./errors.ts"; /** The closed Kestrel mode vocabulary this transport honors. `paper` is the enforced default; `live` * resolves but is refused at connect time in this bead (no live routing ships here). */ export type IbkrMode = "paper" | "live"; /** Publication-safe defaults — loopback + IB's own documented paper port. NOT a baked endpoint: * these reveal no strategy/infra/account, and are always overridable by env/override. */ export const IBKR_DEFAULT_HOST = "127.0.0.1"; export const IBKR_DEFAULT_PORT = 4002; // IB Gateway paper (live/TWS ports differ; owner sets them) export const IBKR_DEFAULT_CLIENT_ID = 0; export const IBKR_DEFAULT_MODE: IbkrMode = "paper"; /** Fully-resolved transport config. `account` is `undefined` when neither supplied nor yet * discovered from the gateway handshake; it is a SENSITIVE identifier — redact it in diagnostics. */ export interface IbkrConfig { readonly host: string; readonly port: number; readonly clientId: number; /** Pinned account id, or `undefined` to accept whatever the gateway's `managedAccounts` reports. */ readonly account: string | undefined; /** The Kestrel mode gate. `live` is refused by the transport in this bead (fail-closed). */ readonly mode: IbkrMode; } /** Caller override: any subset wins over the environment; omitted fields fall through to env then * the publication-safe default. */ export interface IbkrConfigInput { host?: string; port?: number; clientId?: number; account?: string; mode?: IbkrMode; /** Environment to read from; defaults to `process.env` (injected for tests — no ambient env). */ env?: Record<string, string | undefined>; } const nonEmpty = (v: string | undefined): string | undefined => { if (v === undefined) return undefined; const t = v.trim(); return t.length > 0 ? t : undefined; }; /** Parse an integer from override|env; a present-but-non-integer value is a LOUD config error * (fail-closed — never a silent fallback to a wrong port/clientId). Absent → the default. */ function resolveInt( label: string, override: number | undefined, raw: string | undefined, fallback: number, ): number { if (override !== undefined) { if (!Number.isInteger(override)) { throw new IbkrConfigError(`${label} override must be an integer (got ${JSON.stringify(override)})`); } return override; } const s = nonEmpty(raw); if (s === undefined) return fallback; const n = Number(s); if (!Number.isInteger(n)) { throw new IbkrConfigError(`${label} must be an integer (got ${JSON.stringify(s)}) — fail-closed`); } return n; } /** Resolve the mode gate. A present-but-unknown value is refused (fail-closed over the closed * vocabulary — never a silent default to a wrong mode). */ function resolveMode(override: IbkrMode | undefined, raw: string | undefined): IbkrMode { const v = override ?? nonEmpty(raw); if (v === undefined) return IBKR_DEFAULT_MODE; if (v !== "paper" && v !== "live") { throw new IbkrConfigError( `KESTREL_IBKR_MODE must be "paper" or "live" (got ${JSON.stringify(v)}) — fail-closed`, ); } return v; } /** * Resolve the effective {@link IbkrConfig} from override > env > publication-safe default. Pure and * zero-network: it only reads the injected/ambient environment. Throws {@link IbkrConfigError} only * for a PRESENT-but-malformed value (a non-integer port, an unknown mode) — never for an absent one * (defaults cover absence). No credential is ever baked in; the account id, if present, is held on * the returned object in memory only. */ export function resolveIbkrConfig(input: IbkrConfigInput = {}): IbkrConfig { const env = input.env ?? process.env; const host = nonEmpty(input.host) ?? nonEmpty(env.KESTREL_IBKR_HOST) ?? IBKR_DEFAULT_HOST; const port = resolveInt("KESTREL_IBKR_PORT", input.port, env.KESTREL_IBKR_PORT, IBKR_DEFAULT_PORT); const clientId = resolveInt( "KESTREL_IBKR_CLIENT_ID", input.clientId, env.KESTREL_IBKR_CLIENT_ID, IBKR_DEFAULT_CLIENT_ID, ); const account = nonEmpty(input.account) ?? nonEmpty(env.KESTREL_IBKR_ACCOUNT); const mode = resolveMode(input.mode, env.KESTREL_IBKR_MODE); return { host, port, clientId, account, mode }; } // ───────────────────────────────────────────────────────────────────────────── // The SECOND barrier: is the endpoint we reached actually a paper one? (ADR-0034 §3) // ───────────────────────────────────────────────────────────────────────────── /** * What KIND of IB account the gateway's `managedAccounts` handshake reported — the input to * ADR-0034 §3's promised SECOND barrier ("the broker's paper/live account is defense-in-depth … * reaching the right one is a useful *second* barrier"). The Kestrel mode gate stays the PRIMARY * control; this is the backstop that catches what the mode gate structurally cannot see — a * misconfigured port or a fat-fingered endpoint that lands on a LIVE-logged-in gateway while every * mode string still reads `paper`. * * - `paper` — an IB paper account (`DU…` individual, `DF…` advisor). * - `live` — an IB live, real-money account (`U…`). * - `unknown` — anything else: absent, empty, or a shape this vocabulary does not recognise. It is a * DISTINCT member and NOT a synonym for `paper`: an account we cannot classify is refused exactly * as loudly as a live one (AGENTS: never a silent default — an unclassifiable account is not * evidence of a paper account). */ export type IbkrAccountClass = "paper" | "live" | "unknown"; /** IB paper accounts: `DU` (individual) / `DF` (advisor), then the account digits. */ const IBKR_PAPER_ACCOUNT = /^D[UF]\d+$/; /** IB live, real-money accounts: `U`, then the account digits. */ const IBKR_LIVE_ACCOUNT = /^U\d+$/; /** * Classify an IB account id by its prefix — PURE, zero-network, and secret-free (it never returns, * logs, or embeds the id). FAIL CLOSED by construction: it only ever answers `paper` for a shape it * POSITIVELY recognises as a paper account; everything it does not recognise is `unknown`, which the * caller must refuse. There is deliberately no case-normalisation and no prefix-only match: IB emits * these ids uppercase and digit-suffixed, so a value that does not match that shape is precisely the * value we must not be guessing about. */ export function classifyIbkrAccount(account: string | undefined): IbkrAccountClass { if (account === undefined) return "unknown"; const a = account.trim(); if (IBKR_PAPER_ACCOUNT.test(a)) return "paper"; if (IBKR_LIVE_ACCOUNT.test(a)) return "live"; return "unknown"; } /** * IB's own publicly-documented REAL-MONEY API ports, and what listens on each. Defense-in-depth ONLY: * the load-bearing barrier is {@link classifyIbkrAccount} against the account the handshake actually * reported, because a live gateway can be moved to any port at all and a port number is only ever a * claim about an endpoint, never evidence about an account. This map catches the fat-finger EARLY, * before a socket is opened; the account assertion catches everything, including a nonstandard one. */ export const IBKR_LIVE_PORTS: ReadonlyMap<number, string> = new Map([ [4001, "IB Gateway live"], [7496, "TWS live"], ]); /** * Redact a sensitive account id for logs/diagnostics (kestrel-7o2.5) — keep only the leading char * and the last two so an operator can recognize their own account without the full number ever * reaching a log/error/report. `undefined`/short ids collapse to a fixed mask. As the lake redacts * secrets from surfaced SQL, the transport redacts the account from every surfaced string. */ export function redactAccount(account: string | undefined): string { if (account === undefined) return "(none)"; const a = account.trim(); if (a.length <= 3) return "***"; return `${a[0]}***${a.slice(-2)}`; } /** * A publication-safe, secret-free description of a resolved config for logs/diagnostics * (kestrel-7o2.5). The account is REDACTED via {@link redactAccount}; host/port/clientId/mode carry * no secret. This is the ONLY string form of a config that may ever be logged or surfaced. */ export function describeIbkrConfig(config: IbkrConfig): string { return `IBKR[${config.mode}] ${config.host}:${config.port} clientId=${config.clientId} account=${redactAccount(config.account)}`; }