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.

557 lines (508 loc) 23.6 kB
#!/usr/bin/env bun /** * # session/cli — the sim Session CLI (RUNTIME §7) + the ledger surface (ADR-0006/0007) * * A thin, loud command over {@link runSimSession} and the run {@link ../ledger ledger}. It * reads a bus and a Kestrel plans document, grades the session under a named fill model, prints the * report JSON to stdout, (with `--out`) writes it to a file, and — unless `--no-record` — **auto- * records** the graded run into the ledger so it lands in the lineage/leaderboard store the moment * it is graded (ADR-0006: names are data). Plus read-only query subcommands over that store. * * ``` * # grade a session (auto-records into data/kestrel.db) * bun src/session/cli.ts run --bus <path> --plans <path.kestrel|.md> \ * --fill strict-cross-v1 --r-usd 100 [--tau-hours 6.5] [--out report.json] \ * [--db data/kestrel.db] [--no-record] * * # query the ledger (human-readable tables) * bun src/session/cli.ts runs list [--session-date 2025-04-09] [--fill maker-fair/v1] [--lineage flush-fade] [--db …] * bun src/session/cli.ts runs show <run_id> [--db …] * bun src/session/cli.ts lineage <name> [--db …] * bun src/session/cli.ts leaderboard [--since <ms>] [--db …] * ``` * * **Determinism / injected time (RUNTIME §0):** the ledger `recorded_at` is the run's **last bus * event `ts`** (`report.session.settle_ts`) — an injected, deterministic clock value, never * wall-clock — so a re-grade of the same bus records byte-identically. * * The plans path parses as a `.kestrel` file (one document) or a Markdown file with * ```` ```kestrel ```` fenced blocks (`--plans foo.md`, one document per block, parsed * identically — ADR-0001). `--tau-hours <h>` **overrides** the tau with a constant time-to-expiry * (hours to the settle, converted to years) so `@fair` — and therefore the `maker-fair-v1` * resting-at-fair hazard — is pinned (RUNTIME §4: fair needs an injected tau; a pure valuation * reads no clock). **Omitting it uses the dynamic tau by default** (`max(0, 16:00 ET on * session_date − now)`), which decays intraday as a same-day expiry does; the constant override is a * documented simplification. Either way a silent mid is never used, and time is always supplied, * never read. Fails **closed and loud**: any parse error, unknown fill model, * missing flag, non-positive number, or bus corruption prints to stderr and exits `1` * (RUNTIME §8). */ import { readFileSync } from "node:fs"; import { parse, parseMarkdown, type KestrelNode } from "../lang/index.ts"; import { canonicalPlansText } from "../canonical/plans.ts"; import { loadEpisodeSigmoidParams, type EpisodeSigmoidParams } from "../fill/index.ts"; import { getRun, leaderboard, lineage as lineageOf, listRuns, openLedger, record, type LeaderboardRow, type LineageInstance, type PlanInstanceRow, type RunRow, } from "../ledger/index.ts"; import { runSimSession, type FillModelName, type EpisodeReport, type SessionReport } from "./sim.ts"; import { runDaySession, parseWakeTimes, type TimeOfDay } from "./day.ts"; const FILL_MODELS: ReadonlySet<string> = new Set<FillModelName>(["strict-cross-v1", "maker-fair-v1"]); /** The default ledger path. `data/` is gitignored application space (ADR-0006: strategy content * lives in the ledger + gitignored runs, never in committed Kestrel source). */ const DEFAULT_DB = "data/kestrel.db"; // ───────────────────────────────────────────────────────────────────────────── // Argument parsing (flags + positionals + known booleans) // ───────────────────────────────────────────────────────────────────────────── /** A parsed argv: leading positionals, then `--flag value` pairs, with a known set of boolean * flags consumed value-lessly. Anything malformed is a loud error (RUNTIME §8). */ interface ParsedArgs { readonly positionals: readonly string[]; readonly flags: Map<string, string>; readonly bools: ReadonlySet<string>; } function parseArgs(argv: readonly string[], booleanFlags: ReadonlySet<string> = new Set()): ParsedArgs { const positionals: string[] = []; const flags = new Map<string, string>(); const bools = new Set<string>(); let i = 0; // Leading positionals (a run_id, a lineage name) precede any `--flag`. while (i < argv.length && !argv[i]!.startsWith("--")) { positionals.push(argv[i]!); i += 1; } for (; i < argv.length; i += 1) { const key = argv[i]!; if (!key.startsWith("--")) { throw new Error(`bad argument ${JSON.stringify(key)} — expected a --flag here`); } const name = key.slice(2); if (booleanFlags.has(name)) { bools.add(name); continue; } const val = argv[i + 1]; if (val === undefined) throw new Error(`missing value for --${name}`); flags.set(name, val); i += 1; } return { positionals, flags, bools }; } function required(flags: Map<string, string>, name: string): string { const v = flags.get(name); if (v === undefined) throw new Error(`missing required flag --${name}`); return v; } /** Parse a plans document path into Kestrel nodes: a `.md` extension takes the Markdown * fenced-block path; anything else parses as a single `.kestrel` document. */ function loadDocuments(plansPath: string): readonly KestrelNode[] { const text = readFileSync(plansPath, "utf8"); return plansPath.endsWith(".md") ? parseMarkdown(text) : [parse(text)]; } /** Hours in a (365-day) year — the constant `--tau-hours` divides by to reach year fractions. * Matches {@link import("../fair/index.ts").MS_PER_YEAR}'s 365-day convention. */ const HOURS_PER_YEAR = 365 * 24; const USAGE = [ "usage:", " run --bus <path> --plans <path.kestrel|.md> --fill strict-cross-v1|maker-fair-v1 --r-usd <number>", " [--tau-hours <number>] [--hazard-params <calibration.json>] [--out <report.json>]", " [--db <path=data/kestrel.db>] [--no-record]", " day --bus <path> --dir <rundir> --fill strict-cross-v1|maker-fair-v1 --r-usd <number>", " [--wakes HH:MM,HH:MM,…] [--structural] [--max-wait <sec=900>] [--no-author]", " [--tau-hours <number>] [--hazard-params <calibration.json>] [--out <report.json>]", " [--db <path=data/kestrel.db>] [--no-record]", " runs list [--session-date <YYYY-MM-DD>] [--fill <model>] [--lineage <name>] [--db <path>]", " runs show <run_id> [--db <path>]", " lineage <name> [--db <path>]", " leaderboard [--since <ms>] [--db <path>]", ].join("\n"); /** Parse the fill model + r-usd + optional tau + optional hazard params shared by `run` and `day`. * Every branch fails closed and loud (RUNTIME §8). */ function parseGradeFlags(flags: Map<string, string>): { fillModel: FillModelName; rUsd: number; fairTauYears?: (now: number) => number | null; makerFairParams?: EpisodeSigmoidParams; out?: string; } { const fillModel = required(flags, "fill"); if (!FILL_MODELS.has(fillModel)) { throw new Error(`unknown fill model ${JSON.stringify(fillModel)} — one of strict-cross-v1 | maker-fair-v1`); } const rUsdRaw = required(flags, "r-usd"); const rUsd = Number(rUsdRaw); if (!Number.isFinite(rUsd) || rUsd <= 0) { throw new Error(`--r-usd must be a positive number, got ${JSON.stringify(rUsdRaw)}`); } let fairTauYears: ((now: number) => number | null) | undefined; const tauRaw = flags.get("tau-hours"); if (tauRaw !== undefined) { const tauHours = Number(tauRaw); if (!Number.isFinite(tauHours) || tauHours <= 0) { throw new Error(`--tau-hours must be a positive number, got ${JSON.stringify(tauRaw)}`); } const tauYears = tauHours / HOURS_PER_YEAR; fairTauYears = () => tauYears; } let makerFairParams: EpisodeSigmoidParams | undefined; const hazardPath = flags.get("hazard-params"); if (hazardPath !== undefined) { if (fillModel !== "maker-fair-v1") throw new Error("--hazard-params applies only to --fill maker-fair-v1"); const raw = JSON.parse(readFileSync(hazardPath, "utf8")) as unknown; makerFairParams = loadEpisodeSigmoidParams(raw); } return { fillModel: fillModel as FillModelName, rUsd, ...(fairTauYears !== undefined ? { fairTauYears } : {}), ...(makerFairParams !== undefined ? { makerFairParams } : {}), ...(flags.get("out") !== undefined ? { out: flags.get("out")! } : {}), }; } // ───────────────────────────────────────────────────────────────────────────── // `run` — grade + auto-record // ───────────────────────────────────────────────────────────────────────────── function runCommand(argv: readonly string[]): void { const { flags, bools } = parseArgs(argv, new Set(["no-record"])); const busPath = required(flags, "bus"); const plansPath = required(flags, "plans"); // fill model + r-usd + optional tau (RUNTIME §4, a constant is a valid injection) + optional // calibrated hazard params (RUNTIME §6) — all shared with `day`, all fail-closed (§8). const { fillModel, rUsd, fairTauYears, makerFairParams, out } = parseGradeFlags(flags); const documents = loadDocuments(plansPath); const report = runSimSession({ busPath, documents, fillModel, rUsd, ...(fairTauYears !== undefined ? { fairTauYears } : {}), ...(makerFairParams !== undefined ? { makerFairParams } : {}), ...(out !== undefined ? { out } : {}), }); process.stdout.write(JSON.stringify(report, null, 2) + "\n"); // Auto-record into the ledger (ADR-0006) unless opted out. `now` is the run's LAST bus event // ts — an injected, deterministic clock value (RUNTIME §0), never wall-clock — so re-grading the // same bus re-records byte-identically. `calibrated` marks a hazard-params (calibrated) fill. if (!bools.has("no-record")) { recordGraded(flags, report, { plansText: canonicalPlansText(documents), rUsd, calibrated: makerFairParams !== undefined, ...(out !== undefined ? { reportPath: out } : {}), }); } } // ───────────────────────────────────────────────────────────────────────────── // `day` — the stepped session (wake handshake + document supersession) // ───────────────────────────────────────────────────────────────────────────── async function dayCommand(argv: readonly string[]): Promise<void> { const { flags, bools } = parseArgs(argv, new Set(["no-record", "structural", "no-author"])); const busPath = required(flags, "bus"); const dir = required(flags, "dir"); const { fillModel, rUsd, fairTauYears, makerFairParams, out } = parseGradeFlags(flags); const wakes: TimeOfDay[] = flags.get("wakes") !== undefined ? parseWakeTimes(flags.get("wakes")!) : []; let maxWaitSec: number | undefined; const maxWaitRaw = flags.get("max-wait"); if (maxWaitRaw !== undefined) { maxWaitSec = Number(maxWaitRaw); if (!Number.isFinite(maxWaitSec) || maxWaitSec <= 0) { throw new Error(`--max-wait must be a positive number of seconds, got ${JSON.stringify(maxWaitRaw)}`); } } const { report, plansText, wakes: delivered } = await runDaySession({ busPath, dir, wakes, structural: bools.has("structural"), fillModel, rUsd, ...(fairTauYears !== undefined ? { fairTauYears } : {}), ...(makerFairParams !== undefined ? { makerFairParams } : {}), ...(maxWaitSec !== undefined ? { maxWaitSec } : {}), ...(bools.has("no-author") ? { noAuthor: true } : {}), ...(out !== undefined ? { out } : {}), }); process.stdout.write(JSON.stringify(report, null, 2) + "\n"); const revised = delivered.filter((w) => w.revised).length; process.stderr.write(`day: ${delivered.length} wake(s), ${revised} revision(s) — settle ${report.session.settle_ts}\n`); // Auto-record with the FULL concatenated authored text (plans-0 + every revision) as plansText // (ADR-0006). `now` is the injected settle ts, never wall-clock (RUNTIME §0). if (!bools.has("no-record")) { recordGraded(flags, report, { plansText, rUsd, calibrated: makerFairParams !== undefined, ...(out !== undefined ? { reportPath: out } : {}), }); } } /** Record a graded report into the ledger, clocked by the injected settle ts (shared by `run` * and `day`). `--db` overrides the default path. */ function recordGraded( flags: Map<string, string>, report: EpisodeReport, opts: { plansText: string; rUsd: number; calibrated: boolean; reportPath?: string }, ): void { const dbPath = flags.get("db") ?? DEFAULT_DB; const db = openLedger(dbPath); try { const { runId } = record(db, report, { plansText: opts.plansText, now: report.session.settle_ts, rUsd: opts.rUsd, calibrated: opts.calibrated, ...(opts.reportPath !== undefined ? { reportPath: opts.reportPath } : {}), }); process.stderr.write(`session: recorded run ${runId.slice(0, 12)} into ${dbPath}\n`); } finally { db.close(); } } // ───────────────────────────────────────────────────────────────────────────── // Query subcommands — human-readable tables over the ledger // ───────────────────────────────────────────────────────────────────────────── /** Right-pad to width. */ function pad(s: string, w: number): string { return s.length >= w ? s : s + " ".repeat(w - s.length); } /** Left-pad (numbers align right). */ function padL(s: string, w: number): string { return s.length >= w ? s : " ".repeat(w - s.length) + s; } /** Signed dollars, 2dp (`+25.00`, `-270.00`) — the human money glyph. */ function usd(n: number): string { return (n >= 0 ? "+" : "") + n.toFixed(2); } /** Render a header + rows as a fixed-width table, columns sized to content. Numeric columns * (flagged by `rightAlign`) pad left. Prints nothing but the table (a note precedes it). */ function table(headers: readonly string[], rows: readonly (readonly string[])[], rightAlign: readonly boolean[]): string { const widths = headers.map((h, c) => Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length))); const fmt = (cells: readonly string[]): string => cells.map((cell, c) => (rightAlign[c] ? padL(cell, widths[c]!) : pad(cell, widths[c]!))).join(" "); const sep = widths.map((w) => "─".repeat(w)).join(" "); return [fmt(headers), sep, ...rows.map(fmt)].join("\n"); } function shortId(id: string): string { return id.slice(0, 12); } function runsListCommand(argv: readonly string[]): void { const { flags } = parseArgs(argv); const dbPath = flags.get("db") ?? DEFAULT_DB; const db = openLedger(dbPath); try { const rows = listRuns(db, { ...(flags.get("session-date") !== undefined ? { sessionDate: flags.get("session-date")! } : {}), ...(flags.get("fill") !== undefined ? { fillModel: flags.get("fill")! } : {}), ...(flags.get("lineage") !== undefined ? { lineage: flags.get("lineage")! } : {}), }); printRunsTable(rows); } finally { db.close(); } } function printRunsTable(rows: readonly RunRow[]): void { if (rows.length === 0) { process.stdout.write("(no runs)\n"); return; } const headers = ["run_id", "date", "mode", "fill_model", "cal", "realized", "expected", "premium", "events"]; const right = [false, false, false, false, false, true, true, true, true]; const body = rows.map((r) => [ shortId(r.run_id), r.session_date, r.mode, r.fill_model, r.calibrated ? "✓" : "", usd(r.realized_floor_usd), usd(r.expected_usd), r.premium_spent.toFixed(2), String(r.bus_events), ]); process.stdout.write(table(headers, body, right) + "\n"); process.stdout.write(`\n${rows.length} run(s)\n`); } function runsShowCommand(argv: readonly string[]): void { const { positionals, flags } = parseArgs(argv); const runId = positionals[0]; if (runId === undefined) throw new Error("runs show needs a <run_id>"); const dbPath = flags.get("db") ?? DEFAULT_DB; const db = openLedger(dbPath); try { // Accept a short (12-hex) prefix or the full id. const all = listRuns(db); const match = all.filter((r) => r.run_id === runId || r.run_id.startsWith(runId)); if (match.length === 0) throw new Error(`no run matching ${JSON.stringify(runId)}`); if (match.length > 1) throw new Error(`ambiguous run prefix ${JSON.stringify(runId)}${match.length} matches`); const full = match[0]!.run_id; const rec = getRun(db, full)!; const r = rec.run; process.stdout.write( [ `run_id ${r.run_id}`, `session ${r.session_date} ${r.mode} [${r.instruments}]`, `fill_model ${r.fill_model}${r.calibrated ? " (calibrated)" : ""}`, `recorded_at ${r.recorded_at} (settle ts, injected)`, `r_usd ${r.r_usd.toFixed(2)}`, `totals realized ${usd(r.realized_floor_usd)} expected ${usd(r.expected_usd)} premium ${r.premium_spent.toFixed(2)}`, `bus ${r.bus_events} events sha ${shortId(r.bus_sha256)} det ${shortId(r.determinism_hash)}`, r.report_path !== null ? `report_path ${r.report_path}` : "report_path (none)", "", ].join("\n"), ); printPlansTable(rec.plans); } finally { db.close(); } } function printPlansTable(plans: readonly PlanInstanceRow[]): void { if (plans.length === 0) { process.stdout.write("(no plan instances)\n"); return; } const headers = ["plan", "final_state", "outcome", "fired", "orders", "fills", "realized", "expected"]; const right = [false, false, false, true, true, true, true, true]; const body = plans.map((p) => [ p.name, p.final_state, p.outcome ?? "—", p.fired ? "yes" : "no", String(p.orders), String(p.fills), usd(p.realized_usd), usd(p.expected_usd), ]); process.stdout.write(table(headers, body, right) + "\n"); } function lineageCommand(argv: readonly string[]): void { const { positionals, flags } = parseArgs(argv); const name = positionals[0]; if (name === undefined) throw new Error("lineage needs a <name>"); const dbPath = flags.get("db") ?? DEFAULT_DB; const db = openLedger(dbPath); try { const rows = lineageOf(db, name); process.stdout.write(`lineage: ${name}\n\n`); printLineageTable(rows); } finally { db.close(); } } function printLineageTable(rows: readonly LineageInstance[]): void { if (rows.length === 0) { process.stdout.write("(no instances)\n"); return; } const headers = ["date", "mode", "fill_model", "final_state", "outcome", "fired", "fills", "realized", "expected"]; const right = [false, false, false, false, false, true, true, true, true]; const body = rows.map((i) => [ i.session_date, i.mode, i.fill_model, i.final_state, i.outcome ?? "—", i.fired ? "yes" : "no", String(i.fills), usd(i.realized_usd), usd(i.expected_usd), ]); process.stdout.write(table(headers, body, right) + "\n"); const realizedSum = rows.reduce((s, i) => s + i.realized_usd, 0); const expectedSum = rows.reduce((s, i) => s + i.expected_usd, 0); process.stdout.write(`\n${rows.length} instance(s) realized ${usd(realizedSum)} expected ${usd(expectedSum)}\n`); } function leaderboardCommand(argv: readonly string[]): void { const { flags } = parseArgs(argv); const dbPath = flags.get("db") ?? DEFAULT_DB; let since: number | undefined; const sinceRaw = flags.get("since"); if (sinceRaw !== undefined) { since = Number(sinceRaw); if (!Number.isFinite(since)) throw new Error(`--since must be a number (ms), got ${JSON.stringify(sinceRaw)}`); } const db = openLedger(dbPath); try { const rows = leaderboard(db, { ...(flags.get("mode") !== undefined ? { mode: flags.get("mode")! } : {}), ...(since !== undefined ? { since } : {}), }); printLeaderboardTable(rows); } finally { db.close(); } } function printLeaderboardTable(rows: readonly LeaderboardRow[]): void { if (rows.length === 0) { process.stdout.write("(no lineages)\n"); return; } const headers = ["lineage", "runs", "fired", "fills", "realized_sum", "expected_sum", "realized_avg", "expected_avg"]; const right = [false, true, true, true, true, true, true, true]; const body = rows.map((r) => [ r.name, String(r.runs), String(r.fired), String(r.fills), usd(r.realized_sum), usd(r.expected_sum), usd(r.realized_avg), usd(r.expected_avg), ]); process.stdout.write(table(headers, body, right) + "\n"); process.stdout.write(`\n${rows.length} lineage(s)\n`); } // ───────────────────────────────────────────────────────────────────────────── // Dispatch // ───────────────────────────────────────────────────────────────────────────── export async function main(argv: readonly string[]): Promise<number> { const [command, ...rest] = argv; try { switch (command) { case "run": runCommand(rest); return 0; case "day": // `day` delegates to `runDaySession`, which is `async` since it runs OVER the shared // `runSimulateSession` driver (kestrel-5zl.3) — so this command (and `main`) awaits it. await dayCommand(rest); return 0; case "runs": { const [sub, ...subRest] = rest; if (sub === "list") { runsListCommand(subRest); return 0; } if (sub === "show") { runsShowCommand(subRest); return 0; } throw new Error(`unknown \`runs\` subcommand ${JSON.stringify(sub)} — one of list | show`); } case "lineage": lineageCommand(rest); return 0; case "leaderboard": leaderboardCommand(rest); return 0; default: process.stderr.write(`${USAGE}\n`); return 1; } } catch (err) { process.stderr.write(`session: ${err instanceof Error ? err.message : String(err)}\n`); return 1; } } // The report shape re-exported so this module's callers can type a parsed report file. // `SessionReport` is the deprecated pre-jcn0 alias, kept for API compat. export type { EpisodeReport, SessionReport }; if (import.meta.main) { void main(process.argv.slice(2)).then((code) => process.exit(code)); }