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