Skip to main content

optionstratlib_core/model/
trade.rs

1use crate::error::TradeError;
2use crate::model::types::Action;
3use crate::model::types::{OptionStyle, Side};
4use crate::utils::time::now;
5use chrono::{DateTime, Utc};
6use positive::Positive;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::fs::File;
10use std::io::Write;
11use std::{fmt, io};
12
13/// # Transaction Status
14///
15/// Represents the current state of an options transaction in the system.
16///
17/// This enum tracks the lifecycle status of option transactions as they move
18/// through various states from creation to completion. Each status represents
19/// a meaningful business state that affects how the transaction is processed,
20/// displayed, and included in profit and loss calculations.
21///
22/// ## Status Values
23///
24/// * `Open` - The transaction is currently active and has not been closed or settled
25/// * `Closed` - The transaction has been manually closed before expiration
26/// * `Expired` - The transaction reached its expiration date without being exercised
27/// * `Exercised` - The option was exercised, converting it to a position in the underlying asset
28/// * `Assigned` - For short options, indicates the counterparty exercised the option
29#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
31pub enum TradeStatus {
32    /// * `open` - The transaction is open and active
33    #[default]
34    Open,
35
36    /// * `closed` - The transaction has been closed
37    Closed,
38
39    /// * `expired` - The transaction has expired
40    Expired,
41
42    /// * `exercised` - The transaction has been exercised
43    Exercised,
44
45    /// * `assigned` - The transaction has been assigned
46    Assigned,
47
48    /// This enum represents different categories or classifications, including the "Other" category.
49    ///
50    /// Use the `Other` case to represent items or instances that do not fall into predefined categories.
51    Other(String),
52}
53
54/// A trait representing the status management of a trade.
55///
56/// This trait provides methods for transitioning a trade into various predefined statuses.
57/// Implementations of this trait should define how a trade moves between these statuses.
58///
59/// Each method returns a `Trade` instance representing the trade in its updated status.
60pub trait TradeStatusAble {
61    /// - `open`: Return a `Trade` instance representing the trade in its open status or a
62    ///   TradeError if the transition is invalid.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`TradeError::InvalidTrade`] when the current state cannot
67    /// transition to the `Open` status (for example, attempting to re-open a
68    /// trade that has already been closed, exercised, or assigned).
69    fn open(&self) -> Result<Trade, TradeError>;
70    /// - `closed`: Return a `Trade` instance representing the trade in its closed status or a
71    ///   TradeError if the transition is invalid.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`TradeError::InvalidTrade`] when the trade is not currently
76    /// in a state that allows transitioning to `Closed` (e.g. a trade that
77    /// has not been opened yet).
78    fn close(&self) -> Result<Trade, TradeError>;
79    /// - `expired`: Return a `Trade` instance representing the trade in its expired status or a
80    ///   TradeError if the transition is invalid.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`TradeError::InvalidTrade`] when the trade is not in a state
85    /// that allows transitioning to `Expired` (e.g. already closed or
86    /// exercised).
87    fn expired(&self) -> Result<Trade, TradeError>;
88    /// - `exercised`: Return a `Trade` instance representing the trade in its exercised status or a
89    ///   TradeError if the transition is invalid.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`TradeError::InvalidTrade`] when the option cannot legally
94    /// be exercised from the current trade state (e.g. the trade has
95    /// already expired or been assigned).
96    fn exercised(&self) -> Result<Trade, TradeError>;
97    /// - `assigned`: Return a `Trade` instance representing the trade in its assigned status or a
98    ///   TradeError if the transition is invalid.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`TradeError::InvalidTrade`] when the trade cannot be moved
103    /// into the `Assigned` state from its current status.
104    fn assigned(&self) -> Result<Trade, TradeError>;
105    /// - `status_other`: Return a `Trade` instance representing undeclared status or a
106    ///   TradeError if the transition is invalid.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`TradeError::InvalidTrade`] when the implementation rejects
111    /// the transition to a non-canonical status (the semantics are left to
112    /// the implementor).
113    fn status_other(&self) -> Result<Trade, TradeError>;
114}
115
116/// Represents a trade with detailed information such as action, side, option style,
117/// associated fees, and various metadata.
118///
119/// # Contract size
120///
121/// A trade carries its own `contract_size`, so the record stays
122/// self-contained once the position it came from is gone. `premium` is
123/// quoted per unit of the underlying and `fee` per contract: the trade's
124/// premium is `premium × contract_size × quantity` and its fees
125/// `fee × quantity`. [`Trade::cost`], [`Trade::income`] and [`Trade::net`]
126/// combine them with checked arithmetic and return a
127/// [`TradeError::ArithmeticOverflow`] when the result leaves the
128/// representable range.
129#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
130pub struct Trade {
131    /// * `id` - A universally unique identifier (`UUID`) for the trade.
132    pub id: uuid::Uuid,
133    /// * `action` - The action associated with the trade (e.g., Buy or Sell).
134    pub action: Action,
135    /// * `side` - Indicates whether the trade is on the Long or Short side.
136    pub side: Side,
137    /// Specifies the style of an options contract Call or Put.
138    pub option_style: OptionStyle,
139    /// * `fee` - The transaction fee associated with the trade, represented as a positive value.
140    pub fee: Positive,
141    /// * `symbol` - An optional ticker symbol for the trade (e.g., "AAPL" for Apple Inc.).
142    pub symbol: Option<String>, // “AAPL”
143    /// * `strike` - The strike price of the option, represented as a positive value (e.g., 180.0).
144    pub strike: Positive, // 180.0
145    /// * `expiry` - The expiration date of the Option, represented as a `DateTime<Utc>` (e.g., 2025-06-20T00:00:00Z).
146    pub expiry: DateTime<Utc>, // 2025-06-20T00:00:00Z
147    /// * `timestamp` - The trade execution time in nanoseconds since the Unix epoch
148    ///   (1970-01-01 00:00:00 UTC). Serialized using nanoseconds with `serde`.
149    #[serde(with = "ts_ns")]
150    pub timestamp: i64,
151    /// * `quantity` - The number of contracts traded, represented as a positive value.
152    pub quantity: Positive, // contracts traded
153    /// * `premium` - The premium per unit of the underlying, as quoted. One
154    ///   contract pays `premium × contract_size`.
155    pub premium: Positive, // premium per unit of the underlying
156    /// * `contract_size` - The contract multiplier: units of the underlying
157    ///   covered by one contract (100 for a standard US equity option).
158    ///   Defaults to 1, so a trade recorded before the field existed
159    ///   deserializes unchanged. The trade carries its own multiplier so the
160    ///   record stays self-contained after the position it came from is gone.
161    #[serde(default = "default_contract_size")]
162    pub contract_size: Positive,
163    /// * `underlying_price` - The price of the underlying asset, represented as a positive value.
164    pub underlying_price: Positive,
165    /// * `notes` - Optional free-form notes associated with the trade.
166    pub notes: Option<String>,
167
168    /// Represents the current status of a trade.
169    ///
170    /// This `status` field indicates the state of a trade during its lifecycle.
171    /// It uses the `TradeStatus` enum to define the possible statuses.
172    ///
173    /// This field is essential for tracking and managing the lifecycle of a trade.
174    pub status: TradeStatus,
175}
176
177/// Serde default for [`Trade::contract_size`]: one unit of the underlying per
178/// contract, the behaviour before the field existed.
179#[inline]
180#[must_use]
181fn default_contract_size() -> Positive {
182    Positive::ONE
183}
184
185/// Nanoseconds in one second, for the whole-second timestamp fallback.
186const NANOS_PER_SECOND: i64 = 1_000_000_000;
187
188/// `datetime` in nanoseconds since the Unix epoch, falling back to its whole
189/// seconds in nanoseconds when the exact figure leaves the `i64` range.
190///
191/// # Errors
192///
193/// Returns [`TradeError::ArithmeticOverflow`] for `operation` when the whole
194/// seconds in nanoseconds leave the `i64` range too.
195fn timestamp_nanos(datetime: &DateTime<Utc>, operation: &'static str) -> Result<i64, TradeError> {
196    if let Some(nanos) = datetime.timestamp_nanos_opt() {
197        return Ok(nanos);
198    }
199    datetime
200        .timestamp()
201        .checked_mul(NANOS_PER_SECOND)
202        .ok_or_else(|| {
203            TradeError::arithmetic_overflow(
204                operation,
205                format!("{datetime} in nanoseconds since the Unix epoch leaves the i64 range"),
206            )
207        })
208}
209
210impl Trade {
211    /// Creates a new instance of the struct with the provided parameters.
212    ///
213    /// # Parameters
214    /// - `id` (`uuid::Uuid`): The unique identifier for the entity.
215    /// - `action` (`Action`): The action being performed (e.g., buy, sell).
216    /// - `side` (`Side`): The side of the transaction (e.g., long, short).
217    /// - `option_style` (`OptionStyle`): The style of the option (e.g., American, European).
218    /// - `fee` (`Positive`): The fee associated with the transaction. It must be a positive value.
219    /// - `symbol` (`Option<String>`): The symbol of the underlying asset, if applicable.
220    /// - `strike` (`Positive`): The strike price of the option. This must be a positive value.
221    /// - `expiry` (`DateTime<Utc>`): The expiration date and time of the option in UTC.
222    /// - `quantity` (`Positive`): The quantity involved in the transaction. This must be a positive value.
223    /// - `premium` (`Positive`): The premium per unit of the underlying.
224    /// - `underlying_price` (`Positive`): The price of the underlying asset. This must be a positive value.
225    /// - `notes` (`Option<String>`): Any additional notes or metadata for the transaction.
226    ///
227    /// # Returns
228    /// An instance of the struct initialized with the provided parameters, along with a timestamp
229    /// (`i64`) in nanoseconds representing the moment of creation. The contract size is 1; chain
230    /// [`Trade::with_contract_size`] to record a larger multiplier.
231    ///
232    /// # Errors
233    /// Returns [`TradeError::ArithmeticOverflow`] when the current time in nanoseconds since the
234    /// Unix epoch leaves the `i64` range, after 2262, even when truncated to whole seconds, and
235    /// [`TradeError::Clock`] in a build without the `clock` feature: use [`Trade::new_at`].
236    ///
237    /// # Remarks
238    /// - Ensure all `Positive` values are validated and created using the appropriate constructors.
239    /// - The `symbol` and `notes` parameters are optional and can be set to `None` if not applicable.
240    ///
241    #[allow(clippy::too_many_arguments)]
242    pub fn new(
243        id: uuid::Uuid,
244        action: Action,
245        side: Side,
246        option_style: OptionStyle,
247        fee: Positive,
248        symbol: Option<String>,
249        strike: Positive,
250        expiry: DateTime<Utc>,
251        quantity: Positive,
252        premium: Positive,
253        underlying_price: Positive,
254        notes: Option<String>,
255        status: TradeStatus,
256    ) -> Result<Self, TradeError> {
257        Self::new_at(
258            id,
259            action,
260            side,
261            option_style,
262            fee,
263            symbol,
264            strike,
265            expiry,
266            quantity,
267            premium,
268            underlying_price,
269            notes,
270            status,
271            now()?,
272        )
273    }
274
275    /// Creates a trade stamped with `timestamp` instead of the current time.
276    ///
277    /// The other parameters are those of [`Trade::new`]. Reads no clock, so it
278    /// exists and gives the same trade with or without the `clock` feature.
279    ///
280    /// # Errors
281    /// Returns [`TradeError::ArithmeticOverflow`] when `timestamp` in nanoseconds since the Unix
282    /// epoch leaves the `i64` range, even when truncated to whole seconds.
283    #[allow(clippy::too_many_arguments)]
284    pub fn new_at(
285        id: uuid::Uuid,
286        action: Action,
287        side: Side,
288        option_style: OptionStyle,
289        fee: Positive,
290        symbol: Option<String>,
291        strike: Positive,
292        expiry: DateTime<Utc>,
293        quantity: Positive,
294        premium: Positive,
295        underlying_price: Positive,
296        notes: Option<String>,
297        status: TradeStatus,
298        timestamp: DateTime<Utc>,
299    ) -> Result<Self, TradeError> {
300        let timestamp = timestamp_nanos(&timestamp, "Trade::new")?;
301        Ok(Self {
302            id,
303            action,
304            side,
305            option_style,
306            fee,
307            symbol,
308            strike,
309            expiry,
310            timestamp,
311            quantity,
312            premium,
313            contract_size: default_contract_size(),
314            underlying_price,
315            notes,
316            status,
317        })
318    }
319
320    /// Returns the trade with its contract multiplier set to `contract_size`.
321    ///
322    /// [`Trade::new`] records a contract that covers one unit of the
323    /// underlying; chain this to record market contracts, for example
324    /// `.with_contract_size(Positive::HUNDRED)` for a standard US equity
325    /// option.
326    #[must_use = "with_contract_size returns the updated trade and leaves the original untouched"]
327    #[inline]
328    pub fn with_contract_size(mut self, contract_size: Positive) -> Self {
329        self.contract_size = contract_size;
330        self
331    }
332
333    /// Convert back to `DateTime<Utc>` when you need it for pretty printing.
334    #[must_use]
335    pub fn datetime(&self) -> DateTime<Utc> {
336        DateTime::<Utc>::from_timestamp_nanos(self.timestamp)
337    }
338
339    /// Sets the timestamp for the current instance using the provided `DateTime<Utc>` value.
340    ///
341    /// # Parameters
342    /// - `datetime`: A `DateTime<Utc>` object representing the new timestamp to be set.
343    ///
344    /// # Note
345    /// If the nanosecond representation overflows, falls back to the whole seconds in
346    /// nanoseconds.
347    ///
348    /// # Errors
349    /// Returns [`TradeError::ArithmeticOverflow`] when `datetime` in nanoseconds since the Unix
350    /// epoch leaves the `i64` range even when truncated to whole seconds (before 1677 or after
351    /// 2262); the timestamp is left unchanged then.
352    pub fn set_timestamp(&mut self, datetime: DateTime<Utc>) -> Result<(), TradeError> {
353        self.timestamp = timestamp_nanos(&datetime, "Trade::set_timestamp")?;
354        Ok(())
355    }
356
357    /// The fees of the whole trade: the per-contract `fee` times `quantity`.
358    ///
359    /// # Errors
360    ///
361    /// Returns [`TradeError::ArithmeticOverflow`] when the product leaves the
362    /// `Positive` range.
363    #[inline]
364    fn total_fees(&self) -> Result<Positive, TradeError> {
365        self.fee
366            .checked_mul(&self.quantity)
367            .map_err(|e| TradeError::arithmetic_overflow("Trade::fees", e))
368    }
369
370    /// The premium of the whole trade: the per-unit `premium` times
371    /// `contract_size × quantity`.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`TradeError::ArithmeticOverflow`] when the product leaves the
376    /// `Positive` range.
377    #[inline]
378    fn total_premium(&self) -> Result<Positive, TradeError> {
379        self.premium
380            .checked_mul(&self.contract_size)
381            .and_then(|per_contract| per_contract.checked_mul(&self.quantity))
382            .map_err(|e| TradeError::arithmetic_overflow("Trade::premium", e))
383    }
384
385    /// Calculates the total cost associated with a transaction based on the action,
386    /// side, fee, and premium.
387    ///
388    /// # Returns
389    /// A `Positive` type, which represents the total cost of the transaction.
390    ///
391    /// # Logic
392    /// - The total cost is determined by the transaction's `action` (Buy/Sell),
393    ///   `side` (Long/Short), `fee`, and `premium`, all adjusted by the `quantity`.
394    /// - The `fee` is per contract and is multiplied by the `quantity`. The
395    ///   `premium` is per unit of the underlying and is multiplied by
396    ///   `contract_size × quantity`.
397    /// - Depending on the combination of `action` and `side`, the following rules are applied:
398    ///   - `(Action::Buy, Side::Long)` or `(Action::Sell, Side::Short)`:
399    ///     The cost includes both `fees` and `premium`.
400    ///   - `(Action::Buy, Side::Short)` or `(Action::Sell, Side::Long)`:
401    ///     The cost includes only `fees`.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`TradeError::ArithmeticOverflow`] when the fees, the premium
406    /// or their sum leaves the `Positive` range. Every field is a public
407    /// `Positive`, so the products can overflow even though each value is
408    /// valid on its own.
409    pub fn cost(&self) -> Result<Positive, TradeError> {
410        match (self.action, self.side) {
411            (Action::Buy, Side::Long) | (Action::Sell, Side::Short) => self
412                .total_premium()?
413                .checked_add(&self.total_fees()?)
414                .map_err(|e| TradeError::arithmetic_overflow("Trade::cost", e)),
415            (Action::Buy, Side::Short) | (Action::Sell, Side::Long) => self.total_fees(),
416            _ => Ok(Positive::ZERO),
417        }
418    }
419
420    /// Computes the income generated based on the action and side of a trade.
421    ///
422    /// # Description
423    /// This function calculates the income by determining the premium associated
424    /// with the current object's `quantity`, `contract_size` and `premium` values. The resulting
425    /// income depends on the combination of the `action` and `side` values:
426    ///
427    /// - If the action is `Buy` and the side is `Long`, or if the action is `Sell`
428    ///   and the side is `Short`, the income is `0`.
429    /// - If the action is `Buy` and the side is `Short`, or if the action is `Sell`
430    ///   and the side is `Long`, the function returns the computed `premium`.
431    ///
432    /// # Returns
433    /// - `Positive::ZERO`: If the `action` and `side` combination does not result
434    ///   in an income.
435    /// - A `Positive` value (equal to the computed `premium`): For combinations
436    ///   where income is generated.
437    ///
438    /// # Errors
439    ///
440    /// Returns [`TradeError::ArithmeticOverflow`] when `premium ×
441    /// contract_size × quantity` leaves the `Positive` range.
442    ///
443    /// # Dependencies
444    /// This method relies on:
445    /// - `Action` enum (expected values: `Buy`, `Sell`)
446    /// - `Side` enum (expected values: `Long`, `Short`)
447    /// - `Positive` type for representing non-negative values.
448    pub fn income(&self) -> Result<Positive, TradeError> {
449        match (self.action, self.side) {
450            (Action::Buy, Side::Long) | (Action::Sell, Side::Short) => Ok(Positive::ZERO),
451            (Action::Buy, Side::Short) | (Action::Sell, Side::Long) => self.total_premium(),
452            _ => Ok(Positive::ZERO),
453        }
454    }
455
456    /// Calculates the net value by subtracting the cost from the income.
457    ///
458    /// # Returns
459    ///
460    /// A `Decimal` value representing the net result of income minus cost.
461    ///
462    /// # Behavior
463    /// - The `income()` method is called to obtain the income value, which is then converted to a `Decimal` using `to_dec()`.
464    /// - The `cost()` method is called to obtain the cost value, which is also converted to a `Decimal` using `to_dec()`.
465    /// - The net value is computed as: `income.to_dec() - cost.to_dec()`.
466    ///
467    /// # Errors
468    ///
469    /// Propagates [`Trade::cost`] and [`Trade::income`], and returns
470    /// [`TradeError::ArithmeticOverflow`] when the difference leaves the
471    /// `Decimal` range.
472    pub fn net(&self) -> Result<Decimal, TradeError> {
473        self.income()?
474            .to_dec()
475            .checked_sub(self.cost()?.to_dec())
476            .ok_or_else(|| TradeError::arithmetic_overflow("Trade::net", "income - cost"))
477    }
478
479    /// Checks if the current trade status is `Open`.
480    ///
481    /// # Returns
482    ///
483    /// * `true` - If the trade's status is `Open`.
484    /// * `false` - If the trade's status is not `Open`.
485    ///
486    /// This method is commonly used to determine whether a trade is still active and can be interacted with.
487    #[must_use]
488    pub fn is_open(&self) -> bool {
489        self.status == TradeStatus::Open
490    }
491
492    /// Determines whether the trade is closed.
493    ///
494    /// This method checks the status of a trade and returns `true`
495    /// if the trade’s status is `TradeStatus::Closed`, otherwise returns `false`.
496    ///
497    /// # Returns
498    /// * `bool` - `true` if the trade's status is `TradeStatus::Closed`, `false` otherwise.
499    ///
500    #[must_use]
501    pub fn is_closed(&self) -> bool {
502        self.status == TradeStatus::Closed
503    }
504
505    /// Checks if the trade has expired.
506    ///
507    /// This method compares the current `status` of the trade with `TradeStatus::Expired`
508    /// and returns `true` if the trade is expired, otherwise `false`.
509    ///
510    /// # Returns
511    /// * `true` - If the trade's status is `TradeStatus::Expired`.
512    /// * `false` - If the trade's status is not `TradeStatus::Expired`.
513    ///
514    #[must_use]
515    pub fn is_expired(&self) -> bool {
516        self.status == TradeStatus::Expired
517    }
518}
519
520impl fmt::Display for Trade {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        let string =
523            serde_json::to_string(self).unwrap_or_else(|e| format!(r#"{{"error":"{e}"}}"#));
524        f.write_str(&string)
525    }
526}
527
528impl TradeAble for Trade {
529    fn trade(&self) -> Result<Trade, TradeError> {
530        Ok(self.clone())
531    }
532
533    fn trade_ref(&self) -> Result<&Trade, TradeError> {
534        Ok(self)
535    }
536
537    fn trade_mut(&mut self) -> Result<&mut Trade, TradeError> {
538        Ok(self)
539    }
540}
541
542/// Saves a list of trades to a file in JSON format.
543///
544/// # Parameters
545/// - `trades`: A slice containing trade data to be saved. Each trade must implement serialization.
546/// - `file_path`: The path to the file where the trade data will be saved. If the file already exists, its contents will be overwritten.
547///
548/// # Returns
549/// - `Ok(())` if the trades are successfully saved to the file.
550/// - `Err(io::Error)` if an I/O error occurs during file creation or writing, or if serialization fails.
551///
552/// # JSON Formatting
553/// The trades are serialized to JSON using compact formatting (without pretty printing).
554///
555/// # Errors
556/// This function will return an error if:
557/// - Serialization of the `trades` slice to JSON fails.
558/// - The file cannot be created or opened at the specified `file_path`.
559/// - Writing to the file fails.
560///
561pub fn save_trades(trades: &[Trade], file_path: &str) -> io::Result<()> {
562    // Serialize to compact JSON without pretty formatting
563    let json =
564        serde_json::to_string(trades).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
565
566    // Create or open the file for writing
567    let mut file = File::create(file_path)?;
568
569    // Write the JSON string to the file
570    file.write_all(json.as_bytes())?;
571
572    Ok(())
573}
574
575/// A trait that provides functionality for accessing and modifying trade-related data.
576///
577/// Implementors of this trait should provide mechanisms to retrieve both immutable
578/// and mutable references to a `Trade` object.
579///
580pub trait TradeAble {
581    /// Retrieves a reference to a `Trade` instance associated with the current object.
582    ///
583    /// # Returns
584    ///
585    /// A `Trade` object, or a `TradeError` if the trade cannot be accessed.
586    ///
587    /// This method allows access to the `Trade` data within the context of the implementing object.
588    /// It ensures that the `Trade` is available for viewing or interaction.
589    ///
590    /// Note: This method returns an owned `Trade` instance, allowing modification.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`TradeError::InvalidTrade`] when the implementor cannot
595    /// materialize a `Trade` from its current state (typically when the
596    /// container does not yet hold a committed trade).
597    fn trade(&self) -> Result<Trade, TradeError>;
598
599    /// Returns a reference to the `Trade` associated with the current instance.
600    ///
601    /// # Returns
602    /// A reference to the `Trade` object tied to this instance, or a `TradeError` if the trade
603    /// cannot be accessed.
604    ///
605    /// # Note
606    /// The returned reference has the same lifetime as the instance it is called on.
607    ///
608    /// # Errors
609    ///
610    /// Returns [`TradeError::InvalidTrade`] when the implementor does not
611    /// currently hold a materialized `Trade` to borrow.
612    fn trade_ref(&self) -> Result<&Trade, TradeError>;
613
614    /// Provides a mutable reference to the `Trade` instance contained within the current structure.
615    ///
616    /// # Returns
617    ///
618    /// A mutable reference to the internal `Trade` instance, or a `TradeError` if the trade
619    /// cannot be accessed.
620    /// This allows the caller to modify the `Trade` directly.
621    ///
622    /// # Notes
623    ///
624    /// - Since this method provides a mutable reference, it enforces Rust's borrow rules.
625    ///   Only one mutable reference to the `Trade` is allowed at a time.
626    /// - Ensure that concurrent access to the structure is properly managed to avoid runtime issues.
627    ///
628    /// # Errors
629    ///
630    /// Returns [`TradeError::InvalidTrade`] when the implementor does not
631    /// currently hold a materialized `Trade` to borrow mutably.
632    fn trade_mut(&mut self) -> Result<&mut Trade, TradeError>;
633}
634
635mod ts_ns {
636    use serde::{self, Deserialize, Deserializer, Serializer};
637
638    pub fn serialize<S>(nanos: &i64, s: S) -> Result<S::Ok, S::Error>
639    where
640        S: Serializer,
641    {
642        s.serialize_i64(*nanos)
643    }
644
645    pub fn deserialize<'de, D>(d: D) -> Result<i64, D::Error>
646    where
647        D: Deserializer<'de>,
648    {
649        i64::deserialize(d) // read it straight back as i64
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    use chrono::{TimeZone, Utc};
658    use positive::pos_or_panic;
659    use rust_decimal::Decimal;
660    use rust_decimal_macros::dec;
661    use uuid::Uuid;
662
663    /// Helper: build a deterministic sample Trade we can reuse.
664    fn sample_trade() -> Trade {
665        Trade {
666            id: Uuid::nil(),
667            action: Action::Buy,
668            side: Side::Long,
669            option_style: OptionStyle::Call,
670            fee: Positive::new_decimal(Decimal::new(15, 2)).unwrap(), // 0.15
671            symbol: Some("AAPL".to_string()),
672            strike: Positive::new_decimal(Decimal::new(1800, 1)).unwrap(), // 180.0
673            expiry: Utc.with_ymd_and_hms(2025, 6, 20, 0, 0, 0).unwrap(),
674            timestamp: 1_700_000_000_000_000_000, // arbitrary nanos
675            quantity: Positive::new_decimal(Decimal::from(3u32)).unwrap(),
676            premium: pos_or_panic!(2.5), // 2.50
677            contract_size: Positive::ONE,
678            underlying_price: Positive::new_decimal(Decimal::new(1850, 1)).unwrap(), // 185.0
679            notes: Some("unit-test".to_string()),
680            status: TradeStatus::Open,
681        }
682    }
683
684    #[test]
685    fn display_matches_serde_json() {
686        let trade = sample_trade();
687        let expect = serde_json::to_string(&trade).unwrap();
688        assert_eq!(expect, trade.to_string());
689    }
690
691    #[test]
692    fn serde_roundtrip() {
693        let trade = sample_trade();
694        let json = serde_json::to_string(&trade).unwrap();
695        let back: Trade = serde_json::from_str(&json).unwrap();
696        assert_eq!(trade, back);
697    }
698
699    #[test]
700    fn test_trade_contract_size_missing_field_defaults_to_one() {
701        let trade = sample_trade().with_contract_size(Positive::HUNDRED);
702        let mut value = serde_json::to_value(&trade).unwrap();
703        value.as_object_mut().unwrap().remove("contract_size");
704        let legacy: Trade = serde_json::from_value(value).unwrap();
705        assert_eq!(legacy.contract_size, Positive::ONE);
706        assert_eq!(legacy, sample_trade());
707    }
708
709    #[test]
710    fn test_trade_contract_size_scales_premium_not_fees() {
711        let trade = sample_trade().with_contract_size(Positive::HUNDRED);
712        // (2.50 × 100 + 0.15) × 3
713        assert_eq!(trade.cost().unwrap(), pos_or_panic!(750.45));
714        assert_eq!(trade.income().unwrap(), Positive::ZERO);
715        assert_eq!(trade.net().unwrap(), dec!(-750.45));
716
717        let mut sold = trade.clone();
718        sold.action = Action::Sell;
719        // 2.50 × 100 × 3 received, 0.15 × 3 in fees.
720        assert_eq!(sold.income().unwrap(), pos_or_panic!(750.0));
721        assert_eq!(sold.cost().unwrap(), pos_or_panic!(0.45));
722        assert_eq!(sold.net().unwrap(), dec!(749.55));
723    }
724
725    #[test]
726    fn test_trade_cost_overflowing_premium_is_an_error() {
727        // `Positive::MAX × 100` leaves the range; the product is checked.
728        let trade = sample_trade().with_contract_size(Positive::HUNDRED);
729        let mut huge = trade.clone();
730        huge.premium = Positive::MAX;
731        assert!(matches!(
732            huge.cost(),
733            Err(TradeError::ArithmeticOverflow {
734                operation: "Trade::premium",
735                ..
736            })
737        ));
738        assert!(huge.net().is_err());
739
740        // A sale pays no premium away: only the fees are charged, so its cost
741        // does not touch the premium, but its income does.
742        let mut sold = huge.clone();
743        sold.action = Action::Sell;
744        assert_eq!(sold.cost().unwrap(), pos_or_panic!(0.45));
745        assert!(matches!(
746            sold.income(),
747            Err(TradeError::ArithmeticOverflow { .. })
748        ));
749    }
750
751    #[test]
752    fn test_trade_cost_overflowing_fees_is_an_error() {
753        let mut trade = sample_trade();
754        trade.fee = Positive::MAX;
755        assert!(matches!(
756            trade.cost(),
757            Err(TradeError::ArithmeticOverflow {
758                operation: "Trade::fees",
759                ..
760            })
761        ));
762        // A purchase's income is zero whatever the fees.
763        assert_eq!(trade.income().unwrap(), Positive::ZERO);
764    }
765
766    #[test]
767    fn test_trade_cost_overflowing_sum_is_an_error() {
768        // Premium and fees fit on their own, their sum does not.
769        let mut trade = sample_trade();
770        trade.quantity = Positive::ONE;
771        trade.premium = Positive::new_decimal(Decimal::MAX / Decimal::TWO).unwrap();
772        trade.fee = Positive::new_decimal(Decimal::MAX / Decimal::TWO).unwrap();
773        trade.premium = trade.premium.checked_add(&Positive::ONE).unwrap();
774        trade.fee = trade.fee.checked_add(&Positive::ONE).unwrap();
775        assert!(matches!(
776            trade.cost(),
777            Err(TradeError::ArithmeticOverflow {
778                operation: "Trade::cost",
779                ..
780            })
781        ));
782    }
783
784    #[cfg(feature = "clock")]
785    #[test]
786    fn test_trade_new_contract_size_is_one() {
787        let trade = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
788        assert_eq!(trade.contract_size, Positive::ONE);
789    }
790
791    #[test]
792    fn datetime_conversion_roundtrip() {
793        let trade = sample_trade();
794        let dt = trade.datetime();
795        assert_eq!(dt.timestamp_nanos_opt().unwrap(), trade.timestamp);
796    }
797
798    #[cfg(feature = "clock")]
799    #[test]
800    fn new_sets_reasonable_timestamp() {
801        // Allow up to 5 s drift between now() in ctor and now() here.
802        const FIVE_SECS_NS: i64 = 5_000_000_000;
803        let now_before = Utc::now().timestamp_nanos_opt().unwrap();
804        let trade = Trade::new(
805            Uuid::new_v4(),
806            Action::Sell,
807            Side::Short,
808            OptionStyle::Put,
809            Positive::new_decimal(Decimal::new(25, 2)).unwrap(),
810            None,
811            Positive::new_decimal(Decimal::new(2000, 1)).unwrap(), // 200.0
812            Utc::now(),
813            Positive::new_decimal(Decimal::from(1u32)).unwrap(),
814            pos_or_panic!(3.0),                                    // 3.00
815            Positive::new_decimal(Decimal::new(1900, 1)).unwrap(), // 190.0
816            None,
817            TradeStatus::Open,
818        )
819        .unwrap();
820        let now_after = Utc::now().timestamp_nanos_opt().unwrap();
821        assert!(trade.timestamp >= now_before - FIVE_SECS_NS);
822        assert!(trade.timestamp <= now_after + FIVE_SECS_NS);
823    }
824
825    #[test]
826    fn timestamp_field_is_json_number() {
827        let trade = sample_trade();
828        let v = serde_json::to_value(&trade).unwrap();
829        assert!(v["timestamp"].is_number());
830    }
831
832    #[cfg(feature = "clock")]
833    /// Build a reproducible sample trade so tests stay deterministic.
834    fn sample_trade_bis(action: Action, side: Side, status: TradeStatus) -> Trade {
835        Trade::new(
836            Uuid::nil(), // id
837            action,
838            side,
839            OptionStyle::Call,        // option_style
840            pos_or_panic!(0.15),      // fee   = 0.15
841            Some("AAPL".to_string()), // symbol
842            pos_or_panic!(180.0),     // strike = 180
843            Utc.with_ymd_and_hms(2025, 6, 20, 0, 0, 0).unwrap(),
844            pos_or_panic!(3.0),       // quantity = 3
845            pos_or_panic!(2.50),      // premium  = 2.50
846            pos_or_panic!(185.0),     // underlying_price
847            Some("unit-test".into()), // notes
848            status,
849        )
850        .unwrap()
851    }
852
853    #[cfg(feature = "clock")]
854    #[test]
855    fn new_sets_current_timestamp() {
856        let before = Utc::now().timestamp_nanos_opt().unwrap();
857        let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
858        let after = Utc::now().timestamp_nanos_opt().unwrap();
859        assert!(tr.timestamp >= before && tr.timestamp <= after);
860    }
861
862    #[cfg(feature = "clock")]
863    #[test]
864    fn datetime_roundtrip() {
865        let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
866        let dt = tr.datetime();
867        assert_eq!(dt.timestamp_nanos_opt().unwrap(), tr.timestamp);
868    }
869
870    #[cfg(feature = "clock")]
871    #[test]
872    fn set_timestamp_overwrites_value() {
873        let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
874        let new_dt = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
875        tr.set_timestamp(new_dt).unwrap();
876        assert_eq!(tr.datetime(), new_dt);
877    }
878
879    #[cfg(feature = "clock")]
880    #[test]
881    fn test_trade_set_timestamp_falls_back_to_whole_seconds() {
882        // The nanoseconds of the last second before i64::MAX overflow, its
883        // whole seconds do not.
884        let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
885        let late = DateTime::<Utc>::from_timestamp(9_223_372_036, 999_999_999).unwrap();
886        assert!(late.timestamp_nanos_opt().is_none());
887        tr.set_timestamp(late).unwrap();
888        assert_eq!(tr.timestamp, 9_223_372_036_000_000_000);
889    }
890
891    #[cfg(feature = "clock")]
892    #[test]
893    fn test_trade_set_timestamp_overflow_reports_error() {
894        let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
895        let before = tr.timestamp;
896        for datetime in [DateTime::<Utc>::MAX_UTC, DateTime::<Utc>::MIN_UTC] {
897            assert!(matches!(
898                tr.set_timestamp(datetime),
899                Err(TradeError::ArithmeticOverflow {
900                    operation: "Trade::set_timestamp",
901                    ..
902                })
903            ));
904            assert_eq!(tr.timestamp, before);
905        }
906    }
907
908    /* ---------- cost / income / net math ---------- */
909
910    #[cfg(feature = "clock")]
911    fn expect_cost_income(action: Action, side: Side, exp_cost: Decimal, exp_income: Decimal) {
912        let tr = sample_trade_bis(action, side, TradeStatus::Open);
913
914        assert_eq!(tr.cost().unwrap().to_dec(), exp_cost);
915        assert_eq!(tr.income().unwrap().to_dec(), exp_income);
916        assert_eq!(tr.net().unwrap(), exp_income - exp_cost);
917    }
918
919    #[cfg(feature = "clock")]
920    #[test]
921    fn cash_flows_buy_long() {
922        // cost  = (premium + fee) * qty  = (2.50 + 0.15) * 3 = 7.95
923        // income = 0
924        expect_cost_income(Action::Buy, Side::Long, dec!(7.95), dec!(0));
925    }
926
927    #[cfg(feature = "clock")]
928    #[test]
929    fn cash_flows_sell_long() {
930        // cost   = fee * qty            = 0.15 * 3 = 0.45
931        // income = premium * qty        = 2.50 * 3 = 7.50
932        expect_cost_income(Action::Sell, Side::Long, dec!(0.45), dec!(7.50));
933    }
934
935    #[cfg(feature = "clock")]
936    #[test]
937    fn cash_flows_buy_short() {
938        // cost   = fee * qty            = 0.45
939        // income = premium * qty        = 7.50
940        expect_cost_income(Action::Buy, Side::Short, dec!(0.45), dec!(7.50));
941    }
942
943    #[cfg(feature = "clock")]
944    #[test]
945    fn cash_flows_sell_short() {
946        // cost   = (premium + fee) * qty = 7.95
947        // income = 0
948        expect_cost_income(Action::Sell, Side::Short, dec!(7.95), dec!(0));
949    }
950
951    /* ---------- status helpers ---------- */
952
953    #[cfg(feature = "clock")]
954    #[test]
955    fn status_helpers_work() {
956        let open = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
957        let closed = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Closed);
958        let exp = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Expired);
959
960        assert!(open.is_open() && !open.is_closed() && !open.is_expired());
961        assert!(closed.is_closed() && !closed.is_open() && !closed.is_expired());
962        assert!(exp.is_expired() && !exp.is_open() && !exp.is_closed());
963    }
964
965    /* ---------- Display ---------- */
966
967    #[cfg(feature = "clock")]
968    #[test]
969    fn display_outputs_json() {
970        let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
971        let json = serde_json::to_string(&tr).unwrap();
972        assert_eq!(json, tr.to_string());
973    }
974
975    /* ---------- TradeAble trait ---------- */
976
977    #[cfg(feature = "clock")]
978    #[test]
979    fn tradeable_returns_same_ref() {
980        let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
981        let trait_obj: &dyn TradeAble = &tr;
982
983        // The reference handed back by trade() must be the very same object.
984        assert!(std::ptr::eq(
985            trait_obj.trade_ref().unwrap() as *const Trade,
986            &tr as *const Trade
987        ));
988    }
989
990    #[cfg(feature = "clock")]
991    #[test]
992    fn tradeable_mut_allows_mutation() {
993        let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
994        {
995            let trait_obj: &mut dyn TradeAble = &mut tr;
996            // Flip the status via the mutable reference returned by trade_mut()
997            let trade_ref = trait_obj.trade_mut().unwrap();
998            trade_ref.status = TradeStatus::Closed;
999        }
1000        // Change is visible on the original value
1001        assert!(tr.is_closed());
1002    }
1003
1004    #[cfg(feature = "clock")]
1005    #[test]
1006    fn tradeable_returns_cloned_trade() {
1007        let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
1008        let trait_obj: &dyn TradeAble = &tr;
1009
1010        // Get a Trade cloned object
1011        let tr_cloned = trait_obj
1012            .trade()
1013            .expect("trade() should return a cloned Trade object");
1014
1015        // Cloned object and original reference have the same values
1016        assert_eq!(tr_cloned, tr);
1017
1018        // Must not be the same allocation
1019        assert!(!std::ptr::eq(
1020            &tr_cloned as *const Trade,
1021            &tr as *const Trade
1022        ));
1023
1024        // Mutating the clone does not affect the original reference
1025        let mut tr_cloned = tr_cloned;
1026        tr_cloned.status = TradeStatus::Closed;
1027
1028        assert!(tr_cloned.is_closed());
1029        assert!(!tr.is_closed());
1030    }
1031
1032    /* ---------- TradeStatusAble ---------- */
1033
1034    #[cfg(feature = "clock")]
1035    /// helper: assert that two trades are identical except for the `status` field
1036    fn assert_same_except_status(a: &Trade, b: &Trade) {
1037        let mut aa = a.clone();
1038        aa.status = b.status.clone();
1039        assert_eq!(aa, *b);
1040    }
1041
1042    impl TradeStatusAble for Trade {
1043        fn open(&self) -> Result<Trade, TradeError> {
1044            let mut tr = self.clone();
1045            tr.status = TradeStatus::Open;
1046            Ok(tr)
1047        }
1048
1049        fn close(&self) -> Result<Trade, TradeError> {
1050            let mut tr = self.clone();
1051            tr.status = TradeStatus::Closed;
1052            Ok(tr)
1053        }
1054
1055        fn expired(&self) -> Result<Trade, TradeError> {
1056            let mut tr = self.clone();
1057            tr.status = TradeStatus::Expired;
1058            Ok(tr)
1059        }
1060
1061        fn exercised(&self) -> Result<Trade, TradeError> {
1062            let mut tr = self.clone();
1063            tr.status = TradeStatus::Exercised;
1064            Ok(tr)
1065        }
1066
1067        fn assigned(&self) -> Result<Trade, TradeError> {
1068            let mut tr = self.clone();
1069            tr.status = TradeStatus::Assigned;
1070            Ok(tr)
1071        }
1072
1073        fn status_other(&self) -> Result<Trade, TradeError> {
1074            let mut tr = self.clone();
1075            tr.status = TradeStatus::Other("other".to_string());
1076            Ok(tr)
1077        }
1078    }
1079
1080    #[cfg(feature = "clock")]
1081    #[test]
1082    fn status_transitions_return_new_trade() {
1083        let base = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
1084
1085        // Closed
1086        let closed = base.close().unwrap();
1087        assert_eq!(closed.status, TradeStatus::Closed);
1088        assert_same_except_status(&base, &closed);
1089        assert_eq!(base.status, TradeStatus::Open); // original untouched
1090
1091        // Expired
1092        let expired = base.expired().unwrap();
1093        assert_eq!(expired.status, TradeStatus::Expired);
1094        assert_same_except_status(&base, &expired);
1095
1096        // Exercised
1097        let exercised = base.exercised().unwrap();
1098        assert_eq!(exercised.status, TradeStatus::Exercised);
1099        assert_same_except_status(&base, &exercised);
1100
1101        // Assigned
1102        let assigned = base.assigned().unwrap();
1103        assert_eq!(assigned.status, TradeStatus::Assigned);
1104        assert_same_except_status(&base, &assigned);
1105
1106        // Open (idempotent transition)
1107        let reopened = closed.open().unwrap();
1108        assert_eq!(reopened.status, TradeStatus::Open);
1109        assert_same_except_status(&base, &reopened);
1110    }
1111
1112    #[cfg(feature = "clock")]
1113    #[test]
1114    fn status_other_sets_custom_string() {
1115        let base = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
1116        let other = base.status_other().unwrap();
1117        if let TradeStatus::Other(s) = &other.status {
1118            // default impl should put some non-empty tag
1119            assert!(!s.is_empty(), "status_other() must fill the string");
1120        } else {
1121            panic!("status_other() did not return TradeStatus::Other");
1122        }
1123        assert_same_except_status(&base, &other);
1124    }
1125}