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.

280 lines (279 loc) 14.1 kB
/** * # engine/orgfacts — the book ledger + the OrgFacts backing (RUNTIME §2, §5) * * The engine's realization of the {@link OrgFacts} seam from `src/series`: the path-scoped * **org facts** that exist only in an acting session (`pnl`, `fills.avg_px`, `fills.count`, * `plan(x).state`, `plan(x).rungs`, `plan(x).fills`). `src/series` resolves market facts from * canonical state and delegates *org* facts to this backing; the trigger evaluator therefore * reads a plan-state chain (`plan(chase).state eq fired`) or a book P&L guard * (`pnl lt -200`) through here. * * The single mutable substrate is the {@link BookLedger}: the engine folds every observed * `ORDER fill` into it and stamps each plan's lifecycle state onto it, and {@link EngineOrgFacts} * is a **pure read view** over that ledger (it never mutates, never throws — an unresolvable * path reads {@link UNKNOWN}, RUNTIME §8). Because the evaluator's provider closes over the * same ledger instance, an org fact is always current as of the event being swept. * * ## P&L is mark-to-intrinsic (the honest daily fact) * `pnl` = realized cash-flow (`+` on sells, `−` on buys, per-contract × multiplier) **plus** * the residual open position marked at its **intrinsic** at the current spot — the same * `intrinsic(spot, strike, right)` the fill engine settles against (RUNTIME §6). Before any * spot is known the open mark contributes `0`; with no fills at all `pnl` is `0` (a flat book, * not UNKNOWN — a resolved fact). */ import { intrinsic } from "../fair/index.js"; import { UNKNOWN, isUnknown, pathKey } from "../series/index.js"; import { orgPathUnresolvableReason } from "./disarm.js"; /** A `(instrument, strike, right)` position key. A spot fill (no strike/right, ADR-0017) keys * on the instrument alone — `<instrument>|spot` — so spot lots net against each other and never * against an option leg. */ function legKey(instrument, strike, right) { return strike === undefined || right === undefined ? `${instrument}|spot` : `${instrument}|${strike}|${right}`; } /** * The single mutable org substrate. The engine folds fills and stamps plan states; the read * view ({@link EngineOrgFacts}) and the engine both hold the same instance so facts are always * current. Deterministic: fills are appended in observation order and every derived number is a * pure function of that ordered log + the current spot. */ export class BookLedger { #fills = []; #planState = new Map(); #planRungs = new Map(); #planFills = new Map(); #spot = new Map(); /** Child-scope org aggregates: `childName → (factName → value)`. A parent Pod folds each child's * own published aggregate (`drawdown`, `pnl`, …) here so a PM wake can quantify across the pod * (`children(any).drawdown`, RUNTIME §5 / CONTEXT: Pod). */ #childFacts = new Map(); #multiplierOf; constructor(multiplierOf) { this.#multiplierOf = multiplierOf; } /** Fold one observed fill. Increments the owning plan's fill count. */ recordFill(fill) { this.#fills.push(fill); if (fill.plan !== undefined) { this.#planFills.set(fill.plan, (this.#planFills.get(fill.plan) ?? 0) + 1); } } /** Stamp a plan's current lifecycle state (read by `plan(x).state`). */ setPlanState(plan, state) { this.#planState.set(plan, state); } /** Record that a plan placed another reload rung (read by `plan(x).rungs`). */ setPlanRungs(plan, rungs) { this.#planRungs.set(plan, rungs); } /** Update the last observed spot for an instrument (drives the open mark). */ setSpot(instrument, px) { this.#spot.set(instrument, px); } /** The last observed spot for an instrument, or `undefined`. */ spotOf(instrument) { return this.#spot.get(instrument); } /** Book realized cash-flow (`+` sell, `−` buy) + residual open position marked at intrinsic, * per-contract × multiplier (see file header). A flat/empty book is `0`, never UNKNOWN. */ pnl() { let cash = 0; const netQty = new Map(); for (const f of this.#fills) { const mult = this.#multiplierOf(f.instrument); cash += (f.side === "sell" ? 1 : -1) * f.qty * f.px * mult; const k = legKey(f.instrument, f.strike, f.right); const cur = netQty.get(k) ?? { instrument: f.instrument, ...(f.strike !== undefined ? { strike: f.strike } : {}), ...(f.right !== undefined ? { right: f.right } : {}), qty: 0, }; cur.qty += (f.side === "buy" ? 1 : -1) * f.qty; netQty.set(k, cur); } let openMark = 0; for (const pos of netQty.values()) { if (pos.qty === 0) continue; const spot = this.#spot.get(pos.instrument); if (spot === undefined) continue; // no spot yet ⇒ contributes 0 (honest, not a guess) // Option position: marked at intrinsic (the honest daily fact). Spot position (ADR-0017): // marked at the spot itself — mark-to-market, no strike to take an intrinsic of. const mark = pos.strike === undefined || pos.right === undefined ? spot : intrinsic(spot, pos.strike, pos.right); openMark += pos.qty * mark * this.#multiplierOf(pos.instrument); } return cash + openMark; } /** Quantity-weighted average fill price across every observed fill, or UNKNOWN with no fills. */ avgFillPx() { let q = 0; let n = 0; for (const f of this.#fills) { q += f.qty; n += f.qty * f.px; } return q === 0 ? UNKNOWN : n / q; } /** Total number of observed fills (lots). */ fillCount() { return this.#fills.length; } /** Publish (upsert) a child scope's org aggregate — the write side of `children(any).<fact>`. * The parent Pod node receives each child's own aggregate and folds it here; deterministic, a * pure upsert keyed by `(child, fact)`. */ setChildFact(child, fact, value) { let facts = this.#childFacts.get(child); if (facts === undefined) { facts = new Map(); this.#childFacts.set(child, facts); } facts.set(fact, value); } /** A **monotone display summary** of a child-scoped fact across every child that has published it: * `any` reads the worst (**max**), `all` reads the best (**min**). UNKNOWN when no child has * published the fact. This is a comparator-blind scalar and is therefore used ONLY for display / * inspection panes — a *guard* comparison (`children(any|all).<fact> OP …`) must NOT fold to this * scalar, because the ∃/∀ meaning depends on the comparator direction (a `<` guard inverts which * child is the deciding one). The trigger evaluator resolves guards through {@link childValues} * and folds the comparator per member instead. Order-independent (max/min commute) ⇒ deterministic. */ childFact(fact, quant) { let acc; for (const facts of this.#childFacts.values()) { const v = facts.get(fact); if (v === undefined) continue; acc = acc === undefined ? v : quant === "any" ? Math.max(acc, v) : Math.min(acc, v); } return acc === undefined ? UNKNOWN : acc; } /** The per-member value vector for a child-scoped fact — every child that has published `fact`, * in deterministic (child-name-sorted) order. The comparator-honest input to the ∃/∀ fold: the * trigger applies the authored comparator to each member, then combines by quantifier (`any` = ∃, * `all` = ∀), so a `<`/`<=` guard can never invert (the fail-open {@link childFact} would hide). * Empty when no child has published the fact — the caller reads that as a genuinely unresolvable * quantified path and de-arms with a logged reason (RUNTIME §8), never a silent pass. */ childValues(fact) { const out = []; for (const [child, facts] of this.#childFacts.entries()) { const v = facts.get(fact); if (v !== undefined) out.push({ child, v }); } out.sort((a, b) => (a.child < b.child ? -1 : a.child > b.child ? 1 : 0)); return out.map((e) => e.v); } planState(plan) { return this.#planState.get(plan); } planRungs(plan) { return this.#planRungs.get(plan) ?? 0; } planFills(plan) { return this.#planFills.get(plan) ?? 0; } /** A deterministic, serializable snapshot for the determinism certification (RUNTIME §7). */ dump() { const sortEntries = (m) => [...m.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); return { fills: [...this.#fills], planState: sortEntries(this.#planState), planRungs: sortEntries(this.#planRungs), planFills: sortEntries(this.#planFills), spot: sortEntries(this.#spot), childFacts: [...this.#childFacts.entries()] .map(([child, facts]) => [child, sortEntries(facts)]) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)), }; } } /** * The pure read view over a {@link BookLedger} that satisfies the `src/series` {@link OrgFacts} * seam. Resolves the v1 org vocabulary and reads UNKNOWN for anything else (fail-closed, * absent-with-reason — the referencing statement de-arms, it never crashes, RUNTIME §8). */ export class EngineOrgFacts { #ledger; /** Injected de-arm sink (the engine owns the mutable state; this read view stays pure). Invoked * with a path-naming reason ONLY when a path is **structurally unresolvable** as an org fact — * an unknown vocabulary, not a recognized-but-not-yet-warm fact (`plan(x).state` before its * stamp, `fills.avg_px` before a fill). Lets the engine turn a SILENT UNKNOWN into a de-arm with * a logged reason (RUNTIME §8 / CONTEXT: "de-arms cleanly with a logged reason"). */ #onUnresolved; constructor(ledger, onUnresolved) { this.#ledger = ledger; this.#onUnresolved = onUnresolved; } resolve(segments) { const head = segments[0]; if (head === undefined) return UNKNOWN; // plan(x).<field> — path-scoped plan facts (chaining + inventory triggers). if (head.name === "plan" && head.selector?.kind === "sel-name") { const plan = head.selector.name; const field = segments[1]?.name; switch (field) { case "state": { const s = this.#ledger.planState(plan); return s ?? UNKNOWN; // recognized path, not-yet-stamped ⇒ transient UNKNOWN (not a de-arm) } case "rungs": return this.#ledger.planRungs(plan); case "fills": return this.#ledger.planFills(plan); default: return this.#unresolvable(segments); // unknown plan field ⇒ fail closed loudly } } // children(any|all).<field> — child-scoped aggregate across the pod's children (RUNTIME §5). // Resolves the field across every child that published it, folded by the authored quantifier; // no child has it yet ⇒ genuinely unresolvable, de-arm with a logged reason (not a silent 0). if (head.name === "children" && head.selector?.kind === "sel-quant") { const field = segments[1]?.name; if (field === undefined) return this.#unresolvable(segments); const v = this.#ledger.childFact(field, head.selector.q); return isUnknown(v) ? this.#unresolvable(segments) : v; } // Book-scoped scalars. if (segments.length === 1 && head.selector === undefined) { if (head.name === "pnl") return this.#ledger.pnl(); } if (head.name === "fills" && head.selector === undefined) { const field = segments[1]?.name; if (field === "avg_px") return this.#ledger.avgFillPx(); // recognized; UNKNOWN before a fill if (field === "count") return this.#ledger.fillCount(); return this.#unresolvable(segments); // unknown fills field ⇒ fail closed loudly } return this.#unresolvable(segments); } /** Resolve a **quantified** org operand (`children(any|all).<fact>`) to the vector of per-member * values so the trigger folds the comparator per member (∃/∀), honoring the comparator direction. * UNKNOWN (with the same loud, path-naming de-arm as {@link resolve}) when the path is genuinely * unresolvable — an unknown quantified head, a missing field, or a pod where no member has * published the fact (fail-closed, never a silent pass — RUNTIME §8). */ quantify(segments) { const head = segments[0]; if (head === undefined) return UNKNOWN; if (head.name === "children" && head.selector?.kind === "sel-quant") { const field = segments[1]?.name; if (field === undefined) return this.#unresolvable(segments); const vals = this.#ledger.childValues(field); return vals.length === 0 ? this.#unresolvable(segments) : vals; } return this.#unresolvable(segments); } /** Fail closed on a structurally-unresolvable org path: log a path-naming de-arm reason through * the injected sink (if any), then read UNKNOWN. The read stays pure — the engine owns the sink * and decides the de-arm; this view only reports the reason (RUNTIME §8). */ #unresolvable(segments) { this.#onUnresolved?.(orgPathUnresolvableReason(pathKey(segments))); return UNKNOWN; } }