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