Skip to main content

rustyqlib/equity/
asian.rs

1//! Analytic pricing of Asian (average) options.
2//!
3//! - **Geometric average price**: exact closed form — the geometric average
4//!   of lognormals is lognormal. Supports discrete equally spaced averaging
5//!   (`n` points, matching Monte Carlo monitoring) and the continuous limit.
6//! - **Arithmetic average price**: Turnbull-Wakeman (1991) lognormal
7//!   moment-matching approximation, continuous averaging.
8//!
9//! Both assume the averaging period spans the whole life of the option and
10//! has not yet started. Floating-strike (average strike) Asians have no
11//! implemented closed form and price on the Monte Carlo engine.
12
13use crate::core::trade::PutOrCall;
14use crate::core::utils::norm_cdf;
15
16/// How the average is computed along the path.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum AveragingType {
19    Arithmetic,
20    Geometric,
21}
22
23/// Fixed strike (average price) vs floating strike (average strike).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum AsianStrikeType {
26    FixedStrike,
27    FloatingStrike,
28}
29
30fn black(df_r: f64, forward: f64, k: f64, log_var: f64, put_or_call: PutOrCall) -> f64 {
31    let sqrt_v = log_var.sqrt();
32    let d1 = ((forward / k).ln() + 0.5 * log_var) / sqrt_v;
33    let d2 = d1 - sqrt_v;
34    match put_or_call {
35        PutOrCall::Call => df_r * (forward * norm_cdf(d1) - k * norm_cdf(d2)),
36        PutOrCall::Put => df_r * (k * norm_cdf(-d2) - forward * norm_cdf(-d1)),
37    }
38}
39
40/// Exact price of a geometric average-price Asian.
41///
42/// `n = Some(count)`: discrete averaging at `t_i = i*T/n`, `i = 1..=n`
43/// (matches Monte Carlo monitoring, spot excluded). `n = None`: continuous
44/// averaging limit.
45#[allow(clippy::too_many_arguments)]
46pub fn geometric_asian_price(
47    s: f64,
48    k: f64,
49    r: f64,
50    q: f64,
51    sigma: f64,
52    t: f64,
53    n: Option<usize>,
54    put_or_call: PutOrCall,
55) -> f64 {
56    assert!(s > 0.0 && k > 0.0 && sigma > 0.0 && t > 0.0);
57    let b = r - q;
58    let (mean_factor, var_factor) = match n {
59        Some(n) => {
60            assert!(n > 0);
61            let nf = n as f64;
62            ((nf + 1.0) / (2.0 * nf), (nf + 1.0) * (2.0 * nf + 1.0) / (6.0 * nf * nf))
63        }
64        None => (0.5, 1.0 / 3.0),
65    };
66    let mu = (b - 0.5 * sigma * sigma) * t * mean_factor;
67    let log_var = sigma * sigma * t * var_factor;
68    let forward = s * (mu + 0.5 * log_var).exp();
69    black((-r * t).exp(), forward, k, log_var, put_or_call)
70}
71
72/// Exact price of a geometric average-**strike** (floating-strike) Asian:
73/// the call pays `(S_T - G)+` and the put `(G - S_T)+`, with `G` the
74/// geometric average of `n` equally spaced fixings (continuous averaging
75/// when `None`).
76///
77/// `ln S_T` and `ln G` are jointly normal, so the payoff is an exchange
78/// option between two lognormals and prices exactly by
79/// `E[(A - B)+] = E[A] N(d1) - E[B] N(d2)` with
80/// `s^2 = Var(ln S_T - ln G)`. With a single fixing `G = S_T` and the
81/// option is worthless; more fixings decouple the average from the
82/// terminal spot and raise the value toward the continuous limit.
83pub fn geometric_average_strike_price(
84    s: f64,
85    r: f64,
86    q: f64,
87    sigma: f64,
88    t: f64,
89    n: Option<usize>,
90    put_or_call: PutOrCall,
91) -> f64 {
92    assert!(s > 0.0 && sigma > 0.0 && t > 0.0);
93    let b = r - q;
94    // E[ln G] factor, Var(ln G) factor, Cov(ln S_T, ln G) factor (in
95    // units of (b - sigma^2/2) t / sigma^2 t)
96    let (mean_factor, var_factor, cov_factor) = match n {
97        Some(n) => {
98            assert!(n > 0);
99            let nf = n as f64;
100            (
101                (nf + 1.0) / (2.0 * nf),
102                (nf + 1.0) * (2.0 * nf + 1.0) / (6.0 * nf * nf),
103                (nf + 1.0) / (2.0 * nf),
104            )
105        }
106        None => (0.5, 1.0 / 3.0, 0.5),
107    };
108    let e_terminal = s * (b * t).exp();
109    let e_average = s
110        * ((b - 0.5 * sigma * sigma) * t * mean_factor
111            + 0.5 * sigma * sigma * t * var_factor)
112            .exp();
113    let spread_var = sigma * sigma * t * (1.0 + var_factor - 2.0 * cov_factor);
114    let df = (-r * t).exp();
115    if spread_var <= 1e-16 {
116        // single fixing: G = S_T, zero intrinsic either way
117        return match put_or_call {
118            PutOrCall::Call => df * (e_terminal - e_average).max(0.0),
119            PutOrCall::Put => df * (e_average - e_terminal).max(0.0),
120        };
121    }
122    let sv = spread_var.sqrt();
123    let d1 = ((e_terminal / e_average).ln() + 0.5 * spread_var) / sv;
124    let d2 = d1 - sv;
125    match put_or_call {
126        PutOrCall::Call => df * (e_terminal * norm_cdf(d1) - e_average * norm_cdf(d2)),
127        PutOrCall::Put => df * (e_average * norm_cdf(-d2) - e_terminal * norm_cdf(-d1)),
128    }
129}
130
131/// Arithmetic average-**strike** (floating-strike) Asian via the
132/// Henderson-Wojakowski (2002) symmetry: at inception under GBM, a
133/// floating-strike call paying `(S_T - A)+` is **exactly** a
134/// fixed-strike put struck at spot with the roles of `r` and `q`
135/// interchanged (and vice versa for the put). The fixed-strike side is
136/// then priced with the Turnbull-Wakeman moment match, so the symmetry
137/// step is exact and TW is the only approximation layer. Continuous
138/// averaging; exact put-call parity
139/// `C - P = e^{-rT} (E[S_T] - E[A])` is preserved.
140pub fn turnbull_wakeman_average_strike_price(
141    s: f64,
142    r: f64,
143    q: f64,
144    sigma: f64,
145    t: f64,
146    put_or_call: PutOrCall,
147) -> f64 {
148    match put_or_call {
149        PutOrCall::Call => turnbull_wakeman_price(s, s, q, r, sigma, t, PutOrCall::Put),
150        PutOrCall::Put => turnbull_wakeman_price(s, s, q, r, sigma, t, PutOrCall::Call),
151    }
152}
153
154/// Turnbull-Wakeman approximation for an arithmetic average-price Asian
155/// (continuous averaging): the first two moments of the average are matched
156/// to a lognormal and priced with Black's formula.
157pub fn turnbull_wakeman_price(
158    s: f64,
159    k: f64,
160    r: f64,
161    q: f64,
162    sigma: f64,
163    t: f64,
164    put_or_call: PutOrCall,
165) -> f64 {
166    assert!(s > 0.0 && k > 0.0 && sigma > 0.0 && t > 0.0);
167    let b = r - q;
168    let s2 = sigma * sigma;
169    let (m1, m2) = if b.abs() > 1e-8 {
170        let m1 = ((b * t).exp() - 1.0) / (b * t);
171        let m2 = 2.0 * ((2.0 * b + s2) * t).exp() / ((b + s2) * (2.0 * b + s2) * t * t)
172            + 2.0 / (b * t * t) * (1.0 / (2.0 * b + s2) - (b * t).exp() / (b + s2));
173        (m1, m2)
174    } else {
175        let m1 = 1.0;
176        let m2 = (2.0 * (s2 * t).exp() - 2.0 * (1.0 + s2 * t)) / (s2 * s2 * t * t);
177        (m1, m2)
178    };
179    let forward = s * m1;
180    let log_var = (m2 / (m1 * m1)).ln(); // sigma_A^2 * T
181    black((-r * t).exp(), forward, k, log_var, put_or_call)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    const S: f64 = 100.0;
189    const R: f64 = 0.05;
190    const Q: f64 = 0.02;
191    const SIG: f64 = 0.3;
192
193    #[test]
194    fn average_strike_single_fixing_is_worthless() {
195        // n = 1: the average IS the terminal spot
196        for pc in [PutOrCall::Call, PutOrCall::Put] {
197            let p = geometric_average_strike_price(S, R, Q, SIG, 1.0, Some(1), pc);
198            assert!(p.abs() < 1e-12, "{pc:?}: {p}");
199        }
200    }
201
202    #[test]
203    fn average_strike_exchange_parity_is_exact() {
204        // C - P = e^{-rT} (E[S_T] - E[G]) for jointly lognormal legs
205        for n in [Some(4), Some(12), None] {
206            let c = geometric_average_strike_price(S, R, Q, SIG, 1.0, n, PutOrCall::Call);
207            let p = geometric_average_strike_price(S, R, Q, SIG, 1.0, n, PutOrCall::Put);
208            let b = R - Q;
209            let (mf, vf) = match n {
210                Some(n) => {
211                    let nf = n as f64;
212                    ((nf + 1.0) / (2.0 * nf), (nf + 1.0) * (2.0 * nf + 1.0) / (6.0 * nf * nf))
213                }
214                None => (0.5, 1.0 / 3.0),
215            };
216            let e_st = S * b.exp();
217            let e_g = S * ((b - 0.5 * SIG * SIG) * mf + 0.5 * SIG * SIG * vf).exp();
218            let parity = (-R * 1.0f64).exp() * (e_st - e_g);
219            assert!((c - p - parity).abs() < 1e-12, "n = {n:?}");
220        }
221    }
222
223    #[test]
224    fn arithmetic_average_strike_preserves_exact_parity() {
225        // the Henderson-Wojakowski swap preserves put-call parity
226        // C - P = e^{-rT} (E[S_T] - E[A]) exactly, with
227        // E[A] = S (e^{bT} - 1)/(bT)
228        let t = 1.0;
229        let b = R - Q;
230        let c = turnbull_wakeman_average_strike_price(S, R, Q, SIG, t, PutOrCall::Call);
231        let p = turnbull_wakeman_average_strike_price(S, R, Q, SIG, t, PutOrCall::Put);
232        let parity = (-R * t).exp() * (S * (b * t).exp() - S * ((b * t).exp() - 1.0) / (b * t));
233        assert!((c - p - parity).abs() < 1e-12, "{}", c - p - parity);
234        // and the zero-carry branch works through the swap (b' = -b = 0)
235        let c0 = turnbull_wakeman_average_strike_price(S, 0.03, 0.03, SIG, t, PutOrCall::Call);
236        let p0 = turnbull_wakeman_average_strike_price(S, 0.03, 0.03, SIG, t, PutOrCall::Put);
237        assert!(c0 > 0.0 && p0 > 0.0);
238        assert!((c0 - p0).abs() < 1e-12, "b = 0 parity: E[S_T] = E[A]");
239    }
240
241    #[test]
242    fn arithmetic_average_strike_orders_against_geometric_by_am_gm() {
243        // the arithmetic average dominates the geometric, so the
244        // average-strike CALL is cheaper and the PUT richer than the
245        // geometric-average versions
246        let t = 1.0;
247        let arith_call = turnbull_wakeman_average_strike_price(S, R, Q, SIG, t, PutOrCall::Call);
248        let geo_call = geometric_average_strike_price(S, R, Q, SIG, t, None, PutOrCall::Call);
249        assert!(arith_call < geo_call, "call: arith {arith_call} vs geo {geo_call}");
250        let arith_put = turnbull_wakeman_average_strike_price(S, R, Q, SIG, t, PutOrCall::Put);
251        let geo_put = geometric_average_strike_price(S, R, Q, SIG, t, None, PutOrCall::Put);
252        assert!(arith_put > geo_put, "put: arith {arith_put} vs geo {geo_put}");
253    }
254
255    #[test]
256    fn average_strike_value_grows_with_fixings_toward_the_continuous_limit() {
257        let price = |n| geometric_average_strike_price(S, R, Q, SIG, 1.0, n, PutOrCall::Call);
258        assert!(price(Some(2)) > 0.0);
259        assert!(price(Some(4)) > price(Some(2)));
260        assert!(price(Some(12)) > price(Some(4)));
261        let continuous = price(None);
262        assert!(price(Some(12)) < continuous);
263        assert!((price(Some(5000)) - continuous).abs() < 2e-3, "limit");
264    }
265    const T: f64 = 1.0;
266
267    #[test]
268    fn geometric_golden_values() {
269        // independently generated oracle values
270        assert!((geometric_asian_price(S, 100.0, R, Q, SIG, T, Some(252), PutOrCall::Call)
271            - 6.976295)
272            .abs()
273            < 1e-5);
274        assert!((geometric_asian_price(S, 100.0, R, Q, SIG, T, None, PutOrCall::Call) - 6.953600)
275            .abs()
276            < 1e-5);
277    }
278
279    #[test]
280    fn turnbull_wakeman_golden_value() {
281        let price = turnbull_wakeman_price(S, 100.0, R, Q, SIG, T, PutOrCall::Call);
282        assert!((price - 7.409272).abs() < 1e-5, "{price}");
283    }
284
285    #[test]
286    fn geometric_put_call_parity() {
287        // C - P = e^{-rT} (F_G - K) with the same lognormal forward
288        for n in [Some(12), Some(252), None] {
289            let c = geometric_asian_price(S, 90.0, R, Q, SIG, T, n, PutOrCall::Call);
290            let p = geometric_asian_price(S, 90.0, R, Q, SIG, T, n, PutOrCall::Put);
291            // recover F_G from a deep parity-free identity: price both at a
292            // strike and check C - P is strike-linear with slope -e^{-rT}
293            let c2 = geometric_asian_price(S, 110.0, R, Q, SIG, T, n, PutOrCall::Call);
294            let p2 = geometric_asian_price(S, 110.0, R, Q, SIG, T, n, PutOrCall::Put);
295            let df = (-R * T).exp();
296            assert!((((c - p) - (c2 - p2)) - df * 20.0).abs() < 1e-10);
297        }
298    }
299
300    #[test]
301    fn discrete_averaging_converges_to_continuous() {
302        let continuous = geometric_asian_price(S, 100.0, R, Q, SIG, T, None, PutOrCall::Call);
303        let fine = geometric_asian_price(S, 100.0, R, Q, SIG, T, Some(100_000), PutOrCall::Call);
304        assert!((fine - continuous).abs() < 1e-3);
305    }
306
307    #[test]
308    fn averaging_reduces_option_value_below_vanilla() {
309        use crate::equity::blackscholes::bs_price;
310        let vanilla = bs_price(S, 100.0, R, Q, SIG, T, PutOrCall::Call);
311        let geo = geometric_asian_price(S, 100.0, R, Q, SIG, T, None, PutOrCall::Call);
312        let arith = turnbull_wakeman_price(S, 100.0, R, Q, SIG, T, PutOrCall::Call);
313        assert!(geo < arith, "AM-GM: arithmetic average dominates geometric");
314        assert!(arith < vanilla, "averaging reduces effective volatility");
315    }
316
317    #[test]
318    fn zero_cost_of_carry_branch() {
319        // r = q exercises the b = 0 moment formulas
320        let price = turnbull_wakeman_price(S, 100.0, 0.03, 0.03, SIG, T, PutOrCall::Call);
321        assert!(price > 0.0 && price.is_finite());
322        // continuity across the branch: b = 1e-9 vs b = 0
323        let near = turnbull_wakeman_price(S, 100.0, 0.03 + 1e-9, 0.03, SIG, T, PutOrCall::Call);
324        assert!((price - near).abs() < 1e-5);
325    }
326}