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