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.

369 lines (341 loc) 16.5 kB
/** * # ledger — the run / plan / lineage store over `bun:sqlite` (ADR-0006, ADR-0007) * * The durable ledger of graded runs. A {@link EpisodeReport} is an in-memory grading of one * `sim` {@link https://…|Instance} (ADR-0007: definition × mode × run); the ledger is where that * grading **lands** so it can be queried across time. Its whole reason to exist is ADR-0006's * "names are data": a plan's free-text **name is its lineage key**, so the same authored name run * on 40 days is one strategy with 40 instances — and `plan_instances.name` is the natural * aggregation key that `lineage()` and `leaderboard()` roll up. * * ## Determinism doctrine (ADR-0007 / RUNTIME §0) * A run's identity is a pure function of its inputs: `run_id = sha256(bus_sha256 + plans_sha256 + * fill_model + r_usd)`. Re-recording the same run is therefore an **idempotent upsert** (`INSERT OR * REPLACE`) — same inputs collapse to the same row, never a duplicate. Two records of one run leave * the store byte-identical. * * ## Honesty rules this module holds (ADR-0006) * - **Every grade stamps its judge.** `fill_model` is a first-class run column; comparisons across * fill models are the caller's discipline, but the judge is always recorded. * - **Nothing is wall-clock.** `recorded_at` is a caller-injected `now` (never `Date.now()` inside * the module), so a recorded run is reproducible and orderable by the caller's clock. * - **Fail-closed on version skew.** {@link openLedger} refuses (loudly) to open a database whose * `user_version` is newer than this build knows — a future schema is never silently misread. * * The module is pure mapping + parameterized SQL: no string interpolation of values ever reaches a * statement, and the default database path is a caller concern (tests use `:memory:` / a temp file). */ import { Database } from "bun:sqlite"; import type { EpisodeReport } from "../session/sim.ts"; import { aggregatePlans, deriveRunId, plansSha256Of } from "./core.ts"; import type { LeaderboardFilter, LeaderboardRow, LineageInstance, ListRunsFilter, PlanInstanceRow, RecordOptions, RecordResult, RunRow, RunWithPlans, } from "./core.ts"; // The storage-agnostic heart — run-identity hash, report→rows reduction, and the row/query types — // lives in {@link ./core.ts} and is shared byte-for-byte with the Worker-portable {@link ./memory.ts} // backend (kestrel-alw.17). Re-exported here so this module stays the durable ledger's single surface. export { deriveRunId, aggregatePlans } from "./core.ts"; export type { AggregatedPlan, LeaderboardFilter, LeaderboardRow, LineageInstance, ListRunsFilter, OrderSummaryRow, PlanInstanceRow, RecordOptions, RecordResult, RunRow, RunWithPlans, } from "./core.ts"; // The regenerable sim_runs + agent_configs projection (kestrel-5zl.9) — a QUERY-ONLY, PURE index over the // finalized Blotters (a deterministic function of the source; no `bun:sqlite`, no stored state), surfaced // additively here. It never touches the durable run/plan/lineage ledger above. export { projectSimRuns, simRunById, runsByCell, runsByConfig, type SimRunRow, type AgentConfigRow, type ExcludedRow, type SimRunsIndex, } from "./sim-runs.ts"; // ───────────────────────────────────────────────────────────────────────────── // Schema // ───────────────────────────────────────────────────────────────────────────── /** The schema version this build writes and understands. Bumped only alongside a migration. A * database stamped higher than this is from a newer build → {@link openLedger} refuses it. */ export const SCHEMA_VERSION = 1; const SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS runs ( run_id TEXT PRIMARY KEY, recorded_at INTEGER NOT NULL, session_date TEXT NOT NULL, mode TEXT NOT NULL, instruments TEXT NOT NULL, bus_sha256 TEXT NOT NULL, plans_sha256 TEXT NOT NULL, fill_model TEXT NOT NULL, calibrated INTEGER NOT NULL, determinism_hash TEXT NOT NULL, r_usd REAL NOT NULL, realized_floor_usd REAL NOT NULL, expected_usd REAL NOT NULL, premium_spent REAL NOT NULL, report_path TEXT, bus_events INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS plan_instances ( run_id TEXT NOT NULL, name TEXT NOT NULL, canonical_sha256 TEXT NOT NULL, final_state TEXT NOT NULL, outcome TEXT, fired INTEGER NOT NULL, orders INTEGER NOT NULL, fills INTEGER NOT NULL, realized_usd REAL NOT NULL, expected_usd REAL NOT NULL, PRIMARY KEY (run_id, name), FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS orders_summary ( run_id TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL, count INTEGER NOT NULL, esc_stages_max INTEGER NOT NULL, reprice_count INTEGER NOT NULL, PRIMARY KEY (run_id, name, role), FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_runs_session_date ON runs(session_date); CREATE INDEX IF NOT EXISTS idx_runs_recorded_at ON runs(recorded_at); CREATE INDEX IF NOT EXISTS idx_plan_instances_name ON plan_instances(name); `; // ───────────────────────────────────────────────────────────────────────────── // Open // ───────────────────────────────────────────────────────────────────────────── /** * Open (creating if absent) a ledger database at `dbPath` and return the live {@link Database}. * Enables WAL, then reconciles `PRAGMA user_version`: * - `0` (fresh) → create the schema and stamp {@link SCHEMA_VERSION}. * - `SCHEMA_VERSION` → an existing store of this build; used as-is (schema is `IF NOT EXISTS`). * - `> SCHEMA_VERSION` → **refuse loudly**: a future schema is never silently misread (ADR-0006). * * `dbPath` defaults to nothing here — the path is a caller concern; pass `":memory:"` for a * throwaway store. */ export function openLedger(dbPath: string): Database { const db = new Database(dbPath); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); const version = currentUserVersion(db); if (version > SCHEMA_VERSION) { db.close(); throw new Error( `ledger: database at ${JSON.stringify(dbPath)} is schema version ${version}, but this build only understands ${SCHEMA_VERSION} — refusing to open a future schema (fail-closed, ADR-0006)`, ); } if (version === 0) { db.exec(SCHEMA_SQL); db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`); } return db; } /** Read `PRAGMA user_version` as a number (a fresh database reports `0`). */ function currentUserVersion(db: Database): number { const row = db.query<{ user_version: number }, []>("PRAGMA user_version").get(); return row?.user_version ?? 0; } // ───────────────────────────────────────────────────────────────────────────── // Record // ───────────────────────────────────────────────────────────────────────────── /** * Record one graded {@link EpisodeReport} into the ledger (one transaction, `INSERT OR REPLACE` * throughout so a re-record of the same run is a clean idempotent upsert — no duplicate rows). Pure * mapping: it reads the report shape (session / totals / plans / orders) and derives the run row, * per-plan lineage instances, and the lean per-plan × role order summary. Returns the run identity. */ export function record(db: Database, report: EpisodeReport, opts: RecordOptions): RecordResult { const plansSha256 = plansSha256Of(opts.plansText); const busSha256 = report.session.bus_sha256; const fillModel = report.session.fill_model; const runId = deriveRunId(busSha256, plansSha256, fillModel, opts.rUsd); // Per-plan aggregation over the report's orders, keyed by the lineage name. const perPlan = aggregatePlans(report); const tx = db.transaction(() => { db.query( `INSERT OR REPLACE INTO runs (run_id, recorded_at, session_date, mode, instruments, bus_sha256, plans_sha256, fill_model, calibrated, determinism_hash, r_usd, realized_floor_usd, expected_usd, premium_spent, report_path, bus_events) VALUES ($run_id, $recorded_at, $session_date, $mode, $instruments, $bus_sha256, $plans_sha256, $fill_model, $calibrated, $determinism_hash, $r_usd, $realized_floor_usd, $expected_usd, $premium_spent, $report_path, $bus_events)`, ).run({ $run_id: runId, $recorded_at: opts.now, $session_date: report.session.date, $mode: report.session.mode, $instruments: report.session.instruments.join(","), $bus_sha256: busSha256, $plans_sha256: plansSha256, $fill_model: fillModel, $calibrated: opts.calibrated ? 1 : 0, $determinism_hash: report.session.determinism_hash, $r_usd: opts.rUsd, $realized_floor_usd: report.totals.realized_floor_usd, $expected_usd: report.totals.expected_usd, $premium_spent: report.totals.premium_spent, $report_path: opts.reportPath ?? null, $bus_events: report.session.bus_events, }); // Re-recording an existing run must not leave stale child rows behind (a plan/role that // vanished between records). Clear this run's children, then re-insert from the report. db.query("DELETE FROM plan_instances WHERE run_id = $run_id").run({ $run_id: runId }); db.query("DELETE FROM orders_summary WHERE run_id = $run_id").run({ $run_id: runId }); const insertPlan = db.query( `INSERT INTO plan_instances (run_id, name, canonical_sha256, final_state, outcome, fired, orders, fills, realized_usd, expected_usd) VALUES ($run_id, $name, $canonical_sha256, $final_state, $outcome, $fired, $orders, $fills, $realized_usd, $expected_usd)`, ); const insertRole = db.query( `INSERT INTO orders_summary (run_id, name, role, count, esc_stages_max, reprice_count) VALUES ($run_id, $name, $role, $count, $esc_stages_max, $reprice_count)`, ); for (const p of perPlan) { insertPlan.run({ $run_id: runId, $name: p.name, // Per-plan canonical text is not surfaced on the Report yet; the document-canonical // plans hash stands in (one authored document → one canonical hash per run). $canonical_sha256: plansSha256, $final_state: p.finalState, $outcome: p.outcome ?? null, $fired: p.fired ? 1 : 0, $orders: p.orders, $fills: p.fills, $realized_usd: p.realizedUsd, $expected_usd: p.expectedUsd, }); for (const r of p.roles) { insertRole.run({ $run_id: runId, $name: p.name, $role: r.role, $count: r.count, $esc_stages_max: r.escStagesMax, // Reprice is a session-wide tally on the Report, not per-plan; stamp the session value // on each plan/role row so the summary is self-contained without a runs join. $reprice_count: report.session.reprice_count, }); } } }); tx(); return { runId, plansSha256 }; } // ───────────────────────────────────────────────────────────────────────────── // Query API (row/filter types live in ./core.ts and are re-exported at the top of this module) // ───────────────────────────────────────────────────────────────────────────── /** List runs recent-first (by injected `recorded_at`), narrowed by any of the given filters. Every * predicate is a bound parameter — no value is ever interpolated into SQL. */ export function listRuns(db: Database, filter: ListRunsFilter = {}): RunRow[] { const where: string[] = []; const params: Record<string, string> = {}; if (filter.sessionDate !== undefined) { where.push("session_date = $session_date"); params.$session_date = filter.sessionDate; } if (filter.mode !== undefined) { where.push("mode = $mode"); params.$mode = filter.mode; } if (filter.fillModel !== undefined) { where.push("fill_model = $fill_model"); params.$fill_model = filter.fillModel; } if (filter.lineage !== undefined) { where.push("run_id IN (SELECT run_id FROM plan_instances WHERE name = $lineage)"); params.$lineage = filter.lineage; } const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; return db .query<RunRow, [Record<string, string>]>( `SELECT * FROM runs ${clause} ORDER BY recorded_at DESC, run_id ASC`, ) .all(params); } /** Fetch one run with its plan instances, or `undefined` if the run is unknown. */ export function getRun(db: Database, runId: string): RunWithPlans | undefined { const run = db .query<RunRow, [{ $run_id: string }]>("SELECT * FROM runs WHERE run_id = $run_id") .get({ $run_id: runId }); if (run === null) return undefined; const plans = db .query<PlanInstanceRow, [{ $run_id: string }]>( "SELECT * FROM plan_instances WHERE run_id = $run_id ORDER BY name ASC", ) .all({ $run_id: runId }); return { run, plans }; } /** The ADR-0006 lineage view: every instance of a plan `name` across runs, recent-first — the same * strategy authored many times, laid out over time with its run context. */ export function lineage(db: Database, name: string): LineageInstance[] { return db .query<LineageInstance, [{ $name: string }]>( `SELECT pi.run_id, pi.name, pi.canonical_sha256, pi.final_state, pi.outcome, pi.fired, pi.orders, pi.fills, pi.realized_usd, pi.expected_usd, r.recorded_at, r.session_date, r.mode, r.fill_model FROM plan_instances pi JOIN runs r ON r.run_id = pi.run_id WHERE pi.name = $name ORDER BY r.recorded_at DESC, pi.run_id ASC`, ) .all({ $name: name }); } /** Per-lineage aggregate across all recorded instances, ordered by `realized_sum` desc — the * standing leaderboard over the lineage key (ADR-0006). `runs` counts distinct runs; sums/averages * are over the matching plan instances. */ export function leaderboard(db: Database, filter: LeaderboardFilter = {}): LeaderboardRow[] { const where: string[] = []; const params: Record<string, string | number> = {}; if (filter.mode !== undefined) { where.push("r.mode = $mode"); params.$mode = filter.mode; } if (filter.since !== undefined) { where.push("r.recorded_at >= $since"); params.$since = filter.since; } const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; return db .query<LeaderboardRow, [Record<string, string | number>]>( `SELECT pi.name AS name, COUNT(DISTINCT pi.run_id) AS runs, SUM(pi.fired) AS fired, SUM(pi.fills) AS fills, SUM(pi.realized_usd) AS realized_sum, SUM(pi.expected_usd) AS expected_sum, AVG(pi.realized_usd) AS realized_avg, AVG(pi.expected_usd) AS expected_avg FROM plan_instances pi JOIN runs r ON r.run_id = pi.run_id ${clause} GROUP BY pi.name ORDER BY realized_sum DESC, pi.name ASC`, ) .all(params); }