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