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(×tamp, "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}