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.

250 lines (249 loc) 15.7 kB
/** * # bus/types — the typed append-only event bus envelope + payloads (RUNTIME §1) * * The bus is the single substrate underneath every mode: it is the **audit trail**, the * **replay corpus**, and the **grading corpus** at once (RUNTIME §1). Every event is one * flat JSONL record with the envelope `{ seq, ts, stream, type, ...payload }` and a * monotonic `seq`. The engine writes everything it does back onto the bus; a Session in * `sim` mode reads a recorded/synthetic bus *as its clock* — `now` is the current event's * `ts` (RUNTIME §0), so nothing on the runtime path reads a wall clock. * * This file is the **typed object model of the bus**. Other modules import these types; * they never redefine an envelope or a payload. The model is a discriminated union keyed on * `stream` (and, within `TICK`, on `type`) — narrow on `event.stream` first, then on * `event.type`. * * ## Streams (RUNTIME §1) * `META` (session header) · `TICK` (`SPOT` | `BOOK` | `HEARTBEAT`) · `DETECTOR` · * `PLAN` (lifecycle) · `ORDER` (place/cancel/fill/reject) · `WAKE` · `CONTROL` (author * actions) · `REGIME` (tag writes) · `JOURNAL` (author reasoning, full markdown inline) · * `TELEMETRY` (engine-emitted per-order observability). Schema-versioned via {@link BUS_SCHEMA}, * carried on the `META` header and validated by the reader. * * `JOURNAL` is the one stream that is **author metadata, not an engine input** (a57.11): the * per-event pass never folds it, so adding JOURNAL records leaves the emitted engine event * stream — and the determinism hash — byte-identical. It is also the one stream discriminated * on `kind` rather than an engine `type` (there is no `(stream,type)` pairing for it); the * reader tolerates a forward/unknown `kind` by a logged skip instead of a throw. * * `TELEMETRY` (a57.9) is the mirror-image case: it IS engine output (emitted onto the same * stream as PLAN/WAKE, so it joins the determinism hash — same input ⇒ same telemetry ⇒ stable * hash) but it is **observational only** — no engine DECISION reads it back, so the per-event * pass skips it exactly like JOURNAL and its presence never changes an order, fill, or plan * outcome. It carries an **open** `type` vocabulary (like DETECTOR/CONTROL) so per-order * observability can grow without a schema bump; a reader tolerates an unrecognized telemetry * `type` by simply ignoring it, and a report over a bus that carries no telemetry degrades to a * documented default (esc stage 0, reprice count 0) rather than crashing (RUNTIME §8). * * ## Equity-only sessions * An equity-only session is representable two ways, both first-class: emit no `BOOK` events * at all (SPOT-only tape), or emit `BOOK` events whose `legs` array is empty. Options are a * per-instrument overlay on the underlier tape, never a structural requirement. */ // ───────────────────────────────────────────────────────────────────────────── // Schema + enumerations // ───────────────────────────────────────────────────────────────────────────── /** The bus schema version new buses are written at. Bumped on any breaking envelope/payload * change — including the addition of a new stream tag, since a reader that predates the tag * hard-throws on it (fail-closed, but mid-stream): v2 adds the {@link JournalEvent JOURNAL} stream, * v3 adds the {@link TelemetryEvent TELEMETRY} stream. (The TELEMETRY `type` vocabulary is open and * may grow WITHOUT a bump — that is a within-stream extension a telemetry-aware reader tolerates; * introducing the STREAM tag itself is the breaking change, exactly as JOURNAL was.) * * v4 (kestrel-a57.1 slice 1, ADR-0011) records the **probabilistic accounting on the bus** so the * Blotter projector re-derives `totals{floor,expected}` + `orders[].support` with no engine scrape: * a settle-outcome TELEMETRY record ({@link TelemetrySettlePayload}, `type: "settle"`) per order, and * the **judge self-description** on a GRADED bus's META ({@link MetaPayload.fill_model}/`instance`/ * `fidelity`). Both are strictly additive & OPTIONAL — the settle `type` rides the already-open * TELEMETRY vocabulary (no reader gate to widen) and the META judge fields are absent on a fill-model- * agnostic INPUT tape (the two-bus rule, ADR-0011) — so a v1..v3 bus reads clean here and the bump is * really only about advertising the new self-describing shape. The reader accepts every schema in * {@link SUPPORTED_BUS_SCHEMAS} and refuses anything outside it loudly (fail-closed, RUNTIME §8). * * v5 (kestrel-22j.15) mints a per-Plan-**instance** identity — {@link PlanPayload.plan_instance}, carried * onto the authored {@link OrderPayload}/{@link WakePayload}/{@link ControlPayload} evidence — so a legal * same-NAME replacement (a Lineage key recurs after its predecessor is `done`, CONTEXT: Lineage) no longer * collapses into the prior instance's lifecycle trace at report projection. The id is minted DETERMINISTICALLY * at registration (the authored name + a registration ordinal; NO wall clock, NO unseeded RNG, RUNTIME §0) — * same bus + documents ⇒ byte-identical ids. It is additive & OPTIONAL (a v1..v4 bus simply carries none), but * the bump is DELIBERATE: it advertises that a graded bus now self-identifies its Plan instances, and it draws * the fail-closed line for the migration. A projector reads the explicit `plan_instance` when present and, for a * pre-v5 bus that lacks it, MIGRATES EXPLICITLY by sessionizing each name's lifecycle at its structural * `done`→re-authored boundary ({@link ../blotter/project.ts}) — never inferring identity from timestamps or * fuzzy name-matching (which the F4 same-name-supersession guard makes exact: a name only recurs after its * predecessor is `done`). The reader accepts every schema in {@link SUPPORTED_BUS_SCHEMAS} and refuses anything * outside it loudly (fail-closed, RUNTIME §8). * * v6 (kestrel-w7la.1, ADR-0040 — clock-honest wakes) records **deliberation cost on the Bus**: the * within-stream WAKE type `"deliberation"` ({@link DeliberationEvent}) prices one clocked Seat turn — * `ts` IS the derived return time `checkpoint.ts + measured_ms + buffer_ms`, `wake_seq` names the WAKE * checkpoint it belongs to — so replay/certification re-project byte-identically from recorded time * (the determinism invariant's recorded-time clause, RUNTIME §0). The type is within-stream-ADDITIVE, * yet the bump is DELIBERATE (exactly the v5 precedent): a v5 build that *ignored* deliberation records * would re-project a clocked bus into a *different* byte stream — silent wrongness — so stamping v6 * makes every pre-0040 build refuse a clocked bus up front at the META line. Only session-OUTPUT (graded) * buses stamp v6; synthetic INPUT tapes keep their pinned literal schema (an input tape can never carry a * deliberation record, and re-stamping the corpus would force a corpus re-mint — ADR-0018). A `deliberation` * record on a bus whose META stamps `bus_schema < 6` is refused loudly by the reader (a clocked record on a * pre-clocked bus is a corrupt bus). See docs/design/clock-honest-wakes.md §1. */ export const BUS_SCHEMA = 6; /** The bus schemas this build can read. JOURNAL landed in v2, TELEMETRY in v3, the settle-outcome * telemetry + META judge self-description in v4, and the per-Plan-instance identity * ({@link PlanPayload.plan_instance}) in v5 — every one an append-only / additive change that * moves no engine semantics, so older buses are forward-compatible: a v1 bus simply has no JOURNAL/ * TELEMETRY records, a v2 bus no TELEMETRY, a v3 bus no settle-outcome record and a bare (judge-less) * META, a v4 bus no `plan_instance` (a projector migrates it explicitly by lifecycle sessionization, * never a silent mis-identify). The reader tolerates any schema listed here and rejects the rest * loudly — a build that predates a stream refuses a bus carrying it up front at the META line rather * than throwing mid-stream (fail-closed, §8). v6 (clock-honest wakes, ADR-0040) adds the WAKE * `deliberation` record; a v1..v5 bus simply carries none and replays byte-identically. */ export const SUPPORTED_BUS_SCHEMAS = new Set([1, 2, 3, 4, 5, 6]); /** The set of legal stream tags, for reader validation (fail-closed on an unknown stream). */ export const STREAMS = new Set([ "META", "TICK", "DETECTOR", "PLAN", "ORDER", "WAKE", "CONTROL", "REGIME", "JOURNAL", "TELEMETRY", ]); /** * The interface **face** an author reached the model through (kestrel-m9i.2 owner tweak, ENV·CFG): the * closed vocabulary the m9i.7 face tournament pivots on — `http` (raw provider HTTP), `sdk` (a * provider/AI SDK client), `cli` (a terminal harness), `mcp` (a Model Context Protocol server). A CLOSED * set: the projector fail-closes a declared envelope whose `face` is outside it (a typed vocabulary, * never an open string that drifts). * * CANONICAL in the protocol leaf ({@link import("../protocol/index.ts").Face}, ADR-0004) — the bus * re-exports it so the a57.14 envelope-identity axis validates against the SAME closed vocabulary the * surfaces project, one source of truth across the protocol/bus seam (arch-review A6). Protocol does * not import bus, so this cannot cycle. */ export { FACES } from "../protocol/index.js"; /** The closed {@link CorpusTier} vocabulary in a FIXED order (deterministic validation reasons; mirrors * {@link FACES}). */ export const CORPUS_TIERS = ["public", "semi-private", "private"]; /** * The practice-vs-holdback **benchmark-tiering wall** (kestrel-m9i.26; benchmark-matrix-design §3; ADR-0018 * season) mapped explicitly onto the {@link CorpusTier} vocabulary. A pure, TOTAL classifier over the closed * set: `public` is the freely-trainable, non-ranking PRACTICE tier; `semi-private`/`private` are the sealed * forward-window HOLDBACK tiers (event-date > training_cutoff, ranking-eligible). No wall clock/RNG. */ export function corpusTierWall(tier) { return tier === "public" ? "practice" : "holdback"; } /** The known JOURNAL kinds. A JOURNAL whose `kind` is outside this set is well-formed but * unknown — the reader drops it with a logged reason (crash-tolerant forward-compat, RUNTIME §8), * never widening it onto the union. */ export const JOURNAL_KINDS = new Set(["author", "debrief", "note"]); /** The telemetry `type`s this build emits/recognizes. Not a closed reader gate (telemetry is * open-vocabulary) — it is the set a consumer folds; an unrecognized `type` is skipped, not an * error. */ export const TELEMETRY_TYPES = new Set(["order", "settle", "guard", "hazard", "settle_mark", "theta_bleed"]); /** * Fold a {@link BookEvent} into a {@link BookState} (latest-wins, per instrument, per leg). Pure. * * A BOOK event may carry the **full chain** (a snapshot) or a **single-leg delta** (the recorded * OPRA tapes emit one `(strike,right)` leg per event). When `prior` is supplied, each incoming * leg **upserts** into the prior book keyed by `(strike, right)` — legs the event does not mention * keep their last-known top-of-book. Without `prior` (or a different instrument) the event's legs * ARE the book (a snapshot). This is what makes a per-leg delta stream accumulate a full book * instead of collapsing to just the most-recently-quoted leg (RUNTIME §2). */ export function foldBook(ev, prior) { let legs; if (prior === undefined || prior.instrument !== ev.instrument || prior.legs.length === 0) { legs = ev.legs; } else { const merged = new Map(); for (const l of prior.legs) merged.set(`${l.strike}:${l.right}`, l); for (const l of ev.legs) merged.set(`${l.strike}:${l.right}`, l); // incoming leg wins legs = [...merged.values()]; } return { instrument: ev.instrument, underlier_px: ev.underlier_px, ...(ev.expiry !== undefined ? { expiry: ev.expiry } : {}), legs, asof_seq: ev.seq, asof_ts: ev.ts, }; } // ───────────────────────────────────────────────────────────────────────────── // Guards // ───────────────────────────────────────────────────────────────────────────── /** Is `s` a legal stream tag? */ export function isStream(s) { return typeof s === "string" && STREAMS.has(s); } /** The closed `type` vocabulary of the `TICK` stream (RUNTIME §1). */ export const TICK_TYPES = new Set([ "SPOT", "BOOK", "HEARTBEAT", ]); /** The closed `type` vocabulary of the `ORDER` stream. */ export const ORDER_ACTIONS = new Set([ "place", "cancel", "fill", "reject", ]); /** The closed `type` vocabulary of the `WAKE` stream's *delivery* records ({@link WakeKind}). * The v6 `deliberation` record is a WAKE `type` too, but NOT a {@link WakeKind} — it carries the * {@link DeliberationPayload}, not a `wake` — so it is paired separately in {@link isValidStreamType}. */ export const WAKE_KINDS = new Set([ "wake", "coalesced", "downgraded", ]); /** The v6 WAKE `type` of the {@link DeliberationEvent} (clock-honest wakes §1). */ export const DELIBERATION_TYPE = "deliberation"; /** * Is `type` a legal event `type` for `stream`? Enforces the pairing the discriminated union * encodes (RUNTIME §1): a `TICK` must be `SPOT | BOOK | HEARTBEAT`, an `ORDER` a `place | * cancel | fill | reject`, a `PLAN` a `lifecycle`, a `WAKE` a `wake | coalesced | downgraded`, * a `REGIME` a `tag`, a `META` a `session`. `DETECTOR` and `CONTROL` carry **open** * vocabularies (the detector's own event names; the L2→L1 author-action channel) — any * non-empty string. A `(stream, type)` pair that does not inhabit the union is not a thing to * skip; it is corruption (the reader raises loudly, §8). * * `JOURNAL` is deliberately absent: it has no engine `type` (its discriminant is `kind`), so it * is never routed through this pairing check — the reader validates it on `kind`/`body` directly * and returning `false` here would misreport a well-formed journal as corruption. * * `TELEMETRY` (a57.9) carries an **open** vocabulary like DETECTOR/CONTROL — observational * per-order records may grow without a schema bump, so any non-empty `type` is well-formed and a * consumer ignores the ones it does not recognize (crash-tolerant, §8). */ export function isValidStreamType(stream, type) { switch (stream) { case "META": return type === "session"; case "TICK": return TICK_TYPES.has(type); case "DETECTOR": return type.length > 0; case "PLAN": return type === "lifecycle"; case "ORDER": return ORDER_ACTIONS.has(type); case "WAKE": return WAKE_KINDS.has(type) || type === DELIBERATION_TYPE; case "CONTROL": return type.length > 0; case "REGIME": return type === "tag"; case "TELEMETRY": return type.length > 0; default: return false; } }