Skip to main content

bybit/models/
ws_trade.rs

1use crate::prelude::*;
2
3/// Structure for individual trade data in WebSocket trade updates.
4///
5/// Contains details of a single executed trade, such as price, volume, and side. Bots use this to monitor market activity and inform trading decisions.
6#[derive(Serialize, Deserialize, Debug, Clone)]
7pub struct WsTrade {
8    /// The timestamp of the trade in milliseconds.
9    ///
10    /// Indicates when the trade was executed. Bots use this to align trade data with other time-series data.
11    #[serde(rename = "T")]
12    pub timestamp: u64,
13
14    /// The trading pair symbol (e.g., "BTCUSDT").
15    ///
16    /// Identifies the perpetual futures contract for the trade. Bots use this to verify the correct market.
17    #[serde(rename = "s")]
18    pub symbol: String,
19
20    /// The trade side ("Buy" or "Sell").
21    ///
22    /// Indicates whether the trade was initiated by a buyer or seller. Bots use this to assess market direction and momentum.
23    #[serde(rename = "S")]
24    pub side: String,
25
26    /// The trade volume.
27    ///
28    /// The quantity of the base asset traded. Bots use this to gauge trade size and market liquidity.
29    #[serde(rename = "v", with = "string_to_float")]
30    pub volume: f64,
31
32    /// The trade price.
33    ///
34    /// The price at which the trade was executed. Bots use this for price discovery and technical analysis.
35    #[serde(rename = "p", with = "string_to_float")]
36    pub price: f64,
37
38    /// The tick direction of the trade.
39    ///
40    /// Indicates whether the trade was an uptick, downtick, or neutral (e.g., "PlusTick", "MinusTick"). Bots use this to analyze short-term price momentum.
41    #[serde(rename = "L")]
42    pub tick_direction: TickDirection,
43
44    /// The unique trade ID.
45    ///
46    /// A unique identifier for the trade execution. Bots use this to track specific trades and avoid duplicates.
47    #[serde(rename = "i")]
48    pub id: String,
49
50    /// Whether the buyer was the maker.
51    ///
52    /// If `true`, the buyer's order was on the order book (maker); if `false`, the buyer took liquidity (taker). Bots use this to analyze market dynamics and order flow.
53    #[serde(rename = "BT")]
54    pub buyer_is_maker: bool,
55}
56
57impl WsTrade {
58    /// Creates a new WsTrade instance.
59    pub fn new(
60        timestamp: u64,
61        symbol: &str,
62        side: &str,
63        volume: f64,
64        price: f64,
65        tick_direction: TickDirection,
66        id: &str,
67        buyer_is_maker: bool,
68    ) -> Self {
69        Self {
70            timestamp,
71            symbol: symbol.to_string(),
72            side: side.to_string(),
73            volume,
74            price,
75            tick_direction,
76            id: id.to_string(),
77            buyer_is_maker,
78        }
79    }
80
81    /// Returns true if this is a buy trade.
82    pub fn is_buy(&self) -> bool {
83        self.side.eq_ignore_ascii_case("buy")
84    }
85
86    /// Returns true if this is a sell trade.
87    pub fn is_sell(&self) -> bool {
88        self.side.eq_ignore_ascii_case("sell")
89    }
90
91    /// Returns the timestamp as a chrono DateTime.
92    pub fn timestamp_datetime(&self) -> chrono::DateTime<chrono::Utc> {
93        chrono::DateTime::from_timestamp((self.timestamp / 1000) as i64, 0)
94            .unwrap_or_else(chrono::Utc::now)
95    }
96
97    /// Returns the age of the trade in milliseconds.
98    pub fn age_ms(&self) -> u64 {
99        let now = chrono::Utc::now().timestamp_millis() as u64;
100        if now > self.timestamp {
101            now - self.timestamp
102        } else {
103            0
104        }
105    }
106
107    /// Returns true if the trade is stale (older than 5 seconds).
108    pub fn is_stale(&self) -> bool {
109        self.age_ms() > 5000
110    }
111
112    /// Returns the trade value (price * volume).
113    pub fn value(&self) -> f64 {
114        self.price * self.volume
115    }
116
117    /// Returns true if this is a taker trade (buyer is not maker).
118    pub fn is_taker(&self) -> bool {
119        !self.buyer_is_maker
120    }
121
122    /// Returns true if this is a maker trade (buyer is maker).
123    pub fn is_maker(&self) -> bool {
124        self.buyer_is_maker
125    }
126
127    /// Returns the trade type as a string.
128    pub fn trade_type(&self) -> String {
129        if self.is_buy() {
130            if self.is_maker() {
131                "Buy Maker".to_string()
132            } else {
133                "Buy Taker".to_string()
134            }
135        } else {
136            if self.is_maker() {
137                "Sell Maker".to_string()
138            } else {
139                "Sell Taker".to_string()
140            }
141        }
142    }
143
144    /// Returns true if this is an uptick trade.
145    pub fn is_uptick(&self) -> bool {
146        matches!(
147            self.tick_direction,
148            TickDirection::PlusTick | TickDirection::ZeroPlusTick
149        )
150    }
151
152    /// Returns true if this is a downtick trade.
153    pub fn is_downtick(&self) -> bool {
154        matches!(
155            self.tick_direction,
156            TickDirection::MinusTick | TickDirection::ZeroMinusTick
157        )
158    }
159
160    /// Returns true if this is a neutral tick trade.
161    pub fn is_neutral_tick(&self) -> bool {
162        matches!(
163            self.tick_direction,
164            TickDirection::ZeroPlusTick | TickDirection::ZeroMinusTick
165        )
166    }
167
168    /// Returns the tick direction as a human-readable string.
169    pub fn tick_direction_string(&self) -> &'static str {
170        match self.tick_direction {
171            TickDirection::PlusTick => "PlusTick",
172            TickDirection::ZeroPlusTick => "ZeroPlusTick",
173            TickDirection::MinusTick => "MinusTick",
174            TickDirection::ZeroMinusTick => "ZeroMinusTick",
175        }
176    }
177
178    /// Returns true if the trade is valid for analysis.
179    pub fn is_valid(&self) -> bool {
180        self.timestamp > 0
181            && !self.symbol.is_empty()
182            && (self.is_buy() || self.is_sell())
183            && self.volume > 0.0
184            && self.price > 0.0
185            && !self.id.is_empty()
186    }
187
188    /// Returns a summary string for this trade.
189    pub fn to_summary_string(&self) -> String {
190        format!(
191            "[{}] {} {} {} @ {} (Value: {:.2}, {})",
192            self.timestamp_datetime().format("%H:%M:%S%.3f"),
193            self.symbol,
194            self.side,
195            self.volume,
196            self.price,
197            self.value(),
198            self.trade_type()
199        )
200    }
201
202    /// Returns a compact summary string for this trade.
203    pub fn to_compact_string(&self) -> String {
204        let side_char = if self.is_buy() { 'B' } else { 'S' };
205        let maker_char = if self.is_maker() { 'M' } else { 'T' };
206        let tick_char = match self.tick_direction {
207            TickDirection::PlusTick => '↑',
208            TickDirection::ZeroPlusTick => '↗',
209            TickDirection::MinusTick => '↓',
210            TickDirection::ZeroMinusTick => '↘',
211        };
212
213        format!(
214            "{} {}{}{} {}@{:.2}",
215            self.timestamp_datetime().format("%H:%M:%S"),
216            side_char,
217            maker_char,
218            tick_char,
219            self.volume,
220            self.price
221        )
222    }
223
224    /// Returns the trade size category.
225    pub fn size_category(&self) -> TradeSizeCategory {
226        let value = self.value();
227        if value >= 1_000_000.0 {
228            TradeSizeCategory::Whale
229        } else if value >= 100_000.0 {
230            TradeSizeCategory::Large
231        } else if value >= 10_000.0 {
232            TradeSizeCategory::Medium
233        } else if value >= 1_000.0 {
234            TradeSizeCategory::Small
235        } else {
236            TradeSizeCategory::Retail
237        }
238    }
239
240    /// Returns the trade size category as a string.
241    pub fn size_category_string(&self) -> &'static str {
242        match self.size_category() {
243            TradeSizeCategory::Whale => "Whale",
244            TradeSizeCategory::Large => "Large",
245            TradeSizeCategory::Medium => "Medium",
246            TradeSizeCategory::Small => "Small",
247            TradeSizeCategory::Retail => "Retail",
248        }
249    }
250
251    /// Returns the notional value in quote currency.
252    pub fn notional_value(&self) -> f64 {
253        self.value()
254    }
255
256    /// Returns the trade impact (volume / price).
257    /// This can be used to estimate the price impact of the trade.
258    pub fn impact_ratio(&self) -> f64 {
259        self.volume / self.price
260    }
261
262    /// Returns true if this trade occurred within the last N milliseconds.
263    pub fn is_recent(&self, max_age_ms: u64) -> bool {
264        self.age_ms() <= max_age_ms
265    }
266
267    /// Compares this trade with another trade and returns the price difference.
268    pub fn price_diff(&self, other: &WsTrade) -> Option<f64> {
269        if self.symbol == other.symbol {
270            Some(self.price - other.price)
271        } else {
272            None
273        }
274    }
275
276    /// Compares this trade with another trade and returns the price difference percentage.
277    pub fn price_diff_percentage(&self, other: &WsTrade) -> Option<f64> {
278        if self.symbol == other.symbol && other.price > 0.0 {
279            Some((self.price - other.price) / other.price * 100.0)
280        } else {
281            None
282        }
283    }
284
285    /// Returns the trade data as a tuple for easy pattern matching.
286    pub fn as_tuple(&self) -> (u64, &str, &str, f64, f64, TickDirection, &str, bool) {
287        (
288            self.timestamp,
289            &self.symbol,
290            &self.side,
291            self.volume,
292            self.price,
293            self.tick_direction.clone(),
294            &self.id,
295            self.buyer_is_maker,
296        )
297    }
298}
299
300/// Enum representing trade size categories.
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum TradeSizeCategory {
303    /// Retail trade: < $1,000
304    Retail,
305    /// Small trade: $1,000 - $10,000
306    Small,
307    /// Medium trade: $10,000 - $100,000
308    Medium,
309    /// Large trade: $100,000 - $1,000,000
310    Large,
311    /// Whale trade: ≥ $1,000,000
312    Whale,
313}
314
315impl TradeSizeCategory {
316    /// Returns the minimum value for this category.
317    pub fn min_value(&self) -> f64 {
318        match self {
319            TradeSizeCategory::Retail => 0.0,
320            TradeSizeCategory::Small => 1_000.0,
321            TradeSizeCategory::Medium => 10_000.0,
322            TradeSizeCategory::Large => 100_000.0,
323            TradeSizeCategory::Whale => 1_000_000.0,
324        }
325    }
326
327    /// Returns the maximum value for this category.
328    pub fn max_value(&self) -> Option<f64> {
329        match self {
330            TradeSizeCategory::Retail => Some(1_000.0),
331            TradeSizeCategory::Small => Some(10_000.0),
332            TradeSizeCategory::Medium => Some(100_000.0),
333            TradeSizeCategory::Large => Some(1_000_000.0),
334            TradeSizeCategory::Whale => None,
335        }
336    }
337
338    /// Returns a string representation of the category.
339    pub fn as_str(&self) -> &'static str {
340        match self {
341            TradeSizeCategory::Retail => "Retail",
342            TradeSizeCategory::Small => "Small",
343            TradeSizeCategory::Medium => "Medium",
344            TradeSizeCategory::Large => "Large",
345            TradeSizeCategory::Whale => "Whale",
346        }
347    }
348}
349
350impl std::fmt::Display for TradeSizeCategory {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        write!(f, "{}", self.as_str())
353    }
354}