use crate::error::TradeError;
use crate::model::types::Action;
use crate::model::types::{OptionStyle, Side};
use chrono::{DateTime, Utc};
use positive::Positive;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::{fmt, io};
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub enum TradeStatus {
#[default]
Open,
Closed,
Expired,
Exercised,
Assigned,
Other(String),
}
pub trait TradeStatusAble {
fn open(&self) -> Result<Trade, TradeError>;
fn close(&self) -> Result<Trade, TradeError>;
fn expired(&self) -> Result<Trade, TradeError>;
fn exercised(&self) -> Result<Trade, TradeError>;
fn assigned(&self) -> Result<Trade, TradeError>;
fn status_other(&self) -> Result<Trade, TradeError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Trade {
pub id: uuid::Uuid,
pub action: Action,
pub side: Side,
pub option_style: OptionStyle,
pub fee: Positive,
pub symbol: Option<String>, pub strike: Positive, pub expiry: DateTime<Utc>, #[serde(with = "ts_ns")]
pub timestamp: i64,
pub quantity: Positive, pub premium: Positive, #[serde(default = "default_contract_size")]
pub contract_size: Positive,
pub underlying_price: Positive,
pub notes: Option<String>,
pub status: TradeStatus,
}
#[inline]
#[must_use]
fn default_contract_size() -> Positive {
Positive::ONE
}
const NANOS_PER_SECOND: i64 = 1_000_000_000;
fn timestamp_nanos(datetime: &DateTime<Utc>, operation: &'static str) -> Result<i64, TradeError> {
if let Some(nanos) = datetime.timestamp_nanos_opt() {
return Ok(nanos);
}
datetime
.timestamp()
.checked_mul(NANOS_PER_SECOND)
.ok_or_else(|| {
TradeError::arithmetic_overflow(
operation,
format!("{datetime} in nanoseconds since the Unix epoch leaves the i64 range"),
)
})
}
impl Trade {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: uuid::Uuid,
action: Action,
side: Side,
option_style: OptionStyle,
fee: Positive,
symbol: Option<String>,
strike: Positive,
expiry: DateTime<Utc>,
quantity: Positive,
premium: Positive,
underlying_price: Positive,
notes: Option<String>,
status: TradeStatus,
) -> Result<Self, TradeError> {
let timestamp = timestamp_nanos(&Utc::now(), "Trade::new")?;
Ok(Self {
id,
action,
side,
option_style,
fee,
symbol,
strike,
expiry,
timestamp,
quantity,
premium,
contract_size: default_contract_size(),
underlying_price,
notes,
status,
})
}
#[must_use = "with_contract_size returns the updated trade and leaves the original untouched"]
#[inline]
pub fn with_contract_size(mut self, contract_size: Positive) -> Self {
self.contract_size = contract_size;
self
}
#[must_use]
pub fn datetime(&self) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp_nanos(self.timestamp)
}
pub fn set_timestamp(&mut self, datetime: DateTime<Utc>) -> Result<(), TradeError> {
self.timestamp = timestamp_nanos(&datetime, "Trade::set_timestamp")?;
Ok(())
}
#[inline]
fn total_fees(&self) -> Result<Positive, TradeError> {
self.fee
.checked_mul(&self.quantity)
.map_err(|e| TradeError::arithmetic_overflow("Trade::fees", e))
}
#[inline]
fn total_premium(&self) -> Result<Positive, TradeError> {
self.premium
.checked_mul(&self.contract_size)
.and_then(|per_contract| per_contract.checked_mul(&self.quantity))
.map_err(|e| TradeError::arithmetic_overflow("Trade::premium", e))
}
pub fn cost(&self) -> Result<Positive, TradeError> {
match (self.action, self.side) {
(Action::Buy, Side::Long) | (Action::Sell, Side::Short) => self
.total_premium()?
.checked_add(&self.total_fees()?)
.map_err(|e| TradeError::arithmetic_overflow("Trade::cost", e)),
(Action::Buy, Side::Short) | (Action::Sell, Side::Long) => self.total_fees(),
_ => Ok(Positive::ZERO),
}
}
pub fn income(&self) -> Result<Positive, TradeError> {
match (self.action, self.side) {
(Action::Buy, Side::Long) | (Action::Sell, Side::Short) => Ok(Positive::ZERO),
(Action::Buy, Side::Short) | (Action::Sell, Side::Long) => self.total_premium(),
_ => Ok(Positive::ZERO),
}
}
pub fn net(&self) -> Result<Decimal, TradeError> {
self.income()?
.to_dec()
.checked_sub(self.cost()?.to_dec())
.ok_or_else(|| TradeError::arithmetic_overflow("Trade::net", "income - cost"))
}
#[must_use]
pub fn is_open(&self) -> bool {
self.status == TradeStatus::Open
}
#[must_use]
pub fn is_closed(&self) -> bool {
self.status == TradeStatus::Closed
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.status == TradeStatus::Expired
}
}
impl fmt::Display for Trade {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let string =
serde_json::to_string(self).unwrap_or_else(|e| format!(r#"{{"error":"{e}"}}"#));
f.write_str(&string)
}
}
impl TradeAble for Trade {
fn trade(&self) -> Result<Trade, TradeError> {
Ok(self.clone())
}
fn trade_ref(&self) -> Result<&Trade, TradeError> {
Ok(self)
}
fn trade_mut(&mut self) -> Result<&mut Trade, TradeError> {
Ok(self)
}
}
pub fn save_trades(trades: &[Trade], file_path: &str) -> io::Result<()> {
let json =
serde_json::to_string(trades).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let mut file = File::create(file_path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
pub trait TradeAble {
fn trade(&self) -> Result<Trade, TradeError>;
fn trade_ref(&self) -> Result<&Trade, TradeError>;
fn trade_mut(&mut self) -> Result<&mut Trade, TradeError>;
}
mod ts_ns {
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(nanos: &i64, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_i64(*nanos)
}
pub fn deserialize<'de, D>(d: D) -> Result<i64, D::Error>
where
D: Deserializer<'de>,
{
i64::deserialize(d) }
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use positive::pos_or_panic;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use uuid::Uuid;
fn sample_trade() -> Trade {
Trade {
id: Uuid::nil(),
action: Action::Buy,
side: Side::Long,
option_style: OptionStyle::Call,
fee: Positive::new_decimal(Decimal::new(15, 2)).unwrap(), symbol: Some("AAPL".to_string()),
strike: Positive::new_decimal(Decimal::new(1800, 1)).unwrap(), expiry: Utc.with_ymd_and_hms(2025, 6, 20, 0, 0, 0).unwrap(),
timestamp: 1_700_000_000_000_000_000, quantity: Positive::new_decimal(Decimal::from(3u32)).unwrap(),
premium: pos_or_panic!(2.5), contract_size: Positive::ONE,
underlying_price: Positive::new_decimal(Decimal::new(1850, 1)).unwrap(), notes: Some("unit-test".to_string()),
status: TradeStatus::Open,
}
}
#[test]
fn display_matches_serde_json() {
let trade = sample_trade();
let expect = serde_json::to_string(&trade).unwrap();
assert_eq!(expect, trade.to_string());
}
#[test]
fn serde_roundtrip() {
let trade = sample_trade();
let json = serde_json::to_string(&trade).unwrap();
let back: Trade = serde_json::from_str(&json).unwrap();
assert_eq!(trade, back);
}
#[test]
fn test_trade_contract_size_missing_field_defaults_to_one() {
let trade = sample_trade().with_contract_size(Positive::HUNDRED);
let mut value = serde_json::to_value(&trade).unwrap();
value.as_object_mut().unwrap().remove("contract_size");
let legacy: Trade = serde_json::from_value(value).unwrap();
assert_eq!(legacy.contract_size, Positive::ONE);
assert_eq!(legacy, sample_trade());
}
#[test]
fn test_trade_contract_size_scales_premium_not_fees() {
let trade = sample_trade().with_contract_size(Positive::HUNDRED);
assert_eq!(trade.cost().unwrap(), pos_or_panic!(750.45));
assert_eq!(trade.income().unwrap(), Positive::ZERO);
assert_eq!(trade.net().unwrap(), dec!(-750.45));
let mut sold = trade.clone();
sold.action = Action::Sell;
assert_eq!(sold.income().unwrap(), pos_or_panic!(750.0));
assert_eq!(sold.cost().unwrap(), pos_or_panic!(0.45));
assert_eq!(sold.net().unwrap(), dec!(749.55));
}
#[test]
fn test_trade_cost_overflowing_premium_is_an_error() {
let trade = sample_trade().with_contract_size(Positive::HUNDRED);
let mut huge = trade.clone();
huge.premium = Positive::MAX;
assert!(matches!(
huge.cost(),
Err(TradeError::ArithmeticOverflow {
operation: "Trade::premium",
..
})
));
assert!(huge.net().is_err());
let mut sold = huge.clone();
sold.action = Action::Sell;
assert_eq!(sold.cost().unwrap(), pos_or_panic!(0.45));
assert!(matches!(
sold.income(),
Err(TradeError::ArithmeticOverflow { .. })
));
}
#[test]
fn test_trade_cost_overflowing_fees_is_an_error() {
let mut trade = sample_trade();
trade.fee = Positive::MAX;
assert!(matches!(
trade.cost(),
Err(TradeError::ArithmeticOverflow {
operation: "Trade::fees",
..
})
));
assert_eq!(trade.income().unwrap(), Positive::ZERO);
}
#[test]
fn test_trade_cost_overflowing_sum_is_an_error() {
let mut trade = sample_trade();
trade.quantity = Positive::ONE;
trade.premium = Positive::new_decimal(Decimal::MAX / Decimal::TWO).unwrap();
trade.fee = Positive::new_decimal(Decimal::MAX / Decimal::TWO).unwrap();
trade.premium = trade.premium.checked_add(&Positive::ONE).unwrap();
trade.fee = trade.fee.checked_add(&Positive::ONE).unwrap();
assert!(matches!(
trade.cost(),
Err(TradeError::ArithmeticOverflow {
operation: "Trade::cost",
..
})
));
}
#[test]
fn test_trade_new_contract_size_is_one() {
let trade = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
assert_eq!(trade.contract_size, Positive::ONE);
}
#[test]
fn datetime_conversion_roundtrip() {
let trade = sample_trade();
let dt = trade.datetime();
assert_eq!(dt.timestamp_nanos_opt().unwrap(), trade.timestamp);
}
#[test]
fn new_sets_reasonable_timestamp() {
const FIVE_SECS_NS: i64 = 5_000_000_000;
let now_before = Utc::now().timestamp_nanos_opt().unwrap();
let trade = Trade::new(
Uuid::new_v4(),
Action::Sell,
Side::Short,
OptionStyle::Put,
Positive::new_decimal(Decimal::new(25, 2)).unwrap(),
None,
Positive::new_decimal(Decimal::new(2000, 1)).unwrap(), Utc::now(),
Positive::new_decimal(Decimal::from(1u32)).unwrap(),
pos_or_panic!(3.0), Positive::new_decimal(Decimal::new(1900, 1)).unwrap(), None,
TradeStatus::Open,
)
.unwrap();
let now_after = Utc::now().timestamp_nanos_opt().unwrap();
assert!(trade.timestamp >= now_before - FIVE_SECS_NS);
assert!(trade.timestamp <= now_after + FIVE_SECS_NS);
}
#[test]
fn timestamp_field_is_json_number() {
let trade = sample_trade();
let v = serde_json::to_value(&trade).unwrap();
assert!(v["timestamp"].is_number());
}
fn sample_trade_bis(action: Action, side: Side, status: TradeStatus) -> Trade {
Trade::new(
Uuid::nil(), action,
side,
OptionStyle::Call, pos_or_panic!(0.15), Some("AAPL".to_string()), pos_or_panic!(180.0), Utc.with_ymd_and_hms(2025, 6, 20, 0, 0, 0).unwrap(),
pos_or_panic!(3.0), pos_or_panic!(2.50), pos_or_panic!(185.0), Some("unit-test".into()), status,
)
.unwrap()
}
#[test]
fn new_sets_current_timestamp() {
let before = Utc::now().timestamp_nanos_opt().unwrap();
let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let after = Utc::now().timestamp_nanos_opt().unwrap();
assert!(tr.timestamp >= before && tr.timestamp <= after);
}
#[test]
fn datetime_roundtrip() {
let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let dt = tr.datetime();
assert_eq!(dt.timestamp_nanos_opt().unwrap(), tr.timestamp);
}
#[test]
fn set_timestamp_overwrites_value() {
let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let new_dt = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap();
tr.set_timestamp(new_dt).unwrap();
assert_eq!(tr.datetime(), new_dt);
}
#[test]
fn test_trade_set_timestamp_falls_back_to_whole_seconds() {
let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let late = DateTime::<Utc>::from_timestamp(9_223_372_036, 999_999_999).unwrap();
assert!(late.timestamp_nanos_opt().is_none());
tr.set_timestamp(late).unwrap();
assert_eq!(tr.timestamp, 9_223_372_036_000_000_000);
}
#[test]
fn test_trade_set_timestamp_overflow_reports_error() {
let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let before = tr.timestamp;
for datetime in [DateTime::<Utc>::MAX_UTC, DateTime::<Utc>::MIN_UTC] {
assert!(matches!(
tr.set_timestamp(datetime),
Err(TradeError::ArithmeticOverflow {
operation: "Trade::set_timestamp",
..
})
));
assert_eq!(tr.timestamp, before);
}
}
fn expect_cost_income(action: Action, side: Side, exp_cost: Decimal, exp_income: Decimal) {
let tr = sample_trade_bis(action, side, TradeStatus::Open);
assert_eq!(tr.cost().unwrap().to_dec(), exp_cost);
assert_eq!(tr.income().unwrap().to_dec(), exp_income);
assert_eq!(tr.net().unwrap(), exp_income - exp_cost);
}
#[test]
fn cash_flows_buy_long() {
expect_cost_income(Action::Buy, Side::Long, dec!(7.95), dec!(0));
}
#[test]
fn cash_flows_sell_long() {
expect_cost_income(Action::Sell, Side::Long, dec!(0.45), dec!(7.50));
}
#[test]
fn cash_flows_buy_short() {
expect_cost_income(Action::Buy, Side::Short, dec!(0.45), dec!(7.50));
}
#[test]
fn cash_flows_sell_short() {
expect_cost_income(Action::Sell, Side::Short, dec!(7.95), dec!(0));
}
#[test]
fn status_helpers_work() {
let open = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let closed = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Closed);
let exp = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Expired);
assert!(open.is_open() && !open.is_closed() && !open.is_expired());
assert!(closed.is_closed() && !closed.is_open() && !closed.is_expired());
assert!(exp.is_expired() && !exp.is_open() && !exp.is_closed());
}
#[test]
fn display_outputs_json() {
let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let json = serde_json::to_string(&tr).unwrap();
assert_eq!(json, tr.to_string());
}
#[test]
fn tradeable_returns_same_ref() {
let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let trait_obj: &dyn TradeAble = &tr;
assert!(std::ptr::eq(
trait_obj.trade_ref().unwrap() as *const Trade,
&tr as *const Trade
));
}
#[test]
fn tradeable_mut_allows_mutation() {
let mut tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
{
let trait_obj: &mut dyn TradeAble = &mut tr;
let trade_ref = trait_obj.trade_mut().unwrap();
trade_ref.status = TradeStatus::Closed;
}
assert!(tr.is_closed());
}
#[test]
fn tradeable_returns_cloned_trade() {
let tr = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let trait_obj: &dyn TradeAble = &tr;
let tr_cloned = trait_obj
.trade()
.expect("trade() should return a cloned Trade object");
assert_eq!(tr_cloned, tr);
assert!(!std::ptr::eq(
&tr_cloned as *const Trade,
&tr as *const Trade
));
let mut tr_cloned = tr_cloned;
tr_cloned.status = TradeStatus::Closed;
assert!(tr_cloned.is_closed());
assert!(!tr.is_closed());
}
fn assert_same_except_status(a: &Trade, b: &Trade) {
let mut aa = a.clone();
aa.status = b.status.clone();
assert_eq!(aa, *b);
}
impl TradeStatusAble for Trade {
fn open(&self) -> Result<Trade, TradeError> {
let mut tr = self.clone();
tr.status = TradeStatus::Open;
Ok(tr)
}
fn close(&self) -> Result<Trade, TradeError> {
let mut tr = self.clone();
tr.status = TradeStatus::Closed;
Ok(tr)
}
fn expired(&self) -> Result<Trade, TradeError> {
let mut tr = self.clone();
tr.status = TradeStatus::Expired;
Ok(tr)
}
fn exercised(&self) -> Result<Trade, TradeError> {
let mut tr = self.clone();
tr.status = TradeStatus::Exercised;
Ok(tr)
}
fn assigned(&self) -> Result<Trade, TradeError> {
let mut tr = self.clone();
tr.status = TradeStatus::Assigned;
Ok(tr)
}
fn status_other(&self) -> Result<Trade, TradeError> {
let mut tr = self.clone();
tr.status = TradeStatus::Other("other".to_string());
Ok(tr)
}
}
#[test]
fn status_transitions_return_new_trade() {
let base = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let closed = base.close().unwrap();
assert_eq!(closed.status, TradeStatus::Closed);
assert_same_except_status(&base, &closed);
assert_eq!(base.status, TradeStatus::Open);
let expired = base.expired().unwrap();
assert_eq!(expired.status, TradeStatus::Expired);
assert_same_except_status(&base, &expired);
let exercised = base.exercised().unwrap();
assert_eq!(exercised.status, TradeStatus::Exercised);
assert_same_except_status(&base, &exercised);
let assigned = base.assigned().unwrap();
assert_eq!(assigned.status, TradeStatus::Assigned);
assert_same_except_status(&base, &assigned);
let reopened = closed.open().unwrap();
assert_eq!(reopened.status, TradeStatus::Open);
assert_same_except_status(&base, &reopened);
}
#[test]
fn status_other_sets_custom_string() {
let base = sample_trade_bis(Action::Buy, Side::Long, TradeStatus::Open);
let other = base.status_other().unwrap();
if let TradeStatus::Other(s) = &other.status {
assert!(!s.is_empty(), "status_other() must fill the string");
} else {
panic!("status_other() did not return TradeStatus::Other");
}
assert_same_except_status(&base, &other);
}
}