Skip to main content

optionstratlib_core/model/
balance.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 22/9/25
5******************************************************************************/
6
7use crate::error::DecimalError;
8use crate::model::decimal::{d_div, d_mul, d_sub, d_sum_iter};
9use crate::model::types::UnderlyingAssetType;
10use positive::{Positive, PositiveError};
11use rust_decimal::Decimal;
12use serde::{Deserialize, Serialize};
13
14/// Represents the balance of a specific option position in an exchange.
15///
16/// This struct encapsulates all the information needed to track an option position,
17/// including quantity, premium information, and profit/loss calculations.
18/// This balance is specifically designed for options trading.
19#[derive(Clone, PartialEq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
21pub struct Balance {
22    /// Symbol or Epic of the option contract
23    pub symbol: String,
24    /// Quantity of option contracts held
25    pub quantity: Positive,
26    /// Average premium at which the option was acquired
27    pub average_premium: Positive,
28    /// Current market premium of the option (if available)
29    pub current_premium: Option<Positive>,
30    /// Name of the exchange where the option is held
31    pub exchange: String,
32    /// Type of underlying asset (e.g., Stock, Forex, Index)
33    pub underlying_asset_type: UnderlyingAssetType,
34    /// Margin information for accounts that support leverage
35    pub margin_info: Option<MarginInfo>,
36}
37
38crate::impl_json_debug_pretty!(Balance);
39crate::impl_json_display!(Balance);
40
41impl Balance {
42    /// Creates a new Balance instance for an option position.
43    ///
44    /// # Arguments
45    ///
46    /// * `symbol` - The option contract symbol (e.g., "AAPL240315C00150000")
47    /// * `quantity` - The quantity of option contracts held
48    /// * `average_premium` - The average premium paid for the option
49    /// * `current_premium` - The current market premium (optional)
50    /// * `exchange` - The name of the exchange
51    /// * `underlying_asset_type` - The type of the underlying asset
52    /// * `margin_info` - Margin information for accounts that support leverage (optional)
53    ///
54    /// # Returns
55    ///
56    /// A new Balance instance for the option position
57    #[must_use]
58    pub fn new(
59        symbol: String,
60        quantity: Positive,
61        average_premium: Positive,
62        current_premium: Option<Positive>,
63        exchange: String,
64        underlying_asset_type: UnderlyingAssetType,
65        margin_info: Option<MarginInfo>,
66    ) -> Self {
67        Self {
68            symbol,
69            quantity,
70            average_premium,
71            current_premium,
72            exchange,
73            underlying_asset_type,
74            margin_info,
75        }
76    }
77
78    /// Calculates the total value of the option position based on current premium.
79    ///
80    /// # Returns
81    ///
82    /// The total value (quantity * current_premium) if current_premium is available,
83    /// otherwise returns the cost basis (quantity * average_premium). The
84    /// product is formed in `Decimal`, with no `f64` step.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`PositiveError::ArithmeticError`] when the product overflows
89    /// `Decimal`.
90    pub fn get_total_value(&self) -> Result<Positive, PositiveError> {
91        // Use average premium if current premium is not available
92        let premium = self.current_premium.unwrap_or(self.average_premium);
93        self.quantity.checked_mul(&premium)
94    }
95
96    /// Calculates the unrealized profit or loss of the position.
97    ///
98    /// # Returns
99    ///
100    /// The unrealized PnL as a Decimal. Positive values indicate profit,
101    /// negative values indicate loss. Returns zero if current_price is not available.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`DecimalError::Overflow`] when the current value or the cost
106    /// basis leaves the `Decimal` range; the multiplication aborted with
107    /// `Multiplication overflowed` (#788).
108    pub fn get_unrealized_pnl(&self) -> Result<Decimal, DecimalError> {
109        match self.current_premium {
110            Some(current_price) => {
111                let current_value = d_mul(
112                    self.quantity.value(),
113                    current_price.value(),
114                    "Balance::get_unrealized_pnl/current_value",
115                )?;
116                let cost_basis = d_mul(
117                    self.quantity.value(),
118                    self.average_premium.value(),
119                    "Balance::get_unrealized_pnl/cost_basis",
120                )?;
121                d_sub(current_value, cost_basis, "Balance::get_unrealized_pnl")
122            }
123            None => Ok(Decimal::ZERO),
124        }
125    }
126
127    /// Updates the current market premium of the option.
128    ///
129    /// # Arguments
130    ///
131    /// * `new_premium` - The new current market premium
132    pub fn update_current_premium(&mut self, new_premium: Positive) {
133        self.current_premium = Some(new_premium);
134    }
135
136    /// Checks if the position is currently profitable.
137    ///
138    /// # Returns
139    ///
140    /// `true` if the position has unrealized gains, `false` otherwise.
141    /// Returns `false` if current_price is not available.
142    ///
143    /// # Errors
144    ///
145    /// Propagates the error of [`Balance::get_unrealized_pnl`].
146    pub fn is_profitable(&self) -> Result<bool, DecimalError> {
147        Ok(self.get_unrealized_pnl()? > Decimal::ZERO)
148    }
149
150    /// Gets the cost basis of the option position.
151    ///
152    /// # Returns
153    ///
154    /// The total cost basis (quantity * average_premium), formed in
155    /// `Decimal` with no `f64` step.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`PositiveError::ArithmeticError`] when the product overflows
160    /// `Decimal`.
161    pub fn get_cost_basis(&self) -> Result<Positive, PositiveError> {
162        self.quantity.checked_mul(&self.average_premium)
163    }
164
165    /// Gets the percentage return of the position.
166    ///
167    /// # Returns
168    ///
169    /// The percentage return as a Decimal. Returns zero if current_price is
170    /// not available, and zero for a zero cost basis, where no return is
171    /// defined.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`DecimalError::Overflow`] when the cost basis, the ratio of
176    /// the P&L to it, or its scaling to a percentage overflows `Decimal`.
177    pub fn get_percentage_return(&self) -> Result<Decimal, DecimalError> {
178        match self.current_premium {
179            Some(_current_price) => {
180                let pnl = self.get_unrealized_pnl()?;
181                let cost_basis = d_mul(
182                    self.quantity.to_dec(),
183                    self.average_premium.to_dec(),
184                    "Balance::get_percentage_return/cost_basis",
185                )?;
186                if cost_basis > Decimal::ZERO {
187                    let ratio = d_div(pnl, cost_basis, "Balance::get_percentage_return/ratio")?;
188                    d_mul(
189                        ratio,
190                        Decimal::ONE_HUNDRED,
191                        "Balance::get_percentage_return/percent",
192                    )
193                } else {
194                    Ok(Decimal::ZERO)
195                }
196            }
197            None => Ok(Decimal::ZERO),
198        }
199    }
200}
201
202/// Represents margin information for accounts that support leverage
203#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
204#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
205pub struct MarginInfo {
206    /// Available margin for new positions
207    pub available_margin: Decimal,
208    /// Currently used margin
209    pub used_margin: Decimal,
210    /// Maintenance margin requirement
211    pub maintenance_margin: Decimal,
212    /// Initial margin requirement
213    pub initial_margin: Option<Decimal>,
214    /// Current margin ratio (used/available)
215    pub margin_ratio: Option<Decimal>,
216}
217
218crate::impl_json_debug_pretty!(MarginInfo);
219crate::impl_json_display!(MarginInfo);
220
221/// Represents a portfolio containing multiple option balances.
222///
223/// This struct provides functionality to manage and analyze a collection
224/// of option positions across different exchanges.
225#[derive(Clone, PartialEq, Serialize, Deserialize)]
226#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
227pub struct Portfolio {
228    /// Collection of option balances
229    pub balances: Vec<Balance>,
230    /// Name or identifier for the portfolio
231    pub name: String,
232}
233
234crate::impl_json_debug_pretty!(Portfolio);
235crate::impl_json_display!(Portfolio);
236
237impl Portfolio {
238    /// Creates a new empty Portfolio.
239    ///
240    /// # Arguments
241    ///
242    /// * `name` - The name or identifier for the portfolio
243    ///
244    /// # Returns
245    ///
246    /// A new Portfolio instance with an empty balance collection
247    #[must_use]
248    pub fn new(name: String) -> Self {
249        Self {
250            balances: Vec::new(),
251            name,
252        }
253    }
254
255    /// Adds a balance to the portfolio.
256    ///
257    /// # Arguments
258    ///
259    /// * `balance` - The Balance to add to the portfolio
260    pub fn add_balance(&mut self, balance: Balance) {
261        self.balances.push(balance);
262    }
263
264    /// Removes a balance from the portfolio by symbol and exchange.
265    ///
266    /// # Arguments
267    ///
268    /// * `symbol` - The asset symbol to remove
269    /// * `exchange` - The exchange name to match
270    ///
271    /// # Returns
272    ///
273    /// `true` if a balance was removed, `false` if not found
274    pub fn remove_balance(&mut self, symbol: &str, exchange: &str) -> bool {
275        if let Some(pos) = self
276            .balances
277            .iter()
278            .position(|b| b.symbol == symbol && b.exchange == exchange)
279        {
280            self.balances.remove(pos);
281            true
282        } else {
283            false
284        }
285    }
286
287    /// Gets a reference to a balance by symbol and exchange.
288    ///
289    /// # Arguments
290    ///
291    /// * `symbol` - The asset symbol to find
292    /// * `exchange` - The exchange name to match
293    ///
294    /// # Returns
295    ///
296    /// An optional reference to the Balance if found
297    #[must_use]
298    pub fn get_balance(&self, symbol: &str, exchange: &str) -> Option<&Balance> {
299        self.balances
300            .iter()
301            .find(|b| b.symbol == symbol && b.exchange == exchange)
302    }
303
304    /// Gets a mutable reference to a balance by symbol and exchange.
305    ///
306    /// # Arguments
307    ///
308    /// * `symbol` - The asset symbol to find
309    /// * `exchange` - The exchange name to match
310    ///
311    /// # Returns
312    ///
313    /// An optional mutable reference to the Balance if found
314    pub fn get_balance_mut(&mut self, symbol: &str, exchange: &str) -> Option<&mut Balance> {
315        self.balances
316            .iter_mut()
317            .find(|b| b.symbol == symbol && b.exchange == exchange)
318    }
319
320    /// Calculates the total portfolio value.
321    ///
322    /// # Returns
323    ///
324    /// The sum of all balance values in the portfolio, accumulated in
325    /// `Decimal` with no `f64` step. An empty portfolio is worth zero.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`PositiveError::ArithmeticError`] when a balance value or the
330    /// running sum overflows `Decimal`.
331    pub fn get_total_value(&self) -> Result<Positive, PositiveError> {
332        self.balances
333            .iter()
334            .try_fold(Positive::ZERO, |total, balance| {
335                total.checked_add(&balance.get_total_value()?)
336            })
337    }
338
339    /// Calculates the total unrealized PnL for the portfolio.
340    ///
341    /// # Returns
342    ///
343    /// The sum of all unrealized PnL across all balances
344    ///
345    /// # Errors
346    ///
347    /// Propagates the error of [`Balance::get_unrealized_pnl`], and returns
348    /// [`DecimalError::Overflow`] when the sum leaves the `Decimal` range;
349    /// `Iterator::sum` aborted with `Addition overflowed` (#788).
350    pub fn get_total_unrealized_pnl(&self) -> Result<Decimal, DecimalError> {
351        let pnls = self
352            .balances
353            .iter()
354            .map(Balance::get_unrealized_pnl)
355            .collect::<Result<Vec<_>, _>>()?;
356        d_sum_iter(pnls, "Portfolio::get_total_unrealized_pnl")
357    }
358
359    /// Gets all balances from a specific exchange.
360    ///
361    /// # Arguments
362    ///
363    /// * `exchange` - The exchange name to filter by
364    ///
365    /// # Returns
366    ///
367    /// A vector of references to balances from the specified exchange
368    #[must_use]
369    pub fn get_balances_by_exchange(&self, exchange: &str) -> Vec<&Balance> {
370        self.balances
371            .iter()
372            .filter(|balance| balance.exchange == exchange)
373            .collect()
374    }
375
376    /// Checks if the portfolio has any profitable positions.
377    ///
378    /// # Returns
379    ///
380    /// `true` if any balance in the portfolio is profitable
381    ///
382    /// # Errors
383    ///
384    /// Propagates the error of [`Balance::is_profitable`].
385    pub fn has_profitable_positions(&self) -> Result<bool, DecimalError> {
386        for balance in &self.balances {
387            if balance.is_profitable()? {
388                return Ok(true);
389            }
390        }
391        Ok(false)
392    }
393
394    /// Gets the number of balances in the portfolio.
395    ///
396    /// # Returns
397    ///
398    /// The count of balances in the portfolio
399    #[must_use]
400    pub fn balance_count(&self) -> usize {
401        self.balances.len()
402    }
403
404    /// Checks if the portfolio is empty.
405    ///
406    /// # Returns
407    ///
408    /// `true` if the portfolio has no balances
409    #[must_use]
410    pub fn is_empty(&self) -> bool {
411        self.balances.is_empty()
412    }
413}
414
415impl Default for Portfolio {
416    fn default() -> Self {
417        Self::new("Default Portfolio".to_string())
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use positive::pos_or_panic;
425
426    use rust_decimal_macros::dec;
427
428    #[test]
429    fn test_balance_creation() {
430        let balance = Balance::new(
431            "AAPL240315C00150000".to_string(),
432            pos_or_panic!(10.0),
433            pos_or_panic!(5.50),
434            None,
435            "CBOE".to_string(),
436            UnderlyingAssetType::Stock,
437            None,
438        );
439
440        assert_eq!(balance.symbol, "AAPL240315C00150000");
441        assert_eq!(balance.quantity, pos_or_panic!(10.0));
442        assert_eq!(balance.average_premium, pos_or_panic!(5.50));
443        assert_eq!(balance.exchange, "CBOE");
444        assert_eq!(balance.underlying_asset_type, UnderlyingAssetType::Stock);
445        assert!(balance.current_premium.is_none());
446        assert!(balance.margin_info.is_none());
447    }
448
449    #[test]
450    fn test_balance_total_value() {
451        let balance = Balance::new(
452            "TSLA240315C00200000".to_string(),
453            pos_or_panic!(5.0),
454            pos_or_panic!(8.00),
455            Some(pos_or_panic!(12.50)),
456            "CBOE".to_string(),
457            UnderlyingAssetType::Stock,
458            None,
459        );
460
461        let total_value = balance.get_total_value().expect("finite product");
462        assert_eq!(total_value, pos_or_panic!(62.5)); // 5 * 12.50
463    }
464
465    #[test]
466    fn test_balance_unrealized_pnl() {
467        let balance = Balance::new(
468            "AAPL240315C00150000".to_string(),
469            pos_or_panic!(10.0),
470            pos_or_panic!(5.50),
471            Some(pos_or_panic!(7.00)),
472            "CBOE".to_string(),
473            UnderlyingAssetType::Stock,
474            None,
475        );
476
477        let pnl = balance.get_unrealized_pnl();
478        assert_eq!(pnl.ok(), Some(dec!(15.0))); // (7.00 - 5.50) * 10
479    }
480
481    #[test]
482    fn test_balance_is_profitable() {
483        let profitable_balance = Balance::new(
484            "GOOGL240315C00200000".to_string(),
485            pos_or_panic!(5.0),
486            pos_or_panic!(10.00),
487            Some(pos_or_panic!(12.00)),
488            "CBOE".to_string(),
489            UnderlyingAssetType::Stock,
490            None,
491        );
492
493        let losing_balance = Balance::new(
494            "TSLA240315C00080000".to_string(),
495            pos_or_panic!(3.0),
496            pos_or_panic!(8.00),
497            Some(pos_or_panic!(6.50)),
498            "CBOE".to_string(),
499            UnderlyingAssetType::Stock,
500            None,
501        );
502
503        assert_eq!(profitable_balance.is_profitable().ok(), Some(true));
504        assert_eq!(losing_balance.is_profitable().ok(), Some(false));
505    }
506
507    #[test]
508    fn test_portfolio_creation() {
509        let portfolio = Portfolio::new("My Portfolio".to_string());
510        assert_eq!(portfolio.name, "My Portfolio");
511        assert!(portfolio.is_empty());
512        assert_eq!(portfolio.balance_count(), 0);
513    }
514
515    #[test]
516    fn test_portfolio_add_balance() {
517        let mut portfolio = Portfolio::new("Test Portfolio".to_string());
518        let balance = Balance::new(
519            "AAPL240315C00150000".to_string(),
520            pos_or_panic!(10.0),
521            pos_or_panic!(5.50),
522            Some(pos_or_panic!(7.00)),
523            "CBOE".to_string(),
524            UnderlyingAssetType::Stock,
525            None,
526        );
527
528        portfolio.add_balance(balance);
529        assert_eq!(portfolio.balance_count(), 1);
530        assert!(!portfolio.is_empty());
531    }
532
533    #[test]
534    fn test_portfolio_total_value() {
535        let mut portfolio = Portfolio::new("Test Portfolio".to_string());
536
537        let balance1 = Balance::new(
538            "AAPL240315C00150000".to_string(),
539            pos_or_panic!(10.0),
540            pos_or_panic!(5.50),
541            Some(pos_or_panic!(7.00)),
542            "CBOE".to_string(),
543            UnderlyingAssetType::Stock,
544            None,
545        );
546
547        let balance2 = Balance::new(
548            "TSLA240315C00200000".to_string(),
549            pos_or_panic!(5.0),
550            pos_or_panic!(8.00),
551            Some(pos_or_panic!(12.50)),
552            "CBOE".to_string(),
553            UnderlyingAssetType::Stock,
554            None,
555        );
556
557        portfolio.add_balance(balance1);
558        portfolio.add_balance(balance2);
559
560        let total_value = portfolio.get_total_value().expect("finite sum");
561        assert_eq!(total_value, pos_or_panic!(132.5)); // 70.0 + 62.5
562    }
563
564    #[test]
565    fn test_portfolio_get_balance() {
566        let mut portfolio = Portfolio::new("Test Portfolio".to_string());
567        let balance = Balance::new(
568            "AAPL240315C00150000".to_string(),
569            pos_or_panic!(10.0),
570            pos_or_panic!(5.50),
571            Some(pos_or_panic!(7.00)),
572            "CBOE".to_string(),
573            UnderlyingAssetType::Stock,
574            None,
575        );
576
577        portfolio.add_balance(balance);
578
579        let found_balance = portfolio.get_balance("AAPL240315C00150000", "CBOE");
580        assert!(found_balance.is_some());
581        assert_eq!(found_balance.unwrap().symbol, "AAPL240315C00150000");
582
583        let not_found = portfolio.get_balance("TSLA240315C00200000", "CBOE");
584        assert!(not_found.is_none());
585    }
586
587    fn overflowing_balance(current_premium: Option<Positive>) -> Balance {
588        Balance::new(
589            "BIG".to_string(),
590            Positive::MAX,
591            pos_or_panic!(2.0),
592            current_premium,
593            "CBOE".to_string(),
594            UnderlyingAssetType::Stock,
595            None,
596        )
597    }
598
599    #[test]
600    fn test_balance_values_are_exact_decimal_products() {
601        let balance = Balance::new(
602            "AAPL240315C00150000".to_string(),
603            pos_or_panic!(3.0),
604            pos_or_panic!(0.1),
605            Some(pos_or_panic!(0.2)),
606            "CBOE".to_string(),
607            UnderlyingAssetType::Stock,
608            None,
609        );
610
611        assert_eq!(
612            balance.get_total_value().expect("finite product").to_dec(),
613            dec!(0.6)
614        );
615        assert_eq!(
616            balance.get_cost_basis().expect("finite product").to_dec(),
617            dec!(0.3)
618        );
619        assert_eq!(
620            balance.get_percentage_return().expect("finite ratio"),
621            dec!(100)
622        );
623    }
624
625    #[test]
626    fn test_balance_without_current_premium_values_at_cost() {
627        let balance = Balance::new(
628            "AAPL240315C00150000".to_string(),
629            pos_or_panic!(10.0),
630            pos_or_panic!(5.50),
631            None,
632            "CBOE".to_string(),
633            UnderlyingAssetType::Stock,
634            None,
635        );
636
637        assert_eq!(
638            balance.get_total_value().expect("finite product"),
639            pos_or_panic!(55.0)
640        );
641        assert_eq!(
642            balance.get_percentage_return().expect("no premium"),
643            Decimal::ZERO
644        );
645    }
646
647    #[test]
648    fn test_balance_total_value_overflow_is_an_error_not_zero() {
649        let balance = overflowing_balance(Some(pos_or_panic!(2.0)));
650        assert!(matches!(
651            balance.get_total_value(),
652            Err(PositiveError::ArithmeticError { .. })
653        ));
654    }
655
656    #[test]
657    fn test_balance_cost_basis_overflow_is_an_error_not_zero() {
658        let balance = overflowing_balance(None);
659        assert!(matches!(
660            balance.get_cost_basis(),
661            Err(PositiveError::ArithmeticError { .. })
662        ));
663    }
664
665    #[test]
666    fn test_balance_percentage_return_ratio_overflow_is_an_error() {
667        // A P&L of about 1e10 over a cost basis of 1e-20 is a ratio of 1e30,
668        // past `Decimal::MAX`.
669        let balance = Balance::new(
670            "TINY".to_string(),
671            Positive::ONE,
672            pos_or_panic!(0.000_000_000_000_000_000_01),
673            Some(pos_or_panic!(10_000_000_000.0)),
674            "CBOE".to_string(),
675            UnderlyingAssetType::Stock,
676            None,
677        );
678        assert!(matches!(
679            balance.get_percentage_return(),
680            Err(DecimalError::Overflow { .. })
681        ));
682    }
683
684    #[test]
685    fn test_portfolio_total_value_overflow_is_an_error_not_zero() {
686        let mut portfolio = Portfolio::new("Overflow".to_string());
687        portfolio.add_balance(overflowing_balance(Some(pos_or_panic!(1.0))));
688        portfolio.add_balance(overflowing_balance(Some(pos_or_panic!(1.0))));
689        assert!(matches!(
690            portfolio.get_total_value(),
691            Err(PositiveError::ArithmeticError { .. })
692        ));
693    }
694
695    #[test]
696    fn test_empty_portfolio_total_value_is_zero() {
697        let portfolio = Portfolio::new("Empty".to_string());
698        assert_eq!(
699            portfolio.get_total_value().expect("empty sum"),
700            Positive::ZERO
701        );
702    }
703
704    // `quantity * premium` aborted with `Multiplication overflowed` and the
705    // portfolio sum with `Addition overflowed` before #788.
706    #[test]
707    fn test_unrealized_pnl_overflow_is_error() {
708        let wide = overflowing_balance(Some(pos_or_panic!(2.0)));
709        assert!(wide.get_unrealized_pnl().is_err());
710        assert!(wide.is_profitable().is_err());
711
712        let half = Positive::new_decimal(Decimal::MAX / dec!(2)).unwrap();
713        let mut portfolio = Portfolio::new("Overflow".to_string());
714        for _ in 0..3 {
715            portfolio.add_balance(Balance::new(
716                "HALF".to_string(),
717                half,
718                Positive::ZERO,
719                Some(Positive::ONE),
720                "CBOE".to_string(),
721                UnderlyingAssetType::Stock,
722                None,
723            ));
724        }
725        assert!(portfolio.get_total_unrealized_pnl().is_err());
726        assert_eq!(portfolio.has_profitable_positions().ok(), Some(true));
727        portfolio.add_balance(wide);
728        assert!(portfolio.has_profitable_positions().is_ok());
729    }
730
731    #[test]
732    fn test_portfolio_total_unrealized_pnl_sums_balances() {
733        let mut portfolio = Portfolio::new("Sum".to_string());
734        portfolio.add_balance(Balance::new(
735            "A".to_string(),
736            pos_or_panic!(10.0),
737            pos_or_panic!(5.50),
738            Some(pos_or_panic!(7.00)),
739            "CBOE".to_string(),
740            UnderlyingAssetType::Stock,
741            None,
742        ));
743        portfolio.add_balance(Balance::new(
744            "B".to_string(),
745            pos_or_panic!(3.0),
746            pos_or_panic!(8.00),
747            Some(pos_or_panic!(6.50)),
748            "CBOE".to_string(),
749            UnderlyingAssetType::Stock,
750            None,
751        ));
752        // 15.0 - 4.5
753        assert_eq!(portfolio.get_total_unrealized_pnl().ok(), Some(dec!(10.5)));
754        assert_eq!(portfolio.has_profitable_positions().ok(), Some(true));
755    }
756}