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.

232 lines (201 loc) 11.2 kB
/** * # 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 type { TimeUnit } from "../lang/index.ts"; import { durationMs, UNKNOWN, type Unknown } from "./types.ts"; // ───────────────────────────────────────────────────────────────────────────── // Metrics + readings // ───────────────────────────────────────────────────────────────────────────── /** The three window metrics. `velocity` is signed; `move` is its unsigned magnitude; * `range` is the peak-to-trough excursion. */ export type WindowMetric = "velocity" | "move" | "range"; /** A window reading in both framings: `dollars` = the `$/window` value; `pct` = the * `%/window` value (a fraction, e.g. 0.012 = 1.2%). */ export interface WindowValue { readonly dollars: number; readonly pct: number; } /** The baseline statistic requested against a window series. `p` with `n∈[0,100]` is a * nearest-rank percentile; `sigma` with `n` is `n` population standard deviations above the * mean (the portable form of "big", CONTEXT: Window). */ export type BaselineStat = { readonly kind: "p"; readonly n: number } | { readonly kind: "sigma"; readonly n: number }; /** Configuration for the window engine. All optional; the defaults are documented above. */ export interface WindowConfig { /** Trailing samples retained per baseline ring (default 512). */ readonly baselineCapacity?: number; /** Minimum baseline samples before `p*`/`sigma` warm from UNKNOWN (default 30). */ readonly minBaselineSamples?: number; /** Hard cap on retained spot samples (oldest evicted beyond it; default 200_000). A * bounded session never approaches this — it only fences a pathological stream. */ readonly maxSamples?: number; } const DEFAULTS = { baselineCapacity: 512, minBaselineSamples: 30, maxSamples: 200_000 } as const; // ───────────────────────────────────────────────────────────────────────────── // Baseline ring — a fixed-capacity trailing distribution // ───────────────────────────────────────────────────────────────────────────── class BaselineRing { private readonly buf: number[] = []; private head = 0; constructor(private readonly cap: number) {} push(v: number): void { 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(): number { return this.buf.length; } /** Nearest-rank percentile `n∈[0,100]` over the current trailing sample. */ percentile(n: number): number { 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] as number; // rank∈[1,N] ⇒ index in range } /** Mean + `k` population standard deviations. */ sigmaBand(k: number): number { 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 window engine // ───────────────────────────────────────────────────────────────────────────── interface Sample { readonly ts: number; readonly px: number; } /** The key a (metric, windowMs) pair reduces to for the baseline registry. */ function comboKey(metric: WindowMetric, windowMs: number): string { return `${metric}|${windowMs}`; } export class WindowEngine { private readonly samples: Sample[] = []; private readonly baselines = new Map<string, BaselineRing>(); private readonly cfg: Required<WindowConfig>; constructor(cfg: WindowConfig = {}) { 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: WindowMetric, value: number, unit: TimeUnit): void { 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: number, px: number): void { 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("|") as [WindowMetric, string]; 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: WindowMetric, value: number, unit: TimeUnit, now: number): WindowValue | Unknown { 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: WindowMetric, value: number, unit: TimeUnit, stat: BaselineStat): number | Unknown { 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(): number { return this.samples.length; } reset(): void { this.samples.length = 0; this.baselines.clear(); } // ── internals ────────────────────────────────────────────────────────────── private compute(metric: WindowMetric, windowMs: number, now: number): WindowValue | Unknown { 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] as Sample; 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). */ private priceAtOrBefore(cutoff: number): number | undefined { for (let i = this.samples.length - 1; i >= 0; i--) { const s = this.samples[i] as Sample; if (s.ts <= cutoff) return s.px; } return undefined; } private latestPx(): number | undefined { const last = this.samples[this.samples.length - 1]; return last?.px; } }