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