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