Skip to content

Commit aced463

Browse files
authored
feat: WASM binding parity — implied vol, conventions, local vol (PAN-28) — v2.3.0 (#123)
* feat: expose implied vol + conventions in WASM bindings (PAN-28) Add wasm/src/implied.rs mirroring python/src/implied.rs: WasmOptionType, black/normal/displaced price free fns, Wasm{Black,Normal,Displaced}ImpliedVol extractors, and log_moneyness/moneyness/forward_price convention helpers. Bindings only; no core (src/) changes. Work: 2026-06-06_02_pan28-wasm-binding-parity | Task: TASK-001 Acceptance criteria: - new implied.rs mirrors Python layer (implied vol + conventions) - WasmOptionType enum with From conversions to volsurf::OptionType - price fns + ImpliedVol::compute return Result<f64, JsValue> via to_js_err - declared/re-exported in lib.rs - host + wasm32 build clean; clippy -D warnings clean; fmt clean * feat: expose Dupire + boundary local vol in WASM bindings (PAN-28) Add wasm/src/local_vol.rs with WasmDupireLocalVol and WasmBoundaryLocalVol (the v2.2/PAN-25 small-time boundary adapter), each with local_vol(expiry, strike). Since WASM has no unified surface type, wire access via dupire_local_vol() / dupire_local_vol_with_boundary() methods on all three surface types (SSVI/eSSVI via the shared macro, Piecewise hand-written). Bindings only; no core (src/) changes. Work: 2026-06-06_02_pan28-wasm-binding-parity | Task: TASK-002 Acceptance criteria: - WasmDupireLocalVol + WasmBoundaryLocalVol with local_vol -> Result<f64, JsValue> - pub(crate) from_arc helper applies with_bump_size when Some - boundary built via DupireLocalVol::with_boundary() - dupire_local_vol(bump_size?) + _with_boundary on all 3 surface types - declared/re-exported in lib.rs - host + wasm32 build clean; clippy -D warnings clean; fmt clean * test: WASM smoke tests for implied vol, conventions, local vol (PAN-28) Add #[wasm_bindgen_test] coverage mirroring python/tests/test_implied.py: Black/Normal/Displaced price->IV round-trips (Call + Put), displaced beta=1 == Black parity, beta-range rejection, convention known-values + error paths, flat-surface sigma_loc == sigma (1e-10), local vol reachable from builder and SSVI surfaces, and the PAN-25 t=0 boundary rescue. All 78 tests pass under 'wasm-pack test --node'. Also fix a stale pre-existing assertion: version_returns_string expected '2.1.0' but the crate is 2.2.0 (lockstep-bumped in PAN-25); surfaced now because this is the first execution of the wasm suite under a runtime (CI only build+clippy-checks the wasm target). Work: 2026-06-06_02_pan28-wasm-binding-parity | Task: TASK-003 Acceptance criteria: - price->IV round-trips for Black/Normal/Displaced; Call + Put exercised - convention known-values with explicit tolerances - flat sigma_loc==sigma 1e-10; boundary t=0 rescue - local vol reachable from WasmSurfaceBuilder + WasmSsviSurface - cargo test passes (host compiles; executed via wasm-pack test --node) * chore: docs + lockstep 2.3.0 bump for WASM binding parity (PAN-28) Document the new WASM bindings (implied vol, conventions, local vol) in wasm/README.md and add a CHANGELOG [2.3.0] section + compare-link footer. Lockstep-bump core, volsurf-python, and volsurf-wasm to 2.3.0 (core src/ is unchanged; the bump keeps the three crates version-synced per the PAN-25 precedent). Update the version() smoke-test assertion to match. Work: 2026-06-06_02_pan28-wasm-binding-parity | Task: TASK-004 Acceptance criteria: - wasm/README.md lists implied vol, conventions, local vol APIs - CHANGELOG [2.3.0] entry referencing PAN-28; footer updated - workspace builds clean; 78 wasm tests pass under wasm-pack test --node - (PROJECT_MAP.md updated locally; .claude is gitignored in this repo)
1 parent 3727623 commit aced463

11 files changed

Lines changed: 561 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.3.0] - 2026-06-06
11+
12+
### Added
13+
14+
- WASM binding parity with the Python crate (PAN-28):
15+
- Implied vol: `blackPrice`/`normalPrice`/`displacedPrice` undiscounted pricing fns, `WasmBlackImpliedVol`/`WasmNormalImpliedVol` (static `compute`) and `WasmDisplacedImpliedVol` (instance, with `beta`) for Black/Normal/displaced-diffusion IV extraction, and a `WasmOptionType` (`Call`/`Put`) enum
16+
- Conventions: `logMoneyness`, `moneyness`, `forwardPrice` helpers
17+
- Local vol: `WasmDupireLocalVol` and `WasmBoundaryLocalVol` (the v2.2 PAN-25 small-time boundary adapter), reachable from any surface via `dupireLocalVol(bumpSize?)` / `dupireLocalVolWithBoundary(bumpSize?)` on `WasmSsviSurface`, `WasmEssviSurface`, and `WasmPiecewiseSurface`
18+
- WASM smoke tests covering price→IV round-trips, convention known-values, flat-surface `σ_loc ≡ σ`, and the `t = 0` boundary rescue
19+
- Lockstep version bump of all three crates (core, `volsurf-python`, `volsurf-wasm`) to 2.3.0; core `src/` is unchanged in this release
20+
1021
## [2.2.0] - 2026-06-06
1122

