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 18 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 type { OptionQuote, Right } from "../bus/index.ts"; import type { FillSupport } from "../support/index.ts"; export type { FillSupport } from "../support/index.ts"; export { isCalibratedSupport } from "../support/index.ts"; import { type Moneyness } from "./guarded-intent.ts"; export { classifyMoneyness, MONEYNESS_ATM_BAND, MONEYNESS_DEEP_OTM_FRAC, SELL_FAR_OTM_CAP, BUY_FAR_OTM_CROSSING, } from "./guarded-intent.ts"; export type { Moneyness } from "./guarded-intent.ts"; /** The order fields a fill model reads — a defined-risk, single option leg at a resting * price. `px` is the resting limit; the strike/right locate the leg on the book. */ export interface FillOrderView { readonly side: "buy" | "sell"; readonly qty: number; /** The resting limit price. A floor fill always fills *at this price* (RUNTIME §6). */ readonly px: number; readonly strike: number; readonly right: Right; /** Coarse moneyness vs the underlier, for the directional far-OTM guard. * Absent ⇒ symmetric legacy path (guard cannot fire). */ readonly moneyness?: Moneyness; /** `true` when this leg is the **covering wing of a multi-leg defined-risk package** (a * vertical / condor / fly that fills as a combo, anchored by its near leg). A covered far-OTM * short is NOT the standalone-offer fantasy, so the sell-far-OTM cap does not apply to it * (penalizing it would kill every defined-risk spread). Default (absent / * `false`) ⇒ standalone. */ readonly covered?: boolean; } /** Everything a model needs beyond the order and the book leg: the engine's ExecutionFair * for this leg (absent when unbuildable — the model then falls back to the floor only) and * the time since this order's previous assessment (ms, injected — RUNTIME §0). */ export interface FillAssessCtx { /** ExecutionFair for the leg (`@fair`), when the engine could build it. Absent ⇒ dark. */ readonly fair?: number; /** Milliseconds since this order was last assessed. `0` on the first look after placement * or on a same-timestamp update; negative inputs are treated as `0` (causal, never * rewound). */ readonly dtSinceLast: number; } /** A single assessment. `fill` is the **definite floor outcome** — present only when the * conservative strict-cross rule fires (then `pFill` is `1`). When `fill` is absent, `pFill` * is the **per-assessment** probability the engine folds into a survival product for E[$] * accounting. `kind` names why (`"cross"`, `"hazard"`, `"no-cross"`, `"no-fair"`). * * `episodeKey` opts a model into **per-episode** (not per-look) accrual: when present the engine * folds `pFill` into the survival product **only when the key changes** from the order's last * accrued episode (a fresh resting stint), and contributes nothing on a same-key re-look — a * Bernoulli per resting EPISODE rather than a per-second hazard. Absent ⇒ the legacy per-look * (time-integrated) accrual. */ export interface FillAssessment { readonly fill?: { readonly qty: number; readonly px: number; }; readonly pFill: number; readonly kind: string; readonly episodeKey?: string; /** The per-assessment **support flag** ({@link FillSupport}): is this cell * covered by realized live data (`"calibrated"`) or priced off the offline ceiling with no live * anchor (`"extrapolated"`)? A structural strict-cross outcome is `"calibrated"` (a price-priority * fact, not an extrapolation); an uncalibrated hazard cell and the far-OTM-offer / dark-level-lift * internalization regions are `"extrapolated"`. A grader **REFUSES to bank expected-$ from an * extrapolated cell** ({@link isCalibratedSupport}) so strategies cannot hill-climb into * uncalibrated corners of the fill model. Optional/additive: a missing flag is treated * conservatively downstream (not bankable). */ readonly support?: FillSupport; } /** How a model stamps its calibration on a grade (RUNTIME §6 / ADR-0006: every grade stamps * its judge). A pure floor model has none; a hazard model carries its versioned data. */ export interface FillCalibration { readonly version: string; readonly calibrated: boolean; } /** * One thing this fill model (the judge) **structurally could NOT observe** — a stable machine `code` * plus an honest human `note` (a57.4). Each is a documented structural fact of the model, not a * fabrication, so a downstream consumer cannot OVER-CLAIM realism the judge never had. This is the exact * shape the Blotter's fidelity `self_limitation` carries; the model DECLARES it (rather than a downstream * name-string lookup guessing it), so a calibrated model can never be silently mislabelled. */ export interface FillLimit { /** A stable, lexical machine token for the limit (e.g. `no-queue-position`). */ readonly code: string; /** The honest human phrasing of what the judge could not see. */ readonly note: string; } /** {@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 declare const STRICT_CROSS_LIMITS: readonly FillLimit[]; /** 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 declare const MAKER_FAIR_LIMITS: readonly FillLimit[]; /** The one seam. Implementations are pure; time is injected via {@link FillAssessCtx}. */ export interface FillModel { readonly name: string; readonly version: string; /** The calibration data behind this model, when it has any (stamped on reports). */ readonly calibration?: FillCalibration; /** What this judge structurally could NOT observe (a57.4) — declared by the model itself, in a stable * order, so it rides the graded META and reaches the Blotter's fidelity stamp WITHOUT a downstream * name-string lookup that could silently mislabel a calibrated model. */ readonly self_limitation: readonly FillLimit[]; assess(order: FillOrderView, leg: OptionQuote, ctx: FillAssessCtx): FillAssessment; } /** * 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 declare function strictCross(order: FillOrderView, leg: OptionQuote): boolean; /** * 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 declare function directionalPfill(pSym: number, opts: { readonly isBuy: boolean; readonly moneyness?: Moneyness | undefined; readonly covered?: boolean | undefined; }): number; /** 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 declare class StrictCrossV1 implements FillModel { readonly name = "strict-cross"; readonly version = "v1"; readonly self_limitation: readonly FillLimit[]; assess(order: FillOrderView, leg: OptionQuote, _ctx: FillAssessCtx): FillAssessment; } /** * Hazard parameters for {@link MakerFairV1} — **versioned calibration data, injected as a * constructor argument, never hard-coded logic.** A resting maker order near `fair` gets * filled by passing flow at a per-second hazard rate that decays as the order rests further * from fair. Calibrating this against realized live fills produces a new record with a new * `version`; EVs across versions do not naively compare (RUNTIME §6). */ export interface MakerFairHazardParams { /** Calibration identity, stamped on every grade. */ readonly version: string; /** `false` for any set not fit to realized fills — the shipped default is `false`. */ readonly calibrated: boolean; /** Fill hazard **per second** at zero edge (resting exactly at fair). */ readonly baseHazardPerSec: number; /** The relative-edge e-folding scale: at `edgeRel = edgeScale` the hazard rate is `1/e` of * its at-fair value. `edgeRel = |px − fair| / max(|fair|, fairFloor)`. */ readonly edgeScale: number; /** Floor for the fair denominator, so a near-zero fair does not explode the relative * edge. */ readonly fairFloor: number; } /** * 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 declare const MAKER_FAIR_DEFAULT_HAZARD: MakerFairHazardParams; /** * 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 declare function makerFairHazard(params: MakerFairHazardParams, edgeRel: number, dtMs: number): number; /** * 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 declare class MakerFairV1 implements FillModel { readonly name = "maker-fair"; readonly version = "v1"; readonly self_limitation: readonly FillLimit[]; readonly params: MakerFairHazardParams; constructor(params?: MakerFairHazardParams); get calibration(): FillCalibration; assess(order: FillOrderView, leg: OptionQuote, ctx: FillAssessCtx): FillAssessment; } /** The per-side constants of the {@link EpisodeSigmoidParams} form. `uStar` is `null` on a * saturated side (the sigmoid degenerates to the constant `rho`). */ export interface EpisodeSigmoidSide { readonly alpha: number; readonly uStar: number | null; readonly rho: number; readonly floor: number; readonly saturated: boolean; readonly internalizationFloor: number; } /** * The `episode-sigmoid-u-v1` fill-hazard form — **versioned calibration DATA** loaded from a * private JSON, never hard-coded numbers. A resting order's per-episode fill probability is a * sigmoid in the **side-signed aggression axis** `u = a / halfSpread`, where `a = px − fair` * for a BUY and `a = fair − px` for a SELL (u<0 passive, 0 at-fair, >0 crossed) and `halfSpread` * is the leg's current `(ask − bid)/2`: * * `p(u) = floor + (rho − floor)·sigmoid(alpha·(u − u_star))` * * A **saturated** side (alpha≈0 ⇒ SELL) degenerates to the constant `p = rho`. A **dark / one- * sided** book (no half-spread, so `u` is undefined) is a LEVEL lift: `p = internalizationFloor` * (a lone-bid/PFOF hazard), never a `u_star` shift. It is a per-RESTING-EPISODE Bernoulli, not a * per-second hazard (measured time-to-fill is near-instant — median 0s — so time-at-rest adds no * accrual); the engine folds one `p` per episode via {@link FillAssessment.episodeKey}. */ export interface EpisodeSigmoidParams { readonly version: string; readonly buy: EpisodeSigmoidSide; readonly sell: EpisodeSigmoidSide; } /** * 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 declare function loadEpisodeSigmoidParams(raw: unknown): EpisodeSigmoidParams; /** * 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 declare class MakerFairCalV1 implements FillModel { readonly name: string; readonly version = "cal"; readonly self_limitation: readonly FillLimit[]; readonly params: EpisodeSigmoidParams; constructor(params: EpisodeSigmoidParams); get calibration(): FillCalibration; assess(order: FillOrderView, leg: OptionQuote, ctx: FillAssessCtx): FillAssessment; } //# sourceMappingURL=model.d.ts.map