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
JavaScript
/**
* # 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();
}
}