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.

240 lines (216 loc) 11.1 kB
/** * # ledger/core — the storage-agnostic heart of the run / plan / lineage ledger (ADR-0006, ADR-0007) * * The ledger has TWO backends that must agree byte-for-byte on WHAT a run's identity is and HOW a * {@link EpisodeReport} reduces to rows: * - the durable {@link ./index.ts `bun:sqlite`} store the CLI auto-records into (`data/kestrel.db`), and * - the {@link ./memory.ts in-memory} store that runs inside a Cloudflare Worker isolate, where * `bun:sqlite` does not exist (ADR-0006, EPIC kestrel-markets-alw; kestrel-alw.17). * * This module is that shared heart: the row/query TYPES both backends return, the deterministic * {@link deriveRunId run-identity hash}, and the pure {@link aggregatePlans report → per-plan lineage} * reduction. It imports NOTHING Bun-only (its only digest is the portable {@link ../crypto/sha256.ts * sha256}), so it — and any backend built on it — loads and runs in a Worker isolate. No wall clock, * no RNG (RUNTIME §0). */ import { sha256 } from "../crypto/sha256.ts"; import type { PlanTrace, EpisodeReport } from "../session/sim.ts"; import type { OrderRole } from "../engine/index.ts"; // ───────────────────────────────────────────────────────────────────────────── // Row + query types (identical across the sqlite and in-memory backends) // ───────────────────────────────────────────────────────────────────────────── /** A run row as stored (the `runs` table, typed). */ export interface RunRow { readonly run_id: string; readonly recorded_at: number; readonly session_date: string; readonly mode: string; readonly instruments: string; readonly bus_sha256: string; readonly plans_sha256: string; readonly fill_model: string; readonly calibrated: number; readonly determinism_hash: string; readonly r_usd: number; readonly realized_floor_usd: number; readonly expected_usd: number; readonly premium_spent: number; readonly report_path: string | null; readonly bus_events: number; } /** A plan-instance row as stored (the `plan_instances` table, typed). */ export interface PlanInstanceRow { readonly run_id: string; readonly name: string; readonly canonical_sha256: string; readonly final_state: string; readonly outcome: string | null; readonly fired: number; readonly orders: number; readonly fills: number; readonly realized_usd: number; readonly expected_usd: number; } /** A per-plan × role order-summary row (the `orders_summary` table, typed). */ export interface OrderSummaryRow { readonly run_id: string; readonly name: string; readonly role: string; readonly count: number; readonly esc_stages_max: number; readonly reprice_count: number; } /** A run joined with its plan instances (the `getRun` shape). */ export interface RunWithPlans { readonly run: RunRow; readonly plans: readonly PlanInstanceRow[]; } /** One plan instance in a lineage, carrying the run context needed to place it in time. */ export interface LineageInstance extends PlanInstanceRow { readonly recorded_at: number; readonly session_date: string; readonly mode: string; readonly fill_model: string; } /** A per-lineage aggregate row (the `leaderboard` shape). */ export interface LeaderboardRow { readonly name: string; readonly runs: number; readonly fired: number; readonly fills: number; readonly realized_sum: number; readonly expected_sum: number; readonly realized_avg: number; readonly expected_avg: number; } /** Filters for `listRuns`. All optional; `lineage` matches runs that contain a plan instance of that name. */ export interface ListRunsFilter { readonly sessionDate?: string; readonly mode?: string; readonly fillModel?: string; readonly lineage?: string; } /** Filters for `leaderboard`: optional `mode` and an inclusive `since` lower bound on `recorded_at`. */ export interface LeaderboardFilter { readonly mode?: string; readonly since?: number; } /** Options for `record`. `plansText` is the **canonical** plans text (the caller canonicalizes; the * ledger only hashes it). `rUsd` is a run input needed for the run-identity hash + the `r_usd` column. * `now` is the caller-injected `recorded_at` (never wall-clock). `calibrated` marks whether the run's * fill model was calibrated (default `false`). */ export interface RecordOptions { readonly plansText: string; readonly reportPath?: string; readonly now: number; readonly rUsd: number; readonly calibrated?: boolean; } /** The identity + derived hashes computed while recording a run. */ export interface RecordResult { readonly runId: string; readonly plansSha256: string; } // ───────────────────────────────────────────────────────────────────────────── // Run identity (ADR-0007) — a pure function of the four inputs, shared by both backends // ───────────────────────────────────────────────────────────────────────────── /** * The deterministic run identity (ADR-0007): a pure function of the four inputs that fix what was * graded and how. Fields are newline-delimited so distinct field boundaries can never collide, and * `r_usd` is stringified stably. Same inputs → same `run_id` → idempotent upsert. Uses the portable * {@link ../crypto/sha256.ts sha256} so the id is byte-identical under Bun and inside a Worker isolate. */ export function deriveRunId(busSha256: string, plansSha256: string, fillModel: string, rUsd: number): string { return sha256(`${busSha256}\n${plansSha256}\n${fillModel}\n${String(rUsd)}`); } /** sha256 of the canonical plans text — the `plans_sha256` column + a run-identity input. */ export function plansSha256Of(plansText: string): string { return sha256(plansText); } /** * The ONE ranking tiebreak comparator shared by both ledger backends (kestrel-uary). Compares strings * by code unit (`a < b`), matching SQLite's default **BINARY** collation on a `TEXT` column * (`ORDER BY name ASC`) — for the ASCII / BMP plan names the ledger orders, code-unit order equals * UTF-8 byte order equals code-point order, so the in-isolate in-memory backend lands the SAME row * order as a `bun:sqlite` recomputation on any host. * * `String.prototype.localeCompare` must NEVER be used on the ledger/report path: it is host-locale * dependent and, for mixed-case / non-ASCII names (e.g. `'a'` vs `'B'`), orders differently from * BINARY — which would make hosted `getRun`/`leaderboard`/`listRuns`/`lineage` order rows differently * from a local SQLite recomputation, breaking the KestrelBench recomputability claim (ADR-0006/0007). */ export function compareCodePoint(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } // ───────────────────────────────────────────────────────────────────────────── // Report → per-plan lineage reduction (pure; shared by both backends) // ───────────────────────────────────────────────────────────────────────────── /** One plan's aggregated lineage instance, derived from the report's plan trace + its orders. */ export interface AggregatedPlan { readonly name: string; readonly finalState: string; readonly outcome: string | undefined; readonly fired: boolean; readonly orders: number; readonly fills: number; readonly realizedUsd: number; readonly expectedUsd: number; readonly roles: readonly { readonly role: OrderRole; readonly count: number; readonly escStagesMax: number }[]; } /** * Pure reduction of a {@link EpisodeReport} into per-plan **lineage** rows (ADR-0006: the plan NAME is the * aggregation key). The report now carries one trace per exact Plan INSTANCE (kestrel-22j.15), so a run that * authored a name across MULTIPLE instances yields multiple same-name traces; the ledger's `plan_instances` * table keys on `(run_id, name)`, so those instances ROLL UP into ONE per-run lineage row here — never two * rows colliding on the primary key. Orders join by name (spanning the name's instances), and the aggregate * `final_state`/`outcome`/`fired` reflect the name's LAST instance in the run (deterministic: report order). * A name with a single instance (the common case) rolls up to exactly the prior one-trace behaviour. */ export function aggregatePlans(report: EpisodeReport): AggregatedPlan[] { const names: string[] = []; const tracesByName = new Map<string, PlanTrace[]>(); for (const t of report.plans) { let g = tracesByName.get(t.plan); if (g === undefined) { g = []; tracesByName.set(t.plan, g); names.push(t.plan); } g.push(t); } return names.map((name) => { const traces = tracesByName.get(name)!; const orders = report.orders.filter((o) => o.plan === name); const fills = orders.filter((o) => o.filled).length; const realizedUsd = orders.reduce((s, o) => s + o.floorPnl, 0); const expectedUsd = orders.reduce((s, o) => s + o.expectedPnl, 0); // The name reached (or passed through) the `fired` state in ANY of its instances, or it produced orders. const lifecycleFired = traces.some((t) => t.lifecycle.some((s) => s.state === "fired" || s.state === "managing")); // The terminal outcome/state of the name's LAST instance in the run (its most recent standing). const lastTrace = traces[traces.length - 1]!; const lastStep = lastTrace.lifecycle[lastTrace.lifecycle.length - 1]; const outcome = lastStep?.outcome; // Lean per-role rollup: count + max esc-stage occupied, by order role (across the name's instances). const roleAgg = new Map<OrderRole, { count: number; escStagesMax: number }>(); for (const o of orders) { const cur = roleAgg.get(o.role) ?? { count: 0, escStagesMax: 0 }; cur.count += 1; cur.escStagesMax = Math.max(cur.escStagesMax, o.esc_stages); roleAgg.set(o.role, cur); } const roles = [...roleAgg.entries()] .map(([role, v]) => ({ role, count: v.count, escStagesMax: v.escStagesMax })) .sort((a, b) => compareCodePoint(a.role, b.role)); return { name, finalState: String(lastTrace.final_state), outcome, fired: lifecycleFired || orders.length > 0, orders: orders.length, fills, realizedUsd, expectedUsd, roles, }; }); }