Skip to main content

bullet_ws_interface/
server.rs

1use serde::ser::SerializeTuple;
2use serde::{Deserialize, Serialize};
3
4use crate::{RequestId, WSError};
5
6/// client order id (u64 wrapper for type safety)
7pub type ClientOrderId = u64;
8
9#[derive(Serialize, Deserialize, Clone, Debug)]
10pub enum MessageType {
11    #[serde(rename = "s")]
12    Snapshot,
13    #[serde(rename = "u")]
14    Update,
15}
16
17#[derive(Serialize, Deserialize, Clone, Debug)]
18#[serde(rename_all = "camelCase")]
19pub struct DataMessage {
20    pub channel: String,
21    pub symbol: String,
22    pub ts: u64,
23    #[serde(rename = "mt")]
24    pub msg_type: MessageType,
25    pub data: serde_json::Value,
26}
27
28/// Status message for connection lifecycle events
29#[derive(Serialize, Deserialize, Clone, Debug)]
30#[serde(rename_all = "camelCase")]
31pub struct StatusMessage {
32    /// Event time (ms)
33    #[serde(rename = "E")]
34    pub event_time: u64,
35    pub status: String,
36    pub client_id: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub reason: Option<String>,
39}
40
41#[derive(Serialize, Deserialize, Clone, Debug)]
42#[serde(rename_all = "camelCase")]
43pub struct PongMessage {
44    pub id: Option<RequestId>,
45    /// Event time (ms)
46    #[serde(rename = "E")]
47    pub event_time: u64,
48}
49
50/// Error message from the server
51#[derive(Serialize, Deserialize, Clone, Debug)]
52#[serde(rename_all = "camelCase")]
53pub struct ErrorMessage {
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub id: Option<RequestId>,
56    /// Event time (ms)
57    #[serde(rename = "E")]
58    pub event_time: u64,
59    pub error: WSError,
60}
61
62/// Generic success envelope returned for `subscribe`, `unsubscribe`, etc.
63/// Wraps a `result: "success"` value plus the request id (echoed back from
64/// the client message) and the server-side event time.
65#[derive(Serialize, Deserialize, Clone, Debug)]
66pub struct MethodResult {
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub id: Option<RequestId>,
69    /// Event time (us)
70    #[serde(rename = "E")]
71    pub event_time: u64,
72    /// Always `"success"` for these envelopes.
73    pub result: String,
74}
75
76/// Response to a `subscribe` client request.
77#[derive(Serialize, Deserialize, Clone, Debug)]
78#[serde(transparent)]
79pub struct SubscribeOk(pub MethodResult);
80
81/// Response to an `unsubscribe` client request.
82#[derive(Serialize, Deserialize, Clone, Debug)]
83#[serde(transparent)]
84pub struct UnsubscribeOk(pub MethodResult);
85
86/// Response to a `list_subscriptions` client request.
87///
88/// `result` is the list of currently-subscribed topics for the connection.
89#[derive(Serialize, Deserialize, Clone, Debug)]
90pub struct ListSubscriptionsMessage {
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub id: Option<RequestId>,
93    /// Event time (us)
94    #[serde(rename = "E")]
95    pub event_time: u64,
96    pub result: Vec<String>,
97}
98
99/// Price level as [price, quantity]
100#[derive(Clone, Debug)]
101pub struct PriceLevel(pub String, pub String);
102
103impl Serialize for PriceLevel {
104    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
105        let mut tuple = serializer.serialize_tuple(2)?;
106        tuple.serialize_element(&self.0)?;
107        tuple.serialize_element(&self.1)?;
108        tuple.end()
109    }
110}
111
112impl<'de> Deserialize<'de> for PriceLevel {
113    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
114        let (price, qty) = <(String, String)>::deserialize(deserializer)?;
115        Ok(PriceLevel(price, qty))
116    }
117}
118
119/// Binance-compatible depth update message
120#[derive(Serialize, Deserialize, Clone, Debug)]
121pub struct DepthUpdate {
122    #[serde(rename = "e")]
123    pub event_type: String,
124    #[serde(rename = "E")]
125    pub event_time: u64,
126    #[serde(rename = "T")]
127    pub transaction_time: u64,
128    #[serde(rename = "s")]
129    pub symbol: String,
130    #[serde(rename = "U")]
131    pub first_update_id: u64,
132    #[serde(rename = "u")]
133    pub last_update_id: u64,
134    #[serde(rename = "pu")]
135    pub prev_update_id: u64,
136    #[serde(rename = "b")]
137    pub bids: Vec<PriceLevel>,
138    #[serde(rename = "a")]
139    pub asks: Vec<PriceLevel>,
140    #[serde(rename = "mt")]
141    pub msg_type: MessageType,
142}
143
144/// AggTrade message with DEX-specific fields.
145///
146/// As of trading-api 2026-05-23, the DEX-specific fields (`ua`, `oi`, `mk`,
147/// `ff`, `lq`, `fe`, `nf`, `fa`, `co`, `sd`, `ft`, `z`, `Z`, `rs`) are
148/// emitted as deprecated empty/zero placeholders. Consumers should treat
149/// their values as meaningless and use `@user.orders` for real fill data.
150#[derive(Serialize, Deserialize, Clone, Debug)]
151pub struct AggTradeMessage {
152    #[serde(rename = "e")]
153    pub event_type: String,
154    #[serde(rename = "E")]
155    pub event_time: u64,
156    #[serde(rename = "s")]
157    pub symbol: String,
158    #[serde(rename = "a")]
159    pub agg_trade_id: u64,
160    #[serde(rename = "p")]
161    pub price: String,
162    #[serde(rename = "q")]
163    pub quantity: String,
164    #[serde(rename = "f")]
165    pub first_trade_id: u64,
166    #[serde(rename = "l")]
167    pub last_trade_id: u64,
168    #[serde(rename = "T")]
169    pub trade_time: u64,
170    #[serde(rename = "m")]
171    pub is_buyer_maker: bool,
172    // DEX-specific fields — all deprecated as of 2026-05-23; use @user.orders.
173    #[serde(rename = "th")]
174    pub tx_hash: String,
175    /// Deprecated: empty-string placeholder as of 2026-05-23. Use `@user.orders`.
176    #[serde(rename = "ua", default, skip_serializing_if = "Option::is_none")]
177    pub user_address: Option<String>,
178    /// Deprecated: zero placeholder as of 2026-05-23. Use `@user.orders` `i` field.
179    #[serde(rename = "oi", default, skip_serializing_if = "Option::is_none")]
180    pub order_id: Option<u64>,
181    /// Deprecated: empty placeholder as of 2026-05-23. Use `@user.orders` `m` field.
182    #[serde(rename = "mk", default, skip_serializing_if = "Option::is_none")]
183    pub is_maker: Option<bool>,
184    /// Deprecated: empty placeholder as of 2026-05-23. Use order status `X` or `rs == 0`.
185    #[serde(rename = "ff", default, skip_serializing_if = "Option::is_none")]
186    pub is_full_fill: Option<bool>,
187    /// Deprecated: empty placeholder as of 2026-05-23. Use `@user.orders` fill type `ft`.
188    #[serde(rename = "lq", default, skip_serializing_if = "Option::is_none")]
189    pub is_liquidation: Option<bool>,
190    /// Deprecated: zero placeholder as of 2026-05-23. Use `@user.orders` `n`.
191    #[serde(rename = "fe", default, skip_serializing_if = "Option::is_none")]
192    pub fee: Option<String>,
193    /// Deprecated: zero placeholder as of 2026-05-23.
194    #[serde(rename = "nf", default, skip_serializing_if = "Option::is_none")]
195    pub net_fee: Option<String>,
196    /// Deprecated: empty-string placeholder as of 2026-05-23. Use `@user.orders` `N`.
197    #[serde(rename = "fa", default, skip_serializing_if = "Option::is_none")]
198    pub fee_asset: Option<String>,
199    /// Deprecated: empty placeholder as of 2026-05-23. Use `@user.orders` `co`.
200    #[serde(rename = "co", default, skip_serializing_if = "Option::is_none")]
201    pub client_order_id: Option<ClientOrderId>,
202    /// Deprecated: empty-string placeholder as of 2026-05-23. Use `@user.orders` `S`.
203    #[serde(rename = "sd", default, skip_serializing_if = "Option::is_none")]
204    pub side: Option<String>,
205    /// Deprecated: emitted as zero/empty placeholder. Use `@user.orders` `ft`.
206    #[serde(rename = "ft", default, skip_serializing_if = "Option::is_none")]
207    pub fill_type: Option<String>,
208    /// Deprecated: emitted as zero/empty placeholder. Use `@user.orders` `z`.
209    #[serde(rename = "z", default, skip_serializing_if = "Option::is_none")]
210    pub cumulative_filled_size: Option<String>,
211    /// Deprecated: emitted as zero/empty placeholder. Use `@user.orders` `Z`.
212    #[serde(rename = "Z", default, skip_serializing_if = "Option::is_none")]
213    pub cumulative_filled_cot: Option<String>,
214    /// Deprecated: emitted as zero/empty placeholder. Use `@user.orders` `rs`.
215    #[serde(rename = "rs", default, skip_serializing_if = "Option::is_none")]
216    pub remaining_size: Option<String>,
217}
218
219/// Binance-compatible bookTicker (BBO) message
220#[derive(Serialize, Deserialize, Clone, Debug)]
221pub struct BookTickerMessage {
222    #[serde(rename = "e")]
223    pub event_type: String,
224    #[serde(rename = "u")]
225    pub update_id: u64,
226    #[serde(rename = "E")]
227    pub event_time: u64,
228    #[serde(rename = "T")]
229    pub transaction_time: u64,
230    #[serde(rename = "s")]
231    pub symbol: String,
232    #[serde(rename = "b")]
233    pub best_bid_price: String,
234    #[serde(rename = "B")]
235    pub best_bid_qty: String,
236    #[serde(rename = "a")]
237    pub best_ask_price: String,
238    #[serde(rename = "A")]
239    pub best_ask_qty: String,
240    #[serde(rename = "mt")]
241    pub msg_type: MessageType,
242}
243
244/// Binance-compatible forceOrder message for liquidation trades
245#[derive(Serialize, Deserialize, Clone, Debug)]
246pub struct ForceOrderMessage {
247    #[serde(rename = "e")]
248    pub event_type: String,
249    #[serde(rename = "E")]
250    pub event_time: u64,
251    #[serde(rename = "o")]
252    pub order: ForceOrderDetails,
253}
254
255/// Order details within a forceOrder message
256#[derive(Serialize, Deserialize, Clone, Debug)]
257pub struct ForceOrderDetails {
258    #[serde(rename = "s")]
259    pub symbol: String,
260    #[serde(rename = "S")]
261    pub side: String,
262    #[serde(rename = "o")]
263    pub order_type: String,
264    #[serde(rename = "f")]
265    pub time_in_force: String,
266    #[serde(rename = "q", skip_serializing_if = "Option::is_none")]
267    pub quantity: Option<String>,
268    #[serde(rename = "z", skip_serializing_if = "Option::is_none")]
269    pub filled_qty: Option<String>,
270    #[serde(rename = "p")]
271    pub price: String,
272    #[serde(rename = "ap")]
273    pub avg_price: String,
274    #[serde(rename = "X")]
275    pub status: String,
276    #[serde(rename = "l")]
277    pub last_filled_qty: String,
278    #[serde(rename = "T")]
279    pub trade_time: u64,
280    // DEX-specific fields
281    #[serde(rename = "th")]
282    pub tx_hash: String,
283    #[serde(rename = "ua")]
284    pub user_address: String,
285    #[serde(rename = "oi")]
286    pub order_id: u64,
287    #[serde(rename = "ti")]
288    pub trade_id: u64,
289}
290
291/// Binance-compatible markPrice message
292#[derive(Serialize, Deserialize, Clone, Debug)]
293pub struct MarkPriceMessage {
294    #[serde(rename = "e")]
295    pub event_type: String,
296    #[serde(rename = "E")]
297    pub event_time: u64,
298    #[serde(rename = "s")]
299    pub symbol: String,
300    #[serde(rename = "p")]
301    pub mark_price: String,
302    #[serde(rename = "i")]
303    pub index_price: String,
304    #[serde(rename = "P", skip_serializing_if = "Option::is_none")]
305    pub estimated_settle_price: Option<String>,
306    #[serde(rename = "r")]
307    pub funding_rate: String,
308    #[serde(rename = "T", skip_serializing_if = "Option::is_none")]
309    pub next_funding_time: Option<u64>,
310    #[serde(rename = "th", skip_serializing_if = "Option::is_none")]
311    pub tx_hash: Option<String>,
312}
313
314/// User order update message (Binance ORDER_TRADE_UPDATE style)
315#[derive(Serialize, Deserialize, Clone, Debug)]
316pub struct OrderUpdateMessage {
317    #[serde(rename = "e")]
318    pub event_type: String,
319    #[serde(rename = "E")]
320    pub event_time: u64,
321    #[serde(rename = "o")]
322    pub order: OrderUpdateData,
323}
324
325/// Common fields for all order update events
326#[derive(Serialize, Deserialize, Clone, Debug)]
327pub struct OrderUpdateCommon {
328    #[serde(rename = "s")]
329    pub symbol: String,
330    #[serde(rename = "i")]
331    pub order_id: u64,
332    #[serde(rename = "co", skip_serializing_if = "Option::is_none")]
333    pub client_order_id: Option<ClientOrderId>,
334    #[serde(rename = "X")]
335    pub status: String,
336    #[serde(rename = "x")]
337    pub execution_type: String,
338    #[serde(rename = "T")]
339    pub transaction_time: u64,
340    #[serde(rename = "th")]
341    pub tx_hash: String,
342    /// Deprecated as of 2026-05-23: empty-string placeholder. The address is
343    /// implicit from the authenticated user stream, so the field carries no
344    /// information. Will be dropped from the wire in a future release.
345    #[serde(rename = "ua", default, skip_serializing_if = "Option::is_none")]
346    pub user_address: Option<String>,
347}
348
349/// Order data for NEW order placement
350#[derive(Serialize, Deserialize, Clone, Debug)]
351pub struct PlaceOrderData {
352    #[serde(flatten)]
353    pub common: OrderUpdateCommon,
354    #[serde(rename = "S")]
355    pub side: String,
356    #[serde(rename = "o")]
357    pub order_type: String,
358    #[serde(rename = "f")]
359    pub time_in_force: String,
360    #[serde(rename = "p")]
361    pub price: String,
362    #[serde(rename = "q")]
363    pub quantity: String,
364}
365
366/// Order data for CANCELED orders
367#[derive(Serialize, Deserialize, Clone, Debug)]
368pub struct CancelOrderData {
369    #[serde(flatten)]
370    pub common: OrderUpdateCommon,
371}
372
373/// Order data for TRADE fills.
374///
375/// Fields populated only for orderbook fills (when the rollup emits cumulative
376/// counters) are wrapped in `Option`: `ap`, `ft`, `z`, `Z`, `rs`. Liquidation
377/// fills typically omit these — use `X == "FILLED"` from `OrderUpdateCommon`
378/// as the canonical "fully filled" signal in that case.
379#[derive(Serialize, Deserialize, Clone, Debug)]
380pub struct TradeFillData {
381    #[serde(flatten)]
382    pub common: OrderUpdateCommon,
383    #[serde(rename = "S")]
384    pub side: String,
385    #[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
386    pub price: Option<String>,
387    /// Average fill price across cumulative fills. Added 2026-05-23.
388    #[serde(rename = "ap", default, skip_serializing_if = "Option::is_none")]
389    pub avg_price: Option<String>,
390    #[serde(rename = "q", default, skip_serializing_if = "Option::is_none")]
391    pub quantity: Option<String>,
392    #[serde(rename = "l")]
393    pub last_filled_qty: String,
394    #[serde(rename = "L")]
395    pub last_filled_price: String,
396    #[serde(rename = "n")]
397    pub commission: String,
398    /// Commission asset (e.g. `"USDC"`). Added 2026-05-23.
399    #[serde(rename = "N")]
400    pub commission_asset: String,
401    /// Whether this fill was on the maker side.
402    #[serde(rename = "m")]
403    pub is_maker: bool,
404    /// Sequencer-assigned trade id.
405    #[serde(rename = "t")]
406    pub trade_id: u64,
407    /// Realized PnL for this fill. Added 2026-05-23.
408    #[serde(rename = "rp")]
409    pub realized_pnl: String,
410    /// Fill type: `"orderbook"` (`"o"`) for book fills, `"liquidation"` (`"l"`)
411    /// for liquidation fills. Absent on legacy fills.
412    #[serde(rename = "ft", default, skip_serializing_if = "Option::is_none")]
413    pub fill_type: Option<String>,
414    /// Cumulative filled size across partial fills. Absent when upstream did
415    /// not provide it (e.g. liquidations).
416    #[serde(rename = "z", default, skip_serializing_if = "Option::is_none")]
417    pub cumulative_filled_size: Option<String>,
418    /// Cumulative quote notional filled. Absent when upstream did not provide it.
419    #[serde(rename = "Z", default, skip_serializing_if = "Option::is_none")]
420    pub cumulative_filled_cot: Option<String>,
421    /// Remaining size on the order (`"0"` means fully filled). Absent when
422    /// upstream did not provide it; fall back to `X == "FILLED"` for that case.
423    #[serde(rename = "rs", default, skip_serializing_if = "Option::is_none")]
424    pub remaining_size: Option<String>,
425}
426
427/// Untagged enum - serializes directly as the variant's fields
428#[derive(Serialize, Deserialize, Clone, Debug)]
429#[serde(untagged)]
430pub enum OrderUpdateData {
431    /// Boxed because `TradeFillData` is much larger than the other variants
432    /// (clippy::large_enum_variant); matches trading-api's wrapping.
433    TradeFill(Box<TradeFillData>),
434    PlaceOrder(PlaceOrderData),
435    Cancel(CancelOrderData),
436}
437
438#[cfg(test)]
439mod wire_format_tests {
440    //! Regression tests pinning each server message type to the actual JSON
441    //! shape captured live from `tradingapi.testnet.bullet.xyz` (2026-05-24).
442    //! If trading-api changes its wire format, these break here first.
443
444    use super::*;
445
446    #[test]
447    fn subscribe_ok_round_trips_wire_payload() {
448        let wire = r#"{"e":"subscribe","id":1,"E":1779600272876932,"result":"success"}"#;
449        let parsed: MethodResult = serde_json::from_str(wire).expect("MethodResult deserializes");
450        assert_eq!(parsed.id, Some(RequestId::from(1)));
451        assert_eq!(parsed.event_time, 1779600272876932);
452        assert_eq!(parsed.result, "success");
453    }
454
455    #[test]
456    fn list_subscriptions_round_trips_wire_payload() {
457        let wire = r#"{"e":"list_subscriptions","id":2,"E":1779600273218722,"result":["ETH-USD@aggTrade","BTC-USD@bookTicker"]}"#;
458        let parsed: ListSubscriptionsMessage =
459            serde_json::from_str(wire).expect("ListSubscriptionsMessage deserializes");
460        assert_eq!(parsed.id, Some(RequestId::from(2)));
461        assert_eq!(parsed.event_time, 1779600273218722);
462        assert_eq!(
463            parsed.result,
464            vec!["ETH-USD@aggTrade".to_string(), "BTC-USD@bookTicker".to_string()]
465        );
466    }
467
468    #[test]
469    fn trade_fill_round_trips_wire_payload() {
470        let wire = r#"{"s":"SOL-USD","i":183696108,"X":"FILLED","x":"TRADE","T":1779598581565646,"th":"0x44dd","ua":"","S":"BUY","ap":"85.96","l":"0.1","L":"85.96","n":"0.00275072","N":"USDC","m":false,"t":13196983,"rp":"-0.0156","ft":"o","z":"0.1","Z":"8.596","rs":"0"}"#;
471        let parsed: TradeFillData = serde_json::from_str(wire).expect("TradeFillData deserializes");
472        assert_eq!(parsed.common.symbol, "SOL-USD");
473        assert_eq!(parsed.common.order_id, 183696108);
474        assert_eq!(parsed.common.status, "FILLED");
475        assert_eq!(parsed.common.execution_type, "TRADE");
476        assert_eq!(parsed.common.user_address.as_deref(), Some(""));
477        assert_eq!(parsed.side, "BUY");
478        assert_eq!(parsed.avg_price.as_deref(), Some("85.96"));
479        assert_eq!(parsed.last_filled_qty, "0.1");
480        assert_eq!(parsed.last_filled_price, "85.96");
481        assert_eq!(parsed.commission, "0.00275072");
482        assert_eq!(parsed.commission_asset, "USDC");
483        assert!(!parsed.is_maker);
484        assert_eq!(parsed.trade_id, 13196983);
485        assert_eq!(parsed.realized_pnl, "-0.0156");
486        assert_eq!(parsed.fill_type.as_deref(), Some("o"));
487        assert_eq!(parsed.cumulative_filled_size.as_deref(), Some("0.1"));
488        assert_eq!(parsed.cumulative_filled_cot.as_deref(), Some("8.596"));
489        assert_eq!(parsed.remaining_size.as_deref(), Some("0"));
490    }
491
492    #[test]
493    fn agg_trade_round_trips_wire_payload() {
494        // Captured live from tradingapi.testnet.bullet.xyz @aggTrade.
495        // Per the 2026-05-23 changelog the DEX-specific fields are emitted as
496        // empty/zero placeholders — present on the wire, not omitted. Our
497        // `Option<T>` wrap with `default` correctly deserializes these as
498        // `Some(emptyvalue)`, confirming the placeholders parse without error.
499        let wire = r#"{"e":"aggTrade","E":1779600413218736,"s":"SOL-USD","a":13205413,"p":"85.65","q":"0.01","f":13205413,"l":13205413,"T":1779600413211110,"m":true,"th":"0x94764a","ua":"","oi":0,"mk":false,"ff":false,"lq":false,"fe":"0","nf":"0","fa":"","co":0,"sd":"","ft":"","z":"0","Z":"0","rs":"0"}"#;
500        let parsed: AggTradeMessage =
501            serde_json::from_str(wire).expect("AggTradeMessage deserializes");
502        // Non-deprecated fields populated:
503        assert_eq!(parsed.symbol, "SOL-USD");
504        assert_eq!(parsed.price, "85.65");
505        assert_eq!(parsed.quantity, "0.01");
506        assert!(parsed.is_buyer_maker);
507        assert_eq!(parsed.tx_hash, "0x94764a");
508        // Deprecated placeholders: present-but-meaningless. The point of these
509        // assertions is to verify the empty/zero placeholders deserialize
510        // without error, not that the values are useful.
511        assert_eq!(parsed.user_address.as_deref(), Some(""));
512        assert_eq!(parsed.order_id, Some(0));
513        assert_eq!(parsed.is_maker, Some(false));
514        assert_eq!(parsed.is_full_fill, Some(false));
515        assert_eq!(parsed.fill_type.as_deref(), Some(""));
516        assert_eq!(parsed.cumulative_filled_size.as_deref(), Some("0"));
517        assert_eq!(parsed.remaining_size.as_deref(), Some("0"));
518    }
519
520    #[test]
521    fn trade_fill_in_order_update_data() {
522        let wire = r#"{"s":"SOL-USD","i":1,"X":"FILLED","x":"TRADE","T":1,"th":"0x","S":"BUY","l":"0.1","L":"85","n":"0","N":"USDC","m":false,"t":1,"rp":"0"}"#;
523        let parsed: OrderUpdateData =
524            serde_json::from_str(wire).expect("untagged variant resolves to TradeFill");
525        match parsed {
526            OrderUpdateData::TradeFill(_) => {}
527            other => panic!("expected TradeFill, got {other:?}"),
528        }
529    }
530}