Skip to main content

rustrade_data/exchange/coinbase/
trade.rs

1use super::CoinbaseChannel;
2use crate::{
3    Identifier,
4    event::{MarketEvent, MarketIter},
5    exchange::ExchangeSub,
6    subscription::trade::PublicTrade,
7};
8use chrono::{DateTime, Utc};
9use rustrade_instrument::{Side, exchange::ExchangeId};
10use rustrade_integration::subscription::SubscriptionId;
11use serde::{Deserialize, Serialize};
12use smol_str::format_smolstr;
13
14/// Coinbase real-time trade WebSocket message.
15///
16/// ### Raw Payload Examples
17/// See docs: <https://docs.cloud.coinbase.com/exchange/docs/websocket-channels#match>
18/// ```json
19/// {
20///     "type": "match",
21///     "trade_id": 10,
22///     "sequence": 50,
23///     "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8",
24///     "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1",
25///     "time": "2014-11-07T08:19:27.028459Z",
26///     "product_id": "BTC-USD",
27///     "size": "5.23512",
28///     "price":
29///     "400.23",
30///     "side": "sell"
31/// }
32/// ```
33#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
34pub struct CoinbaseTrade {
35    #[serde(alias = "product_id", deserialize_with = "de_trade_subscription_id")]
36    pub subscription_id: SubscriptionId,
37    #[serde(alias = "trade_id")]
38    pub id: u64,
39    pub time: DateTime<Utc>,
40    #[serde(
41        alias = "size",
42        deserialize_with = "rustrade_integration::serde::de::de_str"
43    )]
44    pub amount: f64,
45    #[serde(deserialize_with = "rustrade_integration::serde::de::de_str")]
46    pub price: f64,
47    pub side: Side,
48}
49
50impl Identifier<Option<SubscriptionId>> for CoinbaseTrade {
51    fn id(&self) -> Option<SubscriptionId> {
52        Some(self.subscription_id.clone())
53    }
54}
55
56impl<InstrumentKey> From<(ExchangeId, InstrumentKey, CoinbaseTrade)>
57    for MarketIter<InstrumentKey, PublicTrade>
58{
59    fn from((exchange_id, instrument, trade): (ExchangeId, InstrumentKey, CoinbaseTrade)) -> Self {
60        Self(vec![Ok(MarketEvent {
61            time_exchange: trade.time,
62            time_received: Utc::now(),
63            exchange: exchange_id,
64            instrument,
65            kind: PublicTrade {
66                id: format_smolstr!("{}", trade.id),
67                price: trade.price,
68                amount: trade.amount,
69                side: trade.side,
70            },
71        })])
72    }
73}
74
75/// Deserialize a [`CoinbaseTrade`] "product_id" (eg/ "BTC-USD") as the associated [`SubscriptionId`]
76/// (eg/ SubscriptionId("matches|BTC-USD").
77pub fn de_trade_subscription_id<'de, D>(deserializer: D) -> Result<SubscriptionId, D::Error>
78where
79    D: serde::de::Deserializer<'de>,
80{
81    <&str as Deserialize>::deserialize(deserializer)
82        .map(|product_id| ExchangeSub::from((CoinbaseChannel::TRADES, product_id)).id())
83}
84
85#[cfg(test)]
86#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
87mod tests {
88    use super::*;
89    use chrono::NaiveDateTime;
90    use rustrade_integration::error::SocketError;
91    use serde::de::Error;
92    use std::str::FromStr;
93
94    #[test]
95    fn test_de_coinbase_trade() {
96        struct TestCase {
97            input: &'static str,
98            expected: Result<CoinbaseTrade, SocketError>,
99        }
100
101        let cases = vec![
102            TestCase {
103                // TC0: invalid Coinbase message w/ unknown tag
104                input: r#"{"type": "unknown", "sequence": 50,"product_id": "BTC-USD"}"#,
105                expected: Err(SocketError::Deserialise {
106                    error: serde_json::Error::custom(""),
107                    payload: "".to_owned(),
108                }),
109            },
110            TestCase {
111                // TC1: valid Spot CoinbaseTrade
112                input: r#"
113                {
114                    "type": "match","trade_id": 10,"sequence": 50,
115                    "maker_order_id": "ac928c66-ca53-498f-9c13-a110027a60e8",
116                    "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1",
117                    "time": "2014-11-07T08:19:27.028459Z",
118                    "product_id": "BTC-USD", "size": "5.23512", "price": "400.23", "side": "sell"
119                }"#,
120                expected: Ok(CoinbaseTrade {
121                    subscription_id: SubscriptionId::from("matches|BTC-USD"),
122                    id: 10,
123                    price: 400.23,
124                    amount: 5.23512,
125                    side: Side::Sell,
126                    time: NaiveDateTime::from_str("2014-11-07T08:19:27.028459")
127                        .unwrap()
128                        .and_utc(),
129                }),
130            },
131        ];
132
133        for (index, test) in cases.into_iter().enumerate() {
134            let actual = serde_json::from_str::<CoinbaseTrade>(test.input);
135            match (actual, test.expected) {
136                (Ok(actual), Ok(expected)) => {
137                    assert_eq!(actual, expected, "TC{} failed", index)
138                }
139                (Err(_), Err(_)) => {
140                    // Test passed
141                }
142                (actual, expected) => {
143                    // Test failed
144                    panic!(
145                        "TC{index} failed because actual != expected. \nActual: {actual:?}\nExpected: {expected:?}\n"
146                    );
147                }
148            }
149        }
150    }
151}