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.

172 lines (171 loc) 9 kB
/** * # series/provider — resolve a `SeriesRef` to a value (RUNTIME §2) * * The seam the trigger evaluator (and, later, panes and grade columns) reads through. A * {@link SeriesProvider} answers three questions about the *market* and delegates *org* * facts to an injected {@link OrgFacts}: * * - {@link SeriesProvider.resolve} — a `SeriesRef` (from `src/lang`) → a {@link Resolved} * value (`number | string | UNKNOWN`). **Market facts** (`spot`, `vwap`, `hod`, `lod`, * `prior_close`, `or_high`, `or_low`, and the windowed `velocity/move/range`) come from * {@link CanonicalState} + its window engine; everything else is a path-scoped **org * fact** resolved by the injected {@link OrgFacts} (the engine supplies the real one in a * later milestone; a test fake ships here). * - {@link SeriesProvider.baseline} — a windowed series' trailing `p*`/`sigma` (the portable * "big", CONTEXT: Window). UNKNOWN unless the series is windowed, tracked, and warm. * - {@link SeriesProvider.phase} — the session phase, for `phase open` triggers. * * Detector-driven predicates (`failed-break`), fill telemetry, and the session calendar are * inputs from *other* modules; they are OPTIONAL hooks on the interface, and a provider that * does not supply them makes those triggers read UNKNOWN with a logged reason (fail-closed, * absent-with-reason — RUNTIME §8). Nothing here ever throws or reads silently false. * * All time is injected: `resolve` and the detector/calendar hooks take `now` explicitly * (RUNTIME §0) — the provider holds no clock. */ import { UNKNOWN } from "./types.js"; import { defaultSeriesRegistry } from "./registry.js"; // ───────────────────────────────────────────────────────────────────────────── // Classification // ───────────────────────────────────────────────────────────────────────────── /** The market resolution binding for a single-segment name, from the registry phonebook — or * undefined when the name is not a registered market fact (so it routes to org). This is the * one place market-vs-org routing is decided, and it reads through the shared Registry * (CONTEXT: Registry) rather than a provider-local copy of the market vocabulary. */ function marketBinding(registry, name) { const reg = registry.lookup(name); return reg?.kind === "market" ? reg.market : undefined; } /** True when a ref is a plain single-segment reference with no org selector. */ function isBareSegment(ref) { return ref.segments.length === 1 && ref.segments[0]?.selector === undefined; } // ───────────────────────────────────────────────────────────────────────────── // CanonicalSeriesProvider // ───────────────────────────────────────────────────────────────────────────── export class CanonicalSeriesProvider { state; org; hooks; registry; constructor(state, org, hooks = {}, /** The series phonebook the market-vs-org routing dials (CONTEXT: Registry). Defaults to the * process-wide platform phonebook; the market side is static, so the default is deterministic. */ registry = defaultSeriesRegistry) { this.state = state; this.org = org; this.hooks = hooks; this.registry = registry; } /** The session-calendar hook (`time HH:MM` / `ttl-at`); UNKNOWN when the session injects none. */ timeOfDayMinutes(now) { return this.hooks.timeOfDayMinutes ? this.hooks.timeOfDayMinutes(now) : UNKNOWN; } /** The own-fill telemetry hook; UNKNOWN when the session injects none (absent-with-reason). */ fillEvent(ev) { return this.hooks.fillEvent ? this.hooks.fillEvent(ev) : UNKNOWN; } /** The `{until,at}` temporal-envelope hook (kestrel-rtf); UNKNOWN when the session injects none * (absent-with-reason) — the firing semantics are wired by a future runtime ADR. */ temporalEnvelope(env, inner, now) { return this.hooks.temporalEnvelope ? this.hooks.temporalEnvelope(env, inner, now) : UNKNOWN; } resolve(ref, now) { if (ref.segments.length === 0) return UNKNOWN; const head = ref.segments[0]; if (head === undefined) return UNKNOWN; if (ref.window !== undefined) { // A windowed series is a market metric (windowed org facts are not v1). if (!isBareSegment(ref)) return UNKNOWN; const binding = marketBinding(this.registry, head.name); if (binding?.via !== "window") return UNKNOWN; const wv = this.state.windows.value(binding.metric, ref.window.value, ref.window.unit, now); if (wv === UNKNOWN) return UNKNOWN; return binding.pct ? wv.pct : wv.dollars; } if (isBareSegment(ref)) { const binding = marketBinding(this.registry, head.name); if (binding?.via === "scalar") return this.marketScalar(binding.scalar); } // Not a market fact ⇒ a path-scoped org fact. return this.org.resolve(ref.segments); } /** A quantified org operand folds per member (∃/∀) — delegate to the org backing's vector * resolution; absent (or a market provider without org quantification) ⇒ UNKNOWN (fail-closed). */ quantify(ref, _now) { return this.org.quantify?.(ref.segments) ?? UNKNOWN; } baseline(ref, stat) { if (ref.window === undefined || !isBareSegment(ref)) return UNKNOWN; const head = ref.segments[0]; if (head === undefined) return UNKNOWN; const binding = marketBinding(this.registry, head.name); if (binding?.via !== "window") return UNKNOWN; const bstat = stat.stat === "p" ? { kind: "p", n: stat.n } : { kind: "sigma", n: stat.n }; return this.state.windows.baseline(binding.metric, ref.window.value, ref.window.unit, bstat); } phase() { return this.state.phase; } marketScalar(name) { switch (name) { case "spot": return this.state.spot; case "vwap": return this.state.vwap; // hod/lod/or_high/or_low resolve to their EXCLUSIVE (pre-current-sample) flavour for // trigger evaluation, so `spot crosses above hod` fires on a fresh high (RUNTIME §2 / // state.ts header). Panes read the inclusive `state.hod`/`state.lod` directly. case "hod": return this.state.hodTrigger; case "lod": return this.state.lodTrigger; case "prior_close": return this.state.priorClose; case "or_high": return this.state.orHighTrigger; case "or_low": return this.state.orLowTrigger; default: return UNKNOWN; } } } // ───────────────────────────────────────────────────────────────────────────── // FakeOrgFacts — a deterministic test double for the injected org seam // ───────────────────────────────────────────────────────────────────────────── /** A path key for a segment list: `plan(chase).state`, `children(any).drawdown`, `pnl`. * The canonical string form used by {@link FakeOrgFacts} and handy for logging. */ export function pathKey(segments) { return segments .map((s) => { if (s.selector === undefined) return s.name; const inner = s.selector.kind === "sel-quant" ? s.selector.q : s.selector.name; return `${s.name}(${inner})`; }) .join("."); } /** A map-backed {@link OrgFacts} for tests (the engine ships the real one). Unknown paths * resolve to UNKNOWN — exactly the contract the real seam must honor. */ export class FakeOrgFacts { facts; constructor(facts = new Map()) { this.facts = facts; } static of(entries) { return new FakeOrgFacts(new Map(Object.entries(entries))); } resolve(segments) { return this.facts.get(pathKey(segments)) ?? UNKNOWN; } }