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