use crate::error::TradeError;
use crate::model::types::Action;
use crate::pnl::PnL;
use crate::{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};
use utoipa::ToSchema;
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, 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, pub underlying_price: Positive,
pub notes: Option<String>,
pub status: TradeStatus,
}
impl Trade {
#[allow(clippy::too_many_arguments)]
#[must_use]
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,
) -> Self {
let timestamp = Utc::now()
.timestamp_nanos_opt()
.unwrap_or_else(|| Utc::now().timestamp() * 1_000_000_000);
Self {
id,
action,
side,
option_style,
fee,
symbol,
strike,
expiry,
timestamp,
quantity,
premium,
underlying_price,
notes,
status,
}
}
#[must_use]
pub fn datetime(&self) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp_nanos(self.timestamp)
}
pub fn set_timestamp(&mut self, datetime: DateTime<Utc>) {
self.timestamp = datetime
.timestamp_nanos_opt()
.unwrap_or_else(|| datetime.timestamp() * 1_000_000_000);
}
#[must_use]
pub fn cost(&self) -> Positive {
let fees = self.fee * self.quantity;
let premium = self.premium * self.quantity;
match (self.action, self.side) {
(Action::Buy, Side::Long) | (Action::Sell, Side::Short) => premium + fees,
(Action::Buy, Side::Short) | (Action::Sell, Side::Long) => fees,
_ => Positive::ZERO,
}
}
#[must_use]
pub fn income(&self) -> Positive {
let premium = self.quantity * self.premium;
match (self.action, self.side) {
(Action::Buy, Side::Long) | (Action::Sell, Side::Short) => Positive::ZERO,
(Action::Buy, Side::Short) | (Action::Sell, Side::Long) => premium,
_ => Positive::ZERO,
}
}
#[must_use]
pub fn net(&self) -> Decimal {
self.income().to_dec() - self.cost().to_dec()
}
#[must_use]
pub fn is_open(&self) -> bool {
self.status == TradeStatus::Open
}
#[must_use]
pub fn pnl(&self) -> PnL {
self.into()
}
#[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), 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 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,
);
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,
)
}
#[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);
assert_eq!(tr.datetime(), new_dt);
}
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().to_dec(), exp_cost);
assert_eq!(tr.income().to_dec(), exp_income);
assert_eq!(tr.net(), 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);
}
}