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