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.

245 lines (227 loc) 11.4 kB
/** * # cli/commands/paper — `kestrel paper` (HEAVY, lazy) * * The verb that STARTS a paper session (kestrel-7o2.12) — the last link: `sim | run | day | runs` all * drive RECORDED tape, and nothing on the CLI could put a live feed and a real venue gate under the * SAME engine until this. It mirrors `run`/`day` (`./grade.ts`) flag-for-flag in shape: required flags * via {@link required}, unknown flags loud (exit 2), the heavy runtime behind {@link loadHeavy} so a * bun-less host faults clean (exit 4) rather than dumping a stack, and its own `--help` table * (authored in `./meta.ts`, LIGHT — no heavy import). * * ## The IB Gateway channel is a SOCKET, not a secret (kestrel-7o2.12, scope) * `--host/--port/--client-id/--account` (and their `KESTREL_IBKR_*` env equivalents, resolved by * `adapters/broker/ibkr/config.ts`) address a **locally-running, human-logged-in IB Gateway**. The TWS * socket API has no in-band password, so NO credential store is invented here — BYO-broker credential * hardening is a separate bead (kestrel-7o2.23) and `PaperVenueSource` is the seam if one is needed. * `--account` is a SENSITIVE identifier: it is never echoed unredacted (the config layer redacts it in * every surfaced diagnostic). * * ## PAPER ONLY — there is no `--live`, and there never will be from here * The verb is named `paper` and its driver can only build `makeGate("paper", …)`. There is deliberately * no `--mode` flag: `KESTREL_IBKR_MODE=live` in the environment does NOT make this verb route live — * the driver REFUSES loudly (exit 7 / `PAPER_REFUSED`). Live real-money routing is owner-gated * (kestrel-7o2.10). * * State what ENFORCES that, precisely — not a blanket claim (ADR-0034 §3). This header used to say * live "is not reachable from this command by any flag, env var, or config value", which was FALSE: * `--port`/`KESTREL_IBKR_PORT` IS such a flag, and 7496/4001 point at a LIVE gateway while every mode * string still reads `paper`. Four things enforce it now, and none is a blanket: * - the driver's **mode gate** (a `live` resolution refuses, never a silent downgrade); * - the single literal **`makeGate("paper")`** call in the driver — no variable, no branch; * - the **handshake account assertion** (ADR-0034 §3's second barrier): the gateway's own * `managedAccounts` reply must name a PAPER account (`DU`/`DF`) before any tape/gate/order exists. * A live `U…` account — or one that cannot be classified — refuses and the socket is CLOSED. This * is what makes a fat-fingered `--port` safe, and it holds on ANY port; * - the **live-port refusal**: 4001 / 7496 are refused up front, with no proceed-anyway flag. * * ## The ceilings are REQUIRED * `--max-order-qty`, `--max-position-qty`, `--max-notional-usd` have NO defaults. A defaulted risk * ceiling is the silent default AGENTS.md forbids, and an ABSENT one makes the gate seam's L0 clamp a * benign no-op (`UNBOUNDED_PAPER_LIMITS`). Missing ⇒ exit 2. */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { lang } from "../../engine/index.ts"; type KestrelNode = lang.KestrelNode; import type { OutputCtx } from "../context.ts"; import { parseArgs, required } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { loadHeavy } from "./_heavy.ts"; function usageErr(message: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message }); } /** A required positive number flag — a ceiling or size that is absent, non-numeric, or ≤ 0 is a loud * USAGE error, never a silent fallback. */ function positive(flags: Map<string, string>, name: string): number { const raw = required(flags, name); const n = Number(raw); if (!Number.isFinite(n) || n <= 0) { throw usageErr(`--${name} must be a positive, finite number, got ${JSON.stringify(raw)}`); } return n; } /** An OPTIONAL positive number flag. Present-but-malformed is loud; absent is `undefined`. */ function optionalPositive(flags: Map<string, string>, name: string): number | undefined { const raw = flags.get(name); if (raw === undefined) return undefined; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) { throw usageErr(`--${name} must be a positive, finite number, got ${JSON.stringify(raw)}`); } return n; } /** An OPTIONAL integer flag (port / client-id / cycles). */ function optionalInt(flags: Map<string, string>, name: string): number | undefined { const raw = flags.get(name); if (raw === undefined) return undefined; const n = Number(raw); if (!Number.isInteger(n)) throw usageErr(`--${name} must be an integer, got ${JSON.stringify(raw)}`); return n; } /** `.md` → fenced ```kestrel blocks; else a single `.kestrel` document. Identical to `run`/`day`. */ function loadDocuments(plansPath: string): readonly KestrelNode[] { const text = readFileSync(plansPath, "utf8"); return plansPath.endsWith(".md") ? lang.parseMarkdown(text) : [lang.parse(text)]; } /** The stable codes {@link import("../../session/paper.ts").PaperSessionRefused} raises. Matched * STRUCTURALLY (name + known failure), never with `instanceof`: the class lives behind the dynamic * `loadHeavy` boundary, and an `instanceof` across it is not a contract worth betting a fail-closed * path on (the `day` handshake mapping's own discipline, kestrel-4fc9). Anything else rethrows * UNTOUCHED — a read fault must never be re-labelled a session refusal. */ const PAPER_FAILURES: ReadonlySet<string> = new Set([ "mode-gate", "live-port", "live-account", "limits-absent", "documents-absent", "connect-failed", "contract-unresolved", "feed-absent", "feed-stale", "transport-degraded", "killed", "order-refused", ]); /** Map a typed paper refusal onto a CliError with its OWN stable code + dedicated exit, rather than * letting it fall through to GENERIC/exit 1 — the taxonomy reserves exit 1 for "an unexpected/uncaught * error", an unclassifiable bucket in which a caller cannot tell a REFUSAL from a CRASH. A refused * paper session is the fail-closed path working, and it must say so. */ async function paperTyped<T>(run: () => Promise<T>): Promise<T> { try { return await run(); } catch (err) { if (err instanceof Error && err.name === "PaperSessionRefused") { const failure = (err as { failure?: unknown }).failure; if (typeof failure === "string" && PAPER_FAILURES.has(failure)) { throw new CliError({ code: "PAPER_REFUSED", exit: EXIT.PAPER_REFUSED, message: err.message, hint: `paper session refusal kind: ${failure} — fail-closed; nothing was transmitted after this point`, }); } } throw err; } } /** * `kestrel paper --instrument <sym> --plans <p> --session-date <d> --r-usd <n> * --max-order-qty <n> --max-position-qty <n> --max-notional-usd <n> [flags]` */ export async function paperCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> { const { flags, bools } = parseArgs( argv, new Set(["live-market-data"]), new Set([ "instrument", "plans", "session-date", "r-usd", "max-order-qty", "max-position-qty", "max-notional-usd", "budget-usd", "expiry", "spot", "half-width", "tau-hours", "tolerance", "cycles", "pump-ms", "host", "port", "client-id", "account", "out", ]), ); const instrument = required(flags, "instrument"); const plansPath = required(flags, "plans"); const sessionDate = required(flags, "session-date"); const rUsd = positive(flags, "r-usd"); // THE CEILINGS — all three REQUIRED, no defaults (see the header). An absent ceiling would fall // through to UNBOUNDED_PAPER_LIMITS and make the seam's L0 clamp a no-op. const limits = { maxOrderQty: positive(flags, "max-order-qty"), maxPositionQty: positive(flags, "max-position-qty"), maxNotionalUsd: positive(flags, "max-notional-usd"), }; const expiry = flags.get("expiry"); const spot = optionalPositive(flags, "spot"); if (expiry !== undefined && spot === undefined) { // FAIL CLOSED (kestrel-7o2.19): a chain slice picked with no idea where the money is, is a slice of // the GRID, not of the market — the 741–745-at-751.94 defect. The centre is an INJECTED observation. throw usageErr( "--expiry needs --spot: a strike window must be centred on an OBSERVED underlier price, and a centre is never guessed (kestrel-7o2.19)", ); } const tauHours = optionalPositive(flags, "tau-hours"); const paper = await loadHeavy(() => import("../heavy.ts")); const result = await paperTyped(() => paper.runPaperSession({ instrument, sessionDate, documents: loadDocuments(plansPath), limits, rUsd, cycles: optionalInt(flags, "cycles") ?? 60, ...(optionalInt(flags, "pump-ms") !== undefined ? { pumpMs: optionalInt(flags, "pump-ms")! } : {}), ...(optionalPositive(flags, "budget-usd") !== undefined ? { budgetUsd: optionalPositive(flags, "budget-usd")! } : {}), ...(expiry !== undefined ? { expiry } : {}), ...(spot !== undefined ? { spot } : {}), ...(optionalPositive(flags, "half-width") !== undefined ? { halfWidth: optionalPositive(flags, "half-width")! } : {}), ...(tauHours !== undefined ? { tauYears: tauHours / (365 * 24) } : {}), ...(optionalPositive(flags, "tolerance") !== undefined ? { tolerance: optionalPositive(flags, "tolerance")! } : {}), ...(bools.has("live-market-data") ? { liveMarketData: true } : {}), // The IB Gateway CHANNEL (a socket to a locally-running gateway — never a credential). Omitted // fields fall through to env then the publication-safe defaults (127.0.0.1:4002 = IB PAPER). gateway: { ...(flags.get("host") !== undefined ? { host: flags.get("host")! } : {}), ...(optionalInt(flags, "port") !== undefined ? { port: optionalInt(flags, "port")! } : {}), ...(optionalInt(flags, "client-id") !== undefined ? { clientId: optionalInt(flags, "client-id")! } : {}), ...(flags.get("account") !== undefined ? { account: flags.get("account")! } : {}), }, }), ); const out = flags.get("out"); if (out !== undefined) { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify({ events: result.events, ledger: result.ledger, positions: result.positions }, null, 2)); } // The Bus is the truth (ADR-0010); this line is a HOST status summary of it, never a second record. const fills = result.events.filter((e) => e.stream === "ORDER" && (e as { type?: string }).type === "fill").length; const summary = { schema: "paper-session/v1", mode: "paper", instrument, cycles: result.cycles, events: result.events.length, orders: result.ledger.length, fills, positions: result.positions, feed_health: result.watermark.health, }; if (ctx.mode === "json") process.stdout.write(JSON.stringify(summary) + "\n"); else process.stdout.write(`paper\t${instrument}\tcycles=${result.cycles}\tevents=${result.events.length}\torders=${result.ledger.length}\tfills=${fills}\tfeed=${result.watermark.health}\n`); return 0; }