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.

855 lines (813 loc) 218 kB
/** * # engine/plans — the plan lifecycle runtime (RUNTIME §5) * * The heart of the system: a **pure, injected-time** {@link PlanEngine} that arms parsed * Kestrel documents, sweeps their triggers over a bus event stream, and drives every plan * through its lifecycle `authored → armed → fired → managing → done(filled|expired|invalidated)` * (RUNTIME §5) with **fire-then-inform** semantics — on a definite trigger it submits children * through the Gate *and* emits a `WAKE` in the same step (ARCHITECTURE §1: slow judgment * compiled into a fast reflex). * * It sits **above** the shipped substrate and owns none of it: * - price resolution is delegated to {@link resolvePrice} (`./pricing.ts`, RUNTIME §4) — the one * place that knows `fair`/`mid`/`peg`/`esc`/`cap`/`floor`; * - triggers are evaluated by the injected {@link TriggerEvaluator} factory over a * {@link SeriesProvider} (`src/series`, tri-state + causal, RUNTIME §3) — one evaluator per * trigger per plan instance so each carries its own edge memory; * - orders go out through the injected {@link Gate} (`submit`/`cancel`) and fills come *back* as * `ORDER fill` bus events observed in {@link PlanEngine.onEvent} — the engine never simulates a * fill itself (the fill engine behind the gate is the judge, RUNTIME §6); * - `pnl` / `fills.*` / `plan(x).*` org facts are served from the engine's {@link BookLedger} * through {@link EngineOrgFacts} (`./orgfacts.ts`). * * All time is injected (RUNTIME §0): every method takes `now` from the event; the engine holds * no clock and no RNG. **Same bus + same armed documents ⇒ byte-identical emitted stream** — the * determinism invariant, certified by comparing {@link PlanEngine.dumpState} across two replays. * * ## Sweep order (one pass per event — never a fixpoint) * Each trigger is evaluated **exactly once per event** so its causal edge memory (crosses, * held, within, nth) advances correctly — re-evaluating a `crosses` twice in one tick would * consume the transition. Consequence: plan **chaining** (`plan(a).fired` gating plan `b`) * resolves on the **next** event, not the same one (the fired latch is on the ledger, read on * the following sweep — RUNTIME §5 "plan-state triggers read the bus lifecycle"). * * The per-event pass: * 1. **ingest** — fold TICK into spot/book, REGIME into the tag store, correlate `ORDER` * fill/cancel/reject into inventory + the ledger. * 2. **reconcile arming** — an armed plan whose regime gate goes UNKNOWN de-arms; an authored * plan whose gate is (re)satisfied arms; a past-TTL armed plan expires (RUNTIME §3/§5). * 3. **fire** — collect armed plans whose `WHEN` is definitely true, admit by envelope + * priority arbitration (higher wins, ties → earlier armed), fire the admitted (submit + * inform). * 4. **manage** — for fired plans: line/plan `CANCEL-IF`, `INVALIDATE`, `EXIT` (+ esc/STAND), * `RELOAD` rungs, `TP` maintenance, `TTL`, and peg/esc reprice of working orders. */ import type { ArmClause, CancelIfClause, ExitClause, ExpirySelector, InvalidateClause, Leg, Module, OrderPolicy, PlanStatement, PriceExpr, ReloadClause, RiskLine, StrikeSpec, TpClause, Trigger, } from "../lang/index.ts"; import type { NewBusEvent, BusEvent, OptionQuote, PlanOutcome, PlanState, Right } from "../bus/index.ts"; import { foldBook, type BookState } from "../bus/index.ts"; import { intrinsic, buildSurface, impliedForward, interpIv } from "../fair/index.ts"; import { greeks } from "../fair/black76.ts"; import type { ExecutionFairInput } from "../fair/index.ts"; /** The τ provider `@fair` is built through — `(now, expiry) => years | null`, `null` = unknowable * (fail closed, kestrel-wcnd). Structurally identical to `src/session/clock.ts`'s `FairTauProvider` * and REDECLARED here on purpose: `src/session` imports `src/engine`, so importing the type back * would invert the layering (the same leaf-purity reason `src/bus` redeclares `FillSupport`). The * one shipped implementation is `expiryTauYears`; the CLI's `--tau-hours` constant is the other. */ type FairTauProvider = (now: number, expiry?: string) => number | null; import { guardIntent, type GuardIntentInput, type Moneyness } from "../fill/index.ts"; import { durationMs, isUnknown, UNKNOWN, TriggerEvaluator, defaultSeriesRegistry, type SeriesProvider, type SeriesRegistry, type Resolved, type OrgFacts, type TriState, type Unknown, } from "../series/index.ts"; import type { Baseline, FillEvent, SeriesRef, StructuralEvent } from "../lang/index.ts"; import { assertNever } from "../lang/index.ts"; import { unknownSeriesRejection } from "../validate/index.ts"; import type { SessionPhase } from "../bus/index.ts"; import { isUnresolvable, resolvePrice, type PriceCtx, type PriceLine, type RepriceTokenBucket } from "./pricing.ts"; import { BookLedger, EngineOrgFacts } from "./orgfacts.ts"; import { perLegFillQualifierReason } from "./disarm.ts"; import { sha256 } from "../crypto/sha256.ts"; import type { ArmRefusalCode, ArmRefusal, ArmReport, ArmNotice } from "../protocol/index.ts"; /** * A whole-document arm refusal that MUST halt arming (OSS-ADR-0045). It carries a * wire-stable `.code` from {@link ArmRefusalCode} so a consumer routes on the code, * NEVER on the message string. The one arm throw today is `plan-name-in-use` (the F4 * live-name collision): arming would clobber the by-name ledger, so — unlike the * per-statement `unknown-series` refusal, which is returned as DATA — it fails the * whole document. `refusals`, when present, carries the underlying coded refusals a * driver aggregated into this throw (so nothing is silently dropped at the boundary). */ export class ArmError extends Error { readonly code: ArmRefusalCode; readonly refusals?: readonly ArmRefusal[]; constructor(code: ArmRefusalCode, message: string, refusals?: readonly ArmRefusal[]) { super(message); this.name = "ArmError"; this.code = code; if (refusals !== undefined) this.refusals = refusals; } } /** * The stable lead marker of the `adoption-bound-nothing` notice (kestrel-c25w) — the byte prefix of the * message {@link PlanEngine.armDocument} emits as a PLAN-lifecycle reason (and mirrors onto * {@link ArmReport.notices} / `validateArm.warnings`) when a valid `ARM … foreach held leg` adopter binds * ZERO held legs. A consumer that has only the projected lifecycle reason (the day report carries no * separate code on a lifecycle step) matches on THIS constant instead of hardcoding the prose, so the * default `day` terminal can surface the notice on the plan's own line (kestrel-gjx5 residual). The * message begins with this exact string; do not reword it without updating the constant. */ export const ARM_BOUND_NOTHING_MARKER = "ARM bound nothing"; /** * Aggregate one-or-more per-statement {@link ArmRefusal}s into a single throwable {@link ArmError} * for a session-driver boundary that refuses the WHOLE document (sim/paper): the message enumerates * EVERY refusal (nothing silently dropped) and the error carries the structured `refusals` + the * first refusal's `code` so a consumer routes on the code, never on the message. Callers pass a * non-empty list. */ export function armRefusalError(refusals: readonly ArmRefusal[]): ArmError { const code: ArmRefusalCode = refusals[0]?.code ?? "unknown-series"; const message = `arm: ${refusals.length} statement(s) refused — ` + refusals.map((r) => `[${r.code}] ${r.message}`).join(" | "); return new ArmError(code, message, refusals); } // ───────────────────────────────────────────────────────────────────────────── // Public seams // ───────────────────────────────────────────────────────────────────────────── /** Which clause authored an order — carried onto the intent for grading/lineage. `adopted` is * NOT a gate order: it is the synthetic already-filled long child the cross-Plan inventory * handshake attaches to an explicitly ARM-bound replacement (kestrel-22j.14) so its covered sell * reads as covered — bookkeeping only, never submitted to the Gate. */ export type OrderRole = "entry" | "reload" | "tp" | "exit" | "adopted"; /** A concrete, resolved order the engine hands the {@link Gate}. A single defined-risk option * leg — or a spot/equity leg (ADR-0017) — at a resting price, with its price-resolution * `sourceAnnotation` (a silent mid is forbidden, RUNTIME §4). `ref` is the engine's * deterministic id; the gate returns the ref the engine correlates later `ORDER` fills against. * `strike`/`right` are BOTH present for an option leg and BOTH absent for a spot leg — a spot * instrument has neither, and a fictional strike is never written (ADR-0017); the gate routes * on their absence. */ export interface OrderIntent { readonly ref: string; /** The authoring plan's NAME (Lineage key). */ readonly plan: string; /** The authoring plan's exact INSTANCE identity (kestrel-22j.15) — the id minted at registration, carried * onto the order's bus evidence so an order joins its exact instance, not merely its recurring name. */ readonly plan_instance: string; readonly role: OrderRole; readonly instrument: string; readonly side: "buy" | "sell"; readonly qty: number; /** The option strike; ABSENT for a spot/equity leg (ADR-0017). */ readonly strike?: number; /** The option right; ABSENT for a spot/equity leg (ADR-0017). */ readonly right?: Right; readonly px: number; readonly sourceAnnotation: string; /** Coarse moneyness vs the decision-time underlier (strike vs spot), derived at submission and * threaded into the FillOrder so the directional far-OTM maker-fair guard runs end to end * (kestrel-9gu.6). Absent ⇒ spot was unresolvable for a BUY (the symmetric path; a BUY carries no * bounded-risk leak). A SELL never carries an absent moneyness — an unresolvable one fails closed * to the conservative far-OTM cap (see {@link deriveOrderGuard}). */ readonly moneyness?: Moneyness; /** Covered-state threaded into the FillOrder: `true` iff a covering wing of a defined-risk package * (exempt from the SELL far-OTM cap). The engine authors only single-leg closes of a held long, so * every SELL is a STANDALONE offer (`covered: false`) — the conservative default. Absent for a BUY. */ readonly covered?: boolean; } /** The moneyness + covered-state the engine derives for a submitted leg (kestrel-9gu.6) — the * directional-guard evidence threaded onto the {@link OrderIntent}. `unresolved` is `true` only on a * SELL whose spot was unclassifiable, where the conservative far-OTM cap was applied fail-closed (so * the caller LOGS the fallback rather than letting the sell slip through the guard uncapped). */ export interface OrderGuardEvidence { readonly moneyness?: Moneyness; readonly covered?: boolean; readonly unresolved: boolean; } /** * Derive the directional far-OTM guard evidence for a leg at submission — the engine-side * {@link OrderGuardEvidence} projection of the {@link import("../fill/index.ts").GuardedIntent} * verdict authored by the ONE owner (`guardIntent`, fill/guarded-intent, kestrel-vav2). The * classification, decision-time spot resolution, and the fail-closed fork all live in that owner — * this function is the thin adapter that strips the intent's `side` for the engine's evidence shape, * so the far-OTM standalone SELL cap, the covered-wing exemption, and the BUY crossing unlock all run * END TO END through the fill model. Pure (no clock/RNG). * * - **BUY** — the guard only ever UNLOCKS a far-OTM bid (crossing-print), never a bounded-risk leak, * so an unresolvable spot is safe: classify when we can, else take the symmetric path (no moneyness). * `covered` is not meaningful for a buy. * - **SELL** — the engine's never-naked boundary authors only single-leg closes of a held long, which * are STANDALONE offers in the fill-model sense (`covered: false`) — a genuine multi-leg * combo-anchored short is not authored here (a FORK; if one is ever authored it must set * `covered: true` explicitly). If spot is unresolvable we cannot classify the wing, so we **fail * closed** to the conservative far-OTM cap (`deep_otm`, standalone) and flag it — NEVER a silent * default and never a silent bypass (BOUNDED-RISK / never-naked, RUNTIME §8). */ export function deriveOrderGuard( side: "buy" | "sell", spot: number | undefined, strike: number, right: Right, ): OrderGuardEvidence { const g = guardIntent({ side, strike, right, resolveSpot: () => spot }); return { ...(g.moneyness !== undefined ? { moneyness: g.moneyness } : {}), ...(g.covered !== undefined ? { covered: g.covered } : {}), unresolved: g.unresolved, }; } /** The execution seam (RUNTIME §7): `submit` rests an order (returning the ref to correlate * fills against) and `cancel` pulls one. In `sim` the gate wraps the fill engine; in `live` it * wraps the broker adapter — one engine path, different gate. */ export interface Gate { submit(intent: OrderIntent): string; cancel(ref: string): void; } /** Where the engine writes its own lifecycle output (PLAN transitions + WAKE informs). The * real {@link import("../bus/write.ts").BusWriter} adapts to this; a recording fake backs the * tests. The engine never emits `ORDER` events — those come from the gate/fill engine. */ export interface BusSink { emit(ev: NewBusEvent): void; } /** The per-instrument facts the engine needs to turn a leg into a concrete order: tick size * (snap + peg hysteresis), strike grid step (relative/ATM strikes), contract multiplier * (premium/P&L in dollars), and the plan-side role. */ export interface InstrumentSpec { readonly symbol: string; readonly tickSize: number; readonly strikeStep: number; readonly multiplier: number; readonly role?: "signal" | "exec"; } /** Everything a {@link PlanEngine} is constructed with. All time is injected (`now` seeds the * clock; every subsequent time comes from the swept event). */ export interface PlanEngineOptions { readonly instruments: readonly InstrumentSpec[]; /** The market-fact provider (`src/series`). Org facts (`pnl`, `plan(x).state`, …) are served * by the engine's own {@link BookLedger}; this provider supplies `spot`/`vwap`/windows/phase * and the optional detector/fill/calendar hooks. */ readonly seriesProvider: SeriesProvider; /** The shared series phonebook (cza.1) the engine's market-vs-org routing dials — the same table * the injected {@link seriesProvider} routes through, so their org/market split cannot skew. The * market side is platform-declared and static, so the process-wide {@link defaultSeriesRegistry} * is the deterministic default; inject an isolated one only to observe per-session org registrations. */ readonly registry?: SeriesRegistry; /** A fresh {@link TriggerEvaluator} per trigger per plan (each owns its causal edge memory). * Defaults to `() => new TriggerEvaluator()`. */ readonly newEvaluator?: () => TriggerEvaluator; readonly gate: Gate; readonly busWriter: BusSink; /** The global reprice token bucket for `peg` maintenance (RUNTIME §4). Absent ⇒ unlimited. */ readonly repriceBucket?: RepriceTokenBucket; /** Dollar value of one risk unit `R` — the budget clamp scale (`budget_r × rUsd`). */ readonly rUsd: number; /** The initial injected time (epoch ms). */ readonly now: number; /** Optional injected time-to-expiry (years) for building `@fair` (RUNTIME §4) — resolved against * the valued CONTRACT's own expiry (kestrel-wcnd), which {@link PlanEngine} threads from the book * it is pricing off. Absent (or `null` for an unresolvable expiry) ⇒ `@fair` resolves through its * annotated book fallback, which ALWAYS names itself (never a silent mid). */ readonly fairTauYears?: FairTauProvider; /** The COMPLETE set of regime scopes this tape will ever write (kestrel-ocyf) — available only when * the caller holds the whole bus up front (sim / replay / day; `runSimSession` pre-scans REGIME * events). When present, {@link PlanEngine.armDocument} can tell a PROVABLY-dead regime gate (scope * not in this set ⇒ it can never arm on this tape) from one legitimately waiting for a later REGIME * write. Absent (live / stepped-unknown) ⇒ the arm notice stays conditional, never definitive. */ readonly tapeRegimeScopes?: readonly string[]; } // ───────────────────────────────────────────────────────────────────────────── // Internal runtime // ───────────────────────────────────────────────────────────────────────────── /** A nesting risk envelope (a Book, or a Pod with an `R` risk-budget). A fire must fit every * ancestor (RUNTIME §5). `firedCount` (concurrency) aggregates the subtree via increments up the * parent chain; the DOLLAR reservation is derived on demand from the subtree's OPEN exposure * ({@link PlanEngine.#envelopeExposureUsd}, bd 1a8) — a round-trip to flat frees it. */ interface Envelope { readonly name: string; /** kestrel-eywk: MUTABLE on the engine-minted `default` envelope ONLY — the standing authored document * RESTATES its own bound on every authored arm (a supersede that raises `budget` raises the envelope; a * synthesized one-shot inherits and can never widen it). Author-minted POD/BOOK envelopes are never * restated: their bound is the one their author wrote. */ totalBudgetUsd: number | null; readonly maxConcurrent: number | null; readonly parent: Envelope | null; /** Concurrency count — the number of open (fired, not-done) positions in the subtree. Incremental * (bumped on fire/reload, dropped on finish). The DOLLAR reservation is NOT stored here — it is * derived on demand by {@link PlanEngine} from the subtree's open exposure (bd 1a8). */ firedCount: number; } /** A HELD leg's identity (kestrel-h5nx): the `(strike, right)` never-naked coverage key plus the * instrument it trades. A spot/equity leg carries `undefined` for both (ADR-0017 — no fictional * strike), so spot legs net against each other and never against an option leg. Every multi-leg sell * surface (TP, EXIT) allocates across these and names each sell for the leg that covers it. */ interface HeldLeg { readonly strike: number | undefined; readonly right: Right | undefined; readonly instrument: string; } /** The resolved, priced form of one leg — what {@link PlanEngine.resolveLeg} hands back when the leg's * strike and price line both resolve against the current book (`null` when they do not: fail-closed). */ interface BuiltLeg { readonly instrument: string; readonly strike?: number; readonly right?: Right; readonly qty: number; readonly px: number; readonly ann: string; readonly multiplier: number; readonly priceLine?: PriceLine; } interface ChildOrder { ref: string; readonly role: OrderRole; readonly side: "buy" | "sell"; readonly qty: number; readonly instrument: string; /** The option strike; ABSENT for a spot/equity child (ADR-0017 — no fictional strike). All * leg-keyed netting compares `strike`+`right` directly, so spot children (both `undefined`) * net against each other and never against an option leg. */ readonly strike?: number; /** The option right; ABSENT for a spot/equity child (ADR-0017). */ readonly right?: Right; px: number; ann: string; filled: boolean; /** Cumulative filled contracts against this child (supports partial fills, F/Bug-2). `filled` * latches true once `filledQty >= qty`; the target-qty cap sizes reprice remainders off this. */ filledQty: number; /** Cumulative filled COST of this child (`Σ thisFill × fillPx`) — the per-LEG basis source * (kestrel-m9i.41). The plan-total {@link PlanRuntime.buyCostSum} blends every leg together, which * is meaningless the moment a plan holds MORE THAN ONE leg (a straddle's call and put have * different premiums, so a plan-total `TP +50%` would target 1.5× the *blended* basis on both). * Summing this over a leg's filled BUY children yields that leg's own average entry premium. */ filledCost: number; cancelled: boolean; readonly placedAt: number; escStage: number; readonly priceLine?: PriceLine; readonly lineCancelIf?: Trigger; readonly tpTier?: number; /** Management authority for this filled leg has been HANDED OFF to an explicitly ARM-bound * replacement (the cross-Plan inventory handshake, kestrel-22j.14). A transferred child is * excluded from this plan's {@link PlanEngine.netHeldForLeg} / {@link PlanEngine.restingSellForLeg} * so the source can no longer rest a sell against inventory it no longer manages — the moved claim * lives as a fresh `adopted` long on the replacement. The Book {@link BookLedger} fill log is never * touched (quantity conserved: only the per-plan management claim moved, never the trade record). */ transferred?: boolean; } interface EntrySpec { readonly legs: readonly Leg[]; readonly price: PriceExpr; readonly policy?: OrderPolicy; } interface PlanRuntime { readonly plan: PlanStatement; readonly name: string; /** The exact Plan-instance identity (kestrel-22j.15), minted DETERMINISTICALLY at registration and * carried onto every PLAN/WAKE transition + authored ORDER this instance emits, so a legal same-name * replacement never collapses into this instance's lifecycle trace at report projection. */ readonly instanceId: string; readonly envelope: Envelope; state: PlanState; fired: boolean; done: boolean; armedAtSeq: number | null; armedAtTs: number | null; filledBuyQty: number; filledSellQty: number; buyCostSum: number; basisPx: number | null; /** The injected event time of this plan's FIRST acquiring (buy) fill — the "held since" anchor a * 0DTE `EXIT held <dur>` time-held stop measures from (docs/results/fomc-options-axis). Null until * the position is first acquired; set once (first-write-wins) and never cleared, so a momentarily- * flat-then-re-acquired plan keeps its ORIGINAL entry clock. Pure (from the injected `now`, no wall * clock/RNG, RUNTIME §0), so two replays agree byte-for-byte. */ firstAcquiredAt: number | null; reloadsHalted: boolean; acquisitionHalted: boolean; /** The agent has FLATTENED this plan's position (bd if0 / 75n): acquisition is halted, its resting * orders for the instrument were cancelled, and a covered close crosses to settle. The latch stops * {@link PlanEngine.#maintainTP} from re-posting the TP (the re-post that defeated a bare cancel) and * suppresses the authored EXIT loop, so the flatten actually neutralizes management — the plan finishes * `done(expired, "flattened")` the instant it goes flat with nothing working. Internal-only (never in * dumpState): the visible signal is the graded-bus `flatten` WAKE + the `flattened` done reason. */ flattened: boolean; /** This plan took on ADOPTED inventory via the ARM inventory-binding handshake (kestrel-22j.14 / * kestrel-r866). Set the instant {@link PlanEngine.#transferLeg} attaches an adopted long. A PURE * adopter (`entries.length === 0` — an `ARM … foreach held leg` binding with no fireable `DO`) never * reaches the fire path, so it would sit `armed` forever with its EXIT/TP inert (the tracer-7 "escape * hatch is INERT on the day handshake" defect). This flag drives {@link PlanEngine.#beginAdoptedManagement} * to promote such a plan armed→managing so its authored management clauses actually run. Internal-only. */ adoptedInventory: boolean; exitStood: boolean; reloadRungs: number; /** The org-path de-arm reason currently latched on this plan (an armed WHEN naming a genuinely * unresolvable org path). Non-null ⇒ the loud de-arm WAKE has already been informed for this * episode; cleared the instant the WHEN resolves again, so the notice fires once per edge, not * every sweep (RUNTIME §8). */ orgDeArmReason: string | null; /** The fire-time bounded-risk BUDGET refuse currently latched on this plan: an armed plan whose * WHEN is true and whose order builds, but whose premium won't fit an ancestor risk-envelope's * budget, so it is refused at admission and stays armed. Non-null ⇒ the loud refuse has already * been emitted for this episode; cleared the instant the plan fits (or fires), so the notice fires * ONCE per edge, not every sweep — otherwise an authored plan whose WHEN stays true would storm the * stream. Mirrors {@link orgDeArmReason}'s edge-latch idiom (kestrel-m9i.32.1: the refuse was * previously SILENT — lifecycle stayed armed, orders=[], no journal — invisible to the agent). */ envelopeRefuseReason: string | null; /** The ARM-TIME gate-block reason currently latched on this plan (kestrel-50w): a plan whose regime * gate is UNSATISFIABLE — the gated tag is UNKNOWN (no feed) or holds a different value — so it can * never leave `authored` to arm. Non-null ⇒ the frame renders `authored (blocked: <reason>)` so the * bare-`authored` state (a live plan awaiting its WHEN) is DISTINGUISHABLE from an authored plan stuck * on an unsatisfiable gate (the phantom-position trap: an agent read `authored` as armed-and-live and * traded a position that never existed). PURE — derived from {@link #regimeSatisfied} over the injected * tag store, no wall clock/RNG; cleared the instant the gate is satisfied and the plan arms. This is the * ARM-TIME counterpart to {@link envelopeRefuseReason}'s FIRE-TIME budget refuse (kestrel-m9i.32.1): * both surface a reason the engine already knows but the frame previously hid ("engine knows, frame * hides"). It rides dumpState (the frame's projection source) only — NEVER the graded bus. */ armBlockReason: string | null; /** Has this plan's entry WHEN ever evaluated to a DEFINITE verdict (`true`/`false`, i.e. NOT UNKNOWN) * since it armed? (kestrel-y6vo). An armed plan whose WHEN references a market series the tape never * writes a value for — `velocity(5m)`, a percentile baseline, … — reads UNKNOWN every sweep (fail- * closed), so the trigger silently never confirms and the plan expires at TTL having placed zero * orders with NO reason surfaced (the ungated hero-example silent-0, kestrel-hk9u regression). This * latch lets {@link PlanEngine.#ttlExpiryReason} tell that DOA class ("trigger never RESOLVED — an * operand series never wrote a value") apart from a healthy untriggered plan ("trigger never * CROSSED", `whenEverDefinite === true`) at the ttl-expiry drop site. Set the instant the WHEN * resolves definite; never cleared (a once-resolved trigger is not the unwritten-series class). PURE — * derived from the injected bus (the same evaluate a replay re-runs), no wall clock/RNG (RUNTIME §0), * so it reconstructs identically on replay and need not ride dumpState. */ whenEverDefinite: boolean; /** Non-null once an AUTHORED plan's fire was fully clamped by its own plan budget — every entry leg is * larger than the budget, so {@link PlanEngine.#resolveEntries} admits nothing and the plan stays armed * with `orders=[]` (kestrel-kglw / kestrel-markets-a4ya). Before this latch the computed * `order refused: exceeds plan budget` reason was DROPPED for authored plans (only a SYNTHESIZED * one-shot surfaced it), so a budget-DOA plan was the exact silent never-fired class. The latch (a) * edge-gates the loud PLAN reject so it emits ONCE, not once per sweep, and (b) lets * {@link PlanEngine.#neverFiredExpiryReason} name the budget cause at the ttl-expiry drop site instead * of a bare `ttl`. Cleared the instant a fire admits a leg (the clamp lifted). PURE — derived from the * injected `rUsd` × budget and the resolved leg premium, no wall clock/RNG (RUNTIME §0). */ budgetRefusedReason: string | null; /** Non-null once a `Nd` DELTA-targeted leg (`buy 1 16d P`, kestrel-d6nk) fired but its band could NOT * be resolved to a concrete quotable strike — a degenerate/empty vol surface near the 0DTE close, an * absent book, or no strike quoted for the leg's right. Delta selection is UPSTREAM of the fill: once * the band resolves it becomes a NORMAL single-leg order against a real book quote (identical to * atm/+N), so a resolvable band fires and this stays null. UNRESOLVABLE is the surface.ts-documented * empty-surface case — and it must be LOUD, never the pre-d6nk silent parse+arm+meter+0-fill (the * one strike language options traders actually use, billing but never filling). Mirrors * {@link budgetRefusedReason}'s edge-latch: (a) the loud PLAN reject emits ONCE per distinct reason, * not per sweep, and (b) {@link PlanEngine.#neverFiredExpiryReason} / the never-fired digest name the * delta cause at the drop site instead of a bare `ttl`. Cleared the instant a delta leg resolves to a * strike. PURE — buildSurface+greeks are pure and the tie-break is pinned (lower strike), no wall * clock/RNG (RUNTIME §0), so it reconstructs identically on replay. */ deltaRefusedReason: string | null; /** This plan was SYNTHESIZED from an agent `placeOrder` (kestrel-5zl.5), not authored in a document. * Two behaviors follow (both scoped to this surface — authored plans are untouched, preserving the * plan-scoped never-naked decision of kestrel-22j.14): (1) a SELL leg covers against the whole BOOK's * net-held-minus-resting-sells (the doctrine formula, SURFACES "never-naked SELL boundary"), because * a one-shot placeOrder has no authored plan structure to scope to and no ARM-held handshake to invoke; * (2) an admit-nothing fire is TERMINAL (de-arm loudly ONCE), never a per-sweep refusal storm — a * WHEN-less one-shot has no future sweep that will make an uncovered/unbuildable leg fire. */ readonly synthesizedOrder: boolean; readonly children: ChildOrder[]; // clause partitions readonly entries: readonly EntrySpec[]; readonly reloads: readonly ReloadClause[]; readonly tps: readonly TpClause[]; readonly exits: readonly ExitClause[]; readonly invalidates: readonly InvalidateClause[]; readonly cancelifs: readonly CancelIfClause[]; readonly armClause: ArmClause | null; // per-clause causal memory whenEval: TriggerEvaluator | null; readonly reloadEvals: (TriggerEvaluator | null)[]; readonly reloadPrevTrue: boolean[]; readonly exitEvals: (TriggerEvaluator | null)[]; /** The exit trigger has latched (edge) — the exit INTENT is armed and stays armed until the * held qty is fully worked (F1: protect later-acquired inventory even while momentarily flat). */ readonly exitIntent: boolean[]; /** The exit sell has been submitted for the held qty ⇒ the intent is satisfied (esc staging of * that child then proceeds via {@link PlanEngine.onEvent} → maintainWorking). */ readonly exitWorked: boolean[]; readonly invalidateEvals: (TriggerEvaluator | null)[]; readonly cancelifEvals: (TriggerEvaluator | null)[]; } const EPS = 1e-9; const DEFAULT_SPEC: Omit<InstrumentSpec, "symbol"> = { tickSize: 0.01, strikeStep: 1, multiplier: 1 }; /** The bare `spot` market-fact ref — resolved through the engine's composed provider to feed the * `spot` PRICE anchor (ADR-0030 / kestrel-ipc) from EXACTLY the value the `spot` SERIES reads * (`CanonicalState.spot`), so the two readings of `spot` can never diverge (no two-truths). */ const SPOT_SERIES_REF: SeriesRef = { kind: "series", segments: [{ name: "spot" }] }; /** * A {@link SeriesProvider} that routes **org** paths to the engine's ledger-backed * {@link OrgFacts} and everything else (market scalars, windowed metrics, baselines, phase, the * detector/fill/calendar hooks) to the injected market provider. This lets the engine own the * org side (so `plan(x).state`, `pnl`, `fills.*` are always current as of the swept event) * without the caller having to wire the engine's org facts into the provider before the engine * exists (a construction-order cycle). */ class ComposedProvider implements SeriesProvider { constructor( private readonly market: SeriesProvider, private readonly org: OrgFacts, /** The ONE shared phonebook (cza.1) the market-vs-org decision dials — the same table * `series/provider` routes through. Routing through it (instead of an engine-private mirror of * the market-name set) means the engine's org/market split can never skew from the provider's. */ private readonly registry: SeriesRegistry, /** Stash a fail-closed reason for the sweep in progress — the SAME latch * {@link EngineOrgFacts} feeds for an unresolvable org path, so an UNKNOWN raised HERE also * turns a silent non-fire into a loud, edge-latched de-arm (RUNTIME §3/§8). Optional so a * bare ComposedProvider (tests) still constructs. */ private readonly onUnresolved: (reason: string) => void = () => {}, ) {} private isMarket(ref: SeriesRef): boolean { if (ref.window !== undefined) return true; // windowed ⇒ a market metric (windowed org not v1) const head = ref.segments[0]; if (ref.segments.length !== 1 || head === undefined || head.selector !== undefined) return false; // Single source of truth: a bare name is a market fact iff the phonebook says so (cza.1) — no // second market-name list to drift from `series/provider`. return this.registry.lookup(head.name)?.kind === "market"; } resolve(ref: SeriesRef, now: number): Resolved { return this.isMarket(ref) ? this.market.resolve(ref, now) : this.org.resolve(ref.segments); } /** A quantified operand (`children(any|all).<fact>`) is always an org path (a leading `sel-quant` * selector ⇒ not a bare market name) — resolve it to the per-member vector through the org * backing so the trigger folds the comparator per member (∃/∀). UNKNOWN (fail-closed) if the org * backing does not implement quantification. */ quantify(ref: SeriesRef, _now: number): readonly number[] | Unknown { return this.org.quantify?.(ref.segments) ?? UNKNOWN; } baseline(ref: SeriesRef, stat: Baseline): number | Unknown { return this.market.baseline(ref, stat); } phase(): SessionPhase | Unknown { return this.market.phase(); } structural(ev: StructuralEvent, now: number): TriState { return this.market.structural ? this.market.structural(ev, now) : UNKNOWN; } /** * Fill-lifecycle telemetry (`WHEN filled`, `rejected`, `cancelled`). * * A LEG QUALIFIER (`filled leg 1`) is REFUSED here, before the provider hook is consulted * (kestrel-s3h8). Every fill-telemetry provider that ships today — the sim's session-scoped * `FillTelemetry`, the IBKR feed's — answers from a session-wide latch with no per-plan / per-leg * scoping, so passing the qualifier through would silently WIDEN the author's intent: a plan armed * on "my own leg 1 filled" would fire on a STRANGER's fill elsewhere in the session (cross-plan * bleed). That is the silent default doctrine forbids, and SURFACES.md already states these read * UNKNOWN. So: UNKNOWN **with a logged reason**, which de-arms the plan loudly instead of leaving * it armed on a trigger that means something other than what it says. The real capability * (per-plan/per-leg scoping) lands with kestrel-qbwo.2; when a provider can honour the qualifier * it is admitted here, not by deleting the refusal. */ fillEvent(ev: FillEvent): TriState { if (ev.leg !== undefined) { this.onUnresolved(perLegFillQualifierReason(ev.event, ev.leg)); return UNKNOWN; } return this.market.fillEvent ? this.market.fillEvent(ev) : UNKNOWN; } timeOfDayMinutes(now: number): number | Unknown { return this.market.timeOfDayMinutes ? this.market.timeOfDayMinutes(now) : UNKNOWN; } } /** A short, human display name for a {@link SeriesRef} operand — the dotted segment path, with a `(…)` * window suffix when the ref is windowed (`velocity` + a 5-minute window ⇒ `velocity(5m)`). Used to NAME * the unwritten operands in a `trigger never resolved` ttl-expiry reason (kestrel-y6vo). Deliberately a * compact label (not the full canonical printer): it names WHICH series the tape never wrote. */ function seriesDisplayName(ref: SeriesRef): string { const path = ref.segments.map((s) => s.name).join("."); const w = ref.window; return w === undefined ? path : `${path}(${w.value}${w.unit})`; } /** Every bare {@link SeriesRef} operand a trigger references, collected recursively — the arm-time * input to the unknown-series integrity check (kestrel-mte). Mirrors {@link findMarkInTrigger}'s * structural walk: only `cmp`/`cross`/`event` carry operands; the compound/decorator nodes recurse * into their sub-triggers; `phase`/`time-window`/`fill` reference no named series. */ function collectSeriesOperands(t: Trigger, out: SeriesRef[]): void { switch (t.kind) { case "cmp": case "cross": if (t.left.kind === "series") out.push(t.left); if (t.right.kind === "series") out.push(t.right); return; case "event": { const of = t.of; if (of?.kind === "series") out.push(of); return; } case "break-hold": case "within": case "until": case "at": collectSeriesOperands(t.inner, out); return; case "nth": collectSeriesOperands(t.event, out); return; case "not": collectSeriesOperands(t.term, out); return; case "and": case "or": for (const term of t.terms) collectSeriesOperands(term, out); return; case "phase": case "time-window": case "fill": // A 0DTE time-stop references the clock / hold-duration, never a named series — no operand to collect. case "held-stop": case "clock-stop": return; default: return assertNever(t, "collectSeriesOperands"); } } // ───────────────────────────────────────────────────────────────────────────── // PlanEngine // ───────────────────────────────────────────────────────────────────────────── /** The execution instrument as the AUTHOR bound it: a `(symbol, expiry)` pair (kestrel-ih5h seam 1). * The expiry is the SELECTOR the author wrote (`0dte`, `2026-07-17`, `weekly`) — not a resolved date; * resolving a relative selector needs a session calendar, which this module deliberately does not * have (no ambient clock is ever consulted here). */ interface ExecRef { readonly symbol: string; readonly expiry?: ExpirySelector; } /** Normalize a date selector's RAW digit text for comparison — `2026-1-5` and `2026-01-05` name the * same day and must not read as a contradiction. The raw text is preserved in the AST (the printer's * round-trip depends on it), so the padding is applied HERE, at the comparison, and nowhere else. */ function normalizeExpiryDate(d: string): string { const parts = d.split("-"); if (parts.length !== 3) return d; return `${parts[0]!.padStart(4, "0")}-${parts[1]!.padStart(2, "0")}-${parts[2]!.padStart(2, "0")}`; } /** Does a book whose tenor is `have` satisfy an authored `want` selector? A pure predicate — no clock, * no calendar, no ambient date (kestrel-ih5h seam 1). * * Three cases, each fail-closed in the sense that matters — the function never GUESSES: * - `want === undefined` — the author named no tenor, so nothing can contradict it. Admit. * - `have === undefined` — the book does not state its tenor, so it cannot contradict the author * either. Admit: inventing a tenor for it would be exactly the silent default this forbids. * - `want` is an absolute DATE — comparable to the book's date with no calendar at all. This is the * one case that can be DECIDED, so it is the one case that is enforced. * * A RELATIVE selector (`0dte`) or a TAG (`weekly`) cannot be decided here: `0dte` means "expiring on * the session's date", and the session's date lives behind the clock seam this module must not reach * across. Admitting them preserves today's behavior exactly (every single-tenor plan we grade prices * as it always has) and leaves the resolution to the half of this seam that owns the calendar. That * residue is real and is named in the handoff — it is not a claim that relative tenors are checked. */ function expiryAdmits(want: ExpirySelector | undefined, have: string | undefined): boolean { if (want === undefined || have === undefined) return true; if (want.kind === "expiry-date") return normalizeExpiryDate(want.date) === normalizeExpiryDate(have); return true; } export class PlanEngine { readonly #instruments = new Map<string, InstrumentSpec>(); readonly #execDefault: string | undefined; readonly #signalDefault: string | undefined; readonly #provider: SeriesProvider; readonly #newEvaluator: () => TriggerEvaluator; readonly #gate: Gate; readonly #bus: BusSink; readonly #bucket: RepriceTokenBucket | undefined; readonly #rUsd: number; readonly #fairTau: FairTauProvider | undefined; /** The tape's complete regime-scope vocabulary when the caller pre-scanned the full bus * ({@link PlanEngineOptions.tapeRegimeScopes}), else undefined (live: the future is unknown). */ readonly #tapeRegimeScopes: ReadonlySet<string> | undefined; readonly #ledger: BookLedger; readonly #registry: SeriesRegistry; readonly #orgFacts: EngineOrgFacts; /** The path-naming de-arm reason stashed by {@link EngineOrgFacts} the last time a WHEN sweep hit * a structurally-unresolvable org path — read (and cleared) around each plan's WHEN evaluation in * {@link #fire} to turn a SILENT non-fire into a de-arm with a logged reason. First-write-wins per * sweep, so the reason is deterministic. */ #lastOrgUnresolved: string | null = null; readonly #plans: PlanRuntime[] = []; readonly #childIndex = new Map<string, { pr: PlanRuntime; child: ChildOrder }>(); readonly #childEvals = new Map<string, TriggerEvaluator>(); readonly #envelopes: Envelope[] = []; /** The engine-MINTED fallthrough envelope (kestrel-eywk) — created only when the first-armed document * declares no POD and no BOOK. It is the ONLY envelope the engine may restate, precisely because it is * the only one the AUTHOR did not write. Identity-tracked (not matched by name) so a document that * happens to name a Book "default" can never be mistaken for it. */ #defaultEnvelope: Envelope | null = null; /** Option books re-keyed by `symbol → expiryKey → book` (kestrel-ih5h.2), so two tenors of one * underlier COEXIST (a calendar) instead of latest-wins clobbering under a symbol-only key. The * inner key is the BOOK event's own `expiry` string (`""` when the tape pins none); {@link foldBook} * stays latest-wins WITHIN one expiry — its shape is deliberately unchanged (the collision fence * with kestrel-snq0). A leg's authored tenor selects its OWN expiry's book in {@link #bookFor}. */ readonly #books = new Map<string, Map<string, BookState>>(); /** The last observed spot NBBO per instrument (ADR-0017): the additive `bid`/`ask` riding a * SPOT tick, plus the print itself as `last`. The quote a spot leg's price ctx reads — an * option tape sets neither side, so its entries stay dark and no spot leg can price. */ readonly #spotQuotes = new Map<string, { bid: number | null; ask: number | null; last: number }>(); readonly #tags = new Map<string, string>(); #now: number; #orderSeq = 0; #armSeq = 0; /** Monotonic registration counter — the deterministic ordinal that mints each Plan-instance identity * (kestrel-22j.15). Increments once per {@link #registerPlan}, so the SAME documents armed in the SAME * order yield byte-identical instance ids across replays (no wall clock / no RNG, RUNTIME §0). It is the * only thing that distinguishes a legal same-name replacement from its predecessor. */ #planInstanceSeq = 0; /** Count of actual cancel/replace REPRICE submissions (peg drift + esc-stage boundary) — the * honest reprice tally the report exposes (F6). Distinct from `escStage`, which is the ladder * rung an order occupies, not a count of reprices. */ #repriceCount = 0; constructor(opts: PlanEngineOptions) { for (const spec of opts.instruments) this.#instruments.set(spec.symbol, spec); this.#execDefault = opts.instruments.find((s) => s.role === "exec")?.symbol ?? opts.instruments[0]?.symbol; this.#signalDefault = opts.instruments.find((s) => s.role === "signal")?.symbol ?? opts.instruments[0]?.symbol; this.#newEvaluator = opts.newEvaluator ?? (() => new TriggerEvaluator()); this.#gate = opts.gate; this.#bus = opts.busWriter; this.#bucket = opts.repriceBucket; this.#rUsd = opts.rUsd; this.#fairTau = opts.fairTauYears; this.#tapeRegimeScopes = opts.tapeRegimeScopes !== undefined ? new Set(opts.tapeRegimeScopes) : undefined; this.#now = opts.now; this.#ledger = new BookLedger((instrument) => this.#specOf(instrument).multiplier); this.#registry = opts.registry ?? defaultSeriesRegistry; // The engine owns the mutable de-arm stash; the org read view stays pure and only reports the // reason (fail-closed loud de-arm, RUNTIME §8). First unresolvable path per sweep wins. this.#orgFacts = new EngineOrgFacts(this.#ledger, (reason) => { this.#lastOrgUnresolved ??= reason; }); this.#provider = new ComposedProvider(opts.seriesProvider, this.#orgFacts, this.#registry, (reason) => { this.#lastOrgUnresolved ??= reason; }); } /** The engine's ledger-backed org facts (RUNTIME §2): `pnl`, `fills.*`, `plan(x).*`, * `children(any|all).*`. Exposed for panes/grade columns and inspection; the engine's own trigger * sweep reads it internally. */ get orgFacts(): OrgFacts { return new EngineOrgFacts(this.#ledger); } /** * The PM-node write seam for a child's published aggregate — the write side of * `children(any|all).<fact>` (RUNTIME §5 / CONTEXT: Pod). A parent Pod receives each child's own * org aggregate (its `drawdown`, its `pnl`, …) and folds it into the book ledger so a PM wake can * quantify across the pod (`children(any).drawdown > 0.3R`). Registers the child-scoped org path * in the shared phonebook on first write, source-stamped to the child (implicit registration, * cza.1). Deterministic (a pure upsert); the value is the child's own already-computed aggregate, * so this seam is taste-free — it does not recompute or judge it. */ noteChildFact(child: string, fact: string, value: number): void { this.#ledger.setChildFact(child, fact, value); this.#registry.noteOrgWrite( [{ name: "children", selector: { kind: "sel-quant", q: "any" } }, { name: fact }], child, ); } /** The engine's composed {@link SeriesProvider} — the seam its trigger sweep reads through, * routing org paths to the ledger and market facts to the injected provider via the shared * phonebook. Exposed read-only for inspection (e.g. certifying that the engine and the provider * agree on market-vs-org for every declared series — the anti-skew guarantee, cza.2). */ get seriesProvider(): SeriesProvider { return this.#provider; } // ── arming ──────────────────────────────────────────────────────────────── /** * Arm a parsed Kestrel document (RUNTIME §5). Builds the document's nesting envelope chain * (Pod `R`-risk-budgets → Book budget/concurrency; a single-book document is a chain of one), * registers each plan `authored`, then immediately reconciles arming at the current time: a * plan whose regime gate is satisfied (or `regime {any}`/absent) arms; one gated on a tag not * yet written stays authored until a `REGIME` write (UNKNOWN de-arm, RUNTIME §3). */ armDocument(mod: Module, opts?: { synthesizedOrder?: boolean }): ArmReport { // Two arm-time integrity checks, one per shape (OSS-ADR-0045): // - F4 (same-name supersession shadowing): a revision re-declaring a name still owned by a LIVE // (not-done) prior record would clobber the by-name ledger and garble the merged lifecycle // trace (`plan(x).state`, the frame's plan lines). This THROWS an {@link ArmError} (code // `plan-name-in-use`) — a whole-document refusal, because arming would corrupt LIVE state; the // thrown error carries `.code`, never matched by message. A name whose prior record is already // `done` (fully expired/settled) is free to reuse. // - unknown-series (mte): a trigger names a case-variant of a known market fact. This is a // per-statement DATA refusal collected below — the offending statement is skipped (never armed: // arming it would read UNKNOWN forever, the silent de-arm this reverts), its healthy siblings // arm. Nothing healthy is aborted by a bad sibling; nothing is silently dropped. const refusals: ArmRefusal[] = []; for (const st of mod.statements) { if (st.kind !== "plan") continue; const clash = this.#plans.find((pr) => pr.name === st.name && !pr.done); if (clash !== undefined) { throw new ArmError( "plan-name-in-use", `arm: plan name ${JSON.stringify(st.name)} still owned by a managing plan — names are lineage; author a new name (fail-closed, RUNTIME §8)`, ); } const rejection = this.#unknownSeriesRejection(st); if (rejection !== null) { refusals.push({ statement: st.name, code: "unknown-series", message: rejection.message, repair: rejection.repair }); } // Manages-nothing (kestrel-b4wx/kestrel-hcnj): a covered sell with no way to ever hold inventory is // refused AS DATA — never registered, healthy siblings still arm — so it can never silently arm inert. const manages = this.#managesNothingRejection(st); if (manages !== null) { refusals.push({ statement: st.name, code: "manages-nothing", message: manages.message }); } // Opening-short (kestrel-53gw): an AUTHORED plan whose `DO`/`ALSO` entry opens a SELL leg it can ne