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}