1223
### Added
@@ -154,7 +165,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
154165
- `logging` Cargo feature for optional tracing instrumentation
155166
- Examples: `basic_surface`, `smile_models`, `implied_vol`
156167

157-
[Unreleased]: https://github.com/volsurf-rs/volsurf/compare/v2.2.0...HEAD
168+
[Unreleased]: https://github.com/volsurf-rs/volsurf/compare/v2.3.0...HEAD
169+
[2.3.0]: https://github.com/volsurf-rs/volsurf/compare/v2.2.0...v2.3.0
158170
[2.2.0]: https://github.com/volsurf-rs/volsurf/compare/v2.1.0...v2.2.0
159171
[2.1.0]: https://github.com/volsurf-rs/volsurf/compare/v2.0.0...v2.1.0
160172
[2.0.0]: https://github.com/volsurf-rs/volsurf/compare/v1.0.0...v2.0.0

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volsurf"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
edition = "2024"
55
license = "Apache-2.0"
66
description = "Production-ready volatility surface library for derivatives pricing"

python/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volsurf-python"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
edition = "2024"
55
publish = false
66

wasm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "volsurf-wasm"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
edition = "2024"
55
license = "Apache-2.0"
66
description = "WebAssembly bindings for volsurf"

wasm/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,41 @@ Output in `wasm/pkg/`: `.wasm` binary, `.js` loader, `.d.ts` type definitions, `
3333

3434
## API
3535

36+
### Implied Vol
37+
38+
Undiscounted pricing and implied-vol extraction (Jäckel rational approximations).
39+
`WasmOptionType` is a `{ Call, Put }` enum passed into every pricer.
40+
41+
```typescript
42+
import { WasmOptionType, blackPrice, WasmBlackImpliedVol,
43+
normalPrice, WasmNormalImpliedVol,
44+
displacedPrice, WasmDisplacedImpliedVol } from "volsurf-wasm";
45+
46+
// Black (lognormal)
47+
const price = blackPrice(100, 100, 0.20, 1.0, WasmOptionType.Call);
48+
const iv = WasmBlackImpliedVol.compute(price, 100, 100, 1.0, WasmOptionType.Call); // ~0.20
49+
50+
// Normal (Bachelier) — vol is in price units
51+
const np = normalPrice(100, 100, 20.0, 1.0, WasmOptionType.Put);
52+
const niv = WasmNormalImpliedVol.compute(np, 100, 100, 1.0, WasmOptionType.Put); // ~20.0
53+
54+
// Displaced diffusion (interpolates normal ↔ Black); instance carries beta ∈ [0, 1]
55+
const dp = displacedPrice(100, 100, 0.20, 1.0, 0.5, WasmOptionType.Call);
56+
const calc = new WasmDisplacedImpliedVol(0.5);
57+
calc.beta; // 0.5
58+
const div = calc.compute(dp, 100, 100, 1.0, WasmOptionType.Call); // ~0.20
59+
```
60+
61+
### Conventions
62+
63+
```typescript
64+
import { logMoneyness, moneyness, forwardPrice } from "volsurf-wasm";
65+
66+
logMoneyness(100, 100); // ~0 (k = ln(K / F))
67+
moneyness(120, 100); // ~1.2 (m = K / F)
68+
forwardPrice(100, 0.05, 0, 1); // ~105.127 (F = S·exp((r − q)·T))
69+
```
70+
3671
### Smiles
3772

3873
**WasmSviSmile** — SVI parametric model (Gatheral 2004)
@@ -104,6 +139,23 @@ surface.blackVol(0.5, 100.0)
104139
surface.blackVariance(0.5, 100.0)
105140
```
106141

