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.

203 lines (202 loc) 8.83 kB
/** * # series/state — the canonical market state per signal instrument (RUNTIME §2) * * One causal coordinate per signal instrument: `spot`, `hod`/`lod` (+ their timestamps), * the **opening range**, `vwap`, `prior_close`, and the session **phase**. It is updated * **only** from bus events via {@link CanonicalState.applyEvent} — the single ingress — * and every derived series (the window metrics, the provider) reads back through it; * nothing is recomputed per consumer (RUNTIME §2). State at event *N* is a pure function * of events `≤ N` (no look-ahead, RUNTIME §0/§7). * * ## VWAP — time-weighted (documented choice) * The v1 bus `SpotPayload` is `{ instrument, px }` — it carries **no volume**. VWAP is * therefore **time-weighted**: each price is weighted by the time it prevailed, so * `vwap = Σ pxᵢ·dtᵢ / Σ dtᵢ` where `dtᵢ` is the interval price *i* was the standing quote * (the left-price of each interval). This is the honest reduction the data supports; the * accumulator is generic over the weight, so a future volume-bearing SPOT specializes to * true volume-weighting with no change to callers. Before the second tick there is no * elapsed interval, so vwap reads the first spot (a degenerate single-sample average). * * ## prior_close — injected, not on the v1 META * `prior_close` is a *cross-session* fact and the v1 `MetaPayload` * (`session_date/instruments/mode/bus_schema`) does not carry it. It is injected via * {@link CanonicalStateConfig.priorClose} at construction. Absent that, `prior_close` * reads UNKNOWN and any statement referencing it de-arms cleanly (fail-closed, RUNTIME §8). * * ## Opening range — first N minutes * The opening range is the high/low of spot over the first `openingRangeMinutes` (default * 5) of the session, anchored at the **first SPOT tick's ts** (the session's first observed * price). It reads UNKNOWN until that first tick; once the window elapses it is frozen. * * ## Inclusive vs exclusive levels — the pane/trigger split (RUNTIME §2) * `hod`/`lod` (and `or_high`/`or_low` while the range is still forming) come in **two * flavours**. The plain getters (`hod`, `lod`, `orHigh`, `orLow`) are **inclusive** of the * current sample — the true running extreme a pane/frame should display. The **`*Trigger`** * getters (`hodTrigger`, …) are **exclusive** of the current sample: the value as it stood * **before** this event's spot was folded in. Trigger evaluation reads the exclusive flavour * so `spot crosses above hod` can actually fire — a fresh high IS a cross above the prior * high (the trader idiom). With the inclusive value the current spot is already baked into * `hod`, so `spot > hod` is never true and the cross can never fire. The exclusive value is * captured at the top of {@link CanonicalState.onSpot}, before the extreme is updated; a * non-SPOT event leaves it untouched (there is no new sample to be exclusive of). */ import { UNKNOWN, durationMs } from "./types.js"; import { WindowEngine } from "./windows.js"; export class CanonicalState { instrument; windows; orMs; priorCloseVal; spotVal; spotTs; hodVal; lodVal; // exclusive-of-current-sample levels (the trigger flavour): the extreme as it stood BEFORE // this event's spot was folded in. Captured at the top of onSpot; untouched by non-SPOT events. hodExclVal; lodExclVal; orHiExclVal; orLoExclVal; orAnchorTs; orHi; orLo; // time-weighted VWAP accumulators vwapNum = 0; vwapDen = 0; lastPx; lastPxTs; phaseVal; constructor(cfg) { this.instrument = cfg.instrument; this.priorCloseVal = cfg.priorClose; this.orMs = durationMs(cfg.openingRangeMinutes ?? 5, "m"); this.windows = new WindowEngine(cfg.windows ?? {}); } /** The single ingress. Applies a bus event to the canonical state — SPOT (this * instrument) drives price/level/vwap/windows; HEARTBEAT carries the session phase; all * other streams are ignored here (they are handled by other modules). Idempotent-safe on * unrelated events. */ applyEvent(ev) { if (ev.stream === "TICK") { if (ev.type === "SPOT") { if (ev.instrument === this.instrument) this.onSpot(ev.ts, ev.px); } else if (ev.type === "HEARTBEAT") { if (ev.phase !== undefined) this.phaseVal = ev.phase; } } // META/BOOK/DETECTOR/PLAN/ORDER/WAKE/CONTROL/REGIME: not canonical-state inputs here. } onSpot(ts, px) { // Capture the exclusive (pre-this-sample) extremes BEFORE folding px in — the trigger // flavour, so `spot crosses above hod` fires on a fresh high (RUNTIME §2, file header). this.hodExclVal = this.hodVal?.value; this.lodExclVal = this.lodVal?.value; this.orHiExclVal = this.orHi; this.orLoExclVal = this.orLo; // time-weighted VWAP: the *previous* price prevailed from lastPxTs to now. if (this.lastPx !== undefined && this.lastPxTs !== undefined) { const dt = ts - this.lastPxTs; if (dt > 0) { this.vwapNum += this.lastPx * dt; this.vwapDen += dt; } } this.lastPx = px; this.lastPxTs = ts; this.spotVal = px; this.spotTs = ts; if (this.hodVal === undefined || px > this.hodVal.value) this.hodVal = { value: px, ts }; if (this.lodVal === undefined || px < this.lodVal.value) this.lodVal = { value: px, ts }; // opening range: anchored at the first spot, frozen once the window elapses. if (this.orAnchorTs === undefined) this.orAnchorTs = ts; if (ts <= this.orAnchorTs + this.orMs) { this.orHi = this.orHi === undefined ? px : Math.max(this.orHi, px); this.orLo = this.orLo === undefined ? px : Math.min(this.orLo, px); } this.windows.pushSpot(ts, px); } // ── scalar readers (UNKNOWN until warm) ───────────────────────────────────── get spot() { return this.spotVal ?? UNKNOWN; } get spotStamp() { return this.spotVal !== undefined && this.spotTs !== undefined ? { value: this.spotVal, ts: this.spotTs } : UNKNOWN; } get hod() { return this.hodVal?.value ?? UNKNOWN; } get hodStamp() { return this.hodVal ?? UNKNOWN; } get lod() { return this.lodVal?.value ?? UNKNOWN; } get lodStamp() { return this.lodVal ?? UNKNOWN; } // ── exclusive-of-current-sample levels (the TRIGGER flavour, RUNTIME §2) ───── /** `hod` as it stood before this event's spot — so a fresh high is a cross above it. */ get hodTrigger() { return this.hodExclVal ?? UNKNOWN; } /** `lod` as it stood before this event's spot — so a fresh low is a cross below it. */ get lodTrigger() { return this.lodExclVal ?? UNKNOWN; } /** `or_high` exclusive of the current sample while the range forms; the frozen level after. */ get orHighTrigger() { return this.orHiExclVal ?? UNKNOWN; } /** `or_low` exclusive of the current sample while the range forms; the frozen level after. */ get orLowTrigger() { return this.orLoExclVal ?? UNKNOWN; } get priorClose() { return this.priorCloseVal ?? UNKNOWN; } get orHigh() { return this.orHi ?? UNKNOWN; } get orLow() { return this.orLo ?? UNKNOWN; } get phase() { return this.phaseVal ?? UNKNOWN; } /** Time-weighted VWAP (see file header). UNKNOWN before the first spot; the first spot's * price until an interval accrues; the weighted average thereafter. */ get vwap() { if (this.spotVal === undefined) return UNKNOWN; if (this.vwapDen === 0) return this.spotVal; // single sample: degenerate average return this.vwapNum / this.vwapDen; } reset() { this.spotVal = undefined; this.spotTs = undefined; this.hodVal = undefined; this.lodVal = undefined; this.hodExclVal = undefined; this.lodExclVal = undefined; this.orHiExclVal = undefined; this.orLoExclVal = undefined; this.orAnchorTs = undefined; this.orHi = undefined; this.orLo = undefined; this.vwapNum = 0; this.vwapDen = 0; this.lastPx = undefined; this.lastPxTs = undefined; this.phaseVal = undefined; this.windows.reset(); } }