Skip to main content

optionstratlib_core/model/leg/
spot.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 24/12/25
5******************************************************************************/
6
7//! # Spot Position Module
8//!
9//! This module provides the `SpotPosition` struct for representing direct ownership
10//! of underlying assets such as stocks, cryptocurrency spot holdings, or forex spot positions.
11//!
12//! ## Characteristics
13//!
14//! - No expiration date
15//! - No margin requirements (fully funded)
16//! - Delta = ±1.0 per unit (Long = +1, Short = -1)
17//! - No funding costs or time decay
18//!
19//! ## Example
20//!
21//! ```rust
22//! # #[cfg(feature = "clock")] {
23//! use optionstratlib_core::model::leg::SpotPosition;
24//! use optionstratlib_core::model::types::Side;
25//! use positive::{pos_or_panic,Positive};
26//! use chrono::Utc;
27//!
28//! let spot = SpotPosition::new(
29//!     "AAPL".to_string(),
30//!     Positive::HUNDRED,      // 100 shares
31//!     pos_or_panic!(150.0),      // cost basis $150 per share
32//!     Side::Long,
33//!     Utc::now(),
34//!     Positive::ONE,        // $1 open fee
35//!     Positive::ONE,        // $1 close fee
36//! );
37//! # }
38//! ```
39
40use crate::error::PositionError;
41use crate::model::decimal::{d_div, d_mul, d_sub};
42use crate::model::leg::traits::LegAble;
43use crate::model::types::Side;
44#[cfg(feature = "clock")]
45use crate::utils::time::wall_clock;
46use chrono::{DateTime, Utc};
47use positive::Positive;
48use rust_decimal::Decimal;
49use serde::{Deserialize, Serialize};
50
51/// Represents a spot/underlying asset position.
52///
53/// A spot position represents direct ownership of an asset without any derivative
54/// characteristics. This includes stocks, cryptocurrency spot holdings, forex spot,
55/// and commodities held for delivery.
56///
57/// # Fields
58///
59/// * `symbol` - The ticker symbol or identifier of the asset
60/// * `quantity` - The number of units held
61/// * `cost_basis` - The average acquisition price per unit
62/// * `side` - Whether the position is Long (owned) or Short (borrowed/sold)
63/// * `date` - The timestamp when the position was opened
64/// * `open_fee` - Transaction fee paid to open the position
65/// * `close_fee` - Transaction fee to be paid when closing the position
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
68pub struct SpotPosition {
69    /// The ticker symbol or identifier of the underlying asset.
70    pub symbol: String,
71
72    /// The number of units held in this position.
73    pub quantity: Positive,
74
75    /// The average cost per unit at which the position was acquired.
76    pub cost_basis: Positive,
77
78    /// The direction of the position (Long = owned, Short = borrowed).
79    pub side: Side,
80
81    /// The timestamp when this position was opened.
82    pub date: DateTime<Utc>,
83
84    /// The fee paid to open this position.
85    pub open_fee: Positive,
86
87    /// The fee to be paid when closing this position.
88    pub close_fee: Positive,
89}
90
91impl SpotPosition {
92    /// Creates a new spot position.
93    ///
94    /// # Arguments
95    ///
96    /// * `symbol` - The ticker symbol of the asset
97    /// * `quantity` - Number of units to hold
98    /// * `cost_basis` - Average price per unit
99    /// * `side` - Long or Short position
100    /// * `date` - Position open timestamp
101    /// * `open_fee` - Fee to open the position
102    /// * `close_fee` - Fee to close the position
103    ///
104    /// # Returns
105    ///
106    /// A new `SpotPosition` instance.
107    #[must_use]
108    pub fn new(
109        symbol: String,
110        quantity: Positive,
111        cost_basis: Positive,
112        side: Side,
113        date: DateTime<Utc>,
114        open_fee: Positive,
115        close_fee: Positive,
116    ) -> Self {
117        Self {
118            symbol,
119            quantity,
120            cost_basis,
121            side,
122            date,
123            open_fee,
124            close_fee,
125        }
126    }
127
128    /// Creates a long spot position with default fees.
129    ///
130    /// # Arguments
131    ///
132    /// * `symbol` - The ticker symbol
133    /// * `quantity` - Number of units
134    /// * `cost_basis` - Price per unit
135    ///
136    /// # Returns
137    ///
138    /// A new long `SpotPosition` with zero fees.
139    #[cfg(feature = "clock")]
140    #[must_use]
141    pub fn long(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
142        Self::new(
143            symbol,
144            quantity,
145            cost_basis,
146            Side::Long,
147            wall_clock(),
148            Positive::ZERO,
149            Positive::ZERO,
150        )
151    }
152
153    /// Creates a short spot position with default fees.
154    ///
155    /// # Arguments
156    ///
157    /// * `symbol` - The ticker symbol
158    /// * `quantity` - Number of units
159    /// * `cost_basis` - Price per unit (entry price for short)
160    ///
161    /// # Returns
162    ///
163    /// A new short `SpotPosition` with zero fees.
164    #[cfg(feature = "clock")]
165    #[must_use]
166    pub fn short(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
167        Self::new(
168            symbol,
169            quantity,
170            cost_basis,
171            Side::Short,
172            wall_clock(),
173            Positive::ZERO,
174            Positive::ZERO,
175        )
176    }
177
178    /// Returns the total value of the position at the cost basis.
179    ///
180    /// This represents the initial investment (for long) or proceeds (for short).
181    ///
182    /// # Errors
183    ///
184    /// Returns [`PositionError::PositiveError`] when the product leaves the
185    /// `Positive` range; `Positive * Positive` aborted on it (#788).
186    pub fn initial_value(&self) -> Result<Positive, PositionError> {
187        Ok(self.quantity.checked_mul(&self.cost_basis)?)
188    }
189
190    /// Returns the current market value at a given price.
191    ///
192    /// # Arguments
193    ///
194    /// * `current_price` - The current market price per unit
195    ///
196    /// # Errors
197    ///
198    /// Returns [`PositionError::PositiveError`] when the product leaves the
199    /// `Positive` range (#788).
200    pub fn market_value(&self, current_price: Positive) -> Result<Positive, PositionError> {
201        Ok(self.quantity.checked_mul(&current_price)?)
202    }
203
204    /// Calculates the percentage return on the position.
205    ///
206    /// # Arguments
207    ///
208    /// * `current_price` - The current market price per unit
209    ///
210    /// # Returns
211    ///
212    /// The percentage return as a Decimal (e.g., 0.10 = 10% gain).
213    ///
214    /// # Errors
215    ///
216    /// Returns [`PositionError::DecimalError`] when the return leaves the
217    /// `Decimal` range: a cost basis near zero against a large price, where
218    /// the division aborted with `Division overflowed` (#788).
219    pub fn percentage_return(&self, current_price: Positive) -> Result<Decimal, PositionError> {
220        if self.cost_basis == Positive::ZERO {
221            return Ok(Decimal::ZERO);
222        }
223
224        let price_change = d_sub(
225            current_price.to_dec(),
226            self.cost_basis.to_dec(),
227            "SpotPosition::percentage_return/price_change",
228        )?;
229        let return_pct = d_div(
230            price_change,
231            self.cost_basis.to_dec(),
232            "SpotPosition::percentage_return",
233        )?;
234
235        Ok(match self.side {
236            Side::Long => return_pct,
237            Side::Short => -return_pct,
238        })
239    }
240
241    /// Calculates the break-even price including fees.
242    ///
243    /// For long positions: cost_basis + (total_fees / quantity)
244    /// For short positions: cost_basis - (total_fees / quantity), floored
245    /// at zero.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`PositionError::PositiveError`] when the fee total, the fee
250    /// per unit or the long break-even leaves the `Positive` range: a
251    /// quantity near zero against large fees, where the division aborted
252    /// with `Positive arithmetic overflow in div` (#788).
253    pub fn break_even_price(&self) -> Result<Positive, PositionError> {
254        if self.quantity == Positive::ZERO {
255            return Ok(self.cost_basis);
256        }
257
258        let fee_per_unit = self
259            .open_fee
260            .checked_add(&self.close_fee)?
261            .checked_div(&self.quantity)?;
262
263        Ok(match self.side {
264            Side::Long => self.cost_basis.checked_add(&fee_per_unit)?,
265            Side::Short => {
266                if self.cost_basis > fee_per_unit {
267                    self.cost_basis.checked_sub(&fee_per_unit)?
268                } else {
269                    Positive::ZERO
270                }
271            }
272        })
273    }
274}
275
276impl LegAble for SpotPosition {
277    fn get_symbol(&self) -> &str {
278        &self.symbol
279    }
280
281    fn get_quantity(&self) -> Positive {
282        self.quantity
283    }
284
285    fn get_side(&self) -> Side {
286        self.side
287    }
288
289    fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
290        let price_change = d_sub(
291            price.to_dec(),
292            self.cost_basis.to_dec(),
293            "SpotPosition::pnl_at_price/price_change",
294        )?;
295        let gross_pnl = d_mul(
296            price_change,
297            self.quantity.to_dec(),
298            "SpotPosition::pnl_at_price/gross",
299        )?;
300        let total_fees = self.open_fee.checked_add(&self.close_fee)?;
301
302        let signed_gross = match self.side {
303            Side::Long => gross_pnl,
304            // `Decimal` is symmetric, so negating a representable value is
305            // itself representable and needs no check.
306            Side::Short => -gross_pnl,
307        };
308        Ok(d_sub(
309            signed_gross,
310            total_fees.to_dec(),
311            "SpotPosition::pnl_at_price/net",
312        )?)
313    }
314
315    fn total_cost(&self) -> Result<Positive, PositionError> {
316        Ok(match self.side {
317            Side::Long => self
318                .quantity
319                .checked_mul(&self.cost_basis)?
320                .checked_add(&self.open_fee)?
321                .checked_add(&self.close_fee)?,
322            Side::Short => self.open_fee.checked_add(&self.close_fee)?,
323        })
324    }
325
326    fn fees(&self) -> Result<Positive, PositionError> {
327        Ok(self.open_fee.checked_add(&self.close_fee)?)
328    }
329}
330
331impl std::fmt::Display for SpotPosition {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        write!(
334            f,
335            "{} {} {} @ {} (fees: ",
336            self.side, self.quantity, self.symbol, self.cost_basis,
337        )?;
338        // Fees whose total leaves the `Positive` range are shown as the sum
339        // they are; `Positive + Positive` aborted on them (#788).
340        match self.open_fee.checked_add(&self.close_fee) {
341            Ok(total) => write!(f, "{total})"),
342            Err(_) => write!(f, "{} + {})", self.open_fee, self.close_fee),
343        }
344    }
345}
346
347#[cfg(feature = "clock")]
348impl Default for SpotPosition {
349    fn default() -> Self {
350        Self {
351            symbol: String::new(),
352            quantity: Positive::ZERO,
353            cost_basis: Positive::ZERO,
354            side: Side::Long,
355            date: wall_clock(),
356            open_fee: Positive::ZERO,
357            close_fee: Positive::ZERO,
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    #[cfg(feature = "clock")]
365    use super::*;
366    #[cfg(feature = "clock")]
367    use positive::pos_or_panic;
368
369    #[cfg(feature = "clock")]
370    #[test]
371    fn test_spot_position_new() {
372        let spot = SpotPosition::new(
373            "AAPL".to_string(),
374            Positive::HUNDRED,
375            pos_or_panic!(150.0),
376            Side::Long,
377            wall_clock(),
378            Positive::ONE,
379            Positive::ONE,
380        );
381
382        assert_eq!(spot.symbol, "AAPL");
383        assert_eq!(spot.quantity, Positive::HUNDRED);
384        assert_eq!(spot.cost_basis, pos_or_panic!(150.0));
385        assert_eq!(spot.side, Side::Long);
386        assert_eq!(spot.open_fee, Positive::ONE);
387        assert_eq!(spot.close_fee, Positive::ONE);
388    }
389
390    #[cfg(feature = "clock")]
391    #[test]
392    fn test_spot_position_long_convenience() {
393        let spot = SpotPosition::long("BTC".to_string(), Positive::ONE, pos_or_panic!(50000.0));
394
395        assert_eq!(spot.symbol, "BTC");
396        assert_eq!(spot.quantity, Positive::ONE);
397        assert_eq!(spot.cost_basis, pos_or_panic!(50000.0));
398        assert_eq!(spot.side, Side::Long);
399        assert_eq!(spot.open_fee, Positive::ZERO);
400    }
401
402    #[cfg(feature = "clock")]
403    #[test]
404    fn test_spot_position_short_convenience() {
405        let spot = SpotPosition::short(
406            "ETH".to_string(),
407            pos_or_panic!(10.0),
408            pos_or_panic!(3000.0),
409        );
410
411        assert_eq!(spot.symbol, "ETH");
412        assert_eq!(spot.quantity, pos_or_panic!(10.0));
413        assert_eq!(spot.cost_basis, pos_or_panic!(3000.0));
414        assert_eq!(spot.side, Side::Short);
415    }
416
417    #[cfg(feature = "clock")]
418    #[test]
419    fn test_initial_value() {
420        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
421        assert_eq!(spot.initial_value().ok(), Some(pos_or_panic!(15000.0)));
422    }
423
424    #[cfg(feature = "clock")]
425    #[test]
426    fn test_market_value() {
427        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
428        assert_eq!(
429            spot.market_value(pos_or_panic!(160.0)).ok(),
430            Some(pos_or_panic!(16000.0))
431        );
432    }
433
434    #[cfg(feature = "clock")]
435    #[test]
436    fn test_long_pnl_profit() {
437        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
438        let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
439        assert_eq!(pnl.ok(), Some(Decimal::from(1000))); // (160-150) * 100
440    }
441
442    #[cfg(feature = "clock")]
443    #[test]
444    fn test_long_pnl_loss() {
445        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
446        let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
447        assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); // (140-150) * 100
448    }
449
450    #[cfg(feature = "clock")]
451    #[test]
452    fn test_short_pnl_profit() {
453        let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
454        let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
455        assert_eq!(pnl.ok(), Some(Decimal::from(1000))); // Short profits when price drops
456    }
457
458    #[cfg(feature = "clock")]
459    #[test]
460    fn test_short_pnl_loss() {
461        let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
462        let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
463        assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); // Short loses when price rises
464    }
465
466    #[cfg(feature = "clock")]
467    #[test]
468    fn test_pnl_with_fees() {
469        let spot = SpotPosition::new(
470            "AAPL".to_string(),
471            Positive::HUNDRED,
472            pos_or_panic!(150.0),
473            Side::Long,
474            wall_clock(),
475            pos_or_panic!(10.0),
476            pos_or_panic!(10.0),
477        );
478        let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
479        assert_eq!(pnl.ok(), Some(Decimal::from(980))); // 1000 profit - 20 fees
480    }
481
482    /// `cost_basis` and `quantity` are `pub`, so a spot leg can hold a
483    /// price difference the `Decimal` range cannot express. Before #471 the
484    /// trait signature had nowhere to say so and the process aborted with
485    /// `Subtraction overflowed`.
486    #[cfg(feature = "clock")]
487    #[test]
488    fn test_spot_position_pnl_at_price_overflowing_notional_is_reported() {
489        let mut spot =
490            SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
491        spot.quantity = Positive::MAX;
492
493        assert!(spot.pnl_at_price(Positive::MAX).is_err());
494    }
495
496    /// A long spot leg's `total_cost` multiplies size by cost basis before
497    /// adding the two fees, so both steps can leave the `Positive` range.
498    #[cfg(feature = "clock")]
499    #[test]
500    fn test_spot_position_total_cost_overflowing_notional_is_reported() {
501        let mut spot = SpotPosition::long("AAPL".to_string(), Positive::MAX, Positive::MAX);
502        spot.open_fee = Positive::ONE;
503
504        assert!(spot.total_cost().is_err());
505    }
506
507    /// A short leg pays only the fees, and those still have to sum.
508    #[cfg(feature = "clock")]
509    #[test]
510    fn test_spot_position_total_cost_overflowing_fees_is_reported() {
511        let mut spot =
512            SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
513        spot.open_fee = Positive::MAX;
514        spot.close_fee = Positive::MAX;
515
516        assert!(spot.total_cost().is_err());
517        assert!(spot.fees().is_err());
518    }
519
520    #[cfg(feature = "clock")]
521    #[test]
522    fn test_total_cost_long() {
523        let spot = SpotPosition::new(
524            "AAPL".to_string(),
525            Positive::HUNDRED,
526            pos_or_panic!(150.0),
527            Side::Long,
528            wall_clock(),
529            pos_or_panic!(10.0),
530            pos_or_panic!(10.0),
531        );
532        assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(15020.0))); // 15000 + 20 fees
533    }
534
535    #[cfg(feature = "clock")]
536    #[test]
537    fn test_total_cost_short() {
538        let spot = SpotPosition::new(
539            "AAPL".to_string(),
540            Positive::HUNDRED,
541            pos_or_panic!(150.0),
542            Side::Short,
543            wall_clock(),
544            pos_or_panic!(10.0),
545            pos_or_panic!(10.0),
546        );
547        assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(20.0))); // Only fees for short
548    }
549
550    #[cfg(feature = "clock")]
551    #[test]
552    fn test_percentage_return_long() {
553        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
554        let return_pct = spot.percentage_return(pos_or_panic!(110.0));
555        assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); // 10% = 0.1
556    }
557
558    #[cfg(feature = "clock")]
559    #[test]
560    fn test_percentage_return_short() {
561        let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
562        let return_pct = spot.percentage_return(pos_or_panic!(90.0));
563        assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); // 10% profit for short when price drops
564    }
565
566    #[cfg(feature = "clock")]
567    #[test]
568    fn test_break_even_long() {
569        let spot = SpotPosition::new(
570            "AAPL".to_string(),
571            Positive::HUNDRED,
572            pos_or_panic!(150.0),
573            Side::Long,
574            wall_clock(),
575            pos_or_panic!(50.0),
576            pos_or_panic!(50.0),
577        );
578        assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(151.0))); // 150 + (100/100)
579    }
580
581    #[cfg(feature = "clock")]
582    #[test]
583    fn test_break_even_short() {
584        let spot = SpotPosition::new(
585            "AAPL".to_string(),
586            Positive::HUNDRED,
587            pos_or_panic!(150.0),
588            Side::Short,
589            wall_clock(),
590            pos_or_panic!(50.0),
591            pos_or_panic!(50.0),
592        );
593        assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(149.0))); // 150 - (100/100)
594    }
595
596    // Each of these aborted before #788: `Positive * Positive` with
597    // `Positive arithmetic overflow in mul`, the return with `Division
598    // overflowed`, the fee per unit with `Positive arithmetic overflow in
599    // div` and the `Display` fee total with `... in add`.
600    #[cfg(feature = "clock")]
601    #[test]
602    fn test_extreme_values_are_errors_not_aborts() {
603        let wide = SpotPosition::long("X".to_string(), Positive::MAX, Positive::TWO);
604        assert!(wide.initial_value().is_err());
605        assert!(wide.market_value(Positive::TWO).is_err());
606
607        let tiny_basis = SpotPosition::long("X".to_string(), Positive::ONE, pos_or_panic!(1e-27));
608        assert!(matches!(
609            tiny_basis.percentage_return(Positive::MAX),
610            Err(PositionError::DecimalError(_))
611        ));
612
613        let mut tiny_quantity =
614            SpotPosition::long("X".to_string(), pos_or_panic!(1e-27), Positive::ONE);
615        tiny_quantity.open_fee = Positive::MAX;
616        assert!(tiny_quantity.break_even_price().is_err());
617
618        tiny_quantity.close_fee = Positive::MAX;
619        let shown = tiny_quantity.to_string();
620        assert!(shown.ends_with(&format!("(fees: {} + {})", Positive::MAX, Positive::MAX)));
621    }
622
623    #[cfg(feature = "clock")]
624    #[test]
625    fn test_display() {
626        let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
627        let display = format!("{}", spot);
628        assert!(display.contains("Long"));
629        assert!(display.contains("AAPL"));
630        assert!(display.contains("100"));
631    }
632
633    #[cfg(feature = "clock")]
634    #[test]
635    fn test_is_long_short() {
636        let long = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
637        let short =
638            SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
639
640        assert!(long.is_long());
641        assert!(!long.is_short());
642        assert!(!short.is_long());
643        assert!(short.is_short());
644    }
645}