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.

214 lines (213 loc) 12.5 kB
/** * # adapters/broker/ibkr/surface-window — THE ONE SURFACE-WINDOW RULE (kestrel-7o2.19) * * Which strikes back a vol surface, and whether that surface may be trusted to price the money. * There is exactly ONE definition of each here, because **a rule that exists twice is a rule that * will diverge** — and it had already diverged: the ORDER path centred its window on the ATM strike * the venue lists, while the FEED took whatever the listed grid happened to start with. Rendered * against the live paper gateway with SPY at 751.94, the feed resolved: * * 741C 741P 742C 742P 743C 743P 744C 744P 745C 745P * * Every call 7–11 points IN THE MONEY (nearly pure intrinsic — its price carries almost no vol * information) and every put a 1-to-4-cent lottery ticket (one tick is a huge relative move). The * agent's Frame — its screen, its scarce token budget — was spent on strikes it cannot trade, and the * vol surface built from those legs was then interpolated to price an AT-THE-MONEY leg while its * receipt reported `nLiquid=5` and looked perfectly healthy. A receipt that says `nLiquid=5` while all * five strikes are deep ITM is a receipt that does not know it is lying. * * So this module owns two things and nothing else: * * 1. {@link surfaceWindow} — the ATM-CENTRED window of the strikes the venue **LISTS FOR THAT * EXPIRY**. Centre on the observed spot; take {@link SurfaceWindowInput.halfWidth} listed strikes * each side. Never the chain's UNION grid (`reqSecDefOptParams` returns the union across every * expiry, and a strike in the union may simply not be listed on the expiry you want — selecting * off it invents an identity the venue does not list). Never a centre-less pick: without the * money there is no window, only whatever the grid happens to start with. * * 2. {@link atmCoverage} — the HONEST fit-quality question `nLiquid` cannot answer: *does this * surface know where the money is?* It is measured off the SAME liquidity rule the surface itself * uses ({@link buildSurface} — imported, never re-implemented), and it is deliberately TWO facts, * because either one alone can be gamed by a pathological chain: * - **bracketing** — are there liquid strikes on BOTH sides of the money? `interpIv` FLAT- * EXTRAPOLATES past the ends of the surface, so an ATM read off a surface whose liquid strikes * all lie below the money is not an interpolation at all: it is the deepest wing's IV, wearing * an at-the-money label. (This is the live case exactly.) * - **distance** — is the nearest liquid strike actually NEAR the money (within * {@link DEFAULT_ATM_BAND_FRACTION} of spot)? A surface that brackets the money from ±40 * points away brackets it in name only. * * Pure functions: no clock, no RNG, no socket (RUNTIME §0). Deterministic on the same inputs. * * ## PLACES NO ORDERS * This is selection and fit-quality arithmetic. It reaches no client, opens no request, and names no * order path — market data only, like every other module in the feed face. */ import { buildSurface, impliedForward } from "../../../fair/index.js"; // ───────────────────────────────────────────────────────────────────────────── // The named, injectable parameters (never a magic number at a call site) // ───────────────────────────────────────────────────────────────────────────── /** * How many LISTED strikes to take EACH SIDE of the at-the-money one. The default is the order path's * own (kestrel-7o2.8's dry run): 4 each side ⇒ **9 strikes ⇒ 18 two-sided legs**. A `@fair` with a * receipt needs real liquid neighbours on both wings — one leg cannot imply its own surface, and a * surface that stops at the money can only extrapolate across it. */ export const DEFAULT_SURFACE_HALF_WIDTH = 4; /** * What "NEAR the money" means, as a fraction of spot — the band a liquid strike must fall inside to * count as covering an at-the-money valuation. Dimensionless on purpose: half a percent of spot is * the same statement about a 40-dollar name and a 750-dollar one. (At SPY 751.94 that is ±3.76 — so * the live case's nearest liquid strike, 745, sits 6.94 out and does NOT cover the money.) */ export const DEFAULT_ATM_BAND_FRACTION = 0.005; // ───────────────────────────────────────────────────────────────────────────── // Typed refusal // ───────────────────────────────────────────────────────────────────────────── /** Why a surface window could not be built. Fail-closed: a window is never invented, and a window * with no money at its centre is not a window — it is the kestrel-7o2.19 defect. */ export class IbkrSurfaceWindowError extends Error { name = "IbkrSurfaceWindowError"; reason; constructor(reason) { super(`IBKR surface window refused: ${reason} (fail-closed; STAND_DOWN)`); this.reason = reason; } } /** * The LISTED strike nearest the money. Ties break LOW (deterministic — no clock, no RNG). `null` only * for an empty/unusable grid, which the caller fails closed on. */ export function atmListedStrike(listed, spot) { if (!Number.isFinite(spot)) return null; let best = null; for (const s of listed) { if (!Number.isFinite(s)) continue; if (best === null) { best = s; continue; } const d = Math.abs(s - spot); const db = Math.abs(best - spot); if (d < db || (d === db && s < best)) best = s; } return best; } /** * THE ONE RULE (kestrel-7o2.19). Centre on the ATM strike the venue LISTS for this expiry, take * `halfWidth` listed strikes each side. Both the feed's chain slice and the order path's vol surface * are selected with this and nothing else. * * @throws {IbkrSurfaceWindowError} on an empty listed grid (a grid is never invented) or on a spot * that is not a usable price (a window with no money at its centre is exactly the defect this exists * to kill: it silently becomes "whatever the grid happens to start with"). */ export function surfaceWindow(input) { const { spot } = input; const halfWidth = input.halfWidth ?? DEFAULT_SURFACE_HALF_WIDTH; if (!Number.isFinite(spot) || spot <= 0) { throw new IbkrSurfaceWindowError(`a surface window was asked for with NO usable spot (got ${String(spot)}) — a window with no money at its centre is not a window, it is whatever the grid happens to start with (kestrel-7o2.19: 741–745 while the money was at 751.94)`); } // Sorted + de-duplicated defensively: `listOptionStrikes` already returns the venue's grid this // way, and the window's determinism must not depend on that staying true elsewhere. const grid = [...new Set(input.listed.filter((s) => Number.isFinite(s)))].sort((a, b) => a - b); if (grid.length === 0) { throw new IbkrSurfaceWindowError("the venue LISTS no strikes for this expiry — a strike grid is never invented, and a surface is never built off a grid nobody published"); } if (!Number.isFinite(halfWidth) || halfWidth < 0) { throw new IbkrSurfaceWindowError(`halfWidth must be a non-negative number of listed strikes each side (got ${String(halfWidth)})`); } const atm = atmListedStrike(grid, spot); // grid is non-empty ⇒ never null const i = grid.indexOf(atm); const half = Math.floor(halfWidth); const strikes = grid.slice(Math.max(0, i - half), Math.min(grid.length, i + half + 1)); return { spot, atm, halfWidth: half, strikes }; } /** * Measure a surface's coverage OF THE MONEY (kestrel-7o2.19). The liquidity rule is not re-invented * here: {@link buildSurface} is asked which strikes actually produced a vol point, so a leg this * module calls "liquid" is exactly a leg the surface was built from — one-sided, dark, crossed and * no-arb-violating books contribute nothing, as they contribute nothing there. * * Which is exactly why the surface must be built at the SAME FORWARD the model prices at — the * parity read, not spot (kestrel-ukwz). The forward decides which legs are liquid at all (it sets * `realSides`' no-arb bounds) and whether a strike's call and put agree about vol, so measuring * coverage at spot while `executionFair` prices at the parity forward would describe a DIFFERENT * surface than the one the number came off — a receipt vouching for a surface that was never * built. `spot` still defines the MONEY (the band, the bracketing, the distances): where the * underlier is, is a fact about spot; what the options are worth, is a fact about the forward. */ export function atmCoverage(input) { const { legs, spot, tauYears } = input; const bandFraction = input.bandFraction ?? DEFAULT_ATM_BAND_FRACTION; if (spot === null || !Number.isFinite(spot) || spot <= 0) { return { spot: null, bandUsd: null, bandFraction, nLiquid: 0, nNearMoney: 0, nearestLiquidStrike: null, distanceUsd: null, bracketsSpot: false, supportsAtm: false, reason: "there is no usable underlier, so there is no money for the surface to cover — nothing may vouch for an at-the-money valuation", }; } // The strikes that ACTUALLY backed the surface — the engine's own liquidity rule, not a second // one, AT the engine's own forward, not a second one (see the header). const points = buildSurface(legs, impliedForward(legs, spot).forward, tauYears); const strikes = points.map((p) => p.strike); const bandUsd = Math.abs(bandFraction) * spot; const nLiquid = strikes.length; const nNearMoney = strikes.filter((s) => Math.abs(s - spot) <= bandUsd).length; const below = strikes.some((s) => s <= spot); const above = strikes.some((s) => s >= spot); const bracketsSpot = below && above; const nearestLiquidStrike = atmListedStrike(strikes, spot); const distanceUsd = nearestLiquidStrike === null ? null : Math.abs(nearestLiquidStrike - spot); const base = { spot, bandUsd, bandFraction, nLiquid, nNearMoney, nearestLiquidStrike, distanceUsd, bracketsSpot, }; if (nLiquid === 0) { return { ...base, supportsAtm: false, reason: "the surface has NO liquid strikes at all — there is nothing to interpolate and nothing to vouch for", }; } if (!bracketsSpot) { const side = above ? "ABOVE" : "BELOW"; return { ...base, supportsAtm: false, reason: `every one of the ${nLiquid} liquid strike(s) lies ${side} the money (spot=${spot}, liquid=[${strikes.join(", ")}]) — ` + `the surface does not BRACKET the money, so an at-the-money read off it is a flat EXTRAPOLATION of the wing's IV, not an interpolation`, }; } if (nNearMoney === 0) { return { ...base, supportsAtm: false, reason: `the nearest liquid strike (${nearestLiquidStrike ?? "—"}) is ${(distanceUsd ?? 0).toFixed(2)} from the money ` + `(spot=${spot}), outside the ±${bandUsd.toFixed(2)} near-the-money band — the surface brackets the money only in name`, }; } return { ...base, supportsAtm: true, reason: null }; } /** The taint a valuation carries when its surface does not know the money — the sentence a receipt * reporting `nLiquid` alone would never say about itself. */ export function atmTaint(coverage) { return (`THE SURFACE DOES NOT KNOW THE MONEY (atm-uncovered): ${coverage.reason ?? "no at-the-money coverage"} — ` + `nLiquid=${coverage.nLiquid} counts liquid strikes, it does not locate them, so this valuation is NOT vouched for (kestrel-7o2.19)`); }