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.

367 lines 25.8 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 { Module } from "../lang/index.ts"; import type { NewBusEvent, BusEvent, Right } from "../bus/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 { type Moneyness } from "../fill/index.ts"; import { TriggerEvaluator, type SeriesProvider, type SeriesRegistry, type OrgFacts } from "../series/index.ts"; import { type RepriceTokenBucket } from "./pricing.ts"; import type { ArmRefusalCode, ArmRefusal, ArmReport } 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 declare class ArmError extends Error { readonly code: ArmRefusalCode; readonly refusals?: readonly ArmRefusal[]; constructor(code: ArmRefusalCode, message: string, refusals?: readonly ArmRefusal[]); } /** * 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 declare 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 declare function armRefusalError(refusals: readonly ArmRefusal[]): ArmError; /** 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 declare function deriveOrderGuard(side: "buy" | "sell", spot: number | undefined, strike: number, right: Right): OrderGuardEvidence; /** 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[]; } export declare class PlanEngine { #private; constructor(opts: PlanEngineOptions); /** 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; /** * 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; /** 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; /** * 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; /** * **Document supersession** (RUNTIME §5): a newly-authored document replaces the standing one. * Every currently-standing plan is de-armed cleanly at `now` — acquisition is halted and all * **unfilled** entry/reload children are cancelled — but **filled inventory and its resting * TP/EXIT management PERSIST** under the superseded plan record (never liquidated). A plan that * holds no inventory and has nothing working is finished (`done(expired)`, reason `superseded`); * a plan still holding inventory keeps managing it (TP/EXIT/INVALIDATE) under its old record. The * caller then {@link armDocument}s the replacement. Ordering with the caller is: supersede first * (at `now`), then arm — the two documents never both acquire the same tick. */ supersede(now: number): void; /** * Cancel a resting order by its real Gate ref on behalf of an agent `cancelOrder` (kestrel-5zl.5 / #3c). * Routes through {@link #cancelChild} so BOTH the engine's child state AND the emitted bus are updated * in the SAME step: the engine dump (which the wake snapshot scrapes `resting[]` from) drops the order * and the Gate emits the ORDER `cancel` — so `resting[]` and `engineLog` agree within one snapshot, * never one wake late (the anomaly the capstone agent flagged). Returns whether a resting order was * actually removed; an unknown / already-filled / already-cancelled ref returns `false` (the driver's * proportionate fail-closed refuse — never a crash, never a wrongful cancel). */ cancelOrderByRef(ref: string, now: number): boolean; /** * FLATTEN an instrument on behalf of an agent `flatten` action (bd if0 / the 75n flatten portion) — the * first-class "get me out now" primitive. For EVERY managing plan holding net-long inventory in * `instrument`, in ONE synchronous step (routed through the SAME gate a fired Plan uses): * (1) HALT acquisition and CANCEL all its unfilled children for the instrument — this neutralizes the * resting TP/EXIT (freeing the lot reservation) AND, via the {@link PlanRuntime.flattened} latch, * stops {@link #maintainTP} re-posting it every sweep (the rolling re-post that defeated a bare * `cancelOrder`, the un-exitable-position trap); * (2) cross to CLOSE each held leg with a COVERED sell priced marketable `@bid` — never-naked HOLDS: the * close is covered by the exact held leg (the TP was cancelled in the same step, so * {@link #sellCovered} sees the full lot free), and a SELL is floored at intrinsic downstream in * pricing. A flatten can therefore NEVER create a naked position. * Once the close fills the plan goes flat and finishes `done(expired, "flattened")` on the next sweep * (per bd 1a8 its R is freed the moment it is flat). A FLAT book is a clean no-op. PURE — a function of * current positions + the injected `now`, no wall clock / RNG — so two replays agree byte-for-byte * (RUNTIME §0). Returns the closed contracts and how many plans were flattened (the driver's audit). */ flatten(instrument: string, now: number): { closedQty: number; plansFlattened: number; }; /** * The rejection a supersede-then-arm of `mod` would raise, or `null` when it would arm cleanly — * PURE (no mutation). Previews BOTH of {@link armDocument}'s statically-knowable throws: * * 1. The F4 same-name collision guard, evaluated against the POST-supersede survivor set * ({@link #survivesSupersede}): a name still owned by a managing/inventory-holding plan * collides; a name whose only holder is an un-fired or fully-worked plan (which supersede * finishes) is free to reuse. * 2. The mte unknown-series guard ({@link #unknownSeriesRejection}): a case-variant of a known * market series (`VWAP` for `vwap`) refuses the whole document with the repair-guiding message. * * Covering both is load-bearing (bead kestrel-p48s): a preview that misses a throw lets the caller * supersede first and detonate on the arm — tearing down the standing book for a doomed revision. * A `null` here guarantees the subsequent supersede+arm cannot reject, so a stepped runner can * reject-and-reprompt WITHOUT touching the standing book (fail-closed blast-radius, RUNTIME §8). */ supersedeArmRejection(mod: Module): string | null; /** * Advance the engine by one bus event (RUNTIME §5/§7). Pure and total: state at event *N* is * a function of events `≤ N` (no look-ahead). Returns nothing — all output is emitted onto * the {@link BusSink} (PLAN + WAKE) and through the {@link Gate} (order submit/cancel). */ onEvent(ev: BusEvent): void; /** * De-arm every still-armed, never-fired plan whose entry WHEN spent the WHOLE session UNRESOLVABLE, * carrying the classified reason (kestrel-j43g). Called ONCE by the session driver at settle * (`SessionCore.settle`), with the injected settle instant as `now` — never a wall clock (RUNTIME §0), * so a replay reconstructs the identical terminal step. * * ## The hole this closes * {@link #neverFiredExpiryReason} — the y6vo/kglw classifier that names WHY a 0-order plan never fired * — had exactly ONE drop site: the ttl-expiry transition in {@link #reconcileArming}. But * {@link #ttlExpired} returns `false` outright when the plan authored no `ttl` (and for a `ttl-at` * whose calendar hook is absent), so a plan with no deadline could never reach it. Such a plan armed on * a series the tape never writes, read UNKNOWN on every sweep, placed zero orders, and ended the * session `armed` with NO reason on any lifecycle step and not one byte on any CLI surface — silent on * the text digest, the human report and `--json` alike. Fail-closed-SILENT: safe (it never fired) but * doctrine-violating, because the author cannot distinguish "waiting" from "impossible" (AGENTS.md: * unknown series ⇒ UNKNOWN ⇒ de-arm WITH A LOGGED REASON, never a silent default). * * ## Scope — the UNRESOLVABLE class only, never every armed plan * Only a plan whose WHEN could not have confirmed is de-armed: * - it names market series this tape never wrote (NAMED in the reason — the absent-series class), or * - it never once reached a definite verdict (`whenEverDefinite === false`) — UNKNOWN every sweep. * * A plan whose trigger WAS evaluated definite and simply never crossed is legitimately *waiting*: the * tape ended, not the plan's chance. De-arming it would be over-scope — it would rewrite the terminal * state of every ordinary idle plan (and every golden that carries one) to say something the engine * cannot prove. It is left exactly as it is today: `armed`, silent, byte-identical. * * The reason reuses the y6vo classifier verbatim under a `session end` prefix (never the misleading * `ttl` token — this plan has no ttl), so all three CLI faces surface it through the SAME per-plan * reason projection they already read, from this ONE emission. Pure w.r.t. the tape: it reads the * injected provider and the plan's own latches. */ deArmUnresolvedAtClose(now: number): void; /** * FAIL-CLOSED SYNTHESIS GUARD (kestrel-eywk). Would a synthesized one-shot — an agent `placeOrder` — * inherit a BOUNDED risk envelope? A synthesized module carries no POD/BOOK, so {@link #buildEnvelopes} * hands it the first envelope this engine ever minted (the standing document's), which now carries the * author's OWN stated budget — so the answer is normally YES and the order is admitted, bounded by the * author's number, not one the engine made up. It is NO in exactly one case: the standing document states * no budget ANYWHERE (no POD `RISK`, no BOOK `budget`, at least one plan with no `budget`) — there is then * genuinely no authority to read, and the engine will not manufacture one; the synthesis site refuses that * one order, logged, book intact. With NOTHING standing yet the order's OWN `budget` becomes the bound, so * the production agent-order path (an agent that never arms at OPEN) stays alive AND bounded. */ synthesisEnvelopeBounded(): boolean; /** * A deterministic, JSON-serializable snapshot of the whole engine (RUNTIME §7): plans (sorted * by name, with their children sorted by ref), envelopes, the ledger, the tag store, and the * monotonic counters. Two replays of the same bus + same armed documents produce equal dumps — * the certification the determinism test asserts. */ dumpState(): unknown; } export {}; //# sourceMappingURL=plans.d.ts.map