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.

344 lines (310 loc) 17.1 kB
/** * # 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); } }