142+
### Local Vol
143+
144+
Dupire local volatility (Gatheral 2006, Eq. 1.10) composed over any surface.
145+
Because WASM has no unified surface type, obtain a local-vol object by calling a
146+
method on the surface — available on `WasmSsviSurface`, `WasmEssviSurface`, and
147+
`WasmPiecewiseSurface`. `bumpSize` is optional (defaults to 0.01).
148+
149+
```typescript
150+
const lv = surface.dupireLocalVol(); // WasmDupireLocalVol (optional bumpSize)
151+
lv.localVol(0.5, 100.0); // σ_loc at (expiry, strike)
152+
153+
// Boundary adapter (v2.2 / PAN-25): a query at t ≤ floor (the bump size) is
154+
// evaluated at t = floor, rescuing the t → 0 singularity of the strict path.
155+
const bdy = surface.dupireLocalVolWithBoundary(); // WasmBoundaryLocalVol
156+
bdy.localVol(0.0, 100.0); // succeeds where dupireLocalVol throws at t = 0
157+
```
158+
107159
### Error Handling
108160

109161
All constructors and query methods throw on invalid input or calibration failure:

wasm/src/builder.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,18 @@ impl WasmPiecewiseSurface {
188188
.map(WasmSurfaceDiagnostics::from)
189189
.map_err(to_js_err)
190190
}
191+
192+
pub fn dupire_local_vol(
193+
&self,
194+
bump_size: Option<f64>,
195+
) -> Result<crate::local_vol::WasmDupireLocalVol, JsValue> {
196+
crate::local_vol::WasmDupireLocalVol::from_arc(Arc::clone(&self.inner), bump_size)
197+
}
198+
199+
pub fn dupire_local_vol_with_boundary(
200+
&self,
201+
bump_size: Option<f64>,
202+
) -> Result<crate::local_vol::WasmBoundaryLocalVol, JsValue> {
203+
crate::local_vol::WasmBoundaryLocalVol::from_arc(Arc::clone(&self.inner), bump_size)
204+
}
191205
}

