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