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.

292 lines (266 loc) 14.6 kB
/** * # src/pm/book — `Book` as a REFINED collection, and `PM : MarketState → Book` * * (bd kestrel-markets-n7jx.2 / PM-persona-benchmark design §10-Issue-2). A PM seat answers a * `MarketState` with a **Book** — a risk-budget allocation across plays. The item bank * (`tests/bench/pm-item-bank.ts`) treats a book as a bare `readonly BookEntry[]`; nothing there * *stops* an ill-formed book from being graded. This module makes `Book` a **refined type** whose * constructor {@link mkBook} fails closed on the two ways a book is ill-formed, and enforces the * budget law by construction: * * 1. **The budget law — `total ≤ 1.0`** (the P₀ contract, `RISK_BUDGET`). {@link clampToBudget} * scales an over-budget book down PROPORTIONALLY (over-allocation is a failure of DEGREE, not * kind — identical semantics to `pm-item-bank.clampToBudget`, cross-checked in the spine test), * so every `Book` that leaves `mkBook` has `total ≤ 1.0` as a type-level invariant. * * 2. **The size band** — every entry carries `0 < size ≤ 1`. A non-positive, `NaN`, or `>1` raw * size is a *kind* error and is REJECTED (not silently dropped) so "forgot to size it" can't * masquerade as a real allocation. (The empty book — a legitimate stand-down / restraint book — * is well-typed; it is the `total = 0` element.) * * 3. **The correlation / concentration contract — a correlated stack is an ill-typed book.** Each * play carries a risk-factor signature (delta sign, vega sign; see {@link FACTOR_SIGNATURE}). * Two entries are *correlated* when they load the same factor with the same sign. A book that * stacks ≥ 2 mutually-correlated entries past {@link CONCENTRATION_CAP} of the budget is a * **disguised single bet wearing the costume of a diversified book** — it is REJECTED. A single * all-in conviction (one entry at full size) is NOT a stack — it is *declared* concentration and * is well-typed (this is exactly the oracle book shape `[{ play, size: 1.0 }]`). This is the * sabotage-tested refinement: `mkBook([{momentum_long,0.5},{small_long_starter,0.5}])` — two * same-direction longs faking diversification — THROWS through the real constructor path. * * `{@link PM}` is the reference dispatcher `MarketState → Book`: it reads the decisive edge off the * state, selects the well-typed {@link Strategy} constructor, and returns the single-conviction * refined book for it. The **decisive-field flip** — same tape, `catalyst: promotional → real` — * re-dispatches `PM` to a DIFFERENT well-typed book (short_fade → momentum_long), exercised through * this real constructor path in the spine test. * * OSS/public (`src/**` ships): generic instruments only, no repertoire, no persona playbook. */ import { toPlay, type Play, type Strategy, orb, straddle, vertical, newsShort } from "./strategy.ts"; // ───────────────────────────────────────────────────────────────────────────── // Entries, the budget law, and the size band // ───────────────────────────────────────────────────────────────────────────── /** One entry in a book: a play id and its risk-budget fraction. Shape-compatible with the bank. */ export interface BookEntry { readonly play: string; readonly size: number; } /** The risk budget every book is graded under — the P₀ contract: sizes sum to AT MOST 1.0. */ export const RISK_BUDGET = 1.0; /** The maximum share of the budget a single correlated cluster (≥ 2 entries) may carry. */ export const CONCENTRATION_CAP = 0.6; /** Raised when a proposed book violates a refinement (size band or the correlation/concentration contract). */ export class BookRefinementError extends Error { constructor(message: string) { super(message); this.name = "BookRefinementError"; } } /** * Enforce the budget law: if a book's total size exceeds `budget`, scale every entry down * proportionally so the total equals `budget`; a compliant book passes through unchanged. Identical * semantics to `tests/bench/pm-item-bank.ts`'s `clampToBudget` (cross-checked in the spine test) so * the refined type and the merged graders agree byte-for-byte on the α scale. */ export function clampToBudget(book: readonly BookEntry[], budget: number = RISK_BUDGET): readonly BookEntry[] { const total = book.reduce((s, e) => s + Math.max(0, e.size), 0); if (total <= budget) return book; const scale = budget / total; return book.map((e) => ({ play: e.play, size: Math.max(0, e.size) * scale })); } // ───────────────────────────────────────────────────────────────────────────── // The risk-factor model — what makes two plays correlated // ───────────────────────────────────────────────────────────────────────────── /** A play's exposure to the two risk factors the contract reasons over: direction (delta) and vol (vega). */ export interface FactorSignature { /** Directional exposure sign: +1 long the underlying, −1 short, 0 delta-neutral. */ readonly delta: -1 | 0 | 1; /** Volatility exposure sign: +1 long vol/convexity, −1 short vol/premium, 0 vol-neutral. */ readonly vega: -1 | 0 | 1; } /** * The factor signature of each play the ADT can emit (plus the bank's small starters/hedges and * `stand_down`). A play the map does not know is treated as factor-neutral (it joins no cluster) — * consistent with `bookAlpha` scoring an unknown play as 0. */ export const FACTOR_SIGNATURE: Readonly<Record<string, FactorSignature>> = { orb_breakout: { delta: 1, vega: 0 }, long_straddle: { delta: 0, vega: 1 }, short_straddle: { delta: 0, vega: -1 }, call_vertical: { delta: 1, vega: -1 }, put_vertical: { delta: -1, vega: -1 }, momentum_long: { delta: 1, vega: 0 }, short_fade: { delta: -1, vega: 0 }, small_long_starter: { delta: 1, vega: 0 }, small_short_hedge: { delta: -1, vega: 0 }, stand_down: { delta: 0, vega: 0 }, }; function signatureOf(play: string): FactorSignature { return FACTOR_SIGNATURE[play] ?? { delta: 0, vega: 0 }; } /** * The correlation/concentration check. For each risk factor and each sign, sum the budget carried by * the entries loading that factor in that direction; if ≥ 2 distinct entries load it AND their * combined weight exceeds {@link CONCENTRATION_CAP}, the book is a correlated stack. Returns the * offending `(factor, sign, weight)` or `null` if the book is well-diversified. Operates on the * post-clamp sizes (the weights actually graded). */ export function findCorrelatedStack( book: readonly BookEntry[], ): { factor: "delta" | "vega"; sign: -1 | 1; weight: number; plays: readonly string[] } | null { const factors: Array<"delta" | "vega"> = ["delta", "vega"]; for (const factor of factors) { for (const sign of [-1, 1] as const) { let weight = 0; const plays: string[] = []; for (const e of book) { if (e.size <= 0) continue; if (signatureOf(e.play)[factor] === sign) { weight += e.size; plays.push(e.play); } } if (plays.length >= 2 && weight > CONCENTRATION_CAP + 1e-9) { return { factor, sign, weight, plays }; } } } return null; } // ───────────────────────────────────────────────────────────────────────────── // Book — the refined collection // ───────────────────────────────────────────────────────────────────────────── declare const BookBrand: unique symbol; /** * A refined book: a `BookEntry[]` that has passed {@link mkBook}'s size-band and * correlation/concentration refinements and been clamped to `total ≤ RISK_BUDGET`. The brand makes * the refinement load-bearing — you cannot obtain a `Book` without going through `mkBook`. */ export type Book = readonly BookEntry[] & { readonly [BookBrand]: true }; /** Total risk deployed by a book (≤ `RISK_BUDGET` for any `Book` by construction). */ export function bookTotal(book: readonly BookEntry[]): number { return book.reduce((s, e) => s + e.size, 0); } /** * Construct a refined {@link Book}, failing closed on either ill-formedness: * - **size band** — any entry with `size ≤ 0`, `NaN`, or `> 1` throws {@link BookRefinementError}. * - **budget law** — the book is clamped so `total ≤ RISK_BUDGET` (a scaling, never a rejection). * - **correlation/concentration** — a correlated stack (≥ 2 mutually-correlated entries past * {@link CONCENTRATION_CAP}) throws {@link BookRefinementError}. * * The empty book (`mkBook([])`) is the well-typed stand-down / restraint element. */ export function mkBook(entries: readonly BookEntry[]): Book { for (const e of entries) { if (!Number.isFinite(e.size) || e.size <= 0) { throw new BookRefinementError( `book entry ${JSON.stringify(e.play)} has out-of-band size ${e.size} (expected 0 < size ≤ 1)`, ); } if (e.size > 1) { throw new BookRefinementError( `book entry ${JSON.stringify(e.play)} has size ${e.size} > 1 (a single entry cannot exceed the whole budget)`, ); } } const clamped = clampToBudget(entries); const stack = findCorrelatedStack(clamped); if (stack !== null) { throw new BookRefinementError( `correlated stack: ${stack.plays.join(" + ")} all load ${stack.factor} ${stack.sign > 0 ? "+" : "-"} ` + `for combined weight ${stack.weight.toFixed(3)} > concentration cap ${CONCENTRATION_CAP} — ` + `a disguised single bet, not a diversified book`, ); } return clamped as Book; } // ───────────────────────────────────────────────────────────────────────────── // MarketState and the reference PM dispatcher // ───────────────────────────────────────────────────────────────────────────── /** The expressiveness edge — the options-family read (direction × magnitude/vol). */ export interface ExpressivenessRead { readonly direction: "up" | "down" | "two-sided"; readonly magnitude: "large" | "moderate" | "small"; } /** The information edge — the news-family read (catalyst authenticity). */ export interface InformationRead { readonly catalyst: "real" | "promotional"; } /** The opening-range levels an equity elaboration needs. */ export interface Levels { readonly orHigh: number; readonly orLow: number; /** Breakout side for a pure-levels ORB dispatch; defaults to `"up"`. */ readonly breakout?: "up" | "down"; } /** * The state a PM allocates against. The shared technical `surface` plus AT MOST one decisive edge * (the matched-set invariant: the two poles of a pair differ on exactly one edge-read). `levels` * carry the equity range for `ORB`/`NewsShort` elaboration. */ export interface MarketState { readonly underlying: string; readonly surface: string; readonly levels?: Levels; readonly budgetR?: 1 | 2 | 3 | 4 | 5; readonly tpPct?: number; /** Edge 1 — expressiveness (options family). */ readonly expressiveness?: ExpressivenessRead; /** Edge 2 — information (news family). */ readonly information?: InformationRead; } /** Raised when a MarketState carries no decisive read that any constructor can express. */ export class DispatchError extends Error { constructor(message: string) { super(message); this.name = "DispatchError"; } } /** * Select the well-typed {@link Strategy} the state's decisive read calls for. Priority: the * information edge (news) dominates when present, else the expressiveness edge (options), else a * pure-levels equity breakout. A `moderate` read with no direction has no single well-typed * structure and throws (honest boundary, not a silent default). */ export function dispatch(ms: MarketState): Strategy { if (ms.information) { if (!ms.levels) throw new DispatchError("news dispatch needs opening-range levels to elaborate"); return newsShort(ms.information.catalyst, { underlying: ms.underlying, orHigh: ms.levels.orHigh, orLow: ms.levels.orLow, budgetR: ms.budgetR ?? 3, tpPct: ms.tpPct ?? 2.0, }); } if (ms.expressiveness) { const { direction, magnitude } = ms.expressiveness; if (magnitude === "large") return straddle("large", ms.underlying); if (magnitude === "small") return straddle("small", ms.underlying); // moderate ⇒ a capped directional vertical; needs a side. if (direction === "up" || direction === "down") return vertical(direction, ms.underlying); throw new DispatchError("a moderate two-sided read has no single well-typed structure"); } if (ms.levels) { return orb({ direction: ms.levels.breakout ?? "up", underlying: ms.underlying, orHigh: ms.levels.orHigh, orLow: ms.levels.orLow, budgetR: ms.budgetR ?? 3, tpPct: ms.tpPct ?? 0.5, }); } throw new DispatchError("MarketState carries no decisive read and no levels — nothing to allocate"); } /** * The reference PM: `MarketState → Book`. Dispatches to the well-typed strategy for the decisive * read and returns its single-conviction refined book (full-budget on the dispatched play). Flip * the decisive edge, get a different well-typed book — the demo the spine test asserts through this * exact path. */ export function PM(ms: MarketState): Book { const strategy = dispatch(ms); const play: Play = toPlay(strategy); return mkBook([{ play, size: RISK_BUDGET }]); }