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.

324 lines (295 loc) 15.4 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 type { PathSegment } from "../lang/index.ts"; import type { Right } from "../bus/index.ts"; import { intrinsic } from "../fair/index.ts"; import { UNKNOWN, isUnknown, pathKey, type Resolved, type Unknown, type OrgFacts } from "../series/index.ts"; import { orgPathUnresolvableReason } from "./disarm.ts"; /** One observed fill folded into the ledger — a single leg that actually traded. * `strike`/`right` are BOTH present for an option leg and BOTH absent for a spot/equity leg * (ADR-0017 — a spot instrument has neither, and a fictional strike is never written). */ export interface LedgerFill { readonly plan?: string; readonly ref: string; readonly instrument: string; readonly side: "buy" | "sell"; readonly qty: number; readonly px: number; readonly strike?: number; readonly right?: Right; } /** The per-instrument contract multiplier (dollars per point). Injected so the ledger scales * intrinsic marks and cash flows to dollars exactly as the fill engine does. */ export type MultiplierOf = (instrument: string) => number; /** 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: string, strike: number | undefined, right: Right | undefined): string { 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 { readonly #fills: LedgerFill[] = []; readonly #planState = new Map<string, string>(); readonly #planRungs = new Map<string, number>(); readonly #planFills = new Map<string, number>(); readonly #spot = new Map<string, number>(); /** 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). */ readonly #childFacts = new Map<string, Map<string, number>>(); readonly #multiplierOf: MultiplierOf; constructor(multiplierOf: MultiplierOf) { this.#multiplierOf = multiplierOf; } /** Fold one observed fill. Increments the owning plan's fill count. */ recordFill(fill: LedgerFill): void { 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: string, state: string): void { this.#planState.set(plan, state); } /** Record that a plan placed another reload rung (read by `plan(x).rungs`). */ setPlanRungs(plan: string, rungs: number): void { this.#planRungs.set(plan, rungs); } /** Update the last observed spot for an instrument (drives the open mark). */ setSpot(instrument: string, px: number): void { this.#spot.set(instrument, px); } /** The last observed spot for an instrument, or `undefined`. */ spotOf(instrument: string): number | undefined { 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(): number { let cash = 0; const netQty = new Map<string, { instrument: string; strike?: number; right?: Right; qty: number }>(); 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, } as { instrument: string; strike?: number; right?: Right; qty: number }); 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(): number | typeof UNKNOWN { 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(): number { 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: string, fact: string, value: number): void { 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: string, quant: "any" | "all"): number | Unknown { let acc: number | undefined; 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: string): readonly number[] { const out: { child: string; v: number }[] = []; 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: string): string | undefined { return this.#planState.get(plan); } planRungs(plan: string): number { return this.#planRungs.get(plan) ?? 0; } planFills(plan: string): number { return this.#planFills.get(plan) ?? 0; } /** A deterministic, serializable snapshot for the determinism certification (RUNTIME §7). */ dump(): { fills: readonly LedgerFill[]; planState: readonly (readonly [string, string])[]; planRungs: readonly (readonly [string, number])[]; planFills: readonly (readonly [string, number])[]; spot: readonly (readonly [string, number])[]; childFacts: readonly (readonly [string, readonly (readonly [string, number])[]])[]; } { const sortEntries = <V>(m: Map<string, V>): (readonly [string, V])[] => [...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)] as const) .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 implements OrgFacts { readonly #ledger: BookLedger; /** 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"). */ readonly #onUnresolved: ((reason: string) => void) | undefined; constructor(ledger: BookLedger, onUnresolved?: (reason: string) => void) { this.#ledger = ledger; this.#onUnresolved = onUnresolved; } resolve(segments: readonly PathSegment[]): Resolved { 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: readonly PathSegment[]): readonly number[] | Unknown { 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: readonly PathSegment[]): Unknown { this.#onUnresolved?.(orgPathUnresolvableReason(pathKey(segments))); return UNKNOWN; } }