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
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 { 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