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
Markdown
# 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.)