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.
184 lines (183 loc) • 8.65 kB
JavaScript
/**
* # series/windows — window series over ring buffers + trailing baselines (RUNTIME §2)
*
* The magnitude/rate half of canonical state. Every rate/magnitude series carries a
* **window** (`velocity(1m)`, `move(1w)`, `range(1d)`) and there is **no absolute shock**:
* a value is judged against *that window's own trailing baseline* (`p99`, `3sigma`,
* CONTEXT: Window). This file owns:
*
* 1. A spot **ring buffer** — the `(ts, px)` samples fed from SPOT ticks, the substrate
* every windowed metric reads back through (never recomputed per consumer, RUNTIME §2).
* 2. The **three window metrics**, each with a `$/window` and a `%/window` reading:
* - `velocity(w)` — the **signed** change over the trailing window `spot(now) −
* spot(now−w)`: direction matters (a fast *up*-move). `$/window` = the signed dollar
* change; `%/window` = that change over the window-start price.
* - `move(w)` — the **magnitude** `|spot(now) − spot(now−w)|`: "how big was the move"
* regardless of sign (so `move(1w) > p99` catches a big move either way).
* - `range(w)` — the peak-to-trough **excursion** `max − min` over the trailing window.
* (velocity is signed, move is its unsigned magnitude — they are deliberately different
* series, not a duplication.)
* 3. **Trailing baseline distributions** per (metric, window): a fixed-capacity ring of
* recent window-values supporting `p50/p95/p99` (nearest-rank) and `sigma` (population
* stddev).
*
* ## Warmup (UNKNOWN until warm — RUNTIME §2, documented rule)
* - A **window metric** reads UNKNOWN until the buffer *spans* the window — i.e. there is a
* sample at or before `now − w`. Before that a lookback would extrapolate off the start
* of the buffer, so it reads UNKNOWN, never a guess.
* - A **baseline** reads UNKNOWN until its ring holds at least `minBaselineSamples` values
* (default 30) — a floor for a stable tail estimate; configurable.
*
* ## Determinism (RUNTIME §0)
* All time is injected via the sample `ts`; no wall clock, no RNG. A (metric, window)
* baseline is *tracked* (its ring starts filling) from the first tick after it is
* registered — so for byte-identical replay the driver pre-{@link WindowEngine.track}s the
* combos referenced by the armed documents (their set is fixed a priori). Same bus + same
* tracked set ⇒ identical baselines.
*/
import { durationMs, UNKNOWN } from "./types.js";
const DEFAULTS = { baselineCapacity: 512, minBaselineSamples: 30, maxSamples: 200_000 };
// ─────────────────────────────────────────────────────────────────────────────
// Baseline ring — a fixed-capacity trailing distribution
// ─────────────────────────────────────────────────────────────────────────────
class BaselineRing {
cap;
buf = [];
head = 0;
constructor(cap) {
this.cap = cap;
}
push(v) {
if (this.buf.length < this.cap) {
this.buf.push(v);
}
else {
this.buf[this.head] = v;
this.head = (this.head + 1) % this.cap;
}
}
get count() {
return this.buf.length;
}
/** Nearest-rank percentile `n∈[0,100]` over the current trailing sample. */
percentile(n) {
const sorted = [...this.buf].sort((a, b) => a - b);
const N = sorted.length;
// nearest-rank: rank = ceil(p/100 * N), clamped to [1, N]; index rank-1.
const rank = Math.min(N, Math.max(1, Math.ceil((n / 100) * N)));
return sorted[rank - 1]; // rank∈[1,N] ⇒ index in range
}
/** Mean + `k` population standard deviations. */
sigmaBand(k) {
const N = this.buf.length;
let sum = 0;
for (const v of this.buf)
sum += v;
const mean = sum / N;
let ss = 0;
for (const v of this.buf)
ss += (v - mean) * (v - mean);
const sd = Math.sqrt(ss / N);
return mean + k * sd;
}
}
/** The key a (metric, windowMs) pair reduces to for the baseline registry. */
function comboKey(metric, windowMs) {
return `${metric}|${windowMs}`;
}
export class WindowEngine {
samples = [];
baselines = new Map();
cfg;
constructor(cfg = {}) {
this.cfg = {
baselineCapacity: cfg.baselineCapacity ?? DEFAULTS.baselineCapacity,
minBaselineSamples: cfg.minBaselineSamples ?? DEFAULTS.minBaselineSamples,
maxSamples: cfg.maxSamples ?? DEFAULTS.maxSamples,
};
}
/** Register a (metric, window) combo so its trailing baseline starts filling. Idempotent.
* Pre-track the armed set for byte-identical replay (see file header). */
track(metric, value, unit) {
const key = comboKey(metric, durationMs(value, unit));
if (!this.baselines.has(key))
this.baselines.set(key, new BaselineRing(this.cfg.baselineCapacity));
}
/** Feed one SPOT sample (ts strictly non-decreasing — the injected clock). Appends to the
* ring, evicts beyond `maxSamples`, then pushes the current value of every *tracked* combo
* into its trailing baseline. */
pushSpot(ts, px) {
this.samples.push({ ts, px });
if (this.samples.length > this.cfg.maxSamples)
this.samples.shift();
for (const [key, ring] of this.baselines) {
const [metric, windowMsStr] = key.split("|");
const wv = this.compute(metric, Number(windowMsStr), ts);
if (wv !== UNKNOWN)
ring.push(wv.dollars);
}
}
/** The current reading of a window metric at `now`, or UNKNOWN until the buffer spans the
* window. `now` is the injected clock (typically the latest sample's ts). */
value(metric, value, unit, now) {
return this.compute(metric, durationMs(value, unit), now);
}
/** The baseline statistic of a window metric, or UNKNOWN if the combo is untracked or has
* fewer than `minBaselineSamples` trailing samples. */
baseline(metric, value, unit, stat) {
const ring = this.baselines.get(comboKey(metric, durationMs(value, unit)));
if (ring === undefined || ring.count < this.cfg.minBaselineSamples)
return UNKNOWN;
return stat.kind === "p" ? ring.percentile(stat.n) : ring.sigmaBand(stat.n);
}
/** Number of retained spot samples (test/introspection). */
get sampleCount() {
return this.samples.length;
}
reset() {
this.samples.length = 0;
this.baselines.clear();
}
// ── internals ──────────────────────────────────────────────────────────────
compute(metric, windowMs, now) {
const cutoff = now - windowMs;
const startPx = this.priceAtOrBefore(cutoff);
if (startPx === undefined)
return UNKNOWN; // buffer does not span the window
const nowPx = this.latestPx();
if (nowPx === undefined)
return UNKNOWN;
if (metric === "range") {
let hi = -Infinity;
let lo = Infinity;
for (let i = this.samples.length - 1; i >= 0; i--) {
const s = this.samples[i];
if (s.ts < cutoff)
break;
if (s.px > hi)
hi = s.px;
if (s.px < lo)
lo = s.px;
}
const dollars = hi - lo;
return { dollars, pct: startPx !== 0 ? dollars / Math.abs(startPx) : 0 };
}
const delta = nowPx - startPx;
const signed = metric === "velocity" ? delta : Math.abs(delta);
return { dollars: signed, pct: startPx !== 0 ? signed / Math.abs(startPx) : 0 };
}
/** The most recent sample price at or before `cutoff`, or undefined if every sample is
* newer than the cutoff (the buffer does not span the window ⇒ UNKNOWN). */
priceAtOrBefore(cutoff) {
for (let i = this.samples.length - 1; i >= 0; i--) {
const s = this.samples[i];
if (s.ts <= cutoff)
return s.px;
}
return undefined;
}
latestPx() {
const last = this.samples[this.samples.length - 1];
return last?.px;
}
}