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 (195 loc) 9.62 kB
/** * # fair/black76 — the option-pricing math core (r = 0) * * ExecutionFair is built on Black-76: options priced off the **forward**, discounted at the * risk-free rate. Kestrel runs the whole surface at **r = 0** — short-dated index and * equity options carry no meaningful carry over the horizons that matter, and a zero rate * makes the forward equal spot, keeps put-call parity exactly `C - P = F - K`, and removes a * calibration knob nobody would trust anyway. (ARCHITECTURE §4: "underlying-anchored * intrinsic + a vol read backed out of the *liquid* quotes, floored at intrinsic.") * * Everything here is a **pure function** — no I/O, no clock, no RNG (RUNTIME §0). Time is a * parameter (`tauYears`), never read from a wall clock. * * ## Contract * - `black76` prices a call/put; `tauYears <= 0` **or** `sigma <= 0` collapse to the forward * intrinsic (no time value to add). The price is always `>= intrinsic`. * - `impliedVol` back-outs the volatility that reprices a given premium by bisection, and * returns `null` when there is **no positive-vol solution** (premium at/below intrinsic) or * the premium is **unreachably rich** (>= the asymptote: `forward` for a call, `strike` for * a put — the sigma→∞ limits at r = 0). * - `intrinsic` and `tauYearsToClose` are the small shared helpers callers reuse. */ import type { Right } from "../bus/types.ts"; import { dexp, dlog } from "./detmath.ts"; /** Calendar-year basis for `tauYearsToClose` (365d × 24h × 60m × 60s × 1000ms). */ export const MS_PER_YEAR = 365 * 24 * 60 * 60 * 1000; /** * Error function, implemented **odd by construction** (`erf(-x) = -erf(x)`) so that * `normCdf(x) + normCdf(-x) === 1` exactly — which is what makes put-call parity hold to * floating-point rounding. Abramowitz & Stegun 7.1.26 on the magnitude (|error| ≲ 1.5e-7), * mirrored through the sign. The exponential is the vendored {@link dexp}, not `Math.exp`, so * this stays bit-identical across V8/JSC on the certified-grade path (see {@link ./detmath.ts}). */ export function erf(x: number): number { const sign = x < 0 ? -1 : 1; const ax = Math.abs(x); const t = 1 / (1 + 0.3275911 * ax); const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * dexp(-ax * ax); return sign * y; } /** Standard-normal CDF. `normCdf(x) + normCdf(-x) === 1` exactly (see {@link erf}). */ export function normCdf(x: number): number { return 0.5 * (1 + erf(x / Math.SQRT2)); } /** `1 / √(2π)` — the standard-normal PDF's normalizing constant, as a literal (no `Math.PI` * dependency on the certified path; the value is exact to double precision). */ const INV_SQRT_2PI = 0.3989422804014327; /** Standard-normal PDF `φ(x) = e^(−x²/2) / √(2π)`. Uses the vendored {@link dexp} so gamma/vega * stay bit-identical across V8/JSC on the certified-grade path (see {@link ./detmath.ts}). */ export function normPdf(x: number): number { return INV_SQRT_2PI * dexp(-0.5 * x * x); } /** * The two spot-greeks the options-analytics MODEL panes need (delta + gamma), plus vega, at r = 0 * (forward = spot). All are **model outputs** — computed, never observed — so every consumer that * renders one must carry the Black-Scholes receipt (see {@link ../frame/options-analytics.ts}). * - `delta` — call `N(d1)`, put `N(d1) − 1` (∈ [−1, 1]). * - `gamma` — `φ(d1) / (F σ √τ)` (per $1 of underlier; ≥ 0, peaks at-the-money, blows up as τ→0). * - `vega` — `F φ(d1) √τ` (per 1.00 = 100 vol-points; ≥ 0). * - `theta` — `−F φ(d1) σ / (2√τ)` (per YEAR of calendar time; the time-decay bleed). At r = 0 the * rate/carry terms vanish, so theta is IDENTICAL for a call and a put and is `≤ 0` for a long * option (holding it loses value as τ shrinks) — the θ the theta-bleed percept pane renders. */ export interface Greeks { readonly delta: number; readonly gamma: number; readonly vega: number; readonly theta: number; } /** * The Black-76 greeks at r = 0. A degenerate leg (`τ ≤ 0`, `σ ≤ 0`, or a non-positive forward/ * strike) has NO time value: gamma/vega/theta collapse to 0 and delta is the moneyness step (a call * is 0/1, a put 0/−1 across the strike). Pure — a total function of its parameters. */ export function greeks(p: Black76Params): Greeks { const { forward, strike, tauYears, sigma, right } = p; if (!(tauYears > 0) || !(sigma > 0) || !(forward > 0) || !(strike > 0)) { const itm = right === "C" ? forward > strike : forward < strike; const delta = right === "C" ? (itm ? 1 : 0) : itm ? -1 : 0; return { delta, gamma: 0, vega: 0, theta: 0 }; } const sqrtT = Math.sqrt(tauYears); const vol = sigma * sqrtT; // σ√τ const d1 = (dlog(forward / strike) + 0.5 * vol * vol) / vol; const pdf = normPdf(d1); const delta = right === "C" ? normCdf(d1) : normCdf(d1) - 1; const gamma = pdf / (forward * vol); // φ(d1) / (F σ √τ) const vega = forward * pdf * sqrtT; // Calendar-time decay per YEAR: −∂Price/∂τ. At r = 0 both call and put share this single term // (the discount/drift terms that split them are zero), so long options bleed and short options earn. const theta = -(forward * pdf * sigma) / (2 * sqrtT); return { delta, gamma, vega, theta }; } /** * Intrinsic value of an option at `spot` — the payoff if it settled now. Call = `max(spot - * strike, 0)`, put = `max(strike - spot, 0)`. At r = 0 the forward equals spot, so this is * also the forward intrinsic that {@link black76} floors at. Never negative. */ export function intrinsic(spot: number, strike: number, right: Right): number { return right === "C" ? Math.max(spot - strike, 0) : Math.max(strike - spot, 0); } /** Parameters for a single Black-76 valuation (r = 0, so `forward` is the spot/forward). */ export interface Black76Params { readonly forward: number; readonly strike: number; readonly tauYears: number; readonly sigma: number; readonly right: Right; } /** * Black-76 price at r = 0. `tauYears <= 0` or `sigma <= 0` ⇒ intrinsic exactly (no time value * to add). Strictly increasing in `sigma` (positive vega), so it is invertible by bisection. * The result is always `>= intrinsic(forward, strike, right)`. */ export function black76(p: Black76Params): number { const { forward, strike, tauYears, sigma, right } = p; const intr = intrinsic(forward, strike, right); if (!(tauYears > 0) || !(sigma > 0) || !(forward > 0) || !(strike > 0)) { return intr; } const sqrtT = Math.sqrt(tauYears); const vol = sigma * sqrtT; const d1 = (dlog(forward / strike) + 0.5 * vol * vol) / vol; const d2 = d1 - vol; const price = right === "C" ? forward * normCdf(d1) - strike * normCdf(d2) : strike * normCdf(-d2) - forward * normCdf(-d1); // Numerical floor: the closed form is >= intrinsic analytically; guard rounding. return Math.max(price, intr); } /** Parameters for an implied-vol back-out. `price` is the premium to invert (typically a * two-sided mid). */ export interface ImpliedVolParams { readonly forward: number; readonly strike: number; readonly tauYears: number; readonly price: number; readonly right: Right; } /** * Back out the Black-76 volatility that reprices `price`, by bisection. * * Returns `null` when there is no positive-vol solution: * - `price` is not a finite positive number, or `tauYears <= 0` (no time value to carry vol); * - `price <= intrinsic` (the sigma→0 limit — nothing to back out); * - `price >= asymptote` (`forward` for a call, `strike` for a put — the sigma→∞ limit at * r = 0): an **unreachably-rich** mid. * * Otherwise a unique sigma exists in the open interval and bisection converges to it (the * inverse is exact against {@link black76}, so a round-trip recovers the input sigma). */ export function impliedVol(p: ImpliedVolParams): number | null { const { forward, strike, tauYears, price, right } = p; if (!Number.isFinite(price) || price <= 0) return null; if (!(tauYears > 0) || !(forward > 0) || !(strike > 0)) return null; const intr = intrinsic(forward, strike, right); if (price <= intr) return null; // at/below intrinsic → sigma would be 0, not positive const asymptote = right === "C" ? forward : strike; if (price >= asymptote) return null; // richer than the sigma→∞ limit → unreachable const value = (sigma: number): number => black76({ forward, strike, tauYears, sigma, right }); // Bracket: b76(lo) ≈ intrinsic < price by construction; grow hi until it dominates price. let lo = 1e-8; let hi = 5; const MAX_HI = 50; let vhi = value(hi); for (let guard = 0; vhi < price && hi < MAX_HI && guard < 64; guard++) { hi = Math.min(hi * 2, MAX_HI); vhi = value(hi); } if (vhi < price) return null; // still short of the premium at MAX_HI → treat as unreachable for (let i = 0; i < 128; i++) { const midSigma = 0.5 * (lo + hi); const v = value(midSigma); if (Math.abs(v - price) < 1e-12) return midSigma; if (v < price) lo = midSigma; else hi = midSigma; if (hi - lo < 1e-12) break; } return 0.5 * (lo + hi); } /** * Year-fraction from `nowTs` to the session `closeTs`, both epoch-ms (injected — never a wall * clock, RUNTIME §0), on a 365-day calendar basis. Past the close the value is `<= 0`, which * {@link black76} treats as intrinsic. */ export function tauYearsToClose(nowTs: number, closeTs: number): number { return (closeTs - nowTs) / MS_PER_YEAR; }