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.
344 lines (310 loc) • 17.1 kB
text/typescript
/**
* # src/pm — the Strategy ADT: the TYPED SOURCE the PM item-bank plays are instances of
*
* (bd kestrel-markets-n7jx.2 / PM-persona-benchmark design §10-Issue-2). The PM-persona
* benchmark grades a PM seat's `MarketState → Book` allocation. Until now the "strategies" the
* PM could pick lived only as loose string ids on the item-bank menus
* (`tests/bench/pm-item-bank.ts`, `pm3-bank.ts`): `long_straddle`, `call_vertical`, `short_fade`,
* … — a flat namespace with no type telling you WHICH decisive read makes each one well-formed.
*
* This module makes the strategies a real **algebraic data type** with four constructors:
*
* ORB | Straddle | Vertical | NewsShort
*
* and — the load-bearing part — parameterises each constructor by **the two edges the whole
* benchmark measures**, so the decisive read is a *type parameter*, not a runtime string:
*
* - **Edge 1 — EXPRESSIVENESS** (the options family): the `(direction × magnitude/vol)` read.
* `Straddle<M>` is parameterised by the {@link Magnitude} pole it expresses (`large` ⇒ own
* convexity / LONG straddle; `small` ⇒ harvest premium / SHORT straddle) and is `two-sided`
* by construction; `Vertical<D>` is parameterised by the {@link Direction} it expresses
* (`up` ⇒ call vertical; `down` ⇒ put vertical) and is `moderate`-magnitude by construction.
* - **Edge 2 — INFORMATION** (the small-cap news family): the catalyst-authenticity read.
* `NewsShort<C>` is parameterised by the {@link Catalyst} (`promotional` ⇒ fade / short the
* unfunded pop; `real` ⇒ ride the funded continuation LONG — the *decisive-field flip*).
*
* The parameters are **load-bearing** — they are what makes an ill-typed read a *compile* error,
* not a runtime surprise:
*
* Straddle<"moderate"> // ✗ type error: M extends "large" | "small"
* Vertical<"two-sided"> // ✗ type error: D extends "up" | "down"
* lower(straddle(...)) // ✗ type error: a two-sided vol structure is NOT EquityLowerable
*
* `{@link toPlay}` maps every constructor onto exactly the item-bank play id it stands for, so the
* banks' plays are, provably, *instances* of this ADT (a `toPlay` round-trip is asserted in
* `tests/bench/pm2-spine.test.ts` against `OPTIONS_MENU` / `NEWS_MENU`).
*
* `{@link lower}` elaborates the **equity-expressible** constructors (`ORB`, `NewsShort`) down to
* a real `plan.gbnf` PLAN string that `src/lang`'s `parse()` — the same engine/validator the RL
* reward-bridge drives (`scripts/rl-poc/reward-bridge.ts`) — accepts. It *reuses* the shipped
* `scripts/rl-poc/grammar/plan.gbnf` equity subset as its target (extension, never a rewrite); the
* options structures (`Straddle`, `Vertical`) are NOT `EquityLowerable` — lowering a two-sided /
* multi-leg vol structure needs the options grammar, which does not yet exist, so the type refuses
* it rather than emitting a lossy proxy.
*
* Instrument hygiene: this module is OSS/public (`src/**` ships). Every example and every default
* uses a generic index underlying; no strategy repertoire, persona playbook, or private alpha
* lives here — only the typed spine.
*/
// ─────────────────────────────────────────────────────────────────────────────
// The two LOAD-BEARING EDGES — the decisive reads a PM extracts from a MarketState
// ─────────────────────────────────────────────────────────────────────────────
/** Edge 1a — the directional component of the expressiveness read. */
export type Direction = "up" | "down" | "two-sided";
/** Edge 1b — the magnitude/vol component of the expressiveness read. */
export type Magnitude = "large" | "moderate" | "small";
/** Edge 2 — the information read: is the catalyst funded/real or an unfunded pump? */
export type Catalyst = "real" | "promotional";
/** The one-sided directions — a breakout / vertical / equity order has a side. */
export type OneSided = Extract<Direction, "up" | "down">;
/** The magnitudes a straddle expresses (a straddle is never `moderate` — that is a `Vertical`). */
export type StraddleMag = Extract<Magnitude, "large" | "small">;
/** A budget in whole R, matching `plan.gbnf`'s `digit ::= [1-5]`. */
export type BudgetR = 1 | 2 | 3 | 4 | 5;
// ─────────────────────────────────────────────────────────────────────────────
// The 4-constructor Strategy ADT
// ─────────────────────────────────────────────────────────────────────────────
/** The equity opening-range-breakout scalp — the strategy the shipped `plan.gbnf` directly encodes. */
export interface ORB {
readonly kind: "ORB";
/** Breakout side: `up` ⇒ cross ABOVE the range high; `down` ⇒ cross BELOW the range low. */
readonly direction: OneSided;
readonly underlying: string;
readonly orHigh: number;
readonly orLow: number;
readonly budgetR: BudgetR;
/** Scalp take-profit percent, in `plan.gbnf`'s `[0-9] "." [0-9]` band (0.0 .. 9.9). */
readonly tpPct: number;
}
/**
* The ATM straddle — the TWO-SIDED vol structure. `M` is load-bearing: `large` ⇒ own convexity
* (LONG straddle, the `long_straddle` play); `small` ⇒ harvest a pin/crush (SHORT straddle, the
* `short_straddle` play). There is no directional straddle — `direction` is `"two-sided"` by type.
*/
export interface Straddle<M extends StraddleMag = StraddleMag> {
readonly kind: "Straddle";
readonly magnitude: M;
readonly direction: "two-sided";
readonly underlying: string;
}
/**
* The defined-risk vertical — the MODERATE, capped directional structure. `D` is load-bearing:
* `up` ⇒ bull call spread (`call_vertical`); `down` ⇒ bear put spread (`put_vertical`). A vertical
* is never `two-sided` (that is a `Straddle`) — `magnitude` is `"moderate"` by type.
*/
export interface Vertical<D extends OneSided = OneSided> {
readonly kind: "Vertical";
readonly direction: D;
readonly magnitude: "moderate";
readonly underlying: string;
}
/**
* The small-cap news reaction — the INFORMATION-edge constructor. `C` is load-bearing and encodes
* the decisive-field flip: `promotional` ⇒ fade the unfunded pop (SHORT, the `short_fade` play);
* `real` ⇒ ride the funded continuation (LONG, the `momentum_long` play). Same tape, flip `C`,
* get the opposite well-typed book — this is the demo in {@link file ./book.ts}'s `PM`.
*/
export interface NewsShort<C extends Catalyst = Catalyst> {
readonly kind: "NewsShort";
readonly catalyst: C;
readonly underlying: string;
readonly orHigh: number;
readonly orLow: number;
readonly budgetR: BudgetR;
readonly tpPct: number;
}
/** The Strategy ADT — the closed sum of the four constructors over both edges. */
export type Strategy =
| ORB
| Straddle<"large">
| Straddle<"small">
| Vertical<"up">
| Vertical<"down">
| NewsShort<"real">
| NewsShort<"promotional">;
// ─────────────────────────────────────────────────────────────────────────────
// Smart constructors — enforce the edge-read at the VALUE level too (the type does it statically)
// ─────────────────────────────────────────────────────────────────────────────
/** Build an ORB breakout scalp. */
export function orb(args: {
direction: OneSided;
underlying: string;
orHigh: number;
orLow: number;
budgetR: BudgetR;
tpPct: number;
}): ORB {
return { kind: "ORB", ...args };
}
/** Build a straddle from the magnitude edge (`large` ⇒ long, `small` ⇒ short). */
export function straddle<M extends StraddleMag>(magnitude: M, underlying: string): Straddle<M> {
return { kind: "Straddle", magnitude, direction: "two-sided", underlying };
}
/** Build a vertical from the direction edge (`up` ⇒ call, `down` ⇒ put). */
export function vertical<D extends OneSided>(direction: D, underlying: string): Vertical<D> {
return { kind: "Vertical", direction, magnitude: "moderate", underlying };
}
/** Build a news reaction from the catalyst edge (`promotional` ⇒ fade/short, `real` ⇒ momentum/long). */
export function newsShort<C extends Catalyst>(
catalyst: C,
args: { underlying: string; orHigh: number; orLow: number; budgetR: BudgetR; tpPct: number },
): NewsShort<C> {
return { kind: "NewsShort", catalyst, ...args };
}
// ─────────────────────────────────────────────────────────────────────────────
// toPlay — the ADT → item-bank-play projection (proves the banks' plays are ADT instances)
// ─────────────────────────────────────────────────────────────────────────────
/** The bank play ids the ADT constructors stand for (superset of `OPTIONS_MENU`/`NEWS_MENU` actionables). */
export type Play =
| "orb_breakout"
| "long_straddle"
| "short_straddle"
| "call_vertical"
| "put_vertical"
| "short_fade"
| "momentum_long";
/** Assert-never helper for exhaustive matches (mirrors `src/lang`'s `assertNever`). */
function unreachable(x: never): never {
throw new Error(`unreachable Strategy variant: ${JSON.stringify(x)}`);
}
/**
* The item-bank play a strategy stands for — total and exhaustive over the ADT. This is the
* witness that `OPTIONS_MENU` / `NEWS_MENU`'s actionable plays are exactly the images of the four
* typed constructors (asserted in the spine test).
*/
export function toPlay(s: Strategy): Play {
switch (s.kind) {
case "ORB":
return "orb_breakout";
case "Straddle":
return s.magnitude === "large" ? "long_straddle" : "short_straddle";
case "Vertical":
return s.direction === "up" ? "call_vertical" : "put_vertical";
case "NewsShort":
return s.catalyst === "real" ? "momentum_long" : "short_fade";
default:
return unreachable(s);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// lower — Strategy → a real plan.gbnf PLAN string (equity-expressible constructors only)
// ─────────────────────────────────────────────────────────────────────────────
/**
* The constructors the shipped `plan.gbnf` equity subset can express: the equity breakout and the
* equity long/short news reaction. `Straddle` and `Vertical` are DELIBERATELY excluded — a
* two-sided or multi-leg options structure has no single-order equity elaboration in the shipped
* grammar, and inventing one would be a lossy proxy. The type refuses `lower(straddle(...))`.
*/
export type EquityLowerable = ORB | NewsShort<"real"> | NewsShort<"promotional">;
/** Runtime witness for {@link EquityLowerable} (for dispatch sites that hold a wide `Strategy`). */
export function isEquityLowerable(s: Strategy): s is EquityLowerable {
return s.kind === "ORB" || s.kind === "NewsShort";
}
/** Raised when a value cannot be elaborated into the shipped `plan.gbnf` language. */
export class LowerError extends Error {
constructor(message: string) {
super(message);
this.name = "LowerError";
}
}
// The grammar's terminal productions, transcribed. The guards validate the FORMATTED string
// against these (not the raw number against an open interval): rounding at the band edge —
// planPrice(599.999).toFixed(2) === "600.00", planPct(9.97).toFixed(1) === "10.0" — would
// otherwise slip a non-member past a `px < 600` / `pct < 10` pre-check (PR #178 review finding 1).
const PRICE_PRODUCTION = /^[45][0-9][0-9]\.[0-9][0-9]$/; // price ::= [45][0-9][0-9] "." [0-9][0-9]
const PCT_PRODUCTION = /^[0-9]\.[0-9]$/; // pct ::= [0-9] "." [0-9]
/**
* The grammar's FIXED underlying terminal: `root` hard-codes `USING exec SPY`. A non-SPY
* elaboration would parse under `src/lang` (registry-open) yet be OUTSIDE the plan.gbnf language —
* the honest path to more underlyings is a grammar extension, not a silent emission (finding 2).
*/
export const PLAN_GBNF_UNDERLYING = "SPY";
function planUnderlying(underlying: string): string {
if (underlying !== PLAN_GBNF_UNDERLYING) {
throw new LowerError(
`lower: underlying ${JSON.stringify(underlying)} is outside the shipped plan.gbnf — the grammar's ` +
`root production fixes "USING exec ${PLAN_GBNF_UNDERLYING}". Extend the grammar to add underlyings; ` +
`lower will not emit a non-member string.`,
);
}
return underlying;
}
/** Format a price into `plan.gbnf`'s price production, validating the FORMATTED string. */
function planPrice(px: number): string {
const s = px.toFixed(2);
if (!PRICE_PRODUCTION.test(s)) {
throw new LowerError(
`lower: price ${px} formats to ${JSON.stringify(s)}, outside plan.gbnf price ::= [45][0-9][0-9] "." [0-9][0-9] (400.00 .. 599.99)`,
);
}
return s;
}
/** Format a take-profit percent into `plan.gbnf`'s pct production, validating the FORMATTED string. */
function planPct(pct: number): string {
const s = pct.toFixed(1);
if (!PCT_PRODUCTION.test(s)) {
throw new LowerError(
`lower: tpPct ${pct} formats to ${JSON.stringify(s)}, outside plan.gbnf pct ::= [0-9] "." [0-9] (0.0 .. 9.9)`,
);
}
return s;
}
/** Format/validate a name against `plan.gbnf`'s `name ::= [a-z] [a-z-]{2,14}` (3..15 chars). */
function planName(name: string): string {
if (!/^[a-z][a-z-]{2,14}$/.test(name)) {
throw new Error(`lower: name ${JSON.stringify(name)} violates plan.gbnf name ::= [a-z] [a-z-]{2,14}`);
}
return name;
}
/**
* Elaborate an equity-expressible strategy to a `plan.gbnf`-conformant PLAN document string.
*
* The emitted string is a member of the language `scripts/rl-poc/grammar/plan.gbnf` generates AND
* parses under `src/lang`'s `parse()` — the real engine/validator the reward-bridge drives. The
* `name` argument names the plan (defaulted per-constructor); it must satisfy the grammar's `name`
* production.
*/
export function lower(s: EquityLowerable, name?: string): string {
switch (s.kind) {
case "ORB": {
const nm = planName(name ?? `orb-${s.direction}`);
// Breakout: cross the range edge in the breakout direction; defined risk at the far edge.
const trigger = s.direction === "up" ? `above ${planPrice(s.orHigh)}` : `below ${planPrice(s.orLow)}`;
const stopEdge = s.direction === "up" ? `below ${planPrice(s.orLow)}` : `above ${planPrice(s.orHigh)}`;
return [
`PLAN ${nm} budget ${s.budgetR}R ttl 16:00`,
` USING exec ${planUnderlying(s.underlying)}`,
` WHEN spot crosses ${trigger}`,
` DO buy 100 shares @ mid`,
` TP +${planPct(s.tpPct)}%`,
` EXIT spot crosses ${stopEdge} @ bid`,
].join("\n");
}
case "NewsShort": {
// The decisive-field flip, elaborated: promotional ⇒ SELL (fade the unfunded pop);
// real ⇒ BUY (ride the funded continuation). Same levels, opposite side ⇒ different PLAN.
if (s.catalyst === "promotional") {
const nm = planName(name ?? `news-fade`);
return [
`PLAN ${nm} budget ${s.budgetR}R ttl 16:00`,
` USING exec ${planUnderlying(s.underlying)}`,
` WHEN spot crosses above ${planPrice(s.orHigh)}`,
` DO sell 100 shares @ ask`,
` TP +${planPct(s.tpPct)}%`,
` EXIT spot crosses above ${planPrice(s.orHigh)} @ ask`,
].join("\n");
}
const nm = planName(name ?? `news-momo`);
return [
`PLAN ${nm} budget ${s.budgetR}R ttl 16:00`,
` USING exec ${planUnderlying(s.underlying)}`,
` WHEN spot crosses above ${planPrice(s.orHigh)}`,
` DO buy 100 shares @ mid`,
` TP +${planPct(s.tpPct)}%`,
` EXIT spot crosses below ${planPrice(s.orLow)} @ bid`,
].join("\n");
}
default:
return unreachable(s);
}
}