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.

622 lines (564 loc) 25.2 kB
/** * # frame/render-json — the `json` Rendering: a THIRD format adapter at the one-walk seam (ADR-0052 §2) * * The `json` Rendering materializes a {@link ./types.ts Kernel}/Frame into **typed, structural JSON** * carrying every Field WHOLE — the machine-legible twin of the canonical text screen * ({@link ./render.ts}) and the token-optimal ARM ({@link ./render-arm.ts}). Like both text adapters, * the kernel goes through the ONE cockpit-kernel walk ({@link ./kernel-walk.ts walkKernel}): the * {@link JsonKernelSink} is the third {@link ./kernel-walk.ts KernelSink}, visiting the SAME 8 * fixed-order sections in the SAME order, so a new/reordered kernel section lands in json from the * single edit in `kernel-walk.ts`. This module NEVER touches canonical/ARM bytes — the renderer epoch * corpus (canonical text + both ARMs) is unchanged; the json Rendering's byte contract is its own * golden fixtures, not the epoch. * * ## The invariants this adapter lives inside (ADR-0052 §2, ADR-0009 — not up for grading) * - **Fields cross WHOLE.** A {@link ./types.ts Field} (a predictor/regime {@link ./types.ts Claim}) * is emitted as `{ value, attribution, source?, modelVer?, confidence?, asOfSeq? }` — a `MODEL` * Field keeps its receipt (`source` + `modelVer`) and `confidence`; an `OBS`/`CALC` value crosses as * its raw typed value (it carries no receipt). Provenance is never invented: a raw kernel number * (a health rate, a strike, an envelope) is NOT dressed up with a fabricated attribution. * - **null Fields cross as explicit UNKNOWN.** An absent/`null`/non-finite value is carried as the * explicit {@link UNKNOWN} marker — NEVER omitted, NEVER defaulted to `0`/`false`. This mirrors the * canonical codebook (ADR-0041 §1: the `UNKNOWN` marker, never a bare `?`/omission); the whole * point is that a reader can tell "unknown" from "absent" from "zero". * - **Invents no value.** Every number/string is a field on the input; there is no layout to compute * (json is structural), so a Rendering here never derives a value canonical draws (an axis bound, a * column) — it carries the raw Fields, from which a consumer (the honest chart, ADR-0052 §3) * computes its own view. * - **Fail-closed honesty.** A dishonest `MODEL` claim (non-MODEL, or missing its receipt/confidence) * is REFUSED at the seam ({@link ./types.ts assertClaimHonest}) exactly as the canonical adapter * refuses it — a degraded claim is never serialized as if honest. * - **Deterministic + byte-stable.** Serialization runs through the ONE canonical JSON module * ({@link ../canonical/json.ts canonicalizeJson}: recursively ordered keys, kept array order, no * trailing newline) — the SAME bytes the Bus/Blotter content-address, so the same Frame always * yields byte-identical json. No wall clock, no RNG (RUNTIME §0). */ import { canonicalizeJson } from "../canonical/json.ts"; import { assertBriefHashHonest } from "./brief.ts"; import { assertClaimHonest } from "./types.ts"; import type { Brief, BriefingInput, ChainRow, Claim, EngineAction, Field, InstrumentSpec, Kernel, LevelSet, Mandate, OwnerAct, Position, RestingOrder, SizingHeadroom, TapeRow, VehicleHealth, WakeDeltaInput, WakeRef, } from "./types.ts"; import { bucketEngineLog, walkKernel, type BudgetEnvelopeArg, type KernelSink, } from "./kernel-walk.ts"; // ───────────────────────────────────────────────────────────────────────────── // Explicit UNKNOWN — a null Field crosses the wire as an explicit marker, never omitted/defaulted // ───────────────────────────────────────────────────────────────────────────── /** The explicit unknown marker (ADR-0052 §2 / the ADR-0041 §1 codebook). An absent/`null`/non-finite * value crosses the json wire as this literal — NEVER an omitted key (which reads as "absent") and * NEVER a defaulted `0`/`false` (which reads as a real value). The one honest reading of a null Field. */ export const UNKNOWN = "UNKNOWN" as const; export type JsonUnknown = typeof UNKNOWN; /** A value that may be genuinely unavailable: the value, or the explicit {@link UNKNOWN} marker. */ export type Maybe<T> = T | JsonUnknown; /** A number Field, or explicit UNKNOWN when absent/`null`/non-finite (never `0`, never omitted). */ function maybeNum(x: number | null | undefined): Maybe<number> { return x === null || x === undefined || !Number.isFinite(x) ? UNKNOWN : x; } /** A string Field, or explicit UNKNOWN when absent/empty (never omitted). */ function maybeStr(s: string | null | undefined): Maybe<string> { return s === null || s === undefined || s === "" ? UNKNOWN : s; } // ───────────────────────────────────────────────────────────────────────────── // The typed json Rendering shapes (Fields carried WHOLE) // ───────────────────────────────────────────────────────────────────────────── /** A {@link ./types.ts Field} carried WHOLE — value + attribution, plus the `MODEL` receipt * (`source` + `modelVer`) and `confidence` when present. A raw `OBS`/`CALC` value has no receipt. */ export interface FieldJson { readonly value: number | string; readonly attribution: Field["attribution"]; readonly source?: string; readonly modelVer?: string; readonly confidence?: number; readonly asOfSeq?: number; } /** A predictor/regime claim carried WHOLE — its type + its honest `MODEL` {@link FieldJson}. */ export interface ClaimJson { readonly claimType: Claim["claimType"]; readonly field: FieldJson; } export interface MandateJson { readonly objective: string; readonly rUsd: Maybe<number>; readonly successCriterion: string; readonly riskRule: string; } export interface BriefJson { readonly text: string; readonly hash: string; readonly version?: string; /** Labelled at the seam, exactly as the text adapters do: the Brief is directional English that can * NEVER enter admission/narrowing (ADR-0026 hard guard) — a consumer must not read it as a constraint. */ readonly channel: "directional; NOT a constraint, NOT an admission input"; } export interface WakeJson { readonly reason: string; readonly severity: WakeRef["severity"]; /** Minutes-to-close, a RELATIVE duration (date-blind); UNKNOWN when absent. Never a wall clock/date. */ readonly deadlineMinToClose: Maybe<number>; } export interface VehicleHealthJson { readonly instrument: string; readonly bidPresentRate: Maybe<number>; readonly twoSided: boolean; readonly staleS: Maybe<number>; readonly dark: boolean; } export interface PositionJson { readonly instrument: string; /** An equity/spot leg carries NEITHER strike NOR right (ADR-0017) — both UNKNOWN; an option carries both. */ readonly strike: Maybe<number>; readonly right: Maybe<Position["right"] & string>; readonly qty: number; readonly basis: Maybe<number>; readonly unrealUsd: Maybe<number>; readonly structure: Maybe<string>; readonly claimOwner: Maybe<string>; readonly plan: Maybe<string>; } export interface RestingJson { readonly ref: string; readonly side: RestingOrder["side"]; readonly instrument: string; readonly strike: Maybe<number>; readonly right: Maybe<RestingOrder["right"] & string>; readonly qty: number; readonly px: Maybe<number>; readonly live: boolean; readonly clamped: boolean; readonly note: Maybe<string>; } export interface BudgetJson { readonly remainingR: Maybe<number>; readonly planEnvelope: Maybe<number>; readonly bookEnvelope: Maybe<number>; readonly ownerEnvelope: Maybe<number>; } export interface SizingJson { readonly instrument: string; readonly unit: SizingHeadroom["unit"]; readonly basisPerUnit: Maybe<number>; readonly maxUnits: Maybe<number>; readonly remainingUsd: Maybe<number>; readonly note?: string; } export interface OwnerActJson { readonly id: string; readonly kind: string; readonly asOfSeq: number; } /** The engine log bucketed exactly as the text adapters bucket it — the four fixed buckets in order * plus a catch-all `other` (absent-not-hidden: a kind outside the closed union is surfaced, never dropped). */ export interface EngineActionJson { readonly id: string; readonly kind: string; readonly asOfSeq: number; readonly reason: Maybe<string>; } export interface EngineLogBucketJson { readonly kind: string; readonly count: number; readonly actions: readonly EngineActionJson[]; } export interface EngineLogJson { readonly buckets: readonly EngineLogBucketJson[]; readonly other: readonly EngineActionJson[]; } /** The cockpit kernel as typed JSON — the same 8 fixed-order sections the text adapters render, every * section ALWAYS present (absent-not-hidden: an empty section is `[]`/explicit UNKNOWN, never omitted). */ export interface KernelJson { readonly frame: "OPEN" | "WAKE"; readonly mandate: MandateJson | null; readonly brief: BriefJson | null; readonly wake: Maybe<WakeJson>; readonly dataHealth: readonly VehicleHealthJson[]; readonly unavailable: readonly string[]; readonly positions: readonly PositionJson[]; readonly resting: readonly RestingJson[]; readonly budget: Maybe<BudgetJson>; readonly sizing: Maybe<SizingJson>; readonly ownerEnvelope: Maybe<number>; readonly ownerActs: readonly OwnerActJson[]; readonly engineLog: EngineLogJson; readonly claims: readonly ClaimJson[]; } // ───────────────────────────────────────────────────────────────────────────── // The json KernelSink — the THIRD adapter over the ONE walk (ADR-0052 §1/§2) // ───────────────────────────────────────────────────────────────────────────── const emptyEngineLog: EngineLogJson = { buckets: [], other: [] }; function fieldJson(f: Field): FieldJson { const out: { value: number | string; attribution: Field["attribution"]; source?: string; modelVer?: string; confidence?: number; asOfSeq?: number; } = { value: f.value as number | string, attribution: f.attribution }; if (f.source !== undefined) out.source = f.source; if (f.modelVer !== undefined) out.modelVer = f.modelVer; if (f.confidence !== undefined) out.confidence = f.confidence; if (f.asOfSeq !== undefined) out.asOfSeq = f.asOfSeq; return out; } function engineActionsJson(items: readonly EngineAction[]): readonly EngineActionJson[] { return items.map((e) => ({ id: e.id, kind: e.kind, asOfSeq: e.asofSeq, reason: maybeStr(e.reason) })); } /** * The json {@link KernelSink} — {@link walkKernel}'s third adapter. It accumulates a typed * {@link KernelJson}: the SAME 8 fixed-order sections, every section ALWAYS assigned (absent-not-hidden), * Fields carried WHOLE, nulls as explicit {@link UNKNOWN}. Refuses a dishonest claim (fail-closed), * exactly as the canonical sink does. PURE — no wall clock, no RNG. */ class JsonKernelSink implements KernelSink { private frame: "OPEN" | "WAKE" = "OPEN"; private mandateJson: MandateJson | null = null; private briefJson: BriefJson | null = null; private wakeJson: Maybe<WakeJson> = UNKNOWN; private dataHealthJson: readonly VehicleHealthJson[] = []; private unavailableJson: readonly string[] = []; private positionsJson: readonly PositionJson[] = []; private restingJson: readonly RestingJson[] = []; private budgetJson: Maybe<BudgetJson> = UNKNOWN; private sizingJson: Maybe<SizingJson> = UNKNOWN; private ownerEnvelopeJson: Maybe<number> = UNKNOWN; private ownerActsJson: readonly OwnerActJson[] = []; private engineLogJson: EngineLogJson = emptyEngineLog; private claimsJson: readonly ClaimJson[] = []; sentinel(frameKind: "OPEN" | "WAKE"): void { this.frame = frameKind; } frameLine(): void { // The frame kind rides {@link sentinel}; json folds it into the `frame` key (no separate line). } mandate(m: Mandate): void { this.mandateJson = { objective: m.objective, rUsd: maybeNum(m.rUsd), successCriterion: m.successCriterion, riskRule: m.riskRule, }; } brief(b: Brief): void { // Fail-closed (kestrel-voy9), exactly as the canonical sink does before emitting the hash: a // supplied `{ text, hash }` whose hash disagrees with its text would attribute the WRONG thesis. assertBriefHashHonest(b); const out: BriefJson = { text: b.text, hash: b.hash, channel: "directional; NOT a constraint, NOT an admission input", ...(b.version !== undefined ? { version: b.version } : {}), }; this.briefJson = out; } // §1 WAKE — reason / severity / RELATIVE deadline (never a wall-clock/date). wake(w: WakeRef | null | undefined): void { this.wakeJson = w === undefined || w === null ? UNKNOWN : { reason: w.reason, severity: w.severity, deadlineMinToClose: maybeNum(w.deadline) }; } // §2 DATA-HEALTH per vehicle + the NAMED unavailable capabilities (empty ⇒ [], never omitted). dataHealth(rows: readonly VehicleHealth[], unavailable: readonly string[]): void { this.dataHealthJson = rows.map((h) => ({ instrument: h.instrument, bidPresentRate: maybeNum(h.bidPresentRate), twoSided: h.twoSided === true, staleS: maybeNum(h.staleS), dark: h.dark === true, })); this.unavailableJson = [...unavailable]; } // §3 POSITIONS / INVENTORY-CLAIMS — an equity/spot leg carries neither strike nor right (ADR-0017). positions(positions: readonly Position[]): void { this.positionsJson = positions.map((p) => ({ instrument: p.instrument, strike: maybeNum(p.strike), right: p.right === undefined || p.right === null ? UNKNOWN : p.right, qty: p.qty, basis: maybeNum(p.basis), unrealUsd: maybeNum(p.unrealUsd), structure: maybeStr(p.structure), claimOwner: maybeStr(p.claimOwner), plan: maybeStr(p.plan), })); } // §4 RESTING ORDERS — live AND clamped as independent flags (a live flag never masks a clamp). resting(resting: readonly RestingOrder[]): void { this.restingJson = resting.map((o) => ({ ref: o.ref, side: o.side, instrument: o.instrument, strike: maybeNum(o.strike), right: o.right === undefined || o.right === null ? UNKNOWN : o.right, qty: o.qty, px: maybeNum(o.px), live: o.live === true, clamped: o.clamped === true, note: maybeStr(o.note), })); } // §5 BUDGET / REMAINING-R + the sizing headroom. budget(b: BudgetEnvelopeArg): void { if (b === undefined || b === null) { this.budgetJson = UNKNOWN; this.sizingJson = UNKNOWN; return; } this.budgetJson = { remainingR: maybeNum(b.remainingR), planEnvelope: maybeNum(b.planEnvelope), bookEnvelope: maybeNum(b.bookEnvelope), ownerEnvelope: maybeNum(b.ownerEnvelope), }; this.sizingJson = sizingJson(b.sizing); } // §6 OWNER ENVELOPE + ACTS — the envelope echo reads the same §5 budget. ownerActs(b: BudgetEnvelopeArg, acts: readonly OwnerAct[]): void { this.ownerEnvelopeJson = b !== undefined && b !== null ? maybeNum(b.ownerEnvelope) : UNKNOWN; this.ownerActsJson = acts.map((a) => ({ id: a.id, kind: a.kind, asOfSeq: a.asofSeq })); } // §7 L0/L1 ENGINE LOG — bucketed fired | cancelled | rejected | clamped (+ catch-all `other`). engineLog(elog: readonly EngineAction[]): void { const { buckets, other } = bucketEngineLog(elog); this.engineLogJson = { buckets: buckets.map((b) => ({ kind: b.kind, count: b.items.length, actions: engineActionsJson(b.items) })), other: engineActionsJson(other), }; } // §8 PREDICTOR / REGIME CLAIMS — each an HONEST MODEL Field carried WHOLE (fail-closed on a dishonest one). claims(claims: readonly Claim[]): void { this.claimsJson = claims.map((c) => { assertClaimHonest(c); // fail-closed: a dishonest claim is REFUSED, never serialized as if honest. return { claimType: c.claimType, field: fieldJson(c.field) }; }); } result(): KernelJson { return { frame: this.frame, mandate: this.mandateJson, brief: this.briefJson, wake: this.wakeJson, dataHealth: this.dataHealthJson, unavailable: this.unavailableJson, positions: this.positionsJson, resting: this.restingJson, budget: this.budgetJson, sizing: this.sizingJson, ownerEnvelope: this.ownerEnvelopeJson, ownerActs: this.ownerActsJson, engineLog: this.engineLogJson, claims: this.claimsJson, }; } } function sizingJson(s: SizingHeadroom | null | undefined): Maybe<SizingJson> { if (s === undefined || s === null) return UNKNOWN; const out: SizingJson = { instrument: s.instrument, unit: s.unit, basisPerUnit: maybeNum(s.basisPerUnit), maxUnits: maybeNum(s.maxUnits), remainingUsd: maybeNum(s.remainingUsd), ...(s.note !== undefined ? { note: s.note } : {}), }; return out; } /** * Materialize the cockpit kernel as typed {@link KernelJson} — the ONE walk ({@link walkKernel}) * driving the {@link JsonKernelSink}. This is the json Rendering at the walkKernel seam. PURE + * deterministic; refuses a dishonest claim (fail-closed, {@link assertClaimHonest}). */ export function renderKernelJson(kernel: Kernel, frameKind: "OPEN" | "WAKE"): KernelJson { const sink = new JsonKernelSink(); walkKernel(kernel, frameKind, sink); return sink.result(); } // ───────────────────────────────────────────────────────────────────────────── // The market/instrument body — raw input Fields carried WHOLE (the honest-chart substrate, ADR-0052 §3) // ───────────────────────────────────────────────────────────────────────────── export interface InstrumentJson { readonly symbol: string; readonly assetClass: InstrumentSpec["assetClass"]; readonly role: Maybe<string>; readonly multiplier: Maybe<number>; readonly tick: Maybe<number>; } export interface LevelsJson { readonly spot: Maybe<number>; readonly priorClose: Maybe<number>; readonly hod: Maybe<number>; readonly lod: Maybe<number>; readonly vwap: Maybe<number>; readonly orHigh: Maybe<number>; readonly orLow: Maybe<number>; } /** One tape bucket — OHLC carried WHOLE with its HH:MM ET clock (date-blind). The honest chart draws * candles from THESE raw numbers; an UNKNOWN never becomes an interpolated candle (ADR-0052 §3). */ export interface TapeRowJson { readonly clock: string; readonly open: Maybe<number>; readonly high: Maybe<number>; readonly low: Maybe<number>; readonly close: Maybe<number>; readonly volume: Maybe<number>; } export interface ChainRowJson { readonly strike: Maybe<number>; readonly right: ChainRow["right"]; /** A `null` bid/ask side is DARK — carried as explicit UNKNOWN, never a fabricated price (ADR §4). */ readonly bid: Maybe<number>; readonly ask: Maybe<number>; readonly fair: Maybe<number>; readonly fairNote: Maybe<string>; } export interface MarketJson { readonly instrument: string; readonly levels: LevelsJson; readonly tapeBucketMin: Maybe<number>; readonly tape: readonly TapeRowJson[]; readonly chainDte: Maybe<number>; readonly chain: readonly ChainRowJson[]; } function instrumentsJson(instruments: readonly InstrumentSpec[]): readonly InstrumentJson[] { return instruments.map((i) => ({ symbol: i.symbol, assetClass: i.assetClass, role: maybeStr(i.role), multiplier: maybeNum(i.multiplier), tick: maybeNum(i.tick), })); } function levelsJson(lv: LevelSet): LevelsJson { return { spot: maybeNum(lv.spot), priorClose: maybeNum(lv.priorClose), hod: maybeNum(lv.hod), lod: maybeNum(lv.lod), vwap: maybeNum(lv.vwap), orHigh: maybeNum(lv.orHigh), orLow: maybeNum(lv.orLow), }; } function tapeJson(rows: readonly TapeRow[]): readonly TapeRowJson[] { return rows.map((r) => ({ clock: r.clock, open: maybeNum(r.open), high: maybeNum(r.high), low: maybeNum(r.low), close: maybeNum(r.close), volume: maybeNum(r.volume), })); } function chainJson(rows: readonly ChainRow[]): readonly ChainRowJson[] { return rows.map((r) => ({ strike: maybeNum(r.strike), right: r.right, bid: maybeNum(r.bid), ask: maybeNum(r.ask), fair: maybeNum(r.fair), fairNote: maybeStr(r.fairNote), })); } function marketJson(market: BriefingInput["market"]): MarketJson { return { instrument: market.instrument, levels: levelsJson(market.levels), tapeBucketMin: maybeNum(market.tapeBucketMin), tape: tapeJson(market.tape), chainDte: maybeNum(market.chainDte), chain: chainJson(market.chain), }; } // ───────────────────────────────────────────────────────────────────────────── // The full Frame json Rendering — kernel (via the seam) + the raw market body + the header // ───────────────────────────────────────────────────────────────────────────── /** The OPEN keyframe as typed JSON. */ export interface BriefingJson { readonly kind: "OPEN"; readonly timeToCloseMin: Maybe<number>; readonly phase: Maybe<string>; readonly clockET: Maybe<string>; readonly kernel: KernelJson; readonly instruments: readonly InstrumentJson[]; readonly market: MarketJson; } /** The WAKE delta frame as typed JSON. */ export interface WakeDeltaJson { readonly kind: "WAKE"; readonly wakeIndex: number; readonly minutesSinceLast: Maybe<number>; readonly timeToCloseMin: Maybe<number>; readonly phase: Maybe<string>; readonly clockET: Maybe<string>; readonly wakeReason: Maybe<string>; readonly kernel: KernelJson; readonly market: MarketJson; } export type FrameJson = BriefingJson | WakeDeltaJson; /** * Render the OPEN keyframe briefing as the typed json Rendering (ADR-0052 §2) — the kernel through * the seam ({@link renderKernelJson}) plus the raw market/instrument Fields (the honest-chart * substrate). Fields WHOLE, nulls explicit UNKNOWN, no value invented. Refuses a dishonest claim * (fail-closed). PURE + deterministic. */ export function renderBriefingJson(input: BriefingInput): BriefingJson { return { kind: "OPEN", timeToCloseMin: maybeNum(input.timeToCloseMin), phase: maybeStr(input.phase), clockET: maybeStr(input.clockET), kernel: renderKernelJson(input.kernel, "OPEN"), instruments: instrumentsJson(input.instruments), market: marketJson(input.market), }; } /** * Render a WAKE delta frame as the typed json Rendering (ADR-0052 §2). Structural, so it carries the * full since-last kernel + market (json is NOT the KV-cache-optimized text delta path — a consumer * diffs structurally). Fields WHOLE, nulls explicit UNKNOWN. Refuses a dishonest claim (fail-closed). * PURE + deterministic. */ export function renderWakeDeltaJson(input: WakeDeltaInput): WakeDeltaJson { return { kind: "WAKE", wakeIndex: input.wakeIndex, minutesSinceLast: maybeNum(input.minutesSinceLast), timeToCloseMin: maybeNum(input.timeToCloseMin), phase: maybeStr(input.phase), clockET: maybeStr(input.clockET), wakeReason: maybeStr(input.wakeReason), kernel: renderKernelJson(input.kernel, "WAKE"), market: marketJson(input.market), }; } /** * Serialize a json Rendering to its byte-stable canonical bytes — through the ONE canonical JSON * module ({@link ../canonical/json.ts canonicalizeJson}: recursively ordered keys, kept array order, * NO trailing newline), the SAME bytes the Bus/Blotter content-address. The same Frame always yields * byte-identical text (determinism, RUNTIME §0). Deterministic + pure. */ export function serializeFrameJson(frame: FrameJson | KernelJson): string { return canonicalizeJson(frame); }