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.

323 lines (322 loc) 20.4 kB
/** * # fill/model — the FillModel seam: a floor judge and a ceiling judge (RUNTIME §6) * * One seam sits under every mode of grading: `FillModel.assess(order, leg, ctx) → { fill?, * pFill, kind }`. Every plan **and the structural null cross the same fill model**, so * strategies are compared on fills, not on fill assumptions (ARCHITECTURE §4). A fill model * never returns a bare boolean — it returns a definite floor `fill?` (present only when the * conservative strict-cross rule fires) *and* a per-assessment probability `pFill` the engine * accumulates for expected-value accounting. * * Two implementations, deliberately a floor and a ceiling: * * - {@link StrictCrossV1} — the **floor judge**. A resting order fills only when the tape * quote strictly crosses its price (a BUY fills iff the leg's ask moves *strictly below* * the resting price; symmetric for a SELL vs the bid). Same-price never fills; a dark side * never fills. `pFill ∈ {0, 1}`. Conservative by construction — it under-counts fills, so a * strategy that clears it clears any honest model. * * - {@link MakerFairV1} — the **ceiling judge**. Strict-cross ∪ a calibrated hazard of being * filled while resting at/near `fair`. The hazard **parameters are injected as a typed data * object** (a constructor arg): calibration is versioned *input*, not code. The shipped * default ({@link MAKER_FAIR_DEFAULT_HAZARD}) is clearly labelled **uncalibrated** — it * exists to make the model runnable, never to assert a fill rate. * * Both are pure functions of their arguments (no wall clock, no RNG — RUNTIME §0). All time * enters as `ctx.dtSinceLast` (milliseconds since this order's previous assessment), injected * by the engine from the bus clock. */ import { dexp } from "../fair/detmath.js"; export { isCalibratedSupport } from "../support/index.js"; // The directional far-OTM guard — classification, the GuardedIntent verdict, and the far-OTM cap // decision — is OWNED by fill/guarded-intent (kestrel-vav2, arch-review C1b): ONE interface the fill // model consumes so the never-naked property is testable through the enforcing seam. This module // re-exports the classification surface so consumers keep one fill import, and calls `guardedPfill` // (over `guardedIntentOf`) on both hazard paths — it authors no cap logic of its own. import { guardedIntentOf, guardedPfill } from "./guarded-intent.js"; export { classifyMoneyness, MONEYNESS_ATM_BAND, MONEYNESS_DEEP_OTM_FRAC, SELL_FAR_OTM_CAP, BUY_FAR_OTM_CROSSING, } from "./guarded-intent.js"; /** Every judge in this build reads only the recorded top-of-book tape, so none sees queue position or * intra-tick sequencing — the two limits every model shares. */ const NO_QUEUE_POSITION = { code: "no-queue-position", note: "recorded top-of-book tape only; the judge cannot observe queue position or fill priority", }; const BOOK_TAPE_RESOLUTION = { code: "book-tape-resolution", note: "fills resolve at the recorded book tape's resolution; no intra-tick or intraminute sequencing", }; /** {@link StrictCrossV1}'s self-limitation: the shared tape limits + it credits a fill ONLY on a strict * tape cross (a conservative floor; no passive/maker fills). */ export const STRICT_CROSS_LIMITS = [ NO_QUEUE_POSITION, BOOK_TAPE_RESOLUTION, { code: "strict-cross-only", note: "credits a fill only on a strict tape cross; no passive/maker fill is ever credited (a conservative floor)" }, ]; /** The maker-fair judges' self-limitation: the shared tape limits + a MODELED passive fill near fair (not * an observed execution). Shared by {@link MakerFairV1} and the CALIBRATED {@link MakerFairCalV1}: both * observe the same recorded top-of-book tape and both model passive fills, so the calibrated judge DERIVES * its declaration from here rather than falling through to an "unstated" fail-closed default. */ export const MAKER_FAIR_LIMITS = [ NO_QUEUE_POSITION, BOOK_TAPE_RESOLUTION, { code: "maker-fill-modeled", note: "passive maker fills are a modeled per-second hazard near fair, not observed executions" }, ]; // ───────────────────────────────────────────────────────────────────────────── // Shared primitives // ───────────────────────────────────────────────────────────────────────────── /** * The strict-cross floor rule (RUNTIME §6) — a QUOTE cross **or** a TRADE-through print, the two * deterministic, calibration-free ways the recorded tape hands a resting order a definite fill: * * - **Quote cross.** A resting BUY fills iff the leg's ask is present and **strictly below** the * resting price; a resting SELL iff the bid is present and **strictly above** it. Same-price is * *not* a cross (`<` / `>`, never `≤` / `≥`); a dark side (`null`) never crosses. * - **Trade-through** (kestrel-9gu.9). A resting BUY also fills when a trade **prints at or * through** its price (`leg.last ≤ px`); a resting SELL when `leg.last ≥ px`. RUNTIME §6 defines * strict-cross on "trade prints/quotes moving through the level" — `OptionQuote.last` carries * those prints, yet no fill path read it (the 9gu.9 gap). A print AT the resting level is a real * execution the passive order shared, so it is at-or-through (`≤`/`≥`), not the strict `<`/`>` of * the quote rule. Deterministic and immediately bankable (a recorded print, not a modeled hazard). * * **Same-price doctrine split vs spot (kestrel-0gnb).** The two arms read a same-price *trade * print* differently, and its spot sibling {@link ../fill/spot.ts spotStrictCross} reads it the * OTHER way: this options arm is *at-or-through* (a fresh print AT the level fills), while the spot * arm is strictly-through (`<`/`>`) and REFUSES same-price, because the quote-less catalog tapes * leave queue position unknowable there whereas the option tape's fresh `last` is an execution the * passive order shared. Both are floor-legal; spot is strictly the more conservative reading. The * two share the `strict-cross` judge NAME, so an option-vs-equity comparison under one stamp must * key the same-price rule on the arm, not the name. (Reconciling the two would be a semantics * change needing its own review.) * * **Freshness is the ENGINE's contract, not this function's.** The tape converter carries `last` * forward session-cumulatively (99%+ of last-bearing leg-events on the recorded tapes are stale * carries), and a pure per-look rule cannot tell a fresh execution from a morning print an * afternoon order never interacted with. {@link ../fill/engine.ts SimFillEngine} owns the * cross-event print memory and hands `assess` a leg whose `last` is PRESENT only when the print * CHANGED on this event (fresh; a first sighting is inert). This function therefore treats a * present `last` as fresh — a caller that bypasses the engine must gate freshness itself. * * Either condition is a definite fill, always AT the resting price (RUNTIME §6). This is the * conservative core all three models share (both call sites strict-cross-first). */ export function strictCross(order, leg) { if (order.side === "buy") { if (leg.ask !== null && leg.ask < order.px) return true; return leg.last !== undefined && leg.last <= order.px; } if (leg.bid !== null && leg.bid > order.px) return true; return leg.last !== undefined && leg.last >= order.px; } // ───────────────────────────────────────────────────────────────────────────── // The DIRECTIONAL fill guard — a resting maker p_fill is ASYMMETRIC by SIDE × MONEYNESS in the // far-OTM wing, never symmetric. The classification, the GuardedIntent verdict, and the cap decision // all live in fill/guarded-intent (kestrel-vav2, arch-review C1b) — the ONE interface both hazard // paths below consume via `guardedPfill(pSym, guardedIntentOf(order))`. `classifyMoneyness`, the // moneyness boundaries, and the far-OTM constants are re-exported (above) from that owner so the fill // import surface is unchanged. // ───────────────────────────────────────────────────────────────────────────── /** * Apply the directional far-OTM asymmetry to a **symmetric** maker hazard `pSym` — the LEGACY * positional adapter, retained so existing direct callers keep one call shape. It builds a * {@link GuardedIntent} and delegates to {@link guardedPfill} (fill/guarded-intent), which is the * single owner of the cap decision; this function adds NO logic of its own. New code on the fill path * consumes `guardedPfill(pSym, guardedIntentOf(order))` directly. */ export function directionalPfill(pSym, opts) { const guarded = { side: opts.isBuy ? "buy" : "sell", ...(opts.moneyness !== undefined ? { moneyness: opts.moneyness } : {}), ...(opts.covered !== undefined ? { covered: opts.covered } : {}), unresolved: false, }; return guardedPfill(pSym, guarded); } // ───────────────────────────────────────────────────────────────────────────── // strict-cross-v1 — the floor judge // ───────────────────────────────────────────────────────────────────────────── /** The floor judge: fills only on a strict tape cross, at the resting price, with * `pFill ∈ {0, 1}`. It ignores `fair` and time entirely — the most conservative honest * model. */ export class StrictCrossV1 { name = "strict-cross"; version = "v1"; self_limitation = STRICT_CROSS_LIMITS; assess(order, leg, _ctx) { // Strict-cross outcomes are STRUCTURAL price-priority facts (a definite cross, or a definite // no-cross under the conservative rule) — not extrapolations. Both stamp `calibrated` support // (`support` is the fill-model analogue of data-quality; a structural cross is // as real as a fill gets). This model never hazard-prices, so it has no extrapolated cell. if (strictCross(order, leg)) { return { fill: { qty: order.qty, px: order.px }, pFill: 1, kind: "cross", support: "calibrated" }; } return { pFill: 0, kind: "no-cross", support: "calibrated" }; } } /** * The shipped default hazard set — **UNCALIBRATED**. It exists only so `maker-fair-v1` is * runnable out of the box; it asserts no real fill rate. A live-fit calibration file replaces * it as versioned input (RUNTIME §6). `calibrated: false` is what a report stamps. */ export const MAKER_FAIR_DEFAULT_HAZARD = { version: "uncalibrated-0", calibrated: false, baseHazardPerSec: 0.05, edgeScale: 0.5, fairFloor: 0.05, }; /** * The per-assessment fill hazard: `1 − exp(−λ(edge)·dt)`, with `λ(edge) = baseHazardPerSec · * exp(−edgeRel / edgeScale)`. Monotone **increasing** in `dtMs` (longer at rest ⇒ more likely * to have been hit) and **decreasing** in `edgeRel` (further from fair ⇒ less flow interacts). * A non-positive `dtMs` yields `0` (no time elapsed, no hazard). Pure. The exponentials are the * vendored {@link dexp}, not `Math.exp`, so the hazard is bit-identical across V8/JSC on the * certified-grade path (see {@link ../fair/detmath.ts}). */ export function makerFairHazard(params, edgeRel, dtMs) { if (dtMs <= 0) return 0; const decay = dexp(-Math.max(edgeRel, 0) / params.edgeScale); const lambda = params.baseHazardPerSec * decay; const dtSec = dtMs / 1000; return 1 - dexp(-lambda * dtSec); } /** * The ceiling judge: strict-cross ∪ a hazard of being filled while resting at/near `fair`. * On a strict cross it returns a definite floor fill (`pFill = 1`). Otherwise, if `fair` is * available, it returns the per-assessment hazard as `pFill` (no `fill`); with no `fair` it * degrades to the floor only (`pFill = 0`) — conservative, fail-closed. */ export class MakerFairV1 { name = "maker-fair"; version = "v1"; self_limitation = MAKER_FAIR_LIMITS; params; constructor(params = MAKER_FAIR_DEFAULT_HAZARD) { this.params = params; } get calibration() { return { version: this.params.version, calibrated: this.params.calibrated }; } assess(order, leg, ctx) { // A structural strict-cross is calibrated (a price-priority fact) even on this uncalibrated model. if (strictCross(order, leg)) { return { fill: { qty: order.qty, px: order.px }, pFill: 1, kind: "cross", support: "calibrated" }; } if (ctx.fair === undefined) { // No fair ⇒ degrade to the floor only; there is no calibrated basis to assert, so the cell is // extrapolated (and pFill is 0 anyway ⇒ nothing to bank). Fail-closed. return { pFill: 0, kind: "no-fair", support: "extrapolated" }; } const denom = Math.max(Math.abs(ctx.fair), this.params.fairFloor); const edgeRel = Math.abs(order.px - ctx.fair) / denom; const hSym = makerFairHazard(this.params, edgeRel, ctx.dtSinceLast); // The maker hazard is DIRECTIONAL in the far-OTM wing (buy = real crossing-print, // sell = fantasy → floored to SELL_FAR_OTM_CAP; a covered wing is exempt). Near-the-money / // unclassified ⇒ unchanged. The guard is a POST-CALIBRATION structural multiplier on the OUTPUT // p_fill (directionalPfill applied to the already-calibrated symmetric hazard pSym) — it edits no // fitted DATA, so it also belongs on the calibrated MakerFairCalV1 // path (9gu.1/9gu.2). const h = guardedPfill(hSym, guardedIntentOf(order)); // This shipped default is uncalibrated (calibrated:false) ⇒ its hazard cells are extrapolated; // an injected live-fit param set (calibrated:true) marks them calibrated. const support = this.params.calibrated ? "calibrated" : "extrapolated"; return { pFill: h, kind: "hazard", support }; } } /** Logistic sigmoid on the vendored {@link dexp} (not `Math.exp`) so the calibrated hazard is * bit-identical across V8/JSC on the certified-grade path (see {@link ../fair/detmath.ts}). */ function sigmoid(x) { return 1 / (1 + dexp(-x)); } const clamp01 = (p) => (p < 0 ? 0 : p > 1 ? 1 : p); /** * Validate and load {@link EpisodeSigmoidParams} from a parsed calibration JSON (the private * `hazard_constants.{BUY,SELL}` schema). Fails **loud** on a missing/malformed field — a * calibration you cannot read is never silently defaulted (RUNTIME §8). The private numbers stay * in the (gitignored) data file; only the shape is asserted here. */ export function loadEpisodeSigmoidParams(raw) { const o = raw; const hc = o?.hazard_constants; if (typeof o?.version !== "string" || hc === undefined || hc === null) { throw new Error("fill: malformed hazard-params (need string `version` + `hazard_constants`)"); } const num = (v, where) => { if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`fill: hazard-params ${where} must be a finite number`); return v; }; const side = (s, name) => { const o2 = s; if (o2 === undefined) throw new Error(`fill: hazard-params missing side ${name}`); const saturated = o2.saturated === true; const uStarRaw = o2.u_star; return { alpha: num(o2.alpha, `${name}.alpha`), uStar: uStarRaw === null || uStarRaw === undefined ? null : num(uStarRaw, `${name}.u_star`), rho: num(o2.rho, `${name}.rho`), floor: num(o2.floor, `${name}.floor`), saturated, internalizationFloor: num(o2.internalization_floor, `${name}.internalization_floor`), }; }; return { version: o.version, buy: side(hc.BUY, "BUY"), sell: side(hc.SELL, "SELL") }; } /** * The calibrated ceiling judge (`episode-sigmoid-u-v1`). Strict-cross ∪ the per-episode sigmoid * hazard above. It returns an {@link FillAssessment.episodeKey} (`"lit"` / `"dark"`) so the * engine accrues **one Bernoulli per resting episode** (placement, each reprice = a new order/ * episode, and each dark↔lit transition = a new episode) rather than integrating over time. */ export class MakerFairCalV1 { name; version = "cal"; // DERIVED from MakerFairV1: this calibrated judge observes the SAME recorded top-of-book tape and models // passive fills the same way (only the hazard NUMBERS are fit), so it shares maker-fair's self-limitation. // Declaring it here is what fixes the mislabel — its `maker-fair-v1+<version>` name matched no lookup key // and was silently downgraded to "unstated"/maximally-limited (kestrel-o32). self_limitation = MAKER_FAIR_LIMITS; params; constructor(params) { this.params = params; this.name = `maker-fair-v1+${params.version}`; } get calibration() { return { version: this.params.version, calibrated: true }; } assess(order, leg, ctx) { // A structural strict-cross is calibrated (a price-priority fact). if (strictCross(order, leg)) { return { fill: { qty: order.qty, px: order.px }, pFill: 1, kind: "cross", support: "calibrated" }; } const c = order.side === "buy" ? this.params.buy : this.params.sell; // u needs a two-sided book (a half-spread) AND a fair; otherwise it is the dark LEVEL branch. const twoSided = leg.bid !== null && leg.ask !== null && leg.ask - leg.bid > 0 && ctx.fair !== undefined; if (!twoSided) { // dark / one-sided / no-fair ⇒ level lift to the internalization floor (lone-bid/PFOF). This is // OUTSIDE the fitted u-sigmoid support — a dark internalization region with no live anchor — so // it is EXTRAPOLATED (the far-OTM-offer / dark-lift corners a grader refuses to // bank — the fantasy region). Fail-closed for banking. return { pFill: clamp01(c.internalizationFloor), kind: "cal-dark", episodeKey: "dark", support: "extrapolated" }; } const halfSpread = (leg.ask - leg.bid) / 2; const a = order.side === "buy" ? order.px - ctx.fair : ctx.fair - order.px; const u = a / halfSpread; const pSym = c.saturated ? c.rho : c.floor + (c.rho - c.floor) * sigmoid(c.alpha * (u - (c.uStar ?? 0))); // The directional far-OTM guard is a POST-CALIBRATION // structural multiplier applied to the OUTPUT p_fill (directionalPfill over the already-calibrated // symmetric hazard pSym), NOT a data edit — the fitted episode-sigmoid JSON is never // touched, so this belongs on the CALIBRATED path too. Without it a standalone far-OTM SELL takes // this lit branch (the saturated sell `rho` is a fat p) and returns an uncapped, bankable p_fill — // the far-OTM-offer FANTASY the guard exists to close (BOUNDED RISK / NEVER NAKED). A covering wing // of a defined-risk package is exempt; near-the-money / unclassified legs pass through unchanged. const p = guardedPfill(pSym, guardedIntentOf(order)); // A lit cell within the fitted episode-sigmoid support is CALIBRATED (`support` keys off the // per-cell live-anchor table, orthogonal to the directional guard — the guard caps p_fill, the // support flag reports data quality; both apply on this path). return { pFill: clamp01(p), kind: "cal-sigmoid", episodeKey: "lit", support: "calibrated" }; } }