Skip to main content

qualia_core_db/specialized_libs/computational_economics/
derivatives.rs

1//! Derivatives pricing kernels.
2//!
3//! This module contains deterministic, dependency-free option analytics. All
4//! inputs are supplied by the caller; no market data, calendars, or allocation
5//! are used by the pricing routines.
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum OptionKind {
9    Call,
10    Put,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum OptionStyle {
15    European,
16    American,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DerivativesError {
21    InvalidInput,
22    InvalidSteps,
23    NonRecombiningTree,
24}
25
26#[repr(C)]
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct BlackScholesResult {
29    pub price: f64,
30    pub delta: f64,
31    pub gamma: f64,
32    pub vega: f64,
33    pub theta: f64,
34    pub rho: f64,
35}
36
37pub const MAX_BINOMIAL_STEPS: usize = 1024;
38
39fn finite_positive(x: f64) -> bool {
40    x.is_finite() && x > 0.0
41}
42
43fn finite_nonnegative(x: f64) -> bool {
44    x.is_finite() && x >= 0.0
45}
46
47fn validate_option_inputs(
48    spot: f64,
49    strike: f64,
50    risk_free_rate: f64,
51    dividend_yield: f64,
52    volatility: f64,
53    time_years: f64,
54) -> Result<(), DerivativesError> {
55    if !finite_positive(spot)
56        || !finite_positive(strike)
57        || !risk_free_rate.is_finite()
58        || !dividend_yield.is_finite()
59        || !finite_positive(volatility)
60        || !finite_nonnegative(time_years)
61    {
62        return Err(DerivativesError::InvalidInput);
63    }
64    Ok(())
65}
66
67fn payoff(kind: OptionKind, underlying: f64, strike: f64) -> f64 {
68    match kind {
69        OptionKind::Call => (underlying - strike).max(0.0),
70        OptionKind::Put => (strike - underlying).max(0.0),
71    }
72}
73
74fn normal_pdf(x: f64) -> f64 {
75    const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
76    INV_SQRT_2PI * (-0.5 * x * x).exp()
77}
78
79/// Standard normal CDF using the Abramowitz-Stegun 7.1.26 approximation.
80pub fn normal_cdf(x: f64) -> f64 {
81    if x >= 8.0 {
82        return 1.0;
83    }
84    if x <= -8.0 {
85        return 0.0;
86    }
87
88    let z = x.abs();
89    let t = 1.0 / (1.0 + 0.231_641_9 * z);
90    let poly = (((((1.330_274_429 * t - 1.821_255_978) * t) + 1.781_477_937) * t - 0.356_563_782)
91        * t
92        + 0.319_381_530)
93        * t;
94    let cdf = 1.0 - normal_pdf(z) * poly;
95    if x >= 0.0 {
96        cdf
97    } else {
98        1.0 - cdf
99    }
100}
101
102/// Black-Scholes-Merton option price and Greeks with continuous dividend yield.
103///
104/// `vega` is per 1.0 absolute volatility change, not per volatility point.
105/// `theta` is annualized decay under the standard closed-form convention.
106pub fn black_scholes_price_and_greeks(
107    kind: OptionKind,
108    spot: f64,
109    strike: f64,
110    risk_free_rate: f64,
111    dividend_yield: f64,
112    volatility: f64,
113    time_years: f64,
114) -> Result<BlackScholesResult, DerivativesError> {
115    validate_option_inputs(
116        spot,
117        strike,
118        risk_free_rate,
119        dividend_yield,
120        volatility,
121        time_years,
122    )?;
123
124    if time_years == 0.0 {
125        return Ok(BlackScholesResult {
126            price: payoff(kind, spot, strike),
127            delta: match kind {
128                OptionKind::Call if spot > strike => 1.0,
129                OptionKind::Call => 0.0,
130                OptionKind::Put if spot < strike => -1.0,
131                OptionKind::Put => 0.0,
132            },
133            gamma: 0.0,
134            vega: 0.0,
135            theta: 0.0,
136            rho: 0.0,
137        });
138    }
139
140    let sqrt_t = time_years.sqrt();
141    let sigma_sqrt_t = volatility * sqrt_t;
142    let d1 = ((spot / strike).ln()
143        + (risk_free_rate - dividend_yield + 0.5 * volatility * volatility) * time_years)
144        / sigma_sqrt_t;
145    let d2 = d1 - sigma_sqrt_t;
146    let discount_r = (-risk_free_rate * time_years).exp();
147    let discount_q = (-dividend_yield * time_years).exp();
148    let nd1 = normal_cdf(d1);
149    let nd2 = normal_cdf(d2);
150    let pdf_d1 = normal_pdf(d1);
151
152    let common_theta = -(spot * discount_q * pdf_d1 * volatility) / (2.0 * sqrt_t);
153    let result = match kind {
154        OptionKind::Call => BlackScholesResult {
155            price: spot * discount_q * nd1 - strike * discount_r * nd2,
156            delta: discount_q * nd1,
157            gamma: discount_q * pdf_d1 / (spot * sigma_sqrt_t),
158            vega: spot * discount_q * pdf_d1 * sqrt_t,
159            theta: common_theta - risk_free_rate * strike * discount_r * nd2
160                + dividend_yield * spot * discount_q * nd1,
161            rho: strike * time_years * discount_r * nd2,
162        },
163        OptionKind::Put => {
164            let n_minus_d1 = normal_cdf(-d1);
165            let n_minus_d2 = normal_cdf(-d2);
166            BlackScholesResult {
167                price: strike * discount_r * n_minus_d2 - spot * discount_q * n_minus_d1,
168                delta: -discount_q * n_minus_d1,
169                gamma: discount_q * pdf_d1 / (spot * sigma_sqrt_t),
170                vega: spot * discount_q * pdf_d1 * sqrt_t,
171                theta: common_theta + risk_free_rate * strike * discount_r * n_minus_d2
172                    - dividend_yield * spot * discount_q * n_minus_d1,
173                rho: -strike * time_years * discount_r * n_minus_d2,
174            }
175        }
176    };
177
178    if result.price.is_finite()
179        && result.delta.is_finite()
180        && result.gamma.is_finite()
181        && result.vega.is_finite()
182        && result.theta.is_finite()
183        && result.rho.is_finite()
184    {
185        Ok(result)
186    } else {
187        Err(DerivativesError::InvalidInput)
188    }
189}
190
191/// Returns the put-call parity residual:
192/// `call - put - (spot * exp(-qT) - strike * exp(-rT))`.
193pub fn put_call_parity(
194    call_price: f64,
195    put_price: f64,
196    spot: f64,
197    strike: f64,
198    risk_free_rate: f64,
199    dividend_yield: f64,
200    time_years: f64,
201) -> Result<f64, DerivativesError> {
202    if !call_price.is_finite()
203        || !put_price.is_finite()
204        || !finite_positive(spot)
205        || !finite_positive(strike)
206        || !risk_free_rate.is_finite()
207        || !dividend_yield.is_finite()
208        || !finite_nonnegative(time_years)
209    {
210        return Err(DerivativesError::InvalidInput);
211    }
212
213    Ok(call_price
214        - put_price
215        - (spot * (-dividend_yield * time_years).exp()
216            - strike * (-risk_free_rate * time_years).exp()))
217}
218
219pub fn parity_implied_call_price(
220    put_price: f64,
221    spot: f64,
222    strike: f64,
223    risk_free_rate: f64,
224    dividend_yield: f64,
225    time_years: f64,
226) -> Result<f64, DerivativesError> {
227    if !put_price.is_finite()
228        || !finite_positive(spot)
229        || !finite_positive(strike)
230        || !risk_free_rate.is_finite()
231        || !dividend_yield.is_finite()
232        || !finite_nonnegative(time_years)
233    {
234        return Err(DerivativesError::InvalidInput);
235    }
236    Ok(put_price + spot * (-dividend_yield * time_years).exp()
237        - strike * (-risk_free_rate * time_years).exp())
238}
239
240pub fn parity_implied_put_price(
241    call_price: f64,
242    spot: f64,
243    strike: f64,
244    risk_free_rate: f64,
245    dividend_yield: f64,
246    time_years: f64,
247) -> Result<f64, DerivativesError> {
248    if !call_price.is_finite()
249        || !finite_positive(spot)
250        || !finite_positive(strike)
251        || !risk_free_rate.is_finite()
252        || !dividend_yield.is_finite()
253        || !finite_nonnegative(time_years)
254    {
255        return Err(DerivativesError::InvalidInput);
256    }
257    Ok(call_price - spot * (-dividend_yield * time_years).exp()
258        + strike * (-risk_free_rate * time_years).exp())
259}
260
261/// Cox-Ross-Rubinstein binomial option price.
262///
263/// `steps` must be in `1..=MAX_BINOMIAL_STEPS`. The implementation uses a
264/// fixed stack buffer and backward induction, so American early exercise is
265/// evaluated at every node without allocating.
266pub fn binomial_option_price(
267    kind: OptionKind,
268    style: OptionStyle,
269    spot: f64,
270    strike: f64,
271    risk_free_rate: f64,
272    dividend_yield: f64,
273    volatility: f64,
274    time_years: f64,
275    steps: usize,
276) -> Result<f64, DerivativesError> {
277    validate_option_inputs(
278        spot,
279        strike,
280        risk_free_rate,
281        dividend_yield,
282        volatility,
283        time_years,
284    )?;
285    if steps == 0 || steps > MAX_BINOMIAL_STEPS {
286        return Err(DerivativesError::InvalidSteps);
287    }
288    if time_years == 0.0 {
289        return Ok(payoff(kind, spot, strike));
290    }
291
292    let dt = time_years / steps as f64;
293    let sqrt_dt = dt.sqrt();
294    let up = (volatility * sqrt_dt).exp();
295    let down = 1.0 / up;
296    let growth = ((risk_free_rate - dividend_yield) * dt).exp();
297    let denom = up - down;
298    if denom <= 0.0 || !denom.is_finite() {
299        return Err(DerivativesError::NonRecombiningTree);
300    }
301    let p = (growth - down) / denom;
302    if !p.is_finite() || !(0.0..=1.0).contains(&p) {
303        return Err(DerivativesError::NonRecombiningTree);
304    }
305
306    let discount = (-risk_free_rate * dt).exp();
307    let ratio = up / down;
308    let mut values = [0.0_f64; MAX_BINOMIAL_STEPS + 1];
309
310    let mut underlying = spot * down.powi(steps as i32);
311    for slot in values.iter_mut().take(steps + 1) {
312        *slot = payoff(kind, underlying, strike);
313        underlying *= ratio;
314    }
315
316    for step in (0..steps).rev() {
317        let mut node_underlying = spot * down.powi(step as i32);
318        for node in 0..=step {
319            let continuation = discount * (p * values[node + 1] + (1.0 - p) * values[node]);
320            values[node] = match style {
321                OptionStyle::European => continuation,
322                OptionStyle::American => continuation.max(payoff(kind, node_underlying, strike)),
323            };
324            node_underlying *= ratio;
325        }
326    }
327
328    if values[0].is_finite() {
329        Ok(values[0])
330    } else {
331        Err(DerivativesError::InvalidInput)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
340        (a - b).abs() <= tol
341    }
342
343    #[test]
344    fn black_scholes_put_call_parity_residual_is_small() {
345        let call =
346            black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.01, 0.2, 1.0)
347                .unwrap();
348        let put =
349            black_scholes_price_and_greeks(OptionKind::Put, 100.0, 100.0, 0.05, 0.01, 0.2, 1.0)
350                .unwrap();
351
352        let residual =
353            put_call_parity(call.price, put.price, 100.0, 100.0, 0.05, 0.01, 1.0).unwrap();
354        assert!(residual.abs() < 1e-6);
355    }
356
357    #[test]
358    fn black_scholes_atm_call_is_in_known_bounds() {
359        let result =
360            black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.0, 0.2, 1.0)
361                .unwrap();
362
363        assert!(result.price > 10.40 && result.price < 10.47);
364        assert!(result.gamma > 0.018 && result.gamma < 0.020);
365        assert!(result.vega > 37.0 && result.vega < 38.0);
366    }
367
368    #[test]
369    fn deltas_have_expected_signs() {
370        let call =
371            black_scholes_price_and_greeks(OptionKind::Call, 100.0, 105.0, 0.03, 0.0, 0.25, 0.75)
372                .unwrap();
373        let put =
374            black_scholes_price_and_greeks(OptionKind::Put, 100.0, 105.0, 0.03, 0.0, 0.25, 0.75)
375                .unwrap();
376
377        assert!(call.delta > 0.0 && call.delta < 1.0);
378        assert!(put.delta < 0.0 && put.delta > -1.0);
379    }
380
381    #[test]
382    fn american_put_is_at_least_european_put_in_binomial_tree() {
383        let european = binomial_option_price(
384            OptionKind::Put,
385            OptionStyle::European,
386            100.0,
387            105.0,
388            0.04,
389            0.0,
390            0.25,
391            1.0,
392            256,
393        )
394        .unwrap();
395        let american = binomial_option_price(
396            OptionKind::Put,
397            OptionStyle::American,
398            100.0,
399            105.0,
400            0.04,
401            0.0,
402            0.25,
403            1.0,
404            256,
405        )
406        .unwrap();
407
408        assert!(american >= european);
409    }
410
411    #[test]
412    fn binomial_european_call_converges_near_black_scholes() {
413        let bs =
414            black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.0, 0.2, 1.0)
415                .unwrap();
416        let tree = binomial_option_price(
417            OptionKind::Call,
418            OptionStyle::European,
419            100.0,
420            100.0,
421            0.05,
422            0.0,
423            0.2,
424            1.0,
425            512,
426        )
427        .unwrap();
428
429        assert!(approx_eq(tree, bs.price, 0.05));
430    }
431
432    #[test]
433    fn rejects_invalid_volatility_and_steps() {
434        let bad_vol =
435            black_scholes_price_and_greeks(OptionKind::Call, 100.0, 100.0, 0.05, 0.0, 0.0, 1.0);
436        assert_eq!(bad_vol, Err(DerivativesError::InvalidInput));
437
438        let bad_steps = binomial_option_price(
439            OptionKind::Call,
440            OptionStyle::European,
441            100.0,
442            100.0,
443            0.05,
444            0.0,
445            0.2,
446            1.0,
447            0,
448        );
449        assert_eq!(bad_steps, Err(DerivativesError::InvalidSteps));
450    }
451}