use crate::orderbook::fees::FeeSchedule;
use pricelevel::MatchResult;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TradeResult {
pub symbol: String,
pub match_result: MatchResult,
pub total_maker_fees: i128,
pub total_taker_fees: i128,
#[serde(default)]
pub engine_seq: u64,
#[serde(default)]
pub quote_notional: u128,
}
impl TradeResult {
pub fn new(symbol: String, match_result: MatchResult) -> Self {
let quote_notional = compute_quote_notional(&match_result);
Self {
symbol,
match_result,
total_maker_fees: 0,
total_taker_fees: 0,
engine_seq: 0,
quote_notional,
}
}
pub fn with_fees(
symbol: String,
match_result: MatchResult,
fee_schedule: Option<FeeSchedule>,
) -> Self {
let (total_maker_fees, total_taker_fees) = match fee_schedule {
Some(schedule) if !schedule.is_zero_fee() => {
let mut maker_sum: i128 = 0;
let mut taker_sum: i128 = 0;
for tx in match_result.trades().as_vec() {
let notional = tx
.price()
.as_u128()
.saturating_mul(tx.quantity().as_u64() as u128);
maker_sum = maker_sum
.checked_add(schedule.calculate_fee(notional, true))
.unwrap_or(maker_sum);
taker_sum = taker_sum
.checked_add(schedule.calculate_fee(notional, false))
.unwrap_or(taker_sum);
}
(maker_sum, taker_sum)
}
_ => (0, 0),
};
let quote_notional = compute_quote_notional(&match_result);
Self {
symbol,
match_result,
total_maker_fees,
total_taker_fees,
engine_seq: 0,
quote_notional,
}
}
#[must_use]
#[inline]
pub fn total_fees(&self) -> i128 {
self.total_maker_fees
.checked_add(self.total_taker_fees)
.unwrap_or(i128::MAX)
}
}
#[inline]
#[must_use]
fn compute_quote_notional(match_result: &MatchResult) -> u128 {
let mut total: u128 = 0;
for tx in match_result.trades().as_vec() {
let notional = tx
.price()
.as_u128()
.saturating_mul(u128::from(tx.quantity().as_u64()));
total = total.saturating_add(notional);
}
total
}
pub type TradeListener = Arc<dyn Fn(&TradeResult) + Send + Sync>;
#[derive(Debug, Clone)]
pub struct TradeEvent {
pub symbol: String,
pub trade_result: TradeResult,
pub timestamp: u64,
pub engine_seq: u64,
}
#[derive(Debug, Clone)]
pub struct TradeInfo {
pub symbol: String,
pub order_id: String,
pub executed_quantity: u64,
pub remaining_quantity: u64,
pub is_complete: bool,
pub transaction_count: usize,
pub transactions: Vec<TransactionInfo>,
}
#[derive(Debug, Clone)]
pub struct TransactionInfo {
pub price: u128,
pub quantity: u64,
pub transaction_id: String,
pub maker_order_id: String,
pub taker_order_id: String,
pub maker_fee: i128,
pub taker_fee: i128,
}
impl TradeInfo {
#[must_use]
pub fn from_trade_result(
trade_result: &TradeResult,
fee_schedule: Option<&FeeSchedule>,
) -> Self {
let match_result = &trade_result.match_result;
let schedule = fee_schedule.filter(|s| !s.is_zero_fee());
let transactions: Vec<TransactionInfo> = match_result
.trades()
.as_vec()
.iter()
.map(|tx| {
let notional = tx
.price()
.as_u128()
.saturating_mul(u128::from(tx.quantity().as_u64()));
let (maker_fee, taker_fee) = match schedule {
Some(s) => (
s.calculate_fee(notional, true),
s.calculate_fee(notional, false),
),
None => (0, 0),
};
TransactionInfo {
price: tx.price().as_u128(),
quantity: tx.quantity().as_u64(),
transaction_id: tx.trade_id().to_string(),
maker_order_id: tx.maker_order_id().to_string(),
taker_order_id: tx.taker_order_id().to_string(),
maker_fee,
taker_fee,
}
})
.collect();
Self {
symbol: trade_result.symbol.clone(),
order_id: match_result.order_id().to_string(),
executed_quantity: match_result
.executed_quantity()
.map(|q| q.as_u64())
.unwrap_or(0),
remaining_quantity: match_result.remaining_quantity().as_u64(),
is_complete: match_result.is_complete(),
transaction_count: match_result.trades().len(),
transactions,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pricelevel::{Id, MatchResult, Price, Quantity, Trade};
fn make_match_result_with_trades(trades: Vec<Trade>) -> MatchResult {
let order_id = Id::new_uuid();
let total_qty: u64 = trades.iter().map(|t| t.quantity().as_u64()).sum();
let initial_qty = if trades.is_empty() { 100 } else { total_qty };
let mut mr = MatchResult::new(order_id, Quantity::new(initial_qty));
for trade in trades {
let _ = mr.add_trade(trade);
}
mr
}
fn make_trade(price: u128, quantity: u64) -> Trade {
Trade::new(
Id::new_uuid(),
Id::new_uuid(),
Id::new_uuid(),
Price::new(price),
Quantity::new(quantity),
pricelevel::Side::Buy,
)
}
#[test]
fn test_trade_result_new_has_zero_fees() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
assert_eq!(tr.total_maker_fees, 0);
assert_eq!(tr.total_taker_fees, 0);
assert_eq!(tr.total_fees(), 0);
}
#[test]
fn test_trade_result_with_fees_none_schedule() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, None);
assert_eq!(tr.total_maker_fees, 0);
assert_eq!(tr.total_taker_fees, 0);
assert_eq!(tr.total_fees(), 0);
}
#[test]
fn test_trade_result_with_fees_zero_schedule() {
let schedule = FeeSchedule::zero_fee();
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, Some(schedule));
assert_eq!(tr.total_maker_fees, 0);
assert_eq!(tr.total_taker_fees, 0);
assert_eq!(tr.total_fees(), 0);
}
#[test]
fn test_trade_result_with_fees_single_transaction() {
let schedule = FeeSchedule::new(-2, 5);
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, Some(schedule));
assert_eq!(tr.total_maker_fees, -2);
assert_eq!(tr.total_taker_fees, 5);
assert_eq!(tr.total_fees(), 3);
}
#[test]
fn test_trade_result_with_fees_multiple_transactions() {
let schedule = FeeSchedule::new(-2, 5);
let mr = make_match_result_with_trades(vec![
make_trade(1000, 10), make_trade(2000, 20), ]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, Some(schedule));
assert_eq!(tr.total_maker_fees, -10);
assert_eq!(tr.total_taker_fees, 25);
assert_eq!(tr.total_fees(), 15);
}
#[test]
fn test_trade_result_with_fees_no_transactions() {
let schedule = FeeSchedule::new(-2, 5);
let mr = make_match_result_with_trades(vec![]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, Some(schedule));
assert_eq!(tr.total_maker_fees, 0);
assert_eq!(tr.total_taker_fees, 0);
assert_eq!(tr.total_fees(), 0);
}
#[test]
fn test_trade_result_with_maker_rebate() {
let schedule = FeeSchedule::with_maker_rebate(5, 10);
let mr = make_match_result_with_trades(vec![make_trade(100_000, 50)]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, Some(schedule));
assert_eq!(tr.total_maker_fees, -2_500);
assert_eq!(tr.total_taker_fees, 5_000);
assert_eq!(tr.total_fees(), 2_500);
assert!(tr.total_maker_fees < 0); }
#[test]
fn test_trade_info_from_result_populates_per_transaction_fees_issue_119() {
let schedule = FeeSchedule::new(-2, 5); let mr = make_match_result_with_trades(vec![
make_trade(1000, 10), make_trade(2000, 20), ]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, Some(schedule));
let info = TradeInfo::from_trade_result(&tr, Some(&schedule));
assert_eq!(info.symbol, "BTC/USD");
assert_eq!(info.transaction_count, 2);
assert_eq!(info.transactions.len(), 2);
assert_eq!(info.transactions[0].maker_fee, -2);
assert_eq!(info.transactions[0].taker_fee, 5);
assert_eq!(info.transactions[1].maker_fee, -8);
assert_eq!(info.transactions[1].taker_fee, 20);
let maker_sum: i128 = info.transactions.iter().map(|t| t.maker_fee).sum();
let taker_sum: i128 = info.transactions.iter().map(|t| t.taker_fee).sum();
assert_eq!(maker_sum, tr.total_maker_fees);
assert_eq!(taker_sum, tr.total_taker_fees);
}
#[test]
fn test_trade_info_from_result_none_schedule_zero_fees_issue_119() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::new("ETH/USD".to_string(), mr);
let info = TradeInfo::from_trade_result(&tr, None);
assert_eq!(info.transaction_count, 1);
assert_eq!(info.transactions.len(), 1);
assert_eq!(info.transactions[0].maker_fee, 0);
assert_eq!(info.transactions[0].taker_fee, 0);
assert_eq!(info.transactions[0].price, 1000);
assert_eq!(info.transactions[0].quantity, 10);
let zero = FeeSchedule::zero_fee();
let info_zero = TradeInfo::from_trade_result(&tr, Some(&zero));
assert_eq!(info_zero.transactions[0].maker_fee, 0);
assert_eq!(info_zero.transactions[0].taker_fee, 0);
}
#[test]
fn test_trade_result_symbol_preserved() {
let mr = make_match_result_with_trades(vec![]);
let tr = TradeResult::with_fees("ETH/USDT".to_string(), mr, None);
assert_eq!(tr.symbol, "ETH/USDT");
}
#[test]
fn test_transaction_info_fee_fields() {
let info = TransactionInfo {
price: 50_000,
quantity: 10,
transaction_id: "tx-1".to_string(),
maker_order_id: "maker-1".to_string(),
taker_order_id: "taker-1".to_string(),
maker_fee: -25,
taker_fee: 50,
};
assert_eq!(info.maker_fee, -25);
assert_eq!(info.taker_fee, 50);
}
#[test]
fn test_trade_result_engine_seq_default_zero() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
assert_eq!(tr.engine_seq, 0);
}
#[test]
fn test_trade_result_json_roundtrip_preserves_engine_seq() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let mut tr = TradeResult::new("BTC/USD".to_string(), mr);
tr.engine_seq = 42;
let json = serde_json::to_vec(&tr).expect("serialize trade");
let decoded: TradeResult = serde_json::from_slice(&json).expect("deserialize trade");
assert_eq!(decoded.engine_seq, 42);
assert_eq!(decoded.symbol, tr.symbol);
assert_eq!(decoded.total_maker_fees, tr.total_maker_fees);
assert_eq!(decoded.total_taker_fees, tr.total_taker_fees);
}
#[test]
fn test_trade_result_json_missing_engine_seq_defaults_zero() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let mut tr = TradeResult::new("BTC/USD".to_string(), mr);
tr.engine_seq = 99;
let mut value: serde_json::Value =
serde_json::to_value(&tr).expect("serialize trade to value");
if let Some(map) = value.as_object_mut() {
map.remove("engine_seq");
}
let bytes = serde_json::to_vec(&value).expect("serialize stripped value");
let decoded: TradeResult =
serde_json::from_slice(&bytes).expect("deserialize stripped trade");
assert_eq!(
decoded.engine_seq, 0,
"missing engine_seq must default to 0 via #[serde(default)]"
);
}
#[test]
fn test_trade_result_new_populates_quote_notional() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
assert_eq!(tr.quote_notional, 10_000);
}
#[test]
fn test_trade_result_with_fees_populates_quote_notional_multi_trade() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10), make_trade(2000, 20)]);
let tr = TradeResult::with_fees("BTC/USD".to_string(), mr, None);
assert_eq!(tr.quote_notional, 50_000);
}
#[test]
fn test_trade_result_quote_notional_zero_when_no_trades() {
let mr = make_match_result_with_trades(vec![]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
assert_eq!(tr.quote_notional, 0);
}
#[test]
fn test_trade_result_json_missing_quote_notional_defaults_zero() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
let mut value: serde_json::Value =
serde_json::to_value(&tr).expect("serialize trade to value");
if let Some(map) = value.as_object_mut() {
map.remove("quote_notional");
}
let bytes = serde_json::to_vec(&value).expect("serialize stripped value");
let decoded: TradeResult =
serde_json::from_slice(&bytes).expect("deserialize stripped trade");
assert_eq!(
decoded.quote_notional, 0,
"missing quote_notional must default to 0 via #[serde(default)]"
);
}
#[test]
fn test_trade_result_json_roundtrip_preserves_quote_notional() {
let mr = make_match_result_with_trades(vec![make_trade(1000, 10), make_trade(2000, 5)]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
let original = tr.quote_notional;
assert_eq!(original, 20_000);
let json = serde_json::to_vec(&tr).expect("serialize trade");
let decoded: TradeResult = serde_json::from_slice(&json).expect("deserialize trade");
assert_eq!(decoded.quote_notional, original);
}
#[cfg(feature = "bincode")]
#[test]
fn test_trade_result_bincode_roundtrip_preserves_quote_notional() {
use bincode::config::standard;
use bincode::serde::{decode_from_slice, encode_to_vec};
let mr = make_match_result_with_trades(vec![make_trade(1234, 7)]);
let tr = TradeResult::new("BTC/USD".to_string(), mr);
let original = tr.quote_notional;
assert_eq!(original, 8_638);
let bytes = encode_to_vec(&tr, standard()).expect("bincode encode");
let (decoded, consumed): (TradeResult, usize) =
decode_from_slice(&bytes, standard()).expect("bincode decode");
assert_eq!(consumed, bytes.len());
assert_eq!(decoded.quote_notional, original);
}
#[cfg(feature = "bincode")]
#[test]
fn test_trade_result_bincode_roundtrip_preserves_engine_seq() {
use bincode::config::standard;
use bincode::serde::{decode_from_slice, encode_to_vec};
let mr = make_match_result_with_trades(vec![make_trade(1000, 10)]);
let mut tr = TradeResult::new("BTC/USD".to_string(), mr);
tr.engine_seq = 7;
let bytes = encode_to_vec(&tr, standard()).expect("bincode encode");
let (decoded, consumed): (TradeResult, usize) =
decode_from_slice(&bytes, standard()).expect("bincode decode");
assert_eq!(consumed, bytes.len(), "no trailing bytes expected");
assert_eq!(decoded.engine_seq, 7);
assert_eq!(decoded.symbol, tr.symbol);
}
}