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.

345 lines (314 loc) 16.4 kB
/** * # cli/commands/grade — `run` + `day` (HEAVY, lazy bun/chdb) * * Behavior identical to the old `session/cli.ts`: `parseGradeFlags`, `runSimSession`/`runDaySession`, * `recordGraded` with the **injected settle-ts clock** (`record(db, report, { now: * report.session.settle_ts })` — never wall-clock, so a re-grade of the same bus records * byte-identically). The heavy modules (`session/sim`, `session/day`, `registry` — all pull * `bun:sqlite`/`Bun.CryptoHasher`) are dynamic-imported through {@link loadHeavy}, so this file stays * off the load-time graph and faults loud (exit 4) under bun-less node, never a raw stack. * * Only type-only imports of the heavy modules appear statically (erased at build). The parse/print * and fill helpers this HEAVY command needs bind to the PUBLISHED engine surface (kestrel-djm.3) — * `engine.lang` (parse/print) and `engine.fill` (the hazard-param loader) — rather than deep private * `../../lang` / `../../fill` paths; that aggregate is Bun-hosted but still carries NO * `bun:sqlite`/`chdb` (those stay behind the lazy {@link ../heavy.ts} edge), so this file's static * graph remains free of the native runtime and `run`/`day` still fault loud (exit 4) under bun-less * node only when the heavy barrel is dynamically loaded. */ import { mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; import { lang, fill } from "../../engine/index.ts"; import { canonicalPlansText } from "../../canonical/plans.ts"; type KestrelNode = lang.KestrelNode; type EpisodeSigmoidParams = fill.EpisodeSigmoidParams; import type { FillModelName, EpisodeReport, TimeOfDay } from "../heavy.ts"; import type { OutputCtx } from "../context.ts"; import { parseArgs, required } from "../args.ts"; import { CliError, EXIT } from "../errors.ts"; import { loadHeavy } from "./_heavy.ts"; import { renderOffer } from "../render/offer.ts"; import { renderEpisodeReport, neverFiredDigestLines } from "../render/sim.ts"; import type { ExecutionBackend, SessionArgs, DayArgs } from "../backend/index.ts"; const FILL_MODELS: ReadonlySet<string> = new Set<FillModelName>(["strict-cross-v1", "maker-fair-v1"]); /** The default registry path (gitignored application space, ADR-0006). */ const DEFAULT_DB = "data/kestrel.db"; /** Hours in a (365-day) year — the constant `--tau-hours` divides by to reach year fractions. */ const HOURS_PER_YEAR = 365 * 24; function usageErr(message: string): CliError { return new CliError({ code: "USAGE", exit: EXIT.USAGE, message }); } /** The stable codes {@link import("../../session/day.ts").DayHandshakeError} raises. */ const DAY_HANDSHAKE_CODES: ReadonlySet<string> = new Set(["NO_AUTHOR", "HANDSHAKE_ABANDONED"]); /** Map a `day` handshake refusal onto a CliError with its OWN stable code + a dedicated exit * ({@link EXIT.HANDSHAKE}) rather than letting it fall through to GENERIC/exit 1, which the taxonomy * reserves for "an unexpected/uncaught error" — an unclassifiable bucket a caller (the web repo's * published-command guard) cannot distinguish a refusal from a crash in (kestrel-4fc9). * * The error is matched STRUCTURALLY (name + known code), not with `instanceof`: the class lives in * `session/day.ts`, which this light command module must not statically import (it reaches the runtime * only through `loadHeavy`), and an `instanceof` across that dynamic-import boundary is not a contract * worth betting a fail-closed path on. Anything else rethrows UNTOUCHED — a read fault or a * ReferenceError must never be re-labelled a handshake refusal. */ async function dayHandshakeTyped<T>(run: () => Promise<T>): Promise<T> { try { return await run(); } catch (err) { if (err instanceof Error && err.name === "DayHandshakeError") { const code = (err as { code?: unknown }).code; if (typeof code === "string" && DAY_HANDSHAKE_CODES.has(code)) { const hint = (err as { hint?: unknown }).hint; throw new CliError({ code, exit: EXIT.HANDSHAKE, message: err.message, ...(typeof hint === "string" ? { hint } : {}), }); } } throw err; } } /** `.md` → fenced ```kestrel blocks; else a single `.kestrel` document. */ function loadDocuments(plansPath: string): readonly KestrelNode[] { const text = readFileSync(plansPath, "utf8"); return plansPath.endsWith(".md") ? lang.parseMarkdown(text) : [lang.parse(text)]; } interface GradeFlags { fillModel: FillModelName; rUsd: number; fairTauYears?: (now: number) => number | null; makerFairParams?: EpisodeSigmoidParams; out?: string; } /** Parse the fill model + r-usd + optional tau + optional hazard params shared by `run`/`day`. */ function parseGradeFlags(flags: Map<string, string>): GradeFlags { const fillModel = required(flags, "fill"); if (!FILL_MODELS.has(fillModel)) { throw usageErr(`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 usageErr(`--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 usageErr(`--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 usageErr("--hazard-params applies only to --fill maker-fair-v1"); const raw = JSON.parse(readFileSync(hazardPath, "utf8")) as unknown; makerFairParams = fill.loadEpisodeSigmoidParams(raw); } return { fillModel: fillModel as FillModelName, rUsd, ...(fairTauYears !== undefined ? { fairTauYears } : {}), ...(makerFairParams !== undefined ? { makerFairParams } : {}), ...(flags.get("out") !== undefined ? { out: flags.get("out")! } : {}), }; } /** * The one-line text-mode digest (kestrel-4opv) — the primary feedback loop for an agent iterating on a * strategy, so it must tell the near-miss outcomes APART: a plan that never fired (`plans_fired=0`), * a plan that quietly expired on its ttl (`plans_expired=1`), a fired plan whose order rests unfilled * (`orders_placed=1 orders_filled=0`), and a filled round trip. Read off the report fields the caller * already holds — `plans[].lifecycle` (RUNTIME §5) + `orders[]` + `totals` — and kept as the ONE call * site so the OSS-ADR-0039 migration (kestrel-c1os) re-points the digest at the Blotter in one place. * * Counting rules, stated because they are NOT self-evident from the field names: * - `plans_armed` / `plans_fired` count plans whose lifecycle EVER REACHED that state, not plans * sitting in it at settle — a plan that armed and then fired counts in both. * - `plans_expired` counts the TERMINAL `done(expired)` outcome only (ttl elapsed / deadline passed). * A plan that fired and filled is not "expired", and a plan still armed at settle is not either. * - `orders_placed` counts SUBMISSIONS — every order that crossed the Gate, INCLUDING ones later * cancelled (esc-replaced, TTL/INVALIDATE-pulled, CANCEL-IF; `OrderReport.cancelled`). It is * deliberately not a count of orders still resting at settle: the digest reports what the session * DID, and a replaced order was really submitted. `orders_placed − orders_filled` is therefore an * upper bound on what rested, not an exact resting count. */ function settleDigest(report: EpisodeReport): string { type PlanState = EpisodeReport["plans"][number]["lifecycle"][number]["state"]; const traces = report.plans; const reached = (state: PlanState): number => traces.filter((p) => p.lifecycle.some((s) => s.state === state)).length; const armed = reached("armed"); const fired = reached("fired"); // A plan EXPIRED is a terminal outcome, not a lifecycle state — read it off `final_state`. const expired = traces.filter((p) => p.final_state === "expired").length; const placed = report.orders.length; const filled = report.orders.filter((o) => o.filled).length; return ( `settle=${report.session.settle_ts}` + ` pnl=${report.totals.realized_floor_usd}` + ` expected=${report.totals.expected_usd}` + ` events=${report.session.bus_events}` + ` plans_armed=${armed} plans_fired=${fired} plans_expired=${expired}` + ` orders_placed=${placed} orders_filled=${filled}` ); } /** Write the report to stdout in the active mode (diagnostics go to stderr — §5). */ function writeReport(report: EpisodeReport, ctx: OutputCtx): void { if (ctx.mode === "text") { // The token-cheap agent default: the one-line settle digest — PLUS a per-plan `why` line for every // zero-order plan that carries a never-fired reason (kestrel-1zix). The aggregate digest alone made // the DEFAULT piped/agent frame silent about WHY a plan never fired — a regime-DOA plan, a // ttl-expired plan, and an adoption that bound nothing were byte-identical to a healthy skip. The // reason lines the human/json faces already carry now ride this frame too, so nothing is inaudible on // the surface the docs tell an agent to pipe. Driven HERE by the real command, so DELETING this call // (not editing render/sim.ts) reds the digest fixtures — the decisive wiring probe. A session where // every plan traded yields NO extra lines, so a healthy run's digest stays the single aggregate line. const lines = [settleDigest(report), ...neverFiredDigestLines(report)]; process.stdout.write(lines.join("\n") + "\n"); return; } if (ctx.mode === "human") { // The full session story as pure text — grade channels, plan lifecycle, orders/fills (kestrel-0hx). // The site-is-spec third face: the whole report is legible to a human AND an agent without paying // the JSON parse cost (the report used to exist in a legible form ONLY via --json). renderEpisodeReport(report, ctx); return; } // json emits the raw EpisodeReport object (no wrapper) as canonical two-space JSON. NOTE: a bare pipe // resolves to TEXT mode (the one-line settle digest), so `| jq` needs an explicit --json; only json // mode is jq-stable. process.stdout.write(JSON.stringify(report, null, 2) + "\n"); } /** Record a graded report, clocked by the injected settle ts (shared by `run` + `day`). */ async function recordGraded( flags: Map<string, string>, report: EpisodeReport, opts: { plansText: string; rUsd: number; calibrated: boolean; reportPath?: string }, ): Promise<void> { const registry = await loadHeavy(() => import("../heavy.ts")); const dbPath = flags.get("db") ?? DEFAULT_DB; // First-run DX: a brand-new user's first `run`/`day` records to the default `data/kestrel.db` from a // fresh cwd with no `data/` folder yet; create the resolved db's PARENT dir so the open no longer // faults with SQLite's raw "unable to open database file". CLI-level filesystem setup, off the // deterministic runtime/replay path, and only on this write/record path (reads never create dirs). mkdirSync(dirname(dbPath), { recursive: true }); const db = registry.openRegistry(dbPath); try { const { runId } = registry.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(); } } /** `run` — grade a session, print the report, auto-record (unless `--no-record`). */ export async function runCommand(argv: readonly string[], ctx: OutputCtx, backend: ExecutionBackend): Promise<number> { const { flags, bools } = parseArgs( argv, new Set(["no-record"]), new Set(["bus", "plans", "fill", "r-usd", "tau-hours", "hazard-params", "out", "db"]), ); const busPath = required(flags, "bus"); const plansPath = required(flags, "plans"); const { fillModel, rUsd, fairTauYears, makerFairParams, out } = parseGradeFlags(flags); const documents = loadDocuments(plansPath); const sessionArgs: SessionArgs = { busPath, documents, fillModel, rUsd, ...(fairTauYears !== undefined ? { fairTauYears } : {}), ...(makerFairParams !== undefined ? { makerFairParams } : {}), ...(out !== undefined ? { out } : {}), }; const g = await backend.openSession("sim", sessionArgs); if (g.gated) { renderOffer(g.payment, ctx); return EXIT.PAYMENT_REQUIRED; } const { report } = g.value; writeReport(report, ctx); if (!bools.has("no-record")) { await recordGraded(flags, report, { plansText: canonicalPlansText(documents), rUsd, calibrated: makerFairParams !== undefined, ...(out !== undefined ? { reportPath: out } : {}), }); } return 0; } /** `day` — the stepped/wake session (wake handshake + document supersession), auto-record. */ export async function dayCommand(argv: readonly string[], ctx: OutputCtx, backend: ExecutionBackend): Promise<number> { const { flags, bools } = parseArgs( argv, new Set(["no-record", "structural", "no-author", "await-author"]), new Set(["bus", "dir", "fill", "r-usd", "tau-hours", "hazard-params", "wakes", "max-wait", "out", "db"]), ); const busPath = required(flags, "bus"); const dir = required(flags, "dir"); const { fillModel, rUsd, fairTauYears, makerFairParams, out } = parseGradeFlags(flags); // `--wakes HH:MM,…` parses through the heavy barrel's pure helper (behavior-identical to before). let wakes: TimeOfDay[] = []; const wakesRaw = flags.get("wakes"); if (wakesRaw !== undefined) { const day = await loadHeavy(() => import("../heavy.ts")); wakes = day.parseWakeTimes(wakesRaw); } let maxWaitSec: number | undefined; const maxWaitRaw = flags.get("max-wait"); if (maxWaitRaw !== undefined) { maxWaitSec = Number(maxWaitRaw); if (!Number.isFinite(maxWaitSec) || maxWaitSec <= 0) { throw usageErr(`--max-wait must be a positive number of seconds, got ${JSON.stringify(maxWaitRaw)}`); } } // --await-author (block for an out-of-band author) and --no-author (nobody will reply) are contradictory // declarations — refuse the combination loudly rather than silently letting one win (kestrel-4fc9). if (bools.has("await-author") && bools.has("no-author")) { throw usageErr("--await-author and --no-author are contradictory: declare at most one (default: refuse promptly on a missing document)."); } // The stepped day authors its plan document in-band via the handshake dir (`plans-0.kestrel`), // so `documents` is empty here — runDaySession reads plans from `dir`, not from this field. const dayArgs: DayArgs = { dir, documents: [], busPath, wakes, structural: bools.has("structural"), fillModel, rUsd, ...(fairTauYears !== undefined ? { fairTauYears } : {}), ...(makerFairParams !== undefined ? { makerFairParams } : {}), ...(maxWaitSec !== undefined ? { maxWaitSec } : {}), ...(bools.has("await-author") ? { awaitAuthor: true } : {}), ...(bools.has("no-author") ? { noAuthor: true } : {}), ...(out !== undefined ? { out } : {}), }; const g = await dayHandshakeTyped(() => backend.openDaySession("sim", dayArgs)); if (g.gated) { renderOffer(g.payment, ctx); return EXIT.PAYMENT_REQUIRED; } const { report, plansText, wakes: delivered } = g.value; writeReport(report, ctx); 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`); if (!bools.has("no-record")) { await recordGraded(flags, report, { plansText, rUsd, calibrated: makerFairParams !== undefined, ...(out !== undefined ? { reportPath: out } : {}), }); } return 0; }