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.

273 lines (244 loc) 13.3 kB
/** * # sdk/local — the LOCAL transport: the in-process djm.4 controller + djm.8 catalog + engine projection * * The HEAVY transport (kestrel-djm.5). `localTransport()` binds, IN-PROCESS: the djm.8 public Session * catalog (catalog discovery + subject resolution), the engine parser (validation), the djm.4 incremental * controller (the real Session progression — Bus → Blotter → graded bus), and a deterministic projection of * the rich engine {@link EngineBlotter} into the djm.2 PROTOCOL {@link Blotter} + portable {@link GradeResult} * this SDK returns. It NEVER forks Session progression — a {@link LocalSession} is a thin wrapper over the * djm.4 {@link SessionController}; finalize projects, registers, and seals. * * ── DESIGN FORK LOGGED (hard rules forbid .beads writes) — the engine→protocol Blotter PROJECTION ── * djm.2's {@link Blotter} is the GENERIC public view ("generic instruments only"); the engine Blotter is the * rich internal record (per-order lifecycle, fill support, settle-mark provenance). The MINIMAL, HONEST, * DETERMINISTIC projection this SDK adopts ({@link toProtocolBlotter}): carry the deterministic Session * identity and the settlement (realized floor + asset + settle time, all fully determined by the graded * bus), and leave the generic `orders`/`fills`/`positions` arrays EMPTY — the engine `OrderRecord` lacks a * contract expiry and generic per-order ISO timestamps, and the protocol option {@link import("../protocol/index.ts").Instrument} * REQUIRES an expiry, so fabricating one would violate the never-fabricate non-negotiable (AGENTS.md). The * full per-order detail stays on the rich engine Blotter (the LOCAL path exposes it); the managed backend's * own richer server-side projection is out of djm.5's scope. Both transports agree BY CONSTRUCTION: the HTTP * transport replays this SAME projection's output (baked from a LOCAL run — see scripts/sdk/bake-catalog-records.ts). * * This is the DEFAULT, heaviest surface — it pulls the engine aggregate; a remote-only consumer imports * `remote.ts` (light) instead (the djm.3 light/heavy seam, pinned by tests/package.boundary.test.ts). */ import type { Blotter as EngineBlotter } from "../blotter/index.ts"; // The pure engine→protocol Blotter projection now lives in a LIGHT blotter module (kestrel-b0li) so the // node-only `certify` verb can grade a re-projection without dragging `lang`/`session` in; re-exported // here so existing `from "../sdk"` importers keep working unchanged. import { toProtocolBlotter } from "../blotter/protocol-view.ts"; export { toProtocolBlotter } from "../blotter/protocol-view.ts"; import { KestrelParseError, parse } from "../lang/index.ts"; import { grade, type GradeResult } from "../protocol/index.ts"; import type { SessionId } from "../protocol/session.ts"; import { BACKTEST_CONFIG, openSession as openController, type FillModelName, type SessionController, type SessionFinalized, } from "../session/index.ts"; import { SESSION_CATALOG, catalogListing, loadSessionCatalog, resolveTapeEvents, } from "../catalog/session-catalog.ts"; import type { ArtifactResult, AuthoredResponse, BoundResponse, Delivery, GradeRequest, KestrelSession, OperationRef, OperationResumption, SdkFinalized, SessionResponse, SessionTranscript, Transport, ValidateOutcome, Gated, GradeOutcome, } from "./types.ts"; import { SDK_VERSION } from "./facade.ts"; /** A typed, fail-closed refusal from the LOCAL transport (agents match on `code`, not prose). Never a * `RangeError` / silent load — an unknown subject/artifact is a stated, typed protocol refusal. */ export class SdkLocalError extends Error { override readonly name = "SdkLocalError"; readonly code: string; constructor(code: string, message: string) { super(message); this.code = code; } } // ───────────────────────────────────────────────────────────────────────────── // The engine → protocol projection (pure, deterministic — the ONE source of truth both transports agree on) // ───────────────────────────────────────────────────────────────────────────── /** The conformance root of a run = the graded-bus sha256 (`SimRunId`), a pure function of the graded bus. */ export function conformanceRootOf(b: EngineBlotter): string { return b.session.bus.sha256; } /** * Derive the portable {@link GradeResult} for a finalized run — the SAME OSS {@link grade} Judge an outsider * runs, applied to the projected protocol Blotter (kestrel-h314 D4: `grade` REPLACES the old bespoke * `gradeOf`). Both metric families come out of the certified evidence carried on the wire; grading one * Blotter yields a GradeResult whose `subjectSessionId` is that session's id. Deterministic; both transports * agree by construction. */ export function gradeOf(sessionId: SessionId, b: EngineBlotter): GradeResult { return grade([toProtocolBlotter(sessionId, b)]); } /** The content-addressed artifact handles a finalized Session delivers: the graded-bus root + the Blotter * artifact id (the Blotter's `sessionId` IS its artifact id). Deterministic; identical on both transports. */ export function artifactsOf(sessionId: SessionId, conformanceRoot: string): readonly string[] { return [`sha256:${conformanceRoot}`, `blotter:${sessionId}`]; } // ───────────────────────────────────────────────────────────────────────────── // The transport registry — finalized runs cached for grade / artifact / operation resume // ───────────────────────────────────────────────────────────────────────────── interface FinalizedRecord { readonly finalized: SdkFinalized; readonly grade: GradeResult; readonly engineBlotter: EngineBlotter; } type Register = (id: SessionId, record: FinalizedRecord) => void; // ───────────────────────────────────────────────────────────────────────────── // LocalSession — a thin wrapper over the djm.4 controller (never a forked progression) // ───────────────────────────────────────────────────────────────────────────── class LocalSession implements KestrelSession { readonly sessionId: SessionId; constructor( private readonly ctrl: SessionController, private readonly register: Register, ) { this.sessionId = ctrl.sessionId; } start(): Promise<Delivery> { return this.ctrl.start(); } advance(response: AuthoredResponse): Promise<SessionResponse> { return this.ctrl.advance(response); } revise(response: AuthoredResponse): Promise<SessionResponse> { return this.ctrl.revise(response); } submit(response: BoundResponse): Promise<SessionResponse> { return this.ctrl.submit(response); } async resume(after?: Parameters<SessionController["resume"]>[0]): Promise<SessionTranscript> { return this.ctrl.resume(after); } async finalize(): Promise<SdkFinalized> { const fin: SessionFinalized = await this.ctrl.finalize(); const conformanceRoot = conformanceRootOf(fin.blotter); const blotter = toProtocolBlotter(fin.sessionId, fin.blotter); const artifacts = artifactsOf(fin.sessionId, conformanceRoot); const finalized: SdkFinalized = { blotter, sessionId: fin.sessionId, tipHash: fin.tipHash, conformanceRoot, artifacts, }; const grade = gradeOf(fin.sessionId, fin.blotter); this.register(fin.sessionId, { finalized, grade, engineBlotter: fin.blotter }); return finalized; } } // ───────────────────────────────────────────────────────────────────────────── // The LOCAL transport factory // ───────────────────────────────────────────────────────────────────────────── /** * Build the LOCAL transport — the in-process djm.4 controller + djm.8 catalog + engine projection. The * caller wraps it with `createSdk(localTransport())`. It never gates (the free in-process path invents no * 402); an unknown subject / artifact / operation is a typed {@link SdkLocalError} (fail-closed). */ export function localTransport(): Transport { const registry = new Map<string, FinalizedRecord>(); const register: Register = (id, record) => registry.set(id, record); return { kind: "local", version: `${SDK_VERSION}+local`, async catalog() { // The catalog DISCOVERY projection as a canonical CatalogPage (kestrel-adge): the byte-identical // CatalogListing[] (kestrel-dnxq) the offline in-process sample lists, wrapped in `{ entries }`. The // LOCAL transport is a self-hosted mirror with NO price sheet, so it serves NO `pricing` block — // `pricing` is absent (a bare page), which is exactly the backward-compatible "today's listing" case. // The reproducibility-complete CatalogEntry surface resolves at openSession, not here. return { entries: catalogListing() }; }, async validate(document) { try { parse(document); return { ok: true, diagnostics: [] } satisfies ValidateOutcome; } catch (e) { if (e instanceof KestrelParseError) { return { ok: false, diagnostics: [e.message] } satisfies ValidateOutcome; } throw e; } }, // The optional `document` (the customer strategy the HTTP transport threads to POST /sim, kestrel-88u2) // is accepted for signature parity but IGNORED here: the LOCAL catalog subject already carries its pinned // recipe's strategy (`entry.recipe.document`), so there is no author-no-strategy fence to satisfy. async openSession(subject, _document): Promise<Gated<KestrelSession>> { if (subject.kind !== "catalog") { throw new SdkLocalError( "unsupported-subject", "the LOCAL transport runs catalog subjects; a raw dataset needs the managed backend (HTTP transport)", ); } const entries = loadSessionCatalog(SESSION_CATALOG); const entry = entries.find((e) => e.id === subject.id); if (entry === undefined) { throw new SdkLocalError("unknown-subject", `no catalog entry ${JSON.stringify(subject.id)}`); } const events = resolveTapeEvents(entry); const ctrl = openController({ events, config: BACKTEST_CONFIG, wakes: entry.recipe.wakes, fillModel: entry.entry.permittedFillModel as FillModelName, rUsd: entry.recipe.rUsd, fairTauYears: entry.recipe.fairTauYears, }); return { gated: false, value: new LocalSession(ctrl, register) }; }, async grade(request: GradeRequest): Promise<Gated<GradeOutcome>> { const id = request.blotters[0]; if (id === undefined) throw new SdkLocalError("empty-grade", "grade requires at least one Blotter id"); const record = registry.get(id); if (record === undefined) { throw new SdkLocalError("unknown-artifact", `no finalized Blotter for ${JSON.stringify(id)} — finalize the Session first`); } return { gated: false, value: record.grade }; }, async artifact(ref: string): Promise<ArtifactResult> { for (const record of registry.values()) { if (record.finalized.artifacts.includes(ref)) { const isBlotter = ref.startsWith("blotter:"); return { ref, kind: isBlotter ? "blotter" : "conformance-root", value: isBlotter ? record.finalized.blotter : record.finalized.conformanceRoot, }; } } throw new SdkLocalError("unknown-artifact", `artifact ${JSON.stringify(ref)} not found`); }, async resumeOperation(ref: OperationRef): Promise<OperationResumption> { // The LOCAL path is un-metered: an Operation id maps to a finalized Session id (no control plane). const record = registry.get(ref.operationId); if (record === undefined) { throw new SdkLocalError("unknown-operation", `no finalized Operation ${JSON.stringify(ref.operationId)}`); } return { operationId: ref.operationId, cursor: null, payload: { blotter: record.finalized.blotter, grade: record.grade, artifacts: record.finalized.artifacts }, }; }, }; }