wasm/src/implied.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
use volsurf::OptionType;
2+
use wasm_bindgen::prelude::*;
3+
4+
use crate::error::to_js_err;
5+
6+
/// Option type: call or put. Mirrors [`volsurf::OptionType`].
7+
#[wasm_bindgen]
8+
#[derive(Clone, Copy)]
9+
pub enum WasmOptionType {
10+
Call,
11+
Put,
12+
}
13+
14+
impl From<WasmOptionType> for OptionType {
15+
fn from(v: WasmOptionType) -> Self {
16+
match v {
17+
WasmOptionType::Call => OptionType::Call,
18+
WasmOptionType::Put => OptionType::Put,
19+
}
20+
}
21+
}
22+
23+
impl From<OptionType> for WasmOptionType {
24+
fn from(v: OptionType) -> Self {
25+
match v {
26+
OptionType::Call => WasmOptionType::Call,
27+
OptionType::Put => WasmOptionType::Put,
28+
}
29+
}
30+
}
31+
32+
/// Undiscounted Black (lognormal) option price.
33+
#[wasm_bindgen]
34+
pub fn black_price(
35+
forward: f64,
36+
strike: f64,
37+
vol: f64,
38+
expiry: f64,
39+
option_type: WasmOptionType,
40+
) -> Result<f64, JsValue> {
41+
volsurf::implied::black_price(forward, strike, vol, expiry, option_type.into())
42+
.map_err(to_js_err)
43+
}
44+
45+
/// Undiscounted Bachelier (normal) option price.
46+
#[wasm_bindgen]
47+
pub fn normal_price(
48+
forward: f64,
49+
strike: f64,
50+
vol: f64,
51+
expiry: f64,
52+
option_type: WasmOptionType,
53+
) -> Result<f64, JsValue> {
54+
volsurf::implied::normal_price(forward, strike, vol, expiry, option_type.into())
55+
.map_err(to_js_err)
56+
}
57+
58+
/// Undiscounted displaced-diffusion option price.
59+
#[wasm_bindgen]
60+
pub fn displaced_price(
61+
forward: f64,
62+
strike: f64,
63+
vol: f64,
64+
expiry: f64,
65+
beta: f64,
66+
option_type: WasmOptionType,
67+
) -> Result<f64, JsValue> {
68+
volsurf::implied::displaced_price(forward, strike, vol, expiry, beta, option_type.into())
69+
.map_err(to_js_err)
70+
}
71+
72+
/// Black (lognormal) implied volatility extraction via Jäckel's algorithm.
73+
#[wasm_bindgen]
74+
pub struct WasmBlackImpliedVol;
75+
76+
#[wasm_bindgen]
77+
impl WasmBlackImpliedVol {
78+
/// Extract Black implied volatility from an undiscounted option price.
79+
pub fn compute(
80+
option_price: f64,
81+
forward: f64,
82+
strike: f64,
83+
expiry: f64,
84+
option_type: WasmOptionType,
85+
) -> Result<f64, JsValue> {
86+
volsurf::implied::BlackImpliedVol::compute(
87+
option_price,
88+
forward,
89+
strike,
90+
expiry,
91+
option_type.into(),
92+
)
93+
.map(|v| v.0)
94+
.map_err(to_js_err)
95+
}
96+
}
97+
98+
/// Bachelier (normal) implied volatility extraction.
99+
#[wasm_bindgen]
100+
pub struct WasmNormalImpliedVol;
101+
102+
#[wasm_bindgen]
103+
impl WasmNormalImpliedVol {
104+
/// Extract normal (Bachelier) implied volatility from an undiscounted option price.
105+
pub fn compute(
106+
option_price: f64,
107+
forward: f64,
108+
strike: f64,
109+
expiry: f64,
110+
option_type: WasmOptionType,
111+
) -> Result<f64, JsValue> {
112+
volsurf::implied::NormalImpliedVol::compute(
113+
option_price,
114+
forward,
115+
strike,
116+
expiry,
117+
option_type.into(),
118+
)
119+
.map(|v| v.0)
120+
.map_err(to_js_err)
121+
}
122+
}
123+
124+
/// Displaced-diffusion implied volatility extraction (interpolates normal ↔ Black).
125+
#[wasm_bindgen]
126+
pub struct WasmDisplacedImpliedVol {
127+
inner: volsurf::implied::DisplacedImpliedVol,
128+
}
129+
130+
#[wasm_bindgen]
131+
impl WasmDisplacedImpliedVol {
132+
/// Construct with displacement parameter `beta` ∈ [0, 1].
133+
#[wasm_bindgen(constructor)]
134+
pub fn new(beta: f64) -> Result<WasmDisplacedImpliedVol, JsValue> {
135+
let inner = volsurf::implied::DisplacedImpliedVol::new(beta).map_err(to_js_err)?;
136+
Ok(Self { inner })
137+
}
138+
139+
#[wasm_bindgen(getter)]
140+
pub fn beta(&self) -> f64 {
141+
self.inner.beta()
142+
}
143+
144+
/// Extract displaced-diffusion implied volatility from an undiscounted option price.
145+
pub fn compute(
146+
&self,
147+
option_price: f64,
148+
forward: f64,
149+
strike: f64,
150+
expiry: f64,
151+
option_type: WasmOptionType,
152+
) -> Result<f64, JsValue> {
153+
self.inner
154+
.compute(option_price, forward, strike, expiry, option_type.into())
155+
.map(|v| v.0)
156+
.map_err(to_js_err)
157+
}
158+
}
159+
160+
/// Log-moneyness: k = ln(K / F).
161+
#[wasm_bindgen]
162+
pub fn log_moneyness(strike: f64, forward: f64) -> Result<f64, JsValue> {
163+
volsurf::conventions::log_moneyness(strike, forward).map_err(to_js_err)
164+
}
165+
166+
/// Simple moneyness: m = K / F.
167+
#[wasm_bindgen]
168+
pub fn moneyness(strike: f64, forward: f64) -> Result<f64, JsValue> {
169+
volsurf::conventions::moneyness(strike, forward).map_err(to_js_err)
170+
}
171+
172+
/// Forward price from spot: F = S · exp((r − q) · T).
173+
#[wasm_bindgen]
174+
pub fn forward_price(
175+
spot: f64,
176+
rate: f64,
177+
dividend_yield: f64,
178+
expiry: f64,
179+
) -> Result<f64, JsValue> {
180+
volsurf::conventions::forward_price(spot, rate, dividend_yield, expiry).map_err(to_js_err)
181+
}

wasm/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ use wasm_bindgen::prelude::*;
33
mod arbitrage;
44
mod builder;
55
mod error;
6+
mod implied;
7+
mod local_vol;
68
mod smile;
79
mod surface;
810

911
pub use arbitrage::{
1012
WasmArbitrageReport, WasmButterflyViolation, WasmCalendarViolation, WasmSurfaceDiagnostics,
1113
};
1214
pub use builder::{WasmPiecewiseSurface, WasmSurfaceBuilder};
15+
pub use implied::{
16+
WasmBlackImpliedVol, WasmDisplacedImpliedVol, WasmNormalImpliedVol, WasmOptionType,
17+
black_price, displaced_price, forward_price, log_moneyness, moneyness, normal_price,
18+
};
19+
pub use local_vol::{WasmBoundaryLocalVol, WasmDupireLocalVol};
1320
pub use smile::{
1421
WasmArbitrageScanConfig, WasmDataFilter, WasmSabrSmile, WasmSmile, WasmSviSmile,
1522
WasmWeightingScheme, weighting_model_default, weighting_uniform, weighting_vega,

0 commit comments

Comments
 (0)