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
text/typescript
/**
* # 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 }]);
}