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