Skip to content

Commit d620851

Browse files
feat(wing): analytic wing-seed for |k_log| in [2.95, 8.0]
Add an analytic wing-seed for the deep-wing regime, derived clean-room from Schadner's IG-quantile and the 2-term Mills asymptotic. Replaces a v1.0.0 path that extrapolated the Chebyshev seed beyond its fit domain (SEED_K_HI = 3.0) and produced 3.27e-1 catastrophic errors on a wing-saturated v x Delta stress grid. Algorithm. For an OTM option with h = |k_log| and IG-survival q_surv = (1 - c_*)/m: z1_0 = -Phi^-1(q_surv); u_0 = -z1_0 + sqrt(z1_0^2 + 2h) (W0 lead) for n in 0..N_PICARD: z2 = h/u + u/2 Q = 1/z2 - 1/z2^3 (Mills) phi = (2*pi)^(-1/2) * exp(-z1^2 / 2) delta = phi * Q (= e^h * Phi(-z2), no overflow) z1 = -Phi^-1(q_surv + delta) u = -z1 + sqrt(z1^2 + 2h) v = u The collapse e^h * Phi(-z2) === phi(z1) * Q(z2) (with Q(z) := sqrt(pi/2) * erfcx(z/sqrt(2)) ~ 1/z - 1/z^3) eliminates the exp(h) overflow on the wing. N_PICARD = 1 followed by HH3 polish lands at the f64 conditioning floor. Dispatch gates (chosen by Wren G's analysis): - h >= K_HI_BAILOUT = 2.95 (wing regime starts at |k_log| ~ 3) - q_surv < WING_Q_MAX = 0.30 (deep-OTM, the wing's natural support) - h < WING_H_MAX = 8.0 (above this the Mills truncation breaks down; route to Jaeckel rational fallback) q-convention fix at the wing dispatch site. The IG kernel's q is the IG CDF; the wing analytic uses IG survival. Without the conversion `q_surv = 1 - q_kernel`, the wing seed receives the wrong probability and the dispatch is silently wrong on a band of the wing grid. Caught during integration, verifier-confirmed. Cost on the wing v x Delta stress grid (360 cases, T = 1, F = 1, median of 7 runs of 5000 reps each, single AVX-512 core, taskset -c 0): pre-wing (v1.0.0): Cheb extrapolation, 3.27e-1 max abs error post-wing (v1.0.1): analytic seed, 8.30e-12 max abs error throughput: 81.3 ns/option (+8 ns vs Chebyshev path) Schadner cold (1M synthetic options, the canonical grid) is unchanged: 73 ns / 3.42e-11 / 0 NaN. The wing path costs ~8 ns when it fires; the Chebyshev path's 73 ns dominates the synthetic distribution. Two pre-existing NaN on the wing v x Delta grid at (v=0.01, Delta in {0.30, 0.70}): tiny-sigma near-ATM puts at the f64 BS price floor (< 1e-7) where the f64 inverse is not meaningful. Pinned by tests/wing_seed.rs::volfi_wing_grid_nan_set_bounded_to_two so a future inner-iteration edit cannot silently expand the NaN set. Files: - src/schadner_fast.rs: +wing_seed_simd, +dispatch gates, clamps - src/otm_context.rs: +dispatch in solve_with_ctx_simd - tests/wing_seed.rs: +9 tests (Wren G corner, mpmath-200-bit reference at h in {3..8} x q in {0.01..0.30}, boundary finiteness, SIMD lane independence, kernel sigma recovery, Cheb non-regression, context-API routing, NaN-set regression pin) - bench/wing_grid.rs: +volfi-style v x Delta stress harness - scripts/wing_ref_gen.py: +regenerates WING_REF from mpmath 200-bit - Cargo.toml: +wing_grid binary entry
1 parent 9f2e44a commit d620851

6 files changed

Lines changed: 991 additions & 11 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,7 @@ lto = "thin"
5555
[[bin]]
5656
name = "kernel_nan"
5757
path = "bench/kernel_nan.rs"
58+
59+
[[bin]]
60+
name = "wing_grid"
61+
path = "bench/wing_grid.rs"

bench/wing_grid.rs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
//! `bench/wing_grid.rs` — volfi-style v×Δ fixed-grid throughput benchmark.
2+
//!
3+
//! The volfi paper's evaluation grid:
4+
//! v ∈ {0.01, 0.05, 0.10, 0.15, ..., 2.00} (41 values)
5+
//! Δ ∈ {0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 0.95, 0.99} (13)
6+
//! 533 (v, Δ) points; repeated REPS times for a measurable timing window.
7+
//!
8+
//! Each (v, Δ) → (h, c_*) via:
9+
//! d1 = Φ⁻¹(Δ); d2 = d1 - v; h = |k_log| = |d1·v - 0.5·v²| (need sign).
10+
//! c_* = Φ(d1) - K/F · Φ(d2) with K/F = exp(-h) for OTM call (k>0).
11+
//!
12+
//! We translate to voltic's (S, K, T, r, price) by fixing S=1, T=1, r=0,
13+
//! K = exp(k_log), price = c_* · S.
14+
//!
15+
//! Reports: ns/option for implied_vol_fast on the wing-dominated grid,
16+
//! and max abs err vs σ_true (= v / sqrt(T) = v with T=1).
17+
18+
use std::time::Instant;
19+
use voltic::{implied_vol_fast, OptionKind};
20+
21+
const V_GRID_START: f64 = 0.01;
22+
const V_GRID_STEP: f64 = 0.05;
23+
const V_GRID_N: usize = 41; // 0.01, 0.05, 0.10, ..., 2.00 (overstep is fine)
24+
const D_GRID: &[f64] = &[
25+
0.01, 0.05, 0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 0.95, 0.99,
26+
];
27+
const REPS: usize = 5000;
28+
29+
fn phi_inv(p: f64) -> f64 {
30+
// Acklam's algorithm, scalar; accurate enough for grid construction.
31+
// Adapted from public domain Wichura AS241 polynomial.
32+
let q = p - 0.5;
33+
if q.abs() <= 0.425 {
34+
let r = q * q;
35+
q * ((((-39.69683028665376 * r + 220.9460984245205) * r - 275.9285104469687) * r
36+
+ 138.357751867269) * r - 30.66479806614716) * r + 2.506628277459239 / (((((-54.47609879822406 * r + 161.5858368580409) * r - 155.6989798598866) * r + 66.80131188771972) * r - 13.28068155288572) * r + 1.0)
37+
} else {
38+
// Tail. Use scipy-style approximation for ~6 digit accuracy; sufficient.
39+
let r = if q < 0.0 { p } else { 1.0 - p };
40+
let lr = (-r.ln()).sqrt();
41+
let z = (((((2.938163982698783 * lr + 4.374664141464968) * lr - 2.549732539343734) * lr
42+
- 2.400758277161838) * lr - 0.3223964580411365) * lr - 0.007784894002430293)
43+
/ ((((3.754408661907416 * lr + 2.445134137142996) * lr + 0.3224671290700398) * lr + 0.007784695709041462) * lr + 1.0);
44+
if q < 0.0 { -z } else { z }
45+
}
46+
}
47+
48+
fn build_grid() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<OptionKind>, Vec<f64>) {
49+
let mut s = Vec::new();
50+
let mut k = Vec::new();
51+
let mut t = Vec::new();
52+
let mut r = Vec::new();
53+
let mut price = Vec::new();
54+
let mut kind = Vec::new();
55+
let mut sigma_true = Vec::new();
56+
let spot: f64 = 1.0;
57+
let tte: f64 = 1.0;
58+
let rate: f64 = 0.0;
59+
let sqrt_t: f64 = tte.sqrt();
60+
for vi in 0..V_GRID_N {
61+
let v = V_GRID_START + V_GRID_STEP * vi as f64;
62+
if v < V_GRID_START || v > 2.0 {
63+
continue;
64+
}
65+
for &dlt in D_GRID.iter() {
66+
// d1 = Φ⁻¹(Δ); d2 = d1 − v.
67+
// k_log = ln(K/F) = −(d1·v − 0.5·v²) = 0.5 v² − d1·v.
68+
// For OTM call (positive k), Δ ∈ (0, 0.5): d1 < 0, k > 0.
69+
let d1 = phi_inv(dlt);
70+
let _d2 = d1 - v;
71+
let k_log = 0.5 * v * v - d1 * v;
72+
// We want OTM CALLS (positive k_log, Δ < 0.5) and OTM PUTS
73+
// (negative k_log, |k_log| via Δ > 0.5). Both feed the wing.
74+
let strike = (k_log).exp();
75+
let sigma = v / sqrt_t;
76+
// Use OTM-leg pricing: if k_log > 0, call; else put.
77+
let is_call = k_log >= 0.0;
78+
let opt_kind = if is_call { OptionKind::Call } else { OptionKind::Put };
79+
// Price via voltic's bs_price so the round-trip is internally consistent.
80+
let p = voltic::bs_price(&[spot], &[strike], &[tte], &[rate], &[sigma], &[opt_kind])[0];
81+
// Filter: price must be above f64 noise so the inverse is meaningful.
82+
if !(p.is_finite() && p > 1e-15) {
83+
continue;
84+
}
85+
s.push(spot);
86+
k.push(strike);
87+
t.push(tte);
88+
r.push(rate);
89+
price.push(p);
90+
kind.push(opt_kind);
91+
sigma_true.push(sigma);
92+
}
93+
}
94+
(s, k, t, r, price, kind, sigma_true)
95+
}
96+
97+
fn main() {
98+
let (s, k, t, r, price, kind, sigma_true) = build_grid();
99+
let n = s.len();
100+
eprintln!("built grid: {n} cases");
101+
102+
// Sanity: solve once, report max abs err.
103+
let solved = implied_vol_fast(&s, &k, &t, &r, &price, &kind);
104+
let mut max_abs = 0.0_f64;
105+
let mut nan_count = 0;
106+
for i in 0..n {
107+
if solved[i].is_nan() {
108+
nan_count += 1;
109+
continue;
110+
}
111+
let e = (solved[i] - sigma_true[i]).abs();
112+
if e > max_abs {
113+
max_abs = e;
114+
}
115+
}
116+
println!("=== volfi v×Δ grid benchmark ===");
117+
println!("cases: {n}");
118+
println!("max |σ_solved − σ_true|: {max_abs:.3e}");
119+
println!("NaN count: {nan_count}");
120+
121+
// Warmup.
122+
let w = implied_vol_fast(&s, &k, &t, &r, &price, &kind);
123+
std::hint::black_box(w);
124+
125+
// Timed loop: REPS passes.
126+
let mut samples = Vec::new();
127+
for _ in 0..7 {
128+
let t0 = Instant::now();
129+
let mut total = 0usize;
130+
for _ in 0..REPS {
131+
let v = implied_vol_fast(&s, &k, &t, &r, &price, &kind);
132+
total += v.len();
133+
std::hint::black_box(&v);
134+
}
135+
let dt = t0.elapsed();
136+
std::hint::black_box(total);
137+
samples.push(dt.as_secs_f64() / (n as f64 * REPS as f64) * 1e9);
138+
}
139+
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
140+
let median = samples[samples.len() / 2];
141+
println!("median ns/option (median of 7, {} reps each): {median:.1}", REPS);
142+
println!("options/sec: {:.3e}", 1e9 / median);
143+
144+
// Stratified report by whether the lane goes through the wing predicate.
145+
let mut wing_lanes = 0usize;
146+
let mut wing_max_err = 0.0_f64;
147+
let mut cheb_lanes = 0usize;
148+
let mut cheb_max_err = 0.0_f64;
149+
for i in 0..n {
150+
let k_log = (k[i] / s[i]).ln() - r[i] * t[i];
151+
let h = k_log.abs();
152+
let q_cdf = {
153+
// Approximation: q = (1-c)/m; computed via the kernel formula.
154+
let ek = k_log.exp();
155+
let m = if k_log > 0.0 { 1.0 } else { ek };
156+
let xn = price[i] / s[i];
157+
let c = if matches!(kind[i], OptionKind::Call) { xn } else { xn + 1.0 - ek };
158+
(1.0 - c) / m
159+
};
160+
let q_surv = 1.0 - q_cdf;
161+
let in_wing = h >= 2.95 && h < 8.0 && q_surv > 0.0 && q_surv < 0.30;
162+
if !solved[i].is_nan() {
163+
let e = (solved[i] - sigma_true[i]).abs();
164+
if in_wing {
165+
wing_lanes += 1;
166+
if e > wing_max_err { wing_max_err = e; }
167+
} else {
168+
cheb_lanes += 1;
169+
if e > cheb_max_err { cheb_max_err = e; }
170+
}
171+
}
172+
}
173+
println!("wing lanes: {wing_lanes} max abs err: {wing_max_err:.3e}");
174+
println!("cheb lanes: {cheb_lanes} max abs err: {cheb_max_err:.3e}");
175+
}

