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.

134 lines (133 loc) 8.37 kB
/** * # session/armed-plan-terms — capture the ARMED plans' enforced terms for the watcher (kestrel-wa0j.29) * * The watcher is asked to judge a plan's PREMISE ("the plan armed on a thesis — is it still true?") yet * it NEVER sees the armed document's text: the WHEN/DO/TP/EXIT/INVALIDATE clauses live in the supersede * action on the bus and are never rendered into any frame — the kernel carries only the plan's lifecycle * NAME + STATE (frame/types.ts PlanStateEntry). This module closes that gap by capturing, from the armed * document ASTs the driver ALREADY holds, the TYPED terms the `armed-plan` pane renders. * * ## Attribution honesty (ADR-0041 §1: a pane invents no value) * Every term is a TYPED value from the plan AST printed through the SAME canonical printer the * round-trip is defined by ({@link ../lang/print.ts planClause}/{@link ../lang/print.ts printTrigger}/ * {@link ../lang/print.ts budgetStr}) — a typed value → its byte-stable text, NEVER a hand-rolled * restatement, never the rendered pane bytes. The data enters the frame THROUGH the frame input, exactly * as the `delta`/`prior-session` panes' data does. * * ## Which plans (absent-not-hidden, ADR-0041 §3) * ONLY plans in an ENFORCED lifecycle state — `armed` / `fired` / `managing` — carry terms the watcher * is actively enforcing; an `authored` (not yet armed) or `done` plan is excluded. An empty enforced set * is threaded as ABSENT ({@link armedPlanTermsOf} returns `undefined`), never an empty list — so the pane * fails closed to exactly one absent-with-reason line, byte-identical to a frame without the field. * * Pure: no clock, no RNG. A same-name replacement's LATEST armed AST wins (the driver's arm order). */ import { budgetStr, planClause, printTrigger } from "../lang/index.js"; /** The plan lifecycle states in which the watcher is ENFORCING the plan's terms (kestrel-wa0j.29) — the * plan has committed (or is committing) capital and the WHEN/TP/EXIT/INVALIDATE are live. An `authored` * plan (awaiting its arm gate) and a `done` plan (terminal) carry no terms the watcher enforces NOW. */ export const ENFORCED_PLAN_STATES = new Set(["armed", "fired", "managing"]); /** * Calendar days from the tape's `session_date` to an authored per-leg expiry DATE (kestrel-wa0j.74) — * the SAME days-to-expiry the ih5h date-blind chain layer serves as `+Nd` * ({@link ../session/simulate.ts dateBlindOptions}), computed here from the session date the grammar's * `dte` selector is itself defined against ("`0dte` means expiring on the session's date", * {@link ../engine/plans.ts expiryAdmits}). Both dates are ISO `YYYY-MM-DD`; the difference is a * whole-day count taken at UTC midnight, so it is DST-free and deterministic (no wall clock). */ function dteFromSessionDate(sessionDate, expiryDate) { const utcMidnight = (d) => { const [y, m, day] = d.split("-").map((s) => Number.parseInt(s, 10)); return Date.UTC(y ?? 0, (m ?? 1) - 1, day ?? 1); }; return Math.round((utcMidnight(expiryDate) - utcMidnight(sessionDate)) / 86_400_000); } /** * Date-blind ONE expiry selector for the armed-plan pane (kestrel-wa0j.74). A relative `expiry-dte` * (`0dte`) or a `expiry-tag` (`weekly`) is ALREADY date-blind and passes through untouched; an ABSOLUTE * `expiry-date` (`2026-08-21`, authorable since kestrel-ih5h seam 1) is normalized to its DTE-relative * `expiry-dte` equivalent against the session date. The canonical printer then emits the grammar's OWN * `<n>dte` form, so NO iso-date token crosses the agent boundary and the {@link * ../session/simulate.ts assertFrameDateBlind} fence stays green. print.ts is untouched — the authored * document still prints its real date (round-trip stability, ADR-0004); only this FRAME capture blinds it. */ function dateBlindExpiry(e, sessionDate) { return e.kind === "expiry-date" ? { kind: "expiry-dte", dte: dteFromSessionDate(sessionDate, e.date) } : e; } /** Date-blind an order leg's per-leg tenor. Only an OPTION leg (`kind: "leg"`) carries an `exp`; an * equity leg and an expiry-less option leg pass through unchanged. */ function dateBlindLeg(l, sessionDate) { return l.kind === "leg" && l.expiry !== undefined ? { ...l, expiry: dateBlindExpiry(l.expiry, sessionDate) } : l; } /** Date-blind the per-leg tenors of a clause before it is printed into the pane. Only `do`/`also`/`reload` * carry legs (the entry tickets); every other clause passes through unchanged. */ function dateBlindClause(c, sessionDate) { if (c.kind === "do" || c.kind === "also" || c.kind === "reload") { return { ...c, legs: c.legs.map((l) => dateBlindLeg(l, sessionDate)) }; } return c; } /** The ENTRY tickets — what the plan acquires/reloads. */ function isEntry(c) { return c.kind === "do" || c.kind === "also" || c.kind === "reload"; } /** The EXIT surface — take-profits + thesis-break stops the watcher enforces to get OUT. */ function isExit(c) { return c.kind === "tp" || c.kind === "exit"; } /** The INVALIDATION surface — thesis-dead + resting-cancels that kill the thesis. */ function isInvalidation(c) { return c.kind === "invalidate" || c.kind === "cancel-if"; } /** Capture ONE armed plan's enforced terms as a typed {@link ArmedPlanTermsEntry}: the arming premise * (top-level `WHEN`), the entry/exit/invalidation clause runs, and the size envelope (`budget`) — each * printed through the canonical printer, each optional-honest (an absent field is a clause the plan does * not have, NEVER a fabricated one). Per-leg expiries are date-blinded against `sessionDate` FIRST * ({@link dateBlindClause}, kestrel-wa0j.74) so an absolute `exp 2026-08-21` renders as a relative * `<n>dte` and no iso-date crosses the agent boundary. Pure. */ export function armedPlanEntryOf(p, sessionDate) { const when = p.when !== undefined ? printTrigger(p.when) : undefined; const entries = p.clauses.filter(isEntry).map((c) => planClause(dateBlindClause(c, sessionDate))); const exits = p.clauses.filter(isExit).map((c) => planClause(dateBlindClause(c, sessionDate))); const invalidations = p.clauses.filter(isInvalidation).map((c) => planClause(dateBlindClause(c, sessionDate))); const sizeEnvelope = p.budget !== undefined ? budgetStr(p.budget) : undefined; return { name: p.name, ...(when !== undefined ? { when } : {}), ...(entries.length > 0 ? { entries } : {}), ...(exits.length > 0 ? { exits } : {}), ...(invalidations.length > 0 ? { invalidations } : {}), ...(sizeEnvelope !== undefined ? { sizeEnvelope } : {}), }; } /** * Capture the ARMED plans' enforced terms from the armed document ASTs, filtered to the plans whose * CURRENT lifecycle state (per the frame's kernel PlanStateEntry set) is enforced — or `undefined` when * NONE is enforced (absent-not-hidden: the pane then fails closed to absent-with-reason, and a frame * without the field is byte-identical). Preserves the driver's arm order; a same-name replacement's * LATEST AST wins (the last-armed document is the one whose terms are live). Pure. * * @param asts every armed plan statement the driver holds, in arm order. * @param enforcedNames the plan NAMES currently in an enforced lifecycle state (from the kernel plan set). * @param sessionDate the tape's `session_date` — the reference an absolute per-leg `exp` is blinded to * a relative `<n>dte` against (kestrel-wa0j.74), so no iso-date reaches the frame. */ export function armedPlanTermsOf(asts, enforcedNames, sessionDate) { // Latest-armed AST per name (a legal same-name replacement supersedes the prior terms). const latestByName = new Map(); for (const p of asts) latestByName.set(p.name, p); const plans = []; const seen = new Set(); for (const p of asts) { if (seen.has(p.name)) continue; // one entry per name, in first-armed order (value is the latest AST) if (!enforcedNames.has(p.name)) continue; // only plans the watcher is actively enforcing seen.add(p.name); plans.push(armedPlanEntryOf(latestByName.get(p.name), sessionDate)); } if (plans.length === 0) return undefined; // absent-not-hidden — never an empty list return { plans }; }