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