Skip to main content

optionstratlib_core/model/
position.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 18/8/24
5******************************************************************************/
6
7use crate::error::position::PositionValidationErrorKind;
8use crate::error::{PositionError, TradeError};
9use crate::model::decimal::{d_add, d_mul, d_sub};
10use crate::model::expiration::resolve_expiration_date;
11use crate::model::trade::TradeStatusAble;
12use crate::model::types::{Action, OptionStyle, Side};
13use crate::model::{ExpirationDate, Options};
14use crate::model::{Trade, TradeAble, TradeStatus};
15#[cfg(feature = "clock")]
16use crate::utils::time::wall_clock;
17use crate::utils::time::{evaluation_time, now};
18use chrono::{DateTime, Utc};
19use positive::Positive;
20use rust_decimal::Decimal;
21use serde::{Deserialize, Serialize};
22use tracing::debug;
23
24/// The `Position` struct represents a financial position in an options market.
25///
26/// This structure encapsulates all the necessary information to track an options position,
27/// including the underlying option details, costs associated with the position, and the date
28/// when the position was opened. It provides methods for analyzing profitability, time metrics,
29/// and position characteristics.
30///
31/// # Contract size
32///
33/// The option's `quantity` counts contracts of `contract_size` units of the
34/// underlying (see [`Options::position_size`]):
35///
36/// - `premium` is quoted per unit of the underlying, so one contract pays or
37///   receives `premium × contract_size` and the position
38///   `premium × contract_size × quantity`;
39/// - `open_fee` and `close_fee` are per contract and scale with `quantity`
40///   only;
41/// - payoff, P&L and Greeks are per position, scaled by
42///   `quantity × contract_size`;
43/// - [`TradeAble::trade`] records the per-unit premium and copies the
44///   contract size into the [`Trade`], whose `cost`, `income` and `net` apply
45///   the same rules.
46///
47/// # Examples
48///
49/// ```rust
50/// # #[cfg(feature = "clock")] {
51/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
52/// use optionstratlib_core::model::{Options, Side, OptionStyle};
53/// use positive::pos_or_panic;
54/// use chrono::Utc;
55/// use tracing::info;
56/// use optionstratlib_core::model::Position;
57/// use optionstratlib_core::model::utils::create_sample_option_simplest;
58///
59/// let option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
60/// let position = Position::new(
61///     option,
62///     pos_or_panic!(5.25),           // premium per unit of the underlying
63///     Utc::now(),           // position open date
64///     pos_or_panic!(0.65),           // opening fee per contract
65///     pos_or_panic!(0.65),           // closing fee per contract
66///     None,
67///     None,
68/// );
69///
70/// let total_cost = position.total_cost()?;
71/// info!("Total position cost: {}", total_cost);
72/// # Ok(())
73/// # }
74/// # }
75/// ```
76#[derive(Clone, PartialEq, Serialize, Deserialize)]
77#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
78pub struct Position {
79    /// The detailed options contract information, including the type, strike price,
80    /// expiration, underlying asset details, and other option-specific parameters.
81    pub option: Options,
82
83    /// The premium paid or received, quoted per unit of the underlying (the
84    /// option price). For long positions, this represents the cost; for short
85    /// positions, this is the credit received. One contract pays or receives
86    /// `premium × option.contract_size`; with the default contract size of 1
87    /// that is the premium itself.
88    pub premium: Positive,
89
90    /// The date and time when the position was opened, used for calculating
91    /// time-based metrics like days held and days to expiration.
92    pub date: DateTime<Utc>,
93
94    /// The fee paid to open the position per contract. This typically includes
95    /// broker commissions and exchange fees. It scales by `option.quantity`
96    /// only, never by `option.contract_size`.
97    pub open_fee: Positive,
98
99    /// The fee that will be paid to close the position per contract. This is used
100    /// in profit/loss calculations to account for all transaction costs. It
101    /// scales by `option.quantity` only, never by `option.contract_size`.
102    pub close_fee: Positive,
103
104    /// Identifier for the position in an external system or platform
105    pub epic: Option<String>,
106
107    /// Additional custom data fields for the position stored as JSON
108    pub extra_fields: Option<serde_json::Value>,
109}
110
111impl Position {
112    /// The premium paid or received for one contract:
113    /// `premium × option.contract_size`.
114    ///
115    /// With the default contract size of 1 this is `premium` itself.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`PositionError::PositiveError`] when the product overflows the
120    /// `Positive` range.
121    #[inline]
122    fn premium_per_contract(&self) -> Result<Positive, PositionError> {
123        Ok(self.premium.checked_mul(&self.option.contract_size)?)
124    }
125
126    /// Creates a new options position.
127    ///
128    /// This constructor initializes a new `Position` instance representing an options trade,
129    /// capturing all essential information for position tracking and analysis.
130    ///
131    /// # Parameters
132    ///
133    /// * `option` - The options contract details including type (call/put), strike price,
134    ///   expiration date, underlying asset information, and other option parameters.
135    ///
136    /// * `premium` - The premium paid (for long positions) or received (for short positions)
137    ///   per contract, represented as a positive value.
138    ///
139    /// * `date` - The timestamp when the position was opened, used for calculating time-based
140    ///   metrics like days to expiration and position duration.
141    ///
142    /// * `open_fee` - The transaction costs paid to open the position per contract,
143    ///   including broker commissions and exchange fees.
144    ///
145    /// * `close_fee` - The anticipated transaction costs to close the position per contract,
146    ///   used for accurate profit/loss calculations.
147    ///
148    /// # Returns
149    ///
150    /// Returns a new `Position` instance containing the provided information.
151    ///
152    /// # Examples
153    ///
154    /// ```rust
155    /// # #[cfg(feature = "clock")] {
156    /// use optionstratlib_core::model::{Options, Side, OptionStyle};
157    /// use positive::pos_or_panic;
158    /// use chrono::Utc;
159    /// use optionstratlib_core::model::Position;
160    /// use optionstratlib_core::model::utils::create_sample_option_simplest;
161    ///
162    /// let option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
163    /// let position = Position::new(
164    ///     option,
165    ///     pos_or_panic!(5.25),           // premium per contract
166    ///     Utc::now(),           // position open date
167    ///     pos_or_panic!(0.65),           // opening fee per contract
168    ///     pos_or_panic!(0.65),           // closing fee per contract
169    ///    None,                  // epic (optional)
170    ///   None,                  // extra fields (optional)
171    /// );
172    /// # }
173    /// ```
174    #[must_use]
175    pub fn new(
176        option: Options,
177        premium: Positive,
178        date: DateTime<Utc>,
179        open_fee: Positive,
180        close_fee: Positive,
181        epic: Option<String>,
182        extra_fields: Option<serde_json::Value>,
183    ) -> Self {
184        Position {
185            option,
186            premium,
187            date,
188            open_fee,
189            close_fee,
190            epic,
191            extra_fields,
192        }
193    }
194
195    /// Calculates the total cost of the position based on the option's side and fees.
196    ///
197    /// Depending on whether the position is long or short, different components
198    /// contribute to the total cost calculation:
199    ///
200    /// - For a long position, the total cost includes the premium per contract
201    ///   (`premium × contract_size`), open fee, and close fee multiplied by the
202    ///   option's quantity.
203    /// - For a short position, the total cost includes only the open fee and close fee
204    ///   multiplied by the option's quantity; fees are per contract and do not
205    ///   scale by the contract size.
206    ///
207    /// # Returns
208    ///
209    /// A `Positive` representing the total cost of the position. THE VALUE IS ALWAYS POSITIVE
210    ///
211    /// # Errors
212    ///
213    /// Returns [`PositionError::PositiveError`] when accumulating the premium
214    /// and the two fees, scaling the premium by the contract size, or scaling
215    /// the accumulated per-contract cost by the contract quantity, overflows
216    /// the `Positive` range. The raw `Positive`
217    /// operators abort on overflow, so every step is taken through its
218    /// checked counterpart.
219    pub fn total_cost(&self) -> Result<Positive, PositionError> {
220        let total_cost = match self.option.side {
221            Side::Long => self
222                .premium_per_contract()?
223                .checked_add(&self.open_fee)?
224                .checked_add(&self.close_fee)?
225                .checked_mul(&self.option.quantity)?,
226            Side::Short => self.fees()?,
227        };
228
229        Ok(total_cost)
230    }
231
232    /// Calculates the premium received from an options position.
233    ///
234    /// This method determines the premium amount received based on the position's side:
235    /// - For long positions, it returns zero as the trader pays premium (doesn't receive any)
236    /// - For short positions, it returns the total premium received
237    ///   (`premium × contract_size × quantity`)
238    ///
239    /// The result is always returned as a `Positive` value, ensuring non-negative amounts.
240    ///
241    /// # Returns
242    ///
243    /// - `Result<Positive, PositionError>` - A result containing the premium received as a `Positive`
244    ///   value if successful, or a `PositionError` if any calculation errors occur.
245    ///
246    /// # Examples
247    ///
248    /// ```rust
249    /// # #[cfg(feature = "clock")] {
250    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
251    /// use optionstratlib_core::model::{Side, OptionStyle};
252    /// use positive::pos_or_panic;
253    /// use optionstratlib_core::model::Position;
254    /// use optionstratlib_core::model::utils::create_sample_option_simplest;
255    /// use chrono::Utc;
256    /// use tracing::info;
257    ///
258    /// // Create a short position
259    /// let option = create_sample_option_simplest(OptionStyle::Call, Side::Short);
260    /// let position = Position::new(
261    ///     option,
262    ///     pos_or_panic!(5.25),  // premium per contract
263    ///     Utc::now(),  // position open date
264    ///     pos_or_panic!(0.65),  // opening fee
265    ///     pos_or_panic!(0.65),  // closing fee
266    ///    None,        // epic (optional)
267    ///   None,        // extra fields (optional)
268    /// );
269    ///
270    /// // Calculate premium received
271    /// let received = position.premium_received()?;
272    /// info!("Premium received: {}", received);
273    /// # Ok(())
274    /// # }
275    /// # }
276    /// ```
277    ///
278    /// # Errors
279    ///
280    /// Returns [`PositionError::PositiveError`] when the short-side
281    /// `premium × contract_size × quantity` product overflows the `Positive`
282    /// range. Long
283    /// positions receive no premium and are infallible.
284    pub fn premium_received(&self) -> Result<Positive, PositionError> {
285        match self.option.side {
286            Side::Long => Ok(Positive::ZERO),
287            Side::Short => Ok(self
288                .premium_per_contract()?
289                .checked_mul(&self.option.quantity)?),
290        }
291    }
292
293    /// Calculates the net premium received for the position.
294    ///
295    /// This method determines the premium amount received after accounting for costs,
296    /// which is relevant primarily for short positions. For long positions, this always
297    /// returns zero as premium is paid rather than received.
298    ///
299    /// For short positions, the method calculates the difference between the premium
300    /// received and the total costs incurred. If this value is positive (meaning the
301    /// premium exceeds the costs), it represents the maximum potential profit for the
302    /// position. If negative, the position is considered invalid as it would represent
303    /// a guaranteed loss.
304    ///
305    /// # Returns
306    ///
307    /// - `Ok(Positive)` - The net premium received as a non-negative value
308    /// - `Err(PositionError)` - If the position is invalid because the premium received
309    ///   is less than the costs, resulting in a guaranteed loss
310    ///
311    /// # Errors
312    ///
313    /// Propagates any `PositionError` raised by `total_cost()`, and returns
314    /// [`PositionError::PositiveError`] when scaling the premium by the
315    /// contract size and quantity overflows the `Positive` range.
316    ///
317    /// When the short-side net amount `premium − total_cost` is
318    /// negative the function returns `Ok(Positive::ZERO)` (clamped)
319    /// rather than an error, so a guaranteed-loss short position is
320    /// reported as zero net received rather than as a failure.
321    pub fn net_premium_received(&self) -> Result<Positive, PositionError> {
322        match self.option.side {
323            Side::Long => Ok(Positive::ZERO),
324            Side::Short => {
325                // max profit is premium received - fees (cost)
326                let premium = self
327                    .premium_per_contract()?
328                    .checked_mul(&self.option.quantity)?;
329                let total_cost = self.total_cost()?;
330                if premium >= total_cost {
331                    Ok(premium.checked_sub(&total_cost)?)
332                } else {
333                    Ok(Positive::ZERO)
334                }
335            }
336        }
337    }
338
339    /// Calculates the profit and loss (PnL) at the option's expiration.
340    ///
341    /// This function determines the total profit or loss that would be realized
342    /// when the option position expires, taking into account the intrinsic value
343    /// at expiration, the cost to establish the position, and any premiums received.
344    ///
345    /// # Arguments
346    ///
347    /// * `price` - An optional reference to a positive decimal value representing
348    ///   the underlying asset price at expiration. If None is provided, the calculation
349    ///   will use the current underlying price stored in the option.
350    ///
351    /// # Returns
352    ///
353    /// * `Result<Decimal, PositionError>` - The calculated profit or loss as a Decimal value,
354    ///   or an error if the calculation fails.
355    ///
356    /// # Examples
357    ///
358    /// ```rust
359    /// # #[cfg(feature = "clock")] {
360    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
361    /// // Assuming position is a properly initialized Position
362    /// use chrono::Utc;
363    /// use optionstratlib_core::model::utils::create_sample_option_simplest;
364    /// use optionstratlib_core::model::{OptionStyle, Side};
365    /// use positive::pos_or_panic;
366    /// use optionstratlib_core::model::Position;
367    ///
368    /// let option = create_sample_option_simplest(OptionStyle::Call, Side::Short);
369    /// let position = Position::new(
370    ///     option,
371    ///     pos_or_panic!(5.25),  // premium per contract
372    ///     Utc::now(),  // position open date
373    ///     pos_or_panic!(0.65),  // opening fee
374    ///     pos_or_panic!(0.65),  // closing fee
375    ///    None,        // epic (optional)
376    ///   None,        // extra fields (optional)
377    /// );
378    /// let current_price = pos_or_panic!(105.0);
379    ///
380    /// // Calculate PnL at expiration with specified price
381    /// let pnl_specific = position.pnl_at_expiration(&Some(&current_price))?;
382    ///
383    /// // Calculate PnL at expiration using the option's current underlying price
384    /// let pnl_current = position.pnl_at_expiration(&None)?;
385    /// # Ok(())
386    /// # }
387    /// # }
388    /// ```
389    ///
390    /// # Errors
391    ///
392    /// Propagates any `OptionsError` returned by the underlying payoff
393    /// evaluation ([`Options::intrinsic_value`] or [`Options::payoff`]),
394    /// wrapped as `PositionError::Options`.
395    pub fn pnl_at_expiration(&self, price: &Option<&Positive>) -> Result<Decimal, PositionError> {
396        // P&L = intrinsic_value - total_cost + premium_received.
397        // All three terms are monetary and the composition surfaces the
398        // user-visible P&L, so the fused arithmetic goes through `d_add` /
399        // `d_sub` to trip `DecimalError::Overflow` instead of wrapping on a
400        // pathological cost basis or premium.
401        let intrinsic = match price {
402            None => self.option.intrinsic_value(self.option.underlying_price)?,
403            Some(price) => self.option.intrinsic_value(**price)?,
404        };
405        let cost = self.total_cost()?.to_dec();
406        let premium_recv = self.premium_received()?.to_dec();
407        let net_after_cost = d_sub(intrinsic, cost, "position::pnl_at_expiration::net")?;
408        d_add(
409            net_after_cost,
410            premium_recv,
411            "position::pnl_at_expiration::total",
412        )
413        .map_err(PositionError::from)
414    }
415
416    /// Calculates the unrealized profit and loss (PnL) for an options position at a given price.
417    ///
418    /// This method computes the current theoretical profit or loss of the position if it were
419    /// to be closed at the specified price, taking into account the premium paid/received and
420    /// all transaction fees (both opening and closing fees).
421    ///
422    /// The calculation differs based on the position side:
423    /// - For long positions: ((current_price - premium) * contract_size - open_fee - close_fee) * quantity
424    /// - For short positions: ((premium - current_price) * contract_size - open_fee - close_fee) * quantity
425    ///
426    /// Prices are quoted per unit of the underlying and fees per contract, so
427    /// only the price difference scales by the contract size.
428    ///
429    /// # Parameters
430    ///
431    /// * `price` - A `Positive` value representing the current price of the option,
432    ///   quoted per unit of the underlying like `premium`
433    ///
434    /// # Returns
435    ///
436    /// * `Result<Decimal, PositionError>` - The calculated unrealized PnL as a `Decimal` if successful,
437    ///   or a `PositionError` if the calculation fails
438    ///
439    /// # Example
440    ///
441    /// ```rust
442    /// # #[cfg(feature = "clock")] {
443    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
444    /// use chrono::Utc;
445    /// use tracing::info;
446    /// use optionstratlib_core::model::Position;
447    /// use optionstratlib_core::model::utils::create_sample_option_simplest;
448    /// use optionstratlib_core::model::{OptionStyle, Side};
449    /// use positive::pos_or_panic;
450    /// let current_price = pos_or_panic!(6.50);
451    /// let option = create_sample_option_simplest(OptionStyle::Call, Side::Short);
452    /// let position = Position::new(
453    ///     option,
454    ///     pos_or_panic!(5.25),  // premium per contract
455    ///     Utc::now(),  // position open date
456    ///     pos_or_panic!(0.65),  // opening fee
457    ///     pos_or_panic!(0.65),  // closing fee
458    ///    None,        // epic (optional)
459    ///   None,        // extra fields (optional)
460    /// );
461    /// let unrealized_pnl = position.unrealized_pnl(current_price)?;
462    /// info!("Current unrealized PnL: {}", unrealized_pnl);
463    /// # Ok(())
464    /// # }
465    /// # }
466    /// ```
467    ///
468    /// # Errors
469    ///
470    /// Returns [`PositionError::DecimalError`] when the premium and fee
471    /// deductions, or the scaling by the contract size and quantity, leave the
472    /// representable `Decimal` range. Nothing is repriced here: the result is
473    /// arithmetic over the stored premium, fees and quantity.
474    pub fn unrealized_pnl(&self, price: Positive) -> Result<Decimal, PositionError> {
475        // Per-contract P&L (Long: (price - premium) × contract_size - fees;
476        // Short: (premium - price) × contract_size - fees) then scaled by the
477        // contract quantity. Fees are per contract, so the contract size
478        // scales only the price difference. Each step is a
479        // monetary flow, so overflow surfaces a typed error rather than
480        // wrapping silently on Decimal::MIN or Decimal::MAX-class inputs.
481        let per_contract = match self.option.side {
482            Side::Long => {
483                let after_premium = d_mul(
484                    d_sub(
485                        price.to_dec(),
486                        self.premium.to_dec(),
487                        "position::unrealized_pnl::long::after_premium",
488                    )?,
489                    self.option.contract_size.to_dec(),
490                    "position::unrealized_pnl::long::contract_size",
491                )?;
492                let after_open = d_sub(
493                    after_premium,
494                    self.open_fee.to_dec(),
495                    "position::unrealized_pnl::long::after_open_fee",
496                )?;
497                d_sub(
498                    after_open,
499                    self.close_fee.to_dec(),
500                    "position::unrealized_pnl::long::net",
501                )?
502            }
503            Side::Short => {
504                let after_price = d_mul(
505                    d_sub(
506                        self.premium.to_dec(),
507                        price.to_dec(),
508                        "position::unrealized_pnl::short::after_price",
509                    )?,
510                    self.option.contract_size.to_dec(),
511                    "position::unrealized_pnl::short::contract_size",
512                )?;
513                let after_open = d_sub(
514                    after_price,
515                    self.open_fee.to_dec(),
516                    "position::unrealized_pnl::short::after_open_fee",
517                )?;
518                d_sub(
519                    after_open,
520                    self.close_fee.to_dec(),
521                    "position::unrealized_pnl::short::net",
522                )?
523            }
524        };
525        Ok(d_mul(
526            per_contract,
527            self.option.quantity.to_dec(),
528            "position::unrealized_pnl::scaled",
529        )?)
530    }
531
532    /// Calculates the number of days the position has been held.
533    ///
534    /// This method computes the difference between the current UTC date and the
535    /// position's opening date, returning the result as a `Positive` value.
536    ///
537    /// The calculation uses Chrono's `num_days` method to determine the precise
538    /// number of whole days between the position's date and current time.
539    ///
540    /// # Returns
541    ///
542    /// * `Ok(Positive)` - The number of days the position has been held as a positive value
543    /// * `Err(PositionError)` - If there's an error during the calculation or validation
544    ///
545    /// # Errors
546    ///
547    /// Returns [`PositionError`] wrapping a
548    /// `PositionValidationErrorKind::InvalidPositionSize` if the elapsed
549    /// day-count is negative (future-dated open date) or cannot be
550    /// represented as a `Positive`, and [`PositionError::Clock`] in a build with
551    /// neither the `clock` feature nor a reference datetime on this thread: see
552    /// [`Position::days_held_at`].
553    pub fn days_held(&self) -> Result<Positive, PositionError> {
554        self.days_held_at(evaluation_time()?)
555    }
556
557    /// Calculates the whole days the position has been held at the instant `at`.
558    ///
559    /// The pure form of [`Position::days_held`]: it reads no clock.
560    ///
561    /// # Errors
562    ///
563    /// Returns [`PositionError`] wrapping a
564    /// `PositionValidationErrorKind::InvalidPosition` if the elapsed day-count
565    /// is negative (`at` is before the open date) or cannot be represented as a
566    /// `Positive`.
567    pub fn days_held_at(&self, at: DateTime<Utc>) -> Result<Positive, PositionError> {
568        // `signed_duration_since` is total: any two `DateTime<Utc>` are
569        // within the `TimeDelta` range.
570        let days = at.signed_duration_since(self.date).num_days() as f64;
571        Positive::new(days).map_err(|e| {
572            PositionError::ValidationError(PositionValidationErrorKind::InvalidPosition {
573                reason: format!("failed to calculate days held: {}", e),
574            })
575        })
576    }
577
578    /// Calculates the number of days remaining until the option expires.
579    ///
580    /// This function determines the time to expiration in days based on the option's
581    /// expiration date format. It handles both explicit day counts and datetime-based
582    /// expiration dates.
583    ///
584    /// # Returns
585    ///
586    /// - `Ok(Positive)` - The number of days to expiration as a positive value
587    /// - `Err(PositionError)` - If the calculation fails due to issues with the position data
588    ///
589    /// For datetime-based expirations, the function calculates the difference between
590    /// the expiration date and the current date, converting the result to days.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`PositionError`] wrapping the underlying
595    /// [`expiration_date::error::ExpirationDateError`] when the expiration
596    /// cannot be converted (e.g. a past datetime that would produce a
597    /// negative day count). In a build with neither the `clock` feature nor a
598    /// reference datetime on this thread a `DateTime` expiration returns
599    /// [`PositionError::Clock`]: see [`Position::days_to_expiration_at`]. A `Days`
600    /// needs no clock.
601    pub fn days_to_expiration(&self) -> Result<Positive, PositionError> {
602        match self.option.expiration_date {
603            ExpirationDate::Days(days) => Ok(days),
604            ExpirationDate::DateTime(_) => self.days_to_expiration_at(evaluation_time()?),
605        }
606    }
607
608    /// Calculates the whole days remaining until the option expires, at the
609    /// instant `at`.
610    ///
611    /// The pure form of [`Position::days_to_expiration`]: it reads no clock.
612    ///
613    /// # Errors
614    ///
615    /// Returns [`PositionError`] when the day count cannot be represented as a
616    /// `Positive`.
617    pub fn days_to_expiration_at(&self, at: DateTime<Utc>) -> Result<Positive, PositionError> {
618        match self.option.expiration_date {
619            ExpirationDate::Days(days) => Ok(days),
620            ExpirationDate::DateTime(datetime) => {
621                let days = datetime.signed_duration_since(at).num_days() as f64;
622                Positive::new(days.max(0.0)).map_err(|e| {
623                    PositionError::ValidationError(PositionValidationErrorKind::InvalidPosition {
624                        reason: format!("failed to calculate days to expiration: {}", e),
625                    })
626                })
627            }
628        }
629    }
630
631    /// The default position opened at `date`: no premium or fees, and the
632    /// default option.
633    ///
634    /// What [`Default`] gives with the `clock` feature, without reading a clock.
635    #[must_use]
636    pub fn blank_at(date: DateTime<Utc>) -> Self {
637        Position {
638            option: Options::default(),
639            premium: Positive::ZERO,
640            date,
641            open_fee: Positive::ZERO,
642            close_fee: Positive::ZERO,
643            epic: None,
644            extra_fields: None,
645        }
646    }
647
648    /// Records this position as a trade stamped with `at`, as
649    /// [`TradeAble::trade`] does at the current time.
650    ///
651    /// The pure form of it: it reads no clock, and a `Days` expiration counts
652    /// from `at`, ignoring the thread's reference datetime.
653    ///
654    /// # Errors
655    ///
656    /// Returns [`TradeError::InvalidTrade`] when the fees or the per-contract
657    /// premium overflow, or when the expiration or `at` cannot be represented.
658    pub fn trade_at(&self, at: DateTime<Utc>) -> Result<Trade, TradeError> {
659        self.build_trade(self.option.expiration_date.get_date_at(at).ok(), at)
660    }
661
662    /// Assembles the trade, `expiry` being `None` if it could not be resolved.
663    fn build_trade(
664        &self,
665        expiry: Option<DateTime<Utc>>,
666        at: DateTime<Utc>,
667    ) -> Result<Trade, TradeError> {
668        let fee = self
669            .open_fee
670            .checked_add(&self.close_fee)
671            .map_err(|error| {
672                TradeError::invalid_trade(&format!(
673                    "open and close fees overflow the Positive range: {error}"
674                ))
675            })?;
676        // The trade records the per-unit premium and its own contract size, so
677        // it stays self-contained once the position is gone; `Trade::fee` is
678        // per contract already. The per-contract premium the trade derives
679        // must be representable.
680        self.premium_per_contract().map_err(|error| {
681            TradeError::invalid_trade(&format!(
682                "premium times contract size overflows the Positive range: {error}"
683            ))
684        })?;
685        if let (Some(expiry), Some(timestamp)) = (expiry, at.timestamp_nanos_opt()) {
686            Ok(Trade {
687                id: uuid::Uuid::new_v4(),
688                action: Action::Buy,
689                side: self.option.side,
690                option_style: self.option.option_style,
691                fee,
692                symbol: None,
693                strike: self.option.strike_price,
694                expiry,
695                timestamp,
696                quantity: self.option.quantity,
697                premium: self.premium,
698                contract_size: self.option.contract_size,
699                underlying_price: self.option.underlying_price,
700                notes: None,
701                status: TradeStatus::Other("Not yet initialized".to_string()),
702            })
703        } else {
704            Err(TradeError::invalid_trade(
705                "Could not create trade from position",
706            ))
707        }
708    }
709
710    /// Determines if the position is a long position.
711    ///
712    /// This method checks the side attribute of the option to determine the directionality
713    /// of the position. Long positions profit when the underlying asset's price increases.
714    ///
715    /// # Returns
716    ///
717    /// * `true` if the position is long
718    /// * `false` if the position is short
719    ///
720    #[must_use]
721    pub fn is_long(&self) -> bool {
722        match self.option.side {
723            Side::Long => true,
724            Side::Short => false,
725        }
726    }
727
728    /// Determines if the position is a short position.
729    ///
730    /// This method checks the side attribute of the option to determine the directionality
731    /// of the position. Short positions profit when the underlying asset's price decreases.
732    ///
733    /// # Returns
734    ///
735    /// * `true` if the position is short
736    /// * `false` if the position is long
737    ///
738    #[must_use]
739    pub fn is_short(&self) -> bool {
740        match self.option.side {
741            Side::Long => false,
742            Side::Short => true,
743        }
744    }
745
746    /// Calculates the net cost of the position based on the option's side and fees.
747    ///
748    /// This method calculates the net cost of a position by determining whether the position
749    /// is long or short and then computing the respective costs:
750    ///
751    /// - For a long position, the net cost is equivalent to the `total_cost()` of the position.
752    /// - For a short position, the net cost is calculated by subtracting the premium from the
753    ///   sum of the open and close fees, and then multiplying the result by the option's quantity.
754    ///
755    /// # Returns
756    ///
757    /// A `Decimal` representing the net cost of the position.
758    /// The value should be positive but if the fee is higher than the premium it will be negative
759    /// in short positions
760    ///
761    /// # Errors
762    ///
763    /// Propagates the [`PositionError::PositiveError`] raised by `total_cost`,
764    /// `fees` and `premium_received` when the fee or premium accumulation, or
765    /// its product with the contract quantity, overflows the `Positive` range.
766    pub fn net_cost(&self) -> Result<Decimal, PositionError> {
767        match self.option.side {
768            Side::Long => Ok(self.total_cost()?.to_dec()),
769            Side::Short => {
770                let fees = self.fees()?.to_dec();
771                let premium = self.premium_received()?.to_dec();
772                Ok(d_sub(fees, premium, "Position::net_cost/short")?)
773            }
774        }
775    }
776
777    /// Calculates the break-even price for an options position.
778    ///
779    /// This method determines the price of the underlying asset at which the position
780    /// will neither make a profit nor a loss. The calculation varies based on both the
781    /// side of the position (Long/Short) and the option style (Call/Put).
782    ///
783    /// The break-even price is an important reference point for options traders as it
784    /// represents the threshold price that the underlying asset must cross for the
785    /// position to become profitable, accounting for all costs associated with the position.
786    ///
787    /// # Formula by position type:
788    /// - Long Call: Strike Price + Total Cost per Unit
789    /// - Short Call: Strike Price + Premium - Total Cost per Unit
790    /// - Long Put: Strike Price - Total Cost per Unit
791    /// - Short Put: Strike Price - Premium + Total Cost per Unit
792    ///
793    /// The total cost per unit is the position total cost divided by the
794    /// position size (`quantity × contract_size`), so per-contract fees are
795    /// spread over the units one contract covers.
796    ///
797    /// # Returns
798    ///
799    /// - `Some(Positive)` containing the break-even price if the position has non-zero quantity
800    /// - `None` if the position has zero quantity (no contracts), if the position total
801    ///   cost cannot be calculated, or if the break-even price is not representable as a
802    ///   `Positive` — a cost per contract above the strike puts it below zero, and a cost
803    ///   per contract near `Positive::MAX` overflows the sum
804    ///
805    #[must_use]
806    pub fn break_even(&self) -> Option<Positive> {
807        if self.option.quantity == Positive::ZERO {
808            return None;
809        }
810        let position_total_cost = self.total_cost().ok()?;
811        // The raw `Positive` operators abort on overflow and on a difference
812        // that would go below zero; a long put whose cost per contract exceeds
813        // its strike reaches the latter with entirely ordinary numbers.
814        let position_size = self.option.position_size().ok()?;
815        let total_cost_per_unit = position_total_cost.checked_div(&position_size).ok()?;
816        match (&self.option.side, &self.option.option_style) {
817            (Side::Long, OptionStyle::Call) => self
818                .option
819                .strike_price
820                .checked_add(&total_cost_per_unit)
821                .ok(),
822            (Side::Short, OptionStyle::Call) => self
823                .option
824                .strike_price
825                .checked_add(&self.premium)
826                .and_then(|total| total.checked_sub(&total_cost_per_unit))
827                .ok(),
828            (Side::Long, OptionStyle::Put) => self
829                .option
830                .strike_price
831                .checked_sub(&total_cost_per_unit)
832                .ok(),
833            (Side::Short, OptionStyle::Put) => self
834                .option
835                .strike_price
836                .checked_sub(&self.premium)
837                .and_then(|net| net.checked_add(&total_cost_per_unit))
838                .ok(),
839        }
840    }
841
842    /// Calculates the maximum potential profit for an options position.
843    ///
844    /// This method determines the maximum possible profit based on the position's side:
845    /// - For long positions (buying options), the profit potential is theoretically unlimited,
846    ///   as the underlying asset could increase in value indefinitely.
847    /// - For short positions (selling options), the maximum profit is capped at the net premium
848    ///   received after accounting for all costs.
849    ///
850    /// # Returns
851    ///
852    /// - `Ok(Positive)` - The maximum potential profit as a positive value
853    /// - `Err(PositionError)` - If there's an issue calculating the maximum profit
854    #[allow(dead_code)]
855    pub(crate) fn max_profit(&self) -> Result<Positive, PositionError> {
856        match self.option.side {
857            Side::Long => Ok(Positive::MAX),
858            Side::Short => self.net_premium_received(),
859        }
860    }
861
862    /// Calculates the maximum potential loss for an options position.
863    ///
864    /// This method determines the maximum possible loss based on the position's side:
865    /// - For long positions (buying options), the maximum loss is limited to the total cost
866    ///   of entering the position (premium paid plus all fees).
867    /// - For short positions (selling options), the loss potential is theoretically unlimited,
868    ///   as the underlying asset could increase in value indefinitely.
869    ///
870    /// # Returns
871    ///
872    /// - `Ok(Positive)` - The maximum potential loss as a positive value
873    /// - `Err(PositionError)` - If there's an issue calculating the maximum loss
874    #[allow(dead_code)]
875    pub(crate) fn max_loss(&self) -> Result<Positive, PositionError> {
876        match self.option.side {
877            Side::Long => self.total_cost(),
878            Side::Short => Ok(Positive::MAX),
879        }
880    }
881
882    /// Calculates the total transaction fees for the position.
883    ///
884    /// This method computes the sum of opening and closing fees for the position,
885    /// scaled by the quantity of options contracts. These fees typically include
886    /// broker commissions, exchange fees, and other transaction costs.
887    ///
888    /// # Returns
889    ///
890    /// - `Ok(Positive)` - The total fees as a positive value
891    /// - `Err(PositionError)` - If there's an issue calculating the fees
892    ///
893    /// # Errors
894    ///
895    /// Returns [`PositionError::PositiveError`] when adding the two fees, or
896    /// scaling their sum by the contract quantity, overflows the `Positive`
897    /// range. The raw `Positive` operators abort on overflow, so both steps
898    /// are taken through their checked counterparts.
899    pub fn fees(&self) -> Result<Positive, PositionError> {
900        Ok(self
901            .open_fee
902            .checked_add(&self.close_fee)?
903            .checked_mul(&self.option.quantity)?)
904    }
905
906    /// Validates the position to ensure it meets all necessary conditions for trading.
907    ///
908    /// This method performs a series of checks to determine if the position is valid:
909    /// 1. For short positions, verifies that:
910    ///    - Premium is greater than zero
911    ///    - Premium exceeds the sum of opening and closing fees
912    /// 2. Validates the underlying option parameters
913    ///
914    /// # Returns
915    ///
916    /// * `true` if the position is valid and meets all conditions
917    /// * `false` otherwise, with specific failure reasons logged via debug messages
918    ///
919    /// # Examples
920    ///
921    /// ```rust
922    /// # #[cfg(feature = "clock")] {
923    /// use optionstratlib_core::model::{Position, Options};
924    /// use optionstratlib_core::model::{Side, OptionStyle};
925    /// use positive::pos_or_panic;
926    /// use optionstratlib_core::model::utils::create_sample_option_simplest;
927    /// use chrono::Utc;
928    ///
929    /// // Create a valid position
930    /// let option = create_sample_option_simplest(OptionStyle::Call, Side::Long);
931    /// let position = Position::new(
932    ///     option,
933    ///     pos_or_panic!(5.25),
934    ///     Utc::now(),
935    ///     pos_or_panic!(0.65),
936    ///     pos_or_panic!(0.65),
937    ///     None, // epic (optional)
938    ///    None, // extra fields (optional)
939    /// );
940    ///
941    /// assert!(position.validate());
942    /// # }
943    /// ```
944    pub fn validate(&self) -> bool {
945        if self.option.side == Side::Short && self.premium == Positive::ZERO {
946            debug!("Premium must be greater than zero for short positions.");
947            return false;
948        }
949        if !self.option.validate() {
950            debug!("Option is not valid.");
951            return false;
952        }
953        true
954    }
955}
956
957#[cfg(feature = "clock")]
958impl Default for Position {
959    fn default() -> Self {
960        Self::blank_at(wall_clock())
961    }
962}
963
964impl TradeAble for Position {
965    fn trade(&self) -> Result<Trade, TradeError> {
966        // The expiration resolves as `ExpirationDate::get_date` does, from the
967        // reference datetime when one is set; `trade_at` takes its base as an
968        // argument instead.
969        self.build_trade(
970            resolve_expiration_date(&self.option.expiration_date).ok(),
971            now()?,
972        )
973    }
974
975    fn trade_ref(&self) -> Result<&Trade, TradeError> {
976        Err(TradeError::invalid_trade(
977            "trade_ref() is not implemented for Position",
978        ))
979    }
980
981    fn trade_mut(&mut self) -> Result<&mut Trade, TradeError> {
982        Err(TradeError::invalid_trade(
983            "trade_mut() is not implemented for Position",
984        ))
985    }
986}
987
988impl TradeStatusAble for Position {
989    fn open(&self) -> Result<Trade, TradeError> {
990        let mut trade = self.trade()?;
991        trade.status = TradeStatus::Open;
992        Ok(trade)
993    }
994
995    fn close(&self) -> Result<Trade, TradeError> {
996        let mut trade = self.trade()?;
997        // Compare directly against the `Decimal` literal because
998        // `trade.premium` already stores a `Decimal` internally. Reading
999        // that value with `to_dec()` avoids an unnecessary conversion
1000        // through `Positive` just to check whether the premium should be
1001        // treated as effectively zero.
1002        if trade.premium.to_dec() <= rust_decimal_macros::dec!(0.01) {
1003            trade.premium = Positive::ZERO;
1004        }
1005        trade.status = TradeStatus::Closed;
1006        trade.action = Action::Sell;
1007        Ok(trade)
1008    }
1009
1010    fn expired(&self) -> Result<Trade, TradeError> {
1011        let mut trade = self.trade()?;
1012        trade.status = TradeStatus::Expired;
1013        trade.action = Action::Sell;
1014        Ok(trade)
1015    }
1016
1017    fn exercised(&self) -> Result<Trade, TradeError> {
1018        let mut trade = self.trade()?;
1019        trade.status = TradeStatus::Exercised;
1020        trade.action = Action::Sell;
1021        Ok(trade)
1022    }
1023
1024    fn assigned(&self) -> Result<Trade, TradeError> {
1025        let mut trade = self.trade()?;
1026        trade.status = TradeStatus::Assigned;
1027        trade.action = Action::Other;
1028        Ok(trade)
1029    }
1030
1031    fn status_other(&self) -> Result<Trade, TradeError> {
1032        let mut trade = self.trade()?;
1033        trade.status = TradeStatus::Other("Not yet initialized".to_string());
1034        trade.action = Action::Other;
1035        Ok(trade)
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests_position {
1041    #[cfg(feature = "clock")]
1042    use super::*;
1043
1044    #[cfg(feature = "clock")]
1045    use crate::constants::ZERO;
1046    #[cfg(feature = "clock")]
1047    use crate::model::types::{OptionStyle, OptionType, Side};
1048
1049    #[cfg(feature = "clock")]
1050    use chrono::Duration;
1051    #[cfg(feature = "clock")]
1052    use num_traits::ToPrimitive;
1053    #[cfg(feature = "clock")]
1054    use positive::pos_or_panic;
1055    #[cfg(feature = "clock")]
1056    use rust_decimal_macros::dec;
1057
1058    #[cfg(feature = "clock")]
1059    fn setup_option(
1060        side: Side,
1061        option_style: OptionStyle,
1062        strike_price: Positive,
1063        underlying_price: Positive,
1064        quantity: Positive,
1065        expiration_days: Positive,
1066    ) -> Options {
1067        Options {
1068            option_type: OptionType::European,
1069            side,
1070            underlying_symbol: "".to_string(),
1071            strike_price,
1072            expiration_date: ExpirationDate::Days(expiration_days),
1073            implied_volatility: pos_or_panic!(0.2),
1074            quantity,
1075            contract_size: Positive::ONE,
1076            underlying_price,
1077            risk_free_rate: dec!(0.01),
1078            option_style,
1079            dividend_yield: Positive::ZERO,
1080            exotic_params: None,
1081        }
1082    }
1083
1084    #[cfg(feature = "clock")]
1085    #[test]
1086    fn test_position_total_cost() {
1087        let option = setup_option(
1088            Side::Long,
1089            OptionStyle::Call,
1090            Positive::HUNDRED,
1091            pos_or_panic!(105.0),
1092            Positive::ONE,
1093            pos_or_panic!(30.0),
1094        );
1095        let position = Position::new(
1096            option,
1097            pos_or_panic!(5.0),
1098            Utc::now(),
1099            Positive::ONE,
1100            Positive::ONE,
1101            None,
1102            None,
1103        );
1104        assert_eq!(
1105            position.total_cost().unwrap(),
1106            7.0,
1107            "Total cost calculation is incorrect."
1108        );
1109    }
1110
1111    #[cfg(feature = "clock")]
1112    #[test]
1113    fn test_position_total_cost_size() {
1114        let option = setup_option(
1115            Side::Long,
1116            OptionStyle::Call,
1117            Positive::HUNDRED,
1118            pos_or_panic!(105.0),
1119            pos_or_panic!(10.0),
1120            pos_or_panic!(30.0),
1121        );
1122        let position = Position::new(
1123            option,
1124            pos_or_panic!(5.0),
1125            Utc::now(),
1126            Positive::ONE,
1127            Positive::ONE,
1128            None,
1129            None,
1130        );
1131        assert_eq!(
1132            position.total_cost().unwrap(),
1133            70.0,
1134            "Total cost calculation is incorrect."
1135        );
1136    }
1137
1138    #[cfg(feature = "clock")]
1139    #[test]
1140    fn test_position_total_cost_short() {
1141        let option = setup_option(
1142            Side::Short,
1143            OptionStyle::Call,
1144            Positive::HUNDRED,
1145            pos_or_panic!(105.0),
1146            Positive::ONE,
1147            pos_or_panic!(30.0),
1148        );
1149        let position = Position::new(
1150            option,
1151            pos_or_panic!(5.0),
1152            Utc::now(),
1153            Positive::ONE,
1154            Positive::ONE,
1155            None,
1156            None,
1157        );
1158        assert_eq!(
1159            position.total_cost().unwrap(),
1160            2.0,
1161            "Total cost calculation is incorrect."
1162        );
1163    }
1164
1165    #[cfg(feature = "clock")]
1166    #[test]
1167    fn test_position_total_cost_short_size() {
1168        let option = setup_option(
1169            Side::Short,
1170            OptionStyle::Call,
1171            Positive::HUNDRED,
1172            pos_or_panic!(105.0),
1173            pos_or_panic!(10.0),
1174            pos_or_panic!(30.0),
1175        );
1176        let position = Position::new(
1177            option,
1178            pos_or_panic!(5.0),
1179            Utc::now(),
1180            Positive::ONE,
1181            Positive::ONE,
1182            None,
1183            None,
1184        );
1185        assert_eq!(
1186            position.total_cost().unwrap(),
1187            20.0,
1188            "Total cost calculation is incorrect."
1189        );
1190    }
1191
1192    #[cfg(feature = "clock")]
1193    #[test]
1194    fn test_position_check_negative_premium() {
1195        let option = setup_option(
1196            Side::Long,
1197            OptionStyle::Call,
1198            Positive::HUNDRED,
1199            pos_or_panic!(110.0),
1200            Positive::ONE,
1201            Positive::ZERO,
1202        );
1203        let position = Position::new(
1204            option,
1205            pos_or_panic!(5.0),
1206            Utc::now(),
1207            Positive::ONE,
1208            Positive::ONE,
1209            None,
1210            None,
1211        );
1212        assert_eq!(
1213            position.pnl_at_expiration(&None).unwrap(),
1214            dec!(3.0),
1215            "PNL at expiration for long call ITM is incorrect."
1216        );
1217    }
1218
1219    #[cfg(feature = "clock")]
1220    #[test]
1221    fn test_position_pnl_at_expiration_long_call_itm() {
1222        let option = setup_option(
1223            Side::Long,
1224            OptionStyle::Call,
1225            Positive::HUNDRED,
1226            pos_or_panic!(110.0),
1227            Positive::ONE,
1228            Positive::ZERO,
1229        );
1230        let position = Position::new(
1231            option,
1232            pos_or_panic!(5.0),
1233            Utc::now(),
1234            Positive::ONE,
1235            Positive::ONE,
1236            None,
1237            None,
1238        );
1239        assert_eq!(
1240            position.pnl_at_expiration(&None).unwrap(),
1241            dec!(3.0),
1242            "PNL at expiration for long call ITM is incorrect."
1243        );
1244    }
1245
1246    #[cfg(feature = "clock")]
1247    #[test]
1248    fn test_position_pnl_at_expiration_long_call_itm_quantity() {
1249        let option = setup_option(
1250            Side::Long,
1251            OptionStyle::Call,
1252            Positive::HUNDRED,
1253            pos_or_panic!(110.0),
1254            pos_or_panic!(10.0),
1255            Positive::ZERO,
1256        );
1257        let position = Position::new(
1258            option,
1259            pos_or_panic!(5.0),
1260            Utc::now(),
1261            Positive::ONE,
1262            Positive::ONE,
1263            None,
1264            None,
1265        );
1266        assert_eq!(
1267            position.pnl_at_expiration(&None).unwrap(),
1268            dec!(30.0),
1269            "PNL at expiration for long call ITM is incorrect."
1270        );
1271    }
1272
1273    #[cfg(feature = "clock")]
1274    #[test]
1275    fn test_position_pnl_at_expiration_short_call_itm() {
1276        let option = setup_option(
1277            Side::Short,
1278            OptionStyle::Call,
1279            Positive::HUNDRED,
1280            pos_or_panic!(110.0),
1281            Positive::ONE,
1282            Positive::ZERO,
1283        );
1284        let position = Position::new(
1285            option,
1286            pos_or_panic!(5.0),
1287            Utc::now(),
1288            Positive::ONE,
1289            Positive::ONE,
1290            None,
1291            None,
1292        );
1293        assert_eq!(
1294            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1295            -7.0,
1296            "PNL at expiration for short call ITM is incorrect."
1297        );
1298    }
1299
1300    #[cfg(feature = "clock")]
1301    #[test]
1302    fn test_position_pnl_at_expiration_short_call_itm_quantity() {
1303        let option = setup_option(
1304            Side::Short,
1305            OptionStyle::Call,
1306            Positive::HUNDRED,
1307            pos_or_panic!(110.0),
1308            pos_or_panic!(10.0),
1309            Positive::ZERO,
1310        );
1311        let position = Position::new(
1312            option,
1313            pos_or_panic!(5.0),
1314            Utc::now(),
1315            Positive::ONE,
1316            Positive::ONE,
1317            None,
1318            None,
1319        );
1320        assert_eq!(
1321            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1322            -70.0,
1323            "PNL at expiration for short call ITM is incorrect."
1324        );
1325    }
1326
1327    #[cfg(feature = "clock")]
1328    #[test]
1329    fn test_position_pnl_at_expiration_long_put_itm() {
1330        let option = setup_option(
1331            Side::Long,
1332            OptionStyle::Put,
1333            Positive::HUNDRED,
1334            pos_or_panic!(90.0),
1335            Positive::ONE,
1336            Positive::ZERO,
1337        );
1338        let position = Position::new(
1339            option,
1340            pos_or_panic!(5.0),
1341            Utc::now(),
1342            Positive::ONE,
1343            Positive::ONE,
1344            None,
1345            None,
1346        );
1347        assert_eq!(
1348            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1349            3.0,
1350            "PNL at expiration for long put ITM is incorrect."
1351        );
1352    }
1353
1354    #[cfg(feature = "clock")]
1355    #[test]
1356    fn test_position_pnl_at_expiration_long_put_itm_quantity() {
1357        let option = setup_option(
1358            Side::Long,
1359            OptionStyle::Put,
1360            Positive::HUNDRED,
1361            pos_or_panic!(90.0),
1362            pos_or_panic!(10.0),
1363            Positive::ZERO,
1364        );
1365        let position = Position::new(
1366            option,
1367            pos_or_panic!(5.0),
1368            Utc::now(),
1369            Positive::ONE,
1370            Positive::ONE,
1371            None,
1372            None,
1373        );
1374        assert_eq!(
1375            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1376            30.0,
1377            "PNL at expiration for long put ITM is incorrect."
1378        );
1379    }
1380
1381    #[cfg(feature = "clock")]
1382    #[test]
1383    fn test_position_pnl_at_expiration_short_put_itm() {
1384        let option = setup_option(
1385            Side::Short,
1386            OptionStyle::Put,
1387            Positive::HUNDRED,
1388            pos_or_panic!(90.0),
1389            Positive::ONE,
1390            Positive::ZERO,
1391        );
1392        let position = Position::new(
1393            option,
1394            pos_or_panic!(5.0),
1395            Utc::now(),
1396            Positive::ONE,
1397            Positive::ONE,
1398            None,
1399            None,
1400        );
1401        assert_eq!(
1402            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1403            -7.0,
1404            "PNL at expiration for short put ITM is incorrect."
1405        );
1406    }
1407
1408    #[cfg(feature = "clock")]
1409    #[test]
1410    fn test_position_pnl_at_expiration_short_put_itm_quantity() {
1411        let option = setup_option(
1412            Side::Short,
1413            OptionStyle::Put,
1414            Positive::HUNDRED,
1415            pos_or_panic!(90.0),
1416            pos_or_panic!(10.0),
1417            Positive::ZERO,
1418        );
1419        let position = Position::new(
1420            option,
1421            pos_or_panic!(5.0),
1422            Utc::now(),
1423            Positive::ONE,
1424            Positive::ONE,
1425            None,
1426            None,
1427        );
1428        assert_eq!(
1429            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1430            -70.0,
1431            "PNL at expiration for short put ITM is incorrect."
1432        );
1433    }
1434
1435    #[cfg(feature = "clock")]
1436    #[test]
1437    fn test_position_pnl_at_expiration_short_put_itm_winning() {
1438        let option = setup_option(
1439            Side::Short,
1440            OptionStyle::Put,
1441            Positive::HUNDRED,
1442            pos_or_panic!(110.0),
1443            Positive::ONE,
1444            Positive::ZERO,
1445        );
1446        let position = Position::new(
1447            option,
1448            pos_or_panic!(5.0),
1449            Utc::now(),
1450            Positive::ONE,
1451            Positive::ONE,
1452            None,
1453            None,
1454        );
1455        assert_eq!(
1456            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1457            3.0,
1458            "PNL at expiration for short put ITM is incorrect."
1459        );
1460    }
1461
1462    #[cfg(feature = "clock")]
1463    #[test]
1464    fn test_position_pnl_at_expiration_short_put_itm_quantity_winning() {
1465        let option = setup_option(
1466            Side::Short,
1467            OptionStyle::Put,
1468            Positive::HUNDRED,
1469            pos_or_panic!(110.0),
1470            pos_or_panic!(10.0),
1471            Positive::ZERO,
1472        );
1473        let position = Position::new(
1474            option,
1475            pos_or_panic!(5.0),
1476            Utc::now(),
1477            Positive::ONE,
1478            Positive::ONE,
1479            None,
1480            None,
1481        );
1482        assert_eq!(
1483            position.pnl_at_expiration(&None).unwrap().to_f64().unwrap(),
1484            30.0,
1485            "PNL at expiration for short put ITM is incorrect."
1486        );
1487    }
1488
1489    #[cfg(feature = "clock")]
1490    #[test]
1491    fn test_unrealized_pnl_long_call() {
1492        let option = setup_option(
1493            Side::Long,
1494            OptionStyle::Call,
1495            Positive::HUNDRED,
1496            pos_or_panic!(105.0),
1497            Positive::ONE,
1498            pos_or_panic!(30.0),
1499        );
1500        let position = Position::new(
1501            option,
1502            pos_or_panic!(5.0),
1503            Utc::now(),
1504            Positive::ONE,
1505            Positive::ONE,
1506            None,
1507            None,
1508        );
1509        assert_eq!(
1510            position
1511                .pnl_at_expiration(&Some(&pos_or_panic!(107.0)))
1512                .unwrap(),
1513            Positive::ZERO,
1514            "Unrealized PNL for long call is incorrect."
1515        );
1516    }
1517
1518    #[cfg(feature = "clock")]
1519    #[test]
1520    fn test_unrealized_pnl_long_call_quantity() {
1521        let option = setup_option(
1522            Side::Long,
1523            OptionStyle::Call,
1524            Positive::HUNDRED,
1525            pos_or_panic!(105.0),
1526            pos_or_panic!(10.0),
1527            pos_or_panic!(30.0),
1528        );
1529        let position = Position::new(
1530            option,
1531            pos_or_panic!(5.0),
1532            Utc::now(),
1533            Positive::ONE,
1534            Positive::ONE,
1535            None,
1536            None,
1537        );
1538        assert_eq!(
1539            position
1540                .pnl_at_expiration(&Some(&pos_or_panic!(107.0)))
1541                .unwrap()
1542                .to_f64()
1543                .unwrap(),
1544            ZERO,
1545            "Unrealized PNL for long call is incorrect."
1546        );
1547    }
1548
1549    #[cfg(feature = "clock")]
1550    #[test]
1551    fn test_unrealized_pnl_short_call() {
1552        let option = setup_option(
1553            Side::Short,
1554            OptionStyle::Call,
1555            Positive::HUNDRED,
1556            pos_or_panic!(105.0),
1557            pos_or_panic!(10.0),
1558            pos_or_panic!(30.0),
1559        );
1560        let position = Position::new(
1561            option,
1562            pos_or_panic!(5.0),
1563            Utc::now(),
1564            Positive::ONE,
1565            Positive::ONE,
1566            None,
1567            None,
1568        );
1569        assert_eq!(
1570            position
1571                .unrealized_pnl(pos_or_panic!(3.0))
1572                .unwrap()
1573                .to_f64()
1574                .unwrap(),
1575            ZERO,
1576            "Unrealized PNL for short call is incorrect."
1577        );
1578    }
1579
1580    #[cfg(feature = "clock")]
1581    #[test]
1582    fn test_unrealized_pnl_short_call_bis() {
1583        let option = setup_option(
1584            Side::Short,
1585            OptionStyle::Call,
1586            Positive::HUNDRED,
1587            pos_or_panic!(105.0),
1588            Positive::ONE,
1589            pos_or_panic!(30.0),
1590        );
1591        let position = Position::new(
1592            option,
1593            pos_or_panic!(5.0),
1594            Utc::now(),
1595            Positive::ONE,
1596            Positive::ONE,
1597            None,
1598            None,
1599        );
1600        assert_eq!(
1601            position
1602                .unrealized_pnl(pos_or_panic!(10.0))
1603                .unwrap()
1604                .to_f64()
1605                .unwrap(),
1606            -7.0,
1607            "Unrealized PNL for short call is incorrect."
1608        );
1609    }
1610
1611    #[cfg(feature = "clock")]
1612    #[test]
1613    fn test_days_held() {
1614        let option = setup_option(
1615            Side::Long,
1616            OptionStyle::Call,
1617            Positive::HUNDRED,
1618            pos_or_panic!(105.0),
1619            pos_or_panic!(10.0),
1620            pos_or_panic!(30.0),
1621        );
1622        let date = Utc::now() - Duration::days(10);
1623        let position = Position::new(
1624            option,
1625            pos_or_panic!(5.0),
1626            date,
1627            Positive::ONE,
1628            Positive::ONE,
1629            None,
1630            None,
1631        );
1632        assert_eq!(
1633            position.days_held().unwrap().to_f64(),
1634            10.0,
1635            "Days held calculation is incorrect."
1636        );
1637    }
1638
1639    #[cfg(feature = "clock")]
1640    #[test]
1641    fn test_days_to_expiration() {
1642        let option = setup_option(
1643            Side::Long,
1644            OptionStyle::Call,
1645            Positive::HUNDRED,
1646            pos_or_panic!(105.0),
1647            pos_or_panic!(10.0),
1648            pos_or_panic!(30.0),
1649        );
1650        let position = Position::new(
1651            option,
1652            pos_or_panic!(5.0),
1653            Utc::now(),
1654            Positive::ONE,
1655            Positive::ONE,
1656            None,
1657            None,
1658        );
1659        assert_eq!(
1660            position.days_to_expiration().unwrap().to_f64(),
1661            30.0,
1662            "Days to expiration calculation is incorrect."
1663        );
1664    }
1665
1666    #[cfg(feature = "clock")]
1667    #[test]
1668    fn test_is_long_position() {
1669        let option = setup_option(
1670            Side::Long,
1671            OptionStyle::Call,
1672            Positive::HUNDRED,
1673            pos_or_panic!(105.0),
1674            pos_or_panic!(10.0),
1675            pos_or_panic!(30.0),
1676        );
1677        let position = Position::new(
1678            option,
1679            pos_or_panic!(5.0),
1680            Utc::now(),
1681            Positive::ONE,
1682            Positive::ONE,
1683            None,
1684            None,
1685        );
1686        assert!(
1687            position.is_long(),
1688            "is_long should return true for long positions."
1689        );
1690        assert!(
1691            !position.is_short(),
1692            "is_short should return false for long positions."
1693        );
1694    }
1695
1696    #[cfg(feature = "clock")]
1697    #[test]
1698    fn test_is_short_position() {
1699        let option = setup_option(
1700            Side::Short,
1701            OptionStyle::Call,
1702            Positive::HUNDRED,
1703            pos_or_panic!(105.0),
1704            pos_or_panic!(10.0),
1705            pos_or_panic!(30.0),
1706        );
1707        let position = Position::new(
1708            option,
1709            pos_or_panic!(5.0),
1710            Utc::now(),
1711            Positive::ONE,
1712            Positive::ONE,
1713            None,
1714            None,
1715        );
1716        assert!(
1717            position.is_short(),
1718            "is_short should return true for short positions."
1719        );
1720        assert!(
1721            !position.is_long(),
1722            "is_long should return false for short positions."
1723        );
1724    }
1725}
1726
1727#[cfg(all(test, feature = "clock"))]
1728mod tests_valid_position {
1729    use super::*;
1730
1731    use crate::model::utils::create_sample_position;
1732    use positive::pos_or_panic;
1733
1734    #[test]
1735    fn test_valid_position() {
1736        let position = create_sample_position(
1737            OptionStyle::Call,
1738            Side::Short,
1739            pos_or_panic!(90.0),
1740            Positive::ONE,
1741            pos_or_panic!(95.0),
1742            pos_or_panic!(0.2),
1743        );
1744        assert!(position.validate());
1745    }
1746
1747    #[test]
1748    fn test_zero_premium() {
1749        let mut position = create_sample_position(
1750            OptionStyle::Call,
1751            Side::Short,
1752            pos_or_panic!(90.0),
1753            Positive::ONE,
1754            pos_or_panic!(95.0),
1755            pos_or_panic!(0.2),
1756        );
1757        position.premium = Positive::ZERO;
1758        assert!(!position.validate());
1759    }
1760
1761    #[test]
1762    fn test_invalid_option() {
1763        let mut position = create_sample_position(
1764            OptionStyle::Call,
1765            Side::Short,
1766            pos_or_panic!(90.0),
1767            Positive::ONE,
1768            pos_or_panic!(95.0),
1769            pos_or_panic!(0.2),
1770        );
1771        position.option.strike_price = Positive::ZERO; // This makes the option invalid
1772        assert!(!position.validate());
1773    }
1774
1775    /// A position on an option priced at a negative rate is valid (#709).
1776    #[test]
1777    fn test_position_validate_negative_risk_free_rate_is_valid() {
1778        let mut position = create_sample_position(
1779            OptionStyle::Call,
1780            Side::Short,
1781            pos_or_panic!(90.0),
1782            Positive::ONE,
1783            pos_or_panic!(95.0),
1784            pos_or_panic!(0.2),
1785        );
1786        position.option.risk_free_rate = rust_decimal_macros::dec!(-0.01);
1787        assert!(position.validate());
1788    }
1789
1790    #[test]
1791    fn test_zero_fees() {
1792        let mut position = create_sample_position(
1793            OptionStyle::Call,
1794            Side::Short,
1795            pos_or_panic!(90.0),
1796            Positive::ONE,
1797            pos_or_panic!(95.0),
1798            pos_or_panic!(0.2),
1799        );
1800        position.open_fee = Positive::ZERO;
1801        position.close_fee = Positive::ZERO;
1802        assert!(position.validate());
1803    }
1804}
1805
1806#[cfg(test)]
1807mod tests_position_break_even {
1808    use super::*;
1809
1810    use crate::model::types::{OptionStyle, OptionType, Side};
1811
1812    use positive::pos_or_panic;
1813    use rust_decimal_macros::dec;
1814
1815    fn setup_option(
1816        side: Side,
1817        option_style: OptionStyle,
1818        strike_price: Positive,
1819        underlying_price: Positive,
1820        quantity: Positive,
1821        expiration_days: Positive,
1822    ) -> Options {
1823        Options {
1824            option_type: OptionType::European,
1825            side,
1826            underlying_symbol: "".to_string(),
1827            strike_price,
1828            expiration_date: ExpirationDate::Days(expiration_days),
1829            implied_volatility: pos_or_panic!(0.2),
1830            quantity,
1831            contract_size: Positive::ONE,
1832            underlying_price,
1833            risk_free_rate: dec!(0.01),
1834            option_style,
1835            dividend_yield: Positive::ZERO,
1836            exotic_params: None,
1837        }
1838    }
1839
1840    #[test]
1841    fn test_unrealized_pnl_long_call() {
1842        let option = setup_option(
1843            Side::Long,
1844            OptionStyle::Call,
1845            Positive::HUNDRED,
1846            pos_or_panic!(105.0),
1847            Positive::ONE,
1848            pos_or_panic!(30.0),
1849        );
1850        let position = Position::new(
1851            option,
1852            pos_or_panic!(5.0),
1853            chrono::DateTime::UNIX_EPOCH,
1854            Positive::ONE,
1855            Positive::ONE,
1856            None,
1857            None,
1858        );
1859        assert_eq!(position.break_even().unwrap(), 107.0);
1860    }
1861
1862    #[test]
1863    fn test_unrealized_pnl_long_call_size() {
1864        let option = setup_option(
1865            Side::Long,
1866            OptionStyle::Call,
1867            Positive::HUNDRED,
1868            pos_or_panic!(105.0),
1869            pos_or_panic!(10.0),
1870            pos_or_panic!(30.0),
1871        );
1872        let position = Position::new(
1873            option,
1874            pos_or_panic!(5.0),
1875            chrono::DateTime::UNIX_EPOCH,
1876            Positive::ONE,
1877            Positive::ONE,
1878            None,
1879            None,
1880        );
1881        assert_eq!(position.break_even().unwrap(), 107.0);
1882    }
1883
1884    #[test]
1885    fn test_unrealized_pnl_short_call() {
1886        let option = setup_option(
1887            Side::Short,
1888            OptionStyle::Call,
1889            Positive::HUNDRED,
1890            pos_or_panic!(105.0),
1891            Positive::ONE,
1892            pos_or_panic!(30.0),
1893        );
1894        let position = Position::new(
1895            option,
1896            pos_or_panic!(5.0),
1897            chrono::DateTime::UNIX_EPOCH,
1898            Positive::ONE,
1899            Positive::ONE,
1900            None,
1901            None,
1902        );
1903        assert_eq!(position.break_even().unwrap(), 103.0);
1904    }
1905
1906    #[test]
1907    fn test_unrealized_pnl_short_call_size() {
1908        let option = setup_option(
1909            Side::Short,
1910            OptionStyle::Call,
1911            Positive::HUNDRED,
1912            pos_or_panic!(105.0),
1913            pos_or_panic!(10.0),
1914            pos_or_panic!(30.0),
1915        );
1916        let position = Position::new(
1917            option,
1918            pos_or_panic!(5.0),
1919            chrono::DateTime::UNIX_EPOCH,
1920            Positive::ONE,
1921            Positive::ONE,
1922            None,
1923            None,
1924        );
1925        assert_eq!(position.break_even().unwrap(), 103.0);
1926    }
1927
1928    #[test]
1929    fn test_unrealized_pnl_long_put() {
1930        let option = setup_option(
1931            Side::Long,
1932            OptionStyle::Put,
1933            Positive::HUNDRED,
1934            pos_or_panic!(105.0),
1935            Positive::ONE,
1936            pos_or_panic!(30.0),
1937        );
1938        let position = Position::new(
1939            option,
1940            pos_or_panic!(5.0),
1941            chrono::DateTime::UNIX_EPOCH,
1942            Positive::ONE,
1943            Positive::ONE,
1944            None,
1945            None,
1946        );
1947        assert_eq!(position.break_even().unwrap(), 93.0);
1948    }
1949
1950    #[test]
1951    fn test_unrealized_pnl_long_put_size() {
1952        let option = setup_option(
1953            Side::Long,
1954            OptionStyle::Put,
1955            Positive::HUNDRED,
1956            pos_or_panic!(105.0),
1957            pos_or_panic!(10.0),
1958            pos_or_panic!(30.0),
1959        );
1960        let position = Position::new(
1961            option,
1962            pos_or_panic!(5.0),
1963            chrono::DateTime::UNIX_EPOCH,
1964            Positive::ONE,
1965            Positive::ONE,
1966            None,
1967            None,
1968        );
1969        assert_eq!(position.break_even().unwrap(), 93.0);
1970    }
1971
1972    #[test]
1973    fn test_unrealized_pnl_short_put() {
1974        let option = setup_option(
1975            Side::Short,
1976            OptionStyle::Put,
1977            Positive::HUNDRED,
1978            pos_or_panic!(105.0),
1979            Positive::ONE,
1980            pos_or_panic!(30.0),
1981        );
1982        let position = Position::new(
1983            option,
1984            pos_or_panic!(5.0),
1985            chrono::DateTime::UNIX_EPOCH,
1986            Positive::ONE,
1987            Positive::ONE,
1988            None,
1989            None,
1990        );
1991        assert_eq!(position.break_even().unwrap(), 97.0);
1992    }
1993
1994    #[test]
1995    fn test_unrealized_pnl_short_put_size() {
1996        let option = setup_option(
1997            Side::Short,
1998            OptionStyle::Put,
1999            Positive::HUNDRED,
2000            pos_or_panic!(105.0),
2001            pos_or_panic!(10.0),
2002            pos_or_panic!(30.0),
2003        );
2004        let position = Position::new(
2005            option,
2006            pos_or_panic!(5.0),
2007            chrono::DateTime::UNIX_EPOCH,
2008            Positive::ONE,
2009            Positive::ONE,
2010            None,
2011            None,
2012        );
2013        assert_eq!(position.break_even().unwrap(), 97.0);
2014    }
2015
2016    #[test]
2017    fn test_zero_quantity() {
2018        let option = setup_option(
2019            Side::Short,
2020            OptionStyle::Put,
2021            Positive::HUNDRED,
2022            pos_or_panic!(105.0),
2023            Positive::ZERO, // zero quantity
2024            pos_or_panic!(30.0),
2025        );
2026        let position = Position::new(
2027            option,
2028            pos_or_panic!(5.0),
2029            chrono::DateTime::UNIX_EPOCH,
2030            Positive::ONE,
2031            Positive::ONE,
2032            None,
2033            None,
2034        );
2035        assert!(position.break_even().is_none());
2036    }
2037}
2038
2039#[cfg(test)]
2040mod tests_position_max_loss_profit {
2041    use super::*;
2042
2043    use crate::model::types::{OptionStyle, OptionType, Side};
2044
2045    use approx::assert_relative_eq;
2046    use positive::pos_or_panic;
2047    use rust_decimal_macros::dec;
2048
2049    fn setup_option(
2050        side: Side,
2051        option_style: OptionStyle,
2052        strike_price: Positive,
2053        underlying_price: Positive,
2054        quantity: Positive,
2055        expiration_days: Positive,
2056    ) -> Options {
2057        Options {
2058            option_type: OptionType::European,
2059            side,
2060            underlying_symbol: "".to_string(),
2061            strike_price,
2062            expiration_date: ExpirationDate::Days(expiration_days),
2063            implied_volatility: pos_or_panic!(0.2),
2064            quantity,
2065            contract_size: Positive::ONE,
2066            underlying_price,
2067            risk_free_rate: dec!(0.01),
2068            option_style,
2069            dividend_yield: Positive::ZERO,
2070            exotic_params: None,
2071        }
2072    }
2073
2074    #[test]
2075    fn test_unrealized_pnl_long_call() {
2076        let option = setup_option(
2077            Side::Long,
2078            OptionStyle::Call,
2079            Positive::HUNDRED,
2080            pos_or_panic!(105.0),
2081            Positive::ONE,
2082            pos_or_panic!(30.0),
2083        );
2084        let position = Position::new(
2085            option,
2086            pos_or_panic!(5.0),
2087            chrono::DateTime::UNIX_EPOCH,
2088            Positive::ONE,
2089            Positive::ONE,
2090            None,
2091            None,
2092        );
2093        assert_relative_eq!(position.max_loss().unwrap().to_f64(), 7.0, epsilon = 0.001);
2094        assert_eq!(position.max_profit().unwrap(), Positive::MAX);
2095    }
2096
2097    #[test]
2098    fn test_unrealized_pnl_long_call_size() {
2099        let option = setup_option(
2100            Side::Long,
2101            OptionStyle::Call,
2102            Positive::HUNDRED,
2103            pos_or_panic!(105.0),
2104            pos_or_panic!(10.0),
2105            pos_or_panic!(30.0),
2106        );
2107        let position = Position::new(
2108            option,
2109            pos_or_panic!(5.0),
2110            chrono::DateTime::UNIX_EPOCH,
2111            Positive::ONE,
2112            Positive::ONE,
2113            None,
2114            None,
2115        );
2116        assert_relative_eq!(position.max_loss().unwrap().to_f64(), 70.0, epsilon = 0.001);
2117        assert_eq!(position.max_profit().unwrap(), Positive::MAX);
2118    }
2119
2120    #[test]
2121    fn test_unrealized_pnl_short_call() {
2122        let option = setup_option(
2123            Side::Short,
2124            OptionStyle::Call,
2125            Positive::HUNDRED,
2126            pos_or_panic!(105.0),
2127            Positive::ONE,
2128            pos_or_panic!(30.0),
2129        );
2130        let position = Position::new(
2131            option,
2132            pos_or_panic!(5.0),
2133            chrono::DateTime::UNIX_EPOCH,
2134            Positive::ONE,
2135            Positive::ONE,
2136            None,
2137            None,
2138        );
2139        assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
2140        assert_relative_eq!(
2141            position.max_profit().unwrap().to_f64(),
2142            3.0,
2143            epsilon = 0.001
2144        );
2145    }
2146
2147    #[test]
2148    fn test_unrealized_pnl_short_call_size() {
2149        let option = setup_option(
2150            Side::Short,
2151            OptionStyle::Call,
2152            Positive::HUNDRED,
2153            pos_or_panic!(105.0),
2154            pos_or_panic!(10.0),
2155            pos_or_panic!(30.0),
2156        );
2157        let position = Position::new(
2158            option,
2159            pos_or_panic!(5.0),
2160            chrono::DateTime::UNIX_EPOCH,
2161            Positive::ONE,
2162            Positive::ONE,
2163            None,
2164            None,
2165        );
2166        assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
2167        assert_relative_eq!(
2168            position.max_profit().unwrap().to_f64(),
2169            30.0,
2170            epsilon = 0.001
2171        );
2172    }
2173
2174    #[test]
2175    fn test_unrealized_pnl_long_put() {
2176        let option = setup_option(
2177            Side::Long,
2178            OptionStyle::Put,
2179            Positive::HUNDRED,
2180            pos_or_panic!(105.0),
2181            Positive::ONE,
2182            pos_or_panic!(30.0),
2183        );
2184        let position = Position::new(
2185            option,
2186            pos_or_panic!(5.0),
2187            chrono::DateTime::UNIX_EPOCH,
2188            Positive::ONE,
2189            Positive::ONE,
2190            None,
2191            None,
2192        );
2193        assert_relative_eq!(position.max_loss().unwrap().to_f64(), 7.0, epsilon = 0.001);
2194        assert_relative_eq!(position.max_profit().unwrap(), Positive::MAX);
2195    }
2196
2197    #[test]
2198    fn test_unrealized_pnl_long_put_size() {
2199        let option = setup_option(
2200            Side::Long,
2201            OptionStyle::Put,
2202            Positive::HUNDRED,
2203            pos_or_panic!(105.0),
2204            pos_or_panic!(10.0),
2205            pos_or_panic!(30.0),
2206        );
2207        let position = Position::new(
2208            option,
2209            pos_or_panic!(5.0),
2210            chrono::DateTime::UNIX_EPOCH,
2211            Positive::ONE,
2212            Positive::ONE,
2213            None,
2214            None,
2215        );
2216        assert_relative_eq!(position.max_loss().unwrap().to_f64(), 70.0, epsilon = 0.001);
2217        assert_relative_eq!(position.max_profit().unwrap(), Positive::MAX);
2218    }
2219
2220    #[test]
2221    fn test_unrealized_pnl_short_put() {
2222        let option = setup_option(
2223            Side::Short,
2224            OptionStyle::Put,
2225            Positive::HUNDRED,
2226            pos_or_panic!(105.0),
2227            Positive::ONE,
2228            pos_or_panic!(30.0),
2229        );
2230        let position = Position::new(
2231            option,
2232            pos_or_panic!(5.0),
2233            chrono::DateTime::UNIX_EPOCH,
2234            Positive::ONE,
2235            Positive::ONE,
2236            None,
2237            None,
2238        );
2239        assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
2240        assert_relative_eq!(
2241            position.max_profit().unwrap().to_f64(),
2242            3.0,
2243            epsilon = 0.001
2244        );
2245    }
2246
2247    #[test]
2248    fn test_unrealized_pnl_short_put_size() {
2249        let option = setup_option(
2250            Side::Short,
2251            OptionStyle::Put,
2252            Positive::HUNDRED,
2253            pos_or_panic!(105.0),
2254            pos_or_panic!(10.0),
2255            pos_or_panic!(30.0),
2256        );
2257        let position = Position::new(
2258            option,
2259            pos_or_panic!(5.0),
2260            chrono::DateTime::UNIX_EPOCH,
2261            Positive::ONE,
2262            Positive::ONE,
2263            None,
2264            None,
2265        );
2266        assert_relative_eq!(position.max_loss().unwrap(), Positive::MAX);
2267        assert_relative_eq!(
2268            position.max_profit().unwrap().to_f64(),
2269            30.0,
2270            epsilon = 0.001
2271        );
2272    }
2273}
2274
2275#[cfg(test)]
2276mod tests_premium {
2277    use super::*;
2278    use positive::pos_or_panic;
2279
2280    fn setup_basic_position(side: Side) -> Position {
2281        let option = Options {
2282            side,
2283            quantity: Positive::ONE,
2284            ..Default::default()
2285        };
2286
2287        Position::new(
2288            option,
2289            pos_or_panic!(5.0),
2290            chrono::DateTime::UNIX_EPOCH,
2291            Positive::ONE,
2292            Positive::ONE,
2293            None,
2294            None,
2295        )
2296    }
2297
2298    #[test]
2299    fn test_premium_received_long() {
2300        let position = setup_basic_position(Side::Long);
2301        assert_eq!(position.premium_received().unwrap(), Positive::ZERO);
2302    }
2303
2304    #[test]
2305    fn test_premium_received_short() {
2306        let position = setup_basic_position(Side::Short);
2307        assert_eq!(position.premium_received().unwrap(), 5.0);
2308    }
2309
2310    #[test]
2311    fn test_net_premium_received_long() {
2312        let position = setup_basic_position(Side::Long);
2313        assert_eq!(position.net_premium_received().unwrap(), 0.0);
2314    }
2315
2316    #[test]
2317    fn test_net_premium_received_short() {
2318        let position = setup_basic_position(Side::Short);
2319        assert_eq!(position.net_premium_received().unwrap(), 3.0); // 5.0 - 2.0 (fees)
2320    }
2321
2322    #[test]
2323    fn test_premium_received_with_quantity() {
2324        let side = Side::Short;
2325        let option = Options {
2326            side,
2327            quantity: pos_or_panic!(10.0),
2328            ..Default::default()
2329        };
2330
2331        let position = Position::new(
2332            option,
2333            pos_or_panic!(5.0),
2334            chrono::DateTime::UNIX_EPOCH,
2335            Positive::ONE,
2336            Positive::ONE,
2337            None,
2338            None,
2339        );
2340        assert_eq!(position.premium_received().unwrap(), 50.0);
2341    }
2342}
2343
2344#[cfg(all(test, feature = "clock"))]
2345mod tests_position_serde {
2346    use super::*;
2347
2348    use crate::model::utils::create_sample_position;
2349
2350    use positive::pos_or_panic;
2351    use serde_json;
2352    use tracing::info;
2353
2354    #[test]
2355    fn test_position_serialization() {
2356        let position = create_sample_position(
2357            OptionStyle::Call,
2358            Side::Short,
2359            pos_or_panic!(90.0),
2360            Positive::ONE,
2361            pos_or_panic!(95.0),
2362            pos_or_panic!(0.2),
2363        );
2364        let serialized = serde_json::to_string(&position).unwrap();
2365
2366        // Verify the serialized string contains expected fields
2367        assert!(serialized.contains("\"option\""));
2368        assert!(serialized.contains("\"premium\""));
2369        assert!(serialized.contains("\"date\""));
2370        assert!(serialized.contains("\"open_fee\""));
2371        assert!(serialized.contains("\"close_fee\""));
2372        assert!(serialized.contains("AAPL"));
2373        assert!(serialized.contains("95"));
2374    }
2375
2376    #[test]
2377    fn test_position_deserialization() {
2378        let position = create_sample_position(
2379            OptionStyle::Call,
2380            Side::Short,
2381            pos_or_panic!(90.0),
2382            Positive::ONE,
2383            pos_or_panic!(95.0),
2384            pos_or_panic!(0.2),
2385        );
2386        let serialized = serde_json::to_string(&position).unwrap();
2387        let deserialized: Position = serde_json::from_str(&serialized).unwrap();
2388
2389        assert_eq!(position, deserialized);
2390        assert_eq!(deserialized.option.underlying_symbol, "AAPL");
2391        assert_eq!(deserialized.option.strike_price, pos_or_panic!(95.0));
2392        assert_eq!(deserialized.premium, pos_or_panic!(5.0));
2393        assert_eq!(deserialized.open_fee, pos_or_panic!(0.5));
2394        assert_eq!(deserialized.close_fee, pos_or_panic!(0.5));
2395    }
2396
2397    #[test]
2398    fn test_position_json_structure() {
2399        let position = create_sample_position(
2400            OptionStyle::Call,
2401            Side::Short,
2402            pos_or_panic!(90.0),
2403            Positive::ONE,
2404            pos_or_panic!(95.0),
2405            pos_or_panic!(0.2),
2406        );
2407        let serialized = serde_json::to_string_pretty(&position).unwrap();
2408
2409        info!("Serialized Position:\n{}", serialized);
2410
2411        let value: serde_json::Value = serde_json::from_str(&serialized).unwrap();
2412
2413        // Test structure
2414        assert!(value.is_object());
2415        assert!(value.get("option").is_some());
2416        assert!(value.get("premium").is_some());
2417        assert!(value.get("date").is_some());
2418        assert!(value.get("open_fee").is_some());
2419        assert!(value.get("close_fee").is_some());
2420    }
2421
2422    #[test]
2423    fn test_position_deserialize_invalid_json() {
2424        let invalid_json = r#"{
2425            "option": null,
2426            "premium": 5.0,
2427            "date": "2024-01-01T00:00:00Z",
2428            "open_fee": 1.0,
2429            "close_fee": 1.0
2430        }"#;
2431
2432        let result: Result<Position, serde_json::Error> = serde_json::from_str(invalid_json);
2433        assert!(result.is_err());
2434    }
2435
2436    #[test]
2437    fn test_position_roundtrip() {
2438        let original = create_sample_position(
2439            OptionStyle::Call,
2440            Side::Short,
2441            pos_or_panic!(90.0),
2442            Positive::ONE,
2443            pos_or_panic!(95.0),
2444            pos_or_panic!(0.2),
2445        );
2446        let serialized = serde_json::to_string(&original).unwrap();
2447        let deserialized: Position = serde_json::from_str(&serialized).unwrap();
2448
2449        // Compare objects, not strings (string comparison is fragile due to decimal formatting)
2450        assert_eq!(original, deserialized);
2451
2452        // Verify roundtrip produces equivalent object
2453        let reserialized = serde_json::to_string(&deserialized).unwrap();
2454        let redeserialized: Position = serde_json::from_str(&reserialized).unwrap();
2455        assert_eq!(deserialized, redeserialized);
2456    }
2457
2458    #[test]
2459    fn test_position_with_different_option_types() {
2460        // Test with a Put option
2461        let put_position = create_sample_position(
2462            OptionStyle::Put,
2463            Side::Short,
2464            pos_or_panic!(90.0),
2465            Positive::ONE,
2466            pos_or_panic!(95.0),
2467            pos_or_panic!(0.2),
2468        );
2469
2470        let serialized = serde_json::to_string(&put_position).unwrap();
2471        let deserialized: Position = serde_json::from_str(&serialized).unwrap();
2472
2473        assert_eq!(put_position, deserialized);
2474        assert_eq!(deserialized.option.option_style, OptionStyle::Put);
2475
2476        // Test with a Short position
2477        let short_position = create_sample_position(
2478            OptionStyle::Call,
2479            Side::Short,
2480            pos_or_panic!(90.0),
2481            Positive::ONE,
2482            pos_or_panic!(95.0),
2483            pos_or_panic!(0.2),
2484        );
2485
2486        let serialized = serde_json::to_string(&short_position).unwrap();
2487        let deserialized: Position = serde_json::from_str(&serialized).unwrap();
2488
2489        assert_eq!(short_position, deserialized);
2490        assert_eq!(deserialized.option.side, Side::Short);
2491    }
2492}
2493
2494#[cfg(all(test, feature = "clock"))]
2495mod tests_position_tradeable_trait {
2496    use super::*;
2497
2498    use crate::model::utils::create_sample_position;
2499    use positive::pos_or_panic;
2500
2501    #[test]
2502    fn test_tradeable_trade_ref() {
2503        let position = create_sample_position(
2504            OptionStyle::Call,
2505            Side::Short,
2506            pos_or_panic!(90.0),
2507            Positive::ONE,
2508            pos_or_panic!(95.0),
2509            pos_or_panic!(0.2),
2510        );
2511
2512        let result = position.trade_ref();
2513        assert!(result.is_err());
2514    }
2515
2516    #[test]
2517    fn test_tradeable_trade_mut() {
2518        let mut position = create_sample_position(
2519            OptionStyle::Call,
2520            Side::Short,
2521            pos_or_panic!(90.0),
2522            Positive::ONE,
2523            pos_or_panic!(95.0),
2524            pos_or_panic!(0.2),
2525        );
2526
2527        let result = position.trade_mut();
2528        assert!(result.is_err());
2529    }
2530}
2531
2532#[cfg(all(test, feature = "clock"))]
2533mod tests_position_tradestatusable_trait {
2534    use super::*;
2535
2536    use crate::model::utils::create_sample_position;
2537    use positive::pos_or_panic;
2538
2539    #[test]
2540    fn test_tradestatusable_expired() {
2541        let position = create_sample_position(
2542            OptionStyle::Call,
2543            Side::Short,
2544            pos_or_panic!(90.0), // underlying price
2545            Positive::ONE,       // quantity
2546            pos_or_panic!(95.0), // strike price
2547            pos_or_panic!(0.2),  // implied volatility
2548        );
2549
2550        let result = position.expired();
2551        assert!(result.is_ok());
2552    }
2553
2554    #[test]
2555    fn test_tradestatusable_exercised() {
2556        let position = create_sample_position(
2557            OptionStyle::Call,
2558            Side::Short,
2559            pos_or_panic!(90.0), // underlying price
2560            Positive::ONE,       // quantity
2561            pos_or_panic!(95.0), // strike price
2562            pos_or_panic!(0.2),  // implied volatility
2563        );
2564
2565        let result = position.exercised();
2566        assert!(result.is_ok());
2567    }
2568
2569    #[test]
2570    fn test_tradestatusable_assigned() {
2571        let position = create_sample_position(
2572            OptionStyle::Call,
2573            Side::Short,
2574            pos_or_panic!(90.0), // underlying price
2575            Positive::ONE,       // quantity
2576            pos_or_panic!(95.0), // strike price
2577            pos_or_panic!(0.2),  // implied volatility
2578        );
2579
2580        let result = position.assigned();
2581        assert!(result.is_ok());
2582    }
2583
2584    #[test]
2585    fn test_tradestatusable_status_other() {
2586        let position = create_sample_position(
2587            OptionStyle::Call,
2588            Side::Short,
2589            pos_or_panic!(90.0), // underlying price
2590            Positive::ONE,       // quantity
2591            pos_or_panic!(95.0), // strike price
2592            pos_or_panic!(0.2),  // implied volatility
2593        );
2594
2595        let result = position.status_other();
2596        assert!(result.is_ok());
2597    }
2598
2599    /// Regression for issue #331: `Position::close()` must zero out the
2600    /// trade premium when it is at or below the 0.01 cutoff and preserve
2601    /// it otherwise. This is the only observable effect of the rewritten
2602    /// threshold comparison (previously backed by an `unsafe`
2603    /// `Positive::new_unchecked` block).
2604    #[test]
2605    fn test_tradestatusable_close_zeros_tiny_premium() {
2606        use crate::model::utils::create_sample_option_simplest;
2607
2608        let build = |premium: Positive| {
2609            Position::new(
2610                create_sample_option_simplest(OptionStyle::Call, Side::Long),
2611                premium,
2612                chrono::DateTime::UNIX_EPOCH,
2613                pos_or_panic!(0.01),
2614                pos_or_panic!(0.01),
2615                None,
2616                None,
2617            )
2618        };
2619
2620        // Just above the cutoff: premium is preserved.
2621        let position = build(pos_or_panic!(0.02));
2622        let trade = position.close().expect("close() should succeed");
2623        assert_eq!(trade.premium, pos_or_panic!(0.02));
2624
2625        // Exactly at the cutoff: premium is zeroed.
2626        let position = build(pos_or_panic!(0.01));
2627        let trade = position.close().expect("close() should succeed");
2628        assert_eq!(trade.premium, Positive::ZERO);
2629
2630        // Below the cutoff: premium is zeroed.
2631        let position = build(pos_or_panic!(0.005));
2632        let trade = position.close().expect("close() should succeed");
2633        assert_eq!(trade.premium, Positive::ZERO);
2634    }
2635}
2636
2637#[cfg(test)]
2638mod tests_position_contract_size {
2639    use super::*;
2640    use crate::model::leg::{Leg, LegAble};
2641    use crate::model::utils::create_sample_option;
2642    use positive::pos_or_panic;
2643    use rust_decimal_macros::dec;
2644
2645    /// Two contracts of 100 units each on a call struck at 100, premium 5 per
2646    /// unit, open and close fees of 0.5 per contract.
2647    fn sized_call(side: Side, contract_size: Positive) -> Position {
2648        let option = create_sample_option(
2649            OptionStyle::Call,
2650            side,
2651            Positive::HUNDRED,
2652            Positive::TWO,
2653            Positive::HUNDRED,
2654            pos_or_panic!(0.2),
2655        )
2656        .with_contract_size(contract_size);
2657        Position::new(
2658            option,
2659            pos_or_panic!(5.0),
2660            chrono::DateTime::UNIX_EPOCH,
2661            pos_or_panic!(0.5),
2662            pos_or_panic!(0.5),
2663            None,
2664            None,
2665        )
2666    }
2667
2668    #[test]
2669    fn test_position_contract_size_fees_stay_per_contract() {
2670        let long = sized_call(Side::Long, Positive::HUNDRED);
2671        assert_eq!(long.fees().ok(), Some(Positive::TWO));
2672        let short = sized_call(Side::Short, Positive::HUNDRED);
2673        assert_eq!(short.fees().ok(), Some(Positive::TWO));
2674    }
2675
2676    #[test]
2677    fn test_position_contract_size_long_cost_scales_premium_not_fees() {
2678        // (5 × 100 + 0.5 + 0.5) × 2
2679        let long = sized_call(Side::Long, Positive::HUNDRED);
2680        assert_eq!(long.total_cost().ok(), Some(pos_or_panic!(1002.0)));
2681        assert_eq!(long.premium_received().ok(), Some(Positive::ZERO));
2682        assert_eq!(long.net_cost().ok(), Some(dec!(1002)));
2683    }
2684
2685    #[test]
2686    fn test_position_contract_size_short_premium_scales_fees_do_not() {
2687        // 5 × 100 × 2 received, (0.5 + 0.5) × 2 paid.
2688        let short = sized_call(Side::Short, Positive::HUNDRED);
2689        assert_eq!(short.total_cost().ok(), Some(Positive::TWO));
2690        assert_eq!(short.premium_received().ok(), Some(pos_or_panic!(1000.0)));
2691        assert_eq!(
2692            short.net_premium_received().ok(),
2693            Some(pos_or_panic!(998.0))
2694        );
2695        assert_eq!(short.net_cost().ok(), Some(dec!(-998)));
2696    }
2697
2698    #[test]
2699    fn test_position_contract_size_pnl_at_expiration_scales() {
2700        // Long: 20 × 2 × 100 intrinsic minus 1002 cost.
2701        let long = sized_call(Side::Long, Positive::HUNDRED);
2702        assert_eq!(
2703            long.pnl_at_expiration(&Some(&pos_or_panic!(120.0))).ok(),
2704            Some(dec!(2998))
2705        );
2706        assert_eq!(
2707            long.pnl_at_expiration(&Some(&pos_or_panic!(90.0))).ok(),
2708            Some(dec!(-1002))
2709        );
2710        // Short: -10 × 2 × 100 intrinsic, minus 2 of fees, plus 1000 received.
2711        let short = sized_call(Side::Short, Positive::HUNDRED);
2712        assert_eq!(
2713            short.pnl_at_expiration(&Some(&pos_or_panic!(110.0))).ok(),
2714            Some(dec!(-1002))
2715        );
2716        assert_eq!(
2717            short.pnl_at_expiration(&Some(&pos_or_panic!(90.0))).ok(),
2718            Some(dec!(998))
2719        );
2720    }
2721
2722    #[test]
2723    fn test_position_contract_size_unrealized_pnl_scales_price_not_fees() {
2724        // Long: ((7 - 5) × 100 - 1) × 2; short: ((5 - 3) × 100 - 1) × 2.
2725        let long = sized_call(Side::Long, Positive::HUNDRED);
2726        assert_eq!(
2727            long.unrealized_pnl(pos_or_panic!(7.0)).ok(),
2728            Some(dec!(398))
2729        );
2730        let short = sized_call(Side::Short, Positive::HUNDRED);
2731        assert_eq!(
2732            short.unrealized_pnl(pos_or_panic!(3.0)).ok(),
2733            Some(dec!(398))
2734        );
2735    }
2736
2737    #[test]
2738    fn test_position_contract_size_break_even_spreads_fees_over_units() {
2739        // Long call: 100 + 1002 / 200; short call: 100 + 5 - 2 / 200.
2740        let long = sized_call(Side::Long, Positive::HUNDRED);
2741        assert_eq!(long.break_even(), Some(pos_or_panic!(105.01)));
2742        let short = sized_call(Side::Short, Positive::HUNDRED);
2743        assert_eq!(short.break_even(), Some(pos_or_panic!(104.99)));
2744        // Each break-even is a zero of the expiry P&L.
2745        for position in [long, short] {
2746            let Some(break_even) = position.break_even() else {
2747                panic!("break-even exists");
2748            };
2749            assert_eq!(
2750                position.pnl_at_expiration(&Some(&break_even)).ok(),
2751                Some(Decimal::ZERO)
2752            );
2753        }
2754    }
2755
2756    #[test]
2757    fn test_position_contract_size_one_matches_default() {
2758        let default = sized_call(Side::Long, Positive::ONE);
2759        let mut legacy = default.clone();
2760        legacy.option = create_sample_option(
2761            OptionStyle::Call,
2762            Side::Long,
2763            Positive::HUNDRED,
2764            Positive::TWO,
2765            Positive::HUNDRED,
2766            pos_or_panic!(0.2),
2767        );
2768        assert_eq!(default, legacy);
2769        assert_eq!(default.total_cost().ok(), Some(pos_or_panic!(12.0)));
2770    }
2771
2772    #[cfg(feature = "clock")]
2773    #[test]
2774    fn test_position_contract_size_trade_carries_contract_size() {
2775        let long = sized_call(Side::Long, Positive::HUNDRED);
2776        let Ok(trade) = long.trade() else {
2777            panic!("trade builds");
2778        };
2779        // The trade keeps the per-unit premium and its own multiplier, and
2780        // the per-contract premium it derives is the one the position pays.
2781        assert_eq!(trade.premium, pos_or_panic!(5.0));
2782        assert_eq!(trade.contract_size, Positive::HUNDRED);
2783        assert_eq!(trade.fee, Positive::ONE);
2784        assert_eq!(trade.quantity, Positive::TWO);
2785        // Bought long: (5 × 100 + 1) × 2.
2786        assert_eq!(trade.cost().unwrap(), pos_or_panic!(1002.0));
2787        assert_eq!(trade.income().unwrap(), Positive::ZERO);
2788        assert_eq!(trade.net().unwrap(), dec!(-1002));
2789        let Ok(sold) = sized_call(Side::Short, Positive::HUNDRED).trade() else {
2790            panic!("trade builds");
2791        };
2792        let mut sold = sold;
2793        sold.action = Action::Sell;
2794        // Sold short: the cost carries the premium and the fees.
2795        assert_eq!(sold.cost().unwrap(), pos_or_panic!(1002.0));
2796    }
2797
2798    #[cfg(feature = "clock")]
2799    #[test]
2800    fn test_position_contract_size_trade_matches_unit_lots() {
2801        // One trade of 2 contracts × 100 units derives the same totals as a
2802        // trade of 200 one-unit contracts with the same per-contract fees.
2803        let Ok(sized) = sized_call(Side::Long, Positive::HUNDRED).trade() else {
2804            panic!("trade builds");
2805        };
2806        let mut lots = sized.clone();
2807        lots.contract_size = Positive::ONE;
2808        lots.quantity = pos_or_panic!(200.0);
2809        lots.fee = pos_or_panic!(0.01);
2810        assert_eq!(sized.cost().unwrap(), lots.cost().unwrap());
2811        assert_eq!(sized.net().unwrap(), lots.net().unwrap());
2812    }
2813
2814    #[cfg(feature = "clock")]
2815    #[test]
2816    fn test_position_contract_size_trade_serde_round_trip() {
2817        let Ok(trade) = sized_call(Side::Long, Positive::HUNDRED).trade() else {
2818            panic!("trade builds");
2819        };
2820        let Ok(json) = serde_json::to_string(&trade) else {
2821            panic!("trade serializes");
2822        };
2823        let Ok(back) = serde_json::from_str::<Trade>(&json) else {
2824            panic!("trade deserializes");
2825        };
2826        assert_eq!(back.contract_size, Positive::HUNDRED);
2827        assert_eq!(back, trade);
2828    }
2829
2830    #[test]
2831    fn test_position_contract_size_leg_notional_covers_all_units() {
2832        let leg = Leg::from(sized_call(Side::Long, Positive::HUNDRED));
2833        assert_eq!(leg.get_quantity(), Positive::TWO);
2834        assert_eq!(
2835            leg.notional_value(Positive::HUNDRED).ok(),
2836            Some(pos_or_panic!(20000.0))
2837        );
2838        assert_eq!(
2839            leg.pnl_at_price(pos_or_panic!(120.0)).ok(),
2840            Some(dec!(2998))
2841        );
2842    }
2843}