Skip to content

Commit 769b37c

Browse files
authored
Merge pull request #277 from joaquinbejar/feat/issue-242-chooser-option
feat: Implement Chooser Option Pricing Model
2 parents 776353f + 46c78a8 commit 769b37c

File tree

3 files changed

+372
-4
lines changed

3 files changed

+372
-4
lines changed

src/pricing/black_scholes_model.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,7 @@ pub fn black_scholes(option: &Options) -> Result<Decimal, PricingError> {
7474
OptionType::Binary { .. } => crate::pricing::binary::binary_black_scholes(option),
7575
OptionType::Lookback { .. } => crate::pricing::lookback::lookback_black_scholes(option),
7676
OptionType::Compound { .. } => crate::pricing::compound::compound_black_scholes(option),
77-
OptionType::Chooser { .. } => Err(PricingError::unsupported_option_type(
78-
"Chooser",
79-
"Black-Scholes",
80-
)),
77+
OptionType::Chooser { .. } => crate::pricing::chooser::chooser_black_scholes(option),
8178
OptionType::Cliquet { .. } => Err(PricingError::unsupported_option_type(
8279
"Cliquet",
8380
"Black-Scholes",

src/pricing/chooser.rs

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
/******************************************************************************
2+
Author: Joaquín Béjar García
3+
Email: jb@taunais.com
4+
Date: 13/01/26
5+
******************************************************************************/
6+
7+
//! Chooser option pricing module.
8+
//!
9+
//! Chooser options (also called as-you-like-it options) allow the holder to
10+
//! choose at a specified date whether the option becomes a call or a put.
11+
//!
12+
//! # Simple Chooser (Rubinstein 1991)
13+
//!
14+
//! At the choice date t, the holder chooses max(Call, Put).
15+
//! The value is:
16+
//!
17+
//! `V = S*e^(-qT)*N(d1) - K*e^(-rT)*N(d2) + K*e^(-rt)*N(-y2) - S*e^(-qt)*N(-y1)`
18+
//!
19+
//! Where:
20+
//! - T = time to final expiration
21+
//! - t = time to choice date
22+
//! - d1, d2 are standard BS d-values for T
23+
//! - y1 = [ln(S/K) + b*t + (σ²/2)*t] / (σ√t)
24+
//! - y2 = y1 - σ√t
25+
26+
use crate::Options;
27+
use crate::error::PricingError;
28+
use crate::greeks::{big_n, d1, d2};
29+
use crate::model::types::OptionType;
30+
use positive::Positive;
31+
use rust_decimal::Decimal;
32+
use rust_decimal::prelude::*;
33+
use rust_decimal_macros::dec;
34+
35+
/// Prices a Chooser option using Rubinstein (1991) simple chooser formula.
36+
///
37+
/// # Arguments
38+
///
39+
/// * `option` - The option to price. Must have `OptionType::Chooser`.
40+
///
41+
/// # Returns
42+
///
43+
/// The option price as a `Decimal`, or a `PricingError` if pricing fails.
44+
pub fn chooser_black_scholes(option: &Options) -> Result<Decimal, PricingError> {
45+
match &option.option_type {
46+
OptionType::Chooser { choice_date } => simple_chooser_price(option, *choice_date),
47+
_ => Err(PricingError::other(
48+
"chooser_black_scholes requires OptionType::Chooser",
49+
)),
50+
}
51+
}
52+
53+
/// Prices a simple chooser option.
54+
///
55+
/// At the choice date, the holder chooses the maximum of call or put value.
56+
/// Uses Rubinstein (1991) closed-form solution.
57+
fn simple_chooser_price(option: &Options, choice_date_days: f64) -> Result<Decimal, PricingError> {
58+
let s = option.underlying_price;
59+
let k = option.strike_price;
60+
let r = option.risk_free_rate;
61+
let q = option.dividend_yield.to_dec();
62+
let sigma = option.implied_volatility;
63+
let t_big = option
64+
.expiration_date
65+
.get_years()
66+
.map_err(|e| PricingError::other(&e.to_string()))?;
67+
68+
// Convert choice_date from days to years
69+
let t_choice = Positive::new(choice_date_days / 365.0).unwrap_or(Positive::ZERO);
70+
71+
// Validation: choice date must be before expiration
72+
if t_choice >= t_big {
73+
// If choice at or after expiration, it's just max(call, put) = straddle-like
74+
return price_at_choice_equals_expiry(option);
75+
}
76+
77+
if t_big == Positive::ZERO {
78+
// At expiration, intrinsic value
79+
let call_intrinsic = (s.to_dec() - k.to_dec()).max(Decimal::ZERO);
80+
let put_intrinsic = (k.to_dec() - s.to_dec()).max(Decimal::ZERO);
81+
return Ok(apply_side(call_intrinsic.max(put_intrinsic), option));
82+
}
83+
84+
if sigma == Positive::ZERO {
85+
// Zero vol: deterministic choice
86+
let discount_t = (-r * t_big).exp();
87+
let forward = s.to_dec() * ((r - q) * t_big.to_dec()).exp();
88+
let call_val = (forward - k.to_dec()).max(Decimal::ZERO) * discount_t;
89+
let put_val = (k.to_dec() - forward).max(Decimal::ZERO) * discount_t;
90+
return Ok(apply_side(call_val.max(put_val), option));
91+
}
92+
93+
let b = r - q;
94+
let t_big_dec = t_big.to_dec();
95+
let t_choice_dec = t_choice.to_dec();
96+
let _sqrt_t_big = t_big_dec.sqrt().unwrap_or(Decimal::ZERO);
97+
let sqrt_t_choice = t_choice_dec.sqrt().unwrap_or(dec!(0.001));
98+
99+
// Standard BS d-values for the final expiration T
100+
let d1_val = d1(s, k, b, t_big, sigma)
101+
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
102+
let d2_val = d2(s, k, b, t_big, sigma)
103+
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
104+
105+
// d-values for the choice date t
106+
// y1 = [ln(S/K) + (b + σ²/2)*t] / (σ√t)
107+
// y2 = y1 - σ√t
108+
let y1 = ((s.to_dec() / k.to_dec()).ln() + (b + sigma * sigma / dec!(2)) * t_choice_dec)
109+
/ (sigma.to_dec() * sqrt_t_choice);
110+
let y2 = y1 - sigma.to_dec() * sqrt_t_choice;
111+
112+
// Get cumulative normal values
113+
let n_d1 = big_n(d1_val).unwrap_or(Decimal::ZERO);
114+
let n_d2 = big_n(d2_val).unwrap_or(Decimal::ZERO);
115+
let n_neg_y1 = big_n(-y1).unwrap_or(Decimal::ZERO);
116+
let n_neg_y2 = big_n(-y2).unwrap_or(Decimal::ZERO);
117+
118+
// Discount factors
119+
let dividend_discount_t = (-q * t_big_dec).exp();
120+
let discount_t = (-r * t_big_dec).exp();
121+
let dividend_discount_choice = (-q * t_choice_dec).exp();
122+
let discount_choice = (-r * t_choice_dec).exp();
123+
124+
// Rubinstein (1991) simple chooser formula:
125+
// V = S*e^(-qT)*N(d1) - K*e^(-rT)*N(d2) + K*e^(-rt)*N(-y2) - S*e^(-qt)*N(-y1)
126+
// This equals: Call(K, T) + Put_component_for_choice_flexibility
127+
let price = s.to_dec() * dividend_discount_t * n_d1 - k.to_dec() * discount_t * n_d2
128+
+ k.to_dec() * discount_choice * n_neg_y2
129+
- s.to_dec() * dividend_discount_choice * n_neg_y1;
130+
131+
Ok(apply_side(price.max(Decimal::ZERO), option))
132+
}
133+
134+
/// Handles the edge case where choice date equals or exceeds expiration.
135+
fn price_at_choice_equals_expiry(option: &Options) -> Result<Decimal, PricingError> {
136+
// At this point, chooser becomes max(call, put) = straddle at expiry
137+
// For European option at expiry, this is max(intrinsic_call, intrinsic_put)
138+
let s = option.underlying_price;
139+
let k = option.strike_price;
140+
let r = option.risk_free_rate;
141+
let q = option.dividend_yield.to_dec();
142+
let sigma = option.implied_volatility;
143+
let t = option
144+
.expiration_date
145+
.get_years()
146+
.map_err(|e| PricingError::other(&e.to_string()))?;
147+
148+
if t == Positive::ZERO {
149+
let call_intrinsic = (s.to_dec() - k.to_dec()).max(Decimal::ZERO);
150+
let put_intrinsic = (k.to_dec() - s.to_dec()).max(Decimal::ZERO);
151+
return Ok(apply_side(call_intrinsic.max(put_intrinsic), option));
152+
}
153+
154+
// Price as call + put (straddle) since choice is at expiry
155+
let b = r - q;
156+
let d1_val = d1(s, k, b, t, sigma)
157+
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
158+
let d2_val = d2(s, k, b, t, sigma)
159+
.map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?;
160+
161+
let n_d1 = big_n(d1_val).unwrap_or(Decimal::ZERO);
162+
let n_d2 = big_n(d2_val).unwrap_or(Decimal::ZERO);
163+
let n_neg_d1 = big_n(-d1_val).unwrap_or(Decimal::ZERO);
164+
let n_neg_d2 = big_n(-d2_val).unwrap_or(Decimal::ZERO);
165+
166+
let dividend_discount = (-q * t).exp();
167+
let discount = (-r * t).exp();
168+
169+
// Call + Put = Straddle
170+
let call_price = s.to_dec() * dividend_discount * n_d1 - k.to_dec() * discount * n_d2;
171+
let put_price = k.to_dec() * discount * n_neg_d2 - s.to_dec() * dividend_discount * n_neg_d1;
172+
173+
Ok(apply_side(
174+
(call_price + put_price).max(Decimal::ZERO),
175+
option,
176+
))
177+
}
178+
179+
/// Applies the side (long/short) multiplier to the price.
180+
fn apply_side(price: Decimal, option: &Options) -> Decimal {
181+
match option.side {
182+
crate::model::types::Side::Long => price,
183+
crate::model::types::Side::Short => -price,
184+
}
185+
}
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
use crate::ExpirationDate;
191+
use crate::assert_decimal_eq;
192+
use crate::model::types::{OptionStyle, OptionType, Side};
193+
use positive::pos_or_panic;
194+
use rust_decimal_macros::dec;
195+
196+
fn create_chooser_option(choice_date_days: f64) -> Options {
197+
Options::new(
198+
OptionType::Chooser {
199+
choice_date: choice_date_days,
200+
},
201+
Side::Long,
202+
"TEST".to_string(),
203+
Positive::HUNDRED, // strike
204+
ExpirationDate::Days(pos_or_panic!(182.5)), // ~0.5 years
205+
pos_or_panic!(0.25), // volatility
206+
Positive::ONE, // quantity
207+
Positive::HUNDRED, // underlying (ATM)
208+
dec!(0.05), // risk-free rate
209+
OptionStyle::Call, // Will be ignored for chooser
210+
Positive::ZERO, // dividend yield
211+
None,
212+
)
213+
}
214+
215+
#[test]
216+
fn test_simple_chooser() {
217+
let option = create_chooser_option(45.0); // Choice in 45 days
218+
let price = chooser_black_scholes(&option).unwrap();
219+
// Chooser should have positive value (it's always >= call or put)
220+
assert!(
221+
price > Decimal::ZERO,
222+
"Chooser should be positive: {}",
223+
price
224+
);
225+
}
226+
227+
#[test]
228+
fn test_chooser_more_valuable_than_call() {
229+
let chooser = create_chooser_option(45.0);
230+
let chooser_price = chooser_black_scholes(&chooser).unwrap();
231+
232+
// Create equivalent vanilla call
233+
let call = Options::new(
234+
OptionType::European,
235+
Side::Long,
236+
"TEST".to_string(),
237+
Positive::HUNDRED,
238+
ExpirationDate::Days(pos_or_panic!(182.5)),
239+
pos_or_panic!(0.25),
240+
Positive::ONE,
241+
Positive::HUNDRED,
242+
dec!(0.05),
243+
OptionStyle::Call,
244+
Positive::ZERO,
245+
None,
246+
);
247+
let call_price = crate::pricing::black_scholes_model::black_scholes(&call).unwrap();
248+
249+
assert!(
250+
chooser_price >= call_price,
251+
"Chooser {} should be >= call {}",
252+
chooser_price,
253+
call_price
254+
);
255+
}
256+
257+
#[test]
258+
fn test_chooser_more_valuable_than_put() {
259+
let chooser = create_chooser_option(45.0);
260+
let chooser_price = chooser_black_scholes(&chooser).unwrap();
261+
262+
// Create equivalent vanilla put
263+
let put = Options::new(
264+
OptionType::European,
265+
Side::Long,
266+
"TEST".to_string(),
267+
Positive::HUNDRED,
268+
ExpirationDate::Days(pos_or_panic!(182.5)),
269+
pos_or_panic!(0.25),
270+
Positive::ONE,
271+
Positive::HUNDRED,
272+
dec!(0.05),
273+
OptionStyle::Put,
274+
Positive::ZERO,
275+
None,
276+
);
277+
let put_price = crate::pricing::black_scholes_model::black_scholes(&put).unwrap();
278+
279+
assert!(
280+
chooser_price >= put_price,
281+
"Chooser {} should be >= put {}",
282+
chooser_price,
283+
put_price
284+
);
285+
}
286+
287+
#[test]
288+
fn test_early_choice_date() {
289+
// Very early choice date (1 day)
290+
let option = create_chooser_option(1.0);
291+
let price = chooser_black_scholes(&option).unwrap();
292+
assert!(price > Decimal::ZERO, "Early choice date price: {}", price);
293+
}
294+
295+
#[test]
296+
fn test_late_choice_date() {
297+
// Choice date close to expiration
298+
let option = create_chooser_option(180.0);
299+
let price = chooser_black_scholes(&option).unwrap();
300+
assert!(price > Decimal::ZERO, "Late choice date price: {}", price);
301+
}
302+
303+
#[test]
304+
fn test_choice_at_expiry() {
305+
// Choice at expiration = straddle
306+
let option = create_chooser_option(182.5);
307+
let price = chooser_black_scholes(&option).unwrap();
308+
assert!(price > Decimal::ZERO, "Choice at expiry price: {}", price);
309+
}
310+
311+
#[test]
312+
fn test_short_chooser_option() {
313+
let mut option = create_chooser_option(45.0);
314+
let long_price = chooser_black_scholes(&option).unwrap();
315+
316+
option.side = Side::Short;
317+
let short_price = chooser_black_scholes(&option).unwrap();
318+
319+
assert_decimal_eq!(long_price, -short_price, dec!(1e-10));
320+
}
321+
322+
#[test]
323+
fn test_zero_time_to_expiry() {
324+
let mut option = create_chooser_option(0.0);
325+
option.expiration_date = ExpirationDate::Days(Positive::ZERO);
326+
let price = chooser_black_scholes(&option).unwrap();
327+
// ATM at expiry, intrinsic is 0
328+
assert_decimal_eq!(price, Decimal::ZERO, dec!(0.01));
329+
}
330+
331+
#[test]
332+
fn test_itm_call_at_expiry() {
333+
let mut option = create_chooser_option(0.0);
334+
option.underlying_price = pos_or_panic!(110.0);
335+
option.expiration_date = ExpirationDate::Days(Positive::ZERO);
336+
let price = chooser_black_scholes(&option).unwrap();
337+
// ITM call intrinsic = 10
338+
assert_decimal_eq!(price, dec!(10.0), dec!(0.01));
339+
}
340+
341+
#[test]
342+
fn test_itm_put_at_expiry() {
343+
let mut option = create_chooser_option(0.0);
344+
option.underlying_price = pos_or_panic!(90.0);
345+
option.expiration_date = ExpirationDate::Days(Positive::ZERO);
346+
let price = chooser_black_scholes(&option).unwrap();
347+
// ITM put intrinsic = 10
348+
assert_decimal_eq!(price, dec!(10.0), dec!(0.01));
349+
}
350+
351+
#[test]
352+
fn test_higher_vol_means_higher_chooser_value() {
353+
let low_vol = create_chooser_option(45.0);
354+
let low_vol_price = chooser_black_scholes(&low_vol).unwrap();
355+
356+
let mut high_vol = low_vol.clone();
357+
high_vol.implied_volatility = pos_or_panic!(0.4);
358+
let high_vol_price = chooser_black_scholes(&high_vol).unwrap();
359+
360+
assert!(
361+
high_vol_price > low_vol_price,
362+
"Higher vol {} should mean higher chooser value: {}",
363+
high_vol_price,
364+
low_vol_price
365+
);
366+
}
367+
}

src/pricing/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ pub mod lookback;
175175
/// Compound option pricing (options on options).
176176
pub mod compound;
177177

178+
/// Chooser option pricing (choice between call and put).
179+
pub mod chooser;
180+
178181
/// Black-Scholes model for option pricing and analysis.
179182
///
180183
/// This module implements the Black-Scholes-Merton model for European option pricing
@@ -271,6 +274,7 @@ pub use barrier::barrier_black_scholes;
271274
pub use binary::binary_black_scholes;
272275
pub use binomial_model::{BinomialPricingParams, generate_binomial_tree, price_binomial};
273276
pub use black_scholes_model::{BlackScholes, black_scholes};
277+
pub use chooser::chooser_black_scholes;
274278
pub use compound::compound_black_scholes;
275279
pub use lookback::lookback_black_scholes;
276280
pub use monte_carlo::monte_carlo_option_pricing;

0 commit comments

Comments
 (0)