Skip to main content

rustyqlib/equity/
baw.rs

1//! Barone-Adesi & Whaley (1987) quadratic approximation for American options.
2//!
3//! A fast, closed-form-ish alternative to a tree or PDE for American vanillas:
4//! the early-exercise premium is approximated by the dominant term of the
5//! quadratic (MacMillan) approximation to the American PDE, so pricing is a
6//! European Black-Scholes evaluation plus a short Newton solve for the
7//! critical exercise price. Accuracy is a few cents versus a fine binomial
8//! tree for typical parameters — use it when speed matters more than the last
9//! basis point (e.g. a large book revalued many times), and a tree/FD solve
10//! when precision is paramount.
11//!
12//! Everything is expressed with a continuous cost of carry `b = r - q`, where
13//! `q` is the total carry (dividend yield plus borrow). With `b = r - q` the
14//! generalized Black-Scholes formula is exactly
15//! [`bs_price`](crate::equity::blackscholes::bs_price), so the European leg is
16//! shared with the rest of the library.
17//!
18//! Key properties the implementation preserves:
19//! - an American call on a non-dividend payer (`b >= r`, i.e. `q <= 0`) is
20//!   never exercised early, so it equals the European call;
21//! - the price is bounded below by intrinsic value and by the European price
22//!   (the early-exercise premium is non-negative).
23
24use crate::core::solvers::Solver1d;
25use crate::core::trade::PutOrCall;
26use crate::core::utils::{norm_pdf, ContractStyle, norm_cdf};
27use crate::equity::blackscholes::bs_price;
28use crate::equity::vanilla_option::EquityOption;
29
30/// Relative convergence tolerance (in units of strike) for the critical-price
31/// Newton iteration, and its iteration cap.
32const CRIT_TOL: f64 = 1e-6;
33const CRIT_MAX_ITER: usize = 100;
34
35/// American vanilla price via the Barone-Adesi-Whaley approximation.
36///
37/// `q` is the total continuous carry, so the cost of carry is `b = r - q`.
38/// Degenerate inputs (`t <= 0` or `sigma <= 0`) return intrinsic value.
39pub fn price(s: f64, k: f64, r: f64, q: f64, sigma: f64, t: f64, put_or_call: PutOrCall) -> f64 {
40    let intrinsic = match put_or_call {
41        PutOrCall::Call => (s - k).max(0.0),
42        PutOrCall::Put => (k - s).max(0.0),
43    };
44    if t <= 0.0 || sigma <= 0.0 {
45        return intrinsic;
46    }
47    let b = r - q;
48    let euro = bs_price(s, k, r, q, sigma, t, put_or_call);
49
50    match put_or_call {
51        PutOrCall::Call => {
52            // never optimal to exercise a call early when b >= r
53            if b >= r {
54                return euro;
55            }
56            let s_star = critical_call(k, r, b, sigma, t);
57            if s >= s_star {
58                return intrinsic;
59            }
60            let q2 = quadratic_root(r, b, sigma, t, true);
61            let d1 = d1_of(s_star, k, b, sigma, t);
62            let a2 = (s_star / q2) * (1.0 - ((b - r) * t).exp() * norm_cdf(d1));
63            euro + a2 * (s / s_star).powf(q2)
64        }
65        PutOrCall::Put => {
66            let s_star = critical_put(k, r, b, sigma, t);
67            if s <= s_star {
68                return intrinsic;
69            }
70            let q1 = quadratic_root(r, b, sigma, t, false);
71            let d1 = d1_of(s_star, k, b, sigma, t);
72            let a1 = -(s_star / q1) * (1.0 - ((b - r) * t).exp() * norm_cdf(-d1));
73            euro + a1 * (s / s_star).powf(q1)
74        }
75    }
76}
77
78/// Early-exercise premium: the BAW American price minus the European price.
79/// Non-negative by construction.
80pub fn early_exercise_premium(
81    s: f64,
82    k: f64,
83    r: f64,
84    q: f64,
85    sigma: f64,
86    t: f64,
87    put_or_call: PutOrCall,
88) -> f64 {
89    price(s, k, r, q, sigma, t, put_or_call) - bs_price(s, k, r, q, sigma, t, put_or_call)
90}
91
92fn d1_of(s: f64, k: f64, b: f64, sigma: f64, t: f64) -> f64 {
93    ((s / k).ln() + (b + 0.5 * sigma * sigma) * t) / (sigma * t.sqrt())
94}
95
96/// The `q1` (put, `+` root false) or `q2` (call, `+` root true) exponent of
97/// the quadratic approximation, using the finite-maturity `K` factor.
98fn quadratic_root(r: f64, b: f64, sigma: f64, t: f64, call: bool) -> f64 {
99    let n = 2.0 * b / (sigma * sigma);
100    let kf = 2.0 * r / (sigma * sigma * (1.0 - (-r * t).exp()));
101    let disc = ((n - 1.0).powi(2) + 4.0 * kf).sqrt();
102    if call {
103        (-(n - 1.0) + disc) / 2.0
104    } else {
105        (-(n - 1.0) - disc) / 2.0
106    }
107}
108
109/// Critical (early-exercise boundary) spot for an American call, solved by
110/// Newton-Raphson on the value-matching residual `(S* - K) - RHS(S*)`; the
111/// Barone-Adesi-Whaley update `(K + RHS - b_i S) / (1 - b_i)` is exactly the
112/// Newton step for this residual with slope `1 - b_i`.
113fn critical_call(k: f64, r: f64, b: f64, sigma: f64, t: f64) -> f64 {
114    let n = 2.0 * b / (sigma * sigma);
115    let m = 2.0 * r / (sigma * sigma);
116    let q2u = (-(n - 1.0) + ((n - 1.0).powi(2) + 4.0 * m).sqrt()) / 2.0;
117    let su = k / (1.0 - 1.0 / q2u); // perpetual (infinite-maturity) boundary
118    let h2 = -(b * t + 2.0 * sigma * t.sqrt()) * k / (su - k);
119    let seed = k + (su - k) * (1.0 - h2.exp());
120
121    let q2 = quadratic_root(r, b, sigma, t, true);
122    let sqt = sigma * t.sqrt();
123    let rhs = |si: f64| {
124        let d1 = d1_of(si, k, b, sigma, t);
125        bs_price(si, k, r, r - b, sigma, t, PutOrCall::Call)
126            + (1.0 - ((b - r) * t).exp() * norm_cdf(d1)) * si / q2
127    };
128    // slope b_i of RHS from the Barone-Adesi-Whaley paper
129    let bi = |si: f64| {
130        let d1 = d1_of(si, k, b, sigma, t);
131        ((b - r) * t).exp() * norm_cdf(d1) * (1.0 - 1.0 / q2)
132            + (1.0 - ((b - r) * t).exp() * norm_pdf(d1) / sqt) / q2
133    };
134    Solver1d::new(CRIT_TOL * k, CRIT_MAX_ITER)
135        .newton_raphson(|si| (si - k) - rhs(si), |si| 1.0 - bi(si), seed)
136        .x
137}
138
139/// Critical (early-exercise boundary) spot for an American put; Newton on
140/// the residual `(K - S*) - RHS(S*)` with slope `-(1 + b_i)`.
141fn critical_put(k: f64, r: f64, b: f64, sigma: f64, t: f64) -> f64 {
142    let n = 2.0 * b / (sigma * sigma);
143    let m = 2.0 * r / (sigma * sigma);
144    let q1u = (-(n - 1.0) - ((n - 1.0).powi(2) + 4.0 * m).sqrt()) / 2.0;
145    let su = k / (1.0 - 1.0 / q1u);
146    let h1 = (b * t - 2.0 * sigma * t.sqrt()) * k / (k - su);
147    let seed = su + (k - su) * h1.exp();
148
149    let q1 = quadratic_root(r, b, sigma, t, false);
150    let sqt = sigma * t.sqrt();
151    let rhs = |si: f64| {
152        let d1 = d1_of(si, k, b, sigma, t);
153        bs_price(si, k, r, r - b, sigma, t, PutOrCall::Put)
154            - (1.0 - ((b - r) * t).exp() * norm_cdf(-d1)) * si / q1
155    };
156    let bi = |si: f64| {
157        let d1 = d1_of(si, k, b, sigma, t);
158        -((b - r) * t).exp() * norm_cdf(-d1) * (1.0 - 1.0 / q1)
159            - (1.0 + ((b - r) * t).exp() * norm_pdf(-d1) / sqt) / q1
160    };
161    Solver1d::new(CRIT_TOL * k, CRIT_MAX_ITER)
162        .newton_raphson(|si| (k - si) - rhs(si), |si| -1.0 - bi(si), seed)
163        .x
164}
165
166// ── EquityOption integration ────────────────────────────────────────────
167// Flat Black-Scholes inputs are read the same way the analytic vanilla
168// pricer reads them: escrowed spot (cash dividends carved out), the curve's
169// continuous zero rate, the total carry, and the surface vol at this strike.
170
171fn reprice(option: &EquityOption, d_spot: f64, d_vol: f64, d_rate: f64, d_maturity: f64) -> f64 {
172    let s = option.effective_spot() + d_spot;
173    let k = option.base.strike_price;
174    let r = option.risk_free_rate() + d_rate;
175    let q = option.carry_yield();
176    let sigma = option.volatility() + d_vol;
177    let t = (option.time_to_maturity() + d_maturity).max(1e-8);
178    let pc = *option.payoff.put_or_call();
179    match option.payoff.exercise_style() {
180        ContractStyle::American => price(s, k, r, q, sigma, t, pc),
181        // BAW on a European contract is just the European price
182        ContractStyle::European => bs_price(s, k, r, q, sigma, t, pc),
183        // invariant: check_engine_support refuses Bermudan on this engine
184        ContractStyle::Bermudan(_) => {
185            unreachable!("Bermudan exercise is rejected on the BAW engine before pricing")
186        }
187    }
188}
189
190pub fn npv(option: &EquityOption) -> f64 {
191    reprice(option, 0.0, 0.0, 0.0, 0.0)
192}
193
194/// Critical early-exercise spot for this option (the BAW boundary `S*`).
195pub fn critical_spot(option: &EquityOption) -> f64 {
196    let (k, r, b, sigma, t) = (
197        option.base.strike_price,
198        option.risk_free_rate(),
199        option.risk_free_rate() - option.carry_yield(),
200        option.volatility(),
201        option.time_to_maturity(),
202    );
203    match option.payoff.put_or_call() {
204        PutOrCall::Call => critical_call(k, r, b, sigma, t),
205        PutOrCall::Put => critical_put(k, r, b, sigma, t),
206    }
207}
208
209/// Reprice under a market move for portfolio PnL attribution: spot `+ d_spot`,
210/// a parallel vol shift `+ d_vol`, rate `+ d_rate`, and `d_time` years of
211/// elapsed calendar time (which shortens maturity).
212pub fn price_with(option: &EquityOption, d_spot: f64, d_vol: f64, d_rate: f64, d_time: f64) -> f64 {
213    reprice(option, d_spot, d_vol, d_rate, -d_time)
214}
215
216// Greeks: central-difference bumps on the fast closed form, produced by
217// the central sensitivity engine (`crate::equity::greeks`) through
218// [`price_with`]. The American price is smooth below the exercise
219// boundary, so central differences are well-behaved.
220
221/// The spot-independent pieces of one BAW evaluation: the critical price
222/// `S*`, the quadratic exponent and the premium coefficient depend on
223/// `(K, r, b, sigma, T)` but **not on spot**, so the delta/gamma spot
224/// ladder of the central sensitivity engine solves the boundary once and
225/// reprices the ladder through [`value`](Self::value). Produces exactly
226/// the same numbers as [`price`] evaluation by evaluation.
227pub(crate) struct SpotKernel {
228    k: f64,
229    r: f64,
230    q: f64,
231    sigma: f64,
232    t: f64,
233    pc: PutOrCall,
234    american: bool,
235    /// `(s_star, exponent, coefficient)` when the early-exercise premium
236    /// is live; `None` for the pure-European cases.
237    premium: Option<(f64, f64, f64)>,
238}
239
240impl SpotKernel {
241    /// Solve the boundary for this option under (vol, rate, maturity)
242    /// shifts; `d_maturity` extends the maturity (the same convention as
243    /// [`reprice`]).
244    pub(crate) fn new(option: &EquityOption, d_vol: f64, d_rate: f64, d_maturity: f64) -> Self {
245        let k = option.base.strike_price;
246        let r = option.risk_free_rate() + d_rate;
247        let q = option.carry_yield();
248        let sigma = option.volatility() + d_vol;
249        let t = (option.time_to_maturity() + d_maturity).max(1e-8);
250        let pc = *option.payoff.put_or_call();
251        let american = matches!(option.payoff.exercise_style(), ContractStyle::American);
252        let b = r - q;
253        let premium = if !american || sigma <= 0.0 {
254            None
255        } else {
256            match pc {
257                // never optimal to exercise a call early when b >= r
258                PutOrCall::Call if b >= r => None,
259                PutOrCall::Call => {
260                    let s_star = critical_call(k, r, b, sigma, t);
261                    let q2 = quadratic_root(r, b, sigma, t, true);
262                    let d1 = d1_of(s_star, k, b, sigma, t);
263                    let a2 = (s_star / q2) * (1.0 - ((b - r) * t).exp() * norm_cdf(d1));
264                    Some((s_star, q2, a2))
265                }
266                PutOrCall::Put => {
267                    let s_star = critical_put(k, r, b, sigma, t);
268                    let q1 = quadratic_root(r, b, sigma, t, false);
269                    let d1 = d1_of(s_star, k, b, sigma, t);
270                    let a1 = -(s_star / q1) * (1.0 - ((b - r) * t).exp() * norm_cdf(-d1));
271                    Some((s_star, q1, a1))
272                }
273            }
274        };
275        SpotKernel { k, r, q, sigma, t, pc, american, premium }
276    }
277
278    /// Value at `s` (the escrowed spot, shift already applied) — the
279    /// assembly step of [`price`] with the boundary work factored out.
280    pub(crate) fn value(&self, s: f64) -> f64 {
281        let intrinsic = match self.pc {
282            PutOrCall::Call => (s - self.k).max(0.0),
283            PutOrCall::Put => (self.k - s).max(0.0),
284        };
285        // mirror `price`: degenerate American inputs return intrinsic
286        // before the European leg; European style always prices through
287        // bs_price (as `reprice` does)
288        if self.american && self.sigma <= 0.0 {
289            return intrinsic;
290        }
291        let euro = bs_price(s, self.k, self.r, self.q, self.sigma, self.t, self.pc);
292        if !self.american {
293            return euro;
294        }
295        match self.premium {
296            None => euro,
297            Some((s_star, exponent, coefficient)) => {
298                let exercised = match self.pc {
299                    PutOrCall::Call => s >= s_star,
300                    PutOrCall::Put => s <= s_star,
301                };
302                if exercised {
303                    intrinsic
304                } else {
305                    euro + coefficient * (s / s_star).powf(exponent)
306                }
307            }
308        }
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn golden_values_match_reference() {
318        // American put S=100 K=100 T=1 r=5% q=0 (b=5%) sigma=20%
319        let p = price(100.0, 100.0, 0.05, 0.0, 0.20, 1.0, PutOrCall::Put);
320        assert!((p - 6.09762).abs() < 1e-4, "put {p}");
321        // American call S=100 K=100 T=0.5 r=10% q=10% (b=0) sigma=25%
322        let c = price(100.0, 100.0, 0.10, 0.10, 0.25, 0.5, PutOrCall::Call);
323        assert!((c - 6.80134).abs() < 1e-4, "call {c}");
324    }
325
326    #[test]
327    fn non_dividend_call_equals_european() {
328        // q = 0 => b = r, an American call is never exercised early
329        for s in [80.0, 100.0, 120.0] {
330            let a = price(s, 100.0, 0.05, 0.0, 0.30, 1.0, PutOrCall::Call);
331            let e = bs_price(s, 100.0, 0.05, 0.0, 0.30, 1.0, PutOrCall::Call);
332            assert!((a - e).abs() < 1e-10, "s={s} amer {a} euro {e}");
333        }
334    }
335
336    #[test]
337    fn premium_is_non_negative_and_bounded_by_intrinsic() {
338        for &pc in &[PutOrCall::Call, PutOrCall::Put] {
339            for s in [70.0, 85.0, 100.0, 115.0, 130.0] {
340                let a = price(s, 100.0, 0.08, 0.04, 0.25, 0.75, pc);
341                let e = bs_price(s, 100.0, 0.08, 0.04, 0.25, 0.75, pc);
342                let intrinsic = match pc {
343                    PutOrCall::Call => (s - 100.0).max(0.0),
344                    PutOrCall::Put => (100.0 - s).max(0.0),
345                };
346                assert!(a >= e - 1e-9, "american {a} below european {e}");
347                assert!(a >= intrinsic - 1e-9, "american {a} below intrinsic {intrinsic}");
348            }
349        }
350    }
351
352    #[test]
353    fn deep_in_the_money_put_is_intrinsic() {
354        // far below the exercise boundary: exercise now, value = K - S
355        let p = price(60.0, 100.0, 0.10, 0.0, 0.20, 0.5, PutOrCall::Put);
356        assert!((p - 40.0).abs() < 1e-6, "{p}");
357    }
358
359    // simple reference binomial tree for cross-checking the approximation
360    fn crr(pc: PutOrCall, s: f64, k: f64, r: f64, b: f64, v: f64, t: f64, steps: usize) -> f64 {
361        let dt = t / steps as f64;
362        let u = (v * dt.sqrt()).exp();
363        let d = 1.0 / u;
364        let p = ((b * dt).exp() - d) / (u - d);
365        let disc = (-r * dt).exp();
366        let intrinsic = |sp: f64| match pc {
367            PutOrCall::Call => (sp - k).max(0.0),
368            PutOrCall::Put => (k - sp).max(0.0),
369        };
370        let mut v_nodes: Vec<f64> = (0..=steps)
371            .map(|j| intrinsic(s * u.powi(j as i32) * d.powi((steps - j) as i32)))
372            .collect();
373        for i in (0..steps).rev() {
374            for j in 0..=i {
375                let cont = disc * (p * v_nodes[j + 1] + (1.0 - p) * v_nodes[j]);
376                let sp = s * u.powi(j as i32) * d.powi((i - j) as i32);
377                v_nodes[j] = cont.max(intrinsic(sp));
378            }
379        }
380        v_nodes[0]
381    }
382
383    #[test]
384    fn engine_prices_and_greeks_match_binomial() {
385        use crate::core::traits::Instrument;
386        use crate::equity::builder::EquityOptionBuilder;
387        use crate::equity::utils::Engine;
388        use chrono::NaiveDate;
389
390        let build = |engine: Engine| {
391            EquityOptionBuilder::new()
392                .symbol("ACME")
393                .spot(100.0)
394                .strike(100.0)
395                .flat_vol(0.25)
396                .flat_rate(0.08)
397                .dividend_yield(0.04)
398                .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
399                .maturity_date(NaiveDate::from_ymd_opt(2026, 7, 2).unwrap())
400                .american()
401                .vanilla(PutOrCall::Put)
402                .engine(engine)
403                .build().expect("option must build")
404        };
405        let euro = EquityOptionBuilder::new()
406            .symbol("ACME")
407            .spot(100.0)
408            .strike(100.0)
409            .flat_vol(0.25)
410            .flat_rate(0.08)
411            .dividend_yield(0.04)
412            .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
413            .maturity_date(NaiveDate::from_ymd_opt(2026, 7, 2).unwrap())
414            .vanilla(PutOrCall::Put)
415            .engine(Engine::BlackScholes)
416            .build().expect("option must build");
417
418        let baw_opt = build(Engine::BaroneAdesiWhaley);
419        let tree = build(Engine::Binomial);
420        let fd = build(Engine::FiniteDifference);
421
422        // the approximation lands within a couple of cents of the tree
423        assert!((baw_opt.npv() - tree.npv()).abs() < 0.05,
424            "baw {} vs tree {}", baw_opt.npv(), tree.npv());
425        // and it carries a genuine early-exercise premium over European
426        assert!(baw_opt.npv() > euro.npv(), "baw {} not above euro {}", baw_opt.npv(), euro.npv());
427        // BAW reports true American Greeks; check delta/gamma against the FD
428        // grid (which also solves the American problem), not the tree (whose
429        // Greeks fall back to the European closed form)
430        assert!(baw_opt.delta() < 0.0 && baw_opt.delta() > -1.0);
431        assert!(baw_opt.gamma() > 0.0);
432        assert!(baw_opt.vega() > 0.0);
433        assert!((baw_opt.delta() - fd.delta()).abs() < 0.01,
434            "baw delta {} vs fd {}", baw_opt.delta(), fd.delta());
435        assert!((baw_opt.gamma() - fd.gamma()).abs() < 0.01,
436            "baw gamma {} vs fd {}", baw_opt.gamma(), fd.gamma());
437    }
438
439    #[test]
440    fn tracks_binomial_within_a_few_cents() {
441        // (pc, s, k, r, q, v, t): b = r - q
442        let cases = [
443            (PutOrCall::Put, 100.0, 100.0, 0.05, 0.0, 0.20, 1.0),
444            (PutOrCall::Put, 100.0, 100.0, 0.10, 0.0, 0.25, 0.5),
445            (PutOrCall::Call, 110.0, 100.0, 0.10, 0.10, 0.25, 0.5),
446            (PutOrCall::Put, 95.0, 100.0, 0.08, 0.03, 0.30, 0.25),
447            (PutOrCall::Call, 100.0, 100.0, 0.06, 0.09, 0.20, 1.0),
448        ];
449        for (pc, s, k, r, q, v, t) in cases {
450            let baw = price(s, k, r, q, v, t, pc);
451            let tree = crr(pc, s, k, r, r - q, v, t, 3000);
452            assert!(
453                (baw - tree).abs() < 0.05,
454                "{pc:?} s={s}: baw {baw} vs tree {tree}"
455            );
456        }
457    }
458}