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.

311 lines (310 loc) 18.9 kB
/** * # series/registry — the one shared phonebook of series (CONTEXT: Registry) * * "Register once → visible to every surface." A **series** is anything with a name whose * value changes over time (CONTEXT: Series); the design intent is that every surface that * reads a series — trigger operands, panes, grade columns — dials the *same* phonebook to * learn what a name *is* before it reads what the name *says*. This module owns that * phonebook: **one registration record per series NAME**, carrying its {@link SeriesKind * kind} (market | org), its {@link SeriesScope scope path}, and its {@link Windowing window * semantics}. * * ## What v0 wires (cza.1) — the trigger consumer only * v0 routes exactly **one** of the three consumers through this phonebook: the **trigger * operand**. `series/provider` dials {@link SeriesRegistry.lookup} for market-vs-org routing * (the single place that decision is made), so triggers resolve every market fact by name * through this table rather than a provider-local copy. **Panes and grade columns do not dial * the phonebook yet** — panes assemble their level set directly from canonical state * (`src/session`), and the grade surface is charter-only. Those two consumers are routed * through this registry as they are built (grade: kestrel-a57.8; observability metadata that * panes/grade will read: kestrel-cza.3); v0 deliberately does not stand up a second market * vocabulary for them, it leaves them on their current direct reads until that wiring lands. * * ## Hybrid registration (owner-decided) * There are two-and-only-two kinds of series (CONTEXT: Series), and they register two ways: * * - **Market facts + detector outputs are PLATFORM-declared, in code, with full typed * metadata** — canonical market facts (`spot`, `vwap`, the windowed `velocity/move/range`) * are ambient signal-space facts the platform *knows a priori*, so they are declared here * as {@link PLATFORM_SERIES} with their unit, their window semantics, and the market * {@link MarketBinding binding} the provider dials them through, and each carries its per-series * observability/fidelity **class** ({@link ObservabilityClass}, kestrel-cza.3) — the finest data * granularity at which the fact is faithfully observable, which grading enforces * ({@link SeriesRegistry.admitAtFidelity}). * - **Org facts + regime tags register IMPLICITLY on first authorized write**, source-stamped * `authored:` and otherwise **taste-free** — the platform cannot know a pod's `pnl`, * `plan(x).state`, or open-vocabulary regime tags a priori, so they earn a phonebook entry * the first time an *authorized* write names them ({@link SeriesRegistry.noteOrgWrite}). * Registration is not curation: replay decides what earns usage (CONTEXT: Registry — * "Registration is taste-free; replay decides what earns usage"). * * **v0 status:** {@link SeriesRegistry.noteOrgWrite} is built and unit-tested here, but v0 * does **not** yet call it from the engine's authorized-write sites (the book's fill fold and * plan-lifecycle stamps). A live session therefore *resolves* its org facts (through the * injected `OrgFacts` seam in `series/provider`) but does not yet *register* them, so a * surface that enumerates the phonebook ({@link SeriesRegistry.dump}) sees the platform market * facts only. Wiring the org half to real writes is the province of kestrel-cza.4 (the * child-scoped org-fact resolver); the mechanism, its first-write-wins stamp, its * never-over-platform guard, and its empty-path fail-closed are proven now so cza.4 only has * to call it. * * ## Fail closed (RUNTIME §2/§8) * The phonebook never guesses. A NAME with no record resolves to **UNKNOWN** through * {@link SeriesRegistry.classify} — the same de-arm-with-a-logged-reason path every * unresolvable series takes; never a silent default. (Registration is a *phonebook* fact, * not a resolution gate: an unregistered org path still delegates to the book and reads * UNKNOWN there if the book does not know it — see `series/provider`. `classify` answers the * narrower question "is there a record for this name?", fail-closed.) * * ## No SERIES language surface (v1) * There is no author-facing `SERIES` statement in v1 — the phonebook is populated by the * platform in code and implicitly by authorized writes, never by a grammar surface. (If * author-defined computed series are ever needed, that arrives via an ADR, not here.) * * ## Determinism (RUNTIME §0/§7) * The platform table is a frozen constant; implicit registrations are a pure function of the * ordered authorized writes. {@link SeriesRegistry.dump} is a sorted, deep-copied snapshot, * so the same writes yield a byte-identical phonebook. */ import { UNKNOWN, isUnknown } from "./types.js"; /** The observability lattice, ordered **finest → coarsest**. A series' {@link ObservabilityClass} * names the finest granularity at which it is faithful; a *grading fidelity* names the finest * granularity the grader's data (lake / feed) actually provides. Both index into this order. */ export const OBSERVABILITY_ORDER = ["tick", "second", "minute", "session"]; /** Finest → coarsest rank of an observability rung (0 = `tick`, 3 = `session`), or **-1** when the * value is not a lattice rung. Callers MUST treat -1 as fail-closed (an unrecognized, untrusted rung), * never as "coarser/finer than everything" — {@link isRung} guards that. */ function observabilityRank(c) { return OBSERVABILITY_ORDER.indexOf(c); } /** True iff `v` is a recognized lattice rung. A value that reaches the seam via `as GradingFidelity` * or parsed lake/session JSON may be off-lattice (e.g. `"1min"`); such a value is untrusted and the * seam must fail closed on it — never compare a bogus rung as though it were the coarsest. */ function isRung(v) { return OBSERVABILITY_ORDER.includes(v); } /** * True iff a series of class `cls` requires **finer** data than a `fidelity`-graded session provides * (its rung is strictly finer). Such a series cannot be graded honestly at that fidelity — the * The lesson: a minute-lake session cannot faithfully see a tick-observable trigger. * * **Fail-closed on an off-lattice rung.** If either `cls` or `fidelity` is not a recognized rung * (e.g. an unrecognized fidelity string that slipped past the type via `as`/JSON), this returns * `true` — an untrusted rung is treated as "cannot confirm observable", so the caller refuses. It * never lets a bogus rung compare as coarser-than-everything and silently admit (RUNTIME §2/§8). */ export function isFinerThanFidelity(cls, fidelity) { if (!isRung(cls) || !isRung(fidelity)) return true; // untrusted rung ⇒ refuse, never silently admit return observabilityRank(cls) < observabilityRank(fidelity); } // ───────────────────────────────────────────────────────────────────────────── // The platform declarations — canonical market facts, in code, typed // ───────────────────────────────────────────────────────────────────────────── function marketScalarReg(name, observabilityClass) { return { name, kind: "market", scope: { space: "signal" }, windowing: "scalar", source: { kind: "platform" }, unit: "price", market: { via: "scalar", scalar: name }, observabilityClass, }; } /** Every windowed rate/magnitude metric is `tick`-observable: its window is authored per trigger and * may be sub-minute (`velocity(5s)`), and a coarser bar cannot reconstruct an intra-window rate, * signed move, or excursion. So the series' class is the finest it can express — `tick`. */ function marketWindowReg(name, metric, pct) { return { name, kind: "market", scope: { space: "signal" }, windowing: "windowed", source: { kind: "platform" }, unit: pct ? "fraction" : "price_delta", market: { via: "window", metric, pct }, observabilityClass: "tick", }; } /** * The canonical market-fact vocabulary the platform declares a priori (RUNTIME §2): the seven * scalar levels + the three window metrics in their `$/window` and `%/window` framings. This is * the single source of the market-vs-org routing every surface reads through. * * (Detector outputs are the other half of the platform declaration set, per the cza epic; they * join this table when they become *named* series — today they resolve as structural-event * provider hooks, not named-series reads, so there is no name to register yet.) */ export const PLATFORM_SERIES = [ // spot / vwap move trade-by-trade — a faithful read (and any spot-crossing) needs every tick. marketScalarReg("spot", "tick"), marketScalarReg("vwap", "tick"), // hod/lod and the opening-range extremes are per-bar summaries a minute bar's high/low preserves. marketScalarReg("hod", "minute"), marketScalarReg("lod", "minute"), // prior_close is one fact per session (the prior settle) — faithful with session-level data alone. marketScalarReg("prior_close", "session"), marketScalarReg("or_high", "minute"), marketScalarReg("or_low", "minute"), marketWindowReg("velocity", "velocity", false), marketWindowReg("velocity_pct", "velocity", true), marketWindowReg("move", "move", false), marketWindowReg("move_pct", "move", true), marketWindowReg("range", "range", false), marketWindowReg("range_pct", "range", true), ]; // ───────────────────────────────────────────────────────────────────────────── // The registry // ───────────────────────────────────────────────────────────────────────────── /** The head (single) segment name of a ref, or undefined for an empty path. */ function headName(ref) { return ref.segments[0]?.name; } /** The org **scope path** of a write: the plain segment names, selectors dropped (a scope is a * lexical location, not a specific target — `plan(chase).state` and `plan(urgent).state` share * the `["plan","state"]` scope). */ function orgScopePath(segments) { return segments.map((s) => s.name); } /** * The one shared phonebook of series. Seeded with the frozen platform market-fact declarations; * grows by implicit org registration on first authorized write. Read by every surface through * {@link lookup} / {@link classify}. */ export class SeriesRegistry { records = new Map(); constructor(platform = PLATFORM_SERIES) { for (const r of platform) this.records.set(r.name, r); } /** The registration for a series NAME (the head-segment name), or undefined when the name is * not in the phonebook. The provider uses this for market-vs-org routing. * * **EXACT-MATCH ONLY** (OSS-ADR-0045). Series names are case-SENSITIVE and the platform market * facts are lowercase: `lookup("HOD")` MISSES, exactly like `lookup("zzz")`. A case-variant of a * known market fact is not silently forgiven — the arm-time integrity guard * ({@link import("../engine/plans.ts").PlanEngine.armDocument}) surfaces it as a coded * `unknown-series` REFUSAL with a did-you-mean repair, and the author-lint flags it at author * time. This reverts the 0.4.5 case-insensitive forgiveness (kestrel-7tbi): forgiving a case-clash * at resolution armed a plan on a name the author never registered and hid a de-arm the author * could not see — a silent default the platform forbids (AGENTS.md; RUNTIME §8). The phonebook * never guesses: it answers ONLY for the exact registered name. */ lookup(name) { return this.records.get(name); } /** * Classify a `SeriesRef` → its registration record, or **UNKNOWN** when its head name has no * phonebook entry (fail-closed — never a silent default). This answers "is there a record for * this name?"; it is NOT a resolution gate (an unregistered org path still delegates to the * book — see `series/provider`), it is the phonebook read a surface uses to learn a series' * kind/scope/window semantics before rendering or grading it. */ classify(ref) { const name = headName(ref); if (name === undefined) return UNKNOWN; return this.records.get(name) ?? UNKNOWN; } /** * The **grading-fidelity enforcement seam** (kestrel-cza.3). Given the Session's * grading fidelity (the finest granularity its grading data provides), decide whether a series may * be graded — and REFUSE, fail-closed with a logged reason, any series **finer** than that fidelity. * You cannot grade a tick-sensitive series honestly on minute bars: a plan judged on minute-lake * data may only use minute-observable (or coarser) series. * * - **Unknown series** (no phonebook record) ⇒ refuse — an unresolvable series is never graded on a * guess (fail-closed, mirrors {@link classify}). * - **Org facts** ⇒ admit — an org fact (`pnl`, `plan.state`) is the acting session's own * bookkeeping, computed in-session and not read from the market-data lake, so lake granularity * does not bound its observability. * - **Market facts** ⇒ admit iff the fact's {@link ObservabilityClass} is `fidelity`-or-COARSER; * a finer class is refused. A market record missing a class also refuses (fail-closed — an * unclassified fact's observability cannot be confirmed). * * A `true` verdict never fires anything on its own; a `false` verdict carries the de-arm reason the * caller logs. Pure — no clock, no RNG. */ admitAtFidelity(ref, fidelity) { const rec = this.classify(ref); const label = headName(ref) ?? "(empty path)"; // Fail closed on the FIDELITY axis: an off-lattice fidelity (slipped past the type via `as` or // parsed lake/session JSON, e.g. "1min") is untrusted — refuse before any series is admitted, // including org facts (RUNTIME §2/§8; never a silent default). if (!isRung(fidelity)) { return { admit: false, reason: `unknown grading fidelity "${fidelity}" — not a recognized observability rung (${OBSERVABILITY_ORDER.join(", ")}); series "${label}" cannot be graded on an untrusted fidelity (fail-closed, RUNTIME §28)`, }; } if (isUnknown(rec)) { return { admit: false, reason: `series "${label}" has no phonebook record — an unknown series cannot be graded at ${fidelity} fidelity (fail-closed, RUNTIME §28)`, }; } if (rec.kind === "org") return { admit: true }; // session-internal bookkeeping — not a lake read const cls = rec.observabilityClass; if (cls === undefined) { return { admit: false, reason: `market series "${rec.name}" carries no observability class — cannot confirm it is observable at ${fidelity} fidelity (fail-closed)`, }; } if (isFinerThanFidelity(cls, fidelity)) { return { admit: false, reason: `series "${rec.name}" is ${cls}-observable but the session grades at ${fidelity} fidelity — a finer-than-fidelity series cannot be graded honestly; de-arm (fidelity-1, RUNTIME §28)`, }; } return { admit: true }; } /** * Record an **org** fact's phonebook entry on first authorized write (the implicit half of * hybrid registration). Idempotent by NAME — first write wins the source-stamp; later writes * to the same name (or a sibling path sharing the head) do not re-stamp. Taste-free: it * records the name/scope/source, it does not judge whether the write *should* exist (replay * culls). Returns the resulting record. Never registers over a platform (market) name. * * **Head-granularity caveat (v0, deliberate).** The record is keyed by the **head** segment * only (matching {@link classify}, which also keys on the head), so genuinely distinct sibling * series that share a head — `plan(x).state` vs `plan(x).rungs`, `fills.avg_px` vs * `fills.count` — collapse to ONE record, and the retained {@link SeriesScope scope path} is * whichever sibling wrote first (a deterministic first-write artifact, not a claim that the * others don't exist). Enumerating the phonebook in v0 therefore cannot distinguish collapsed * siblings. This matches the trigger routing, which keys market-vs-org on the head; per-path * org records (a record per full scope path, with a path-aware `classify`) are the province of * kestrel-cza.4, the child-scoped org-fact resolver. */ noteOrgWrite(segments, by) { const head = segments[0]; if (head === undefined) return UNKNOWN; // an empty path names nothing — fail closed const existing = this.records.get(head.name); if (existing !== undefined) return existing; // platform name or already-noted org name: first wins const reg = { name: head.name, kind: "org", scope: { space: "org", path: orgScopePath(segments) }, windowing: "scalar", // windowed org facts are not a v1 concept source: { kind: "authored", by }, }; this.records.set(head.name, reg); return reg; } /** A deterministic, serializable snapshot of the whole phonebook — records sorted by name, * deep-copied — for the determinism certification and for surfaces that enumerate the * phonebook. */ dump() { return [...this.records.values()] .map((r) => ({ ...r })) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } } /** The process-wide default phonebook, seeded with the platform market facts. The market side is * static and taste-free, so a shared default is deterministic; a caller that needs an isolated * phonebook (e.g. to observe implicit org registrations per session) constructs its own. */ export const defaultSeriesRegistry = new SeriesRegistry();