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