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.
522 lines (469 loc) • 21.2 kB
text/typescript
/**
* # builders — the programmatic authoring surface (ADR-0004)
*
* Ergonomic constructors for the {@link ../ast.ts} object model. An ESM module composing
* these objects and a `.kestrel` file are the same kind of module (ADR-0004): programs
* compose here, agents/humans write text, neither is second-class.
*
* Every helper returns a plain readonly AST node with its `kind` tag filled in. Optional
* fields are omitted (never set to `undefined`) so the result satisfies
* `exactOptionalPropertyTypes`.
*/
import type {
AbsolutePrice,
Anchor,
AnchorName,
Arbitration,
ArmClause,
Baseline,
BookStatement,
Budget,
CancelIfClause,
Citation,
Comparison,
CompareOp,
Coverage,
Counterfactual,
CrossEvent,
DeliverSpec,
DoTicket,
Duration,
ExitClause,
ExpirySelector,
FillEvent,
GradeDimension,
GradeStatement,
GradeSubject,
HeldQuantifier,
ImportDecl,
InvalidateClause,
Instrument,
Leg,
Lean,
Module,
Operand,
OrderPolicy,
PaneArg,
PaneRef,
PhaseEvent,
PlanClause,
PlanStatement,
PodStatement,
PriceExpr,
PriceMinMax,
PriceOffset,
Provenance,
Quantity,
RegimeGate,
RegimeTagBinding,
ReloadClause,
RiskLine,
SeriesRef,
Standing,
Statement,
StrikeSpec,
StructuralEvent,
TimeOfDay,
TimeUnit,
TimeWindowTrig,
TpClause,
TpTarget,
Trigger,
Ttl,
Using,
ViewStatement,
WakeBudget,
WakeStatement,
Window,
} from "./ast.ts";
import {
refuseAtomic,
refuseBadCitation,
refuseExitOnMark,
refuseHeldOverCross,
refuseNonPositiveBand,
refuseNonPositiveBudget,
refuseProvenanceElevation,
} from "./validate.ts";
// Strip keys whose value is undefined, so optional AST fields stay absent rather than
// present-and-undefined (exactOptionalPropertyTypes). The narrowing back to the AST type
// happens at each call site's return.
function compact<T extends Record<string, unknown>>(o: T): Partial<T> {
const r: Record<string, unknown> = {};
for (const [k, v] of Object.entries(o)) if (v !== undefined) r[k] = v;
return r as Partial<T>;
}
// ── Lexical units ────────────────────────────────────────────────────────────
export const win = (value: number, unit: TimeUnit): Window => ({ kind: "window", value, unit });
export const dur = (value: number, unit: TimeUnit): Duration => ({ kind: "duration", value, unit });
export const tod = (hour: number, minute: number): TimeOfDay => ({
kind: "time-of-day",
hour,
minute,
});
export const num = (value: number, unit?: Quantity["unit"]): Quantity =>
({ kind: "quantity", ...compact({ value, unit }) }) as Quantity;
export const p = (n: number): Baseline => ({ kind: "baseline", stat: "p", n });
export const sigma = (n: number): Baseline => ({ kind: "baseline", stat: "sigma", n });
// ── Series ───────────────────────────────────────────────────────────────────
/** A series reference: `series("spot")`, `series("velocity", { window: win(1,"m") })`.
* Dotted paths pass segments: `series(["regime","intraday"])`. The market/org distinction
* is registry metadata, not syntax (ADR-0004), so it is not encoded here. */
export function series(path: string | readonly string[], opts: { window?: Window } = {}): SeriesRef {
const names = typeof path === "string" ? [path] : path;
return {
kind: "series",
segments: names.map((name) => ({ name })),
...compact({ window: opts.window }),
} as SeriesRef;
}
/** An org-aggregate path with a quantified segment: `children(any).drawdown`. */
export const childrenAny = (field: string): SeriesRef => ({
kind: "series",
segments: [{ name: "children", selector: { kind: "sel-quant", q: "any" } }, { name: field }],
});
/** A named plan-state path: `plan(chase-urgent).state`. */
export const planState = (planName: string, field = "state"): SeriesRef => ({
kind: "series",
segments: [{ name: "plan", selector: { kind: "sel-name", name: planName } }, { name: field }],
});
// ── Triggers ───────────────────────────────────────────────────────────────
export const cmp = (left: Operand, op: CompareOp, right: Operand): Comparison => ({
kind: "cmp",
op,
left,
right,
});
export const gt = (l: Operand, r: Operand): Comparison => cmp(l, "gt", r);
export const ge = (l: Operand, r: Operand): Comparison => cmp(l, "ge", r);
export const lt = (l: Operand, r: Operand): Comparison => cmp(l, "lt", r);
export const le = (l: Operand, r: Operand): Comparison => cmp(l, "le", r);
export const eq = (l: Operand, r: Operand): Comparison => cmp(l, "eq", r);
export const ne = (l: Operand, r: Operand): Comparison => cmp(l, "ne", r);
/** A cross edge event. `opts.touch` picks the at-or-touch predicate (`touches`, `>=`/`<=`);
* `opts.band` is a positive re-arm width so a lone spurious tick cannot phantom-fire.
* Both are omitted (a bare strict cross) by default. The band>0 doctrine invariant is enforced
* at construction (fail closed early) via ./validate.ts, symmetric with parse/print. */
export const cross = (
left: Operand,
direction: "above" | "below",
right: Operand,
opts: { touch?: boolean; band?: Quantity } = {},
): CrossEvent => {
refuseNonPositiveBand(opts.band);
return { kind: "cross", left, dir: direction, right, ...compact(opts) } as CrossEvent;
};
/** Refuse to CONSTRUCT held-over-cross (the sfg8 anti-pattern; ./validate.ts). `within` over a
* cross stays legal. */
export const held = (inner: Trigger, d: Duration): Trigger => {
refuseHeldOverCross(inner);
return { kind: "break-hold", inner, dur: d };
};
export const within = (inner: Trigger, d: Duration): Trigger => ({ kind: "within", inner, dur: d });
/** `held <dur>` in lead position — a 0DTE time-held stop (`EXIT held 90m`); a bare hold-clock on
* the acquired position, NOT the `held(inner, dur)` break-hold postfix (docs/results/fomc-options).*/
export const heldStop = (d: Duration): Trigger => ({ kind: "held-stop", dur: d });
/** `clockET <clock>` — a 0DTE wall-clock time-stop (`EXIT clockET 15:40`). */
export const clockStop = (time: TimeOfDay): Trigger => ({ kind: "clock-stop", at: time });
/** `inner until <clock>` — the thesis temporal envelope (kestrel-rtf), a postfix sibling of
* `within` over the same predicate surface (no second predicate language). */
export const until = (inner: Trigger, time: TimeOfDay): Trigger => ({ kind: "until", inner, at: time });
/** `inner at <clock>` — the thesis temporal envelope (kestrel-rtf), a postfix sibling of `within`. */
export const at = (inner: Trigger, time: TimeOfDay): Trigger => ({ kind: "at", inner, at: time });
export const nth = (ordinal: number, event: Trigger): Trigger => {
// Ordinals are 1-based (`first`, `second`, …) — the parser rejects 0/negatives and the
// printer cannot render them lexably. Refuse to construct one that could never round-trip.
if (!Number.isInteger(ordinal) || ordinal < 1) {
throw new Error(`kestrel/builders: nth ordinal must be a positive integer (>= 1); got ${String(ordinal)}`);
}
return { kind: "nth", ordinal, event };
};
export const event = (name: string, of?: Operand): StructuralEvent =>
({ kind: "event", name, ...compact({ of }) }) as StructuralEvent;
export const phase = (name: string): PhaseEvent => ({ kind: "phase", phase: name });
export const timeWindow = (opts: { from?: TimeOfDay; to?: TimeOfDay }): TimeWindowTrig =>
({ kind: "time-window", ...compact(opts) }) as TimeWindowTrig;
export const fill = (evt: FillEvent["event"], leg?: number): FillEvent =>
({ kind: "fill", event: evt, ...compact({ leg }) }) as FillEvent;
export const and = (...terms: Trigger[]): Trigger => ({ kind: "and", terms });
export const or = (...terms: Trigger[]): Trigger => ({ kind: "or", terms });
export const not = (term: Trigger): Trigger => ({ kind: "not", term });
// ── Prices ───────────────────────────────────────────────────────────────────
export const anchor = (name: AnchorName): Anchor => ({ kind: "anchor", name });
export const fair: Anchor = anchor("fair");
export const intrinsic: Anchor = anchor("intrinsic");
export const basis: Anchor = anchor("basis");
export const bid: Anchor = anchor("bid");
export const ask: Anchor = anchor("ask");
export const mid: Anchor = anchor("mid");
export const last: Anchor = anchor("last");
export const join: Anchor = anchor("join");
export const improve: Anchor = anchor("improve");
export const stub: Anchor = anchor("stub");
// The `spot` PRICE anchor (ADR-0030 / kestrel-ipc). No collision with the `spot` SERIES: that is
// the function-call `series("spot")`, this is the anchor-value node.
export const spot: Anchor = anchor("spot");
export const px = (value: number): AbsolutePrice => ({ kind: "price-abs", value });
export const minus = (base: PriceExpr, amount: number, unit: "c" | "%" = "c"): PriceOffset => ({
kind: "price-offset",
base,
sign: "-",
amount,
unit,
});
export const plus = (base: PriceExpr, amount: number, unit: "c" | "%" = "c"): PriceOffset => ({
kind: "price-offset",
base,
sign: "+",
amount,
unit,
});
export const lean = (a: PriceExpr, b: PriceExpr, x: number): Lean => ({ kind: "lean", a, b, x });
export const pmin = (...args: PriceExpr[]): PriceMinMax => ({ kind: "price-fn", fn: "min", args });
export const pmax = (...args: PriceExpr[]): PriceMinMax => ({ kind: "price-fn", fn: "max", args });
// ── Legs & order policy ──────────────────────────────────────────────────────
export const rel = (steps: number): StrikeSpec => ({ kind: "strike-rel", steps });
export const abs = (strike: number): StrikeSpec => ({ kind: "strike-abs", strike });
export const atm: StrikeSpec = { kind: "strike-atm" };
export const delta = (d: number): StrikeSpec => ({ kind: "strike-delta", delta: d });
/** An option leg `buy <qty> <strike> <right>`, optionally with its OWN expiry (`buy 2 +1 C exp 0dte`,
* kestrel-ih5h seam 1). Omit `expiry` and the leg inherits the ambient `USING exec` tenor — the object
* form of leaving `exp` off the text, so the two authoring surfaces (ADR-0004: objects and text are
* peers) stay mechanically equivalent. */
export const buy = (qty: number, strike: StrikeSpec, right: "C" | "P", expiry?: ExpirySelector): Leg =>
({ kind: "leg", side: "buy", qty, strike, right, ...compact({ expiry }) }) as Leg;
export const sell = (qty: number, strike: StrikeSpec, right: "C" | "P", expiry?: ExpirySelector): Leg =>
({ kind: "leg", side: "sell", qty, strike, right, ...compact({ expiry }) }) as Leg;
/** An equity/spot leg `buy <qty> shares` (ADR-0017) — no strike/right; the symbol is the
* ambient `USING exec`. */
export const buyShares = (qty: number): Leg => ({ kind: "equity-leg", side: "buy", qty });
export const sellShares = (qty: number): Leg => ({ kind: "equity-leg", side: "sell", qty });
export const esc = (to: PriceExpr, after: Duration): { kind: "esc-stage"; to: PriceExpr; after: Duration } => ({
kind: "esc-stage",
to,
after,
});
export const policy = (opts: {
pricing?: "peg" | "fix";
esc?: readonly ReturnType<typeof esc>[];
caps?: readonly PriceExpr[];
floors?: readonly PriceExpr[];
cancelIf?: Trigger;
gtc?: boolean;
}): OrderPolicy => ({ kind: "order-policy", ...compact(opts) }) as OrderPolicy;
export const foreachHeld: HeldQuantifier = { kind: "held-foreach" };
export const anyHeld: HeldQuantifier = { kind: "held-any" };
// ── Plan clauses ─────────────────────────────────────────────────────────────
export const doTicket = (opts: {
legs: readonly Leg[];
price: PriceExpr;
policy?: OrderPolicy;
atomic?: boolean;
}): DoTicket => (refuseAtomic(opts.atomic), { kind: "do", ...compact(opts) } as DoTicket);
export const also = (opts: {
legs: readonly Leg[];
price: PriceExpr;
policy?: OrderPolicy;
atomic?: boolean;
}): PlanClause => (refuseAtomic(opts.atomic), { kind: "also", ...compact(opts) } as PlanClause);
export const reload = (opts: {
when?: Trigger;
legs: readonly Leg[];
price: PriceExpr;
policy?: OrderPolicy;
atomic?: boolean;
}): ReloadClause => (refuseAtomic(opts.atomic), { kind: "reload", ...compact(opts) } as ReloadClause);
export const tpPct = (pct: number): TpTarget => ({ kind: "tp-pct", pct });
export const tpMult = (mult: number): TpTarget => ({ kind: "tp-mult", mult });
export const tpPrice = (price: PriceExpr): TpTarget => ({ kind: "tp-price", price });
export const tp = (opts: {
target: TpTarget;
frac?: number;
over?: HeldQuantifier;
price?: PriceExpr;
policy?: OrderPolicy;
}): TpClause => ({ kind: "tp", ...compact(opts) }) as TpClause;
export const exit = (opts: {
when: Trigger;
over?: HeldQuantifier;
price?: PriceExpr;
policy?: OrderPolicy;
}): ExitClause => (
// marks lie (ADR-0005): refuse an EXIT conditioned on a mark at construction, symmetric with
// parse/print, so the object never exists to break `parse(print(x))`.
refuseExitOnMark(opts.when), { kind: "exit", ...compact(opts) } as ExitClause
);
export const invalidate = (when: Trigger): InvalidateClause => ({ kind: "invalidate", when });
export const cancelIf = (when: Trigger): CancelIfClause => ({ kind: "cancel-if", when });
export const arm = (opts: { when?: Trigger; basis?: PriceExpr; over?: HeldQuantifier } = {}): ArmClause =>
({ kind: "arm", ...compact(opts) }) as ArmClause;
// ── Instruments, USING, provenance ───────────────────────────────────────────
export const dte = (n: number): ExpirySelector => ({ kind: "expiry-dte", dte: n });
export const expDate = (date: string): ExpirySelector => ({ kind: "expiry-date", date });
export const expTag = (tag: string): ExpirySelector => ({ kind: "expiry-tag", tag });
export const instrument = (symbol: string, expiry?: ExpirySelector): Instrument =>
({ kind: "instrument", symbol, ...compact({ expiry }) }) as Instrument;
export const using = (opts: { signal?: Instrument; exec?: Instrument }): Using =>
({ kind: "using", ...compact(opts) }) as Using;
export const provenance = (opts: {
tier?: Provenance["tier"];
author?: string;
origin?: string;
replay?: string;
}): Provenance => ({ kind: "provenance", ...compact(opts) }) as Provenance;
/** A `because` pre-registration citation (kestrel-rtf): a content hash of the fixed shape
* `sha256:<64 hex>`, no inline body. Refuses a malformed digest at construction, symmetric with
* parse/print (./validate.ts), so the object never exists to break `parse(print(x))`. */
export const citation = (hash: string, algo: Citation["algo"] = "sha256"): Citation => {
const c: Citation = { kind: "citation", algo, hash };
refuseBadCitation(c);
return c;
};
// ── Statement helpers ────────────────────────────────────────────────────────
export const budget = (value: number): Budget => (
refuseNonPositiveBudget(value), { kind: "budget", value, unit: "R" }
);
export const ttlIn = (d: Duration): Ttl => ({ kind: "ttl-rel", dur: d });
export const ttlAt = (at: TimeOfDay): Ttl => ({ kind: "ttl-at", at });
export const regime = (...tags: RegimeTagBinding[]): RegimeGate => ({ kind: "regime-gate", tags });
export const tag = (scope: string, value: string): RegimeTagBinding => ({ scope, value });
export const argIdent = (name: string): PaneArg => ({ kind: "arg-ident", name });
export const argWindow = (w: Window): PaneArg => ({ kind: "arg-window", window: w });
export const argCount = (count: number): PaneArg => ({ kind: "arg-count", count });
/** A SessionOrdinal literal `d-<n>` (Train 1B / ADR-0041 §1): `d-0` current session, `d-1` prior… */
export const argOrdinal = (ordinal: number): PaneArg => ({ kind: "arg-ordinal", ordinal });
/** An ExpiryOrdinal literal `e-<n>` (Train 1B / ADR-0041 §1): `e-0` nearest expiry, `e-1` next out… */
export const argExpiry = (expiry: number): PaneArg => ({ kind: "arg-expiry", expiry });
export const pane = (name: string, ...args: PaneArg[]): PaneRef => ({ kind: "pane", name, args });
export const view = (
name: string,
panes: readonly PaneRef[],
opts: { budget?: number } = {},
): ViewStatement => ({ kind: "view", name, panes, ...compact(opts) }) as ViewStatement;
export const deliver = (
viewName: string,
opts: { mandatory?: boolean; keyframe?: boolean } = {},
): DeliverSpec => ({ kind: "deliver", view: viewName, ...compact(opts) }) as DeliverSpec;
export const wakeBudget = (opts: { wakesPerDay?: number; tokensPerDay?: number }): WakeBudget =>
({ kind: "wake-budget", ...compact(opts) }) as WakeBudget;
export const wake = (
name: string,
when: Trigger,
opts: {
because?: Citation;
deliver?: DeliverSpec;
priority?: number;
coalesce?: string;
budget?: WakeBudget;
universe?: { kind: "universe"; universe: string };
} = {},
): WakeStatement => ({ kind: "wake", name, when, ...compact(opts) }) as WakeStatement;
export const universe = (name: string): { kind: "universe"; universe: string } => ({
kind: "universe",
universe: name,
});
export const plan = (
name: string,
opts: {
budget?: Budget;
ttl?: Ttl;
regime?: RegimeGate;
priority?: number;
standing?: Standing;
provenance?: Provenance;
using?: Using;
when?: Trigger;
because?: Citation;
clauses?: readonly PlanClause[];
atomic?: boolean;
} = {},
): PlanStatement => (
refuseAtomic(opts.atomic),
({ kind: "plan", name, clauses: opts.clauses ?? [], ...compact({ ...opts, clauses: undefined }) }) as PlanStatement
);
export const subject = (what: GradeSubject["what"], name: string): GradeSubject => ({
kind: "grade-subject",
what,
name,
});
export const over = (from: string, to: string): { kind: "corpus-range"; from: string; to: string } => ({
kind: "corpus-range",
from,
to,
});
export const vsUngated: Counterfactual = { kind: "cf-ungated" };
export const vsNull: Counterfactual = { kind: "cf-null" };
export const vsBracket: Counterfactual = { kind: "cf-bracket" };
export const bySeries = (s: SeriesRef): GradeDimension => ({ kind: "dim-series", series: s });
export const byVehicle: GradeDimension = { kind: "dim-vehicle" };
export const byName: GradeDimension = { kind: "dim-name" };
export const byLineage: GradeDimension = { kind: "dim-lineage" };
export const grade = (
gradeSubject: GradeSubject,
opts: {
over?: { kind: "corpus-range"; from: string; to: string };
fill?: string;
versus?: readonly Counterfactual[];
by?: readonly GradeDimension[];
} = {},
): GradeStatement =>
({
kind: "grade",
subject: gradeSubject,
versus: opts.versus ?? [],
by: opts.by ?? [],
...compact({ over: opts.over, fill: opts.fill }),
}) as GradeStatement;
export const coverage = (instruments: readonly Instrument[], thesis: string): Coverage => ({
kind: "coverage",
instruments,
thesis,
});
export const arbitration = (policy: string, concurrency?: number): Arbitration =>
({ kind: "arbitration", policy, ...compact({ concurrency }) }) as Arbitration;
export const risk = (metric: string, threshold: Quantity, action: string): RiskLine => ({
kind: "risk",
metric,
threshold,
action,
});
export const book = (
name: string,
opts: {
budget?: Budget;
coverage?: Coverage;
arbitration?: Arbitration;
risk?: readonly RiskLine[];
} = {},
): BookStatement => ({ kind: "book", name, ...compact(opts) }) as BookStatement;
export const pod = (
name: string,
opts: { risk?: readonly RiskLine[]; children?: readonly (PodStatement | BookStatement)[] } = {},
): PodStatement => ({
kind: "pod",
name,
risk: opts.risk ?? [],
children: opts.children ?? [],
});
export const importFrom = (names: readonly string[], from: string): ImportDecl => ({
kind: "import",
names,
from,
});
export const module = (opts: {
imports?: readonly ImportDecl[];
using?: Using;
statements?: readonly Statement[];
provenance?: Provenance;
}): Module => {
const statements = opts.statements ?? [];
// Provenance ceiling (ARCHITECTURE §6): refuse a module whose statement elevates above its
// declared tier at construction, symmetric with parse/print (./validate.ts).
refuseProvenanceElevation(opts.provenance?.tier, statements);
return {
kind: "module",
imports: opts.imports ?? [],
statements,
...compact({ using: opts.using, provenance: opts.provenance }),
} as Module;
};