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.

291 lines (265 loc) 21.5 kB
# Kestrel runtime semantics (v1 spec — the engine contract) > The binding semantics for the sim/paper/live runtime. Where this document and the > ADRs disagree, the ADRs win. Everything here is testable; the determinism and > fail-closed clauses are CI-enforced invariants. ## 0. Time and determinism - **All time is injected.** No module on the runtime path reads a wall clock or an unseeded RNG. In `sim` mode the recorded/synthetic bus IS the clock: events are applied in sequence and `now` = the current event's timestamp. The ONE sanctioned wall-clock read is the clock-honest driver's injectable `now` seam (ADR-0040): a LIVE clock-honest run measures each seat turn once, at that seam only, and the measurement immediately becomes part of the recorded session (a `deliberation` record on the Bus, bus_schema v6); replay and certification re-project from the recorded costs and **never re-time**. - **Determinism invariant:** same bus + same armed documents **+ same recorded deliberation costs****byte-identical** emitted event sequence and byte-identical report. This is a test, not a hope. (The recorded-costs clause is ADR-0040's: latency-blind sessions record no costs and the invariant reads exactly as before; a clock-honest session's costs are ordinary bus events inside the byte hash, so two live replicates of one config are replicates — distinct run identities — not duplicates.) ## 1. The event bus - Append-only, typed JSONL; one file per session. Envelope: `{ seq, ts, stream, type, ...payload }` with monotonic `seq`. - Streams: `META` (session header: instruments, mode, schema version), `TICK` (`SPOT` | `BOOK` | `HEARTBEAT`), `DETECTOR`, `PLAN` (lifecycle transitions), `ORDER` (place/cancel/fill), `WAKE`, `CONTROL` (author actions), `REGIME` (tag writes), `JOURNAL` (author reasoning, full markdown `body` inline), `TELEMETRY` (engine-emitted per-order observability). Schema-versioned (`bus_schema: 3`; the reader accepts v1/v2 too — a v1 bus has no JOURNAL, a v2 bus no TELEMETRY). - `JOURNAL` (v2) is **author metadata, not an engine input**: it is discriminated on `kind` (`author` | `debrief` | `note`), not an engine `type`, and the per-event pass never folds it. Adding JOURNAL records therefore cannot change the emitted engine event stream or the determinism hash — "same input events in ⇒ same engine events out". An `author` record written before arming is provably pre-hoc (`seq` < the first armed `PLAN`'s `seq`); a `debrief` appended after CLOSE is provably post-hoc (`seq` > the CLOSE `seq`). Reasoning rides the bus, so pre-hoc vs post-hoc is a fact of the log. - Reader is crash-tolerant: a torn final line is dropped; mid-file corruption is a loud error; a v1 bus and a well-formed JOURNAL with an unknown/forward `kind` are tolerated by a logged skip (never a throw, never a silent success). - The engine writes everything it does back onto the bus — the bus is the audit trail, the replay substrate, and the grading corpus at once. ## 2. Canonical state and series - One **canonical state** per signal instrument: `spot`, `hod`, `lod` (+ timestamps), opening range, `vwap`, `prior_close`, session phase. Updated only from bus events; every derived series references it (nothing recomputed per consumer). - **Inclusive vs exclusive extremes (the pane/trigger split):** panes/frames read `hod`/`lod` (and `or_high`/`or_low` while forming) **inclusive** of the current sample (the true running extreme), but **trigger evaluation reads them EXCLUSIVE of the current sample** — the value as it stood *before* this event's spot — so a fresh high IS a `crosses above hod` (the trader idiom; with the inclusive value the current spot is already the hod and the cross could never fire). - **Window series**: `velocity(w)`, `move(w)`, `range(w)` over ring buffers keyed by window; baselines (`p99`, `sigma`) computed over trailing distributions at that window. A series with insufficient data reads **UNKNOWN**, never a guess. - **Org facts** (`pnl`, `fills.avg_px`, `plan(x).state`, …) resolve against the acting session's book; absent in spectator contexts. - Detectors are versioned pure reducers over the stream: same input sequence ⇒ same detector event sequence. ## 3. Trigger evaluation (tri-state, causal) - Triggers evaluate to `true | false | UNKNOWN`. **Only a definite `true` fires.** UNKNOWN propagates through NOT and short-circuits conservatively (AND: false wins, else UNKNOWN if any UNKNOWN; OR: true wins, else UNKNOWN if any UNKNOWN). - `crosses above/below` requires an observed transition (prior sample on the other side) — never true on first sight. `held Ns` requires the condition continuously true for the duration (per-node anchor timestamp). `within Nm` windows anchor at the qualifying event. All per-node memory is causal and owned by the armed statement instance. - A plan whose regime gate references an UNKNOWN tag level **de-arms** (only `regime {any}` survives a dead tag source). An unresolvable series in any trigger de-arms the statement with a logged reason. Never crash; never silently false. ## 4. Price resolution - Anchor families: VALUE (`fair`, `intrinsic`, `basis`), BOOK (`bid ask mid last join improve`), ABSOLUTE (`1.85`, `stub`). - **`fair` = ExecutionFair with receipts.** When ExecutionFair returns null (unbuildable), the resolution falls back to an **annotated** book value (`fair=fallback(mid)` for BUY, `fair=fallback(max(mid,intrinsic))` for SELL) — the annotation is carried on the order record; a silent mid is forbidden. - **SELL floor:** every resolved SELL price ≥ intrinsic, always (ceil-snapped to tick). **BUY caps** compose conservatively (`cap fair, 0.73` = tighter binds). - `esc` stages are **absolute-from-placement** (stage clock starts at placement, not at last reprice). `peg` re-evaluates on data change and cancel/replaces only on ≥ 1-tick drift (hysteresis) under a global reprice token bucket. - `basis` resolves against the position's cost basis; unresolvable without a held position (statement de-arms if used with no inventory). ## 5. Plan lifecycle - States: `authored → armed → fired → managing → done(filled|expired|invalidated)`. Every transition is a `PLAN` bus event. - **Fire-then-inform:** on definite trigger true, actions submit through the Gate at the current event, and a `WAKE` (lifecycle) event is emitted in the same step. - `DO`/`ALSO`: independently gated single-leg children, submitted in order (`atomic` refused at parse; nothing to handle here). - `RELOAD`: adds rungs on adverse movement per the rung spec, each rung budget- checked; halted by INVALIDATE. - `TP`: rests take-profit sells against filled inventory (fractioned per tier); never sells more than held; prices floored at intrinsic. - `EXIT`: on its (underlying-only) trigger, cancels resting TP for the exiting quantity and works a maker-first sell per its price line (esc stages), floored at `max(intrinsic, floor)`; unfilled at the final stage ⇒ STAND (ride to settle). - `INVALIDATE`: halt reloads + cancel the plan's **unfilled** children + held inventory rides. Never sells. - `CANCEL-IF` (plan-level and line-level): cancels **unfilled acquisition children (entry/reload) only**; resting TP/EXIT management orders **persist** — inventory management is governed by TP/EXIT/INVALIDATE. Effective on terminal confirmation; partial fills keep their filled quantity. - `TTL`: at expiry the plan stops arming/firing and pulls unfilled children; inventory rides. - `ARM <plan>`: arms the named sibling (chaining); plan-state triggers (`plan(x).fired`) read the bus lifecycle. - **Document supersession** (the stepped-day handshake, §7 / EVALUATION Part 1): when an author revises the standing document mid-session, the replacement **supersedes** it. Every standing plan is de-armed **cleanly** at the supersede instant — acquisition is halted and all **unfilled entry/reload children are cancelled** (identical to a plan-level `CANCEL-IF`) — but **filled inventory and its resting `TP`/`EXIT` management PERSIST** under the superseded plan record; it is never liquidated. A plan holding no inventory with nothing working is finished `done(expired)` (reason `superseded`); a plan still holding inventory keeps managing it (`TP`/`EXIT`/`INVALIDATE`) under its old record while the replacement arms fresh. Ordering is supersede-then-arm at one injected `now`, so the two documents never both acquire on the same tick. A `CONTROL supersede` event marks it on the bus. - **BOOK envelope**: total-budget / max-concurrent-R checked at fire time across the book's plans; arbitrate-by priority (higher wins; ties → earlier armed). Budgets nest per the org tree: a child fire must fit every ancestor envelope. ## 6. Fills (the judge) - One seam: `FillModel.assess(order, bookState, fair?) → { fill?: {qty, px}, p_fill, kind }`. Implementations: - **`strict-cross-v1`** (floor judge): a resting order fills only when the tape strictly crosses its price (BUY: ask < px would-cross rule — trade prints/quotes moving through the level); no queue model. Conservative by construction. - **Same-price doctrine split (kestrel-0gnb).** A *quote* cross is strictly-through (`<`/`>`, same-price never fills) on both arms. A same-price *trade print* splits under the one `strict-cross` name: the **options** arm (`fill/model.ts`) is *at-or-through* — a fresh print AT the resting level fills, a real execution the passive order shared; the **spot** arm (`fill/spot.ts`) is strictly-through and *refuses* same-price, since the quote-less catalog tapes leave queue position unknowable. Spot is strictly the more conservative; both are floor-legal. An option-vs-equity comparison under the shared stamp must read the same-price rule from the arm, not the name (reconciling them is a semantics change needing its own review). - **`maker-fair-v1`** (ceiling judge): strict-cross ∪ a calibrated hazard of being filled while resting at/near fair (hazard parameters injected as data — the calibration file is versioned input, not code). The uncalibrated hazard is **directional** in the far-OTM wing: a resting BID is a real crossing-print fill, a resting standalone OFFER is fantasy — floored/capped toward no-fill (`SELL_FAR_OTM_CAP`, a positive floor, never a hard zero); a covering wing of a defined-risk package is exempt. - Each assessment carries a **support flag** — `calibrated` (a structural cross, or a cell with a live anchor) vs `extrapolated` (an uncalibrated hazard cell, or a fantasy corner: a far-OTM offer / dark internalization lift). A grader **refuses to bank expected-$ from an extrapolated cell** so strategies cannot hill-climb into uncalibrated corners of the fill model. Fail-closed: a missing flag is treated as un-bankable. - Settlement: at session close, held cash-settled inventory settles to intrinsic from the settle spot; physical-style instruments mark at last. Reports carry both `outcome` (floor) and `outcome_expected` (E[$] under p_fill). - **Settle-mark provenance (kestrel-xwf):** the settle spot never grades without its as-of — the injected-clock `ts` at which its VALUE was last established on the tape (a frozen feed re-printing the same px does not refresh it). A mark older than the declared threshold (default 5 min) at the settle instant is **stale**: the settle still computes (never a re-guessed "true close"), but the verdict rides the report (`totals.settle_mark`), the bus (a `settle_mark` TELEMETRY record), and the Blotter (`totals.settle_mark` + a tainted headline) — the settled P&L is non-bankable, never a silently fresh-looking number. Unstated provenance grades stale (fail-closed). A STALE mark additionally attempts an **annotated recovery** (kestrel-xwf/kestrel-pez): closing put-call parity (`S = K + C_mid − P_mid`) at the strike nearest the frozen mark, derived ONLY from quotes carried two-sided on a BOOK event within the same declared threshold of settle (a pair surviving in the fold from before a feed death is not a closing strike). Recovered ⇒ the book cash-settles against the parity spot (`source: "parity"`, `mark_uncertain: false`) — the staleness verdict and the headline taint STAND (a recovery annotates, it never pardons). Not derivable ⇒ honestly refused: the last-known mark is retained (`source: "stale"`, `mark_uncertain: true`), never fabricated. - Every strategy and the structural null cross the same fill model; the model name + version is stamped on every report. ## 7. Episode (sim mode, v1) > **Naming (OSS-ADR-0055):** the deterministic execution unit is an **Episode**. > The runtime object is still spelled `Session` in code (`src/session/`, the > `Session` type) until the coordinated rename lands under `kestrel-10he` inside > the pre-0.8.0 MINOR bump; canon leads, code follows. The report type is already > renamed (`SessionReport` → `EpisodeReport`, deprecated alias retained). - `Episode = { clock, gate, bus, mode }` (code: `Session`). Sim: drive a recorded/synthetic bus event by event → canonical state updates → **fill reassessment against resting orders (BOOK only, the market acts first)** → detector reducers → trigger sweep → fires → Gate → SimFillEngine → bus writes. One pass, no lookahead: state at event N is a function of events ≤ N. **Fills-before-sweep is load-bearing:** on a BOOK event resting orders are assessed for fills (and the fills fed back) *before* the engine sweep reacts to that book, so an INVALIDATE / peg cancel-replace can never pull an order the same book would have filled (the market acts, then the plan reacts). - **Report** (JSON): per-plan lifecycle trace; per-order resolution annotations, p_fill, fills; realized $ at settle (floor) + expected $ (under p_fill); fill-model version; determinism hash of the emitted event stream. - **Certification test:** replaying the same bus twice yields identical determinism hashes; replaying with no plans armed yields a byte-identical pass-through. - **Stepped day (the supervised agent-day, EVALUATION Part 1):** the same core driven with **wake checkpoints** and a **file handshake** — before the first event a briefing (OPEN keyframe) is written and the runner blocks for the authored `plans-0`; at each wake (authored triggers + the structural cadence into the close) a delta frame + machine state are written and the runner blocks for a revision (supersede) or a pass. **Injected-time boundary (latency-blind sessions):** the sim clock is entirely injected from the bus (§0); only the **host wait loop** (filesystem polling + the `--max-wait` safety timeout) reads the host wall clock — an out-of-band author interaction, never part of the graded computation. Determinism is unaffected: the graded output is a pure function of `(bus, wake set, handshake file contents)`, so a re-run with identical revision files replays byte-identically. **This off-the-graded-clock clause is scoped to latency-blind sessions** (ADR-0040 / clock-honest-wakes §3). In a **clock-honest** session the deliberation seam is the driver's and **uniform across transports**: `measured_ms` spans the whole `await` of the adapter — an in-process SDK call or a file-handshake block-with-retries alike — so the author's wall time is priced into the tape exactly as ADR-0040 requires, with no per-transport special case. (A file handshake run by a *human* author would grade terribly clock-honest; operators therefore run those latency-blind — an operational policy call, not an engine rule. The mechanism does not distinguish who is on the other end of the `await`; the clock-honest data-floor eligibility gate, not the transport, decides admissibility.) **Every handshake block is announced** before it is entered (which file(s), in which dir, for how long) and **heartbeats** while blocked, on the host status channel (stderr) — a day waiting on a declared author is observably distinct from a hung process; the wait budget itself is unchanged by the announcement. **The wait discipline is declared, never inferred — and the default terminates.** The runner never sniffs the host (stdin's TTY-ness) to guess whether an author is coming — a non-interactive stdin is exactly what the primary agentic path (an LLM authoring out-of-band) looks like, and a host property must not sit on the control-flow path. Instead, both ends are caller declarations: - **default** (no flag): run from whatever is staged in `--dir`; a missing document refuses **promptly** (code `NO_AUTHOR`, §8) after a short staging grace. A published verb therefore always terminates under plain node — it never rides a 900s poll for a reply the caller never said was coming. - **`--await-author`** (or supplying `--max-wait`, which implies it): block up to `--max-wait` (default 900s) for an out-of-band author, announcing + heartbeating, and abandon **loudly** past that budget with code `HANDSHAKE_ABANDONED`. This is the primary agentic path. - **`--no-author`**: the explicit form of the default (documents pre-staged; a miss is the same prompt `NO_AUTHOR` refusal). This **supersedes** the 0.4.x contract where a bare `day` rode `--max-wait` (900s) for the author: a shipped, published verb that polls 900s under plain node never terminated inside a caller's offline budget — a fail-closed violation on a live surface (kestrel-4fc9). The real author path is unchanged in substance; it now declares itself with `--await-author`. ## 8. Fail-closed catalogue (all loud, all tested) parse error → no arm · unknown series → de-arm with reason · UNKNOWN gate → de-arm · unresolvable price → no order + logged · SELL below intrinsic → refused · never-naked SELL is a TWO-TIER refusal (ADR-0017 amendment 2026-07-18): a statically-visible opening-short STRUCTURE — an entry `DO`/`ALSO` SELL co-authored with an entry BUY and no held-leg adopt — is refused AS DATA **at arm** (`opening-short-uncovered`, so it can never mint a proof byte-identical to a naked long, kestrel-53gw); a DYNAMICALLY-naked sell (a RELOAD sell whose covering fill never arrived; a pure naked opening sell) stays refused at **fire** · budget exceeded → clamped/refused per envelope · fill-model absent → refuse to grade (never default silently) · bus corruption → loud error · `day --await-author` with no reply within `--max-wait``HANDSHAKE_ABANDONED` · `day` (default or `--no-author`) with an unstaged document → `NO_AUTHOR` promptly (both exit 6, never a silent wait, never an unbounded one — the default terminates under plain node). ## 9. Golden re-bake is part of the change (the leg/field ⇒ golden contract) A golden fixture under `tests/golden/` pins the **exact bytes** of a serialized artifact — `serialize(project(bus))` for the Blotter, `serializeEvent` for the bus envelope, a rendered frame. Those bytes are a pure function of the artifact's **leg/field set**: the legs a plan carries, the fields a record kind emits, the axes a cell renders. A canonical serializer sorts keys and drops `undefined`, so adding *one* participant — a fifth `clock_honest` leg, a `spot_age` frame field — shifts the pinned bytes of **every** golden that artifact reaches, repo-wide. **Contract.** Any change to the leg/field set participating in a golden fixture MUST re-bake the affected goldens **in the same change**. The re-bake is not follow-up work and not a reviewer's job to notice — it is *part of* the change that moved the bytes, exactly as the source edit is. Re-bake only with the artifact's **real baker** (e.g. `bun scripts/gen-canonical-golden.ts`); never hand-edit a golden's bytes or a digest (a hand-edit can encode bytes the real path would never produce, which is worse than a stale golden). **This is a checkable contract, not a post-hoc red.** Each baker exposes a `--check` mode that re-derives the artifact's bytes through the real production path and byte-compares them against the committed golden; a leg/field participant that moved without re-baking leaves the golden stale and the check **reds at the point of change** — with an actionable message naming the re-bake command — rather than surfacing later as an opaque byte-diff on main. The freshness check is a byte re-derivation, deliberately **not** a git-diff heuristic: it can neither be fooled by a trivial touch of the golden file nor depend on history (which never ships, ADR-publication doctrine), and it never false-reds when the participant change *did* re-bake (the bytes match). The golden bakers wired this way — `gen-canonical-golden:check` (the Blotter canonical bytes), `gen-field-order-golden:check` (the repo-wide bus field-order envelope), and `gen-oversight-frame-golden:check` (the projected `OversightFrame`) — each run inside `bun run check` and CI; a stale golden is a fail-closed red, never a silent default. (Not every golden under `tests/golden/` is baker-derived: some — e.g. the grammar `accept`/`rejects` corpora — are pinned by their own golden *tests*, a byte-compare inside the suite rather than a point-of-change re-derivation.)