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
text/typescript
/**
* # 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;
}