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.

242 lines (220 loc) 10.9 kB
/** * # ledger/memory — the Worker-portable in-memory run / plan / lineage store (kestrel-alw.17) * * The {@link ./index.ts durable ledger} is backed by `bun:sqlite`, a **Bun-only** module that does not * exist inside a Cloudflare Worker isolate — the very environment the grade path must also run in * (ADR-0006, EPIC kestrel-markets-alw). This module is the Worker-portable twin: the exact same * record / query surface, but over plain in-memory arrays and Maps, importing NOTHING Bun-only (it * shares the storage-agnostic {@link ./core.ts core} — the run-identity hash, the report→rows * reduction, and the row types — with the sqlite backend, so both agree byte-for-byte on WHAT a run * is). No persistence: the store lives for the life of the isolate; a Durable Object binds durability * separately (kestrel-alw.18). * * ## The semantics it holds (identical to the sqlite backend) * - **Idempotent upsert.** Re-recording the same run (same `run_id`) replaces its row and rebuilds its * child rows — never a duplicate (RUNTIME §0 / ADR-0007). * - **Injected clock.** `recorded_at` is the caller's `now`; nothing reads a wall clock here. * - **Deterministic ordering.** `listRuns`/`lineage` are recent-first (`recorded_at` desc, then * `run_id`/`name` asc); `leaderboard` is `realized_sum` desc then `name` asc — matching the SQL * `ORDER BY` clauses exactly. */ import { aggregatePlans, compareCodePoint, deriveRunId, plansSha256Of, type LeaderboardFilter, type LeaderboardRow, type LineageInstance, type ListRunsFilter, type OrderSummaryRow, type PlanInstanceRow, type RecordOptions, type RecordResult, type RunRow, type RunWithPlans, } from "./core.ts"; import type { EpisodeReport } from "../session/sim.ts"; // ───────────────────────────────────────────────────────────────────────────── // The store // ───────────────────────────────────────────────────────────────────────────── /** * An in-memory ledger store — the Worker-portable stand-in for a `bun:sqlite` {@link Database}. Opaque * to callers; pass it to {@link record} / {@link listRuns} / {@link getRun} / {@link lineage} / * {@link leaderboard}, mirroring the durable backend's function-plus-handle shape so a call site can * swap backends by import alone. */ export interface MemoryLedger { readonly runs: Map<string, RunRow>; readonly planInstances: PlanInstanceRow[]; readonly ordersSummary: OrderSummaryRow[]; } /** Open a fresh, empty in-memory ledger. Nothing to close (no file, no handle) — GC reclaims it. */ export function openMemoryLedger(): MemoryLedger { return { runs: new Map(), planInstances: [], ordersSummary: [] }; } // ───────────────────────────────────────────────────────────────────────────── // Record // ───────────────────────────────────────────────────────────────────────────── /** * Record one graded {@link EpisodeReport} into the in-memory store, idempotently keyed by the derived * `run_id` (re-recording the same run replaces its row and rebuilds its child rows — never a duplicate). * Byte-for-byte identical mapping to the sqlite backend's {@link ./index.ts record} (it shares the same * {@link ./core.ts core} reduction + run-identity hash). Returns the run identity. */ export function record(store: MemoryLedger, 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); const perPlan = aggregatePlans(report); // Idempotent upsert: replace the run row and clear this run's child rows before re-inserting, so a // plan/role that vanished between records leaves no stale row behind. store.runs.set(runId, { 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, }); removeInPlace(store.planInstances, (r) => r.run_id === runId); removeInPlace(store.ordersSummary, (r) => r.run_id === runId); for (const p of perPlan) { store.planInstances.push({ 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) — matching the sqlite backend. 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) { store.ordersSummary.push({ 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 (matching the sqlite backend). reprice_count: report.session.reprice_count, }); } } return { runId, plansSha256 }; } /** Remove every element matching `pred` from `arr`, in place. */ function removeInPlace<T>(arr: T[], pred: (x: T) => boolean): void { for (let i = arr.length - 1; i >= 0; i--) { if (pred(arr[i]!)) arr.splice(i, 1); } } // ───────────────────────────────────────────────────────────────────────────── // Query API — the same shapes + ordering as the sqlite backend // ───────────────────────────────────────────────────────────────────────────── /** Runs recent-first (`recorded_at` desc, then `run_id` asc), narrowed by any of the given filters * (`lineage` matches runs that contain a plan instance of that name). */ export function listRuns(store: MemoryLedger, filter: ListRunsFilter = {}): RunRow[] { const lineageRunIds = filter.lineage === undefined ? undefined : new Set(store.planInstances.filter((p) => p.name === filter.lineage).map((p) => p.run_id)); return [...store.runs.values()] .filter((r) => { if (filter.sessionDate !== undefined && r.session_date !== filter.sessionDate) return false; if (filter.mode !== undefined && r.mode !== filter.mode) return false; if (filter.fillModel !== undefined && r.fill_model !== filter.fillModel) return false; if (lineageRunIds !== undefined && !lineageRunIds.has(r.run_id)) return false; return true; }) .sort(byRecentThen((r) => r.run_id)); } /** Fetch one run with its plan instances (by `name` asc), or `undefined` if the run is unknown. */ export function getRun(store: MemoryLedger, runId: string): RunWithPlans | undefined { const run = store.runs.get(runId); if (run === undefined) return undefined; const plans = store.planInstances .filter((p) => p.run_id === runId) .sort((a, b) => compareCodePoint(a.name, b.name)); return { run, plans }; } /** Every instance of a plan `name` across runs, recent-first, with run context (ADR-0006 lineage). */ export function lineage(store: MemoryLedger, name: string): LineageInstance[] { return store.planInstances .filter((p) => p.name === name) .map((p) => { const run = store.runs.get(p.run_id)!; return { ...p, recorded_at: run.recorded_at, session_date: run.session_date, mode: run.mode, fill_model: run.fill_model, }; }) .sort(byRecentThen((i) => i.run_id)); } /** Per-lineage aggregate across all recorded instances, `realized_sum` desc then `name` asc — the * standing leaderboard over the lineage key (ADR-0006). `runs` counts distinct runs; sums/averages are * over the matching plan instances (matching the SQL `AVG`, which divides by row count, not run count). */ export function leaderboard(store: MemoryLedger, filter: LeaderboardFilter = {}): LeaderboardRow[] { interface Acc { name: string; runIds: Set<string>; rows: number; fired: number; fills: number; realized_sum: number; expected_sum: number; } const byName = new Map<string, Acc>(); for (const p of store.planInstances) { const run = store.runs.get(p.run_id)!; if (filter.mode !== undefined && run.mode !== filter.mode) continue; if (filter.since !== undefined && run.recorded_at < filter.since) continue; let acc = byName.get(p.name); if (acc === undefined) { acc = { name: p.name, runIds: new Set(), rows: 0, fired: 0, fills: 0, realized_sum: 0, expected_sum: 0 }; byName.set(p.name, acc); } acc.runIds.add(p.run_id); acc.rows += 1; acc.fired += p.fired; acc.fills += p.fills; acc.realized_sum += p.realized_usd; acc.expected_sum += p.expected_usd; } return [...byName.values()] .map((a) => ({ name: a.name, runs: a.runIds.size, fired: a.fired, fills: a.fills, realized_sum: a.realized_sum, expected_sum: a.expected_sum, realized_avg: a.realized_sum / a.rows, expected_avg: a.expected_sum / a.rows, })) .sort((x, y) => (y.realized_sum - x.realized_sum) || compareCodePoint(x.name, y.name)); } /** Recent-first comparator: `recorded_at` desc, then a caller-chosen ascending tiebreak (`run_id`). */ function byRecentThen<T extends { recorded_at: number }>(key: (x: T) => string): (a: T, b: T) => number { return (a, b) => b.recorded_at - a.recorded_at || compareCodePoint(key(a), key(b)); }