scripts/wing_ref_gen.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env python3
2+
"""Regenerate the WING_REF table in tests/wing_seed.rs from mpmath at 200 bits.
3+
4+
The wing-seed kernel maps (h = |k_log|, q = IG survival) to v = σ√T. The
5+
mpmath-200-bit reference table pins the seed against an oracle that has no
6+
dependency on the Rust kernel itself, so test agreement is independent
7+
evidence of correctness.
8+
9+
Domain: h ∈ {3, 4, 5, 6, 7, 8}, q ∈ {0.01, 0.05, 0.10, 0.20, 0.30}.
10+
11+
Reference equation: the IG-inverse-Gaussian survival relation that maps the
12+
wing parameter pair (h, q) back to v. Schadner's parameterisation is
13+
q = F_IG(4/v² | 2/h, 1) (the IG CDF with mean 2/h and shape 1, at 4/v²)
14+
which is equivalent to
15+
q_surv = 1 - F_IG(4/v² | 2/h, 1).
16+
17+
We invert by mp.findroot.
18+
19+
Run:
20+
python3 scripts/wing_ref_gen.py > /tmp/wing_ref.txt
21+
then paste the table into tests/wing_seed.rs above the existing WING_REF entries.
22+
23+
Not wired into CI; here for reproducibility.
24+
"""
25+
import mpmath as mp
26+
27+
mp.mp.prec = 200 # 200 bits ≈ 60 decimals
28+
29+
30+
def ig_cdf(x, mu, lam):
31+
"""Inverse-Gaussian CDF F(x | mu, lam) at 200 bits.
32+
33+
F(x) = Φ( √(λ/x) · (x/μ − 1) ) + exp(2λ/μ) · Φ( −√(λ/x) · (x/μ + 1) )
34+
35+
Φ via mp.ncdf.
36+
"""
37+
a = mp.sqrt(lam / x) * (x / mu - 1)
38+
b = -mp.sqrt(lam / x) * (x / mu + 1)
39+
return mp.ncdf(a) + mp.exp(2 * lam / mu) * mp.ncdf(b)
40+
41+
42+
def solve_v_for(h, q_surv):
43+
"""Find v such that 1 - ig_cdf(4/v², 2/h, 1) = q_surv."""
44+
mu = mp.mpf(2) / mp.mpf(h)
45+
lam = mp.mpf(1)
46+
target = mp.mpf(q_surv)
47+
48+
def f(v):
49+
v = mp.fabs(v)
50+
if v < mp.mpf("1e-12"):
51+
v = mp.mpf("1e-12")
52+
x = mp.mpf(4) / (v * v)
53+
cdf = ig_cdf(x, mu, lam)
54+
return (mp.mpf(1) - cdf) - target
55+
56+
# Initial guess: leading-order W0(h, q).
57+
z1 = -mp.mpf(mp.qfunc(target).real if False else 0) # placeholder; use a coarse seed
58+
# Use a robust bracket-style: scan h-dependent range.
59+
# In practice v ∈ (0.1, 10.0) covers the (h, q) cells in the table.
60+
lo = mp.mpf("0.1")
61+
hi = mp.mpf("10.0")
62+
root = mp.findroot(f, (lo, hi), solver="anderson", tol=mp.mpf("1e-50"), maxsteps=200)
63+
return mp.fabs(root)
64+
65+
66+
def main():
67+
H_VALUES = [3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
68+
Q_VALUES = [0.01, 0.05, 0.10, 0.20, 0.30]
69+
print("// Regenerated by scripts/wing_ref_gen.py at mpmath 200-bit precision.")
70+
print("const WING_REF: &[(f64, f64, f64)] = &[")
71+
for h in H_VALUES:
72+
for q in Q_VALUES:
73+
v = solve_v_for(h, q)
74+
print(f" ({h:.1f}, {q:.4f}, {float(v):.17e}),")
75+
print("];")
76+
77+
78+
if __name__ == "__main__":
79+
main()

src/otm_context.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use std::simd::StdFloat;
3939
use crate::schadner_fast::{
4040
cheb_qside_basis, cheb_qside_basis_simd, cheb_seed_from_basis_simd,
4141
cheb_seed_from_kside_basis_scalar, householder3_step_simd, ig_kt_prelude_scalar,
42-
SEED_DEG_PUB,
42+
wing_seed_simd, SEED_DEG_PUB, WING_H_MAX, WING_K_LO, WING_Q_MAX,
4343
};
4444
use crate::{LANES, M, V, VOL_MAX, VOL_MIN};
4545

@@ -481,7 +481,33 @@ fn solve_with_ctx_simd(ctx: &OtmContextSimd, c_v: V) -> V {
481481
let q_for_seed = q_v.simd_max(p_lo_v).simd_min(p_hi_v);
482482

483483
let cheb_tw = cheb_qside_basis_simd(q_for_seed);
484-
let seed_v = cheb_seed_from_basis_simd(&ctx.cheb_tu, &cheb_tw);
484+
let cheb_v = cheb_seed_from_basis_simd(&ctx.cheb_tu, &cheb_tw);
485+
486+
// Wing dispatch: lanes in the deep-OTM wing regime get the analytic
487+
// wing seed instead of the Chebyshev seed. ctx.ak carries |k_log|. The
488+
// wing seed expects IG SURVIVAL (= c_*); kernel q is the IG CDF
489+
// (= 1 - c_*) — convert here.
490+
let q_surv = V::splat(1.0) - q_v;
491+
let use_wing = ctx.ak.simd_ge(V::splat(WING_K_LO))
492+
& q_surv.simd_lt(V::splat(WING_Q_MAX))
493+
& q_surv.simd_gt(V::splat(0.0))
494+
& ctx.ak.simd_lt(V::splat(WING_H_MAX));
495+
// Chunk-level bailout: skip the expensive wing_seed_simd call if no
496+
// lane in this chunk needs it. Preserves cold-grid throughput.
497+
let seed_v = if use_wing.any() {
498+
let q_wing_clamped = q_surv
499+
.simd_max(V::splat(1e-300))
500+
.simd_min(V::splat(WING_Q_MAX));
501+
let h_wing_clamped = ctx
502+
.ak
503+
.simd_max(V::splat(WING_K_LO))
504+
.simd_min(V::splat(WING_H_MAX));
505+
let wing_v = wing_seed_simd(h_wing_clamped, q_wing_clamped);
506+
use_wing.select(wing_v, cheb_v)
507+
} else {
508+
cheb_v
509+
};
510+
485511
let mut v_iter = seed_v.simd_max(ctx.v_lo).simd_min(ctx.v_hi);
486512
let mut j = 0;
487513
while j < HOUSEHOLDER3_STEPS_CTX {

0 commit comments

Comments
 (0)