Skip to main content

optionstratlib_core/model/
option.rs

1use crate::constants::ZERO;
2use crate::error::{OptionsError, OptionsResult};
3use crate::model::ExpirationDate;
4use crate::model::payoff::{Payoff, PayoffInfo};
5use crate::model::types::{OptionStyle, OptionType, Side};
6use positive::Positive;
7#[cfg(test)]
8use positive::pos_or_panic;
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use tracing::error;
12
13/// Error for a size-scaled payoff that leaves the `Decimal` range.
14///
15/// The three payoff entry points below (`payoff`, `payoff_at_price`,
16/// `intrinsic_value`) take the `Decimal` payoff of [`Payoff::payoff`] and
17/// scale it by the position size (`quantity × contract_size`, see
18/// [`Options::position_size`]) with a checked multiplication. Both
19/// factors can be as large as `Positive::MAX` (`≈ 7.92e28`), so the product
20/// can leave the `Decimal` range; a payoff that is itself unrepresentable
21/// (a deep in-the-money call on an underlying at `Positive::MAX`, whose `f64`
22/// kernel value rounds above `Decimal::MAX`) is already rejected by
23/// [`Payoff::payoff`]. Those cases used to come back as `Ok(Decimal::ZERO)`:
24/// a worthless payoff for an option that is worth more than the type can
25/// express.
26///
27/// The returned error is [`OptionsError::PayoffError`], carrying the entry
28/// point and the offending factors.
29#[cold]
30#[inline(never)]
31fn payoff_out_of_range(context: &'static str, payoff: Decimal, size: Positive) -> OptionsError {
32    OptionsError::PayoffError {
33        reason: format!(
34            "{context}: payoff {payoff} times position size {size} is not representable as a Decimal (out of range)"
35        ),
36    }
37}
38
39/// Error for a `quantity × contract_size` product that leaves the `Positive`
40/// range.
41#[cold]
42#[inline(never)]
43fn position_size_out_of_range(quantity: Positive, contract_size: Positive) -> OptionsError {
44    OptionsError::ValidationError {
45        field: "contract_size".to_string(),
46        reason: format!(
47            "quantity {quantity} times contract size {contract_size} is not representable as a Positive (out of range)"
48        ),
49    }
50}
51
52/// Serde default for [`Options::contract_size`]: one unit of the underlying per
53/// contract, the library's behaviour before the field existed.
54#[inline]
55#[must_use]
56fn default_contract_size() -> Positive {
57    Positive::ONE
58}
59
60/// Parameters for exotic option pricing models.
61///
62/// This structure holds specific data required by various exotic option types
63/// such as Asian options (which depend on average prices) and Lookback options
64/// (which depend on minimum/maximum prices during the option's lifetime).
65///
66/// Each field is optional since different exotic option types require different parameters.
67#[derive(Clone, Default, PartialEq, Serialize, Deserialize, Debug)]
68#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
69pub struct ExoticParams {
70    /// Historical spot prices, primarily used for Asian options which
71    /// depend on the average price of the underlying asset.
72    pub spot_prices: Option<Vec<Positive>>, // Asian
73
74    /// Minimum observed spot price during the option's lifetime,
75    /// used for lookback option pricing.
76    pub spot_min: Option<Decimal>, // Lookback
77
78    /// Maximum observed spot price during the option's lifetime,
79    /// used for lookback option pricing.
80    pub spot_max: Option<Decimal>, // Lookback
81
82    /// Local cap for Cliquet options, limiting the periodic return.
83    pub cliquet_local_cap: Option<Decimal>, // Cliquet
84
85    /// Local floor for Cliquet options, limiting the periodic return.
86    pub cliquet_local_floor: Option<Decimal>, // Cliquet
87
88    /// Global cap for Cliquet options, limiting the total return.
89    pub cliquet_global_cap: Option<Decimal>, // Cliquet
90
91    /// Global floor for Cliquet options, limiting the total return.
92    pub cliquet_global_floor: Option<Decimal>, // Cliquet
93
94    /// Price of the second underlying asset for Rainbow options.
95    pub rainbow_second_asset_price: Option<Positive>, // Rainbow
96
97    /// Volatility of the second underlying asset for Rainbow options.
98    pub rainbow_second_asset_volatility: Option<Positive>, // Rainbow
99
100    /// Dividend yield of the second underlying asset for Rainbow options.
101    pub rainbow_second_asset_dividend: Option<Positive>, // Rainbow
102
103    /// Correlation between the two underlying assets for Rainbow options.
104    /// Must be between -1.0 and 1.0.
105    pub rainbow_correlation: Option<Decimal>, // Rainbow
106
107    /// Volatility of the second underlying asset for Spread options.
108    pub spread_second_asset_volatility: Option<Positive>, // Spread
109
110    /// Dividend yield of the second underlying asset for Spread options.
111    pub spread_second_asset_dividend: Option<Positive>, // Spread
112
113    /// Correlation between the two underlying assets for Spread options.
114    /// Must be between -1.0 and 1.0.
115    pub spread_correlation: Option<Decimal>, // Spread
116
117    /// Volatility of the exchange rate for Quanto options.
118    pub quanto_fx_volatility: Option<Positive>, // Quanto
119
120    /// Correlation between the underlying asset and the exchange rate for Quanto options.
121    /// Must be between -1.0 and 1.0.
122    pub quanto_fx_correlation: Option<Decimal>, // Quanto
123
124    /// Foreign risk-free interest rate for Quanto options.
125    pub quanto_foreign_rate: Option<Decimal>, // Quanto
126
127    /// Foreign risk-free interest rate `r_f` of a Garman–Kohlhagen FX
128    /// option, per year, continuously compounded. Signed, so a negative
129    /// foreign rate (CHF, JPY, EUR in parts of 2015–2022) is expressible,
130    /// which `Options::dividend_yield` (a `Positive`) is not. When `None`,
131    /// Garman–Kohlhagen falls back to `Options::dividend_yield`; when set, it
132    /// takes precedence and `dividend_yield` is ignored by that model. Only
133    /// the Garman–Kohlhagen pricer and Greeks read it; Quanto reads
134    /// `quanto_foreign_rate`.
135    pub foreign_rate: Option<Decimal>, // Garman–Kohlhagen
136
137    /// Volatility of the second underlying asset for Exchange options.
138    pub exchange_second_asset_volatility: Option<Positive>, // Exchange
139
140    /// Dividend yield of the second underlying asset for Exchange options.
141    pub exchange_second_asset_dividend: Option<Positive>, // Exchange
142
143    /// Correlation between the two underlying assets for Exchange options.
144    /// Must be between -1.0 and 1.0.
145    pub exchange_correlation: Option<Decimal>, // Exchange
146}
147
148/// Represents a financial option contract with its essential parameters and characteristics.
149///
150/// This structure contains all the necessary information to define an options contract,
151/// including its type (call/put), market position (long/short), pricing parameters,
152/// and contract specifications. It serves as the core data model for option pricing,
153/// risk analysis, and strategy development.
154///
155/// The `Options` struct supports both standard option types and exotic options through
156/// the optional `exotic_params` field, making it versatile for various financial modeling
157/// scenarios.
158///
159/// # Contract size
160///
161/// `quantity` counts contracts and `contract_size` is the multiplier: the
162/// units of the underlying one contract covers (100 for a standard US equity
163/// option, 1 by default). Their product, [`Options::position_size`], is the
164/// position in units of the underlying, and it scales everything that is a
165/// money amount or an exposure of the whole position:
166///
167/// - the payoff and intrinsic value (`payoff`, `payoff_at_price`,
168///   `intrinsic_value`) and the P&L built on them;
169/// - every Greek, which is reported per position, not per unit;
170/// - the premium paid or received by a [`Position`](crate::model::Position)
171///   holding the option.
172///
173/// Prices from the pricing models stay per unit of the underlying, which is
174/// how a premium is quoted. Fees are charged per contract by `Position` and
175/// do not scale with the multiplier. With a contract size of 1 every figure
176/// is the per-unit figure times `quantity`, as before the field existed.
177#[derive(Clone, PartialEq, Serialize, Deserialize)]
178#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
179pub struct Options {
180    /// Specifies whether this is a European or American option
181    pub option_type: OptionType,
182
183    /// Indicates whether the position is Long (purchased) or Short (sold/written),
184    /// which determines the profit/loss direction and risk profile.
185    pub side: Side,
186
187    /// The ticker symbol or identifier of the underlying asset (e.g., "AAPL" for Apple stock).
188    pub underlying_symbol: String,
189
190    /// The price at which the option holder can exercise their right to buy (for calls)
191    /// or sell (for puts) the underlying asset.
192    pub strike_price: Positive,
193
194    /// When the option contract expires, either as days from now or as a specific date.
195    pub expiration_date: ExpirationDate,
196
197    /// The market's expectation for future volatility of the underlying asset,
198    /// a key parameter for option pricing models.
199    pub implied_volatility: Positive,
200
201    /// The number of contracts in this position.
202    pub quantity: Positive,
203
204    /// The contract multiplier: units of the underlying covered by one
205    /// contract (100 for a standard US equity option, other sizes for futures
206    /// options). Defaults to 1, so one contract covers one unit of the
207    /// underlying, and payloads written before the field existed deserialize
208    /// unchanged.
209    ///
210    /// Payoff, intrinsic value, premium, P&L and Greeks of the position scale
211    /// by `quantity × contract_size` (see [`Options::position_size`]). Prices
212    /// returned by the pricing models stay per unit of the underlying, and
213    /// [`crate::model::Position`] fees stay per contract.
214    #[serde(default = "default_contract_size")]
215    pub contract_size: Positive,
216
217    /// The current market price of the underlying asset.
218    pub underlying_price: Positive,
219
220    /// The current risk-free interest rate used in option pricing models,
221    /// typically based on treasury yields of similar duration. Annual, as a
222    /// fraction; may be negative (#709).
223    pub risk_free_rate: Decimal,
224
225    /// The option is a Call or Put option, determining the fundamental right
226    /// option can be exercised.
227    pub option_style: OptionStyle,
228
229    /// The annualized dividend yield of the underlying asset, affecting option pricing
230    /// particularly for longer-dated contracts.
231    pub dividend_yield: Positive,
232
233    /// Additional parameters required for exotic option types like Asian or Lookback options.
234    /// This field is None for standard (vanilla) options.
235    pub exotic_params: Option<ExoticParams>,
236}
237
238impl Options {
239    /// Creates a new options contract with the specified parameters.
240    ///
241    /// This constructor creates an instance of `Options` with all the required parameters
242    /// for defining and pricing an option contract. It supports both standard (vanilla)
243    /// options and exotic options through the optional `exotic_params` parameter.
244    ///
245    /// # Parameters
246    ///
247    /// * `option_type` - Specifies whether this is a Call or Put option, determining the fundamental
248    ///   right the option contract provides.
249    /// * `side` - Indicates whether the position is Long (purchased) or Short (sold/written),
250    ///   which determines the profit/loss direction.
251    /// * `underlying_symbol` - The ticker symbol or identifier of the underlying asset (e.g., "AAPL").
252    /// * `strike_price` - The price at which the option can be exercised, represented as a `Positive` value.
253    /// * `expiration_date` - When the option contract expires, either as days from now or as a specific date.
254    /// * `implied_volatility` - The market's expectation for future volatility of the underlying asset,
255    ///   a key parameter for option pricing.
256    /// * `quantity` - The number of contracts in this position, represented as a `Positive` value.
257    /// * `underlying_price` - The current market price of the underlying asset.
258    /// * `risk_free_rate` - The current risk-free interest rate used in option pricing models.
259    /// * `option_style` - The option exercise style (European or American), determining when the
260    ///   option can be exercised.
261    /// * `dividend_yield` - The annualized dividend yield of the underlying asset, affecting option pricing.
262    /// * `exotic_params` - Additional parameters required for exotic option types. Set to `None` for
263    ///   standard (vanilla) options.
264    ///
265    /// # Returns
266    ///
267    /// A fully configured `Options` instance with all the specified parameters.
268    ///
269    #[allow(clippy::too_many_arguments)]
270    #[must_use]
271    pub fn new(
272        option_type: OptionType,
273        side: Side,
274        underlying_symbol: String,
275        strike_price: Positive,
276        expiration_date: ExpirationDate,
277        implied_volatility: Positive,
278        quantity: Positive,
279        underlying_price: Positive,
280        risk_free_rate: Decimal,
281        option_style: OptionStyle,
282        dividend_yield: Positive,
283        exotic_params: Option<ExoticParams>,
284    ) -> Self {
285        Options {
286            option_type,
287            side,
288            underlying_symbol,
289            strike_price,
290            expiration_date,
291            implied_volatility,
292            quantity,
293            contract_size: default_contract_size(),
294            underlying_price,
295            risk_free_rate,
296            option_style,
297            dividend_yield,
298            exotic_params,
299        }
300    }
301
302    /// Returns the option with its contract multiplier set to `contract_size`.
303    ///
304    /// [`Options::new`] builds a contract that covers one unit of the
305    /// underlying; chain this to size it in market contracts, for example
306    /// `.with_contract_size(Positive::HUNDRED)` for a standard US equity
307    /// option. A zero multiplier is accepted here and rejected by validation,
308    /// as a zero quantity is.
309    #[must_use = "with_contract_size returns the updated option and leaves the original untouched"]
310    #[inline]
311    pub fn with_contract_size(mut self, contract_size: Positive) -> Self {
312        self.contract_size = contract_size;
313        self
314    }
315
316    /// The size of the position in units of the underlying:
317    /// `quantity × contract_size`.
318    ///
319    /// This is the factor that scales payoff, intrinsic value, premium, P&L
320    /// and Greeks from one unit of the underlying to the whole position. With
321    /// the default `contract_size` of 1 it is exactly `quantity`.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`OptionsError::ValidationError`] when the product overflows the
326    /// `Positive` range.
327    #[inline]
328    pub fn position_size(&self) -> OptionsResult<Positive> {
329        self.quantity
330            .checked_mul(&self.contract_size)
331            .map_err(|_| position_size_out_of_range(self.quantity, self.contract_size))
332    }
333
334    /// Calculates the time to expiration of the option in years.
335    ///
336    /// This function computes the time remaining until the option's expiration date,
337    /// expressed as a positive decimal value representing years. This is a key parameter
338    /// used in option pricing models.
339    ///
340    /// # Returns
341    ///
342    /// * `OptionsResult<Positive>` - A result containing the time to expiration in years
343    ///   as a Positive value, or an error if the calculation failed.
344    ///
345    /// # Errors
346    ///
347    /// Propagates any [`expiration_date::error::ExpirationDateError`] returned by
348    /// [`ExpirationDate::get_years`] (wrapped as [`OptionsError::ExpirationDate`])
349    /// when the stored expiration cannot be converted to a positive
350    /// year fraction (e.g. past expiration or invalid date).
351    pub fn time_to_expiration(&self) -> OptionsResult<Positive> {
352        Ok(self.expiration_date.get_years()?)
353    }
354
355    /// Determines if the option position is long (purchased).
356    ///
357    /// A long position indicates that the option has been bought, meaning the holder
358    /// has the right to exercise the option according to its terms.
359    ///
360    /// # Returns
361    ///
362    /// * `bool` - Returns true if the option is held as a long position, false otherwise.
363    ///
364    #[must_use]
365    pub fn is_long(&self) -> bool {
366        matches!(self.side, Side::Long)
367    }
368
369    /// Determines if the option position is short (written/sold).
370    ///
371    /// A short position indicates that the option has been sold or written, meaning
372    /// the holder has the obligation to fulfill the contract terms if the option is exercised.
373    ///
374    /// # Returns
375    ///
376    /// * `bool` - Returns true if the option is held as a short position, false otherwise.
377    ///
378    #[must_use]
379    pub fn is_short(&self) -> bool {
380        matches!(self.side, Side::Short)
381    }
382
383    /// Calculates the intrinsic value (payoff) of the option at the current underlying price.
384    ///
385    /// The payoff represents what the option would be worth if exercised immediately,
386    /// based on the current market conditions. For out-of-the-money options, the payoff
387    /// will be zero.
388    ///
389    /// # Returns
390    ///
391    /// * `OptionsResult<Decimal>` - A result containing the calculated payoff as a
392    ///   Decimal value, adjusted for the position size (`quantity × contract_size`), or an error if
393    ///   the calculation failed.
394    ///
395    /// This method is useful for determining the exercise value of an option and for
396    /// analyzing whether an option has intrinsic value.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`OptionsError::PayoffError`] when the size-scaled payoff is
401    /// not representable as a `Decimal` (non-finite, or beyond the `Decimal`
402    /// range). It previously returned `Ok(Decimal::ZERO)` for those inputs,
403    /// which reported a deep in-the-money position as worthless.
404    ///
405    /// Returns [`OptionsError::ValidationError`] when the position size
406    /// `quantity × contract_size` overflows the `Positive` range.
407    ///
408    /// # Barrier options
409    ///
410    /// No path is known here, only `underlying_price`, so the
411    /// [`PayoffInfo`] carries no `spot_min` / `spot_max` and a barrier
412    /// counts as hit only when `underlying_price` is at or beyond it (`≥`
413    /// for an up barrier, `≤` for a down one). An unhit knock-in pays its
414    /// rebate and a hit knock-out pays its rebate, signed by the side. For
415    /// a path-dependent payoff build a [`PayoffInfo`] with the observed
416    /// extremes and call [`crate::model::payoff::Payoff::payoff`] on the
417    /// option type.
418    ///
419    /// # Exotic options
420    ///
421    /// The payoff is the contract's terminal payoff, the value its pricing
422    /// kernel returns at `T = 0` (#844), signed by the side for every family.
423    /// The option's `exotic_params` reach the [`PayoffInfo`], so a two-asset
424    /// rainbow reads its second asset price and a cliquet its global cap and
425    /// floor; observed Asian fixings and lookback extremes do not, so an
426    /// Asian pays its intrinsic value on the spot and a floating-strike
427    /// lookback pays `0`, as a new contract does at expiry.
428    pub fn payoff(&self) -> OptionsResult<Decimal> {
429        let payoff_info = PayoffInfo {
430            spot: self.underlying_price,
431            strike: self.strike_price,
432            style: self.option_style,
433            side: self.side,
434            spot_prices: None,
435            spot_min: None,
436            spot_max: None,
437            exotic_params: self.exotic_params.clone(),
438        };
439        let payoff = self.option_type.payoff(&payoff_info)?;
440        let size = self.position_size()?;
441        payoff
442            .checked_mul(size.to_dec())
443            .ok_or_else(|| payoff_out_of_range("Options::payoff", payoff, size))
444    }
445
446    /// Calculates the financial payoff value of the option at a specific underlying price.
447    ///
448    /// This method determines the option's payoff based on its type, strike price, style,
449    /// and side (long/short) at the given underlying price. The result represents the
450    /// total profit or loss for the option position at that price, adjusted by the position size
451    /// (`quantity × contract_size`).
452    ///
453    /// # Parameters
454    ///
455    /// * `price` - A `Positive` value representing the hypothetical price of the underlying asset.
456    ///
457    /// # Returns
458    ///
459    /// * `OptionsResult<Decimal>` - The calculated payoff value as a `Decimal`, wrapped in a `Result` type.
460    ///   Returns an `Err` if the payoff calculation encounters an error.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`OptionsError::PayoffError`] when the size-scaled payoff at
465    /// `price` is not representable as a `Decimal` (non-finite, or beyond the
466    /// `Decimal` range). It previously returned `Ok(Decimal::ZERO)` for those
467    /// inputs, which reported a deep in-the-money position as worthless.
468    ///
469    /// Returns [`OptionsError::ValidationError`] when the position size
470    /// `quantity × contract_size` overflows the `Positive` range.
471    pub fn payoff_at_price(&self, price: &Positive) -> OptionsResult<Decimal> {
472        let payoff_info = PayoffInfo {
473            spot: *price,
474            strike: self.strike_price,
475            style: self.option_style,
476            side: self.side,
477            spot_prices: None,
478            spot_min: None,
479            spot_max: None,
480            exotic_params: self.exotic_params.clone(),
481        };
482        let payoff = self.option_type.payoff(&payoff_info)?;
483        let size = self.position_size()?;
484        payoff
485            .checked_mul(size.to_dec())
486            .ok_or_else(|| payoff_out_of_range("Options::payoff_at_price", payoff, size))
487    }
488
489    /// Calculates the intrinsic value of the option.
490    ///
491    /// The intrinsic value is the difference between the underlying asset's price and the option's strike price.
492    /// For call options, the intrinsic value is the maximum of zero and the difference between the underlying price and the strike price.
493    /// For put options, the intrinsic value is the maximum of zero and the difference between the strike price and the underlying price.
494    ///
495    /// # Arguments
496    ///
497    /// * `underlying_price` - The current price of the underlying asset.
498    ///
499    /// # Returns
500    ///
501    /// * `OptionsResult<Decimal>` - The intrinsic value of the option, or an error if the calculation fails.
502    ///
503    /// # Errors
504    ///
505    /// Returns [`OptionsError::PayoffError`] when the size-scaled intrinsic
506    /// value is not representable as a `Decimal` (non-finite, or beyond the
507    /// `Decimal` range). It previously returned `Ok(Decimal::ZERO)` for those
508    /// inputs, which reported a deep in-the-money position as worthless.
509    ///
510    /// Returns [`OptionsError::ValidationError`] when the position size
511    /// `quantity × contract_size` overflows the `Positive` range.
512    pub fn intrinsic_value(&self, underlying_price: Positive) -> OptionsResult<Decimal> {
513        let payoff_info = PayoffInfo {
514            spot: underlying_price,
515            strike: self.strike_price,
516            style: self.option_style,
517            side: self.side,
518            spot_prices: None,
519            spot_min: None,
520            spot_max: None,
521            exotic_params: self.exotic_params.clone(),
522        };
523        let payoff = self.option_type.payoff(&payoff_info)?;
524        let size = self.position_size()?;
525        payoff
526            .checked_mul(size.to_dec())
527            .ok_or_else(|| payoff_out_of_range("Options::intrinsic_value", payoff, size))
528    }
529
530    /// Determines whether an option is "in-the-money" based on its current price relative to strike price.
531    ///
532    /// An option is considered in-the-money when:
533    /// - For Call options: the underlying asset price is greater than or equal to the strike price
534    /// - For Put options: the underlying asset price is less than or equal to the strike price
535    ///
536    /// This status is important for evaluating the option's current value and potential profitability.
537    ///
538    /// # Returns
539    /// `true` if the option is in-the-money, `false` otherwise
540    #[must_use]
541    pub fn is_in_the_money(&self) -> bool {
542        match self.option_style {
543            OptionStyle::Call => self.underlying_price >= self.strike_price,
544            OptionStyle::Put => self.underlying_price <= self.strike_price,
545        }
546    }
547
548    /// Validates that the option parameters are in a valid state for calculations.
549    ///
550    /// This function performs comprehensive validation of the option's critical parameters
551    /// to ensure they meet basic requirements for meaningful financial calculations.
552    /// It logs detailed error messages when validation fails.
553    ///
554    /// Validation checks include:
555    /// - Underlying symbol is not empty
556    /// - Implied volatility is non-negative
557    /// - Quantity is non-zero
558    /// - Strike price is positive and non-zero
559    /// - Underlying price is positive and non-zero
560    ///
561    /// The risk-free rate is not checked: a negative rate is a legitimate
562    /// market input (EUR, CHF and JPY rates were negative for years) and the
563    /// pricers handle it (#709).
564    ///
565    /// # Returns
566    /// `true` if all parameters are valid, `false` if any validation fails
567    pub(crate) fn validate(&self) -> bool {
568        if self.underlying_symbol == *"" {
569            error!("Underlying symbol is empty");
570            return false;
571        }
572        if self.implied_volatility < ZERO {
573            error!("Implied volatility is less than zero");
574            return false;
575        }
576        if self.quantity == ZERO {
577            error!("Quantity is equal to zero");
578            return false;
579        }
580        if self.contract_size == Positive::ZERO {
581            error!("Contract size is equal to zero");
582            return false;
583        }
584        if self.strike_price == Positive::ZERO {
585            error!("Strike is zero");
586            return false;
587        }
588        if self.underlying_price == Positive::ZERO {
589            error!("Underlying price is zero");
590            return false;
591        }
592        true
593    }
594}
595
596impl Default for Options {
597    fn default() -> Self {
598        Options {
599            option_type: OptionType::European,
600            side: Side::Long,
601            underlying_symbol: "".to_string(),
602            strike_price: Positive::ZERO,
603            expiration_date: ExpirationDate::Days(Positive::ZERO),
604            implied_volatility: Positive::ZERO,
605            quantity: Positive::ZERO,
606            contract_size: default_contract_size(),
607            underlying_price: Positive::ZERO,
608            risk_free_rate: Decimal::ZERO,
609            option_style: OptionStyle::Call,
610            dividend_yield: Positive::ZERO,
611            exotic_params: None,
612        }
613    }
614}
615
616#[cfg(test)]
617mod tests_options {
618    use super::*;
619    use crate::model::utils::create_sample_option_simplest;
620
621    use approx::assert_relative_eq;
622    use chrono::{Duration, Utc};
623    use num_traits::ToPrimitive;
624    use rust_decimal_macros::dec;
625
626    #[test]
627    fn test_new_option() {
628        let option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
629        assert_eq!(option.underlying_symbol, "AAPL");
630        assert_eq!(option.strike_price, 100.0);
631        assert_eq!(option.implied_volatility, 0.2);
632    }
633
634    #[test]
635    fn test_time_to_expiration() {
636        let option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
637        assert_relative_eq!(
638            option.time_to_expiration().unwrap().to_f64(),
639            30.0 / 365.0,
640            epsilon = 0.0001
641        );
642
643        let future_date = Utc::now() + Duration::days(60);
644        let option_with_datetime = Options::new(
645            OptionType::European,
646            Side::Long,
647            "AAPL".to_string(),
648            Positive::HUNDRED,
649            ExpirationDate::DateTime(future_date),
650            pos_or_panic!(0.2),
651            Positive::ONE,
652            pos_or_panic!(105.0),
653            dec!(0.05),
654            OptionStyle::Call,
655            pos_or_panic!(0.01),
656            None,
657        );
658        assert!(option_with_datetime.time_to_expiration().unwrap() >= 59.0 / 365.0);
659        assert!(option_with_datetime.time_to_expiration().unwrap() < 61.0 / 365.0);
660    }
661
662    #[test]
663    fn test_is_long_and_short() {
664        let long_option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
665        assert!(long_option.is_long());
666        assert!(!long_option.is_short());
667
668        let short_option = Options::new(
669            OptionType::European,
670            Side::Short,
671            "AAPL".to_string(),
672            Positive::HUNDRED,
673            ExpirationDate::Days(pos_or_panic!(30.0)),
674            pos_or_panic!(0.2),
675            Positive::ONE,
676            pos_or_panic!(105.0),
677            dec!(0.05),
678            OptionStyle::Call,
679            pos_or_panic!(0.01),
680            None,
681        );
682        assert!(!short_option.is_long());
683        assert!(short_option.is_short());
684    }
685
686    #[test]
687    fn test_payoff_european_call_long() {
688        let call_option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
689        let call_payoff = call_option.payoff().unwrap();
690        assert_eq!(call_payoff, Decimal::ZERO); // max(100 - 100, 0) = 0
691
692        let put_option = Options::new(
693            OptionType::European,
694            Side::Long,
695            "AAPL".to_string(),
696            Positive::HUNDRED,
697            ExpirationDate::Days(pos_or_panic!(30.0)),
698            pos_or_panic!(0.2),
699            Positive::ONE,
700            pos_or_panic!(95.0),
701            dec!(0.05),
702            OptionStyle::Put,
703            pos_or_panic!(0.01),
704            None,
705        );
706        let put_payoff = put_option.payoff().unwrap();
707        assert_eq!(put_payoff.to_f64().unwrap(), 5.0); // max(100 - 95, 0) = 5
708    }
709}
710
711#[cfg(test)]
712mod tests_valid_option {
713    use super::*;
714
715    use rust_decimal_macros::dec;
716
717    fn create_valid_option() -> Options {
718        Options {
719            option_type: OptionType::European,
720            side: Side::Long,
721            underlying_symbol: "AAPL".to_string(),
722            strike_price: Positive::HUNDRED,
723            expiration_date: ExpirationDate::Days(pos_or_panic!(30.0)),
724            implied_volatility: pos_or_panic!(0.2),
725            quantity: Positive::ONE,
726            contract_size: Positive::ONE,
727            underlying_price: pos_or_panic!(105.0),
728            risk_free_rate: dec!(0.05),
729            option_style: OptionStyle::Call,
730            dividend_yield: pos_or_panic!(0.01),
731            exotic_params: None,
732        }
733    }
734
735    #[test]
736    fn test_valid_option() {
737        let option = create_valid_option();
738        assert!(option.validate());
739    }
740
741    #[test]
742    fn test_empty_underlying_symbol() {
743        let mut option = create_valid_option();
744        option.underlying_symbol = "".to_string();
745        assert!(!option.validate());
746    }
747
748    #[test]
749    fn test_zero_strike_price() {
750        let mut option = create_valid_option();
751        option.strike_price = Positive::ZERO;
752        assert!(!option.validate());
753    }
754
755    #[test]
756    fn test_zero_quantity() {
757        let mut option = create_valid_option();
758        option.quantity = Positive::ZERO;
759        assert!(!option.validate());
760    }
761
762    #[test]
763    fn test_zero_underlying_price() {
764        let mut option = create_valid_option();
765        option.underlying_price = Positive::ZERO;
766        assert!(!option.validate());
767    }
768
769    /// A negative risk-free rate is a market input, not an invalid option
770    /// (#709): r = -1 % validates, and so do a zero rate and a deeply
771    /// negative one.
772    #[test]
773    fn test_options_validate_negative_risk_free_rate_is_valid() {
774        for rate in [dec!(-0.01), Decimal::ZERO, dec!(-0.0075), dec!(-0.25)] {
775            let mut option = create_valid_option();
776            option.risk_free_rate = rate;
777            assert!(option.validate(), "r = {rate}");
778        }
779    }
780
781    /// The other checks still reject at a negative rate.
782    #[test]
783    fn test_options_validate_negative_rate_keeps_other_checks() {
784        let mut option = create_valid_option();
785        option.risk_free_rate = dec!(-0.01);
786        option.quantity = Positive::ZERO;
787        assert!(!option.validate());
788
789        let mut option = create_valid_option();
790        option.risk_free_rate = dec!(-0.01);
791        option.underlying_symbol = String::new();
792        assert!(!option.validate());
793
794        let mut option = create_valid_option();
795        option.risk_free_rate = dec!(-0.01);
796        option.underlying_price = Positive::ZERO;
797        assert!(!option.validate());
798    }
799}
800
801#[cfg(test)]
802mod tests_options_payoffs {
803    use super::*;
804    use crate::model::utils::create_sample_option_simplest_strike;
805
806    use rust_decimal_macros::dec;
807
808    #[test]
809    fn test_payoff_european_call_long() {
810        let call_option = create_sample_option_simplest_strike(
811            Side::Long,
812            OptionStyle::Call,
813            pos_or_panic!(95.0),
814        );
815        let call_payoff = call_option.payoff().unwrap();
816        assert_eq!(call_payoff, dec!(5.0)); // max(100 - 95, 0) = 5
817
818        let call_option_otm = create_sample_option_simplest_strike(
819            Side::Long,
820            OptionStyle::Call,
821            pos_or_panic!(105.0),
822        );
823        let call_payoff_otm = call_option_otm.payoff().unwrap();
824        assert_eq!(call_payoff_otm, Decimal::ZERO); // max(100 - 105, 0) = 0
825    }
826
827    #[test]
828    fn test_payoff_european_call_short() {
829        let call_option = create_sample_option_simplest_strike(
830            Side::Short,
831            OptionStyle::Call,
832            pos_or_panic!(95.0),
833        );
834        let call_payoff = call_option.payoff().unwrap();
835        assert_eq!(call_payoff, dec!(-5.0)); // -max(100 - 95, 0) = -5
836
837        let call_option_otm = create_sample_option_simplest_strike(
838            Side::Short,
839            OptionStyle::Call,
840            pos_or_panic!(105.0),
841        );
842        let call_payoff_otm = call_option_otm.payoff().unwrap();
843        assert_eq!(call_payoff_otm, Decimal::ZERO); // -max(95 - 100, 0) = 0
844    }
845
846    #[test]
847    fn test_payoff_european_put_long() {
848        let put_option = create_sample_option_simplest_strike(
849            Side::Long,
850            OptionStyle::Put,
851            pos_or_panic!(105.0),
852        );
853        let put_payoff = put_option.payoff().unwrap();
854        assert_eq!(put_payoff, dec!(5.0)); // max(105 - 100, 0) = 5
855
856        let put_option_otm =
857            create_sample_option_simplest_strike(Side::Long, OptionStyle::Put, pos_or_panic!(95.0));
858        let put_payoff_otm = put_option_otm.payoff().unwrap();
859        assert_eq!(put_payoff_otm, Decimal::ZERO); // max(95 - 100, 0) = 0
860    }
861
862    #[test]
863    fn test_payoff_european_put_short() {
864        let put_option = create_sample_option_simplest_strike(
865            Side::Short,
866            OptionStyle::Put,
867            pos_or_panic!(105.0),
868        );
869        let put_payoff = put_option.payoff().unwrap();
870        assert_eq!(put_payoff, dec!(-5.0)); // -max(105 - 100, 0) = -5
871
872        let put_option_otm = create_sample_option_simplest_strike(
873            Side::Short,
874            OptionStyle::Put,
875            pos_or_panic!(95.0),
876        );
877        let put_payoff_otm = put_option_otm.payoff().unwrap();
878        assert_eq!(put_payoff_otm, Decimal::ZERO); // -max(95 - 100, 0) = 0
879    }
880
881    /// The three payoff entry points reported `Ok(0)` for a payoff too large
882    /// to represent, which is a deep in-the-money position priced at nothing.
883    #[test]
884    fn test_payoff_out_of_range_reports_an_error_instead_of_zero() {
885        let mut option = create_sample_option_simplest_strike(
886            Side::Long,
887            OptionStyle::Call,
888            pos_or_panic!(95.0),
889        );
890        option.strike_price = Positive::ZERO;
891        option.underlying_price = Positive::MAX;
892        option.quantity = Positive::TWO;
893
894        // The unit payoff is `Decimal::MAX` exactly since #844 computes it in
895        // `Decimal`; two contracts of it leave the range.
896        assert!(matches!(
897            option.payoff(),
898            Err(OptionsError::PayoffError { .. })
899        ));
900        assert!(matches!(
901            option.payoff_at_price(&Positive::MAX),
902            Err(OptionsError::PayoffError { .. })
903        ));
904        assert!(matches!(
905            option.intrinsic_value(Positive::MAX),
906            Err(OptionsError::PayoffError { .. })
907        ));
908    }
909
910    /// A payoff that is genuinely zero still comes back as `Ok(0)`: the fix
911    /// distinguishes "worth nothing" from "cannot be represented".
912    #[test]
913    fn test_payoff_out_of_the_money_still_returns_zero() {
914        let mut option =
915            create_sample_option_simplest_strike(Side::Long, OptionStyle::Put, pos_or_panic!(95.0));
916        option.strike_price = Positive::ZERO;
917        option.underlying_price = Positive::MAX;
918        option.quantity = Positive::ONE;
919
920        assert_eq!(option.payoff().unwrap(), Decimal::ZERO);
921        assert_eq!(
922            option.payoff_at_price(&Positive::MAX).unwrap(),
923            Decimal::ZERO
924        );
925        assert_eq!(
926            option.intrinsic_value(Positive::MAX).unwrap(),
927            Decimal::ZERO
928        );
929    }
930}
931
932#[cfg(test)]
933mod tests_options_payoff_at_price {
934    use super::*;
935    use crate::model::utils::create_sample_option_simplest;
936
937    use rust_decimal_macros::dec;
938
939    #[test]
940    fn test_payoff_european_call_long() {
941        let call_option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
942        let call_payoff = call_option.payoff_at_price(&pos_or_panic!(105.0)).unwrap();
943        assert_eq!(call_payoff, dec!(5.0)); // max(105 - 100, 0) = 5
944
945        let call_option_otm = create_sample_option_simplest(OptionStyle::Call, Side::Long);
946        let call_payoff_otm = call_option_otm
947            .payoff_at_price(&pos_or_panic!(95.0))
948            .unwrap();
949        assert_eq!(call_payoff_otm, Decimal::ZERO); // max(95 - 100, 0) = 0
950    }
951
952    #[test]
953    fn test_payoff_european_call_short() {
954        let call_option = create_sample_option_simplest(OptionStyle::Call, Side::Short);
955        let call_payoff = call_option.payoff_at_price(&pos_or_panic!(105.0)).unwrap();
956        assert_eq!(call_payoff, dec!(-5.0)); // -max(105 - 100, 0) = -5
957
958        let call_option_otm = create_sample_option_simplest(OptionStyle::Call, Side::Short);
959        let call_payoff_otm = call_option_otm
960            .payoff_at_price(&pos_or_panic!(95.0))
961            .unwrap();
962        assert_eq!(call_payoff_otm, Decimal::ZERO); // -max(95 - 100, 0) = 0
963    }
964
965    #[test]
966    fn test_payoff_european_put_long() {
967        let put_option = create_sample_option_simplest(OptionStyle::Put, Side::Long);
968        let put_payoff = put_option.payoff_at_price(&pos_or_panic!(95.0)).unwrap();
969        assert_eq!(put_payoff, dec!(5.0)); // max(100 - 95, 0) = 5
970
971        let put_option_otm = create_sample_option_simplest(OptionStyle::Put, Side::Long);
972        let put_payoff_otm = put_option_otm
973            .payoff_at_price(&pos_or_panic!(105.0))
974            .unwrap();
975        assert_eq!(put_payoff_otm, Decimal::ZERO); // max(100 - 105, 0) = 0
976    }
977
978    #[test]
979    fn test_payoff_european_put_short() {
980        let put_option = create_sample_option_simplest(OptionStyle::Put, Side::Short);
981        let put_payoff = put_option.payoff_at_price(&pos_or_panic!(95.0)).unwrap();
982        assert_eq!(put_payoff, dec!(-5.0)); // -max(100 - 95, 0) = -5
983
984        let put_option_otm = create_sample_option_simplest(OptionStyle::Put, Side::Short);
985        let put_payoff_otm = put_option_otm
986            .payoff_at_price(&pos_or_panic!(105.0))
987            .unwrap();
988        assert_eq!(put_payoff_otm, Decimal::ZERO); // -max(100 - 105, 0) = 0
989    }
990}
991
992#[cfg(test)]
993mod tests_options_payoffs_with_quantity {
994    use super::*;
995    use crate::model::utils::create_sample_option;
996
997    use num_traits::ToPrimitive;
998    use rust_decimal_macros::dec;
999
1000    #[test]
1001    fn test_payoff_call_long() {
1002        let option = create_sample_option(
1003            OptionStyle::Call,
1004            Side::Long,
1005            pos_or_panic!(105.0),
1006            pos_or_panic!(10.0),
1007            Positive::HUNDRED,
1008            pos_or_panic!(0.02),
1009        );
1010        assert_eq!(option.payoff().unwrap().to_f64().unwrap(), 50.0);
1011
1012        let option_otm = create_sample_option(
1013            OptionStyle::Call,
1014            Side::Long,
1015            pos_or_panic!(95.0),
1016            pos_or_panic!(4.0),
1017            Positive::HUNDRED,
1018            pos_or_panic!(0.02),
1019        );
1020        assert_eq!(option_otm.payoff().unwrap(), Decimal::ZERO);
1021    }
1022
1023    #[test]
1024    fn test_payoff_call_short() {
1025        let option = create_sample_option(
1026            OptionStyle::Call,
1027            Side::Short,
1028            pos_or_panic!(105.0),
1029            pos_or_panic!(3.0),
1030            Positive::HUNDRED,
1031            pos_or_panic!(0.02),
1032        );
1033        assert_eq!(option.payoff().unwrap().to_f64().unwrap(), -15.0);
1034
1035        let option_otm = create_sample_option(
1036            OptionStyle::Call,
1037            Side::Short,
1038            pos_or_panic!(95.0),
1039            pos_or_panic!(7.0),
1040            Positive::HUNDRED,
1041            pos_or_panic!(0.02),
1042        );
1043        assert_eq!(option_otm.payoff().unwrap(), Decimal::ZERO);
1044    }
1045
1046    #[test]
1047    fn test_payoff_put_long() {
1048        let option = create_sample_option(
1049            OptionStyle::Put,
1050            Side::Long,
1051            pos_or_panic!(95.0),
1052            Positive::TWO,
1053            Positive::HUNDRED,
1054            pos_or_panic!(0.02),
1055        );
1056        assert_eq!(option.payoff().unwrap().to_f64().unwrap(), 10.0);
1057
1058        let option_otm = create_sample_option(
1059            OptionStyle::Put,
1060            Side::Long,
1061            pos_or_panic!(105.0),
1062            pos_or_panic!(7.0),
1063            Positive::HUNDRED,
1064            pos_or_panic!(0.02),
1065        );
1066        assert_eq!(option_otm.payoff().unwrap(), Decimal::ZERO);
1067    }
1068
1069    #[test]
1070    fn test_payoff_put_short() {
1071        let option = create_sample_option(
1072            OptionStyle::Put,
1073            Side::Short,
1074            pos_or_panic!(95.0),
1075            pos_or_panic!(3.0),
1076            Positive::HUNDRED,
1077            pos_or_panic!(0.02),
1078        );
1079        assert_eq!(option.payoff().unwrap().to_f64().unwrap(), -15.0);
1080
1081        let option_otm = create_sample_option(
1082            OptionStyle::Put,
1083            Side::Short,
1084            pos_or_panic!(105.0),
1085            pos_or_panic!(3.0),
1086            Positive::HUNDRED,
1087            pos_or_panic!(0.02),
1088        );
1089        assert_eq!(option_otm.payoff().unwrap(), Decimal::ZERO);
1090    }
1091
1092    #[test]
1093    fn test_payoff_with_quantity() {
1094        let option = create_sample_option(
1095            OptionStyle::Call,
1096            Side::Long,
1097            pos_or_panic!(110.0),
1098            pos_or_panic!(3.0),
1099            Positive::HUNDRED,
1100            pos_or_panic!(0.02),
1101        );
1102        assert_eq!(option.payoff().unwrap().to_f64().unwrap(), 30.0); // (110 - 100) * 3
1103    }
1104
1105    #[test]
1106    fn test_intrinsic_value_call_long() {
1107        let option = create_sample_option(
1108            OptionStyle::Call,
1109            Side::Long,
1110            Positive::HUNDRED,
1111            pos_or_panic!(11.0),
1112            Positive::HUNDRED,
1113            pos_or_panic!(0.02),
1114        );
1115        assert_eq!(
1116            option.intrinsic_value(pos_or_panic!(105.0)).unwrap(),
1117            dec!(55.0)
1118        );
1119        assert_eq!(
1120            option.intrinsic_value(pos_or_panic!(95.0)).unwrap(),
1121            Decimal::ZERO
1122        );
1123    }
1124
1125    #[test]
1126    fn test_intrinsic_value_call_short() {
1127        let option = create_sample_option(
1128            OptionStyle::Call,
1129            Side::Short,
1130            Positive::HUNDRED,
1131            pos_or_panic!(13.0),
1132            Positive::HUNDRED,
1133            pos_or_panic!(0.02),
1134        );
1135        assert_eq!(
1136            option.intrinsic_value(pos_or_panic!(105.0)).unwrap(),
1137            dec!(-65.0)
1138        );
1139        assert_eq!(
1140            option.intrinsic_value(pos_or_panic!(95.0)).unwrap(),
1141            Decimal::ZERO
1142        );
1143    }
1144
1145    #[test]
1146    fn test_intrinsic_value_put_long() {
1147        let option = create_sample_option(
1148            OptionStyle::Put,
1149            Side::Long,
1150            Positive::HUNDRED,
1151            pos_or_panic!(17.0),
1152            Positive::HUNDRED,
1153            pos_or_panic!(0.02),
1154        );
1155        assert_eq!(
1156            option.intrinsic_value(pos_or_panic!(95.0)).unwrap(),
1157            dec!(85.0)
1158        );
1159        assert_eq!(
1160            option.intrinsic_value(pos_or_panic!(105.0)).unwrap(),
1161            Decimal::ZERO
1162        );
1163    }
1164
1165    #[test]
1166    fn test_intrinsic_value_put_short() {
1167        let option = create_sample_option(
1168            OptionStyle::Put,
1169            Side::Short,
1170            Positive::HUNDRED,
1171            pos_or_panic!(19.0),
1172            Positive::HUNDRED,
1173            pos_or_panic!(0.02),
1174        );
1175        assert_eq!(
1176            option.intrinsic_value(pos_or_panic!(95.0)).unwrap(),
1177            dec!(-95.0)
1178        );
1179        assert_eq!(
1180            option.intrinsic_value(pos_or_panic!(105.0)).unwrap(),
1181            Decimal::ZERO
1182        );
1183    }
1184
1185    #[test]
1186    fn test_intrinsic_value_with_quantity() {
1187        let option = create_sample_option(
1188            OptionStyle::Call,
1189            Side::Long,
1190            Positive::HUNDRED,
1191            pos_or_panic!(23.0),
1192            Positive::HUNDRED,
1193            pos_or_panic!(0.02),
1194        );
1195        assert_eq!(
1196            option.intrinsic_value(pos_or_panic!(110.0)).unwrap(),
1197            dec!(230.0)
1198        ); // (110 - 100) * 23
1199    }
1200}
1201
1202#[cfg(test)]
1203mod tests_in_the_money {
1204    use super::*;
1205    use crate::model::utils::create_sample_option;
1206
1207    #[test]
1208    fn test_call_in_the_money() {
1209        let mut option = create_sample_option(
1210            OptionStyle::Call,
1211            Side::Long,
1212            pos_or_panic!(110.0),
1213            Positive::ONE,
1214            pos_or_panic!(110.0),
1215            pos_or_panic!(0.02),
1216        );
1217        option.strike_price = Positive::HUNDRED;
1218        assert!(option.is_in_the_money());
1219    }
1220
1221    #[test]
1222    fn test_call_at_the_money() {
1223        let mut option = create_sample_option(
1224            OptionStyle::Call,
1225            Side::Long,
1226            Positive::HUNDRED,
1227            Positive::ONE,
1228            pos_or_panic!(110.0),
1229            pos_or_panic!(0.02),
1230        );
1231        option.strike_price = Positive::HUNDRED;
1232        assert!(option.is_in_the_money());
1233    }
1234
1235    #[test]
1236    fn test_call_out_of_the_money() {
1237        let mut option = create_sample_option(
1238            OptionStyle::Call,
1239            Side::Long,
1240            pos_or_panic!(90.0),
1241            Positive::ONE,
1242            pos_or_panic!(110.0),
1243            pos_or_panic!(0.02),
1244        );
1245        option.strike_price = Positive::HUNDRED;
1246        assert!(!option.is_in_the_money());
1247    }
1248
1249    #[test]
1250    fn test_put_in_the_money() {
1251        let mut option = create_sample_option(
1252            OptionStyle::Put,
1253            Side::Long,
1254            pos_or_panic!(90.0),
1255            Positive::ONE,
1256            pos_or_panic!(110.0),
1257            pos_or_panic!(0.02),
1258        );
1259        option.strike_price = Positive::HUNDRED;
1260        assert!(option.is_in_the_money());
1261    }
1262
1263    #[test]
1264    fn test_put_at_the_money() {
1265        let mut option = create_sample_option(
1266            OptionStyle::Put,
1267            Side::Long,
1268            Positive::HUNDRED,
1269            Positive::ONE,
1270            pos_or_panic!(110.0),
1271            pos_or_panic!(0.02),
1272        );
1273        option.strike_price = Positive::HUNDRED;
1274        assert!(option.is_in_the_money());
1275    }
1276
1277    #[test]
1278    fn test_put_out_of_the_money() {
1279        let mut option = create_sample_option(
1280            OptionStyle::Put,
1281            Side::Long,
1282            pos_or_panic!(110.0),
1283            Positive::ONE,
1284            pos_or_panic!(110.0),
1285            pos_or_panic!(0.02),
1286        );
1287        option.strike_price = Positive::HUNDRED;
1288        assert!(!option.is_in_the_money());
1289    }
1290}
1291
1292#[cfg(test)]
1293mod tests_serialize_deserialize {
1294    use super::*;
1295    use crate::model::utils::create_sample_option_simplest_strike;
1296
1297    #[test]
1298    fn test_serialize_deserialize_options() {
1299        let options = create_sample_option_simplest_strike(
1300            Side::Long,
1301            OptionStyle::Call,
1302            pos_or_panic!(95.0),
1303        );
1304        let serialized = serde_json::to_string(&options).expect("Failed to serialize");
1305        let deserialized: Options =
1306            serde_json::from_str(&serialized).expect("Failed to deserialize");
1307        assert_eq!(options, deserialized);
1308    }
1309}
1310
1311#[cfg(test)]
1312mod tests_contract_size {
1313    use super::*;
1314    use crate::model::utils::create_sample_option;
1315    use rust_decimal_macros::dec;
1316
1317    fn call(side: Side, spot: Positive, quantity: Positive) -> Options {
1318        create_sample_option(
1319            OptionStyle::Call,
1320            side,
1321            spot,
1322            quantity,
1323            Positive::HUNDRED,
1324            pos_or_panic!(0.2),
1325        )
1326    }
1327
1328    #[test]
1329    fn test_options_contract_size_defaults_to_one() {
1330        let option = call(Side::Long, pos_or_panic!(110.0), Positive::ONE);
1331        assert_eq!(option.contract_size, Positive::ONE);
1332        assert_eq!(Options::default().contract_size, Positive::ONE);
1333        assert_eq!(option.position_size().ok(), Some(Positive::ONE));
1334    }
1335
1336    #[test]
1337    fn test_options_with_contract_size_sets_the_multiplier() {
1338        let option = call(Side::Long, pos_or_panic!(110.0), Positive::TWO)
1339            .with_contract_size(Positive::HUNDRED);
1340        assert_eq!(option.contract_size, Positive::HUNDRED);
1341        assert_eq!(option.position_size().ok(), Some(pos_or_panic!(200.0)));
1342    }
1343
1344    #[test]
1345    fn test_options_position_size_overflow_is_an_error() {
1346        let option = call(Side::Long, pos_or_panic!(110.0), Positive::MAX)
1347            .with_contract_size(Positive::HUNDRED);
1348        assert!(matches!(
1349            option.position_size(),
1350            Err(OptionsError::ValidationError { .. })
1351        ));
1352        assert!(option.payoff().is_err());
1353    }
1354
1355    #[test]
1356    fn test_options_payoff_scales_by_contract_size() {
1357        // 2 contracts of 100 units, 10 in the money: 2 × 100 × 10.
1358        let one = call(Side::Long, pos_or_panic!(110.0), Positive::TWO);
1359        let sized = one.clone().with_contract_size(Positive::HUNDRED);
1360        assert_eq!(one.payoff().ok(), Some(dec!(20)));
1361        assert_eq!(sized.payoff().ok(), Some(dec!(2000)));
1362        assert_eq!(
1363            sized.payoff_at_price(&pos_or_panic!(120.0)).ok(),
1364            Some(dec!(4000))
1365        );
1366        assert_eq!(
1367            sized.intrinsic_value(pos_or_panic!(105.0)).ok(),
1368            Some(dec!(1000))
1369        );
1370        assert_eq!(
1371            sized.intrinsic_value(pos_or_panic!(90.0)).ok(),
1372            Some(Decimal::ZERO)
1373        );
1374    }
1375
1376    #[test]
1377    fn test_options_short_payoff_scales_by_contract_size() {
1378        let sized = call(Side::Short, pos_or_panic!(110.0), Positive::ONE)
1379            .with_contract_size(Positive::HUNDRED);
1380        assert_eq!(sized.payoff().ok(), Some(dec!(-1000)));
1381    }
1382
1383    #[test]
1384    fn test_options_validate_rejects_zero_contract_size() {
1385        let option = call(Side::Long, pos_or_panic!(110.0), Positive::ONE);
1386        assert!(option.validate());
1387        assert!(!option.with_contract_size(Positive::ZERO).validate());
1388    }
1389
1390    #[test]
1391    fn test_options_deserialize_without_contract_size_defaults_to_one() {
1392        let option = call(Side::Long, pos_or_panic!(110.0), Positive::ONE);
1393        let mut value = serde_json::to_value(&option).expect("serialize");
1394        let removed = value
1395            .as_object_mut()
1396            .and_then(|map| map.remove("contract_size"));
1397        assert!(removed.is_some(), "contract_size is serialized");
1398        let restored: Options = serde_json::from_value(value).expect("deserialize");
1399        assert_eq!(restored.contract_size, Positive::ONE);
1400        assert_eq!(restored, option);
1401    }
1402
1403    #[test]
1404    fn test_options_contract_size_round_trips_through_serde() {
1405        let option = call(Side::Long, pos_or_panic!(110.0), Positive::ONE)
1406            .with_contract_size(Positive::HUNDRED);
1407        let json = serde_json::to_string(&option).expect("serialize");
1408        let restored: Options = serde_json::from_str(&json).expect("deserialize");
1409        assert_eq!(restored.contract_size, Positive::HUNDRED);
1410    }
1411
1412    #[test]
1413    fn test_options_format_shows_contract_size_only_when_not_one() {
1414        let one = call(Side::Long, pos_or_panic!(110.0), Positive::ONE);
1415        assert!(!format!("{one}").contains("Contract Size"));
1416        assert!(!format!("{one:?}").contains("contract_size"));
1417        let sized = one.with_contract_size(Positive::HUNDRED);
1418        assert!(format!("{sized}").contains("Contract Size: 100"));
1419        assert!(format!("{sized:?}").contains("contract_size: 100"));
1420    }
1421}