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